| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 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. |
| 24 | const ( |
| 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. |
| 32 | type 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. |
| 67 | func 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. |
| 179 | var ErrForeignRule = fmt.Errorf("calendar: recurrence outside the supported subset") |
| 180 | |
| 181 | // IsForeignRule reports whether err says "outside the subset". |
| 182 | func IsForeignRule(err error) bool { |
| 183 | return err != nil && strings.Contains(err.Error(), ErrForeignRule.Error()) |
| 184 | } |
| 185 | |
| 186 | func 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. |
| 211 | func 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 | |
| 231 | var 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 | |
| 237 | func 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. |
| 243 | func 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. |
| 259 | func 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. |
| 277 | func 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 | |