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_client.go | 92 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 internal/api/ics_client.go (limited to 'internal/api/ics_client.go') 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 + } +} -- cgit v1.2.3