fix(caldav): expand RRULE for recurring events
This commit is contained in:
+113
-64
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user