fix(caldav): expand RRULE for recurring events

This commit is contained in:
2026-09-06 15:49:09 +02:00
parent f35121b3dc
commit 8e946964ec
11 changed files with 1223 additions and 126 deletions
+113 -64
View File
@@ -296,9 +296,9 @@ func parseWeekStart(r *http.Request) time.Time {
// 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.
// days of neighboring months to fill full weeks). Recurring events (RRULE)
// are expanded: every occurrence falling in the grid's window is placed
// (see eventOccurrenceDays), not just the event's base DTSTART/DTEND.
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)
@@ -473,33 +473,24 @@ func (s *Server) collectCalendarEvents(username string, gridStart, gridEnd time.
if err != nil {
continue
}
form, err := eventFormFromICS(id, data)
ev, err := firstEventFromICS(data)
if err != nil {
s.logger.Warn("decoding calendar object", "calendar", entry.Name, "id", id, "error", err)
continue
}
startDay, endDay, err := eventDayRange(form, loc)
daysSet := eventOccurrenceDays(ev, gridStart, gridEnd, loc, dayIndex)
if len(daysSet) == 0 {
continue
}
form, err := eventFormFromComponent(id, ev)
if err != nil {
continue
}
if endDay.Before(gridStart) || startDay.After(gridEnd) {
continue
timeText := ""
if !form.AllDay {
timeText = form.StartTime
}
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
}
for idx := range daysSet {
days[idx].Events = append(days[idx].Events, templates.EventSummary{
ID: id,
CalRef: entry.Ref,
@@ -529,25 +520,76 @@ func mondayOf(t time.Time) time.Time {
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)
// eventOccurrenceDays returns the set of grid day indices (into
// dayIndex) that event ev occupies within [gridStart, gridEnd]. A plain
// event occupies its DTSTART..DTEND day span. A recurring event (RRULE)
// occupies the day span of each occurrence whose expansion falls in the
// window, so e.g. a weekly meeting is painted on every in-grid weekly date
// rather than only its original date. EXDATE/RDATE are honored via
// go-ical's RecurrenceSet. Only indices for days actually in the grid are
// returned; occurrences entirely outside the window paint nothing.
func eventOccurrenceDays(ev ical.Event, gridStart, gridEnd time.Time, loc *time.Location, dayIndex map[string]int) map[int]bool {
out := make(map[int]bool)
addDay := func(day time.Time) {
day = day.In(loc)
if idx, ok := dayIndex[day.Format(dateLayout)]; ok {
out[idx] = true
}
}
startProp := ev.Props.Get(ical.PropDateTimeStart)
if startProp == nil {
return nil
}
baseStart, err := startProp.DateTime(loc)
if err != nil {
return time.Time{}, time.Time{}, err
return nil
}
endDate := form.EndDate
if endDate == "" {
endDate = form.StartDate
// Per-occurrence duration: the DTSTART..DTEND span of the base event
// (all-day events with only DTEND=DTSTART+1 give a 1-day span; timed
// events give their hour span). Recurrence preserves the duration.
dur := time.Duration(0)
if endProp := ev.Props.Get(ical.PropDateTimeEnd); endProp != nil {
if baseEnd, err := endProp.DateTime(loc); err == nil {
dur = baseEnd.Sub(baseStart)
}
}
end, err = time.ParseInLocation(dateLayout, endDate, loc)
spread := func(start time.Time) {
end := start.Add(dur)
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
addDay(d)
if len(out) == len(dayIndex) {
return
}
}
}
if rset, rerr := ev.RecurrenceSet(loc); rerr == nil && rset != nil {
for _, occ := range rset.Between(gridStart, gridEnd, true) {
spread(occ)
}
} else {
spread(baseStart)
}
return out
}
// firstEventFromICS decodes data (a single-VEVENT calendar object) and
// returns its first VEVENT, for grid placement (which needs the raw
// ical.Event so recurring events can be expanded, see
// eventOccurrenceDays).
func firstEventFromICS(data []byte) (ical.Event, error) {
calendar, err := ical.NewDecoder(bytes.NewReader(icalfix.NormalizeTimeZones(data))).Decode()
if err != nil {
return time.Time{}, time.Time{}, err
return ical.Event{}, err
}
if end.Before(start) {
end = start
events := calendar.Events()
if len(events) == 0 {
return ical.Event{}, fmt.Errorf("no VEVENT in calendar object")
}
return start, end, nil
return events[0], nil
}
func (s *Server) handleEventNew(w http.ResponseWriter, r *http.Request) {
@@ -712,8 +754,9 @@ func (s *Server) eventForDisplay(username, ref, id string) (color string, form t
}
// icsEventForDisplay resolves one of username's ICS subscriptions named
// ref[len(!):] and looks up the event whose icssub.EventID hashes to id.
// Returns the subscription's display color and a form with Writable=false.
// ref[len(!):] and looks up the Series whose icssub ID matches id, then
// returns the subscription's display color and a form (Writable=false)
// summarizing the series' anchor event (base, or earliest instance).
func (s *Server) icsEventForDisplay(username, ref, id string) (color string, form templates.EventFormData, err error) {
name := strings.TrimPrefix(ref, icsRefPrefix)
sub, err := s.dbase.GetICSSubscription(username, name)
@@ -724,11 +767,15 @@ func (s *Server) icsEventForDisplay(username, ref, id string) (color string, for
if err != nil {
return "", form, err
}
for _, ev := range cal.Events() {
if icssub.EventID(ev) != id {
for _, series := range icssub.GroupSeries(cal) {
if series.Base == nil && len(series.Instances) == 0 {
continue
}
form, err = eventFormFromComponent(id, ev)
if series.ID() != id {
continue
}
anchor := series.Anchor()
form, err = eventFormFromComponent(id, *anchor)
if err != nil {
return "", form, err
}
@@ -1304,44 +1351,46 @@ func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridSta
// addICSEvents fetches entry's remote ICS/webcal calendar (via s.icsCache,
// which uses stale-while-revalidate so a month-view render never blocks on
// network I/O) and places each VEVENT's occurrence onto the month grid,
// the same way a stored calendar object would be. Each event's ID is the
// stable icssub.EventID hash, which routes through the read-only detail
// page (eventForDisplay → icsEventForDisplay).
// network I/O) and places each Series' day-occurrences onto the grid.
//
// A "Series" is one UID group in the feed: the base (RRULE-carrying)
// VEVENT plus any explicit per-occurrence instances. OccurrencesIn
// collapses them to one event per (series, day), so a day the base RRULE
// covers AND an explicit instance covers is painted once, with the
// instance winning (Exchange's "override" model — this is how "Canceled:"
// entries replace the base occurrence for that day).
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 _, ev := range cal.Events() {
id := icssub.EventID(ev)
if id == "" {
for _, series := range icssub.GroupSeries(cal) {
if series.Base == nil && len(series.Instances) == 0 {
continue
}
form, err := eventFormFromComponent(id, ev)
id := series.ID()
occByDay := series.OccurrencesIn(gridStart, gridEnd, loc)
if len(occByDay) == 0 {
continue
}
anchor := series.Anchor()
baseForm, err := eventFormFromComponent(id, *anchor)
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)]
for dayStr, ev := range occByDay {
idx, ok := dayIndex[dayStr]
if !ok {
continue
}
// Per-day data: the day's own summary (an explicit instance
// may override the base's — "Canceled: X" or a per-instance
// title change). Fall back to the base summary otherwise.
form := baseForm
if ps := ev.Props.Get(ical.PropSummary); ps != nil && ps.Value != "" {
form.Summary = ps.Value
}
timeText := ""
if !form.AllDay {
timeText = form.StartTime
+99 -3
View File
@@ -74,9 +74,9 @@ func TestICSDetailRouteRendersReadonlyEvent(t *testing.T) {
handler := s.Handler(emptyStaticFS{})
cookie := loginAs(t, handler, "alice", "password")
id := icssub.EventID(mustParseICS(t, icsDetailSample))
id := seriesID(t, icsDetailSample)
if id == "" {
t.Fatal("EventID must be non-empty")
t.Fatal("series ID must be non-empty")
}
req := httptest.NewRequest(http.MethodGet, "/calendar/"+url.PathEscape("!holidays")+"/"+id, nil)
@@ -147,7 +147,7 @@ func TestMonthGridLinksICSEventToDetailPage(t *testing.T) {
if !strings.Contains(body, "ICS detail event") {
t.Fatalf("month grid should list ICS events, got:\n%s", body)
}
id := icssub.EventID(mustParseICS(t, icsDetailSample))
id := seriesID(t, icsDetailSample)
// The grid renders the detail link with a "/web/calendar/!<ref>/<id>"
// shape (see eventLinkURL in calendar.templ). The "!" in the ref is
// not %-escaped by templ.URL — we observed un-escaped output in the
@@ -161,6 +161,102 @@ func TestMonthGridLinksICSEventToDetailPage(t *testing.T) {
}
}
// TestEventOccurrenceDaysExpandsRRule verifies that a recurring event
// (RRULE) is placed on every in-window occurrence day, not just its base
// date — the core of the "recurring series events missing" fix.
func TestEventOccurrenceDaysExpandsRRule(t *testing.T) {
const raw = "BEGIN:VCALENDAR\r\n" +
"VERSION:2.0\r\n" +
"PRODID:-//nidus//test//EN\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:rec1@nidus.test\r\n" +
"DTSTAMP:20260101T000000Z\r\n" +
"DTSTART:20260803T090000Z\r\n" + // a Monday
"DTEND:20260803T100000Z\r\n" +
"RRULE:FREQ=WEEKLY;COUNT=4\r\n" +
"SUMMARY:weekly meeting\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
ev := mustParseICS(t, raw)
loc := time.UTC
// Window covering the whole of August 2026 (the 4 weekly occurrences:
// Aug 3, 10, 17, 24 are all within range).
gridStart := time.Date(2026, 8, 1, 0, 0, 0, 0, loc)
gridEnd := time.Date(2026, 8, 31, 0, 0, 0, 0, loc)
dayIndex := make(map[string]int)
for d, i := gridStart, 0; !d.After(gridEnd); d, i = d.AddDate(0, 0, 1), i+1 {
dayIndex[d.Format(dateLayout)] = i
}
got := eventOccurrenceDays(ev, gridStart, gridEnd, loc, dayIndex)
wantDates := []string{"2026-08-03", "2026-08-10", "2026-08-17", "2026-08-24"}
for _, ws := range wantDates {
if _, ok := got[dayIndex[ws]]; !ok {
t.Errorf("expected recurring occurrence on %s, got days %v", ws, got)
}
}
if len(got) != 4 {
t.Errorf("expected exactly 4 in-window occurrence days, got %d (%v)", len(got), got)
}
}
// TestEventOccurrenceDaysSingleEvent verifies a non-recurring event occupies
// only its own day(s) (no spurious expansion).
func TestEventOccurrenceDaysSingleEvent(t *testing.T) {
const raw = "BEGIN:VCALENDAR\r\n" +
"VERSION:2.0\r\n" +
"PRODID:-//nidus//test//EN\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:single1@nidus.test\r\n" +
"DTSTAMP:20260101T000000Z\r\n" +
"DTSTART:20260805T090000Z\r\n" +
"DTEND:20260805T100000Z\r\n" +
"SUMMARY:one off\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
ev := mustParseICS(t, raw)
loc := time.UTC
gridStart := time.Date(2026, 8, 1, 0, 0, 0, 0, loc)
gridEnd := time.Date(2026, 8, 31, 0, 0, 0, 0, loc)
dayIndex := make(map[string]int)
for d, i := gridStart, 0; !d.After(gridEnd); d, i = d.AddDate(0, 0, 1), i+1 {
dayIndex[d.Format(dateLayout)] = i
}
got := eventOccurrenceDays(ev, gridStart, gridEnd, loc, dayIndex)
if len(got) != 1 {
t.Fatalf("expected exactly 1 occurrence day, got %d (%v)", len(got), got)
}
if _, ok := got[dayIndex["2026-08-05"]]; !ok {
t.Errorf("expected event on 2026-08-05, got days %v", got)
}
}
// seriesID returns the stable ID that addICSEvents/icsEventForDisplay
// advertise for the (single) series in raw — GroupSeries(raw)[0].ID().
// ICS-subscription grid cells and detail links are keyed by the series,
// not by individual VEVENTs, so this is what the href/ID in assertions
// must match.
func seriesID(t *testing.T, raw string) string {
t.Helper()
_ = t
cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode()
if err != nil {
t.Fatalf("seriesID: ical decode: %v", err)
}
series := icssub.GroupSeries(cal)
if len(series) == 0 {
t.Fatal("seriesID: no series")
}
return series[0].ID()
}
// mustParseICS parses raw and returns its first VEVENT (panic on error).
func mustParseICS(t *testing.T, raw string) ical.Event {
t.Helper()