Add ICS/webcal HTTP subscription calendars
Users can now subscribe to a remote ICS/webcal feed from the dashboard (name + color), the same way they set up a real calendar or the virtual Birthdays calendar. Subscriptions are read-only, per-user, and share the "/cal/home/<name>/" namespace with real calendars and "birthdays" (name collisions are rejected in both directions). - internal/db: new ics_subscriptions table + CRUD (internal/db/ics.go); CreateCalendarWithColor checks for a colliding subscription name. - internal/icssub: shared HTTP-fetch + TTL cache (15 min) for remote ICS calendars, with webcal:// -> https:// rewriting and stale-on-error fallback, used by both the web UI and the CalDAV backend. - internal/web: dashboard "Subscribe to an ICS/webcal calendar" form, color picker, delete button (internal/web/ics.go, templates/dashboard.templ); month view renders subscription events in their chosen color, read-only (internal/web/calendar.go). - internal/caldav: subscriptions are exposed as read-only calendars (internal/caldav/ics.go) - listed in PROPFIND, events served via GET/REPORT, PUT/DELETE on individual events rejected with 403, but DELETE on the calendar itself unsubscribes; calendar-color is injected the same way as for real calendars and Birthdays. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+101
-7
@@ -83,9 +83,10 @@ func (s *Server) ownsCalendar(username, cal string) (bool, error) {
|
||||
// store.Store/db.DB. If requireWrite is true, a read-only share is
|
||||
// rejected with errCalendarReadOnly.
|
||||
func (s *Server) resolveCalRef(username, ref string, requireWrite bool) (owner, name string, err error) {
|
||||
if ref == birthdaysCalRef {
|
||||
// The birthdays calendar is virtual/computed, not backed by any
|
||||
// stored calendar object — there's nothing to resolve to.
|
||||
if ref == birthdaysCalRef || strings.HasPrefix(ref, icsRefPrefix) {
|
||||
// Both the birthdays calendar and ICS subscriptions are
|
||||
// virtual/computed, not backed by any stored calendar object —
|
||||
// there's nothing to resolve to.
|
||||
return "", "", errCalendarNotFound
|
||||
}
|
||||
if owner, name, ok := strings.Cut(ref, calRefSep); ok {
|
||||
@@ -123,7 +124,8 @@ type calendarEntry struct {
|
||||
Name string // calendar's own name (unqualified)
|
||||
Color string
|
||||
Writable bool
|
||||
Virtual bool // true for computed calendars (e.g. birthdays) with no backing store objects
|
||||
Virtual bool // true for computed calendars (e.g. birthdays) with no backing store objects
|
||||
ICSURL string // set only for ICS-subscription entries (Ref has icsRefPrefix); the remote URL to fetch events from
|
||||
}
|
||||
|
||||
// birthdaysCalRef is the fixed reference for the synthetic "Birthdays"
|
||||
@@ -133,6 +135,19 @@ type calendarEntry struct {
|
||||
// mistaken for an "owner~name" shared-calendar ref either).
|
||||
const birthdaysCalRef = "@birthdays"
|
||||
|
||||
// icsRefPrefix marks a calendarEntry's Ref as referring to one of
|
||||
// username's own ICS/webcal subscriptions (see internal/db/ics.go). Like
|
||||
// "@" for the birthdays calendar, "!" isn't in resourceNameRe's character
|
||||
// class and can't appear in a calRefSep-joined shared-calendar ref either,
|
||||
// so "!<name>" can't collide with any other kind of ref.
|
||||
const icsRefPrefix = "!"
|
||||
|
||||
// icsCalRef builds the reference string for one of username's own ICS
|
||||
// subscriptions named name.
|
||||
func icsCalRef(name string) string {
|
||||
return icsRefPrefix + name
|
||||
}
|
||||
|
||||
// defaultBirthdayColor is the display color for the virtual birthdays
|
||||
// calendar used until the user picks their own from the dashboard (a
|
||||
// pink, distinct from typical user-picked calendar colors).
|
||||
@@ -150,8 +165,8 @@ func (s *Server) birthdayCalendarColor(username string) string {
|
||||
|
||||
// listCalendarEntries returns every calendar visible to username: the
|
||||
// synthetic birthdays calendar, their own calendars (always writable),
|
||||
// and any calendars shared with them (writable only if the share grants
|
||||
// write permission).
|
||||
// any calendars shared with them (writable only if the share grants write
|
||||
// permission), and their own read-only ICS/webcal subscriptions.
|
||||
func (s *Server) listCalendarEntries(username string) ([]calendarEntry, error) {
|
||||
entries := []calendarEntry{
|
||||
{Ref: birthdaysCalRef, Owner: username, Name: "Birthdays", Color: s.birthdayCalendarColor(username), Writable: false, Virtual: true},
|
||||
@@ -185,6 +200,17 @@ func (s *Server) listCalendarEntries(username string) ([]calendarEntry, error) {
|
||||
})
|
||||
}
|
||||
|
||||
subs, err := s.dbase.ListICSSubscriptions(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, sub := range subs {
|
||||
entries = append(entries, calendarEntry{
|
||||
Ref: icsCalRef(sub.Name), Owner: username, Name: sub.Name, Color: sub.Color,
|
||||
Writable: false, Virtual: true, ICSURL: sub.URL,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
if entries[i].Owner != entries[j].Owner {
|
||||
return entries[i].Owner < entries[j].Owner
|
||||
@@ -283,6 +309,13 @@ func (s *Server) buildMonthView(username string, year int, month time.Month) (te
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(entry.Ref, icsRefPrefix) {
|
||||
if err := s.addICSEvents(entry, gridStart, gridEnd, loc, dayIndex, days); err != nil {
|
||||
s.logger.Warn("fetching ics subscription events", "calendar", entry.Name, "error", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
ids, err := s.store.ListObjects(entry.Owner, "cal-"+entry.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -766,8 +799,14 @@ func eventFormFromICS(id string, data []byte) (templates.EventFormData, error) {
|
||||
if len(events) == 0 {
|
||||
return templates.EventFormData{}, fmt.Errorf("no VEVENT in %s", id)
|
||||
}
|
||||
ev := events[0]
|
||||
return eventFormFromComponent(id, events[0])
|
||||
}
|
||||
|
||||
// eventFormFromComponent extracts an EventFormData from a single decoded
|
||||
// VEVENT, shared by eventFormFromICS (one event per stored .ics object)
|
||||
// and the ICS-subscription rendering path (many events per fetched
|
||||
// calendar, see addICSEvents).
|
||||
func eventFormFromComponent(id string, ev ical.Event) (templates.EventFormData, error) {
|
||||
form := templates.EventFormData{ID: id}
|
||||
if p := ev.Props.Get(ical.PropSummary); p != nil {
|
||||
form.Summary = p.Value
|
||||
@@ -1031,3 +1070,58 @@ func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridSta
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addICSEvents fetches entry's remote ICS/webcal calendar (via s.icsCache,
|
||||
// which caches it for a while so every month-view render doesn't re-fetch
|
||||
// from origin) and places each VEVENT's occurrence onto the month grid,
|
||||
// the same way a stored calendar object would be. There's no per-event
|
||||
// edit page for these (the source is external and read-only), so each
|
||||
// event's LinkURL is left pointing nowhere useful ("#").
|
||||
func (s *Server) addICSEvents(entry calendarEntry, gridStart, gridEnd time.Time, loc *time.Location, dayIndex map[string]int, days []templates.MonthDay) error {
|
||||
cal, err := s.icsCache.Get(entry.ICSURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
const totalDays = 42
|
||||
for i, ev := range cal.Events() {
|
||||
id := fmt.Sprintf("ics-%d", i)
|
||||
form, err := eventFormFromComponent(id, ev)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
startDay, endDay, err := eventDayRange(form, loc)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if endDay.Before(gridStart) || startDay.After(gridEnd) {
|
||||
continue
|
||||
}
|
||||
if startDay.Before(gridStart) {
|
||||
startDay = gridStart
|
||||
}
|
||||
if endDay.After(gridEnd) {
|
||||
endDay = gridEnd
|
||||
}
|
||||
for d, n := startDay, 0; !d.After(endDay) && n < totalDays; d, n = d.AddDate(0, 0, 1), n+1 {
|
||||
idx, ok := dayIndex[d.Format(dateLayout)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
timeText := ""
|
||||
if !form.AllDay {
|
||||
timeText = form.StartTime
|
||||
}
|
||||
days[idx].Events = append(days[idx].Events, templates.EventSummary{
|
||||
ID: id,
|
||||
CalRef: entry.Ref,
|
||||
Color: entry.Color,
|
||||
Summary: form.Summary,
|
||||
TimeText: timeText,
|
||||
AllDay: form.AllDay,
|
||||
LinkURL: "#",
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user