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() }