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 "strconv"
6 "strings"
7 "time"
8)
9
10// The recurrence subset this app can expand.
11//
12// It is deliberately exactly what a repeat picker offers — daily,
13// weekly on chosen weekdays, monthly on a date or an ordinal weekday,
14// yearly — with an interval and an optional end. That is not a
15// compromise reached by running out of time; it is the set a person can
16// actually express in a form, and RRULE's remainder (BYSETPOS chains,
17// BYYEARDAY, BYWEEKNO, multi-ordinal BYDAY) exists to describe rules
18// that no UI asks for.
19//
20// A rule OUTSIDE the subset is never refused and never dropped. It is
21// stored verbatim, flagged Foreign, shown on the event's page as the
22// raw rule, and its occurrences are not expanded. An importer that ate
23// a rule it did not understand would be an importer that ate data.
24const (
25 FreqDaily = "DAILY"
26 FreqWeekly = "WEEKLY"
27 FreqMonthly = "MONTHLY"
28 FreqYearly = "YEARLY"
29)
30
31// Rule is a parsed RRULE, restricted to the subset above.
32type Rule struct {
33 Freq string
34 Interval int
35
36 // ByDay, for a weekly rule: which weekdays it lands on. Empty means
37 // "the weekday DTSTART falls on", which is the RFC's default.
38 ByDay []time.Weekday
39
40 // For a monthly rule, exactly one of these two shapes:
41 //
42 // BYMONTHDAY=15 → MonthDay = 15
43 // BYDAY=2FR → Ordinal = 2, OrdinalDay = Friday
44 // BYDAY=-1FR → Ordinal = -1, OrdinalDay = Friday
45 MonthDay int
46 Ordinal int
47 OrdinalDay time.Weekday
48
49 // WeekStart is the day weeks are counted from, for INTERVAL on a
50 // weekly rule. The RFC's default is Monday; ParseRule sets it.
51 WeekStart time.Weekday
52
53 // Count and Until are the two ways a series ends; a series may have
54 // neither, and then it is forever.
55 Count int
56 Until time.Time
57 HasUntil bool
58
59 // UntilIsUTC records whether the UNTIL arrived as a UTC instant
60 // (trailing Z) or as a local date. Round-tripping the wrong one
61 // shifts a series' last day by the zone offset.
62 UntilIsUTC bool
63}
64
65// ParseRule reads an RRULE. A rule outside the subset is reported with
66// ErrForeignRule, which callers store rather than reject.
67func ParseRule(s string) (Rule, error) {
68 r := Rule{Interval: 1}
69 r.WeekStart = time.Monday // the RFC default; WKST overrides
70 s = strings.TrimSpace(s)
71 if s == "" {
72 return r, nil
73 }
74 // Some generators include the property name; the RFC's value is
75 // what is stored, so accept both and keep the value.
76 s = strings.TrimPrefix(s, "RRULE:")
77
78 for _, part := range strings.Split(s, ";") {
79 if part == "" {
80 continue
81 }
82 k, v, ok := strings.Cut(part, "=")
83 if !ok {
84 return r, fmt.Errorf("%w: %q is not KEY=VALUE", ErrForeignRule, part)
85 }
86 k = strings.ToUpper(strings.TrimSpace(k))
87 v = strings.TrimSpace(v)
88 switch k {
89 case "FREQ":
90 switch strings.ToUpper(v) {
91 case FreqDaily, FreqWeekly, FreqMonthly, FreqYearly:
92 r.Freq = strings.ToUpper(v)
93 default:
94 return r, fmt.Errorf("%w: FREQ=%s", ErrForeignRule, v)
95 }
96 case "INTERVAL":
97 n, err := strconv.Atoi(v)
98 if err != nil || n < 1 {
99 return r, fmt.Errorf("%w: INTERVAL=%s", ErrForeignRule, v)
100 }
101 r.Interval = n
102 case "COUNT":
103 n, err := strconv.Atoi(v)
104 if err != nil || n < 1 {
105 return r, fmt.Errorf("%w: COUNT=%s", ErrForeignRule, v)
106 }
107 r.Count = n
108 case "UNTIL":
109 t, isUTC, err := parseUntil(v)
110 if err != nil {
111 return r, err
112 }
113 r.Until, r.HasUntil, r.UntilIsUTC = t, true, isUTC
114 case "BYDAY":
115 if err := parseByDay(&r, v); err != nil {
116 return r, err
117 }
118 case "BYMONTHDAY":
119 n, err := strconv.Atoi(v)
120 // Negative month days (-1 = last day) are a real RFC shape
121 // this subset does not carry, so they are foreign rather
122 // than an error.
123 if err != nil || n < 1 || n > 31 {
124 return r, fmt.Errorf("%w: BYMONTHDAY=%s", ErrForeignRule, v)
125 }
126 r.MonthDay = n
127 case "WKST":
128 // Which day a week starts on, for a weekly rule with an
129 // interval: "every second week, Sunday and Monday" from a
130 // Sunday puts that Monday in the NEXT week under the RFC's
131 // Monday default and in the same week under a Sunday start.
132 // A booking page that read it wrong offered the Monday as
133 // free.
134 wd, ok := weekdayFromICS(v)
135 if !ok {
136 return r, fmt.Errorf("%w: WKST=%s", ErrForeignRule, v)
137 }
138 r.WeekStart = wd
139 default:
140 return r, fmt.Errorf("%w: %s", ErrForeignRule, k)
141 }
142 }
143 if r.Freq == "" {
144 return r, fmt.Errorf("%w: no FREQ", ErrForeignRule)
145 }
146 if r.Count > 0 && r.HasUntil {
147 return r, fmt.Errorf("%w: COUNT and UNTIL together", ErrForeignRule)
148 }
149 // Monthly takes one shape or the other, never both.
150 if r.MonthDay > 0 && r.Ordinal != 0 {
151 return r, fmt.Errorf("%w: BYMONTHDAY and an ordinal BYDAY together", ErrForeignRule)
152 }
153 // A COMBINATION the expander cannot run is foreign too. Each part
154 // above is legal on its own; "every Monday of the month" is
155 // FREQ=MONTHLY with a plain BYDAY, and the expander reads a monthly
156 // rule by its day of the month — it would have booked the 7th of
157 // every month over a rule that says every Monday, silently (booking
158 // spec §4.1, r8 f2). Refusing here stores the rule verbatim, shows
159 // its first occurrence, and lets the strict reader fail closed.
160 switch r.Freq {
161 case FreqWeekly:
162 if r.MonthDay > 0 || r.Ordinal != 0 {
163 return r, fmt.Errorf("%w: a monthly shape on a weekly rule", ErrForeignRule)
164 }
165 case FreqMonthly:
166 if len(r.ByDay) > 0 {
167 return r, fmt.Errorf("%w: BYDAY without an ordinal on a monthly rule", ErrForeignRule)
168 }
169 default: // daily, yearly
170 if len(r.ByDay) > 0 || r.MonthDay > 0 || r.Ordinal != 0 {
171 return r, fmt.Errorf("%w: BYDAY or BYMONTHDAY on a %s rule", ErrForeignRule, strings.ToLower(r.Freq))
172 }
173 }
174 return r, nil
175}
176
177// ErrForeignRule marks a rule outside the subset. It is a signal, not a
178// failure: the caller stores the rule verbatim and stops expanding it.
179var ErrForeignRule = fmt.Errorf("calendar: recurrence outside the supported subset")
180
181// IsForeignRule reports whether err says "outside the subset".
182func IsForeignRule(err error) bool {
183 return err != nil && strings.Contains(err.Error(), ErrForeignRule.Error())
184}
185
186func parseByDay(r *Rule, v string) error {
187 parts := strings.Split(v, ",")
188 // An ordinal prefix ("2FR", "-1SU") is the monthly shape and only
189 // ever appears alone here: "the second Friday and the last Tuesday"
190 // is a real rule and not one this subset carries.
191 if len(parts) == 1 {
192 if ord, wd, ok := parseOrdinalDay(parts[0]); ok {
193 r.Ordinal, r.OrdinalDay = ord, wd
194 return nil
195 }
196 }
197 for _, p := range parts {
198 if _, _, ok := parseOrdinalDay(p); ok {
199 return fmt.Errorf("%w: BYDAY=%s has more than one ordinal", ErrForeignRule, v)
200 }
201 wd, ok := weekdayFromICS(p)
202 if !ok {
203 return fmt.Errorf("%w: BYDAY=%s", ErrForeignRule, v)
204 }
205 r.ByDay = append(r.ByDay, wd)
206 }
207 return nil
208}
209
210// parseOrdinalDay reads "2FR" or "-1SU". A bare "FR" is not an ordinal.
211func parseOrdinalDay(s string) (int, time.Weekday, bool) {
212 s = strings.TrimSpace(strings.ToUpper(s))
213 if len(s) < 3 {
214 return 0, 0, false
215 }
216 num, day := s[:len(s)-2], s[len(s)-2:]
217 if num == "" {
218 return 0, 0, false
219 }
220 n, err := strconv.Atoi(num)
221 if err != nil || n == 0 {
222 return 0, 0, false
223 }
224 wd, ok := weekdayFromICS(day)
225 if !ok {
226 return 0, 0, false
227 }
228 return n, wd, true
229}
230
231var icsWeekdays = map[string]time.Weekday{
232 "SU": time.Sunday, "MO": time.Monday, "TU": time.Tuesday,
233 "WE": time.Wednesday, "TH": time.Thursday, "FR": time.Friday,
234 "SA": time.Saturday,
235}
236
237func weekdayFromICS(s string) (time.Weekday, bool) {
238 wd, ok := icsWeekdays[strings.ToUpper(strings.TrimSpace(s))]
239 return wd, ok
240}
241
242// ICSWeekday is the two-letter form, for export.
243func ICSWeekday(wd time.Weekday) string {
244 for k, v := range icsWeekdays {
245 if v == wd {
246 return k
247 }
248 }
249 return ""
250}
251
252// parseUntil reads both forms the RFC allows: a UTC instant
253// ("20261231T235959Z") and a bare date ("20261231").
254//
255// Which one it was matters on the way back out. Re-emitting a local
256// date as a UTC instant, or the reverse, moves a series' last day by
257// the zone's offset — which is invisible until somebody in UTC+13
258// notices their weekly meeting stopped a day early.
259func parseUntil(v string) (time.Time, bool, error) {
260 v = strings.TrimSpace(v)
261 if t, err := time.Parse("20060102T150405Z", v); err == nil {
262 return t, true, nil
263 }
264 if t, err := time.Parse("20060102", v); err == nil {
265 return t, false, nil
266 }
267 return time.Time{}, false, fmt.Errorf("%w: UNTIL=%s", ErrForeignRule, v)
268}
269
270// RuleUntilUTC is the indexed "this series cannot produce anything
271// after here" bound, so a range query can skip masters that ended
272// before the window without expanding them.
273//
274// It reports ok=false for a rule with no UNTIL — including one bounded
275// by COUNT, whose end cannot be known without expanding it. A missing
276// bound costs an expansion; a wrong one loses events.
277func RuleUntilUTC(rrule, startLocal, tz string, allDay bool) (int64, bool) {
278 if strings.TrimSpace(rrule) == "" {
279 return 0, false
280 }
281 r, err := ParseRule(rrule)
282 if err != nil || !r.HasUntil {
283 return 0, false
284 }
285 if r.UntilIsUTC {
286 return r.Until.Unix(), true
287 }
288 // A bare-date UNTIL is local to the event's own zone, and it is
289 // inclusive of that whole day.
290 loc, err := Zone(tz)
291 if err != nil {
292 return 0, false
293 }
294 end := time.Date(r.Until.Year(), r.Until.Month(), r.Until.Day(), 23, 59, 59, 0, loc)
295 return end.Unix(), true
296}
297