summaryrefslogtreecommitdiff
path: root/internal/api
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
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')
-rw-r--r--internal/api/ics.go249
-rw-r--r--internal/api/ics_client.go92
-rw-r--r--internal/api/ics_test.go200
3 files changed, 541 insertions, 0 deletions
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("<html><body>404 Not Found</body></html>")); 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")
+ }
+}