Files
nidus/internal/web/calendar.go
T
arnefandCopilot 1c039e6396 Add week view to web calendar with Month/Week toggle
Extract the shared per-calendar event-collection logic out of
buildMonthView into collectCalendarEvents, keyed by day so it can feed
either the existing 42-cell month grid or a new 7-cell week grid.

Add templates.WeekDay/WeekViewData, a WeekView templ, and a
viewSwitcher component shown in both views' headers to switch between
Month and Week. Add buildWeekView, handleCalendarWeek, parseWeekStart,
and mondayOf in internal/web/calendar.go, and register the new
/calendar/week route.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-21 06:19:59 +02:00

1256 lines
39 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"time"
ical "github.com/emersion/go-ical"
"github.com/yourusername/caldav-server/internal/birthdays"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/icalfix"
"github.com/yourusername/caldav-server/internal/web/templates"
)
// eventIDRe validates an event's object ID as it appears in a URL path
// segment: a filename like "3f9a2b8c1d4e5f60ab12cd34ef56ab78.ics".
var eventIDRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,128}\.ics$`)
// dateLayout/timeLayout are the HTML date/time input formats used
// throughout the event form.
const dateLayout = "2006-01-02"
const timeLayout = "15:04"
// calRefSep separates the owner and calendar name in a shared calendar's
// reference string (e.g. "alice~vacations"), mirroring (but independent
// from) internal/caldav's sharedNameSep convention for its own URL
// namespace.
const calRefSep = "~"
// errCalendarNotFound/errCalendarReadOnly are sentinel errors returned by
// resolveCalRef, translated by callers into 404/403 responses.
var (
errCalendarNotFound = errors.New("calendar not found")
errCalendarReadOnly = errors.New("calendar is read-only")
)
// newEventID generates a random filename for a new event object,
// mirroring the "<id>.ics" convention used by the CalDAV backend
// (internal/caldav).
func newEventID() (string, error) {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf) + ".ics", nil
}
// sharedCalRef builds the reference string used in URLs for a calendar
// shared with the requesting user by owner.
func sharedCalRef(owner, name string) string {
return owner + calRefSep + name
}
// ownsCalendar reports whether cal is one of username's own calendars.
func (s *Server) ownsCalendar(username, cal string) (bool, error) {
if !resourceNameRe.MatchString(cal) {
return false, nil
}
cals, err := s.dbase.ListCalendars(username)
if err != nil {
return false, err
}
for _, c := range cals {
if c.Name == cal {
return true, nil
}
}
return false, nil
}
// resolveCalRef resolves a calendar reference (either a bare name for
// username's own calendar, or "<owner>~<name>" for a calendar shared with
// username) into the concrete (owner, name) pair to use when addressing
// store.Store/db.DB. If requireWrite is true, a read-only share is
// rejected with errCalendarReadOnly.
func (s *Server) resolveCalRef(username, ref string, requireWrite bool) (owner, name string, err error) {
if ref == birthdaysCalRef || strings.HasPrefix(ref, icsRefPrefix) {
// Both the birthdays calendar and ICS subscriptions are
// virtual/computed, not backed by any stored calendar object —
// there's nothing to resolve to.
return "", "", errCalendarNotFound
}
if owner, name, ok := strings.Cut(ref, calRefSep); ok {
if !resourceNameRe.MatchString(owner) || !resourceNameRe.MatchString(name) {
return "", "", errCalendarNotFound
}
share, err := s.dbase.CalendarShareFor(owner, name, username)
if err != nil {
if errors.Is(err, db.ErrShareNotFound) {
return "", "", errCalendarNotFound
}
return "", "", err
}
if requireWrite && share.Permission != db.PermWrite {
return "", "", errCalendarReadOnly
}
return owner, name, nil
}
ok, err := s.ownsCalendar(username, ref)
if err != nil {
return "", "", err
}
if !ok {
return "", "", errCalendarNotFound
}
return username, ref, nil
}
// calendarEntry is one calendar (own or shared) visible to a user in the
// combined month view.
type calendarEntry struct {
Ref string // path-safe reference: "name" or "owner~name"
Owner string // owner's username
Name string // calendar's own name (unqualified)
Color string
Writable bool
Virtual bool // true for computed calendars (e.g. birthdays) with no backing store objects
ICSURL string // set only for ICS-subscription entries (Ref has icsRefPrefix); the remote URL to fetch events from
}
// birthdaysCalRef is the fixed reference for the synthetic "Birthdays"
// calendar. It deliberately can't collide with a real calendar's ref: "@"
// is not in resourceNameRe's character class (so it can't be a bare own
// calendar name) and it contains no calRefSep ("~") (so it can't be
// mistaken for an "owner~name" shared-calendar ref either).
const birthdaysCalRef = "@birthdays"
// icsRefPrefix marks a calendarEntry's Ref as referring to one of
// username's own ICS/webcal subscriptions (see internal/db/ics.go). Like
// "@" for the birthdays calendar, "!" isn't in resourceNameRe's character
// class and can't appear in a calRefSep-joined shared-calendar ref either,
// so "!<name>" can't collide with any other kind of ref.
const icsRefPrefix = "!"
// icsCalRef builds the reference string for one of username's own ICS
// subscriptions named name.
func icsCalRef(name string) string {
return icsRefPrefix + name
}
// defaultBirthdayColor is the display color for the virtual birthdays
// calendar used until the user picks their own from the dashboard (a
// pink, distinct from typical user-picked calendar colors).
const defaultBirthdayColor = "#ec4899"
// birthdayCalendarColor returns username's chosen color for the virtual
// birthdays calendar, falling back to defaultBirthdayColor if unset.
func (s *Server) birthdayCalendarColor(username string) string {
color, err := s.dbase.GetBirthdayCalendarColor(username)
if err != nil || color == "" {
return defaultBirthdayColor
}
return color
}
// listCalendarEntries returns every calendar visible to username: the
// synthetic birthdays calendar, their own calendars (always writable),
// any calendars shared with them (writable only if the share grants write
// permission), and their own read-only ICS/webcal subscriptions.
func (s *Server) listCalendarEntries(username string) ([]calendarEntry, error) {
entries := []calendarEntry{
{Ref: birthdaysCalRef, Owner: username, Name: "Birthdays", Color: s.birthdayCalendarColor(username), Writable: false, Virtual: true},
}
own, err := s.dbase.ListCalendars(username)
if err != nil {
return nil, err
}
for _, c := range own {
entries = append(entries, calendarEntry{
Ref: c.Name, Owner: username, Name: c.Name, Color: c.Color, Writable: true,
})
}
shared, err := s.dbase.CalendarsSharedWith(username)
if err != nil {
return nil, err
}
for _, sh := range shared {
color, err := s.dbase.GetCalendarColor(sh.Owner, sh.CalendarName)
if err != nil {
color = ""
}
entries = append(entries, calendarEntry{
Ref: sharedCalRef(sh.Owner, sh.CalendarName),
Owner: sh.Owner,
Name: sh.CalendarName,
Color: color,
Writable: sh.Permission == db.PermWrite,
})
}
subs, err := s.dbase.ListICSSubscriptions(username)
if err != nil {
return nil, err
}
for _, sub := range subs {
entries = append(entries, calendarEntry{
Ref: icsCalRef(sub.Name), Owner: username, Name: sub.Name, Color: sub.Color,
Writable: false, Virtual: true, ICSURL: sub.URL,
})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Owner != entries[j].Owner {
return entries[i].Owner < entries[j].Owner
}
return entries[i].Name < entries[j].Name
})
return entries, nil
}
func (s *Server) handleCalendarMonth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := userFromContext(r.Context())
year, month := parseYearMonth(r)
data, err := s.buildMonthView(username, year, month)
if err != nil {
s.logger.Error("building month view", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.MonthView(username, data).Render(context.Background(), w)
}
// parseYearMonth reads ?year=&month= query params, defaulting to the
// current month if absent or invalid.
func parseYearMonth(r *http.Request) (year int, month time.Month) {
now := time.Now()
year, month = now.Year(), now.Month()
if y, err := strconv.Atoi(r.URL.Query().Get("year")); err == nil && y >= 1 && y <= 9999 {
year = y
}
if m, err := strconv.Atoi(r.URL.Query().Get("month")); err == nil && m >= 1 && m <= 12 {
month = time.Month(m)
}
return year, month
}
// handleCalendarWeek renders the week view, mirroring
// handleCalendarMonth but for a single 7-day week.
func (s *Server) handleCalendarWeek(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := userFromContext(r.Context())
weekStart := parseWeekStart(r)
data, err := s.buildWeekView(username, weekStart)
if err != nil {
s.logger.Error("building week view", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.WeekView(username, data).Render(context.Background(), w)
}
// parseWeekStart reads ?date=YYYY-MM-DD and returns the Monday of that
// date's week, defaulting to the current week if the parameter is
// absent or invalid.
func parseWeekStart(r *http.Request) time.Time {
if v := r.URL.Query().Get("date"); v != "" {
if d, err := time.ParseInLocation(dateLayout, v, time.Local); err == nil {
return mondayOf(d)
}
}
return mondayOf(time.Now())
}
// buildMonthView loads every event from every calendar visible to
// username (own + shared), then places each occurrence's days onto a
// 6-week grid covering the requested month (plus enough leading/trailing
// days of neighboring months to fill full weeks). Recurring events
// (RRULE) are not expanded — only an event's own DTSTART/DTEND span is
// considered.
func (s *Server) buildMonthView(username string, year int, month time.Month) (templates.MonthViewData, error) {
loc := time.Local
first := time.Date(year, month, 1, 0, 0, 0, 0, loc)
// Monday-first offset: time.Weekday has Sunday=0..Saturday=6.
offset := (int(first.Weekday()) + 6) % 7
gridStart := first.AddDate(0, 0, -offset)
const totalDays = 42 // 6 weeks
today := time.Now().In(loc).Format(dateLayout)
days := make([]templates.MonthDay, totalDays)
for i := 0; i < totalDays; i++ {
d := gridStart.AddDate(0, 0, i)
dateStr := d.Format(dateLayout)
days[i] = templates.MonthDay{
Date: dateStr,
Day: d.Day(),
InMonth: d.Month() == month,
IsToday: dateStr == today,
}
}
gridEnd := gridStart.AddDate(0, 0, totalDays-1)
eventsByDay, calSummaries, hasWritable, err := s.collectCalendarEvents(username, gridStart, gridEnd, loc)
if err != nil {
return templates.MonthViewData{}, err
}
for i := range days {
days[i].Events = sortedDayEvents(eventsByDay[days[i].Date])
}
weeks := make([][]templates.MonthDay, 0, 6)
for i := 0; i < totalDays; i += 7 {
weeks = append(weeks, days[i:i+7])
}
prevMonth := first.AddDate(0, -1, 0)
nextMonth := first.AddDate(0, 1, 0)
monthURL := func(y int, m time.Month) string {
return fmt.Sprintf("/web/calendar?year=%d&month=%d", y, int(m))
}
return templates.MonthViewData{
Calendars: calSummaries,
HasWritable: hasWritable,
MonthLabel: first.Format("January 2006"),
Weeks: weeks,
PrevMonthURL: monthURL(prevMonth.Year(), prevMonth.Month()),
NextMonthURL: monthURL(nextMonth.Year(), nextMonth.Month()),
TodayURL: "/web/calendar",
WeekURL: "/web/calendar/week",
}, nil
}
// buildWeekView loads every event from every calendar visible to
// username (own + shared) that falls within the 7-day week starting on
// weekStart (which must already be a Monday — see mondayOf), placing
// each occurrence's days onto a single 7-column row.
func (s *Server) buildWeekView(username string, weekStart time.Time) (templates.WeekViewData, error) {
loc := time.Local
gridEnd := weekStart.AddDate(0, 0, 6)
today := time.Now().In(loc).Format(dateLayout)
eventsByDay, calSummaries, hasWritable, err := s.collectCalendarEvents(username, weekStart, gridEnd, loc)
if err != nil {
return templates.WeekViewData{}, err
}
days := make([]templates.WeekDay, 7)
for i := 0; i < 7; i++ {
d := weekStart.AddDate(0, 0, i)
dateStr := d.Format(dateLayout)
days[i] = templates.WeekDay{
Date: dateStr,
Weekday: d.Format("Monday"),
Day: d.Day(),
Month: d.Format("Jan"),
IsToday: dateStr == today,
Events: sortedDayEvents(eventsByDay[dateStr]),
}
}
prevWeek := weekStart.AddDate(0, 0, -7)
nextWeek := weekStart.AddDate(0, 0, 7)
weekURL := func(t time.Time) string {
return "/web/calendar/week?date=" + t.Format(dateLayout)
}
rangeLabel := fmt.Sprintf("%s %s", weekStart.Format("Jan 2"), gridEnd.Format("Jan 2, 2006"))
return templates.WeekViewData{
Calendars: calSummaries,
HasWritable: hasWritable,
RangeLabel: rangeLabel,
Days: days,
PrevWeekURL: weekURL(prevWeek),
NextWeekURL: weekURL(nextWeek),
TodayURL: "/web/calendar/week",
MonthURL: "/web/calendar",
}, nil
}
// sortedDayEvents returns evs (which may be nil) sorted with all-day
// events first, then timed events by start time — the stable, readable
// order used by both the month and week grids.
func sortedDayEvents(evs []templates.EventSummary) []templates.EventSummary {
sort.SliceStable(evs, func(a, b int) bool {
if evs[a].AllDay != evs[b].AllDay {
return evs[a].AllDay
}
return evs[a].TimeText < evs[b].TimeText
})
return evs
}
// collectCalendarEvents gathers every event, across every calendar
// visible to username (own, shared, birthdays, ICS subscriptions), whose
// day-span overlaps [gridStart, gridEnd] (inclusive, in loc), keyed by
// day (dateLayout) so callers can place them into either a month or a
// week grid. It also returns the calendar summary list (used for the
// legend/sidebar) and whether any calendar is writable (for "new event"
// links).
func (s *Server) collectCalendarEvents(username string, gridStart, gridEnd time.Time, loc *time.Location) (eventsByDay map[string][]templates.EventSummary, calSummaries []templates.CalendarSummary, hasWritable bool, err error) {
entries, err := s.listCalendarEntries(username)
if err != nil {
return nil, nil, false, err
}
// Cap how many days any single event can add, in case of malformed
// data with a wildly distant DTEND.
totalDays := int(gridEnd.Sub(gridStart).Hours()/24) + 1
dayIndex := make(map[string]int, totalDays)
days := make([]templates.MonthDay, totalDays)
for i := 0; i < totalDays; i++ {
d := gridStart.AddDate(0, 0, i)
dateStr := d.Format(dateLayout)
days[i] = templates.MonthDay{Date: dateStr}
dayIndex[dateStr] = i
}
eventsByDay = make(map[string][]templates.EventSummary)
for _, entry := range entries {
if entry.Writable {
hasWritable = true
}
calSummaries = append(calSummaries, templates.CalendarSummary{
Ref: entry.Ref, Name: entry.Name, Owner: entry.Owner, Color: entry.Color,
Shared: entry.Owner != username, Writable: entry.Writable, Virtual: entry.Virtual,
})
if entry.Ref == birthdaysCalRef {
if err := s.addBirthdayEvents(username, entry, gridStart, gridEnd, dayIndex, days); err != nil {
s.logger.Warn("computing birthday events", "error", err)
}
continue
}
if strings.HasPrefix(entry.Ref, icsRefPrefix) {
if err := s.addICSEvents(entry, gridStart, gridEnd, loc, dayIndex, days); err != nil {
s.logger.Warn("fetching ics subscription events", "calendar", entry.Name, "error", err)
}
continue
}
ids, err := s.store.ListObjects(entry.Owner, "cal-"+entry.Name)
if err != nil {
continue
}
for _, id := range ids {
data, err := s.store.GetObject(entry.Owner, "cal-"+entry.Name, id)
if err != nil {
continue
}
form, err := eventFormFromICS(id, data)
if err != nil {
s.logger.Warn("decoding calendar object", "calendar", entry.Name, "id", id, "error", err)
continue
}
startDay, endDay, err := eventDayRange(form, loc)
if err != nil {
continue
}
if endDay.Before(gridStart) || startDay.After(gridEnd) {
continue
}
if startDay.Before(gridStart) {
startDay = gridStart
}
if endDay.After(gridEnd) {
endDay = gridEnd
}
for d, n := startDay, 0; !d.After(endDay) && n < totalDays; d, n = d.AddDate(0, 0, 1), n+1 {
idx, ok := dayIndex[d.Format(dateLayout)]
if !ok {
continue
}
timeText := ""
if !form.AllDay {
timeText = form.StartTime
}
days[idx].Events = append(days[idx].Events, templates.EventSummary{
ID: id,
CalRef: entry.Ref,
Color: entry.Color,
Summary: form.Summary,
TimeText: timeText,
AllDay: form.AllDay,
})
}
}
}
for _, d := range days {
if len(d.Events) > 0 {
eventsByDay[d.Date] = d.Events
}
}
return eventsByDay, calSummaries, hasWritable, nil
}
// mondayOf returns the Monday (in loc, at midnight) of the week
// containing t.
func mondayOf(t time.Time) time.Time {
// time.Weekday has Sunday=0..Saturday=6; convert to a Monday-first
// offset so Monday itself maps to 0.
offset := (int(t.Weekday()) + 6) % 7
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, -offset)
}
// eventDayRange returns the inclusive [start, end] calendar-day span an
// event occupies, in loc, for placing it on the month grid.
func eventDayRange(form templates.EventFormData, loc *time.Location) (start, end time.Time, err error) {
start, err = time.ParseInLocation(dateLayout, form.StartDate, loc)
if err != nil {
return time.Time{}, time.Time{}, err
}
endDate := form.EndDate
if endDate == "" {
endDate = form.StartDate
}
end, err = time.ParseInLocation(dateLayout, endDate, loc)
if err != nil {
return time.Time{}, time.Time{}, err
}
if end.Before(start) {
end = start
}
return start, end, nil
}
func (s *Server) handleEventNew(w http.ResponseWriter, r *http.Request) {
username := userFromContext(r.Context())
entries, err := s.listCalendarEntries(username)
if err != nil {
s.logger.Error("listing calendars", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
var options []templates.CalendarOption
for _, e := range entries {
if !e.Writable {
continue
}
label := e.Name
if e.Owner != username {
label = e.Name + " (" + e.Owner + ")"
}
options = append(options, templates.CalendarOption{Ref: e.Ref, Label: label})
}
if len(options) == 0 {
http.Error(w, "you don't have any calendar you can add events to", http.StatusConflict)
return
}
switch r.Method {
case http.MethodGet:
selected := r.URL.Query().Get("calendar")
if selected == "" {
selected = options[0].Ref
}
form := newEventFormDefaults(selected, r.URL.Query().Get("date"))
form.Calendars = options
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EventForm(username, form, "").Render(context.Background(), w)
case http.MethodPost:
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
ref := strings.TrimSpace(r.PostForm.Get("calendar"))
owner, name, err := s.resolveCalRef(username, ref, true)
if err != nil {
s.handleCalRefError(w, r, err)
return
}
s.saveEventFromForm(w, r, owner, name, ref, "", options)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// handleCalRefError translates a resolveCalRef error into the
// appropriate HTTP response.
func (s *Server) handleCalRefError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, errCalendarNotFound):
http.NotFound(w, r)
case errors.Is(err, errCalendarReadOnly):
http.Error(w, "this calendar is shared read-only", http.StatusForbidden)
default:
s.logger.Error("resolving calendar reference", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
// newEventFormDefaults builds the initial form values for a brand new
// event. If dateParam is a valid "YYYY-MM-DD" (e.g. from clicking a day
// cell in the month grid), the event defaults to an all-day event on that
// date; otherwise it defaults to a one-hour timed event starting at the
// next full hour today.
func newEventFormDefaults(calRef, dateParam string) templates.EventFormData {
if _, err := time.Parse(dateLayout, dateParam); err == nil {
return templates.EventFormData{CalRef: calRef, AllDay: true, StartDate: dateParam, EndDate: dateParam}
}
now := time.Now()
start := now.Truncate(time.Hour).Add(time.Hour)
end := start.Add(time.Hour)
return templates.EventFormData{
CalRef: calRef,
StartDate: start.Format(dateLayout),
StartTime: start.Format(timeLayout),
EndDate: end.Format(dateLayout),
EndTime: end.Format(timeLayout),
}
}
func (s *Server) handleEventEdit(w http.ResponseWriter, r *http.Request) {
username := userFromContext(r.Context())
ref := r.PathValue("ref")
id := r.PathValue("id")
owner, name, err := s.resolveCalRef(username, ref, false)
if err != nil {
s.handleCalRefError(w, r, err)
return
}
if !eventIDRe.MatchString(id) {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodGet:
data, err := s.store.GetObject(owner, "cal-"+name, id)
if err != nil {
http.NotFound(w, r)
return
}
form, err := eventFormFromICS(id, data)
if err != nil {
s.logger.Error("decoding event", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
form.CalRef = ref
label := name
if owner != username {
label = name + " (" + owner + ")"
}
form.CalendarLabel = label
form.Writable = owner == username
if !form.Writable {
if share, err := s.dbase.CalendarShareFor(owner, name, username); err == nil {
form.Writable = share.Permission == db.PermWrite
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EventForm(username, form, "").Render(context.Background(), w)
case http.MethodPost:
if owner2, _, err := s.resolveCalRef(username, ref, true); err != nil {
s.handleCalRefError(w, r, err)
return
} else {
owner = owner2
}
s.saveEventFromForm(w, r, owner, name, ref, id, nil)
default:
w.Header().Set("Allow", "GET, POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (s *Server) handleEventDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := userFromContext(r.Context())
ref := r.PathValue("ref")
id := r.PathValue("id")
owner, name, err := s.resolveCalRef(username, ref, true)
if err != nil {
s.handleCalRefError(w, r, err)
return
}
if !eventIDRe.MatchString(id) {
http.NotFound(w, r)
return
}
if err := s.store.DeleteObject(owner, "cal-"+name, id); err != nil {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/web/calendar", http.StatusSeeOther)
}
// eventFormInput holds the parsed, not-yet-validated values submitted by
// the event form.
type eventFormInput struct {
Summary string
Description string
Location string
AllDay bool
StartDate string
StartTime string
EndDate string
EndTime string
}
func parseEventForm(r *http.Request) eventFormInput {
return eventFormInput{
Summary: strings.TrimSpace(r.PostForm.Get("summary")),
Description: strings.TrimSpace(r.PostForm.Get("description")),
Location: strings.TrimSpace(r.PostForm.Get("location")),
AllDay: r.PostForm.Get("all_day") != "",
StartDate: strings.TrimSpace(r.PostForm.Get("start_date")),
StartTime: strings.TrimSpace(r.PostForm.Get("start_time")),
EndDate: strings.TrimSpace(r.PostForm.Get("end_date")),
EndTime: strings.TrimSpace(r.PostForm.Get("end_time")),
}
}
// saveEventFromForm handles both event creation (id == "") and editing
// (id preserves the existing event's UID). owner/name identify the
// resolved (and already permission-checked) storage location; ref is the
// calendar reference used for redirect/re-render URLs; options is only
// non-nil when creating a new event (to re-render the calendar <select>
// on a validation error).
func (s *Server) saveEventFromForm(w http.ResponseWriter, r *http.Request, owner, name, ref, id string, options []templates.CalendarOption) {
username := userFromContext(r.Context())
if err := r.ParseForm(); err != nil {
http.Error(w, "invalid form", http.StatusBadRequest)
return
}
in := parseEventForm(r)
reRender := func(errMsg string) {
form := templates.EventFormData{
CalRef: ref, ID: id, Summary: in.Summary, Description: in.Description, Location: in.Location,
AllDay: in.AllDay, StartDate: in.StartDate, StartTime: in.StartTime, EndDate: in.EndDate, EndTime: in.EndTime,
Calendars: options,
}
if id != "" {
label := name
if owner != username {
label = name + " (" + owner + ")"
}
form.CalendarLabel = label
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = templates.EventForm(username, form, errMsg).Render(context.Background(), w)
}
if in.Summary == "" {
reRender("Title is required")
return
}
start, end, err := computeEventTimes(in)
if err != nil {
reRender(err.Error())
return
}
var uid string
if id == "" {
newID, err := newEventID()
if err != nil {
s.logger.Error("generating event id", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
id = newID
uid = strings.TrimSuffix(id, ".ics")
} else {
data, err := s.store.GetObject(owner, "cal-"+name, id)
if err != nil {
http.NotFound(w, r)
return
}
existing, err := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(data))).Decode()
if err != nil {
s.logger.Error("decoding existing event", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
if events := existing.Events(); len(events) > 0 {
if uidProp := events[0].Props.Get(ical.PropUID); uidProp != nil {
uid = uidProp.Value
}
}
if uid == "" {
uid = strings.TrimSuffix(id, ".ics")
}
}
newCal := buildEventCalendar(uid, in, start, end)
if err := s.saveEvent(owner, name, id, newCal); err != nil {
s.logger.Error("saving event", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/web/calendar", http.StatusSeeOther)
}
// computeEventTimes validates and converts the submitted form fields into
// concrete start/end instants (for timed events) or dates (for all-day
// events, where end is returned as the exclusive DTEND day).
func computeEventTimes(in eventFormInput) (start, end time.Time, err error) {
if in.StartDate == "" {
return time.Time{}, time.Time{}, fmt.Errorf("start date is required")
}
endDate := in.EndDate
if endDate == "" {
endDate = in.StartDate
}
if in.AllDay {
start, err = time.ParseInLocation(dateLayout, in.StartDate, time.Local)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid start date")
}
end, err = time.ParseInLocation(dateLayout, endDate, time.Local)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid end date")
}
if end.Before(start) {
return time.Time{}, time.Time{}, fmt.Errorf("end date must not be before start date")
}
// DTEND for all-day events is exclusive: the end date the user
// picks is the last day the event covers, so add one day.
end = end.AddDate(0, 0, 1)
return start, end, nil
}
if in.StartTime == "" || in.EndTime == "" {
return time.Time{}, time.Time{}, fmt.Errorf("start and end time are required for timed events")
}
start, err = time.ParseInLocation(dateLayout+"T"+timeLayout, in.StartDate+"T"+in.StartTime, time.Local)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid start date/time")
}
end, err = time.ParseInLocation(dateLayout+"T"+timeLayout, endDate+"T"+in.EndTime, time.Local)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid end date/time")
}
if !end.After(start) {
return time.Time{}, time.Time{}, fmt.Errorf("end must be after start")
}
return start, end, nil
}
// buildEventCalendar builds a fresh VCALENDAR/VEVENT from the submitted
// form input and resolved start/end instants.
func buildEventCalendar(uid string, in eventFormInput, start, end time.Time) *ical.Calendar {
cal := ical.NewCalendar()
cal.Props.SetText(ical.PropVersion, "2.0")
cal.Props.SetText(ical.PropProductID, "-//nidus//web calendar//EN")
ev := ical.NewEvent()
ev.Props.SetText(ical.PropUID, uid)
ev.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC())
ev.Props.SetText(ical.PropSummary, in.Summary)
if in.Description != "" {
ev.Props.SetText(ical.PropDescription, in.Description)
}
if in.Location != "" {
ev.Props.SetText(ical.PropLocation, in.Location)
}
if in.AllDay {
ev.Props.SetDate(ical.PropDateTimeStart, start)
ev.Props.SetDate(ical.PropDateTimeEnd, end)
} else {
ev.Props.SetDateTime(ical.PropDateTimeStart, start.UTC())
ev.Props.SetDateTime(ical.PropDateTimeEnd, end.UTC())
}
cal.Children = append(cal.Children, ev.Component)
return cal
}
func (s *Server) saveEvent(owner, cal, id string, calendar *ical.Calendar) error {
var buf strings.Builder
enc := ical.NewEncoder(&buf)
if err := enc.Encode(calendar); err != nil {
return fmt.Errorf("encoding ical: %w", err)
}
return s.store.PutObject(owner, "cal-"+cal, id, []byte(buf.String()))
}
// eventFormFromICS decodes a single-VEVENT .ics object into the shared
// EventFormData shape used both for pre-filling the edit form and for
// placing the event on the month grid.
func eventFormFromICS(id string, data []byte) (templates.EventFormData, error) {
calendar, err := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(data))).Decode()
if err != nil {
return templates.EventFormData{}, err
}
events := calendar.Events()
if len(events) == 0 {
return templates.EventFormData{}, fmt.Errorf("no VEVENT in %s", id)
}
return eventFormFromComponent(id, events[0])
}
// eventFormFromComponent extracts an EventFormData from a single decoded
// VEVENT, shared by eventFormFromICS (one event per stored .ics object)
// and the ICS-subscription rendering path (many events per fetched
// calendar, see addICSEvents).
func eventFormFromComponent(id string, ev ical.Event) (templates.EventFormData, error) {
form := templates.EventFormData{ID: id}
if p := ev.Props.Get(ical.PropSummary); p != nil {
form.Summary = p.Value
}
if p := ev.Props.Get(ical.PropDescription); p != nil {
form.Description = p.Value
}
if p := ev.Props.Get(ical.PropLocation); p != nil {
form.Location = p.Value
}
startProp := ev.Props.Get(ical.PropDateTimeStart)
endProp := ev.Props.Get(ical.PropDateTimeEnd)
if startProp == nil {
return templates.EventFormData{}, fmt.Errorf("missing DTSTART in %s", id)
}
allDay := startProp.ValueType() == ical.ValueDate || len(startProp.Value) == 8 // RFC 5545 DATE value, e.g. "20060102"
form.AllDay = allDay
if allDay {
start, err := startProp.DateTime(time.Local)
if err != nil {
return templates.EventFormData{}, err
}
form.StartDate = start.Format(dateLayout)
end := start.AddDate(0, 0, 1)
if endProp != nil {
if e, err := endProp.DateTime(time.Local); err == nil {
end = e
}
}
// Displayed end date is the last inclusive day (DTEND is exclusive).
form.EndDate = end.AddDate(0, 0, -1).Format(dateLayout)
} else {
// go-ical's Prop.DateTime always parses a "Z"-suffixed UTC value
// as UTC regardless of the loc argument passed in, so the
// returned time must be explicitly converted to the local zone
// before formatting for display.
start, err := startProp.DateTime(time.Local)
if err != nil {
return templates.EventFormData{}, err
}
start = start.In(time.Local)
form.StartDate = start.Format(dateLayout)
form.StartTime = start.Format(timeLayout)
end := start.Add(time.Hour)
if endProp != nil {
if e, err := endProp.DateTime(time.Local); err == nil {
end = e.In(time.Local)
}
}
form.EndDate = end.Format(dateLayout)
form.EndTime = end.Format(timeLayout)
}
return form, nil
}
// handleCalendarExportOne exports a single event as a standalone .ics
// file.
func (s *Server) handleEventExportOne(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := userFromContext(r.Context())
ref := r.PathValue("ref")
id := r.PathValue("id")
owner, name, err := s.resolveCalRef(username, ref, false)
if err != nil {
s.handleCalRefError(w, r, err)
return
}
if !eventIDRe.MatchString(id) {
http.NotFound(w, r)
return
}
data, err := s.store.GetObject(owner, "cal-"+name, id)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="`+id+`"`)
_, _ = w.Write(data)
}
// handleCalendarExportAll exports every event in a calendar as one
// concatenated .ics file.
func (s *Server) handleCalendarExportAll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := userFromContext(r.Context())
ref := r.PathValue("ref")
owner, name, err := s.resolveCalRef(username, ref, false)
if err != nil {
s.handleCalRefError(w, r, err)
return
}
ids, err := s.store.ListObjects(owner, "cal-"+name)
if err != nil {
s.logger.Error("listing calendar objects", "calendar", name, "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`.ics"`)
for _, id := range ids {
data, err := s.store.GetObject(owner, "cal-"+name, id)
if err != nil {
continue
}
// Each stored object is already a complete, self-contained
// VCALENDAR document, so concatenating their raw bytes produces a
// file most clients will happily re-import (though it is not a
// single strictly-valid multi-event VCALENDAR document).
_, _ = w.Write(data)
}
}
// handleCalendarImport accepts an uploaded .ics file that may contain one
// or more VCALENDAR blocks, each possibly holding multiple VEVENTs, and
// stores each VEVENT as its own object (mirroring how the CalDAV backend
// stores one event per object).
func (s *Server) handleCalendarImport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username := userFromContext(r.Context())
ref := r.PathValue("ref")
owner, name, err := s.resolveCalRef(username, ref, true)
if err != nil {
s.handleCalRefError(w, r, err)
return
}
if err := r.ParseMultipartForm(maxUploadMemory); err != nil {
http.Error(w, "invalid upload", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "no file provided", http.StatusBadRequest)
return
}
defer file.Close()
body, err := io.ReadAll(file)
if err != nil {
s.logger.Error("reading import file", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
imported := 0
dec := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(body)))
for {
srcCal, err := dec.Decode()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
s.logger.Warn("stopping ics import on parse error", "calendar", name, "error", err)
break
}
version := "2.0"
if p := srcCal.Props.Get(ical.PropVersion); p != nil && p.Value != "" {
version = p.Value
}
prodID := "-//nidus//web calendar//EN"
if p := srcCal.Props.Get(ical.PropProductID); p != nil && p.Value != "" {
prodID = p.Value
}
var timezones []*ical.Component
for _, child := range srcCal.Children {
if child.Name == ical.CompTimezone {
timezones = append(timezones, child)
}
}
for _, ev := range srcCal.Events() {
id, err := newEventID()
if err != nil {
s.logger.Error("generating event id", "error", err)
continue
}
out := ical.NewCalendar()
out.Props.SetText(ical.PropVersion, version)
out.Props.SetText(ical.PropProductID, prodID)
out.Children = append(out.Children, timezones...)
out.Children = append(out.Children, ev.Component)
if err := s.saveEvent(owner, name, id, out); err != nil {
s.logger.Warn("saving imported event", "error", err)
continue
}
imported++
}
}
http.Redirect(w, r, "/web/calendar?imported="+strconv.Itoa(imported), http.StatusSeeOther)
}
// addBirthdayEvents places one virtual all-day event per contact
// birthday falling within [gridStart, gridEnd] into days, titled
// "🎂 Name" (or "🎂 Name (Age)" when the birth year is known). Each event
// links to the contact's edit page instead of an event edit page, since
// there is no underlying calendar object to edit.
func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridStart, gridEnd time.Time, dayIndex map[string]int, days []templates.MonthDay) error {
contacts, err := birthdays.Collect(s.store, s.dbase, username)
if err != nil {
return err
}
if len(contacts) == 0 {
return nil
}
// The 42-day grid can span at most two calendar years (e.g. a
// December/January boundary), so only those years' occurrences need
// checking.
years := []int{gridStart.Year()}
if gridEnd.Year() != gridStart.Year() {
years = append(years, gridEnd.Year())
}
for _, c := range contacts {
for _, year := range years {
occurrence, ok := birthdays.OccurrenceDate(c, year, gridStart.Location())
if !ok {
continue
}
if occurrence.Before(gridStart) || occurrence.After(gridEnd) {
continue
}
idx, ok := dayIndex[occurrence.Format(dateLayout)]
if !ok {
continue
}
days[idx].Events = append(days[idx].Events, templates.EventSummary{
ID: c.Book + "/" + c.ID,
CalRef: entry.Ref,
Color: entry.Color,
Summary: birthdays.Summary(c, year),
AllDay: true,
LinkURL: "/web/contacts/" + c.Book + "/" + c.ID + "/edit",
})
}
}
return nil
}
// addICSEvents fetches entry's remote ICS/webcal calendar (via s.icsCache,
// which caches it for a while so every month-view render doesn't re-fetch
// from origin) and places each VEVENT's occurrence onto the month grid,
// the same way a stored calendar object would be. There's no per-event
// edit page for these (the source is external and read-only), so each
// event's LinkURL is left pointing nowhere useful ("#").
func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time, loc *time.Location, dayIndex map[string]int, days []templates.MonthDay) error {
cal, err := s.icsCache.Get(entry.ICSURL)
if err != nil {
return err
}
const totalDays = 42
for i, ev := range cal.Events() {
id := fmt.Sprintf("ics-%d", i)
form, err := eventFormFromComponent(id, ev)
if err != nil {
continue
}
startDay, endDay, err := eventDayRange(form, loc)
if err != nil {
continue
}
if endDay.Before(gridStart) || startDay.After(gridEnd) {
continue
}
if startDay.Before(gridStart) {
startDay = gridStart
}
if endDay.After(gridEnd) {
endDay = gridEnd
}
for d, n := startDay, 0; !d.After(endDay) && n < totalDays; d, n = d.AddDate(0, 0, 1), n+1 {
idx, ok := dayIndex[d.Format(dateLayout)]
if !ok {
continue
}
timeText := ""
if !form.AllDay {
timeText = form.StartTime
}
days[idx].Events = append(days[idx].Events, templates.EventSummary{
ID: id,
CalRef: entry.Ref,
Color: entry.Color,
Summary: form.Summary,
TimeText: timeText,
AllDay: form.AllDay,
LinkURL: "#",
})
}
}
return nil
}