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
+237
View File
@@ -220,6 +220,243 @@ func normalizeURL(u string) string {
return u
}
// Series is one addressable calendar entity in a fetched ICS/calendar
// subscription feed: all VEVENTs that share the same UID, or — for feeds
// that omit UIDs — that hash to the same EventID, grouped together.
//
// This matches how Outlook / Exchange publish recurring events ("fully
// expanded" ICS): one <b>base</b> VEVENT carrying the RRULE, plus one bare
// <b>instance</b> VEVENT per explicit occurrence (per-instance edits,
// one-off additions, "Canceled:" overrides) — all under the SAME UID.
// Treating the base and each instance as separate events is what produces
// the two visible bugs, so everything on the subscription path
// (web grid, CalDAV objects, detail page) is keyed off the Series, not off
// individual VEVENTs:
//
// - the web month/week grid paints ONE entry per (series, day), so a day
// covered by both the RRULE base and an explicit instance is not
// rendered twice;
// - the CalDAV backend advertises ONE calendar object per series, whose
// VCALENDAR holds the base + all instances — the shape the source feed
// uses — so clients keep the whole series instead of dropping it.
//
// A series has at least one of Base or a non-empty Instances; Key is
// stable across fetches and is the only thing callers address it by.
type Series struct {
Key string
Base *ical.Event // the VEVENT carrying the RRULE, if any
Instances []ical.Event // the explicit per-occurrence VEVENTs
// tzs holds every VTIMEZONE component from the source feed (shared
// across all series parsed from the same feed). Calendar() embeds
// whichever of these are actually referenced by this series' VEVENTs,
// so the synthetic per-series VCALENDAR stays RFC 5545-compliant. See
// Calendar's doc comment for why this matters.
tzs []*ical.Component
}
// ID returns a stable filesystem-name/URL-path identifier for the series
// (32 hex chars + ".ics"), derived from the series Key. Two different
// series never collide; the same series keeps the same ID across
// refetches of the same feed.
func (s *Series) ID() string {
sum := sha256.Sum256([]byte("series:" + s.Key))
return hex.EncodeToString(sum[:16]) + ".ics"
}
// GroupSeries folds all VEVENTs of cal into Series, one per UID (falling
// back to EventID for feeds without UIDs). Within a series the VEVENT that
// carries an RRULE becomes Base; every other VEVENT is an Instance. The
// returned slice preserves the feed's first-seen order of each series.
func GroupSeries(cal *ical.Calendar) []*Series {
if cal == nil {
return nil
}
// Collect every VTIMEZONE the feed defines, so each series can embed
// whichever ones its own VEVENTs actually reference (see Calendar).
var tzs []*ical.Component
for _, child := range cal.Children {
if child.Name == ical.CompTimezone {
tzs = append(tzs, child)
}
}
byKey := make(map[string]*Series)
var order []string
for _, ev := range cal.Events() {
var key string
if uid := ev.Props.Get(ical.PropUID); uid != nil && uid.Value != "" {
key = "uid=" + uid.Value
} else if id := EventID(ev); id != "" {
key = "eid=" + id
} else {
continue // not addressable
}
s := byKey[key]
if s == nil {
s = &Series{Key: key, tzs: tzs}
byKey[key] = s
order = append(order, key)
}
if ev.Props.Get(ical.PropRecurrenceRule) != nil {
if s.Base == nil {
s.Base = &ev
}
} else {
s.Instances = append(s.Instances, ev)
}
}
out := make([]*Series, 0, len(order))
for _, key := range order {
out = append(out, byKey[key])
}
return out
}
// Anchor returns the VEVENT that best represents the series for display of
// a single occurrence: the recurring Base when present, otherwise the
// earliest Instance. It is nil for an empty series.
func (s *Series) Anchor() *ical.Event {
if s.Base != nil {
return s.Base
}
best := -1
for i := range s.Instances {
if best == -1 || dtStartEarlier(s.Instances[i], s.Instances[best]) {
best = i
}
}
if best == -1 {
return nil
}
return &s.Instances[best]
}
// Calendar returns a fresh, self-contained ical.Calendar holding every
// VEVENT of the series (base first, then instances in feed order), i.e. the
// source feed's own group of events — suitable to encode as one CalDAV
// calendar object and for the subscription detail page.
//
// It also embeds every VTIMEZONE component (copied from the source feed)
// that's actually referenced by one of the series' own VEVENTs (via a
// TZID parameter on DTSTART/DTEND/RECURRENCE-ID/EXDATE/RDATE). Per RFC
// 5545 §3.6.5, a TZID that isn't "UTC" or a bare offset MUST have a
// matching VTIMEZONE definition in the same iCalendar object. Without it,
// strict parsers (notably ical4j, which DAVx5 is built on) can fail to
// resolve the timezone for recurrence-rule expansion and silently drop
// the whole VEVENT — which is exactly what made recurring events
// (e.g. a weekly meeting) vanish from CalDAV clients even though the
// event listing/query logic itself was correct: each series used to be
// encoded as a bare VEVENT-only VCALENDAR with no VTIMEZONE at all.
func (s *Series) Calendar() *ical.Calendar {
out := ical.NewCalendar()
out.Props.SetText(ical.PropVersion, "2.0")
out.Props.SetText(ical.PropProductID, "-//nidus//ics-subscription//EN")
for _, tz := range s.referencedTimezones() {
out.Children = append(out.Children, tz)
}
if s.Base != nil {
out.Children = append(out.Children, s.Base.Component)
}
for i := range s.Instances {
out.Children = append(out.Children, s.Instances[i].Component)
}
return out
}
// referencedTimezones returns the subset of s.tzs whose TZID is
// referenced by any property of s.Base or s.Instances, preserving s.tzs'
// original order and including each matched VTIMEZONE at most once.
func (s *Series) referencedTimezones() []*ical.Component {
if len(s.tzs) == 0 {
return nil
}
needed := make(map[string]bool)
note := func(ev *ical.Event) {
if ev == nil {
return
}
for _, props := range ev.Props {
for _, p := range props {
if tzid := p.Params.Get(ical.PropTimezoneID); tzid != "" {
needed[tzid] = true
}
}
}
}
note(s.Base)
for i := range s.Instances {
note(&s.Instances[i])
}
if len(needed) == 0 {
return nil
}
var out []*ical.Component
for _, tz := range s.tzs {
tzidProp := tz.Props.Get(ical.PropTimezoneID)
if tzidProp == nil || !needed[tzidProp.Value] {
continue
}
out = append(out, tz)
}
return out
}
// OccurrencesIn returns, for every calendar day (formatted with layout
// "2006-01-02", in loc) in [gridStart, gridEnd] that the series occupies,
// the single VEVENT representing the series on that day. The recurring
// base (RRULE expansion) supplies the day's event, but an explicit
// instance on the same day takes precedence (Exchange's "override" model:
// this is how per-instance edits and "Canceled:" entries replace the
// series occurrence for that day). At most one event per day is ever
// returned, so callers can paint exactly one grid cell per day.
func (s *Series) OccurrencesIn(gridStart, gridEnd time.Time, loc *time.Location) map[string]ical.Event {
const layout = "2006-01-02"
out := make(map[string]ical.Event)
note := func(t0 time.Time, ev ical.Event) {
out[t0.In(loc).Format(layout)] = ev
}
if s.Base != nil {
if rset, err := s.Base.RecurrenceSet(loc); err == nil && rset != nil {
for _, occ := range rset.Between(gridStart, gridEnd, true) {
note(occ, *s.Base)
}
}
}
for i := range s.Instances {
dtp := s.Instances[i].Props.Get(ical.PropDateTimeStart)
if dtp == nil {
continue
}
t0, err := dtp.DateTime(loc)
if err != nil || t0.Before(gridStart) || t0.After(gridEnd) {
continue
}
note(t0, s.Instances[i])
}
return out
}
// dtStartEarlier reports whether a's DTSTART value sorts before b's
// (string compare of the raw property value is sufficient for a
// deterministic tie-break used by Anchor).
func dtStartEarlier(a, b ical.Event) bool {
av := a.Props.Get(ical.PropDateTimeStart)
bv := b.Props.Get(ical.PropDateTimeStart)
switch {
case av == nil:
return false
case bv == nil:
return true
}
return av.Value < bv.Value
}
// EventID returns a stable, short identifier for a single ical.Event,
// suitable for use as a filesystem object name or URL path segment. It is
// the first 32 hex chars (128 bits) of