summaryrefslogtreecommitdiff
path: root/internal/api/ics.go
blob: 925efc733ce70c34805c6391bd0689c27de7f30d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
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()
}