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, } } // 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. 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 _, ev := range cal.Events() { obj, err := b.encodeICSObject(localName, ev) if err != nil { continue } objs = append(objs, *obj) } return objs, nil } // icsSubscriptionCalendarObject fetches sub's remote calendar and returns // the single VEVENT whose derived object 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 { continue } return b.encodeICSObject(localName, ev) } 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) 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()) return &caldav.CalendarObject{ Path: calObjectPath(localName, objID), ModTime: time.Now(), ContentLength: int64(len(data)), ETag: fmt.Sprintf(`"ics-%s"`, objID), Data: out, }, nil }