Files
mailgoserver/internal/webui/webmail_calendar.go
T
2026-08-20 19:42:20 +01:00

233 lines
8.4 KiB
Go

package webui
import (
"encoding/json"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"time"
"mailgoserver/internal/db"
)
// calendarDefaultColor is used whenever an event has no color of its own (every event
// created before the color picker existed, or a submitted value that isn't valid
// "#rrggbb") — matches the blue this app's primary buttons/links already use.
const calendarDefaultColor = "#0d6efd"
var calendarHexColorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
// calendarEventColor returns c if it's a valid "#rrggbb" hex color, else the default —
// never trusts a submitted color value directly into storage/ICS output.
func calendarEventColor(c string) string {
if calendarHexColorRe.MatchString(c) {
return c
}
return calendarDefaultColor
}
// webmailCalendarPage renders the standalone Calendar page (own route, real viewport
// width for the month grid — not nested inside the Settings card like
// Contacts/Rules). All rendering of the month grid itself happens client-side against
// webmailCalendarEvents' JSON.
func (a *App) webmailCalendarPage(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
a.render(w, r, "webmail_calendar.html", M{
"mailbox": mbox, "flashes": popFlashes(w, r), "default_color": calendarDefaultColor,
})
}
// calendarEventJSON is one occurrence placed on the month grid — id/uid are shared
// across every occurrence of the same recurring series (there's no per-occurrence
// identity in this pass; editing/deleting always acts on the whole series).
type calendarEventJSON struct {
ID int64 `json:"id"`
UID string `json:"uid"`
Summary string `json:"summary"`
Description string `json:"description"`
Location string `json:"location"`
Start string `json:"start"` // RFC3339
End string `json:"end"` // RFC3339
AllDay bool `json:"all_day"`
Color string `json:"color"`
ReminderMinutes *int `json:"reminder_minutes"`
Recurring bool `json:"recurring"`
}
// webmailCalendarEvents returns every occurrence (recurring events expanded via
// expandOccurrences) falling in [start,end) as JSON — fetched by the page's own JS on
// load and on month navigation, same "fetch JSON, render client-side" shape as
// webmailRecipientSuggest.
func (a *App) webmailCalendarEvents(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
start, err1 := time.Parse("2006-01-02", r.URL.Query().Get("start"))
end, err2 := time.Parse("2006-01-02", r.URL.Query().Get("end"))
if err1 != nil || err2 != nil || !end.After(start) {
http.Error(w, "invalid start/end", http.StatusBadRequest)
return
}
events, err := a.DB.ListEventsInRange(mbox.ID, start, end)
if err != nil {
http.Error(w, "error loading events", http.StatusInternalServerError)
return
}
var out []calendarEventJSON
for _, e := range events {
occurrenceLen := e.EndAt.Sub(e.StartAt)
color := e.Color
if color == "" {
color = calendarDefaultColor
}
for _, occStart := range expandOccurrences(e, start, end) {
out = append(out, calendarEventJSON{
ID: e.ID, UID: e.UID, Summary: e.Summary, Description: e.Description, Location: e.Location,
Start: occStart.Format(time.RFC3339), End: occStart.Add(occurrenceLen).Format(time.RFC3339),
AllDay: e.AllDay, Color: color, ReminderMinutes: e.ReminderMinutes, Recurring: e.RRule != "",
})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Start < out[j].Start })
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
}
// calendarRecurrencePresets maps the modal's "Repeats" select to a stored RRULE value
// — no UNTIL/COUNT UI in this pass, a chosen preset repeats indefinitely.
var calendarRecurrencePresets = map[string]string{
"none": "", "daily": "FREQ=DAILY", "weekly": "FREQ=WEEKLY", "monthly": "FREQ=MONTHLY", "yearly": "FREQ=YEARLY",
}
// webmailCalendarEventSave creates a new event, or updates one when id (a hidden form
// field, not a path segment) is set — mirrors webmailContactSave's shared-endpoint
// shape exactly.
func (a *App) webmailCalendarEventSave(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
summary := strings.TrimSpace(r.FormValue("summary"))
description := strings.TrimSpace(r.FormValue("description"))
location := strings.TrimSpace(r.FormValue("location"))
allDay := r.FormValue("all_day") == "1"
layout := "2006-01-02T15:04"
if allDay {
layout = "2006-01-02"
}
start, errStart := time.Parse(layout, r.FormValue("start"))
end, errEnd := time.Parse(layout, r.FormValue("end"))
if allDay {
// A date-only end is exclusive-of-the-next-day in most calendar UIs' mental
// model ("ends on the 12th" means through the end of the 12th) — bump it so
// EndAt (stored as a moment in time) actually covers that whole day. Done
// before the end-after-start check below: a single-day all-day event has
// start == end as submitted (both "the 15th"), which must validate as fine,
// not get rejected as a zero-length range.
end = end.Add(24 * time.Hour)
}
if summary == "" || errStart != nil || errEnd != nil || !end.After(start) {
setFlash(w, "error", "An event needs a title, and an end date/time on or after its start")
http.Redirect(w, r, MailboxPrefix+"/calendar", http.StatusFound)
return
}
rrule, ok := calendarRecurrencePresets[r.FormValue("recurrence")]
if !ok {
rrule = ""
}
color := calendarEventColor(r.FormValue("color"))
var reminderMinutes *int
if v := r.FormValue("reminder"); v != "" {
if m, err := strconv.Atoi(v); err == nil {
reminderMinutes = &m
}
}
id := int64(atoi(r.FormValue("id")))
var err error
var eventID int64
if id != 0 {
eventID = id
err = a.DB.UpdateEvent(mbox.ID, id, summary, description, location, start, end, allDay, rrule, color)
} else {
eventID, err = a.DB.CreateEvent(mbox.ID, summary, description, location, start, end, allDay, rrule, color)
}
if err != nil {
setFlash(w, "error", "Could not save the event")
http.Redirect(w, r, MailboxPrefix+"/calendar", http.StatusFound)
return
}
if err := a.DB.SetEventReminder(mbox.ID, eventID, reminderMinutes); err != nil {
a.Logger.Error("set event reminder for event %d, mailbox %d: %v", eventID, mbox.ID, err)
}
setFlash(w, "success", "Event saved")
http.Redirect(w, r, MailboxPrefix+"/calendar", http.StatusFound)
}
func (a *App) webmailCalendarEventDelete(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
id := int64(atoi(r.PathValue("id")))
if err := a.DB.DeleteEvent(mbox.ID, id); err != nil {
setFlash(w, "error", "Could not delete the event")
} else {
setFlash(w, "success", "Event deleted")
}
http.Redirect(w, r, MailboxPrefix+"/calendar", http.StatusFound)
}
// webmailCalendarEventGet returns one event's raw (non-expanded) fields as JSON, for
// the edit modal to prefill — the events-in-range list only carries per-occurrence
// display data (§ calendarEventJSON), not the series' own stored start/end/recurrence,
// which the edit form needs verbatim.
func (a *App) webmailCalendarEventGet(w http.ResponseWriter, r *http.Request) {
mbox := mailboxFromContext(r)
id := int64(atoi(r.PathValue("id")))
e, err := a.DB.GetEventByID(mbox.ID, id)
if err != nil || e == nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(eventEditJSON(*e))
}
type eventEditJSONBody struct {
ID int64 `json:"id"`
Summary string `json:"summary"`
Description string `json:"description"`
Location string `json:"location"`
Start string `json:"start"`
End string `json:"end"`
AllDay bool `json:"all_day"`
Recurrence string `json:"recurrence"`
Color string `json:"color"`
ReminderMinutes *int `json:"reminder_minutes"`
}
func eventEditJSON(e db.CalendarEvent) eventEditJSONBody {
layout := "2006-01-02T15:04"
end := e.EndAt
if e.AllDay {
layout = "2006-01-02"
end = end.Add(-24 * time.Hour) // inverse of the +24h applied on save
}
recurrence := "none"
for preset, rrule := range calendarRecurrencePresets {
if rrule != "" && rrule == e.RRule {
recurrence = preset
}
}
color := e.Color
if color == "" {
color = calendarDefaultColor
}
return eventEditJSONBody{
ID: e.ID, Summary: e.Summary, Description: e.Description, Location: e.Location,
Start: e.StartAt.Format(layout), End: end.Format(layout),
AllDay: e.AllDay, Recurrence: recurrence, Color: color, ReminderMinutes: e.ReminderMinutes,
}
}