From 4d160762aeef531208f8cf6094a0e58a9e7d53ec Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 20 Sep 2026 07:57:36 +0000 Subject: Add .ics calendar feed support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doot reads Google Calendar through a service account, by calendar ID. That cannot consume an .ics URL, and it cannot see a calendar subscribed to inside a personal Google account either, because Google does not allow re-sharing a subscribed calendar. Published schedules — sports fixtures, school terms, bin collections — are distributed almost exclusively as .ics, so none of them could be added at all. Feeds are now fetched on the normal calendar refresh and merged into calendar_events alongside Google events, so the timeline, widget and everything downstream keep seeing one list and needed no changes. Failure handling is deliberately asymmetric. A Google error is still returned as an error, unchanged, so an outage there surfaces. A single failing feed is recorded against that feed and skipped, because one dead URL should not take the whole calendar down — and last_error is shown in settings, since a feed that has quietly stopped resolving otherwise looks identical to a team with no games left. The parser handles what real feeds actually contain: line unfolding, CRLF and LF, UTC / TZID / VALUE=DATE times, and escaped text. Two details worth naming: a TZID time is wall-clock in that zone, and reading it as UTC would put a 1:15pm Pacific kickoff on the timeline at 1:15pm local — wrong by the offset but entirely plausible-looking. And non-calendar content is a hard error rather than zero events, because an HTML error page from a moved feed URL is the common failure and "no events" reads as success. RRULE is deliberately unsupported: every schedule feed this targets expands recurrence itself, one VEVENT per fixture, and a correct expansion is a large amount of subtle code. A recurring event appears once rather than wrongly. Settings gains an add/enable/disable/remove UI. webcal:// links are accepted and rewritten, since that is how feeds are usually advertised. 16 parser tests, 5 store tests. Verified against the real Seattle Seahawks feed: 21 events, all with valid times. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- internal/api/ics.go | 249 +++++++++++++++++++++++++++++++++++++++ internal/api/ics_client.go | 92 +++++++++++++++ internal/api/ics_test.go | 200 +++++++++++++++++++++++++++++++ internal/handlers/handlers.go | 32 ++++- internal/handlers/ics.go | 180 ++++++++++++++++++++++++++++ internal/handlers/settings.go | 3 + internal/store/ics_feeds.go | 95 +++++++++++++++ internal/store/ics_feeds_test.go | 144 ++++++++++++++++++++++ 8 files changed, 989 insertions(+), 6 deletions(-) create mode 100644 internal/api/ics.go create mode 100644 internal/api/ics_client.go create mode 100644 internal/api/ics_test.go create mode 100644 internal/handlers/ics.go create mode 100644 internal/store/ics_feeds.go create mode 100644 internal/store/ics_feeds_test.go (limited to 'internal') diff --git a/internal/api/ics.go b/internal/api/ics.go new file mode 100644 index 0000000..925efc7 --- /dev/null +++ b/internal/api/ics.go @@ -0,0 +1,249 @@ +package api + +import ( + "bufio" + "crypto/sha1" + "encoding/hex" + "errors" + "fmt" + "io" + "strings" + "time" +) + +// ICSEvent is a single VEVENT, reduced to the fields doot shows. +type ICSEvent struct { + UID string + Summary string + Description string + Location string + Start time.Time + End time.Time + AllDay bool +} + +// StableID returns an id that stays the same across refreshes, scoped to the +// feed it came from. +// +// Scoping matters: two feeds can legitimately contain the same UID (the same +// fixture published by two aggregators), and an unscoped id would make one +// silently overwrite the other. Feeds that omit UID entirely fall back to a +// hash of the event's own content, which is stable as long as the event is. +func (e ICSEvent) StableID(feedID string) string { + key := e.UID + if key == "" { + key = fmt.Sprintf("%s|%s|%s", e.Summary, e.Start.UTC().Format(time.RFC3339), e.Location) + } + sum := sha1.Sum([]byte(feedID + "|" + key)) + return "ics-" + hex.EncodeToString(sum[:])[:20] +} + +// ParseICS reads an iCalendar stream and returns its VEVENTs. +// +// Deliberately not supported: RRULE. Recurring events are expanded by the +// publisher in every schedule feed this targets — each fixture is its own +// VEVENT — and a correct RRULE expansion (COUNT/UNTIL/BYDAY/EXDATE, across DST +// boundaries) is a large amount of subtle code. A recurring event will appear +// once, at its first occurrence, rather than silently wrongly. +func ParseICS(r io.Reader) ([]ICSEvent, error) { + lines, err := unfold(r) + if err != nil { + return nil, err + } + + sawCalendar := false + for _, l := range lines { + if strings.EqualFold(strings.TrimSpace(l), "BEGIN:VCALENDAR") { + sawCalendar = true + break + } + } + if !sawCalendar { + // Usually an HTML error page from a feed URL that has moved or died. + // Returning zero events would look like "the team has no games". + return nil, errors.New("not an iCalendar document (no BEGIN:VCALENDAR)") + } + + var events []ICSEvent + var cur *ICSEvent + for _, line := range lines { + trimmed := strings.TrimSpace(line) + switch { + case strings.EqualFold(trimmed, "BEGIN:VEVENT"): + cur = &ICSEvent{} + continue + case strings.EqualFold(trimmed, "END:VEVENT"): + if cur != nil { + if !cur.Start.IsZero() { + if cur.End.IsZero() { + cur.End = defaultEnd(*cur) + } + events = append(events, *cur) + } + cur = nil + } + continue + } + if cur == nil { + continue // outside a VEVENT (VCALENDAR headers, VTIMEZONE, VALARM) + } + + name, params, value := splitProperty(line) + switch strings.ToUpper(name) { + case "UID": + cur.UID = value + case "SUMMARY": + cur.Summary = unescapeText(value) + case "DESCRIPTION": + cur.Description = unescapeText(value) + case "LOCATION": + cur.Location = unescapeText(value) + case "DTSTART": + if t, allDay, ok := parseICSTime(value, params); ok { + cur.Start, cur.AllDay = t, allDay + } + case "DTEND": + if t, _, ok := parseICSTime(value, params); ok { + cur.End = t + } + } + } + return events, nil +} + +// defaultEnd supplies an end for an event that gave none. A zero end would +// render as an event running from its start to year zero. +func defaultEnd(e ICSEvent) time.Time { + if e.AllDay { + return e.Start.AddDate(0, 0, 1) + } + return e.Start.Add(time.Hour) +} + +// unfold reads the stream and joins continuation lines. Per RFC 5545 a line +// beginning with a space or tab continues the previous one; splitting a long +// SUMMARY is routine, so not unfolding truncates titles mid-word. +func unfold(r io.Reader) ([]string, error) { + scanner := bufio.NewScanner(r) + // Feeds with long DESCRIPTIONs exceed bufio's default 64KB line limit. + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + var out []string + for scanner.Scan() { + line := strings.TrimRight(scanner.Text(), "\r") + if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') && len(out) > 0 { + out[len(out)-1] += line[1:] + continue + } + out = append(out, line) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return out, nil +} + +// splitProperty divides "NAME;PARAM=VAL;P2=V2:value" into its parts. The colon +// inside a value (a URL, a time) must not be treated as the separator, so only +// the first colon outside a quoted parameter counts. +func splitProperty(line string) (name string, params map[string]string, value string) { + params = map[string]string{} + + inQuotes := false + colon := -1 + for i, r := range line { + if r == '"' { + inQuotes = !inQuotes + } + if r == ':' && !inQuotes { + colon = i + break + } + } + if colon == -1 { + return line, params, "" + } + + head := line[:colon] + value = line[colon+1:] + + parts := strings.Split(head, ";") + name = parts[0] + for _, p := range parts[1:] { + if eq := strings.Index(p, "="); eq > 0 { + k := strings.ToUpper(strings.TrimSpace(p[:eq])) + v := strings.Trim(strings.TrimSpace(p[eq+1:]), `"`) + params[k] = v + } + } + return name, params, value +} + +// parseICSTime handles the three forms that appear in practice: +// +// 20260913T201500Z UTC +// 20260920T131500 with TZID=Zone wall-clock time in that zone +// 20260913 with VALUE=DATE all-day +// +// The TZID case is the one worth care: those digits are local to the named +// zone, and reading them as UTC shifts the event by the zone's offset — wrong +// by hours, but plausible-looking, so easy to miss. +func parseICSTime(value string, params map[string]string) (t time.Time, allDay bool, ok bool) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, false, false + } + + if strings.EqualFold(params["VALUE"], "DATE") || len(value) == 8 { + parsed, err := time.ParseInLocation("20060102", value, time.UTC) + if err != nil { + return time.Time{}, false, false + } + return parsed, true, true + } + + if strings.HasSuffix(value, "Z") { + parsed, err := time.ParseInLocation("20060102T150405Z", value, time.UTC) + if err != nil { + return time.Time{}, false, false + } + return parsed, false, true + } + + loc := time.UTC + if tzid := params["TZID"]; tzid != "" { + if l, err := time.LoadLocation(tzid); err == nil { + loc = l + } + // An unknown TZID falls back to UTC rather than dropping the event; + // a visible event at the wrong hour beats a missing one. + } + parsed, err := time.ParseInLocation("20060102T150405", value, loc) + if err != nil { + return time.Time{}, false, false + } + return parsed, false, true +} + +// unescapeText reverses RFC 5545 text escaping. +func unescapeText(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + if s[i] != '\\' || i+1 >= len(s) { + b.WriteByte(s[i]) + continue + } + i++ + switch s[i] { + case 'n', 'N': + b.WriteByte('\n') + case '\\', ',', ';': + b.WriteByte(s[i]) + default: + // Unknown escape: keep both characters rather than eat them. + b.WriteByte('\\') + b.WriteByte(s[i]) + } + } + return b.String() +} diff --git a/internal/api/ics_client.go b/internal/api/ics_client.go new file mode 100644 index 0000000..857fa8f --- /dev/null +++ b/internal/api/ics_client.go @@ -0,0 +1,92 @@ +package api + +import ( + "context" + "fmt" + "io" + "net/http" + "time" + + "task-dashboard/internal/models" +) + +// ICSFetcher retrieves and parses a remote iCalendar feed. +type ICSFetcher struct { + client *http.Client +} + +// NewICSFetcher builds a fetcher with a bounded timeout. +func NewICSFetcher(timeout time.Duration) *ICSFetcher { + return &ICSFetcher{client: &http.Client{Timeout: timeout}} +} + +// maxICSBytes caps how much of a feed is read. A calendar is small; anything +// enormous is a misconfigured URL pointing at something else, and reading it +// all into memory would be the only harm it does. +const maxICSBytes = 8 << 20 // 8 MiB + +// Fetch downloads the feed at url and returns its events. +// +// The context is threaded into the request, so a hung feed cannot outlive the +// refresh that started it — see internal/api/context_audit_test.go for why that +// matters here. +func (f *ICSFetcher) Fetch(ctx context.Context, url string) ([]ICSEvent, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("building request: %w", err) + } + // Some publishers serve HTML to unknown agents and the calendar only to + // something that looks like a calendar client. + req.Header.Set("Accept", "text/calendar, text/plain;q=0.9, */*;q=0.8") + req.Header.Set("User-Agent", "doot/1.0 (+https://doot.terst.org)") + + resp, err := f.client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching feed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("feed returned HTTP %d", resp.StatusCode) + } + + events, err := ParseICS(io.LimitReader(resp.Body, maxICSBytes)) + if err != nil { + return nil, fmt.Errorf("parsing feed: %w", err) + } + return events, nil +} + +// ToCalendarEvents converts parsed ICS events into the model the timeline +// already renders, so feed events need no special handling downstream. +// +// feedID scopes the generated ids; feedName is prefixed to each title so a +// bare fixture like "Seahawks at 49ers" is identifiable on a mixed timeline. +func ToCalendarEvents(feedID, feedName string, events []ICSEvent) []models.CalendarEvent { + out := make([]models.CalendarEvent, 0, len(events)) + for _, e := range events { + summary := e.Summary + if summary == "" { + summary = feedName + } + out = append(out, models.CalendarEvent{ + ID: e.StableID(feedID), + Summary: summary, + Description: descriptionWithLocation(e), + Start: e.Start, + End: e.End, + }) + } + return out +} + +func descriptionWithLocation(e ICSEvent) string { + switch { + case e.Location == "": + return e.Description + case e.Description == "": + return e.Location + default: + return e.Description + "\n" + e.Location + } +} diff --git a/internal/api/ics_test.go b/internal/api/ics_test.go new file mode 100644 index 0000000..a4a3474 --- /dev/null +++ b/internal/api/ics_test.go @@ -0,0 +1,200 @@ +package api + +import ( + "strings" + "testing" + "time" +) + +// A realistic feed: UTC stamps, a TZID-qualified local time, an all-day event, +// folded lines, escaped text, and CRLF endings — all of which appear in the +// sports-schedule feeds this was written for. +const sampleICS = "BEGIN:VCALENDAR\r\n" + + "VERSION:2.0\r\n" + + "PRODID:-//Test//EN\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:game-1@example.com\r\n" + + "DTSTART:20260913T201500Z\r\n" + + "DTEND:20260913T231500Z\r\n" + + "SUMMARY:Seahawks at 49ers\r\n" + + "LOCATION:Levi's Stadium\r\n" + + "END:VEVENT\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:game-2@example.com\r\n" + + "DTSTART;TZID=America/Los_Angeles:20260920T131500\r\n" + + "DTEND;TZID=America/Los_Angeles:20260920T161500\r\n" + + "SUMMARY:Seahawks vs. Saints\r\n" + + "DESCRIPTION:Home opener\\, kickoff 1:15pm\\nGates open at 11am\r\n" + + "END:VEVENT\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:bye-week@example.com\r\n" + + "DTSTART;VALUE=DATE:20261012\r\n" + + "DTEND;VALUE=DATE:20261013\r\n" + + "SUMMARY:Bye week\r\n" + + "END:VEVENT\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:folded@example.com\r\n" + + "DTSTART:20261019T170000Z\r\n" + + "DTEND:20261019T200000Z\r\n" + + // Folded mid-word on purpose: unfolding removes the CRLF and exactly one + // leading space, so "...that the fe" + " " + "ed has..." must rejoin as + // "that the feed has...". Folding at a word boundary would not distinguish + // a correct implementation from one that also eats a real space. + "SUMMARY:A very long game title that the fe\r\n" + + " ed has folded across two lines\r\n" + + "END:VEVENT\r\n" + + "END:VCALENDAR\r\n" + +func parseOrFail(t *testing.T, body string) []ICSEvent { + t.Helper() + events, err := ParseICS(strings.NewReader(body)) + if err != nil { + t.Fatalf("ParseICS: %v", err) + } + return events +} + +func TestParseICS_ReadsAllEvents(t *testing.T) { + events := parseOrFail(t, sampleICS) + if len(events) != 4 { + t.Fatalf("got %d events, want 4: %+v", len(events), events) + } +} + +func TestParseICS_UTCTimestamps(t *testing.T) { + e := parseOrFail(t, sampleICS)[0] + if e.UID != "game-1@example.com" { + t.Errorf("UID = %q", e.UID) + } + if e.Summary != "Seahawks at 49ers" { + t.Errorf("Summary = %q", e.Summary) + } + want := time.Date(2026, 9, 13, 20, 15, 0, 0, time.UTC) + if !e.Start.Equal(want) { + t.Errorf("Start = %v, want %v", e.Start.UTC(), want) + } + if !e.End.Equal(time.Date(2026, 9, 13, 23, 15, 0, 0, time.UTC)) { + t.Errorf("End = %v", e.End.UTC()) + } + if e.AllDay { + t.Error("a timed event was marked all-day") + } +} + +// A TZID-qualified time is a wall-clock time in that zone, not UTC. Reading it +// as UTC would put a 1:15pm Pacific kickoff on the timeline at 1:15pm local — +// silently, and only wrong by the offset. +func TestParseICS_TZIDIsInterpretedInThatZone(t *testing.T) { + e := parseOrFail(t, sampleICS)[1] + la, err := time.LoadLocation("America/Los_Angeles") + if err != nil { + t.Skipf("tzdata unavailable: %v", err) + } + want := time.Date(2026, 9, 20, 13, 15, 0, 0, la) + if !e.Start.Equal(want) { + t.Errorf("Start = %v (%v), want %v", e.Start, e.Start.UTC(), want.UTC()) + } +} + +func TestParseICS_AllDayEvent(t *testing.T) { + e := parseOrFail(t, sampleICS)[2] + if !e.AllDay { + t.Error("VALUE=DATE event should be all-day") + } + if e.Start.Year() != 2026 || e.Start.Month() != time.October || e.Start.Day() != 12 { + t.Errorf("Start = %v, want 2026-10-12", e.Start) + } +} + +// Folded lines: a leading space or tab continues the previous line. Not +// unfolding leaves the title truncated at the fold. +func TestParseICS_UnfoldsLines(t *testing.T) { + e := parseOrFail(t, sampleICS)[3] + want := "A very long game title that the feed has folded across two lines" + if e.Summary != want { + t.Errorf("Summary = %q, want %q", e.Summary, want) + } +} + +// Text values escape commas, semicolons, backslashes and newlines. +func TestParseICS_UnescapesText(t *testing.T) { + e := parseOrFail(t, sampleICS)[1] + want := "Home opener, kickoff 1:15pm\nGates open at 11am" + if e.Description != want { + t.Errorf("Description = %q, want %q", e.Description, want) + } +} + +func TestParseICS_LocationIsCaptured(t *testing.T) { + if got := parseOrFail(t, sampleICS)[0].Location; got != "Levi's Stadium" { + t.Errorf("Location = %q", got) + } +} + +// An event with no DTEND gets a sensible end rather than a zero time, which +// would render as a 56-year-long event. +func TestParseICS_MissingDTENDDefaults(t *testing.T) { + body := "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:x\r\nDTSTART:20260913T201500Z\r\nSUMMARY:No end\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" + e := parseOrFail(t, body)[0] + if !e.End.After(e.Start) { + t.Errorf("End %v should be after Start %v", e.End, e.Start) + } +} + +// Events with no usable start are skipped rather than landing at the zero time, +// where they would sort to the top of the timeline forever. +func TestParseICS_SkipsEventsWithoutAStart(t *testing.T) { + body := "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:x\r\nSUMMARY:No start\r\nEND:VEVENT\r\n" + + "BEGIN:VEVENT\r\nUID:y\r\nDTSTART:20260913T201500Z\r\nSUMMARY:Fine\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" + events := parseOrFail(t, body) + if len(events) != 1 || events[0].Summary != "Fine" { + t.Errorf("got %+v, want only the event with a start", events) + } +} + +func TestParseICS_HandlesLFOnlyLineEndings(t *testing.T) { + body := strings.ReplaceAll(sampleICS, "\r\n", "\n") + if got := len(parseOrFail(t, body)); got != 4 { + t.Errorf("got %d events with LF endings, want 4", got) + } +} + +// Anything that is not a calendar must be a clear error, not silently zero +// events — an HTML error page from a dead feed URL is the common case. +func TestParseICS_RejectsNonCalendarContent(t *testing.T) { + if _, err := ParseICS(strings.NewReader("404 Not Found")); err == nil { + t.Error("expected an error for non-calendar content") + } +} + +func TestParseICS_EmptyCalendarIsNotAnError(t *testing.T) { + events, err := ParseICS(strings.NewReader("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(events) != 0 { + t.Errorf("got %d events, want 0", len(events)) + } +} + +// A feed with no UID still needs a stable id, or every refresh would look like +// a brand-new set of events. +func TestICSEvent_StableIDWithoutUID(t *testing.T) { + body := "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260913T201500Z\r\nSUMMARY:No UID\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" + first := parseOrFail(t, body)[0].StableID("feed-1") + second := parseOrFail(t, body)[0].StableID("feed-1") + if first == "" { + t.Fatal("StableID returned empty") + } + if first != second { + t.Errorf("StableID is not stable: %q vs %q", first, second) + } +} + +// Two feeds containing the same UID must not collide into one event. +func TestICSEvent_StableIDIsScopedToItsFeed(t *testing.T) { + e := parseOrFail(t, sampleICS)[0] + if e.StableID("feed-1") == e.StableID("feed-2") { + t.Error("same UID in different feeds produced the same id") + } +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 147db07..caf7daa 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -325,9 +325,19 @@ func (h *Handler) fetchMeals(ctx context.Context, forceRefresh bool) ([]models.M return fetcher.FetchWithCache(ctx, forceRefresh) } -// fetchCalendarEvents fetches Google Calendar events from cache or API +// fetchCalendarEvents fetches calendar events from cache or source. +// +// Two sources feed the one cache: Google Calendar, and any subscribed .ics +// feeds. They are merged here rather than downstream so the timeline, the +// widget and everything else keep seeing a single list of calendar events. +// +// A Google failure is still returned as an error, unchanged — an outage there +// should surface rather than be masked by a partially-populated calendar. A +// single failing .ics feed, by contrast, is recorded against that feed and +// skipped, because one dead feed URL should not take the calendar down. func (h *Handler) fetchCalendarEvents(ctx context.Context, forceRefresh bool) ([]models.CalendarEvent, error) { - if h.googleCalendarClient == nil { + icsFeeds, _ := h.store.GetEnabledICSFeeds() + if h.googleCalendarClient == nil && len(icsFeeds) == 0 { return nil, nil } @@ -355,20 +365,30 @@ func (h *Handler) fetchCalendarEvents(ctx context.Context, forceRefresh bool) ([ enabledIDs = append(enabledIDs, trimmed) } } - } else { - // Configs exist but all disabled — respect that + } else if len(icsFeeds) == 0 { + // Configs exist but all disabled, and no .ics feeds either. return nil, nil } } - h.googleCalendarClient.SetCalendarIDs(enabledIDs) + if h.googleCalendarClient != nil { + h.googleCalendarClient.SetCalendarIDs(enabledIDs) + } fetcher := &CacheFetcher[models.CalendarEvent]{ Store: h.store, CacheKey: store.CacheKeyGoogleCalendar, TTLMinutes: h.config.CacheTTLMinutes, Fetch: func(ctx context.Context) ([]models.CalendarEvent, error) { - return h.googleCalendarClient.GetUpcomingEvents(ctx, 50) + var events []models.CalendarEvent + if h.googleCalendarClient != nil && len(enabledIDs) > 0 { + googleEvents, err := h.googleCalendarClient.GetUpcomingEvents(ctx, 50) + if err != nil { + return nil, err + } + events = append(events, googleEvents...) + } + return append(events, h.fetchICSFeedEvents(ctx)...), nil }, GetFromCache: h.store.GetCalendarEvents, SaveToCache: h.store.SaveCalendarEvents, diff --git a/internal/handlers/ics.go b/internal/handlers/ics.go new file mode 100644 index 0000000..e0f96de --- /dev/null +++ b/internal/handlers/ics.go @@ -0,0 +1,180 @@ +package handlers + +import ( + "context" + "log" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + + "task-dashboard/internal/api" + "task-dashboard/internal/config" + "task-dashboard/internal/models" + "task-dashboard/internal/store" +) + +// fetchICSFeedEvents fetches every enabled .ics feed and returns their events. +// +// Failures are per-feed: one unreachable URL is recorded against that feed and +// skipped, leaving the others (and Google Calendar) unaffected. The whole point +// of recording last_error is that a feed which has quietly stopped resolving +// shows as broken in settings, instead of just silently having no events — +// which is indistinguishable from a team having no games left. +func (h *Handler) fetchICSFeedEvents(ctx context.Context) []models.CalendarEvent { + feeds, err := h.store.GetEnabledICSFeeds() + if err != nil { + log.Printf("ERROR [ICS]: listing feeds: %v", err) + return nil + } + if len(feeds) == 0 { + return nil + } + + fetcher := api.NewICSFetcher(config.HTTPClientTimeout) + + var all []models.CalendarEvent + for _, feed := range feeds { + events, err := fetcher.Fetch(ctx, feed.URL) + if err != nil { + log.Printf("ERROR [ICS/%s]: %v", feed.Name, err) + _ = h.store.RecordICSFeedResult(feed.ID, 0, err) + continue + } + converted := api.ToCalendarEvents(strconv.FormatInt(feed.ID, 10), feed.Name, events) + all = append(all, converted...) + _ = h.store.RecordICSFeedResult(feed.ID, len(converted), nil) + log.Printf("DEBUG [ICS/%s]: %d events", feed.Name, len(converted)) + } + return all +} + +// HandleAddICSFeed subscribes to a new .ics feed. +func (h *Handler) HandleAddICSFeed(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) + return + } + + name := strings.TrimSpace(r.FormValue("name")) + feedURL := strings.TrimSpace(r.FormValue("url")) + + // webcal:// is how calendar feeds are usually advertised; it is plain HTTPS + // underneath, and pasting the advertised link is the obvious thing to do. + if strings.HasPrefix(strings.ToLower(feedURL), "webcal://") { + feedURL = "https://" + feedURL[len("webcal://"):] + } + + if name == "" || feedURL == "" { + JSONError(w, http.StatusBadRequest, "Name and URL are required", nil) + return + } + parsed, err := url.Parse(feedURL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + JSONError(w, http.StatusBadRequest, "URL must be a valid http(s) or webcal address", nil) + return + } + + if err := h.store.AddICSFeed(name, feedURL); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to save feed", err) + return + } + + // Drop the cached calendar so the new feed's events appear on the next + // load rather than after the TTL expires. + _ = h.store.InvalidateCache(store.CacheKeyGoogleCalendar) + + h.renderICSFeedList(w, r) +} + +// HandleToggleICSFeed enables or disables a feed without deleting it. +func (h *Handler) HandleToggleICSFeed(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + JSONError(w, http.StatusBadRequest, "Invalid feed id", err) + return + } + if err := r.ParseForm(); err != nil { + JSONError(w, http.StatusBadRequest, "Failed to parse form", err) + return + } + enabled := r.FormValue("enabled") == "true" + + if err := h.store.SetICSFeedEnabled(id, enabled); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to update feed", err) + return + } + _ = h.store.InvalidateCache(store.CacheKeyGoogleCalendar) + h.renderICSFeedList(w, r) +} + +// HandleDeleteICSFeed removes a feed. +func (h *Handler) HandleDeleteICSFeed(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + JSONError(w, http.StatusBadRequest, "Invalid feed id", err) + return + } + if err := h.store.DeleteICSFeed(id); err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to delete feed", err) + return + } + _ = h.store.InvalidateCache(store.CacheKeyGoogleCalendar) + h.renderICSFeedList(w, r) +} + +// HandleGetICSFeeds renders the feed list on its own, for the settings page. +func (h *Handler) HandleGetICSFeeds(w http.ResponseWriter, r *http.Request) { + h.renderICSFeedList(w, r) +} + +func (h *Handler) renderICSFeedList(w http.ResponseWriter, r *http.Request) { + feeds, err := h.store.GetICSFeeds() + if err != nil { + JSONError(w, http.StatusInternalServerError, "Failed to list feeds", err) + return + } + HTMLResponse(w, h.renderer, "ics-feed-list", struct { + Feeds []icsFeedView + }{toICSFeedViews(feeds)}) +} + +// icsFeedView is the feed as the settings template needs it: pre-formatted, so +// the template holds no date or error-shortening logic. +type icsFeedView struct { + ID int64 + Name string + URL string + Enabled bool + LastFetched string + LastError string + EventCount int +} + +func toICSFeedViews(feeds []store.ICSFeed) []icsFeedView { + out := make([]icsFeedView, 0, len(feeds)) + for _, f := range feeds { + lastFetched := "never" + if f.LastFetchedAt != nil { + lastFetched = config.ToDisplayTZ(*f.LastFetchedAt).Format("Jan 2, 3:04 PM") + } + // A raw Go error can be a paragraph of URL and wrapping; the settings + // row needs enough to recognise the problem, not the whole thing. + lastError := f.LastError + if len(lastError) > 160 { + lastError = lastError[:157] + "..." + } + out = append(out, icsFeedView{ + ID: f.ID, + Name: f.Name, + URL: f.URL, + Enabled: f.Enabled, + LastFetched: lastFetched, + LastError: lastError, + EventCount: f.EventCount, + }) + } + return out +} diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go index d140aaf..d6d78c0 100644 --- a/internal/handlers/settings.go +++ b/internal/handlers/settings.go @@ -23,6 +23,7 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { } googleTasksToken, _ := h.store.GetOAuthToken(api.GoogleTasksOAuthSource) + icsFeeds, _ := h.store.GetICSFeeds() data := struct { Configs map[string][]models.SourceConfig @@ -34,6 +35,7 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { GoogleTasksOAuthReady bool GoogleTasksConnected bool GoogleTasksError string + Feeds []icsFeedView }{ Configs: bySource, Sources: []string{"trello", "gcal", "gtasks"}, @@ -43,6 +45,7 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { WebAuthnEnabled: h.WebAuthnEnabled, GoogleTasksOAuthReady: h.googleTasksOAuthConfig != nil, GoogleTasksConnected: googleTasksToken != nil, + Feeds: toICSFeedViews(icsFeeds), GoogleTasksError: r.URL.Query().Get("google_tasks_error"), } diff --git a/internal/store/ics_feeds.go b/internal/store/ics_feeds.go new file mode 100644 index 0000000..6495e13 --- /dev/null +++ b/internal/store/ics_feeds.go @@ -0,0 +1,95 @@ +package store + +import ( + "database/sql" + "time" +) + +// ICSFeed is a subscribed iCalendar feed. +type ICSFeed struct { + ID int64 + Name string + URL string + Enabled bool + LastFetchedAt *time.Time + LastError string + EventCount int + CreatedAt time.Time +} + +// AddICSFeed stores a feed. Re-adding an existing URL updates its name and +// re-enables it rather than failing on the unique constraint — "add it again" +// is the obvious way to try to fix a feed that was turned off. +func (s *Store) AddICSFeed(name, url string) error { + _, err := s.db.Exec(` + INSERT INTO ics_feeds (name, url, enabled) + VALUES (?, ?, 1) + ON CONFLICT(url) DO UPDATE SET name = excluded.name, enabled = 1 + `, name, url) + return err +} + +// GetICSFeeds returns every feed, enabled or not, newest name-order first. +func (s *Store) GetICSFeeds() ([]ICSFeed, error) { + return s.queryICSFeeds(` + SELECT id, name, url, enabled, last_fetched_at, COALESCE(last_error, ''), event_count, created_at + FROM ics_feeds ORDER BY name`) +} + +// GetEnabledICSFeeds returns only the feeds that should be fetched. +func (s *Store) GetEnabledICSFeeds() ([]ICSFeed, error) { + return s.queryICSFeeds(` + SELECT id, name, url, enabled, last_fetched_at, COALESCE(last_error, ''), event_count, created_at + FROM ics_feeds WHERE enabled = 1 ORDER BY name`) +} + +func (s *Store) queryICSFeeds(query string) ([]ICSFeed, error) { + rows, err := s.db.Query(query) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var feeds []ICSFeed + for rows.Next() { + var f ICSFeed + var lastFetched sql.NullTime + if err := rows.Scan(&f.ID, &f.Name, &f.URL, &f.Enabled, &lastFetched, + &f.LastError, &f.EventCount, &f.CreatedAt); err != nil { + return nil, err + } + if lastFetched.Valid { + t := lastFetched.Time + f.LastFetchedAt = &t + } + feeds = append(feeds, f) + } + return feeds, rows.Err() +} + +// SetICSFeedEnabled turns a feed on or off without losing it. +func (s *Store) SetICSFeedEnabled(id int64, enabled bool) error { + _, err := s.db.Exec(`UPDATE ics_feeds SET enabled = ? WHERE id = ?`, enabled, id) + return err +} + +// DeleteICSFeed removes a feed entirely. +func (s *Store) DeleteICSFeed(id int64) error { + _, err := s.db.Exec(`DELETE FROM ics_feeds WHERE id = ?`, id) + return err +} + +// RecordICSFeedResult stores the outcome of a fetch. A successful fetch clears +// any previous error, so a feed that recovers stops being reported as broken. +func (s *Store) RecordICSFeedResult(id int64, eventCount int, fetchErr error) error { + errText := "" + if fetchErr != nil { + errText = fetchErr.Error() + } + _, err := s.db.Exec(` + UPDATE ics_feeds + SET last_fetched_at = CURRENT_TIMESTAMP, last_error = ?, event_count = ? + WHERE id = ? + `, errText, eventCount, id) + return err +} diff --git a/internal/store/ics_feeds_test.go b/internal/store/ics_feeds_test.go new file mode 100644 index 0000000..5b8889c --- /dev/null +++ b/internal/store/ics_feeds_test.go @@ -0,0 +1,144 @@ +package store + +import ( + "database/sql" + "errors" + "path/filepath" + "testing" + + _ "github.com/mattn/go-sqlite3" +) + +func setupICSStore(t *testing.T) *Store { + t.Helper() + db, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "ics.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + db.SetMaxOpenConns(1) + if _, err := db.Exec(` + CREATE TABLE ics_feeds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url TEXT NOT NULL UNIQUE, + enabled INTEGER NOT NULL DEFAULT 1, + last_fetched_at DATETIME, + last_error TEXT, + event_count INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + );`); err != nil { + t.Fatalf("schema: %v", err) + } + return &Store{db: db} +} + +func TestICSFeeds_AddAndList(t *testing.T) { + s := setupICSStore(t) + defer func() { _ = s.Close() }() + + if err := s.AddICSFeed("Seahawks", "https://example.com/sea.ics"); err != nil { + t.Fatalf("add: %v", err) + } + feeds, err := s.GetICSFeeds() + if err != nil { + t.Fatalf("list: %v", err) + } + if len(feeds) != 1 || feeds[0].Name != "Seahawks" || !feeds[0].Enabled { + t.Fatalf("got %+v", feeds) + } + if feeds[0].LastFetchedAt != nil { + t.Error("a feed that has never been fetched should have no timestamp") + } +} + +// Re-adding the same URL should update and re-enable rather than error, since +// "add it again" is the obvious way to try to fix a disabled feed. +func TestICSFeeds_ReAddingUpdatesAndReEnables(t *testing.T) { + s := setupICSStore(t) + defer func() { _ = s.Close() }() + + _ = s.AddICSFeed("Old name", "https://example.com/sea.ics") + feeds, _ := s.GetICSFeeds() + _ = s.SetICSFeedEnabled(feeds[0].ID, false) + + if err := s.AddICSFeed("Seahawks", "https://example.com/sea.ics"); err != nil { + t.Fatalf("re-add: %v", err) + } + feeds, _ = s.GetICSFeeds() + if len(feeds) != 1 { + t.Fatalf("expected 1 feed, got %d", len(feeds)) + } + if feeds[0].Name != "Seahawks" || !feeds[0].Enabled { + t.Errorf("got %+v, want renamed and re-enabled", feeds[0]) + } +} + +func TestICSFeeds_DisabledAreExcludedFromFetchList(t *testing.T) { + s := setupICSStore(t) + defer func() { _ = s.Close() }() + + _ = s.AddICSFeed("On", "https://example.com/on.ics") + _ = s.AddICSFeed("Off", "https://example.com/off.ics") + all, _ := s.GetICSFeeds() + for _, f := range all { + if f.Name == "Off" { + _ = s.SetICSFeedEnabled(f.ID, false) + } + } + + enabled, err := s.GetEnabledICSFeeds() + if err != nil { + t.Fatalf("enabled: %v", err) + } + if len(enabled) != 1 || enabled[0].Name != "On" { + t.Errorf("got %+v, want only the enabled feed", enabled) + } +} + +// A failure must be recorded, and a later success must clear it — otherwise a +// feed that recovers still shows as broken forever. +func TestICSFeeds_RecordResultClearsPreviousError(t *testing.T) { + s := setupICSStore(t) + defer func() { _ = s.Close() }() + + _ = s.AddICSFeed("Seahawks", "https://example.com/sea.ics") + feeds, _ := s.GetICSFeeds() + id := feeds[0].ID + + if err := s.RecordICSFeedResult(id, 0, errors.New("feed returned HTTP 404")); err != nil { + t.Fatalf("record failure: %v", err) + } + feeds, _ = s.GetICSFeeds() + if feeds[0].LastError == "" { + t.Fatal("expected the failure to be recorded") + } + if feeds[0].LastFetchedAt == nil { + t.Error("a failed fetch is still a fetch attempt and should be timestamped") + } + + if err := s.RecordICSFeedResult(id, 21, nil); err != nil { + t.Fatalf("record success: %v", err) + } + feeds, _ = s.GetICSFeeds() + if feeds[0].LastError != "" { + t.Errorf("a successful fetch should clear the error, got %q", feeds[0].LastError) + } + if feeds[0].EventCount != 21 { + t.Errorf("EventCount = %d, want 21", feeds[0].EventCount) + } +} + +func TestICSFeeds_Delete(t *testing.T) { + s := setupICSStore(t) + defer func() { _ = s.Close() }() + + _ = s.AddICSFeed("Seahawks", "https://example.com/sea.ics") + feeds, _ := s.GetICSFeeds() + if err := s.DeleteICSFeed(feeds[0].ID); err != nil { + t.Fatalf("delete: %v", err) + } + feeds, _ = s.GetICSFeeds() + if len(feeds) != 0 { + t.Errorf("got %d feeds after delete", len(feeds)) + } +} -- cgit v1.2.3