| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 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. |
| 14 | const 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". |
| 19 | type 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. |
| 51 | func 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. |
| 121 | type 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 | |
| 139 | func (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. |
| 178 | func (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. |
| 252 | func (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 | |