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 "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.
12const 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.
26func 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
42func 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.
116func 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.
144func 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.
162func 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).
194func 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.
198func 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
208func utcStamp(t time.Time) string {
209 return t.UTC().Format("20060102T150405Z")
210}
211
212// compactDate turns "2026-09-02" into "20260902".
213func 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.
228func icsDateTime(local string) string {
229 return compactLocal(local) + "00"
230}
231