| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | // ProdID identifies this app in the files it writes. The RFC requires |
| 10 | // one, and importers use it to work around known generators — so it |
| 11 | // names the app honestly rather than imitating anything. |
| 12 | const ProdID = "-//oficina//calendar//EN" |
| 13 | |
| 14 | // WriteICS renders a calendar's events as an iCalendar stream. |
| 15 | // |
| 16 | // VTIMEZONE is NOT emitted, and this is a stated shortfall rather than |
| 17 | // an oversight. A conforming VTIMEZONE carries the full transition |
| 18 | // history for every zone used, which is a large amount of generated |
| 19 | // text and a real source of subtle error. Instead each DTSTART carries |
| 20 | // its IANA TZID, which Google, Apple and Thunderbird all resolve from |
| 21 | // their own zone databases. |
| 22 | // |
| 23 | // What breaks: Outlook desktop, which historically resolves only the |
| 24 | // zones it finds in the file. The README says so, and the trigger for |
| 25 | // building it is an Outlook import showing a wrong time. |
| 26 | func WriteICS(name string, events []EventView, withAlarms bool) string { |
| 27 | var b strings.Builder |
| 28 | line(&b, "BEGIN:VCALENDAR") |
| 29 | line(&b, "VERSION:2.0") |
| 30 | line(&b, "PRODID:"+ProdID) |
| 31 | line(&b, "CALSCALE:GREGORIAN") |
| 32 | line(&b, "METHOD:PUBLISH") |
| 33 | line(&b, "X-WR-CALNAME:"+escapeText(name)) |
| 34 | |
| 35 | for _, e := range events { |
| 36 | writeEvent(&b, e, withAlarms) |
| 37 | } |
| 38 | line(&b, "END:VCALENDAR") |
| 39 | return b.String() |
| 40 | } |
| 41 | |
| 42 | func writeEvent(b *strings.Builder, e EventView, withAlarms bool) { |
| 43 | line(b, "BEGIN:VEVENT") |
| 44 | |
| 45 | uid := e.UIDSealedOpen |
| 46 | if uid == "" { |
| 47 | // A VEVENT with no UID is one an importer will duplicate on |
| 48 | // every re-import. Derive a stable one rather than omit it. |
| 49 | uid = fmt.Sprintf("%d@calendar.oficina", e.ID) |
| 50 | } |
| 51 | line(b, "UID:"+escapeText(uid)) |
| 52 | line(b, "DTSTAMP:"+utcStamp(e.UpdatedAt)) |
| 53 | line(b, fmt.Sprintf("SEQUENCE:%d", e.Sequence)) |
| 54 | |
| 55 | if e.AllDay { |
| 56 | // A DATE value, never a DATETIME at midnight: the difference is |
| 57 | // what makes a birthday land on the same day everywhere rather |
| 58 | // than shifting west of Greenwich. |
| 59 | line(b, "DTSTART;VALUE=DATE:"+compactDate(e.StartLocal)) |
| 60 | line(b, "DTEND;VALUE=DATE:"+compactDate(e.EndLocal)) |
| 61 | } else if e.TZ == "" { |
| 62 | // Floating: no zone, no Z. Means "this wall time, wherever the |
| 63 | // reader is". Only ever produced from an import that was |
| 64 | // floating to begin with. |
| 65 | line(b, "DTSTART:"+icsDateTime(e.StartLocal)) |
| 66 | line(b, "DTEND:"+icsDateTime(e.EndLocal)) |
| 67 | } else { |
| 68 | line(b, "DTSTART;TZID="+e.TZ+":"+icsDateTime(e.StartLocal)) |
| 69 | line(b, "DTEND;TZID="+e.TZ+":"+icsDateTime(e.EndLocal)) |
| 70 | } |
| 71 | |
| 72 | line(b, "SUMMARY:"+escapeText(e.Title)) |
| 73 | if e.Location != "" { |
| 74 | line(b, "LOCATION:"+escapeText(e.Location)) |
| 75 | } |
| 76 | if e.Notes != "" { |
| 77 | line(b, "DESCRIPTION:"+escapeText(e.Notes)) |
| 78 | } |
| 79 | // Attendees, so a calendar that DOES send invitations can. This app |
| 80 | // never has: PARTSTAT is NEEDS-ACTION and RSVP is FALSE because |
| 81 | // nobody was asked, and claiming otherwise would put a lie in a file |
| 82 | // somebody else's software will believe. |
| 83 | for _, a := range e.Attendees { |
| 84 | line(b, "ATTENDEE;PARTSTAT=NEEDS-ACTION;RSVP=FALSE:mailto:"+escapeText(a)) |
| 85 | } |
| 86 | if e.RRule != "" { |
| 87 | line(b, "RRULE:"+e.RRule) |
| 88 | } |
| 89 | if e.Exdates != "" { |
| 90 | writeExdates(b, e) |
| 91 | } |
| 92 | if e.SeriesID != nil && e.RecurrenceIDLocal != "" { |
| 93 | if e.AllDay { |
| 94 | line(b, "RECURRENCE-ID;VALUE=DATE:"+compactDate(e.RecurrenceIDLocal)) |
| 95 | } else if e.TZ != "" { |
| 96 | line(b, "RECURRENCE-ID;TZID="+e.TZ+":"+icsDateTime(e.RecurrenceIDLocal)) |
| 97 | } else { |
| 98 | line(b, "RECURRENCE-ID:"+icsDateTime(e.RecurrenceIDLocal)) |
| 99 | } |
| 100 | } |
| 101 | line(b, "STATUS:"+strings.ToUpper(e.Status)) |
| 102 | |
| 103 | // The app's own kind has no RFC equivalent, so it rides as an |
| 104 | // X- property: a client that does not know it ignores it, and a |
| 105 | // re-import into this app gets it back. |
| 106 | line(b, "X-OFICINA-KIND:"+e.Kind) |
| 107 | |
| 108 | if withAlarms { |
| 109 | writeAlarm(b, e) |
| 110 | } |
| 111 | line(b, "END:VEVENT") |
| 112 | } |
| 113 | |
| 114 | // writeExdates emits the excluded occurrences, grouped so the property |
| 115 | // appears once rather than per date. |
| 116 | func writeExdates(b *strings.Builder, e EventView) { |
| 117 | var dates []string |
| 118 | for _, d := range strings.Split(e.Exdates, ",") { |
| 119 | if d = strings.TrimSpace(d); d == "" { |
| 120 | continue |
| 121 | } |
| 122 | if e.AllDay { |
| 123 | dates = append(dates, compactDate(d)) |
| 124 | } else { |
| 125 | dates = append(dates, icsDateTime(d)) |
| 126 | } |
| 127 | } |
| 128 | if len(dates) == 0 { |
| 129 | return |
| 130 | } |
| 131 | switch { |
| 132 | case e.AllDay: |
| 133 | line(b, "EXDATE;VALUE=DATE:"+strings.Join(dates, ",")) |
| 134 | case e.TZ != "": |
| 135 | line(b, "EXDATE;TZID="+e.TZ+":"+strings.Join(dates, ",")) |
| 136 | default: |
| 137 | line(b, "EXDATE:"+strings.Join(dates, ",")) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // writeAlarm emits a display alarm, for subscribers that want the feed |
| 142 | // to carry reminders. Off by default: a subscribed calendar that starts |
| 143 | // making somebody's phone buzz without being asked is a bad surprise. |
| 144 | func writeAlarm(b *strings.Builder, e EventView) { |
| 145 | if e.ReminderOffsetMin == 0 { |
| 146 | return |
| 147 | } |
| 148 | line(b, "BEGIN:VALARM") |
| 149 | line(b, "ACTION:DISPLAY") |
| 150 | line(b, "DESCRIPTION:"+escapeText(e.Title)) |
| 151 | line(b, fmt.Sprintf("TRIGGER:-PT%dM", e.ReminderOffsetMin)) |
| 152 | line(b, "END:VALARM") |
| 153 | } |
| 154 | |
| 155 | // line writes one content line, folded. |
| 156 | // |
| 157 | // The RFC caps a line at 75 OCTETS, not characters, and continues it |
| 158 | // with CRLF plus one space. Folding on characters would split a |
| 159 | // multi-byte rune across the fold and produce a file that is not valid |
| 160 | // UTF-8 — which importers reject in ways that look like a corrupt |
| 161 | // calendar rather than a bad fold. |
| 162 | func line(b *strings.Builder, s string) { |
| 163 | const limit = 75 |
| 164 | if len(s) <= limit { |
| 165 | b.WriteString(s) |
| 166 | b.WriteString("\r\n") |
| 167 | return |
| 168 | } |
| 169 | written := 0 |
| 170 | for i := 0; i < len(s); { |
| 171 | // Never split inside a rune: advance to a rune boundary. |
| 172 | end := i + limit |
| 173 | if written > 0 { |
| 174 | end = i + limit - 1 // the leading space costs one octet |
| 175 | } |
| 176 | if end > len(s) { |
| 177 | end = len(s) |
| 178 | } |
| 179 | for end > i && end < len(s) && !utf8Boundary(s[end]) { |
| 180 | end-- |
| 181 | } |
| 182 | if written > 0 { |
| 183 | b.WriteString(" ") |
| 184 | } |
| 185 | b.WriteString(s[i:end]) |
| 186 | b.WriteString("\r\n") |
| 187 | i = end |
| 188 | written++ |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | // utf8Boundary reports whether b starts a rune (i.e. is not a |
| 193 | // continuation byte). |
| 194 | func utf8Boundary(b byte) bool { return b&0xC0 != 0x80 } |
| 195 | |
| 196 | // escapeText applies the RFC's TEXT escaping. The order matters: |
| 197 | // backslash first, or the escapes this adds get escaped again. |
| 198 | func escapeText(s string) string { |
| 199 | s = strings.ReplaceAll(s, `\`, `\\`) |
| 200 | s = strings.ReplaceAll(s, ";", `\;`) |
| 201 | s = strings.ReplaceAll(s, ",", `\,`) |
| 202 | s = strings.ReplaceAll(s, "\r\n", `\n`) |
| 203 | s = strings.ReplaceAll(s, "\n", `\n`) |
| 204 | s = strings.ReplaceAll(s, "\r", `\n`) |
| 205 | return s |
| 206 | } |
| 207 | |
| 208 | func utcStamp(t time.Time) string { |
| 209 | return t.UTC().Format("20060102T150405Z") |
| 210 | } |
| 211 | |
| 212 | // compactDate turns "2026-09-02" into "20260902". |
| 213 | func compactDate(local string) string { |
| 214 | return strings.ReplaceAll(local, "-", "") |
| 215 | } |
| 216 | |
| 217 | // icsDateTime turns "2026-09-02T09:30" into "20260902T093000". |
| 218 | // |
| 219 | // The seconds are not decoration: the RFC's DATE-TIME is |
| 220 | // YYYYMMDDTHHMMSS, and a value without them is not a DATE-TIME. This |
| 221 | // app stores no seconds, so they are always "00" — but emitting the |
| 222 | // shorter form produced files that this app's OWN parser rejected, |
| 223 | // which is how the round-trip test found it. |
| 224 | // |
| 225 | // Deliberately not compactLocal, which is the URL form: an occurrence |
| 226 | // key in a path wants the short spelling, and sharing one function |
| 227 | // between a URL segment and a wire format is what let them disagree. |
| 228 | func icsDateTime(local string) string { |
| 229 | return compactLocal(local) + "00" |
| 230 | } |
| 231 | |