oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
4 "bufio"
5 "fmt"
6 "io"
7 "strings"
8 "time"
9)
10
11// MaxImportBytes bounds an upload. Ten megabytes is far more than a
12// decade of one person's calendar and far less than enough to exhaust
13// the instance.
14const MaxImportBytes = 10 << 20
15
16// ImportResult is what the import page reports. Every number it carries
17// is one the person can act on, and the dropped ones are named rather
18// than summed into a single "some events could not be imported".
19type ImportResult struct {
20 Created int
21 Updated int
22 Skipped int
23
24 // Floating counts events that arrived with no zone. They are kept,
25 // because refusing them loses data, but they are counted because a
26 // floating event on a shared calendar means different instants for
27 // different readers.
28 Floating int
29
30 // ForeignRules counts recurrences stored verbatim but not expanded.
31 ForeignRules int
32
33 // Attendees counts ATTENDEE lines dropped, because this app does
34 // not model guests yet. Reported rather than silently discarded:
35 // somebody importing a calendar full of invitations should know
36 // that the invitations did not come with it.
37 Attendees int
38
39 // UnknownZones names TZIDs that could not be resolved. Named, not
40 // counted, because the fix is per-zone.
41 UnknownZones []string
42}
43
44// ParseICS reads a stream into event inputs.
45//
46// Nothing here rejects a file. An importer's job is to get as much of
47// somebody's calendar in as it honestly can and to say plainly what it
48// could not — a parser that refuses a whole file over one malformed
49// line is a parser that loses a decade of someone's history to a typo
50// from a client they no longer use.
51func ParseICS(r io.Reader, defaultZone string) ([]EventInput, ImportResult, error) {
52 var (
53 out []EventInput
54 res ImportResult
55 cur *icsEvent
56 zones = map[string]bool{}
57 inVEV bool
58 inOther int
59 )
60
61 sc := bufio.NewScanner(io.LimitReader(r, MaxImportBytes))
62 sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
63
64 var pending string
65 flush := func() {
66 if pending == "" {
67 return
68 }
69 name, params, value := splitLine(pending)
70 pending = ""
71
72 switch {
73 case name == "BEGIN" && value == "VEVENT":
74 inVEV, cur = true, &icsEvent{}
75 case name == "BEGIN":
76 // VTIMEZONE, VALARM, VTODO: skipped wholesale. Their
77 // properties must not land on the event being built.
78 if inVEV {
79 inOther++
80 }
81 case name == "END" && value == "VEVENT":
82 if cur != nil {
83 in, ev := cur.build(defaultZone, zones, &res)
84 if ev {
85 out = append(out, in)
86 }
87 }
88 inVEV, cur = false, nil
89 case name == "END":
90 if inOther > 0 {
91 inOther--
92 }
93 case inVEV && inOther == 0 && cur != nil:
94 cur.set(name, params, value, &res)
95 }
96 }
97
98 for sc.Scan() {
99 raw := sc.Text()
100 // A line beginning with a space or tab continues the previous
101 // one. Unfolding must happen before anything is parsed, or a
102 // long SUMMARY arrives truncated at 75 octets.
103 if strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t") {
104 pending += strings.TrimRight(raw[1:], "\r")
105 continue
106 }
107 flush()
108 pending = strings.TrimRight(raw, "\r")
109 }
110 flush()
111 if err := sc.Err(); err != nil {
112 return nil, res, fmt.Errorf("calendar: read ics: %w", err)
113 }
114 for z := range zones {
115 res.UnknownZones = append(res.UnknownZones, z)
116 }
117 return out, res, nil
118}
119
120// icsEvent accumulates one VEVENT's properties.
121type icsEvent struct {
122 uid string
123 summary string
124 location string
125 notes string
126 rrule string
127 exdates []string
128 status string
129 kind string
130
131 start, end string
132 startTZ string
133 allDay bool
134 hasStart bool
135 hasEnd bool
136 durationMins int
137}
138
139func (e *icsEvent) set(name, params, value string, res *ImportResult) {
140 switch name {
141 case "UID":
142 e.uid = unescapeText(value)
143 case "SUMMARY":
144 e.summary = unescapeText(value)
145 case "LOCATION":
146 e.location = unescapeText(value)
147 case "DESCRIPTION":
148 e.notes = unescapeText(value)
149 case "RRULE":
150 e.rrule = value
151 case "STATUS":
152 e.status = strings.ToLower(value)
153 case "X-OFICINA-KIND":
154 e.kind = strings.ToLower(value)
155 case "ATTENDEE":
156 res.Attendees++
157 case "EXDATE":
158 for _, d := range strings.Split(value, ",") {
159 if local, _, ok := parseICSTime(strings.TrimSpace(d), params); ok {
160 e.exdates = append(e.exdates, local)
161 }
162 }
163 case "DTSTART":
164 if local, allDay, ok := parseICSTime(value, params); ok {
165 e.start, e.allDay, e.hasStart = local, allDay, true
166 e.startTZ = tzidOf(params)
167 }
168 case "DTEND":
169 if local, _, ok := parseICSTime(value, params); ok {
170 e.end, e.hasEnd = local, true
171 }
172 case "DURATION":
173 e.durationMins = parseDurationMinutes(value)
174 }
175}
176
177// build turns the accumulated properties into an input.
178func (e *icsEvent) build(defaultZone string, zones map[string]bool, res *ImportResult) (EventInput, bool) {
179 if !e.hasStart || e.summary == "" {
180 res.Skipped++
181 return EventInput{}, false
182 }
183
184 zone := e.startTZ
185 switch {
186 case e.allDay:
187 zone = ""
188 case zone == "":
189 // Floating. Kept as floating rather than assumed into the
190 // importer's zone: guessing is how an event silently moves.
191 res.Floating++
192 default:
193 if _, err := Zone(zone); err != nil {
194 // An unresolvable TZID — a Windows zone name, usually.
195 // Fall back to the importing person's own zone and say so,
196 // rather than dropping the event.
197 zones[zone] = true
198 zone = defaultZone
199 }
200 }
201
202 end := e.end
203 if !e.hasEnd {
204 end = e.impliedEnd(zone)
205 }
206
207 if e.rrule != "" {
208 if _, err := ParseRule(e.rrule); err != nil {
209 res.ForeignRules++
210 }
211 }
212
213 kind := e.kind
214 switch kind {
215 case KindMeeting, KindCommitment, KindNote:
216 default:
217 // An imported event with no opinion is a meeting if it is
218 // timed and a note if it is all day — which is what a birthday
219 // or a public holiday actually is.
220 if e.allDay {
221 kind = KindNote
222 } else {
223 kind = KindMeeting
224 }
225 }
226
227 status := e.status
228 switch status {
229 case StatusConfirmed, StatusTentative, StatusCancelled:
230 default:
231 status = StatusConfirmed
232 }
233
234 return EventInput{
235 Title: e.summary,
236 Location: e.location,
237 Notes: e.notes,
238 AllDay: e.allDay,
239 StartLocal: e.start,
240 EndLocal: end,
241 TZ: zone,
242 RRule: e.rrule,
243 Exdates: strings.Join(e.exdates, ","),
244 Kind: kind,
245 Status: status,
246 }, true
247}
248
249// impliedEnd applies the RFC's defaults for a VEVENT with no DTEND: a
250// DURATION if one was given, otherwise one day for a DATE start and
251// zero length for a DATETIME one.
252func (e *icsEvent) impliedEnd(zone string) string {
253 loc, err := Zone(zone)
254 if err != nil {
255 loc = time.UTC
256 }
257 t, _, err := ParseLocal(e.start, loc)
258 if err != nil {
259 return e.start
260 }
261 switch {
262 case e.durationMins > 0:
263 return FormatLocal(t.Add(time.Duration(e.durationMins)*time.Minute), e.allDay)
264 case e.allDay:
265 return FormatLocal(t.AddDate(0, 0, 1), true)
266 default:
267 return e.start
268 }
269}
270