233 lines
8.4 KiB
Go
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,
|
|
}
|
|
}
|