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
+105 -1
View File
@@ -240,7 +240,16 @@ func (b *Backend) ListCalendarObjects(ctx context.Context, calPath string, req *
}
func (b *Backend) QueryCalendarObjects(ctx context.Context, calPath string, query *caldav.CalendarQuery) ([]caldav.CalendarObject, error) {
// List all and filter sufficient for small collections.
if query == nil {
return b.ListCalendarObjects(ctx, calPath, nil)
}
// hoistPropFilterTimeRanges and closeOpenEndedTimeRanges are both
// idempotent and only mutate a local shallow copy of the query's
// comp-tree — the caller's original CalendarQuery is not visible
// (query is a *CalendarQuery but we only walk the value-comp fields).
hoistPropFilterTimeRanges(&query.CompFilter)
closeOpenEndedTimeRanges(&query.CompFilter)
all, err := b.ListCalendarObjects(ctx, calPath, &query.CompRequest)
if err != nil {
return nil, err
@@ -248,6 +257,101 @@ func (b *Backend) QueryCalendarObjects(ctx context.Context, calPath string, quer
return caldav.Filter(query, all)
}
// hoistPropFilterTimeRanges rewrites a CompFilter in place, moving any
// prop-filter time-ranges (typically <prop-filter name="DTSTART">
// <time-range/></prop-filter>) up to the enclosing comp-filter and clearing
// them from the prop-filter. This is what fixes go-webdav's
// non-recurrence-aware matchPropTimeRange (which only compares literal
// DTSTART — so a weekly series whose base DTSTART is in 2025 gets dropped
// for a 2026 query even though its RRULE has 2026 occurrences).
//
// Lifting the range onto the enclosing comp-filter is the RFC 4791
// 9.9-equivalent for recurring components: the comp-filter's time-range is
// evaluated by go-webdav's matchCompTimeRange, which *does* expand RRULE
// (comp.RecurrenceSet) and returns true if any occurrence falls in the
// window. Non-recurring events are unaffected because the comp-level
// time-range and the prop-level time-range both check against the same
// DTSTART/DTEND.
//
// A small heuristic governs the merge:
//
// - parent has no range yet → parent.Start/End := child's range
// - parent already has a range → parent's range wins (rare / ambiguous
// client request), but the child's time-range is still cleared so
// go-webdav's literal DTSTART check doesn't re-exclude recurring
// series
//
// This runs for every comp-filter in the tree, regardless of depth, so
// both VCALENDAR>VEVENT and any other nesting the client sends are
// handled.
func hoistPropFilterTimeRanges(cf *caldav.CompFilter) {
if cf == nil {
return
}
for i := range cf.Props {
pf := &cf.Props[i]
if pf.Start.IsZero() && pf.End.IsZero() {
continue
}
if cf.Start.IsZero() {
cf.Start = pf.Start
}
if cf.End.IsZero() {
cf.End = pf.End
}
// Clear the prop-filter's own time-range so go-webdav's literal
// DTSTART check (matchPropTimeRange) doesn't re-exclude a series
// whose base DTSTART is outside the window but whose RRULE has
// occurrences in it.
pf.Start = time.Time{}
pf.End = time.Time{}
}
for i := range cf.Comps {
hoistPropFilterTimeRanges(&cf.Comps[i])
}
}
// farFutureSentinel stands in for "no upper bound" in an open-ended
// <C:time-range start="..."/> (RFC 4791 §9.9 explicitly allows a
// time-range with only a start attribute, meaning "everything from start
// onward"). It's a fixed calendar date rather than e.g. time.Now() plus
// some duration so behavior doesn't depend on when a request happens to
// run; 2100 is comfortably beyond any realistic calendar subscription's
// horizon while still bounding recurrence expansion to a finite,
// fast-to-compute range.
var farFutureSentinel = time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)
// closeOpenEndedTimeRanges rewrites a CompFilter in place, replacing a
// zero-value End on any comp-filter that has a non-zero Start with
// farFutureSentinel.
//
// This works around a real bug (as of go-webdav v0.6.0): a client asking
// for "everything from date X onward" sends a time-range with only a
// start attribute, which decodes with End left as the zero time.Time.
// For a *non*-recurring event, go-webdav's matchCompTimeRange correctly
// treats a zero End as "unbounded" (it explicitly checks end.IsZero()).
// But for a *recurring* event, it instead calls
// rrule.Set.Between(start, end, true) unconditionally — and passing the
// zero time.Time (year 1) as the upper bound there means "before start",
// so Between always returns zero occurrences, silently excluding every
// recurring series from an open-ended query. This is exactly the shape
// of query many real CalDAV clients send for their default "sync events
// from N days in the past, no future limit" setting (e.g. DAVx5) — so
// without this workaround, a recurring series survives a bounded
// time-range query (both start and end given) but vanishes the moment a
// client asks for an unbounded future window, which is a common default.
func closeOpenEndedTimeRanges(cf *caldav.CompFilter) {
if cf == nil {
return
}
if !cf.Start.IsZero() && cf.End.IsZero() {
cf.End = farFutureSentinel
}
for i := range cf.Comps {
closeOpenEndedTimeRanges(&cf.Comps[i])
}
}
func (b *Backend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar) error {
p := auth.FromContext(ctx)
if p == nil {