summaryrefslogtreecommitdiff
path: root/internal/api/ics.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api/ics.go')
-rw-r--r--internal/api/ics.go249
1 files changed, 249 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()
+}