summaryrefslogtreecommitdiff
path: root/internal/api/ics_client.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-09-20 07:57:36 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-09-20 07:57:36 +0000
commit4d160762aeef531208f8cf6094a0e58a9e7d53ec (patch)
tree79584994808a38a2518ad986dc739daf720d3d77 /internal/api/ics_client.go
parentc5ab2f9a7f6a0b1e456e13934268d1509504c55a (diff)
Add .ics calendar feed support
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'internal/api/ics_client.go')
-rw-r--r--internal/api/ics_client.go92
1 files changed, 92 insertions, 0 deletions
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
+ }
+}