fix(caldav): expand RRULE for recurring events
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -197,3 +197,273 @@ func TestEventIDWorksWithoutUID(t *testing.T) {
|
||||
t.Fatal("EventID should be non-empty even without UID")
|
||||
}
|
||||
}
|
||||
|
||||
// outlookStyleSeries mimics the canonical Outlook/Exchange shape: one UID
|
||||
// carrying a base VEVENT (with RRULE) plus bare per-occurrence instance
|
||||
// VEVENTs (the "fully expanded" ICS model). GroupSeries must fold all of
|
||||
// them into ONE Series (base + 2 instances), not two separate entities.
|
||||
const outlookStyleSeries = "BEGIN:VCALENDAR\r\n" +
|
||||
"VERSION:2.0\r\n" +
|
||||
"PRODID:-//nidus//test//EN\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:series@nidus.test\r\n" +
|
||||
"DTSTAMP:20260101T000000Z\r\n" +
|
||||
"DTSTART:20260306T103000Z\r\n" +
|
||||
"DTEND:20260306T113000Z\r\n" +
|
||||
"RRULE:FREQ=WEEKLY;UNTIL=20260903T083000Z;INTERVAL=3;BYDAY=FR;WKST=SU\r\n" +
|
||||
"SUMMARY:#smarttouch Planning\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:series@nidus.test\r\n" +
|
||||
"DTSTAMP:20260101T000000Z\r\n" +
|
||||
"DTSTART:20260320T103000Z\r\n" +
|
||||
"DTEND:20260320T113000Z\r\n" +
|
||||
"SUMMARY:#smarttouch Planning\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:series@nidus.test\r\n" +
|
||||
"DTSTAMP:20260101T000000Z\r\n" +
|
||||
"DTSTART:20260403T103000Z\r\n" +
|
||||
"DTEND:20260403T123000Z\r\n" +
|
||||
"SUMMARY:#smarttouch Planning (extended)\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
|
||||
func TestGroupSeriesFoldsOutlookSeriesShape(t *testing.T) {
|
||||
cal, err := ical.NewDecoder(strings.NewReader(outlookStyleSeries)).Decode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(cal.Events()); got != 3 {
|
||||
t.Fatalf("fixture must have 3 VEVENTs, got %d", got)
|
||||
}
|
||||
series := GroupSeries(cal)
|
||||
if len(series) != 1 {
|
||||
t.Fatalf("GroupSeries: expected 1 series, got %d", len(series))
|
||||
}
|
||||
s := series[0]
|
||||
if s.Base == nil {
|
||||
t.Fatal("the base (RRULE) VEVENT must be the series Base")
|
||||
}
|
||||
if s.Base.Props.Get(ical.PropUID).Value != "series@nidus.test" {
|
||||
t.Fatalf("wrong base UID: %q", s.Base.Props.Get(ical.PropUID).Value)
|
||||
}
|
||||
if len(s.Instances) != 2 {
|
||||
t.Fatalf("expected 2 explicit instances, got %d", len(s.Instances))
|
||||
}
|
||||
if s.ID() == "" {
|
||||
t.Fatal("series ID must be non-empty")
|
||||
}
|
||||
// Anchor must be the base.
|
||||
if got := s.Anchor().Props.Get(ical.PropUID).Value; got != "series@nidus.test" {
|
||||
t.Fatalf("Anchor must be the base, got UID %q", got)
|
||||
}
|
||||
// Calendar() must contain all three VEVENTs.
|
||||
if got := s.Calendar().Events(); len(got) != 3 {
|
||||
t.Fatalf("Calendar() must contain 3 events, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// tzAwareSeries mimics a real Outlook/Exchange feed (already run through
|
||||
// icalfix.NormalizeTimeZones, hence "Europe/Berlin" rather than the raw
|
||||
// Windows zone name): a VCALENDAR with one VTIMEZONE the events actually
|
||||
// reference, plus a second, unrelated VTIMEZONE no event references.
|
||||
const tzAwareSeries = "BEGIN:VCALENDAR\r\n" +
|
||||
"VERSION:2.0\r\n" +
|
||||
"PRODID:-//nidus//test//EN\r\n" +
|
||||
"BEGIN:VTIMEZONE\r\n" +
|
||||
"TZID:Europe/Berlin\r\n" +
|
||||
"END:VTIMEZONE\r\n" +
|
||||
"BEGIN:VTIMEZONE\r\n" +
|
||||
"TZID:America/New_York\r\n" +
|
||||
"END:VTIMEZONE\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:tz-series@nidus.test\r\n" +
|
||||
"DTSTAMP:20260101T000000Z\r\n" +
|
||||
"DTSTART;TZID=Europe/Berlin:20260910T100000\r\n" +
|
||||
"DTEND;TZID=Europe/Berlin:20260910T110000\r\n" +
|
||||
"RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=WE\r\n" +
|
||||
"SUMMARY:Frontend Weekly\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
|
||||
// TestSeriesCalendarEmbedsReferencedTimezone is the regression test for
|
||||
// the bug report "Frontend Weekly / Abstimmung Next-Release Package /
|
||||
// Prd-Deployment don't show up in the CalDAV client": Series.Calendar()
|
||||
// used to build a VTIMEZONE-less VCALENDAR for every ICS-subscription
|
||||
// series, even though its VEVENTs' DTSTART/DTEND still carried a
|
||||
// TZID=Europe/Berlin parameter — an RFC 5545 §3.6.5 violation that made
|
||||
// strict clients (notably ical4j, which DAVx5 is built on) fail to
|
||||
// resolve the timezone for recurrence-rule expansion, silently dropping
|
||||
// the whole (recurring) VEVENT from sync. The fix must embed exactly the
|
||||
// VTIMEZONE(s) the series' own VEVENTs reference — no more, no less.
|
||||
func TestSeriesCalendarEmbedsReferencedTimezone(t *testing.T) {
|
||||
cal, err := ical.NewDecoder(strings.NewReader(tzAwareSeries)).Decode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
series := GroupSeries(cal)
|
||||
if len(series) != 1 {
|
||||
t.Fatalf("expected 1 series, got %d", len(series))
|
||||
}
|
||||
out := series[0].Calendar()
|
||||
|
||||
var tzids []string
|
||||
for _, child := range out.Children {
|
||||
if child.Name == ical.CompTimezone {
|
||||
tzids = append(tzids, child.Props.Get(ical.PropTimezoneID).Value)
|
||||
}
|
||||
}
|
||||
if len(tzids) != 1 || tzids[0] != "Europe/Berlin" {
|
||||
t.Fatalf("expected exactly the referenced VTIMEZONE (Europe/Berlin) to be embedded, got %v", tzids)
|
||||
}
|
||||
|
||||
// Re-encoding and re-decoding must round-trip: the VEVENT's DTSTART
|
||||
// TZID must resolve to a VTIMEZONE actually present in the same
|
||||
// object (this is what a strict client like ical4j checks).
|
||||
if got := len(out.Events()); got != 1 {
|
||||
t.Fatalf("expected 1 VEVENT in the series calendar, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// twoDistinctSeries are two completely unrelated events (different UIDs).
|
||||
// GroupSeries must NOT fold them together.
|
||||
const twoDistinctSeries = "BEGIN:VCALENDAR\r\n" +
|
||||
"VERSION:2.0\r\n" +
|
||||
"PRODID:-//nidus//test//EN\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:a@nidus.test\r\n" +
|
||||
"DTSTAMP:20260101T000000Z\r\n" +
|
||||
"DTSTART:20260809T090000Z\r\n" +
|
||||
"DTEND:20260809T100000Z\r\n" +
|
||||
"SUMMARY:meeting A\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:b@nidus.test\r\n" +
|
||||
"DTSTAMP:20260101T000000Z\r\n" +
|
||||
"DTSTART:20260809T110000Z\r\n" +
|
||||
"DTEND:20260809T120000Z\r\n" +
|
||||
"SUMMARY:meeting B\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
|
||||
func TestGroupSeriesKeepsDistinctSeries(t *testing.T) {
|
||||
cal, err := ical.NewDecoder(strings.NewReader(twoDistinctSeries)).Decode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(GroupSeries(cal)); got != 2 {
|
||||
t.Fatalf("two distinct UIDs must produce two series, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// seriesOnlyInstances is the shape of a set of one-off events on one UID
|
||||
// with no RRULE. The earliest instance must anchor the series.
|
||||
const seriesOnlyInstances = "BEGIN:VCALENDAR\r\n" +
|
||||
"VERSION:2.0\r\n" +
|
||||
"PRODID:-//nidus//test//EN\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:g@nidus.test\r\n" +
|
||||
"DTSTAMP:20260101T000000Z\r\n" +
|
||||
"DTSTART:20260810T090000Z\r\n" +
|
||||
"DTEND:20260810T100000Z\r\n" +
|
||||
"SUMMARY:later\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:g@nidus.test\r\n" +
|
||||
"DTSTAMP:20260101T000000Z\r\n" +
|
||||
"DTSTART:20260809T090000Z\r\n" +
|
||||
"DTEND:20260809T100000Z\r\n" +
|
||||
"SUMMARY:earlier\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
|
||||
func TestGroupSeriesAnchorWithoutBase(t *testing.T) {
|
||||
cal, err := ical.NewDecoder(strings.NewReader(seriesOnlyInstances)).Decode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
series := GroupSeries(cal)
|
||||
if len(series) != 1 {
|
||||
t.Fatalf("expected 1 series, got %d", len(series))
|
||||
}
|
||||
s := series[0]
|
||||
if s.Base != nil {
|
||||
t.Fatalf("no event carries RRULE, so Base must be nil, got %v", s.Base)
|
||||
}
|
||||
if len(s.Instances) != 2 {
|
||||
t.Fatalf("expected 2 instances, got %d", len(s.Instances))
|
||||
}
|
||||
anchor := s.Anchor()
|
||||
if anchor == nil {
|
||||
t.Fatal("Anchor must be non-nil for a non-empty series")
|
||||
}
|
||||
sp := anchor.Props.Get(ical.PropSummary)
|
||||
if sp == nil || sp.Value != "earlier" {
|
||||
t.Fatalf("Anchor should be the earliest instance, got %v", sp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeriesOccurrencesInReturnsOneDayPerDay verifies the crux of the
|
||||
// web-grid fix: on a day the RRULE base covers AND an explicit instance
|
||||
// covers, OccurrencesIn returns exactly one event for that day (the
|
||||
// instance, which "wins" per the Exchange override model), and does not
|
||||
// double-paint.
|
||||
func TestSeriesOccurrencesInReturnsOneDayPerDay(t *testing.T) {
|
||||
cal, err := ical.NewDecoder(strings.NewReader(outlookStyleSeries)).Decode()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
series := GroupSeries(cal)
|
||||
if len(series) != 1 {
|
||||
t.Fatalf("expected 1 series, got %d", len(series))
|
||||
}
|
||||
s := series[0]
|
||||
|
||||
loc := time.UTC
|
||||
// Window that contains 2026-03-20 (which is both an RRULE date and a
|
||||
// separate explicit instance in the fixture).
|
||||
gs := time.Date(2026, 3, 1, 0, 0, 0, 0, loc)
|
||||
ge := time.Date(2026, 4, 10, 0, 0, 0, 0, loc)
|
||||
|
||||
occ := s.OccurrencesIn(gs, ge, loc)
|
||||
if len(occ) < 3 {
|
||||
t.Fatalf("expected at least 3 days of occurrences, got %d: %v", len(occ), keysOf(occ))
|
||||
}
|
||||
// The explicit instance on 2026-03-20 (title "#smarttouch Planning")
|
||||
// must be the representative event for that day (the base also covers
|
||||
// that day via RRULE — but the instance wins).
|
||||
evOnMar20, ok := occ["2026-03-20"]
|
||||
if !ok {
|
||||
t.Fatalf("2026-03-20 is in the window AND is an explicit instance — must be present, got %v", keysOf(occ))
|
||||
}
|
||||
if p := evOnMar20.Props.Get(ical.PropSummary); p == nil || p.Value != "#smarttouch Planning" {
|
||||
t.Fatalf("instance on 2026-03-20 should win over the base; got %v", p)
|
||||
}
|
||||
// 2026-04-03 has an explicit instance with a different title — should
|
||||
// be that title (not the base title) for that day.
|
||||
evOnApr3, ok := occ["2026-04-03"]
|
||||
if !ok {
|
||||
t.Fatalf("2026-04-03 must be present, got %v", keysOf(occ))
|
||||
}
|
||||
if p := evOnApr3.Props.Get(ical.PropSummary); p == nil || p.Value != "#smarttouch Planning (extended)" {
|
||||
t.Fatalf("instance on 2026-04-03 should have the overridden title; got %v", p)
|
||||
}
|
||||
// 2026-03-06 is the RRULE base's DTSTART and NOT explicitly overridden —
|
||||
// the base should be the representative for that day.
|
||||
evOnBase, ok := occ["2026-03-06"]
|
||||
if !ok {
|
||||
t.Fatalf("2026-03-06 (base DTSTART) must be present, got %v", keysOf(occ))
|
||||
}
|
||||
if p := evOnBase.Props.Get(ical.PropSummary); p == nil || p.Value != "#smarttouch Planning" {
|
||||
t.Fatalf("base should represent 2026-03-06; got %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func keysOf(m map[string]ical.Event) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user