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