package caldav import ( "fmt" "net/http" "strings" "time" ical "github.com/emersion/go-ical" "github.com/emersion/go-webdav" "github.com/emersion/go-webdav/caldav" "git.arnef.de/arnef/nidus/internal/db" "git.arnef.de/arnef/nidus/internal/icssub" ) // defaultICSColor is the display color for an ICS/webcal subscription // calendar when the user hasn't chosen one, matching internal/web's. const defaultICSColor = "#0ea5e9" // icsSubscriptionCalendarMeta returns the caldav.Calendar metadata for // owner's ICS subscription sub, exposed under its own chosen name. func (b *Backend) icsSubscriptionCalendarMeta(owner string, sub db.ICSSubscription) caldav.Calendar { return caldav.Calendar{ Path: calHomePath() + sub.Name + "/", Name: sub.Name, Description: "Read-only subscription: " + sub.URL, SupportedComponentSet: []string{"VEVENT"}, MaxResourceSize: 256 * 1024, } } // icsSubscriptionCalendarObjects fetches sub's remote calendar (via // 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 { return nil, fmt.Errorf("fetching ics subscription %q: %w", sub.Name, err) } var objs []caldav.CalendarObject 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 } objs = append(objs, *obj) } return objs, nil } // icsSubscriptionCalendarObject fetches sub's remote calendar and returns // 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 _, series := range icssub.GroupSeries(cal) { if series.Base == nil && len(series.Instances) == 0 { continue } if series.ID() != objID { continue } return b.encodeICSSeries(localName, series) } return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("ics subscription event not found")) } // 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), ModTime: time.Now(), ContentLength: int64(len(data)), ETag: fmt.Sprintf(`"ics-%s"`, objID), Data: out, }, nil }