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 {
|
||||
|
||||
@@ -10,12 +10,15 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.arnef.de/arnef/nidus/internal/auth"
|
||||
"git.arnef.de/arnef/nidus/internal/config"
|
||||
"git.arnef.de/arnef/nidus/internal/db"
|
||||
"git.arnef.de/arnef/nidus/internal/icssub"
|
||||
"git.arnef.de/arnef/nidus/internal/store"
|
||||
ical "github.com/emersion/go-ical"
|
||||
"github.com/emersion/go-webdav/caldav"
|
||||
)
|
||||
|
||||
func newTestBackend(t *testing.T) (*Backend, *db.DB) {
|
||||
@@ -331,3 +334,297 @@ func TestSharedCalendarNameCollidingWithOwnGetsDisambiguated(t *testing.T) {
|
||||
t.Fatalf("GetCalendar: expected display name %q, got %q", want, cal.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHoistPropFilterTimeRangesUnit pins the shape of the hoist rewrite:
|
||||
// a prop-filter time-range (DTSTART window) is lifted onto its enclosing
|
||||
// VEVENT comp-filter, and the prop-filter's own Start/End are cleared,
|
||||
// so go-webdav's recurrence-aware comp-level check runs instead of its
|
||||
// literal-DTSTART prop check.
|
||||
func TestHoistPropFilterTimeRangesUnit(t *testing.T) {
|
||||
loc := time.UTC
|
||||
gs := time.Date(2026, 9, 1, 0, 0, 0, 0, loc)
|
||||
ge := time.Date(2026, 9, 30, 0, 0, 0, 0, loc)
|
||||
|
||||
cf := caldav.CompFilter{
|
||||
Name: "VCALENDAR",
|
||||
Comps: []caldav.CompFilter{{
|
||||
Name: "VEVENT",
|
||||
Props: []caldav.PropFilter{{Name: "DTSTART", Start: gs, End: ge}},
|
||||
}},
|
||||
}
|
||||
|
||||
hoistPropFilterTimeRanges(&cf)
|
||||
|
||||
vevent := cf.Comps[0]
|
||||
if vevent.Start != gs || vevent.End != ge {
|
||||
t.Fatalf("VEVENT comp should inherit the time-range, got Start=%v End=%v", vevent.Start, vevent.End)
|
||||
}
|
||||
if !vevent.Props[0].Start.IsZero() || !vevent.Props[0].End.IsZero() {
|
||||
t.Fatalf("prop-filter time-range must be cleared, got Start=%v End=%v", vevent.Props[0].Start, vevent.Props[0].End)
|
||||
}
|
||||
// The VEVENT name is untouched — hoist must not clobber component names.
|
||||
if vevent.Name != "VEVENT" {
|
||||
t.Fatalf("VEVENT comp name must be preserved, got %q", vevent.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// rruleSample is a weekly series whose base DTSTART sits in the past
|
||||
// (2025) relative to the queried 2026 window — the classic shape that
|
||||
// go-webdav's literal-DTSTART prop-time-range check drops, but that a
|
||||
// proper RRULE expansion keeps.
|
||||
const rruleSample = "BEGIN:VCALENDAR\r\n" +
|
||||
"VERSION:2.0\r\n" +
|
||||
"PRODID:-//nidus//test//EN\r\n" +
|
||||
"BEGIN:VEVENT\r\n" +
|
||||
"UID:weekly@nidus.test\r\n" +
|
||||
"DTSTAMP:20260101T000000Z\r\n" +
|
||||
"DTSTART:20250911T100000Z\r\n" + // 2025 base, far before the 2026-09 window
|
||||
"DTEND:20250911T103000Z\r\n" +
|
||||
"RRULE:FREQ=WEEKLY;UNTIL=20270826T080000Z;INTERVAL=3;BYDAY=TH;WKST=SU\r\n" +
|
||||
"SUMMARY:Frontend Weekly\r\n" +
|
||||
"END:VEVENT\r\n" +
|
||||
"END:VCALENDAR\r\n"
|
||||
|
||||
// TestQueryCalendarObjectsPropFilterTimeRangeRecurring is the regression
|
||||
// test for the "CalDAV still misses Frontend Weekly / Abstimmung /
|
||||
// Prd-Deployment" report. DAVx5 and Thunderbird issue calendar-query
|
||||
// filters as <prop-filter name="DTSTART"><time-range/></prop-filter>
|
||||
// *inside* a VEVENT comp-filter. go-webdav's matchPropTimeRange only
|
||||
// compares the literal DTSTART (2025), so without the hoist the 2026-09
|
||||
// query returns an empty result for a weekly series whose base date is
|
||||
// in 2025. The hoist lifts the time-range onto the comp-filter, where
|
||||
// go-webdav's recurrence-aware matchCompTimeRange (RecurrenceSet) keeps
|
||||
// the series.
|
||||
func TestQueryCalendarObjectsPropFilterTimeRangeRecurring(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(rruleSample))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
st, err := store.NewStore(filepath.Join(dir, "data"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
dbase, err := db.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open db: %v", err)
|
||||
}
|
||||
defer dbase.Close()
|
||||
if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
if err := dbase.CreateICSSubscription("alice", "holidays", srv.URL, ""); err != nil {
|
||||
t.Fatalf("CreateICSSubscription: %v", err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
b := NewBackend(&config.Config{}, st, dbase, logger, icssub.NewCache(time.Hour))
|
||||
ctx := auth.NewContext(context.Background(), &auth.Principal{Username: "alice"})
|
||||
|
||||
loc := time.UTC
|
||||
gs := time.Date(2026, 9, 1, 0, 0, 0, 0, loc)
|
||||
ge := time.Date(2026, 9, 30, 0, 0, 0, 0, loc)
|
||||
|
||||
// The DAVx5/Thunderbird form: a prop-filter time-range on DTSTART
|
||||
// nested inside a VEVENT comp-filter under VCALENDAR.
|
||||
q := &caldav.CalendarQuery{
|
||||
CompFilter: caldav.CompFilter{
|
||||
Name: "VCALENDAR",
|
||||
Comps: []caldav.CompFilter{{
|
||||
Name: "VEVENT",
|
||||
Props: []caldav.PropFilter{{Name: "DTSTART", Start: gs, End: ge}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
objs, err := b.QueryCalendarObjects(ctx, "/cal/home/holidays/", q)
|
||||
if err != nil {
|
||||
t.Fatalf("QueryCalendarObjects: %v", err)
|
||||
}
|
||||
if len(objs) == 0 {
|
||||
t.Fatal("QueryCalendarObjects with a DAVx5-shape prop-filter time-range " +
|
||||
"returned 0 objects for a weekly series whose RRULE has 2026-09 " +
|
||||
"occurrences, but whose base DTSTART is 2025 — this is the " +
|
||||
"regression the hoist fix exists to prevent")
|
||||
}
|
||||
found := false
|
||||
for _, co := range objs {
|
||||
for _, ev := range co.Data.Events() {
|
||||
if p := ev.Props.Get(ical.PropSummary); p != nil && p.Value == "Frontend Weekly" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected 'Frontend Weekly' in the query result")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportPropFilterTimeRangeRecurringE2E drives the *full* HTTP stack a
|
||||
// real CalDAV client uses: NewHandler → go-webdav caldav.Handler → XML decode
|
||||
// of the <calendar-query>/<prop-filter>/<time-range> body →
|
||||
// QueryCalendarObjects (hoist) → caldav.Filter. The in-process tests above
|
||||
// hand-build the CompFilter struct and therefore skip the XML-decode step,
|
||||
// so this is the only test that can catch a bug in how the client's actual
|
||||
// request gets parsed. DAVx5/Thunderbird send exactly this shape.
|
||||
func TestReportPropFilterTimeRangeRecurringE2E(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(rruleSample))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
st, err := store.NewStore(filepath.Join(dir, "data"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
dbase, err := db.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("db.Open: %v", err)
|
||||
}
|
||||
defer dbase.Close()
|
||||
if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
if err := dbase.CreateICSSubscription("alice", "holidays", srv.URL, ""); err != nil {
|
||||
t.Fatalf("CreateICSSubscription: %v", err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
cfg := &config.Config{}
|
||||
cfg.Auth.Realm = "test"
|
||||
|
||||
handlers := auth.NewMiddleware(cfg, dbase, logger)
|
||||
httpd := httptest.NewServer(handlers.Wrap(NewHandler(cfg, st, dbase, logger, icssub.NewCache(time.Hour))))
|
||||
defer httpd.Close()
|
||||
|
||||
reportXML := `<?xml version="1.0" encoding="utf-8"?>
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:allprop/>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VEVENT">
|
||||
<C:prop-filter name="DTSTART">
|
||||
<C:time-range start="20260901T000000Z" end="20260930T000000Z"/>
|
||||
</C:prop-filter>
|
||||
</C:comp-filter>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>`
|
||||
|
||||
req, err := http.NewRequest("REPORT", httpd.URL+"/cal/home/holidays/", strings.NewReader(reportXML))
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
req.SetBasicAuth("alice", "pw")
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
req.Header.Set("Depth", "1")
|
||||
|
||||
resp, err := httpd.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusMultiStatus {
|
||||
t.Fatalf("REPORT status = %d, want 207; body:\n%s", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(body), "Frontend Weekly") {
|
||||
t.Fatalf("REPORT response did not include the recurring 'Frontend Weekly' series. This is the exact DAVx5/Thunderbird request shape; body:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseOpenEndedTimeRangesUnit exercises closeOpenEndedTimeRanges
|
||||
// directly: a comp-filter with only Start set (no End) must get End
|
||||
// filled in with farFutureSentinel, and a comp-filter with neither set
|
||||
// must be left alone (it has no time-range at all, so there's nothing to
|
||||
// "close").
|
||||
func TestCloseOpenEndedTimeRangesUnit(t *testing.T) {
|
||||
gs := time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC)
|
||||
cf := caldav.CompFilter{
|
||||
Name: "VCALENDAR",
|
||||
Comps: []caldav.CompFilter{
|
||||
{Name: "VEVENT", Start: gs}, // open-ended: no End
|
||||
{Name: "VTODO"}, // no time-range at all
|
||||
},
|
||||
}
|
||||
|
||||
closeOpenEndedTimeRanges(&cf)
|
||||
|
||||
if cf.Comps[0].End != farFutureSentinel {
|
||||
t.Fatalf("open-ended VEVENT comp-filter should get End=farFutureSentinel, got %v", cf.Comps[0].End)
|
||||
}
|
||||
if !cf.Comps[1].Start.IsZero() || !cf.Comps[1].End.IsZero() {
|
||||
t.Fatalf("VTODO comp-filter without any time-range must be left untouched, got Start=%v End=%v",
|
||||
cf.Comps[1].Start, cf.Comps[1].End)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryCalendarObjectsOpenEndedTimeRangeRecurring is the regression
|
||||
// test for a real bug found while investigating the "missing recurring
|
||||
// events" report: a client asking for "everything from date X onward"
|
||||
// (RFC 4791 §9.9 explicitly permits a time-range with only a start
|
||||
// attribute) — which is exactly what a client's default "sync events
|
||||
// from N days in the past, no future limit" setting produces — decodes
|
||||
// with End left as the zero time.Time. go-webdav's matchCompTimeRange
|
||||
// then calls rrule.Set.Between(start, zero-time, true) for any recurring
|
||||
// event, which always returns zero occurrences (the "end" bound is year
|
||||
// 1, before "start"), so *every* recurring series silently vanishes from
|
||||
// an open-ended query, even though the exact same series matches fine
|
||||
// when the client happens to also send an explicit (bounded) end date.
|
||||
func TestQueryCalendarObjectsOpenEndedTimeRangeRecurring(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(rruleSample))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
st, err := store.NewStore(filepath.Join(dir, "data"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
dbase, err := db.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("Open db: %v", err)
|
||||
}
|
||||
defer dbase.Close()
|
||||
if err := dbase.CreateUser("alice", "pw", "", ""); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
if err := dbase.CreateICSSubscription("alice", "holidays", srv.URL, ""); err != nil {
|
||||
t.Fatalf("CreateICSSubscription: %v", err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
b := NewBackend(&config.Config{}, st, dbase, logger, icssub.NewCache(time.Hour))
|
||||
ctx := auth.NewContext(context.Background(), &auth.Principal{Username: "alice"})
|
||||
|
||||
// "Sync from 90 days ago, no future limit": only Start is set.
|
||||
gs := time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC)
|
||||
q := &caldav.CalendarQuery{
|
||||
CompFilter: caldav.CompFilter{
|
||||
Name: "VCALENDAR",
|
||||
Comps: []caldav.CompFilter{{
|
||||
Name: "VEVENT",
|
||||
Start: gs, // no End: open-ended
|
||||
}},
|
||||
},
|
||||
}
|
||||
objs, err := b.QueryCalendarObjects(ctx, "/cal/home/holidays/", q)
|
||||
if err != nil {
|
||||
t.Fatalf("QueryCalendarObjects: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, co := range objs {
|
||||
for _, ev := range co.Data.Events() {
|
||||
if p := ev.Props.Get(ical.PropSummary); p != nil && p.Value == "Frontend Weekly" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected 'Frontend Weekly' to match an open-ended (start-only) time-range query")
|
||||
}
|
||||
}
|
||||
|
||||
+26
-45
@@ -30,22 +30,14 @@ func (b *Backend) icsSubscriptionCalendarMeta(owner string, sub db.ICSSubscripti
|
||||
}
|
||||
}
|
||||
|
||||
// icsObjectUID returns the UID a fetched VEVENT should be addressed by:
|
||||
// its own UID property if it has one, otherwise a placeholder derived from
|
||||
// the object ID (so the event still has a *unique* UID in the returned
|
||||
// VCALENDAR).
|
||||
func icsObjectUID(ev ical.Event, fallback string) string {
|
||||
if p := ev.Props.Get(ical.PropUID); p != nil && p.Value != "" {
|
||||
return p.Value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// icsSubscriptionCalendarObjects fetches sub's remote calendar (via
|
||||
// b.icsCache) and returns one caldav.CalendarObject per VEVENT. The
|
||||
// object path is derived from icssub.EventID(ev) so the same event keeps
|
||||
// the same path across fetches, even if its position in the document
|
||||
// changes.
|
||||
// b.icsCache) and returns one caldav.CalendarObject per Series — the
|
||||
// source feed's own group of VEVENTs sharing a UID (a recurring base plus
|
||||
// its explicit per-occurrence instances). Exposing the whole group as a
|
||||
// single object is the correct, non-lossy shape: a CalDAV client keeps the
|
||||
// entire series (RRULE + overrides) instead of the base and its instances
|
||||
// arriving as competing objects. The object path is derived from
|
||||
// series.ID(), stable across fetches of the same feed.
|
||||
func (b *Backend) listICSSubscriptionCalendarObjects(localName string, sub db.ICSSubscription) ([]caldav.CalendarObject, error) {
|
||||
cal, err := b.icsCache.Get(sub.URL)
|
||||
if err != nil {
|
||||
@@ -53,8 +45,11 @@ func (b *Backend) listICSSubscriptionCalendarObjects(localName string, sub db.IC
|
||||
}
|
||||
|
||||
var objs []caldav.CalendarObject
|
||||
for _, ev := range cal.Events() {
|
||||
obj, err := b.encodeICSObject(localName, ev)
|
||||
for _, series := range icssub.GroupSeries(cal) {
|
||||
if series.Base == nil && len(series.Instances) == 0 {
|
||||
continue
|
||||
}
|
||||
obj, err := b.encodeICSSeries(localName, series)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -64,51 +59,37 @@ func (b *Backend) listICSSubscriptionCalendarObjects(localName string, sub db.IC
|
||||
}
|
||||
|
||||
// icsSubscriptionCalendarObject fetches sub's remote calendar and returns
|
||||
// the single VEVENT whose derived object ID matches objID.
|
||||
// the Series whose stable ID matches objID.
|
||||
func (b *Backend) icsSubscriptionCalendarObject(localName, objID string, sub db.ICSSubscription) (*caldav.CalendarObject, error) {
|
||||
cal, err := b.icsCache.Get(sub.URL)
|
||||
if err != nil {
|
||||
return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("fetching ics subscription: %w", err))
|
||||
}
|
||||
for _, ev := range cal.Events() {
|
||||
if icssub.EventID(ev) != objID {
|
||||
for _, series := range icssub.GroupSeries(cal) {
|
||||
if series.Base == nil && len(series.Instances) == 0 {
|
||||
continue
|
||||
}
|
||||
return b.encodeICSObject(localName, ev)
|
||||
if series.ID() != objID {
|
||||
continue
|
||||
}
|
||||
return b.encodeICSSeries(localName, series)
|
||||
}
|
||||
return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("ics subscription event not found"))
|
||||
}
|
||||
|
||||
// encodeICSObject wraps a single fetched VEVENT ev into its own
|
||||
// caldav.CalendarObject, encoding it as a standalone one-event calendar
|
||||
// the same way every other calendar object in this backend is represented.
|
||||
func (b *Backend) encodeICSObject(localName string, ev ical.Event) (*caldav.CalendarObject, error) {
|
||||
objID := icssub.EventID(ev)
|
||||
if objID == "" {
|
||||
// Not addressable (no DTSTART) — skip.
|
||||
return nil, fmt.Errorf("event has no DTSTART; not addressable")
|
||||
}
|
||||
uid := icsObjectUID(ev, objID)
|
||||
|
||||
event := ical.NewEvent()
|
||||
event.Props = ev.Props
|
||||
if event.Props.Get(ical.PropUID) == nil {
|
||||
event.Props.SetText(ical.PropUID, uid)
|
||||
}
|
||||
if event.Props.Get(ical.PropDateTimeStamp) == nil {
|
||||
event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC())
|
||||
}
|
||||
|
||||
out := ical.NewCalendar()
|
||||
out.Props.SetText(ical.PropVersion, "2.0")
|
||||
out.Props.SetText(ical.PropProductID, "-//nidus//ics-subscription//EN")
|
||||
out.Children = append(out.Children, event.Component)
|
||||
// encodeICSSeries wraps a Series (base VEVENT + explicit instances, all the
|
||||
// feed's events that share a UID) into its own caldav.CalendarObject,
|
||||
// encoded as a multi-VEVENT VCALENDAR the same way the source subscription
|
||||
// is published.
|
||||
func (b *Backend) encodeICSSeries(localName string, series *icssub.Series) (*caldav.CalendarObject, error) {
|
||||
out := series.Calendar()
|
||||
|
||||
var buf strings.Builder
|
||||
if err := ical.NewEncoder(&buf).Encode(out); err != nil {
|
||||
return nil, fmt.Errorf("encoding ics subscription event: %w", err)
|
||||
}
|
||||
data := []byte(buf.String())
|
||||
objID := series.ID()
|
||||
|
||||
return &caldav.CalendarObject{
|
||||
Path: calObjectPath(localName, objID),
|
||||
|
||||
@@ -49,6 +49,23 @@ func mustCalEvent(t *testing.T, raw string) ical.Event {
|
||||
return evs[0]
|
||||
}
|
||||
|
||||
// icssubSeriesID returns the stable ID the CalDAV backend advertises for the
|
||||
// (single) series in raw. All tests here use single-series feeds, so this is
|
||||
// just GroupSeries(raw)[0].ID().
|
||||
func icssubSeriesID(t *testing.T, raw string) string {
|
||||
t.Helper()
|
||||
_ = t
|
||||
cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode()
|
||||
if err != nil {
|
||||
panic("icssubSeriesID: " + err.Error())
|
||||
}
|
||||
series := icssub.GroupSeries(cal)
|
||||
if len(series) == 0 {
|
||||
panic("icssubSeriesID: no series")
|
||||
}
|
||||
return series[0].ID()
|
||||
}
|
||||
|
||||
// TestICSSubscriptionListAndGetObject verifies the full CalDAV read path
|
||||
// for an ICS subscription: ListCalendarObjects returns one synthetic
|
||||
// stand-alone object whose Path is derived from icssub.EventID (not from
|
||||
@@ -92,16 +109,12 @@ func TestICSSubscriptionListAndGetObject(t *testing.T) {
|
||||
t.Fatalf("expected 1 object, got %d", len(objs))
|
||||
}
|
||||
|
||||
// The Path must be derived from the event's DTSTART/DTEND/SUMMARY —
|
||||
// i.e. icssub.EventID(ev), not from the event's position in the feed.
|
||||
ev := mustCalEvent(t, icsSample)
|
||||
wantID := icssub.EventID(ev)
|
||||
if wantID == "" {
|
||||
t.Fatal("EventID must be non-empty for a valid event")
|
||||
}
|
||||
// The Path must be derived from the event's stable identity (its UID) —
|
||||
// i.e. the series ID — not from the event's position in the feed.
|
||||
wantID := icssubSeriesID(t, icsSample)
|
||||
wantPath := calObjectPath("holidays", wantID)
|
||||
if objs[0].Path != wantPath {
|
||||
t.Fatalf("Path = %q, want %q (icssub.EventID-based, not position-based)", objs[0].Path, wantPath)
|
||||
t.Fatalf("Path = %q, want %q (series-ID-based, not position-based)", objs[0].Path, wantPath)
|
||||
}
|
||||
|
||||
// GetCalendarObject by the same Path should return an object that has
|
||||
@@ -260,7 +273,7 @@ func TestICSSubscriptionEventIDMatchesEventInCalDav(t *testing.T) {
|
||||
t.Fatalf("list: objs=%d err=%v", len(objs), err)
|
||||
}
|
||||
gotPath := objs[0].Path
|
||||
wantID := icssub.EventID(mustCalEvent(t, icsSample))
|
||||
wantID := icssubSeriesID(t, icsSample)
|
||||
wantPath := calObjectPath("holidays", wantID)
|
||||
if gotPath != wantPath {
|
||||
t.Fatalf("CalDAV object path %q does not match web-side EventID-derived %q", gotPath, wantPath)
|
||||
|
||||
Reference in New Issue
Block a user