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