slotlock 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +582 -0
  2. package/CONTRIBUTING.md +35 -8
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +498 -65
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +62 -10
  10. package/agent-server.js +577 -105
  11. package/agent-store-backend.d.ts +12 -0
  12. package/agent-store-backend.js +1115 -55
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +35 -1
  16. package/application-api.js +683 -12
  17. package/booking-rules.d.ts +84 -0
  18. package/booking-rules.js +149 -0
  19. package/config.d.ts +112 -0
  20. package/config.js +225 -0
  21. package/dashboard.d.ts +67 -5
  22. package/dashboard.js +1012 -140
  23. package/ddl.d.ts +45 -3
  24. package/ddl.js +1124 -13
  25. package/engine.d.ts +5 -0
  26. package/engine.js +23 -6
  27. package/event-feed.d.ts +178 -0
  28. package/event-feed.js +435 -0
  29. package/event-schema.d.ts +59 -0
  30. package/event-schema.js +85 -0
  31. package/ical-worker-thread.js +4 -2
  32. package/ical-worker.d.ts +8 -5
  33. package/ical-worker.js +9 -2
  34. package/index.d.ts +12 -6
  35. package/index.js +9 -4
  36. package/interop.d.ts +117 -0
  37. package/interop.js +356 -0
  38. package/maintenance.d.ts +27 -5
  39. package/maintenance.js +92 -28
  40. package/mcp-app.d.ts +7 -1
  41. package/mcp-app.js +259 -40
  42. package/metrics.d.ts +24 -0
  43. package/metrics.js +167 -0
  44. package/node-server.d.ts +9 -1
  45. package/node-server.js +7 -2
  46. package/openapi.json +10970 -0
  47. package/package.json +3 -2
  48. package/rate-limit.d.ts +80 -0
  49. package/rate-limit.js +741 -0
  50. package/rest-api.d.ts +22 -0
  51. package/rest-api.js +166 -11
  52. package/rules.js +6 -3
  53. package/self-host.d.ts +45 -8
  54. package/self-host.js +616 -46
  55. package/slotlock-manifest.schema.json +7 -1
  56. package/slotlock.manifest.json +8 -2
  57. package/store.d.ts +647 -28
  58. package/store.js +2982 -327
  59. package/sync.d.ts +43 -2
  60. package/sync.js +1515 -323
  61. package/timezone.d.ts +58 -3
  62. package/timezone.js +249 -20
  63. package/types.d.ts +300 -9
  64. package/types.js +0 -2
  65. package/webhooks.d.ts +91 -0
  66. package/webhooks.js +300 -0
package/sync.js CHANGED
@@ -1,13 +1,15 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import ICAL from 'ical.js';
3
- import { createTimeZoneConverter, utcFrame, zonedDateTimeFields, zonedDateTimeToUtc, } from './timezone.js';
3
+ import { canonicalTimeZoneName, createTimeZoneConverter, utcFrame, wallFrameToInstant, ZONE_OFFSET_REACH_MS, zonedDateTimeFields, zonedDateTimeToUtc, zonedOffsetMinutes, zoneOffsetChangeReads, zoneOffsetDay, zoneTransitions, } from './timezone.js';
4
4
  /** The most text one feed may be, in UTF-8 bytes. */
5
5
  export const MAX_ICAL_INPUT_BYTES = 2_000_000;
6
6
  const MAX_ICAL_OCCURRENCES = 2_000;
7
7
  const MAX_ICAL_COMPONENTS = 2_000;
8
8
  const MAX_ICAL_EXCEPTIONS_PER_SERIES = 1_000;
9
9
  const MAX_ICAL_WORK = 4_000;
10
- const MAX_ICAL_WINDOW_MS = 367 * 24 * 60 * 60 * 1000;
10
+ // The store materializes a recurring series for up to 369 days (SLOTLOCK_MATERIALIZATION_WINDOW_DAYS:
11
+ // a 367-day search from any moment of the day, plus the day before it).
12
+ const MAX_ICAL_WINDOW_MS = 369 * 24 * 60 * 60 * 1000;
11
13
  /**
12
14
  * A feed's own time zones (VTIMEZONE), their STANDARD and DAYLIGHT observances, and the RDATE values
13
15
  * those list. A real feed carries one zone per zone its events use, each a few observances (a zone's
@@ -28,47 +30,42 @@ const MAX_ICAL_PROPERTY_PARAMETERS = 100;
28
30
  const MAX_ICAL_TIMEZONE_OBSERVANCES = 1_000;
29
31
  const MAX_ICAL_TIMEZONE_DATES = 2_000;
30
32
  /**
31
- * Steps ical.js may take, in one parse, looking for occurrences. RecurIterator.next() searches for a
32
- * SECONDLY to WEEKLY rule's next occurrence one step (second, minute, hour, day or week) at a time
33
- * with no limit, and gives up on a MONTHLY or YEARLY one only when whole months or years fail (not
34
- * when one matches a part the rule then rules out), so a rule that never occurs
35
- * (`FREQ=DAILY;BYMONTH=2;BYMONTHDAY=30`, `FREQ=DAILY;INTERVAL=7;BYDAY=TU` from a Monday, or
36
- * `FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=30;BYDAY=MO`) held the whole process, and one that almost never
37
- * does held it for minutes. A real rule takes one to five steps an occurrence (a yearly date written
38
- * as a daily rule, 365), and MAX_ICAL_WORK bounds the occurrences.
39
- */
40
- const MAX_ICAL_STEPS = 50_000;
41
- /**
42
- * And the time a whole parse may take, from before ical.js reads the text to the last occurrence: a
43
- * step costs from a microsecond (a daily rule) to a millisecond (a WEEKLY rule with a huge INTERVAL,
44
- * which ical.js walks a day at a time), so a count alone does not bound it, and ical.js does work no
45
- * step is charged for (relating an exception for every later occurrence reads its time, and so its
46
- * zone, as the event is built). A busy feed within MAX_ICAL_WORK takes about a fifth of a second
47
- * (thirty fifth-Monday series since 2000); one stored series' window, a few tens of milliseconds.
48
- */
49
- const MAX_ICAL_EXPANSION_MS = 1_000;
33
+ * Work ical.js may do, in one parse, looking for occurrences, in steps of about a microsecond each.
34
+ * RecurIterator.next() searches for a SECONDLY to WEEKLY rule's next occurrence one step (second,
35
+ * minute, hour, day or week) at a time with no limit, and gives up on a MONTHLY or YEARLY one only
36
+ * when whole months or years fail (not when one matches a part the rule then rules out), so a rule
37
+ * that never occurs (`FREQ=DAILY;BYMONTH=2;BYMONTHDAY=30`, `FREQ=DAILY;INTERVAL=7;BYDAY=TU` from a
38
+ * Monday) held the whole process, and one that almost never does held it for minutes. Only this
39
+ * count decides, never a clock, so a calendar is read or refused the same way however loaded the
40
+ * machine (ICAL_STEP_CHARGES says what each piece of work costs).
41
+ *
42
+ * A feed, and a stored series as maintenance rolls it, may take half a million: thirty series on the
43
+ * fifth Monday of the month since 2000 take about 350,000.
44
+ */
45
+ const MAX_ICAL_STEPS = 500_000;
50
46
  /** A feed, and a stored series as maintenance rolls it. */
51
- const FEED_LIMITS = { steps: MAX_ICAL_STEPS, ms: MAX_ICAL_EXPANSION_MS };
47
+ const FEED_LIMITS = { steps: MAX_ICAL_STEPS };
52
48
  /**
53
- * One event as it is written or asked about (expandCalendarEventOccurrences): half of a feed's, so
54
- * a DAILY or rarer series written today still fits when the roll searches it again from its start
55
- * for decades (that search grows about a step a day as the window moves; the roll resumes from a
56
- * cursor instead whenever it has one), and an ordinary series, a few milliseconds, stays far from
57
- * the time limit however loaded the machine.
49
+ * One event as it is written or asked about (expandCalendarEventOccurrences): a tenth of a feed's,
50
+ * so a write is refused within a tenth of a second whatever its rule or zone (fifty writes of one
51
+ * that walks 9,999 days a step held a hosted server for 23 seconds when a write could search for
52
+ * half of one), and a series written today still fits when the roll searches it again from its
53
+ * start for decades (that search grows as the window moves; the roll resumes from a cursor instead
54
+ * whenever it has one). A yearly date written as a daily rule fits from 1990, and from 1950 does
55
+ * not, but rolls.
58
56
  */
59
- const EVENT_LIMITS = {
60
- steps: MAX_ICAL_STEPS / 2,
61
- ms: MAX_ICAL_EXPANSION_MS / 2,
62
- };
57
+ const EVENT_LIMITS = { steps: 50_000 };
63
58
  /** RDATE and EXDATE values one event may list: ical.js sorts them in as it reads them. */
64
59
  const MAX_ICAL_DATES_PER_EVENT = MAX_ICAL_EXCEPTIONS_PER_SERIES;
60
+ /** RRULEs one event may list: every step of its expansion compares each one's next time. */
61
+ const MAX_ICAL_RULES_PER_EVENT = 8;
65
62
  /** And a whole feed: each value is a time ical.js resolves, its zone looked up among the feed's. */
66
63
  const MAX_ICAL_DATES_PER_FEED = 20 * MAX_ICAL_DATES_PER_EVENT;
67
64
  /**
68
65
  * The furthest one step of a SECONDLY to WEEKLY rule may move, in days: about a century. ical.js
69
- * moves it a day at a time (RecurIterator#increment_monthday) inside one step, which is charged
70
- * once: `FREQ=DAILY;INTERVAL=1000000000` spent 97 seconds in its second step. A century is 36,600
71
- * days, about 4 ms.
66
+ * moves it a day at a time (RecurIterator#increment_monthday) inside one step:
67
+ * `FREQ=DAILY;INTERVAL=1000000000` spent 97 seconds in its second step. The days are charged
68
+ * (ICAL_STEP_CHARGES), and a century is 36,600 days, about 7 ms.
72
69
  */
73
70
  const MAX_ICAL_STEP_DAYS = 36_600;
74
71
  const MAX_ICAL_INTERVAL = {
@@ -79,12 +76,62 @@ const MAX_ICAL_INTERVAL = {
79
76
  WEEKLY: Math.floor(MAX_ICAL_STEP_DAYS / 7),
80
77
  };
81
78
  /**
82
- * The RecurIterator methods a step is charged on. Every step of a search, whatever the frequency,
83
- * ends in exactly one `next_second` (`next_year`, `next_month`, `next_week` and `next_day` each call
84
- * `next_hour`, which calls `next_minute`, which calls `next_second`); `expand_year_days` is a YEARLY
85
- * rule's scan of one year for matching days, which the constructor repeats as far as the year 20000.
79
+ * The RecurIterator methods work is charged on, and what each call costs, in steps of about a
80
+ * microsecond (measured on ical.js 2.2.1 under Node 26, with the costliest rule shapes each method
81
+ * serves). Every step of a search, whatever the frequency, ends in exactly one `next_second`
82
+ * (`next_year`, `next_month`, `next_week` and `next_day` each call `next_hour`, which calls
83
+ * `next_minute`, which calls `next_second`), and is followed by a check of the rule's parts: about
84
+ * three microseconds for a DAILY rule with BYMONTH and BYMONTHDAY. `ruleDayOfWeek` reads one BYDAY value, which the
85
+ * searches of a month and a week and the checks of a day do for every day and every value they
86
+ * compare. `increment_monthday` walks its days one at a time, about a fifth of a microsecond each on a
87
+ * 4-vCPU Linux runner under Node 22 (a tenth on the machine the rest was measured on).
88
+ * `expand_year_days` lists the days of one year a YEARLY rule names, which the constructor repeats
89
+ * as far as the year 20000: about three microseconds, and an eighth of one for each day it lists
90
+ * and sorts (`BYMONTH` with 28 `BYMONTHDAY` values, 336 days, about 42). The days of the week
91
+ * through a whole year, which it filters by month and day of the month, it gets from
92
+ * `expand_by_day`: about half a microsecond for each day that lists (all seven weekdays filtered
93
+ * to the 13th, 365 days, about 150). A scan of every day of a month for `BYSETPOS` is charged
94
+ * through `ruleDayOfWeek`, as a day's check is. Outlook writes every zone's observances from 1601
95
+ * as `BYMONTH` with one numbered `BYDAY`, so one zone's 430 years cost about 6,000 steps.
96
+ */
97
+ const ICAL_STEP_CHARGES = {
98
+ next_second: { before: () => 3 },
99
+ ruleDayOfWeek: { before: () => 1 },
100
+ increment_monthday: {
101
+ before: (days) => (typeof days === 'number' && days > 0 ? days / 5 : 1),
102
+ },
103
+ expand_year_days: {
104
+ before: () => 3,
105
+ after: (iterator) => listedDays(iterator.days) / 8,
106
+ },
107
+ expand_by_day: { before: () => 0, after: (_iterator, days) => listedDays(days) / 2 },
108
+ };
109
+ /**
110
+ * What Slotlock's own work on what ical.js finds costs, in the same steps: each occurrence an
111
+ * expansion passes (its instant, exceptions and change); each conversion of a time between a zone's
112
+ * wall clock and its instants, half a step in an IANA zone (a few lookups in the offsets timezone.ts
113
+ * keeps a day at a time) and one and a half in a zone only the feed defines (ical.js's list of its
114
+ * changes, searched at each lookup, as ical.js itself searches it at each step); and each instant
115
+ * Intl is asked for to read a day of an IANA zone's offsets (two and a half microseconds on the
116
+ * machine the rest was measured on, and with reading its parts as long again on a 4-vCPU Linux
117
+ * runner, five steps), the first time a budget reads that day. Before these were charged, a series in a named zone ran
118
+ * twenty times as long as the same series in UTC within the same budget.
119
+ */
120
+ const OCCURRENCE_STEPS = 1;
121
+ /** Each further RRULE whose next time an expansion step compares, choosing among them. */
122
+ const RULE_SCAN_STEPS = 1;
123
+ const ZONE_READ_STEPS = { conversion: 0.5, ownZone: 1.5, intl: 5 };
124
+ /**
125
+ * Each change ical.js lists when it expands one of the feed's own zones (Timezone#_ensureCoverage):
126
+ * it adjusts and sorts every one, about four tenths of a microsecond each, and the steps of the
127
+ * zone's rules are charged besides. Uncharged, a zone at every cap read again for 1,000 later years
128
+ * listed 3 million changes in one call, two seconds on a 4-vCPU Linux runner within the budget.
86
129
  */
87
- const ICAL_STEP_METHODS = ['next_second', 'expand_year_days'];
130
+ const ZONE_CHANGE_STEPS = 0.4;
131
+ /** How many days a list holds, or a whole year's when ical.js no longer gives one. */
132
+ function listedDays(days) {
133
+ return Array.isArray(days) ? days.length : 366;
134
+ }
88
135
  /**
89
136
  * The ical.js release a series cursor holds the iterator state of. A cursor is ical.js's own
90
137
  * RecurIterator and RecurExpansion state, which only that release is known to continue exactly, so
@@ -143,7 +190,9 @@ function expansionLimit() {
143
190
  }
144
191
  function requiredIdentity(value) {
145
192
  const normalized = value.trim();
146
- if (!normalized || Buffer.byteLength(normalized, 'utf8') > 1024) {
193
+ if (!normalized ||
194
+ Buffer.byteLength(normalized, 'utf8') > 1024 ||
195
+ /[\u0000-\u001f\u007f]/.test(normalized)) {
147
196
  throw new CalendarContractError('invalid_external_identity');
148
197
  }
149
198
  return normalized;
@@ -155,30 +204,43 @@ function optionalIdentity(value) {
155
204
  }
156
205
  function instant(value) {
157
206
  const parsed = value instanceof Date ? new Date(value.getTime()) : new Date(value);
158
- if (!Number.isFinite(parsed.getTime())) {
207
+ // Years 1-9999: what an RFC 3339 date-time, as the events are returned, can write.
208
+ const year = parsed.getUTCFullYear();
209
+ if (!Number.isFinite(parsed.getTime()) || year < 1 || year > 9999) {
159
210
  throw new CalendarContractError('invalid_external_interval');
160
211
  }
161
212
  return parsed;
162
213
  }
214
+ /**
215
+ * Text as an event keeps it: trimmed, its line breaks LF. A tab or LF is kept (ical.js escapes LF in
216
+ * a value and a parameter), and any other control character refused: ical.js leaves a bare CR as it
217
+ * is, and a reader that ends lines at one would read what follows it as a property of its own.
218
+ */
163
219
  function boundedText(value, maxBytes, required) {
164
- const normalized = value.trim();
165
- if ((required && !normalized) || Buffer.byteLength(normalized, 'utf8') > maxBytes) {
220
+ const normalized = value.replace(/\r\n/g, '\n').trim();
221
+ if ((required && !normalized) ||
222
+ Buffer.byteLength(normalized, 'utf8') > maxBytes ||
223
+ /[\u0000-\u0008\u000b-\u001f\u007f]/.test(normalized)) {
166
224
  throw new CalendarContractError('invalid_icalendar');
167
225
  }
168
226
  return normalized || undefined;
169
227
  }
228
+ /**
229
+ * An event's zone as Slotlock keeps it: an IANA name in one spelling (canonicalTimeZoneName). A UTC
230
+ * offset such as `+05:00` is no zone a calendar can name (RFC 5545 §3.2.19 wants a VTIMEZONE for
231
+ * each TZID, and an offset has no rules), and is refused.
232
+ */
170
233
  function validTimezone(timezone) {
171
234
  const normalized = timezone.trim();
172
235
  if (!normalized || Buffer.byteLength(normalized, 'utf8') > 255) {
173
236
  throw new CalendarContractError('invalid_icalendar');
174
237
  }
175
238
  try {
176
- new Intl.DateTimeFormat('en-GB', { timeZone: normalized }).format(0);
239
+ return canonicalTimeZoneName(normalized);
177
240
  }
178
241
  catch {
179
242
  throw new CalendarContractError('invalid_icalendar');
180
243
  }
181
- return normalized;
182
244
  }
183
245
  function normalizeEmail(value) {
184
246
  const normalized = value.trim().toLowerCase();
@@ -318,13 +380,147 @@ const RECUR_RULE_PARTS = new Map([
318
380
  ]);
319
381
  /** The longest each month can be, February in a leap year. */
320
382
  const MONTH_DAYS = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
383
+ /** The shortest each month can be, February in a common year. */
384
+ const MIN_MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
385
+ /** The numeric BY parts, which RFC 5545 §3.3.10 reads as sets and ical.js walks in written order. */
386
+ const NUMERIC_BY_PARTS = [
387
+ 'BYSECOND',
388
+ 'BYMINUTE',
389
+ 'BYHOUR',
390
+ 'BYMONTHDAY',
391
+ 'BYYEARDAY',
392
+ 'BYWEEKNO',
393
+ 'BYMONTH',
394
+ 'BYSETPOS',
395
+ ];
396
+ /**
397
+ * Put a rule's numeric BY lists in ascending order, once each. RFC 5545 §3.3.10 reads each as a set,
398
+ * but ical.js 2.2.1 walks BYHOUR, BYMINUTE, BYSECOND and BYMONTH in the order written:
399
+ * `FREQ=DAILY;BYHOUR=17,8` gave 17:00 and then 08:00 the same day, an hour before it.
400
+ */
401
+ function sortRuleParts(recur) {
402
+ const parts = recur.parts;
403
+ for (const name of NUMERIC_BY_PARTS) {
404
+ const values = parts[name];
405
+ if (!Array.isArray(values))
406
+ continue;
407
+ parts[name] = [...new Set(values)].sort((a, b) => Number(a) - Number(b));
408
+ }
409
+ }
410
+ /**
411
+ * Why ical.js 2.2.1 would expand `recur` other than RFC 5545 §3.3.10 says, or null for a rule it
412
+ * expands correctly. Each refusal is a shape the readiness audit or a check against a brute-force
413
+ * reading of the RFC (the tests' rrule-oracle) found it gets wrong:
414
+ * - BYWEEKNO: unimplemented on its own, and with BYDAY it kept every week but the one named;
415
+ * - a BYDAY ordinal of two digits: read by its last digit (`20MO` as every Monday);
416
+ * - BYSETPOS other than with FREQ=MONTHLY and BYDAY alone: WEEKLY ignored it, others picked wrongly;
417
+ * - BYMONTH with FREQ=MONTHLY: it walked BYMONTH from its first value whatever DTSTART's month,
418
+ * dropping whole months or repeating them;
419
+ * - a negative BYMONTHDAY except with FREQ=MONTHLY and no BYDAY: compared as written with each day;
420
+ * - BYMONTHDAY with FREQ=YEARLY but no BYMONTH (only DTSTART's month was expanded), naming only
421
+ * days none of its BYMONTH months has (it searched every year to 20000 for one), or a day one of
422
+ * them can lack with COUNT (31 April became 1 May, and was counted: yearlyDays filters it out
423
+ * otherwise) or with BYDAY (BYMONTH=4,5;BYMONTHDAY=30,31;BYDAY=WE missed 31 May 2034);
424
+ * - with FREQ=YEARLY, BYYEARDAY with BYMONTH or BYMONTHDAY (it found no day at all), and a numbered
425
+ * BYDAY with BYMONTHDAY (counted in the year, not the month);
426
+ * - BYHOUR with FREQ=HOURLY, BYMINUTE or BYSECOND with FREQ=MINUTELY, and any of the three with
427
+ * FREQ=MONTHLY or YEARLY: it repeated or dropped occurrences (it expands them correctly with
428
+ * DAILY and WEEKLY, and HOURLY's BYMINUTE and BYSECOND and MINUTELY's BYHOUR);
429
+ * - FREQ=SECONDLY, which nothing Slotlock reads needs.
430
+ */
431
+ function recurShapeProblem(recur) {
432
+ const parts = recur.parts;
433
+ const freq = recur.freq;
434
+ if (freq === 'SECONDLY')
435
+ return 'FREQ=SECONDLY';
436
+ if (parts.BYWEEKNO)
437
+ return 'BYWEEKNO';
438
+ if (parts.BYDAY?.some((day) => /\d\d/.test(String(day))))
439
+ return 'a BYDAY ordinal of two digits';
440
+ if (parts.BYSETPOS && !(freq === 'MONTHLY' && parts.BYDAY && !parts.BYMONTHDAY)) {
441
+ return 'BYSETPOS other than with FREQ=MONTHLY and BYDAY';
442
+ }
443
+ if (freq === 'MONTHLY' && parts.BYMONTH)
444
+ return 'BYMONTH with FREQ=MONTHLY';
445
+ const monthDays = parts.BYMONTHDAY?.map(Number);
446
+ if (monthDays?.some((day) => day < 0) && !(freq === 'MONTHLY' && !parts.BYDAY)) {
447
+ return 'a negative BYMONTHDAY other than with FREQ=MONTHLY and no BYDAY';
448
+ }
449
+ if (freq === 'YEARLY' && parts.BYYEARDAY && (parts.BYMONTH || monthDays)) {
450
+ return 'BYYEARDAY with BYMONTH or BYMONTHDAY and FREQ=YEARLY';
451
+ }
452
+ if (freq === 'YEARLY' && monthDays && parts.BYDAY?.some((day) => /\d/.test(String(day)))) {
453
+ return 'a numbered BYDAY with BYMONTHDAY and FREQ=YEARLY';
454
+ }
455
+ if (freq === 'YEARLY' && monthDays) {
456
+ const months = parts.BYMONTH?.map(Number);
457
+ if (!months)
458
+ return 'BYMONTHDAY with FREQ=YEARLY and no BYMONTH';
459
+ if (!months.some((month) => monthDays.some((day) => day <= (MONTH_DAYS[month - 1] ?? 0)))) {
460
+ return 'a BYMONTHDAY none of its BYMONTH months has';
461
+ }
462
+ if ((recur.count != null || parts.BYDAY) &&
463
+ months.some((month) => monthDays.some((day) => day > (MIN_MONTH_DAYS[month - 1] ?? 0)))) {
464
+ return 'COUNT or BYDAY with a BYMONTHDAY one of its BYMONTH months can lack';
465
+ }
466
+ }
467
+ if (freq === 'HOURLY' && parts.BYHOUR)
468
+ return 'BYHOUR with FREQ=HOURLY';
469
+ if (freq === 'MINUTELY' && (parts.BYMINUTE || parts.BYSECOND)) {
470
+ return 'BYMINUTE or BYSECOND with FREQ=MINUTELY';
471
+ }
472
+ if ((freq === 'MONTHLY' || freq === 'YEARLY') &&
473
+ (parts.BYHOUR || parts.BYMINUTE || parts.BYSECOND)) {
474
+ return `BYHOUR, BYMINUTE or BYSECOND with FREQ=${freq}`;
475
+ }
476
+ return null;
477
+ }
478
+ /**
479
+ * Why a rule cannot be expanded from this DTSTART, or null. FREQ=YEARLY with no BYMONTHDAY, BYDAY or
480
+ * BYYEARDAY takes its day from DTSTART, and ical.js put a day its month lacks into the next month
481
+ * (29 February 2024 recurred on 1 March 2025) instead of skipping that year (RFC 5545 §3.3.10).
482
+ * yearlyDays filters those out, but COUNT has already counted them.
483
+ */
484
+ function seriesShapeProblem(recur, start) {
485
+ return recur.count != null && yearlyDays(recur, start) !== null
486
+ ? 'COUNT with a yearly DTSTART on a day one of its months can lack'
487
+ : null;
488
+ }
489
+ /**
490
+ * The months and days a FREQ=YEARLY rule's occurrences fall on, when it names a day one of its
491
+ * months can lack (29 February, or a 31st with April), or null. ical.js 2.2.1 moves such a day into
492
+ * the next month (29 February 2025 became 1 March) where RFC 5545 §3.3.10 ignores it, so an
493
+ * occurrence on any other day is not one. With BYYEARDAY it picks real dates already; with BYDAY
494
+ * such a rule is refused (recurShapeProblem).
495
+ */
496
+ function yearlyDays(recur, start) {
497
+ const parts = recur.parts;
498
+ if (recur.freq !== 'YEARLY' || parts.BYDAY || parts.BYYEARDAY)
499
+ return null;
500
+ const days = parts.BYMONTHDAY?.map(Number) ?? [start.day];
501
+ const months = parts.BYMONTH?.map(Number) ?? [start.month];
502
+ return months.some((month) => days.some((day) => day > (MIN_MONTH_DAYS[month - 1] ?? 0)))
503
+ ? { months: new Set(months), days: new Set(days) }
504
+ : null;
505
+ }
506
+ /**
507
+ * Why Slotlock refuses `rule`, in words, or null when it expands it: not RFC 5545 §3.3.10 as
508
+ * canonicalRecurrenceRule reads it, or a shape ical.js expands wrongly (recurShapeProblem).
509
+ */
510
+ export function recurrenceRuleProblem(rule) {
511
+ const canonical = canonicalRecurrenceRule(rule);
512
+ if (canonical === null)
513
+ return 'not a recurrence rule as RFC 5545 writes it';
514
+ return recurShapeProblem(ICAL.Recur.fromString(canonical));
515
+ }
321
516
  /**
322
517
  * A recurrence rule as RFC 5545 §3.3.10 writes it, in ical.js's canonical spelling, or `null`.
323
518
  * ical.js accepts more than it should: it drops unknown parts, lets a repeated part replace the
324
519
  * first, reads `COUNT=3X` as 3 and `COUNT=0` as no count at all. So every part must be one RFC 5545
325
520
  * names, appear once and match its grammar, with FREQ present and never UNTIL and COUNT together,
326
521
  * and the parts RFC 5545 forbids for a frequency (BYWEEKNO but YEARLY, BYYEARDAY with DAILY, WEEKLY
327
- * or MONTHLY, BYMONTHDAY with WEEKLY, a numbered BYDAY but MONTHLY or YEARLY) are refused.
522
+ * or MONTHLY, BYMONTHDAY with WEEKLY, a numbered BYDAY but MONTHLY or YEARLY) are refused. The
523
+ * numeric BY lists come back in ascending order (sortRuleParts).
328
524
  */
329
525
  export function canonicalRecurrenceRule(rule) {
330
526
  const text = rule.trim().toUpperCase();
@@ -353,7 +549,9 @@ export function canonicalRecurrenceRule(rule) {
353
549
  if (numberedDay && (!['MONTHLY', 'YEARLY'].includes(freq) || parts.has('BYWEEKNO')))
354
550
  return null;
355
551
  try {
356
- return ICAL.Recur.fromString(text).toString();
552
+ const recur = ICAL.Recur.fromString(text);
553
+ sortRuleParts(recur);
554
+ return recur.toString();
357
555
  }
358
556
  catch {
359
557
  return null;
@@ -367,7 +565,7 @@ export function canonicalRecurrenceRule(rule) {
367
565
  */
368
566
  export function isBookableRecurrenceRule(rule) {
369
567
  const canonical = canonicalRecurrenceRule(rule);
370
- if (canonical === null)
568
+ if (canonical === null || recurrenceRuleProblem(canonical) !== null)
371
569
  return false;
372
570
  const parts = new Map(canonical.split(';').map((part) => part.split('=')));
373
571
  const freq = parts.get('FREQ');
@@ -380,24 +578,40 @@ export function isBookableRecurrenceRule(rule) {
380
578
  const days = monthDays.split(',').map((day) => Math.abs(Number(day)));
381
579
  return months.some((month) => days.some((day) => day >= 1 && day <= (MONTH_DAYS[month - 1] ?? 0)));
382
580
  }
383
- function normalizeRecurrence(input) {
581
+ /**
582
+ * A stored series' rule: one Slotlock books (isBookableRecurrenceRule: RFC 5545's grammar, daily or
583
+ * less often, able to occur, a shape ical.js expands correctly) for this start, in its canonical
584
+ * spelling. A DATE UNTIL becomes the UTC instant its day ends in the event's zone: RFC 5545
585
+ * §3.3.10 wants a zoned DTSTART's UNTIL in UTC and reads it inclusively, and read as midnight UTC
586
+ * it lost the last day in every zone east of UTC with a morning start.
587
+ */
588
+ function normalizeRecurrence(input, start, timezone) {
384
589
  if (!input)
385
590
  return undefined;
386
- const rrule = input.rrule.trim().toUpperCase();
387
- if (!rrule ||
388
- Buffer.byteLength(rrule, 'utf8') > 4_096 ||
389
- /[\r\n]/.test(rrule) ||
390
- !rrule.startsWith('FREQ=')) {
591
+ const written = input.rrule.trim().toUpperCase();
592
+ if (!written ||
593
+ Buffer.byteLength(written, 'utf8') > 4_096 ||
594
+ /[\r\n]/.test(written) ||
595
+ !written.startsWith('FREQ=') ||
596
+ !isBookableRecurrenceRule(written)) {
391
597
  throw new CalendarContractError('invalid_icalendar');
392
598
  }
393
- try {
394
- ICAL.Recur.fromString(rrule);
395
- }
396
- catch {
599
+ const recur = ICAL.Recur.fromString(canonicalRecurrenceRule(written));
600
+ if (seriesShapeProblem(recur, zonedDateTimeFields(start, timezone)) !== null) {
397
601
  throw new CalendarContractError('invalid_icalendar');
398
602
  }
603
+ if (recur.until?.isDate) {
604
+ const { year, month, day } = recur.until;
605
+ const nextDay = utcFrame(year, month, day + 1, 0, 0, 0, 0);
606
+ const end = zonedDateTimeToUtc({
607
+ year: nextDay.getUTCFullYear(),
608
+ month: nextDay.getUTCMonth() + 1,
609
+ day: nextDay.getUTCDate(),
610
+ }, timezone);
611
+ recur.until = ICAL.Time.fromJSDate(new Date(end.getTime() - 1000), true);
612
+ }
399
613
  const exceptions = normalizeExceptions(input.exceptions);
400
- const recurrence = { rrule };
614
+ const recurrence = { rrule: recur.toString() };
401
615
  if (exceptions.length > 0)
402
616
  recurrence.exceptions = exceptions;
403
617
  return recurrence;
@@ -452,7 +666,7 @@ export function normalizeCalendarEventContent(input) {
452
666
  }
453
667
  if (reminders.length > 0)
454
668
  normalized.reminders = reminders;
455
- const recurrence = normalizeRecurrence(input.recurrence);
669
+ const recurrence = normalizeRecurrence(input.recurrence, start, normalized.timezone);
456
670
  if (recurrence)
457
671
  normalized.recurrence = recurrence;
458
672
  return normalized;
@@ -488,7 +702,88 @@ export function normalizeExternalCalendarChange(input) {
488
702
  change.recurrenceId = recurrenceId;
489
703
  return change;
490
704
  }
491
- function calendarTimeValue(value, propertyTimezone, calendarTimezone) {
705
+ const UTC_READER = { toInstant: (wall) => wall, toWall: (instant) => instant };
706
+ const intlReaders = new Map();
707
+ function intlReader(timezone) {
708
+ let reader = intlReaders.get(timezone);
709
+ if (reader)
710
+ return reader;
711
+ const converter = createTimeZoneConverter(timezone);
712
+ reader = {
713
+ zone: timezone,
714
+ toInstant: (wall, choice) => converter.wallFrameToUtc(wall, choice).getTime(),
715
+ toWall: (instant) => {
716
+ const fields = zonedDateTimeFields(new Date(instant), timezone);
717
+ return utcFrame(fields.year, fields.month, fields.day, fields.hour, fields.minute, fields.second, 0).getTime();
718
+ },
719
+ };
720
+ // A feed names at most MAX_ICAL_TIMEZONES zones it does not define; the readers of all feeds
721
+ // read are kept like the formatters behind them, emptied rather than grown without bound.
722
+ if (intlReaders.size >= 256)
723
+ intlReaders.clear();
724
+ intlReaders.set(timezone, reader);
725
+ return reader;
726
+ }
727
+ /**
728
+ * A zone only the calendar defines, read from its own observances. ical.js reads a wall time in
729
+ * such a zone with `want_daylight = false; // TODO` (timezone.js), so a time the clocks repeat named
730
+ * the later instant and one they skip moved back; here the offset in force at each instant comes
731
+ * from the changes ical.js expands (UTC instants, each with the offset before and after it), and a
732
+ * wall time is read from those as from any other zone (wallFrameToInstant).
733
+ */
734
+ function vtimezoneReader(zone) {
735
+ const known = vtimezoneReaders.get(zone);
736
+ if (known)
737
+ return known;
738
+ const coverage = zone;
739
+ let changes;
740
+ let starts = [];
741
+ const offsetAt = (instant) => {
742
+ const year = new Date(instant).getUTCFullYear();
743
+ if (typeof coverage._ensureCoverage !== 'function') {
744
+ throw new CalendarContractError('invalid_icalendar');
745
+ }
746
+ coverage._ensureCoverage(year + 1);
747
+ if (coverage.changes !== changes) {
748
+ changes = coverage.changes;
749
+ starts = changes.map((change) => utcFrame(change.year, change.month, change.day, change.hour, change.minute, change.second, 0).getTime());
750
+ }
751
+ const list = changes;
752
+ if (list.length === 0)
753
+ return 0;
754
+ // The last change at or before the instant; before the first, the offset it changed from.
755
+ let low = 0;
756
+ let high = starts.length - 1;
757
+ let found = -1;
758
+ while (low <= high) {
759
+ const middle = (low + high) >> 1;
760
+ if (starts[middle] <= instant) {
761
+ found = middle;
762
+ low = middle + 1;
763
+ }
764
+ else {
765
+ high = middle - 1;
766
+ }
767
+ }
768
+ const change = list[found === -1 ? 0 : found];
769
+ return (found === -1 ? change.prevUtcOffset : change.utcOffset) * 1000;
770
+ };
771
+ const reader = {
772
+ toInstant: (wall, choice) => wallFrameToInstant(wall, offsetAt, choice),
773
+ toWall: (instant) => instant + offsetAt(instant),
774
+ };
775
+ vtimezoneReaders.set(zone, reader);
776
+ return reader;
777
+ }
778
+ /** One reader per zone a calendar defines, for as long as its parse holds the zone. */
779
+ const vtimezoneReaders = new WeakMap();
780
+ /**
781
+ * The zone a calendar time is read in: UTC; the IANA zone its TZID (or else the calendar's zone, for
782
+ * a floating time) names; or the calendar's own VTIMEZONE for a TZID Intl does not know. A floating
783
+ * time in a calendar without a zone, or a TZID neither Intl nor the calendar defines, is refused: a
784
+ * server process has no local zone of its own to read it in.
785
+ */
786
+ function zoneReader(value, propertyTimezone, calendarTimezone) {
492
787
  if (!value || typeof value !== 'object' || !('toJSDate' in value)) {
493
788
  throw new CalendarContractError('invalid_icalendar');
494
789
  }
@@ -496,37 +791,74 @@ function calendarTimeValue(value, propertyTimezone, calendarTimezone) {
496
791
  const zone = typeof propertyTimezone === 'string' ? propertyTimezone.trim() : '';
497
792
  const valueZone = time.zone?.tzid?.trim() ?? '';
498
793
  if (zone === 'UTC' || valueZone === 'UTC')
499
- return time.toJSDate();
500
- // ical.js intentionally frames floating values as local JS dates. A server process has no
501
- // legitimate local calendar zone, so resolve wall-clock fields against provider metadata.
502
- const timezone = zone || calendarTimezone;
794
+ return UTC_READER;
795
+ const timezone = zone || calendarTimezone?.trim();
796
+ const own = time.zone instanceof ICAL.Timezone ? time.zone : undefined;
797
+ const defined = valueZone !== 'floating' && valueZone !== 'local' && own?.component;
503
798
  if (timezone) {
504
- try {
505
- return zonedDateTimeToUtc({
506
- year: time.year,
507
- month: time.month,
508
- day: time.day,
509
- hour: time.isDate ? 0 : time.hour,
510
- minute: time.isDate ? 0 : time.minute,
511
- second: time.isDate ? 0 : time.second,
512
- }, timezone);
513
- }
514
- catch {
515
- // A standards-compliant embedded VTIMEZONE may use a private TZID that Intl does not know.
516
- // ical.js resolves that definition itself; an undeclared TZID Intl does not know either
517
- // remains `floating` and cannot reach this fallback.
518
- if (zone && valueZone === zone && valueZone !== 'floating' && valueZone !== 'local') {
519
- const resolved = time.toJSDate();
520
- if (Number.isFinite(resolved.getTime()))
521
- return resolved;
522
- }
523
- throw new CalendarContractError('invalid_icalendar');
524
- }
799
+ if (isKnownTimezone(timezone))
800
+ return intlReader(timezone);
801
+ // A standards-compliant embedded VTIMEZONE may use a private TZID that Intl does not know; an
802
+ // undeclared TZID Intl does not know either remains `floating` and cannot reach this.
803
+ if (zone && valueZone === zone && defined && own)
804
+ return vtimezoneReader(own);
805
+ throw new CalendarContractError('invalid_icalendar');
525
806
  }
526
- if (valueZone && valueZone !== 'floating' && valueZone !== 'local')
527
- return time.toJSDate();
807
+ if (own instanceof IntlTimezone)
808
+ return intlReader(own.tzid.trim());
809
+ if (defined && own)
810
+ return vtimezoneReader(own);
528
811
  throw new CalendarContractError('invalid_icalendar');
529
812
  }
813
+ /** A calendar time's wall-clock fields (a date's midnight), framed as UTC. */
814
+ function wallFrameOf(value) {
815
+ const time = value;
816
+ return utcFrame(time.year, time.month, time.day, time.isDate ? 0 : time.hour, time.isDate ? 0 : time.minute, time.isDate ? 0 : time.second, 0).getTime();
817
+ }
818
+ /**
819
+ * Where a DURATION from `start` (wall time `startWall`) ends exactly, RFC 5545 §3.3.6: its weeks and
820
+ * days on the calendar first (the same wall time that many days on), then its hours, minutes and
821
+ * seconds as elapsed time. PT48H is 48 hours however the clocks change; P2D may be 47 or 49.
822
+ */
823
+ function durationEnd(reader, startWall, start, duration) {
824
+ const days = duration.weeks * 7 + duration.days;
825
+ const ms = ((duration.hours * 60 + duration.minutes) * 60 + duration.seconds) * 1000;
826
+ return (days > 0 ? reader.toInstant(startWall + days * 24 * 60 * 60 * 1000) : start) + ms;
827
+ }
828
+ /**
829
+ * The value of every RRULE written inside a VEVENT of `input`, as written: what ical.js has read
830
+ * by then is no longer the text (it read `COUNT=0` as no count, `COUNT=3X` as 3, and the second of
831
+ * two FREQs). A time zone's own rules (inside VTIMEZONE) are not an event's and are left out.
832
+ */
833
+ function veventRuleTexts(input) {
834
+ const rules = [];
835
+ const open = [];
836
+ for (const line of unfoldedLines(input)) {
837
+ const component = componentLine(line);
838
+ if (component?.kind === 'begin')
839
+ open.push(component.name);
840
+ else if (component?.kind === 'end')
841
+ open.pop();
842
+ else if (open.at(-1) === 'vevent' && /^rrule[;:]/i.test(line)) {
843
+ const { end } = icalendarPropertyHead(line, {});
844
+ rules.push(line.slice(end + 1));
845
+ }
846
+ }
847
+ return rules;
848
+ }
849
+ /**
850
+ * The instant a calendar time names (zoneReader), RFC 5545 §3.3.5's first instant of a repeated
851
+ * time and a skipped time moved forward, charged to `budget` when one is given. ical.js frames a
852
+ * floating value as a local JS date, which a server process has no business with.
853
+ */
854
+ function calendarTimeValue(value, propertyTimezone, calendarTimezone, budget) {
855
+ const shared = zoneReader(value, propertyTimezone, calendarTimezone);
856
+ const reader = budget ? budget.zoneReader(shared) : shared;
857
+ const instant = reader.toInstant(wallFrameOf(value));
858
+ if (!Number.isFinite(instant))
859
+ throw new CalendarContractError('invalid_icalendar');
860
+ return new Date(instant);
861
+ }
530
862
  /** The TZID of a time ical.js reads in a zone (a VTIMEZONE's, an IntlTimezone, UTC); else none. */
531
863
  function ownZone(value) {
532
864
  if (!(value instanceof ICAL.Time))
@@ -535,38 +867,85 @@ function ownZone(value) {
535
867
  return tzid && tzid !== 'floating' && tzid !== 'local' ? tzid : undefined;
536
868
  }
537
869
  /**
538
- * What one parse may spend: steps searching for occurrences, and time from its start. The time is
539
- * checked at every step and after every piece of ical.js work the parse does, so work that takes no
540
- * steps is stopped at the next check.
870
+ * What one parse may spend: steps searching for occurrences (ICAL_STEP_CHARGES), reading each one
871
+ * it finds, and reading times in their zones (ZONE_READ_STEPS).
541
872
  */
542
873
  class RecurrenceBudget {
543
- limits;
544
874
  steps;
545
- started = performance.now();
875
+ /** Each zone's readers as this budget charges them, so a parse's readers stay one per zone. */
876
+ readers = new Map();
877
+ /**
878
+ * What of each IANA zone's offsets this budget has paid for: the midnight that starts UTC day `d`
879
+ * as `2d`, the changes within day `d` as `2d + 1` (zoneOffsetDay).
880
+ */
881
+ read = new Map();
546
882
  constructor(limits) {
547
- this.limits = limits;
548
883
  this.steps = limits.steps;
549
884
  }
550
- /** Throw once the parse has run longer than its limit. */
551
- check() {
552
- if (performance.now() - this.started > this.limits.ms)
885
+ /** Charge `steps`, throwing once the parse has spent more than its limit. */
886
+ charge(steps) {
887
+ this.steps -= steps;
888
+ if (this.steps < 0)
553
889
  expansionLimit();
554
890
  }
891
+ /** `reader`, with every conversion it makes charged here (chargeZoneRead). */
892
+ zoneReader(reader) {
893
+ if (reader === UTC_READER)
894
+ return reader;
895
+ const known = this.readers.get(reader);
896
+ if (known)
897
+ return known;
898
+ const zone = reader.zone;
899
+ const charged = {
900
+ toInstant: (wall, choice) => {
901
+ this.chargeZoneRead(zone, wall - ZONE_OFFSET_REACH_MS, wall + ZONE_OFFSET_REACH_MS);
902
+ return reader.toInstant(wall, choice);
903
+ },
904
+ toWall: (instant) => {
905
+ this.chargeZoneRead(zone, instant, instant);
906
+ return reader.toWall(instant);
907
+ },
908
+ };
909
+ if (zone !== undefined)
910
+ charged.zone = zone;
911
+ this.readers.set(reader, charged);
912
+ return charged;
913
+ }
555
914
  /**
556
- * Run one piece of ical.js work, then check the time: building an event's expansion, its next
557
- * occurrence, or reading a time, which can expand a feed's own time zone.
915
+ * Charge one conversion of a time in `zone` that reads its offsets from `fromMs` to `toMs` (at
916
+ * most three UTC days), and what of those days this budget has not paid for: each midnight, and
917
+ * the search for each day's changes (zoneOffsetChangeReads), as Intl is asked for them with none
918
+ * kept. The process keeps them, but a parse is charged as if it read them now, so what it refuses
919
+ * never depends on what was read before it.
558
920
  */
559
- search(work) {
560
- const result = work();
561
- this.check();
562
- return result;
563
- }
564
- /** Charge one step, throwing past either limit. */
565
- charge() {
566
- this.steps -= 1;
567
- if (this.steps < 0)
568
- expansionLimit();
569
- this.check();
921
+ chargeZoneRead(zone, fromMs, toMs) {
922
+ // Without an IANA zone, a reader reads one only the feed defines (UTC's is never charged).
923
+ if (zone === undefined) {
924
+ this.charge(ZONE_READ_STEPS.ownZone);
925
+ return;
926
+ }
927
+ this.charge(ZONE_READ_STEPS.conversion);
928
+ let paid = this.read.get(zone);
929
+ if (!paid) {
930
+ paid = new Set();
931
+ this.read.set(zone, paid);
932
+ }
933
+ const first = zoneOffsetDay(fromMs);
934
+ const last = zoneOffsetDay(toMs);
935
+ // Not a time: the conversion refuses it.
936
+ if (!Number.isFinite(first) || !Number.isFinite(last))
937
+ return;
938
+ const pay = (key, reads) => {
939
+ if (paid.has(key))
940
+ return;
941
+ paid.add(key);
942
+ this.charge(reads() * ZONE_READ_STEPS.intl);
943
+ };
944
+ for (let day = first; day <= last; day++) {
945
+ pay(2 * day, () => 1);
946
+ pay(2 * day + 2, () => 1);
947
+ pay(2 * day + 1, () => zoneOffsetChangeReads(zone, day));
948
+ }
570
949
  }
571
950
  }
572
951
  /**
@@ -581,13 +960,16 @@ function budgetedRecurIterator(budget) {
581
960
  }
582
961
  const inherited = ICAL.RecurIterator.prototype;
583
962
  const own = BudgetedRecurIterator.prototype;
584
- for (const name of ICAL_STEP_METHODS) {
963
+ for (const [name, cost] of Object.entries(ICAL_STEP_CHARGES)) {
585
964
  const step = inherited[name];
586
965
  if (typeof step !== 'function')
587
966
  throw new CalendarContractError('invalid_icalendar');
588
967
  own[name] = function (...args) {
589
- budget.charge();
590
- return step.apply(this, args);
968
+ budget.charge(cost.before(...args));
969
+ const result = step.apply(this, args);
970
+ if (cost.after)
971
+ budget.charge(cost.after(this, result));
972
+ return result;
591
973
  };
592
974
  }
593
975
  return BudgetedRecurIterator;
@@ -610,16 +992,16 @@ function budgetRule(rule, Budgeted) {
610
992
  * values are changed before it is called: wrapping its iterators afterwards left that step, the
611
993
  * one most rules that never occur stop in, unbounded.
612
994
  */
613
- function budgetedExpansion(event, Budgeted) {
995
+ function budgetedExpansion(event, Budgeted, budget, start) {
614
996
  for (const property of event.component.getAllProperties('rrule')) {
615
997
  budgetRule(property.getFirstValue(), Budgeted);
616
998
  }
617
- const expansion = event.iterator();
999
+ const expansion = event.iterator(start);
618
1000
  const iterators = expansion.ruleIterators;
619
1001
  if (!Array.isArray(iterators) || !iterators.every((iterator) => iterator instanceof Budgeted)) {
620
1002
  throw new CalendarContractError('invalid_icalendar');
621
1003
  }
622
- return withEveryRule(expansion);
1004
+ return withEveryRule(expansion, budget);
623
1005
  }
624
1006
  /**
625
1007
  * `expansion`, choosing among its RRULEs' iterators correctly. ical.js 2.2.1's own choice
@@ -627,13 +1009,15 @@ function budgetedExpansion(event, Budgeted) {
627
1009
  * skipped the iterator after an ended first one, and for any other removed the one before it,
628
1010
  * which had not ended, so a series with two RRULEs lost occurrences once either rule ended.
629
1011
  */
630
- function withEveryRule(expansion) {
1012
+ function withEveryRule(expansion, budget) {
631
1013
  const state = expansion;
632
1014
  if (typeof state._nextRecurrenceIter !== 'function') {
633
1015
  throw new CalendarContractError('invalid_icalendar');
634
1016
  }
635
1017
  state._nextRecurrenceIter = () => {
636
1018
  const iterators = state.ruleIterators;
1019
+ if (iterators.length > 1)
1020
+ budget.charge((iterators.length - 1) * RULE_SCAN_STEPS);
637
1021
  let chosen = null;
638
1022
  for (let index = 0; index < iterators.length;) {
639
1023
  const iterator = iterators[index];
@@ -683,7 +1067,8 @@ function writtenFieldsMs(property) {
683
1067
  * Refuse a VEVENT that ical.js would spend unbounded work reading, before anything reads its times:
684
1068
  * a DURATION, or a DTEND after its DTSTART, longer than MAX_ICAL_STEP_DAYS (ical.js adds a span to
685
1069
  * a time a month at a time, Time#adjust, and `DURATION:PT99999999999999999999S` held the process),
686
- * a negative DURATION, or more RDATE and EXDATE values than MAX_ICAL_DATES_PER_EVENT. Returns how
1070
+ * a negative DURATION, more RDATE and EXDATE values than MAX_ICAL_DATES_PER_EVENT, or more RRULEs
1071
+ * than MAX_ICAL_RULES_PER_EVENT. Returns how
687
1072
  * many it lists, for the feed's own limit. It reads times only as written, so it runs before the
688
1073
  * times are framed: framing reads every date value, and 115,000 EXDATE values took 11 seconds.
689
1074
  */
@@ -710,16 +1095,17 @@ function boundComponent(component) {
710
1095
  }
711
1096
  if (dates > MAX_ICAL_DATES_PER_EVENT)
712
1097
  expansionLimit();
1098
+ if (component.getAllProperties('rrule').length > MAX_ICAL_RULES_PER_EVENT)
1099
+ expansionLimit();
713
1100
  return dates;
714
1101
  }
715
1102
  /** Bound every VEVENT of a feed, and the date values of all of them together. */
716
- function boundComponents(components, budget) {
1103
+ function boundComponents(components) {
717
1104
  let dates = 0;
718
1105
  for (const component of components) {
719
1106
  dates += boundComponent(component);
720
1107
  if (dates > MAX_ICAL_DATES_PER_FEED)
721
1108
  expansionLimit();
722
- budget.check();
723
1109
  }
724
1110
  }
725
1111
  /**
@@ -728,12 +1114,8 @@ function boundComponents(components, budget) {
728
1114
  * local time, so one series' cancelled 09:00 cancelled every other series' 09:00 that day; the
729
1115
  * parsers relate each series' own exceptions themselves.
730
1116
  */
731
- function feedEvents(components, budget) {
732
- return components.map((component) => {
733
- const event = new ICAL.Event(component, { exceptions: [] });
734
- budget.check();
735
- return event;
736
- });
1117
+ function feedEvents(components) {
1118
+ return components.map((component) => new ICAL.Event(component, { exceptions: [] }));
737
1119
  }
738
1120
  /** Refuse a feed whose text alone shows it is past a limit, before ical.js parses it. */
739
1121
  function boundInput(input) {
@@ -956,12 +1338,15 @@ function budgetTimezoneRules(root, Budgeted) {
956
1338
  }
957
1339
  }
958
1340
  /**
959
- * Charge the time of each expansion of the feed's own zones, and keep one copy of their changes.
960
- * Whenever ical.js reads a year past what it has expanded a zone to, it expands every observance
1341
+ * Keep one copy of the changes of each of the feed's own zones (their rules' steps are charged by
1342
+ * budgetTimezoneRules). Whenever ical.js reads a year past what it has expanded a zone to, it expands every observance
961
1343
  * again from its start and appends all of them to what it already had: 1,000 date values in years
962
1344
  * six apart, sorted inside one call, expanded a zone of 3,000 changes 1,000 times into 3 million
963
1345
  * (26 to 35 seconds, 650 MB). Each expansion starts again from DTSTART, so emptying the list first
964
- * leaves the same changes, once. If ical.js no longer expands zones this way, the feed is refused.
1346
+ * leaves the same changes, once, and each expansion is charged for the changes it lists
1347
+ * (ZONE_CHANGE_STEPS). If ical.js no longer expands zones this way, the feed is refused.
1348
+ * And charge each offset ical.js looks up in such a zone to `budget`, as a conversion in it is: it
1349
+ * compares a series' times at every step of its search.
965
1350
  */
966
1351
  function budgetTimezoneCoverage(root, budget) {
967
1352
  for (const component of root.getAllSubcomponents('vtimezone')) {
@@ -972,14 +1357,20 @@ function budgetTimezoneCoverage(root, budget) {
972
1357
  if (!zone)
973
1358
  continue;
974
1359
  const ensure = zone._ensureCoverage;
975
- if (typeof ensure !== 'function')
1360
+ const offset = zone.utcOffset;
1361
+ if (typeof ensure !== 'function' || typeof offset !== 'function') {
976
1362
  throw new CalendarContractError('invalid_icalendar');
1363
+ }
977
1364
  zone._ensureCoverage = function (year) {
978
1365
  if (this.changes.length > 0 && this.expandedUntilYear >= year)
979
1366
  return;
980
- budget.check();
981
1367
  this.changes = [];
982
1368
  ensure.call(this, year);
1369
+ budget.charge(this.changes.length * ZONE_CHANGE_STEPS);
1370
+ };
1371
+ zone.utcOffset = function (time) {
1372
+ budget.charge(ZONE_READ_STEPS.ownZone);
1373
+ return offset.call(this, time);
983
1374
  };
984
1375
  }
985
1376
  }
@@ -1138,7 +1529,7 @@ function keptSeriesCursor(snapshot) {
1138
1529
  * rebuilt from their state (`initialized`, so the iterator does not search again from DTSTART) with
1139
1530
  * this parse's rule, start and zone, and its steps charged to this parse's budget.
1140
1531
  */
1141
- function resumedExpansion(event, Budgeted, cursor) {
1532
+ function resumedExpansion(event, Budgeted, budget, cursor) {
1142
1533
  const rule = event.component.getFirstPropertyValue('rrule');
1143
1534
  budgetRule(rule, Budgeted);
1144
1535
  const dtstart = event.startDate;
@@ -1171,7 +1562,7 @@ function resumedExpansion(event, Budgeted, cursor) {
1171
1562
  if (iterators.length !== 1 || !(iterators[0] instanceof Budgeted)) {
1172
1563
  throw new CalendarContractError('invalid_icalendar');
1173
1564
  }
1174
- return withEveryRule(expansion);
1565
+ return withEveryRule(expansion, budget);
1175
1566
  }
1176
1567
  /**
1177
1568
  * Whether a cursor's iterator state agrees with `reference`, an iterator ical.js built for the same
@@ -1225,19 +1616,26 @@ function cursorFitsRule(state, reference, rule) {
1225
1616
  * of Slotlock reads wall times with: a gap moves forward, a fold takes its first instant.
1226
1617
  */
1227
1618
  class IntlTimezone extends ICAL.Timezone {
1619
+ zone;
1228
1620
  converter;
1621
+ /** The parse's budget, which each offset found is charged to as any conversion is. */
1622
+ budget;
1229
1623
  /** Offsets already found, by wall time: ical.js asks again at every comparison. */
1230
1624
  offsets = new Map();
1231
- constructor(tzid) {
1625
+ constructor(tzid, budget) {
1232
1626
  super({ tzid });
1233
1627
  // A TZID is matched as written (" Europe/Berlin" is that TZID); Slotlock reads its zone trimmed.
1234
- this.converter = createTimeZoneConverter(tzid.trim());
1628
+ this.zone = tzid.trim();
1629
+ this.converter = createTimeZoneConverter(this.zone);
1630
+ this.budget = budget;
1235
1631
  }
1236
1632
  utcOffset(time) {
1237
1633
  const wall = utcFrame(time.year, time.month, time.day, time.hour, time.minute, time.second, 0);
1238
1634
  const wallMs = wall.getTime();
1239
1635
  let offset = this.offsets.get(wallMs);
1240
1636
  if (offset === undefined) {
1637
+ // ical.js compares a series' times at every step of its search, each in this zone.
1638
+ this.budget.chargeZoneRead(this.zone, wallMs - ZONE_OFFSET_REACH_MS, wallMs + ZONE_OFFSET_REACH_MS);
1241
1639
  offset = Math.round((wallMs - this.converter.wallFrameToUtc(wallMs).getTime()) / 1000);
1242
1640
  if (this.offsets.size >= 4_096)
1243
1641
  this.offsets.clear();
@@ -1248,14 +1646,32 @@ class IntlTimezone extends ICAL.Timezone {
1248
1646
  }
1249
1647
  /** VEVENT properties whose times ical.js compares with one another. */
1250
1648
  const COMPARED_TIME_PROPERTIES = ['dtstart', 'dtend', 'recurrence-id', 'rdate', 'exdate'];
1649
+ const knownTimezones = new Map();
1650
+ /**
1651
+ * Whether Intl reads `timezone` (trimmed) as a zone: how a feed's TZID is read, leniently. Asking
1652
+ * builds a formatter (about 30 microseconds), and every occurrence of a series asks again, so each
1653
+ * name's answer is kept: Intl gives one name the same answer for the life of a process. The map is
1654
+ * emptied when it fills, like the formatters (a feed names at most MAX_ICAL_TIMEZONES zones).
1655
+ */
1251
1656
  function isKnownTimezone(timezone) {
1657
+ const name = timezone.trim();
1658
+ if (!name || Buffer.byteLength(name, 'utf8') > 255)
1659
+ return false;
1660
+ const known = knownTimezones.get(name);
1661
+ if (known !== undefined)
1662
+ return known;
1663
+ let answer;
1252
1664
  try {
1253
- validTimezone(timezone);
1254
- return true;
1665
+ new Intl.DateTimeFormat('en-GB', { timeZone: name }).format(0);
1666
+ answer = true;
1255
1667
  }
1256
1668
  catch {
1257
- return false;
1669
+ answer = false;
1258
1670
  }
1671
+ if (knownTimezones.size >= 256)
1672
+ knownTimezones.clear();
1673
+ knownTimezones.set(name, answer);
1674
+ return answer;
1259
1675
  }
1260
1676
  /** Wall-clock fields as a jCal DATE-TIME, `2027-01-04T09:00:00`, with a `Z` when they are UTC's. */
1261
1677
  function jCalDateTime(fields, utc = false) {
@@ -1348,7 +1764,6 @@ function frameEventTimes(root, components, calendarTimezone, budget) {
1348
1764
  for (const value of values)
1349
1765
  property.jCal.push(value[0]);
1350
1766
  }
1351
- budget.check();
1352
1767
  }
1353
1768
  }
1354
1769
  if (!component.hasProperty('recurrence-id') &&
@@ -1360,7 +1775,6 @@ function frameEventTimes(root, components, calendarTimezone, budget) {
1360
1775
  }
1361
1776
  if (timezone) {
1362
1777
  const toDate = (property) => {
1363
- budget.check();
1364
1778
  if (property.type !== 'date-time')
1365
1779
  return;
1366
1780
  const tzid = property.getParameter('tzid');
@@ -1404,10 +1818,9 @@ function frameEventTimes(root, components, calendarTimezone, budget) {
1404
1818
  // ical.js keeps a calendar's zones here; without it, it could not use one of these.
1405
1819
  if (!(cache instanceof Map))
1406
1820
  throw new CalendarContractError('invalid_icalendar');
1407
- cache.set(tzid, new IntlTimezone(tzid));
1408
- budget.check();
1821
+ cache.set(tzid, new IntlTimezone(tzid, budget));
1409
1822
  }
1410
- alignSeriesTimes(components, budget);
1823
+ alignSeriesTimes(components);
1411
1824
  return periods;
1412
1825
  }
1413
1826
  /**
@@ -1425,7 +1838,7 @@ function frameEventTimes(root, components, calendarTimezone, budget) {
1425
1838
  * RDATEs with no RRULE gets its DTSTART as one: it is the first instance (RFC 5545 §3.8.2.4), which
1426
1839
  * ical.js left out.
1427
1840
  */
1428
- function alignSeriesTimes(components, budget) {
1841
+ function alignSeriesTimes(components) {
1429
1842
  const seriesStarts = new Map();
1430
1843
  for (const component of components) {
1431
1844
  if (component.hasProperty('recurrence-id'))
@@ -1459,6 +1872,20 @@ function alignSeriesTimes(components, budget) {
1459
1872
  for (const property of component.getAllProperties('rrule')) {
1460
1873
  const rule = property.jCal[3];
1461
1874
  const until = rule?.until;
1875
+ if (typeof until === 'string' && until.length === 10 && rule) {
1876
+ // A DATE UNTIL on a timed series (RFC 5545 §3.3.10 wants DTSTART's value type, and UTC for
1877
+ // a zoned one) names its whole day on the series' clock: up to the instant before the
1878
+ // next day begins there. Read as midnight UTC, it lost that day in most zones.
1879
+ const next = utcFrame(Number(until.slice(0, 4)), Number(until.slice(5, 7)), Number(until.slice(8, 10)) + 1, 0, 0, 0, 0);
1880
+ // A DTSTART in UTC keeps the series' clock in UTC.
1881
+ const midnight = value.endsWith('Z')
1882
+ ? next
1883
+ : zonedInstant(ICAL.Time.fromDateTimeString(jCalDateTime(zonedDateTimeFields(next, 'UTC')), start));
1884
+ if (midnight !== undefined) {
1885
+ rule.until = jCalDateTime(zonedDateTimeFields(new Date(midnight.getTime() - 1000), 'UTC'), true);
1886
+ }
1887
+ continue;
1888
+ }
1462
1889
  if (typeof until !== 'string' || until.length <= 10 || until.endsWith('Z'))
1463
1890
  continue;
1464
1891
  const instant = zonedInstant(ICAL.Time.fromDateTimeString(until, start));
@@ -1466,7 +1893,6 @@ function alignSeriesTimes(components, budget) {
1466
1893
  rule.until = jCalDateTime(zonedDateTimeFields(instant, 'UTC'), true);
1467
1894
  }
1468
1895
  }
1469
- budget.check();
1470
1896
  }
1471
1897
  for (const component of components) {
1472
1898
  const uid = component.getFirstPropertyValue('uid');
@@ -1474,7 +1900,6 @@ function alignSeriesTimes(components, budget) {
1474
1900
  if (series === undefined)
1475
1901
  continue;
1476
1902
  for (const property of component.getAllProperties('recurrence-id')) {
1477
- budget.check();
1478
1903
  const value = property.jCal[3];
1479
1904
  if (property.type !== 'date-time' || typeof value !== 'string')
1480
1905
  continue;
@@ -1509,28 +1934,47 @@ function alignSeriesTimes(components, budget) {
1509
1934
  }
1510
1935
  }
1511
1936
  }
1512
- /** Parse VEVENTs with the standards library; event content is deliberately discarded. */
1937
+ /**
1938
+ * Parse VEVENTs with the standards library; event content is deliberately discarded. All or
1939
+ * nothing: a series Slotlock cannot read refuses the whole calendar, so a caller that reads the
1940
+ * result as the calendar's busy time never takes a refused series' time as free. To keep the rest
1941
+ * of a calendar when one series is refused, use parseICalendarFeed.
1942
+ */
1513
1943
  export function parseICalendarChanges(input, calendarTimezone, expansionWindow) {
1514
1944
  return parseCalendarChanges(input, calendarTimezone, expansionWindow, FEED_LIMITS);
1515
1945
  }
1516
- function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits, series) {
1946
+ /**
1947
+ * parseICalendarChanges, refusing one series at a time: a series Slotlock cannot read (a rule
1948
+ * shape ical.js expands wrongly, an event whose end the clocks move before its start, DTEND with
1949
+ * DURATION, or one that needs more than its budget) is named in `refused` and the rest of the
1950
+ * calendar is read. A calendar that is not one (its size, its time zones, a missing UID, a window
1951
+ * past 367 days) is still refused whole, with CalendarContractError.
1952
+ */
1953
+ export function parseICalendarFeed(input, calendarTimezone, expansionWindow) {
1954
+ const refused = [];
1955
+ const changes = parseCalendarChanges(input, calendarTimezone, expansionWindow, FEED_LIMITS, undefined, refused);
1956
+ return { changes, refused };
1957
+ }
1958
+ const DAY_MS = 24 * 60 * 60 * 1000;
1959
+ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits, series, refused) {
1517
1960
  try {
1518
1961
  // Bound work before ical.js allocates and normalizes the full component tree. The parsed-tree
1519
1962
  // checks below remain authoritative; these cheap standards-shaped counts are an early DoS guard.
1520
1963
  boundInput(input);
1521
- const budget = new RecurrenceBudget(limits);
1964
+ const budget = limits instanceof RecurrenceBudget ? limits : new RecurrenceBudget(limits);
1522
1965
  const root = new ICAL.Component(ICAL.parse(input));
1523
1966
  // A bare VEVENT (or anything else) would read as a calendar with no events.
1524
1967
  if (root.name !== 'vcalendar')
1525
1968
  throw new CalendarContractError('invalid_icalendar');
1526
- budget.check();
1527
1969
  let Budgeted;
1528
1970
  const budgeted = () => (Budgeted ??= budgetedRecurIterator(budget));
1529
1971
  budgetTimezoneRules(root, budgeted);
1530
1972
  budgetTimezoneCoverage(root, budget);
1531
1973
  // A time read in a zone of its own (a VTIMEZONE's, or an IntlTimezone) is read in that zone: an
1532
1974
  // RDATE may name another zone than its series' DTSTART, and an occurrence keeps the RDATE's.
1533
- const timeValue = (value, zone, fallback) => budget.search(() => calendarTimeValue(value, ownZone(value) ?? zone, fallback));
1975
+ // Every time is read through the budget, which charges its conversions.
1976
+ const timeValue = (value, zone, fallback) => calendarTimeValue(value, ownZone(value) ?? zone, fallback, budget);
1977
+ const readerOf = (value, zone) => budget.zoneReader(zoneReader(value, ownZone(value) ?? zone, defaultTimezone));
1534
1978
  const embeddedTimezone = root.getFirstPropertyValue('x-wr-timezone');
1535
1979
  const defaultTimezone = calendarTimezone?.trim() ||
1536
1980
  (typeof embeddedTimezone === 'string' ? embeddedTimezone.trim() : '') ||
@@ -1539,71 +1983,54 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
1539
1983
  if (components.length > MAX_ICAL_COMPONENTS) {
1540
1984
  throw new CalendarContractError('invalid_icalendar');
1541
1985
  }
1542
- boundComponents(components, budget);
1986
+ boundComponents(components);
1543
1987
  const periods = frameEventTimes(root, components, defaultTimezone, budget);
1544
- const events = feedEvents(components, budget);
1545
- const masters = new Map();
1546
- const exceptionsByUid = new Map();
1988
+ const events = feedEvents(components);
1989
+ // A series is a master and the overrides (RECURRENCE-ID components) that share its UID. Each is
1990
+ // read on its own: with `refused`, a series Slotlock cannot read is named there and the rest of
1991
+ // the calendar is read; without, it refuses the calendar.
1992
+ const groups = new Map();
1547
1993
  for (const event of events) {
1994
+ const uid = event.uid;
1995
+ // An event without a UID cannot be named, so it refuses the calendar either way.
1996
+ if (typeof uid !== 'string')
1997
+ throw new CalendarContractError('invalid_icalendar');
1998
+ const group = groups.get(uid) ?? { masters: [], overrides: [] };
1548
1999
  if (event.isRecurrenceException()) {
1549
- const exceptions = exceptionsByUid.get(event.uid) ?? [];
1550
- if (exceptions.length >= MAX_ICAL_EXCEPTIONS_PER_SERIES) {
2000
+ if (group.overrides.length >= MAX_ICAL_EXCEPTIONS_PER_SERIES) {
1551
2001
  throw new CalendarContractError('invalid_icalendar');
1552
2002
  }
1553
- exceptions.push(event);
1554
- exceptionsByUid.set(event.uid, exceptions);
2003
+ group.overrides.push(event);
1555
2004
  }
1556
2005
  else {
1557
- if (masters.has(event.uid))
1558
- throw new CalendarContractError('invalid_icalendar');
1559
- masters.set(event.uid, event);
1560
- }
1561
- }
1562
- for (const [uid, exceptions] of exceptionsByUid) {
1563
- const master = masters.get(uid);
1564
- if (!master)
1565
- continue;
1566
- for (const exception of exceptions) {
1567
- // An exception for this and every later occurrence (RANGE=THISANDFUTURE) is placed by its
1568
- // time, which reads its zone.
1569
- master.relateException(exception);
1570
- budget.check();
2006
+ group.masters.push(event);
1571
2007
  }
2008
+ groups.set(uid, group);
1572
2009
  }
1573
2010
  /**
1574
- * One event or occurrence as a change, or null when it has no length: RFC 5545 §3.6.1 ends a
1575
- * DATE-TIME DTSTART without DTEND or DURATION when it starts, so it occupies no time. One that
1576
- * ends before it starts, or has no UID, makes the feed an invalid calendar. `knownEnd` is an end
1577
- * already found: a PERIOD RDATE's, or an occurrence's that starts in an hour the clocks skip.
2011
+ * A change for `start` to `end`, with the status, transparency and sequence of `component`, or
2012
+ * null when it has no length: RFC 5545 §3.6.1 ends a DATE-TIME DTSTART without DTEND or DURATION
2013
+ * when it starts, so it occupies no time. A cancelled one still says so, with no time.
1578
2014
  */
1579
- const normalizeEvent = (event, startValue, endValue, recurrenceValue, knownEnd) => {
1580
- const component = event.component;
1581
- const startProperty = component.getFirstProperty('dtstart');
1582
- const endProperty = component.getFirstProperty('dtend');
1583
- const recurrenceProperty = component.getFirstProperty('recurrence-id');
2015
+ const makeChange = (component, uid, start, end, recurrenceId) => {
1584
2016
  const status = String(component.getFirstPropertyValue('status') ?? 'confirmed').toLowerCase();
1585
2017
  const transparency = String(component.getFirstPropertyValue('transp') ?? 'opaque').toLowerCase();
1586
- const start = timeValue(startValue, startProperty?.getParameter('tzid'), defaultTimezone);
1587
- const end = knownEnd ??
1588
- timeValue(endValue, endProperty?.getParameter('tzid') ?? startProperty?.getParameter('tzid'), defaultTimezone);
1589
2018
  const change = {
1590
- externalId: event.uid,
1591
- start,
1592
- end,
2019
+ externalId: uid,
2020
+ start: new Date(start),
2021
+ end: new Date(end),
1593
2022
  status,
1594
2023
  transparent: transparency === 'transparent',
1595
2024
  sequence: Number(component.getFirstPropertyValue('sequence') ?? 0),
1596
2025
  };
1597
- if (end.getTime() === start.getTime()) {
2026
+ if (end === start) {
1598
2027
  if (status !== 'cancelled')
1599
2028
  return null;
1600
- // Cancelled, it still says so, with no time.
1601
2029
  change.start = null;
1602
2030
  change.end = null;
1603
2031
  }
1604
- if (recurrenceValue) {
1605
- change.recurrenceId = timeValue(recurrenceValue, recurrenceProperty?.getParameter('tzid') ?? startProperty?.getParameter('tzid'), defaultTimezone).toISOString();
1606
- }
2032
+ if (recurrenceId !== undefined)
2033
+ change.recurrenceId = new Date(recurrenceId).toISOString();
1607
2034
  try {
1608
2035
  return normalizeExternalCalendarChange(change);
1609
2036
  }
@@ -1614,57 +2041,154 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
1614
2041
  throw error;
1615
2042
  }
1616
2043
  };
1617
- /** Each PERIOD RDATE of a series, by the instant it starts: the instant it ends. */
2044
+ /**
2045
+ * How long an event's occurrences last (SPEC.md, "Daylight-saving time"). DTSTART is read as
2046
+ * written (the store's own instant for a stored series), DTEND as an instant per RFC 5545 §3.3.5,
2047
+ * and a DURATION per §3.3.6: its days on the calendar, its hours, minutes and seconds exact.
2048
+ */
2049
+ const lengthsOf = (event, anchor) => {
2050
+ const component = event.component;
2051
+ const startTime = event.startDate;
2052
+ if (!(startTime instanceof ICAL.Time))
2053
+ throw new CalendarContractError('invalid_icalendar');
2054
+ const startZone = component.getFirstProperty('dtstart')?.getParameter('tzid');
2055
+ const reader = readerOf(startTime, startZone);
2056
+ const startWall = wallFrameOf(startTime);
2057
+ const start = anchor ? anchor.start.getTime() : reader.toInstant(startWall);
2058
+ const allDay = startTime.isDate;
2059
+ const endProperty = component.getFirstProperty('dtend');
2060
+ const duration = component.getFirstPropertyValue('duration');
2061
+ const base = { allDay, reader, startWall, start };
2062
+ if (endProperty) {
2063
+ const endTime = endProperty.getFirstValue();
2064
+ if (!(endTime instanceof ICAL.Time))
2065
+ throw new CalendarContractError('invalid_icalendar');
2066
+ if (allDay) {
2067
+ const wall = wallFrameOf(endTime) - startWall;
2068
+ if (wall < 0)
2069
+ throw new CalendarContractError('invalid_icalendar');
2070
+ return { ...base, wall, ends: true };
2071
+ }
2072
+ const endZone = endProperty.getParameter('tzid') ?? startZone;
2073
+ const end = anchor
2074
+ ? anchor.end.getTime()
2075
+ : readerOf(endTime, endZone).toInstant(wallFrameOf(endTime));
2076
+ // An end the clocks moved onto its start (both in the hour they skip), or before it, is
2077
+ // refused: the event as written cannot be read. An end written as its start is no length.
2078
+ const sameZone = (ownZone(endTime) ?? endZone) === (ownZone(startTime) ?? startZone);
2079
+ const writtenAsStart = wallFrameOf(endTime) === startWall && sameZone;
2080
+ if (end < start || (end === start && !writtenAsStart)) {
2081
+ throw new CalendarContractError('invalid_icalendar');
2082
+ }
2083
+ // DTEND's wall time as written, when it is written in DTSTART's zone: one in the hour the
2084
+ // clocks skip is carried to later days as written, though its own instant reads an hour
2085
+ // later (RFC 5545 §3.3.5). In another zone, or for a stored series (instants), it is the
2086
+ // wall time its instant reads in DTSTART's zone.
2087
+ const endWall = !anchor && sameZone ? wallFrameOf(endTime) : reader.toWall(end);
2088
+ return { ...base, exact: end - start, wall: endWall - startWall, ends: end > start };
2089
+ }
2090
+ if (duration instanceof ICAL.Duration) {
2091
+ const days = duration.weeks * 7 + duration.days;
2092
+ const ms = ((duration.hours * 60 + duration.minutes) * 60 + duration.seconds) * 1000;
2093
+ if (allDay)
2094
+ return { ...base, wall: (days + (ms > 0 ? 1 : 0)) * DAY_MS, ends: true };
2095
+ return {
2096
+ ...base,
2097
+ duration: { days, ms },
2098
+ wall: days * DAY_MS + ms,
2099
+ ends: days > 0 || ms > 0,
2100
+ };
2101
+ }
2102
+ // No end: a DATE-TIME occupies no time (§3.6.1), a DATE its one day.
2103
+ return allDay
2104
+ ? { ...base, wall: DAY_MS, ends: true }
2105
+ : { ...base, exact: 0, wall: 0, ends: false };
2106
+ };
2107
+ /** The exact end of an occurrence that starts at `start`, wall time `wall`: what a one-off takes. */
2108
+ const exactEnd = (lengths, reader, wall, start) => {
2109
+ if (lengths.allDay)
2110
+ return reader.toInstant(wall + lengths.wall);
2111
+ if (lengths.duration) {
2112
+ const { days, ms } = lengths.duration;
2113
+ return (days > 0 ? reader.toInstant(wall + days * DAY_MS) : start) + ms;
2114
+ }
2115
+ return start + (lengths.exact ?? 0);
2116
+ };
2117
+ /**
2118
+ * The end of a generated occurrence: the later of its exact end and its wall-clock end (DTEND's
2119
+ * wall time carried to the occurrence's date, read at the later instant of an hour the clocks
2120
+ * repeat). One reading or the other leaves time free that people mean as busy wherever the clocks
2121
+ * change inside an occurrence; their union does not.
2122
+ */
2123
+ const occurrenceEnd = (lengths, reader, wall, start) => {
2124
+ const exact = exactEnd(lengths, reader, wall, start);
2125
+ if (lengths.allDay || !lengths.ends)
2126
+ return exact;
2127
+ return Math.max(exact, reader.toInstant(wall + lengths.wall, { fold: 'later' }));
2128
+ };
2129
+ /**
2130
+ * An override as Slotlock reads it: the instant of the occurrence it replaces, and its own
2131
+ * interval, exact as written. One without DTEND or DURATION lasts its series' length (RFC 5545
2132
+ * §3.6.1 alone would end it as it starts, leaving the moved occurrence free).
2133
+ */
2134
+ const readOverride = (override, uid, seriesLengths, seriesZone) => {
2135
+ const component = override.component;
2136
+ const recurrence = component.getFirstProperty('recurrence-id');
2137
+ const at = timeValue(override.recurrenceId, recurrence?.getParameter('tzid') ??
2138
+ component.getFirstProperty('dtstart')?.getParameter('tzid') ??
2139
+ seriesZone, defaultTimezone).getTime();
2140
+ const own = lengthsOf(override);
2141
+ const lengths = own.ends || !seriesLengths
2142
+ ? own
2143
+ : { ...seriesLengths, reader: own.reader, startWall: own.startWall, start: own.start };
2144
+ const end = exactEnd(lengths, own.reader, own.startWall, own.start);
2145
+ return {
2146
+ at,
2147
+ start: own.start,
2148
+ end,
2149
+ change: makeChange(component, uid, own.start, end, at),
2150
+ component,
2151
+ range: String(recurrence?.getParameter('range') ?? '').toUpperCase() === 'THISANDFUTURE',
2152
+ lengths,
2153
+ recurrenceWall: wallFrameOf(override.recurrenceId),
2154
+ };
2155
+ };
2156
+ const changes = [];
2157
+ const overlapsWindow = (start, end) => expansionWindow !== undefined &&
2158
+ end > expansionWindow.start.getTime() &&
2159
+ start < expansionWindow.end.getTime();
2160
+ /** Each PERIOD RDATE of a series, by the instant it starts: the instant it ends, exactly. */
1618
2161
  const periodEndsOf = (event) => {
1619
2162
  const ends = new Map();
1620
- const maxMs = MAX_ICAL_STEP_DAYS * 24 * 60 * 60 * 1000;
2163
+ const maxMs = MAX_ICAL_STEP_DAYS * DAY_MS;
1621
2164
  for (const period of periods.get(event.component) ?? []) {
1622
2165
  const zone = period.property.getParameter('tzid');
1623
2166
  const start = ICAL.Time.fromDateTimeString(period.start, period.property);
1624
- let end;
2167
+ const reader = readerOf(start, zone);
2168
+ const startWall = wallFrameOf(start);
2169
+ const startMs = reader.toInstant(startWall);
2170
+ let endMs;
1625
2171
  if (/^[+-]?P/.test(period.end)) {
1626
- // Bounded first, as boundComponent bounds a DURATION: ical.js adds a month at a time.
2172
+ // Bounded first, as boundComponent bounds a DURATION.
1627
2173
  const duration = ICAL.Duration.fromString(period.end);
1628
2174
  if (duration.isNegative || duration.toSeconds() * 1000 > maxMs)
1629
2175
  expansionLimit();
1630
- end = start.clone();
1631
- end.addDuration(duration);
2176
+ const days = duration.weeks * 7 + duration.days;
2177
+ const ms = ((duration.hours * 60 + duration.minutes) * 60 + duration.seconds) * 1000;
2178
+ endMs = (days > 0 ? reader.toInstant(startWall + days * DAY_MS) : startMs) + ms;
1632
2179
  }
1633
2180
  else {
1634
- end = ICAL.Time.fromDateTimeString(period.end, period.property);
1635
- if (Math.abs(fieldsMs(end) - fieldsMs(start)) > maxMs + 24 * 60 * 60 * 1000) {
2181
+ const end = ICAL.Time.fromDateTimeString(period.end, period.property);
2182
+ if (Math.abs(fieldsMs(end) - fieldsMs(start)) > maxMs + DAY_MS)
1636
2183
  expansionLimit();
1637
- }
2184
+ endMs = timeValue(end, zone, defaultTimezone).getTime();
1638
2185
  }
1639
- const startMs = timeValue(start, zone, defaultTimezone).getTime();
1640
- const endsAt = timeValue(end, zone, defaultTimezone);
1641
- if (endsAt.getTime() < startMs)
2186
+ if (endMs < startMs)
1642
2187
  throw new CalendarContractError('invalid_icalendar');
1643
- ends.set(startMs, endsAt);
1644
- budget.check();
2188
+ ends.set(startMs, endMs);
1645
2189
  }
1646
2190
  return ends;
1647
2191
  };
1648
- /**
1649
- * The exact length of a timed series: what its DTEND gives it (RFC 5545 §3.8.5.3), a clock
1650
- * change or not, or its DURATION in seconds (exact for hours, minutes and seconds, §3.3.6; days
1651
- * count as 24 hours, but a length of days never ends as it starts, where this is used);
1652
- * undefined for an all-day series.
1653
- */
1654
- const exactLengthOf = (event) => {
1655
- const startProperty = event.component.getFirstProperty('dtstart');
1656
- if (startProperty?.type !== 'date-time')
1657
- return undefined;
1658
- const duration = event.component.getFirstPropertyValue('duration');
1659
- if (duration instanceof ICAL.Duration)
1660
- return duration.toSeconds() * 1000;
1661
- const endProperty = event.component.getFirstProperty('dtend');
1662
- if (!endProperty)
1663
- return undefined;
1664
- const start = timeValue(event.startDate, startProperty.getParameter('tzid'), defaultTimezone);
1665
- const end = timeValue(event.endDate, endProperty.getParameter('tzid') ?? startProperty.getParameter('tzid'), defaultTimezone);
1666
- return end.getTime() - start.getTime();
1667
- };
1668
2192
  /**
1669
2193
  * A series' EXDATEs: the instants its DATE-TIME values name, and the days its DATE values name
1670
2194
  * (one excludes every occurrence that day, as ical.js reads it). ical.js passes them one at a
@@ -1688,16 +2212,53 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
1688
2212
  }
1689
2213
  return { instants, days };
1690
2214
  };
1691
- const changes = [];
1692
- for (const event of events) {
1693
- if (event.isRecurrenceException() && masters.has(event.uid))
1694
- continue;
1695
- if (!event.isRecurring()) {
1696
- const change = normalizeEvent(event, event.startDate, event.endDate, event.recurrenceId ?? undefined);
2215
+ /** A one-off event and its overrides: each occupies exactly what it says, window or not. */
2216
+ const readOneOff = (uid, event, overrides) => {
2217
+ const lengths = lengthsOf(event);
2218
+ const replaced = overrides.some((override) => override.at === lengths.start);
2219
+ if (!replaced) {
2220
+ const end = exactEnd(lengths, lengths.reader, lengths.startWall, lengths.start);
2221
+ const recurrenceId = event.recurrenceId ? lengths.start : undefined;
2222
+ const change = makeChange(event.component, uid, lengths.start, end, recurrenceId);
1697
2223
  if (change)
1698
2224
  changes.push(change);
1699
- continue;
1700
2225
  }
2226
+ // A server-expanded CalDAV series may send its first instance without a RECURRENCE-ID, and
2227
+ // must send every other one with one (RFC 4791 §9.6.5): each is an override of this one-off,
2228
+ // and occupies its own time.
2229
+ for (const override of overrides)
2230
+ if (override.change)
2231
+ changes.push(override.change);
2232
+ };
2233
+ const readSeries = (uid, group) => {
2234
+ if (group.masters.length > 1)
2235
+ throw new CalendarContractError('invalid_icalendar');
2236
+ for (const event of [...group.masters, ...group.overrides]) {
2237
+ // RFC 5545 §3.6.1: 'dtend' and 'duration' MUST NOT occur in the same eventprop.
2238
+ if (event.component.hasProperty('dtend') && event.component.hasProperty('duration')) {
2239
+ throw new CalendarContractError('invalid_icalendar');
2240
+ }
2241
+ }
2242
+ const master = group.masters[0];
2243
+ if (!master) {
2244
+ // Overrides of a series this calendar does not hold: each occupies its own interval.
2245
+ for (const override of group.overrides) {
2246
+ const read = readOverride(override, uid, undefined, undefined);
2247
+ if (read.change)
2248
+ changes.push(read.change);
2249
+ }
2250
+ return;
2251
+ }
2252
+ const startZone = master.component.getFirstProperty('dtstart')?.getParameter('tzid');
2253
+ if (!master.isRecurring()) {
2254
+ const lengths = lengthsOf(master);
2255
+ const overrides = group.overrides.map((override) => readOverride(override, uid, lengths, startZone));
2256
+ readOneOff(uid, master, overrides);
2257
+ return;
2258
+ }
2259
+ readRecurring(uid, master, group.overrides, startZone);
2260
+ };
2261
+ const readRecurring = (uid, event, overrideEvents, startZone) => {
1701
2262
  if (!expansionWindow ||
1702
2263
  !Number.isFinite(expansionWindow.start.getTime()) ||
1703
2264
  !Number.isFinite(expansionWindow.end.getTime()) ||
@@ -1705,44 +2266,112 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
1705
2266
  expansionWindow.end.getTime() - expansionWindow.start.getTime() > MAX_ICAL_WINDOW_MS) {
1706
2267
  throw new CalendarContractError('invalid_icalendar');
1707
2268
  }
2269
+ const startTime = event.startDate;
2270
+ for (const property of event.component.getAllProperties('rrule')) {
2271
+ const recur = property.getFirstValue();
2272
+ if (!(recur instanceof ICAL.Recur))
2273
+ throw new CalendarContractError('invalid_icalendar');
2274
+ if (recurShapeProblem(recur) !== null ||
2275
+ (startTime instanceof ICAL.Time && seriesShapeProblem(recur, startTime) !== null)) {
2276
+ throw new CalendarContractError('invalid_icalendar');
2277
+ }
2278
+ sortRuleParts(recur);
2279
+ }
2280
+ // A yearly day some years lack is kept to the days the rule names (yearlyDays), which needs
2281
+ // the rule to be the series' only source: ical.js mixes RDATEs and other rules in, and one of
2282
+ // those on another day could not be told from a day it moved.
2283
+ const ruleValues = event.component.getAllProperties('rrule');
2284
+ const lacking = startTime instanceof ICAL.Time
2285
+ ? ruleValues.map((rule) => yearlyDays(rule.getFirstValue(), startTime))
2286
+ : [];
2287
+ const onDays = lacking[0] ?? null;
2288
+ if (lacking.some((days) => days !== null) &&
2289
+ (ruleValues.length > 1 || event.component.hasProperty('rdate'))) {
2290
+ throw new CalendarContractError('invalid_icalendar');
2291
+ }
2292
+ const stored = series?.stored;
2293
+ const lengths = lengthsOf(event, stored);
2294
+ const overrides = stored
2295
+ ? stored.exceptions.map((exception) => storedOverride(uid, event, lengths, exception))
2296
+ : overrideEvents.map((override) => readOverride(override, uid, lengths, startZone));
1708
2297
  // What one series may pass from its start, so that one series' age never refuses another's.
1709
2298
  let remainingWork = MAX_ICAL_WORK;
1710
2299
  let maxBackwardExceptionShiftMs = 0;
1711
- for (const exception of exceptionsByUid.get(event.uid) ?? []) {
2300
+ let lastOverrideAt = Number.NEGATIVE_INFINITY;
2301
+ for (const override of overrides) {
1712
2302
  remainingWork -= 1;
1713
2303
  if (remainingWork < 0)
1714
2304
  expansionLimit();
1715
- const recurrenceProperty = exception.component.getFirstProperty('recurrence-id');
1716
- const startProperty = exception.component.getFirstProperty('dtstart');
1717
- const originalStart = timeValue(exception.recurrenceId, recurrenceProperty?.getParameter('tzid'), defaultTimezone);
1718
- const movedStart = timeValue(exception.startDate, startProperty?.getParameter('tzid'), defaultTimezone);
1719
- const backwardShiftMs = originalStart.getTime() - movedStart.getTime();
2305
+ const backwardShiftMs = override.at - override.start;
1720
2306
  if (backwardShiftMs > MAX_EXCEPTION_BACKWARD_SHIFT_MS) {
1721
2307
  throw new CalendarContractError('invalid_icalendar');
1722
2308
  }
1723
- // ical.js moves the occurrence by the shift a month at a time (Time#adjust).
1724
- if (-backwardShiftMs > MAX_ICAL_STEP_DAYS * 24 * 60 * 60 * 1000)
2309
+ // ical.js moved an occurrence by the shift a month at a time (Time#adjust); a range
2310
+ // override still moves every later one by it.
2311
+ if (-backwardShiftMs > MAX_ICAL_STEP_DAYS * DAY_MS)
1725
2312
  expansionLimit();
1726
2313
  maxBackwardExceptionShiftMs = Math.max(maxBackwardExceptionShiftMs, backwardShiftMs);
2314
+ lastOverrideAt = Math.max(lastOverrideAt, override.at);
1727
2315
  }
1728
2316
  const recurrenceScanEndMs = expansionWindow.end.getTime() + maxBackwardExceptionShiftMs;
2317
+ // A direct override replaces the occurrence it names; a RANGE=THISANDFUTURE one also moves
2318
+ // every later occurrence by its own shift, with its own length.
2319
+ const byInstant = new Map(overrides.map((override) => [override.at, override]));
2320
+ const ranges = overrides
2321
+ .filter((override) => override.range)
2322
+ .sort((left, right) => left.at - right.at);
2323
+ const rangeBefore = (instant) => {
2324
+ let found;
2325
+ for (const range of ranges) {
2326
+ if (range.at < instant)
2327
+ found = range;
2328
+ else
2329
+ break;
2330
+ }
2331
+ return found;
2332
+ };
1729
2333
  // A stored series (the only master) resumes from its cursor when the cursor is this series'
1730
2334
  // and stands where the window needs nothing before it, and leaves one for the next window.
1731
- const key = series && masters.size === 1 ? seriesKey(event.component) : null;
2335
+ const key = series && groups.size === 1 ? seriesKey(event.component) : null;
1732
2336
  const kept = key === null ? null : readSeriesCursor(series?.cursor, key);
1733
2337
  const usable = kept !== null && series !== undefined && kept.position <= series.resumeLimitMs
1734
2338
  ? kept
1735
2339
  : null;
1736
- const startZone = event.component.getFirstProperty('dtstart')?.getParameter('tzid');
1737
2340
  let periodEnds;
1738
2341
  let exclusions;
1739
- const exactLengthMs = exactLengthOf(event);
2342
+ // A feed's series that repeats every so many days (one RRULE, DAILY or WEEKLY, with no
2343
+ // COUNT, RDATE or RANGE=THISANDFUTURE override) repeats the same way from DTSTART moved a
2344
+ // whole number of its periods on the wall clock: a WEEKLY rule's weeks stay in step, a DAILY
2345
+ // rule's BY parts only limit its days. So its search starts at the last such time from which
2346
+ // no occurrence before it could reach the window (it lasts at most its wall-clock or exact
2347
+ // length and a day more), and a daily standup since 2014 is read like one from this year.
2348
+ let from;
2349
+ const rules = event.component.getAllProperties('rrule');
2350
+ const only = rules.length === 1 ? rules[0]?.getFirstValue() : undefined;
2351
+ if (!series &&
2352
+ ranges.length === 0 &&
2353
+ !event.component.hasProperty('rdate') &&
2354
+ startTime instanceof ICAL.Time &&
2355
+ only instanceof ICAL.Recur &&
2356
+ only.count == null &&
2357
+ (only.freq === 'DAILY' || only.freq === 'WEEKLY')) {
2358
+ const periodMs = only.interval * (only.freq === 'WEEKLY' ? 7 : 1) * DAY_MS;
2359
+ const reachMs = Math.max(lengths.exact ?? 0, lengths.wall) + OCCURRENCE_END_SLACK_MS + periodMs;
2360
+ const periods = Math.floor((expansionWindow.start.getTime() - reachMs - lengths.start) / periodMs);
2361
+ if (periods > 0) {
2362
+ const day = new Date(lengths.startWall + periods * periodMs);
2363
+ from = startTime.clone();
2364
+ from.year = day.getUTCFullYear();
2365
+ from.month = day.getUTCMonth() + 1;
2366
+ from.day = day.getUTCDate();
2367
+ }
2368
+ }
1740
2369
  // The occurrences in the window from `resume` (or the start), and the cursor to keep; null
1741
2370
  // when `resume` does not fit this series.
1742
2371
  const expand = (resume) => {
1743
- const iterator = budget.search(() => resume
1744
- ? resumedExpansion(event, budgeted(), resume)
1745
- : budgetedExpansion(event, budgeted()));
2372
+ const iterator = resume
2373
+ ? resumedExpansion(event, budgeted(), budget, resume)
2374
+ : budgetedExpansion(event, budgeted(), budget, from);
1746
2375
  if (iterator === null)
1747
2376
  return null;
1748
2377
  // The cursor stands on the occurrence it last passed: the same time, read the same way.
@@ -1750,72 +2379,142 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
1750
2379
  timeValue(iterator.last, startZone, defaultTimezone).getTime() !== resume.position) {
1751
2380
  return null;
1752
2381
  }
2382
+ // A stored series' exceptions are checked against every occurrence up to the last of them
2383
+ // when it is searched from its start, so one that names no occurrence is refused.
2384
+ const scanEndMs = stored && !resume
2385
+ ? Math.max(recurrenceScanEndMs, lastOverrideAt + 1)
2386
+ : recurrenceScanEndMs;
1753
2387
  const found = [];
2388
+ const matched = new Set();
1754
2389
  let position = resume?.position;
1755
- let previous;
2390
+ const seen = new Set();
1756
2391
  let snapshot = resume;
1757
2392
  let leaving = key !== null;
2393
+ // Every occurrence starting before this instant has been seen (all of them once the series
2394
+ // has ended).
2395
+ let scannedTo = Number.NEGATIVE_INFINITY;
2396
+ // DTSTART is the first instance (RFC 5545 §3.8.5.3), whether or not the rule gives it.
2397
+ let first = resume === null && from === undefined && startTime instanceof ICAL.Time
2398
+ ? startTime
2399
+ : null;
2400
+ const firstAt = first ? timeValue(first, startZone, defaultTimezone).getTime() : 0;
2401
+ // Each zone the series' times are in is read with one reader for the whole series: an
2402
+ // RRULE's occurrences are in DTSTART's zone, an RDATE's in its own.
2403
+ const readers = new Map();
2404
+ const readerFor = (time) => {
2405
+ let reader = readers.get(time.zone);
2406
+ if (!reader) {
2407
+ reader = readerOf(time, startZone);
2408
+ readers.set(time.zone, reader);
2409
+ }
2410
+ return reader;
2411
+ };
2412
+ let held;
1758
2413
  while (true) {
1759
2414
  remainingWork -= 1;
1760
2415
  if (remainingWork < 0)
1761
2416
  expansionLimit();
2417
+ budget.charge(OCCURRENCE_STEPS);
1762
2418
  // Before the next step, everything the iterator has passed may end before the window:
1763
2419
  // then this is where the next expansion can start.
1764
- if (leaving && key !== null && series && position !== undefined) {
2420
+ if (leaving && key !== null && series && position !== undefined && held === undefined) {
1765
2421
  if (position <= series.resumeLimitMs)
1766
2422
  snapshot = snapshotSeries(iterator, key, position);
1767
2423
  else
1768
2424
  leaving = false;
1769
2425
  }
1770
- const occurrence = budget.search(() => iterator.next());
1771
- if (!occurrence)
2426
+ let occurrence;
2427
+ let isFirst = false;
2428
+ if (held !== undefined) {
2429
+ occurrence = held;
2430
+ held = undefined;
2431
+ }
2432
+ else {
2433
+ occurrence = iterator.next() ?? null;
2434
+ if (first) {
2435
+ const at = occurrence
2436
+ ? timeValue(occurrence, startZone, defaultTimezone).getTime()
2437
+ : 0;
2438
+ if (occurrence && at === firstAt) {
2439
+ isFirst = true;
2440
+ first = null;
2441
+ }
2442
+ else if (!occurrence || at > firstAt) {
2443
+ held = occurrence;
2444
+ occurrence = first;
2445
+ isFirst = true;
2446
+ first = null;
2447
+ }
2448
+ }
2449
+ }
2450
+ if (!occurrence) {
2451
+ scannedTo = Number.POSITIVE_INFINITY;
1772
2452
  break;
1773
- const occurrenceStart = timeValue(occurrence, startZone, defaultTimezone);
1774
- position = occurrenceStart.getTime();
1775
- if (occurrenceStart.getTime() >= recurrenceScanEndMs)
2453
+ }
2454
+ const reader = readerFor(occurrence);
2455
+ const wall = wallFrameOf(occurrence);
2456
+ position = reader.toInstant(wall);
2457
+ if (!Number.isFinite(position))
2458
+ throw new CalendarContractError('invalid_icalendar');
2459
+ if (position >= scanEndMs) {
2460
+ scannedTo = position;
1776
2461
  break;
2462
+ }
2463
+ if (onDays &&
2464
+ !isFirst &&
2465
+ !(onDays.months.has(occurrence.month) && onDays.days.has(occurrence.day))) {
2466
+ continue;
2467
+ }
2468
+ // The first instance is DTSTART's own instant: for a stored series, the instant stored.
2469
+ const at = isFirst ? lengths.start : position;
1777
2470
  // An instant is one occurrence however many RRULEs and RDATEs name it (RFC 5545
1778
- // §3.8.5.3; ical.js yields it again), and none where an EXDATE names it.
1779
- const repeated = position === previous;
1780
- previous = position;
1781
- if (repeated)
2471
+ // §3.8.5.3; ical.js yields it again, and a spring-forward can move a wall time onto one
2472
+ // the rule names later), and none where an EXDATE names it.
2473
+ if (seen.has(at))
1782
2474
  continue;
2475
+ seen.add(at);
1783
2476
  if (event.component.hasProperty('exdate')) {
1784
2477
  exclusions ??= exclusionsOf(event);
1785
- if (exclusions.instants.has(position) || exclusions.days.has(dayOf(occurrence))) {
2478
+ if (exclusions.instants.has(at) || exclusions.days.has(dayOf(occurrence)))
1786
2479
  continue;
2480
+ }
2481
+ const override = byInstant.get(at);
2482
+ if (override) {
2483
+ matched.add(override);
2484
+ if (override.change && overlapsWindow(override.start, override.end)) {
2485
+ found.push(override.change);
1787
2486
  }
2487
+ continue;
1788
2488
  }
1789
- const details = event.getOccurrenceDetails(occurrence);
1790
- const start = timeValue(details.startDate, details.item.component.getFirstProperty('dtstart')?.getParameter('tzid'), defaultTimezone);
1791
- // A PERIOD RDATE's occurrence ends where its period does, unless an exception moved it.
1792
- let knownEnd = details.item === event
1793
- ? (periodEnds ??= periodEndsOf(event)).get(occurrenceStart.getTime())
1794
- : undefined;
1795
- let end = knownEnd ??
1796
- timeValue(details.endDate, details.item.component.getFirstProperty('dtend')?.getParameter('tzid') ??
1797
- details.item.component.getFirstProperty('dtstart')?.getParameter('tzid'), defaultTimezone);
1798
- // Any other ends on the wall clock, as calendars show it: ical.js adds its series' length
1799
- // there. One that starts in an hour the clocks skip ended as it started (a 02:00-03:00
1800
- // occurrence the night Berlin skips 02:00), and was dropped: it lasts its series' exact
1801
- // length, as RFC 5545 §3.8.5.3 has every occurrence last.
1802
- if (knownEnd === undefined &&
1803
- details.item === event &&
1804
- exactLengthMs !== undefined &&
1805
- exactLengthMs > 0 &&
1806
- end <= start) {
1807
- knownEnd = new Date(start.getTime() + exactLengthMs);
1808
- end = knownEnd;
2489
+ // A PERIOD RDATE's occurrence ends where its period does.
2490
+ const periodEnd = (periodEnds ??= periodEndsOf(event)).get(position);
2491
+ const range = periodEnd === undefined ? rangeBefore(at) : undefined;
2492
+ let start = at;
2493
+ let end;
2494
+ let component = event.component;
2495
+ if (periodEnd !== undefined) {
2496
+ end = periodEnd;
1809
2497
  }
1810
- if (end > expansionWindow.start && start < expansionWindow.end) {
1811
- const change = normalizeEvent(details.item, details.startDate, details.endDate, occurrence, knownEnd);
2498
+ else if (range) {
2499
+ // Moved by the range override's own shift on the wall clock, in its zone (as ical.js
2500
+ // moves it), lasting the override's length.
2501
+ const shifted = wall + (range.lengths.startWall - range.recurrenceWall);
2502
+ start = range.lengths.reader.toInstant(shifted);
2503
+ end = occurrenceEnd(range.lengths, range.lengths.reader, shifted, start);
2504
+ component = range.component;
2505
+ }
2506
+ else {
2507
+ end = occurrenceEnd(lengths, reader, wall, start);
2508
+ }
2509
+ if (overlapsWindow(start, end)) {
2510
+ const change = makeChange(component, uid, start, end, at);
1812
2511
  if (change)
1813
2512
  found.push(change);
1814
2513
  if (changes.length + found.length > MAX_ICAL_OCCURRENCES)
1815
2514
  expansionLimit();
1816
2515
  }
1817
2516
  }
1818
- return { found, snapshot };
2517
+ return { found, snapshot, matched, scannedTo };
1819
2518
  };
1820
2519
  let expanded = null;
1821
2520
  if (usable !== null) {
@@ -1838,10 +2537,70 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
1838
2537
  if (expanded === null)
1839
2538
  throw new CalendarContractError('invalid_icalendar');
1840
2539
  changes.push(...expanded.found);
2540
+ for (const override of overrides) {
2541
+ if (expanded.matched.has(override))
2542
+ continue;
2543
+ // An override whose occurrence an EXDATE removes, or that names none, still occupies its
2544
+ // own interval, as the provider shows it. A stored exception that names no occurrence is
2545
+ // refused wherever this expansion could tell (before the series starts, or before the first
2546
+ // occurrence it did not reach), or collected in `series.unmatched` when that is given.
2547
+ const passed = resumed ? override.at <= (usable?.position ?? 0) : false;
2548
+ if (stored &&
2549
+ !passed &&
2550
+ (override.at < lengths.start || override.at < expanded.scannedTo)) {
2551
+ if (!series?.unmatched)
2552
+ throw new CalendarContractError('invalid_icalendar');
2553
+ series.unmatched.add(override.at);
2554
+ continue;
2555
+ }
2556
+ if (override.change && overlapsWindow(override.start, override.end)) {
2557
+ changes.push(override.change);
2558
+ }
2559
+ }
1841
2560
  if (series && key !== null) {
1842
2561
  series.resumed = resumed;
1843
2562
  series.left = keptSeriesCursor(expanded.snapshot);
1844
2563
  }
2564
+ };
2565
+ /** A stored series' exception, read from its instants: they are what the store holds. */
2566
+ const storedOverride = (uid, event, lengths, exception) => {
2567
+ const at = exception.recurrenceId.getTime();
2568
+ const start = exception.start?.getTime() ?? at;
2569
+ const end = exception.end?.getTime() ?? at + (lengths.exact ?? 0);
2570
+ // A cancelled exception is a cancellation with no time, whatever the series' status.
2571
+ const change = exception.cancelled
2572
+ ? normalizeExternalCalendarChange({
2573
+ externalId: uid,
2574
+ recurrenceId: new Date(at).toISOString(),
2575
+ status: 'cancelled',
2576
+ })
2577
+ : makeChange(event.component, uid, start, end, at);
2578
+ return {
2579
+ at,
2580
+ start,
2581
+ end,
2582
+ change,
2583
+ component: event.component,
2584
+ range: false,
2585
+ lengths,
2586
+ recurrenceWall: 0,
2587
+ };
2588
+ };
2589
+ for (const [uid, group] of groups) {
2590
+ const before = changes.length;
2591
+ try {
2592
+ readSeries(uid, group);
2593
+ separateOccurrences(changes, before, expansionWindow);
2594
+ }
2595
+ catch (error) {
2596
+ if (!refused || !(error instanceof CalendarContractError))
2597
+ throw error;
2598
+ changes.length = before;
2599
+ const refusal = { uid, code: error.code };
2600
+ if (error instanceof CalendarExpansionLimitError)
2601
+ refusal.limit = true;
2602
+ refused.push(refusal);
2603
+ }
1845
2604
  }
1846
2605
  return changes;
1847
2606
  }
@@ -1851,6 +2610,41 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
1851
2610
  throw new CalendarContractError('invalid_icalendar');
1852
2611
  }
1853
2612
  }
2613
+ /**
2614
+ * Make one series' busy occurrences, `changes` from `from` on, disjoint without freeing a minute of
2615
+ * them: where one ends after the next starts, it ends where the next starts, and the next ends at
2616
+ * the later of the two ends. The union rule makes an occurrence of about a day or longer reach
2617
+ * into the next one where the clocks go forward (24 exact hours from midnight end at 01:00 the
2618
+ * next day), and the store holds each occurrence as a reservation under one EXCLUDE constraint,
2619
+ * which refuses two that overlap. Two that start at the same instant are left as they are. An
2620
+ * occurrence cut to end before the window is dropped: the next one holds its time there.
2621
+ */
2622
+ function separateOccurrences(changes, from, window) {
2623
+ const busy = changes
2624
+ .slice(from)
2625
+ .filter((change) => change.busy && change.start !== null && change.end !== null)
2626
+ .sort((left, right) => left.start.getTime() - right.start.getTime());
2627
+ const cut = new Set();
2628
+ for (let index = 1; index < busy.length; index++) {
2629
+ const before = busy[index - 1];
2630
+ const change = busy[index];
2631
+ const start = change.start.getTime();
2632
+ const reach = before.end.getTime();
2633
+ if (reach <= start || before.start.getTime() === start)
2634
+ continue;
2635
+ if (reach > change.end.getTime())
2636
+ change.end = new Date(reach);
2637
+ before.end = new Date(start);
2638
+ cut.add(before);
2639
+ }
2640
+ if (!window || cut.size === 0)
2641
+ return;
2642
+ for (let index = changes.length - 1; index >= from; index--) {
2643
+ const change = changes[index];
2644
+ if (cut.has(change) && change.end <= window.start)
2645
+ changes.splice(index, 1);
2646
+ }
2647
+ }
1854
2648
  function requiredEventText(value, code) {
1855
2649
  const normalized = value.trim();
1856
2650
  if (!normalized || Buffer.byteLength(normalized, 'utf8') > MAX_TRUSTED_SUMMARY_BYTES) {
@@ -1869,6 +2663,16 @@ function addDateTimeProperty(component, name, value, timezone) {
1869
2663
  }
1870
2664
  component.addProperty(property);
1871
2665
  }
2666
+ /**
2667
+ * Whether `instant`'s wall time in `timezone` names it again as RFC 5545 §3.3.5 reads a wall time:
2668
+ * not for the second pass of an hour the clocks repeat, which reads as the first.
2669
+ */
2670
+ function wallTimeNames(instant, timezone) {
2671
+ if (timezone === 'UTC')
2672
+ return true;
2673
+ const named = zonedDateTimeToUtc(zonedDateTimeFields(instant, timezone), timezone);
2674
+ return named.getTime() === instant.getTime();
2675
+ }
1872
2676
  function attendeeRoleValue(role) {
1873
2677
  switch (role) {
1874
2678
  case 'chair':
@@ -1928,10 +2732,23 @@ function eventComponent(params) {
1928
2732
  component.updatePropertyWithValue('uid', params.uid);
1929
2733
  component.updatePropertyWithValue('dtstamp', ICAL.Time.fromJSDate(params.updatedAt, true));
1930
2734
  component.updatePropertyWithValue('last-modified', ICAL.Time.fromJSDate(params.updatedAt, true));
1931
- addDateTimeProperty(component, 'dtstart', params.event.start, params.event.timezone);
1932
- addDateTimeProperty(component, 'dtend', params.event.end, params.event.timezone);
2735
+ const { start, end, timezone } = params.event;
2736
+ const recurring = params.includeRecurrence === true && params.event.recurrence !== undefined;
2737
+ // A series recurs at DTSTART's wall time, so its DTSTART is written in its zone. Any other time
2738
+ // is written in its zone only where that wall time names it (wallTimeNames), else in UTC: the
2739
+ // second 01:30 of the night the clocks go back reads, in its zone, as the first.
2740
+ addDateTimeProperty(component, 'dtstart', start, recurring || wallTimeNames(start, timezone) ? timezone : 'UTC');
2741
+ if (recurring && !wallTimeNames(start, timezone)) {
2742
+ // Its length as a DURATION, exact (RFC 5545 §3.3.6), since its DTSTART reads an hour early:
2743
+ // emitITipCalendar adds an override that puts the first instance at its own instants.
2744
+ component.updatePropertyWithValue('duration', ICAL.Duration.fromSeconds((end.getTime() - start.getTime()) / 1000));
2745
+ }
2746
+ else {
2747
+ addDateTimeProperty(component, 'dtend', end, wallTimeNames(end, timezone) ? timezone : 'UTC');
2748
+ }
1933
2749
  if (params.recurrenceId) {
1934
- addDateTimeProperty(component, 'recurrence-id', params.recurrenceId, params.event.timezone);
2750
+ // In UTC: the instant the occurrence starts names it, wherever the clocks change.
2751
+ addDateTimeProperty(component, 'recurrence-id', params.recurrenceId, 'UTC');
1935
2752
  }
1936
2753
  component.updatePropertyWithValue('summary', params.event.summary);
1937
2754
  if (params.event.description) {
@@ -1951,6 +2768,98 @@ function eventComponent(params) {
1951
2768
  }
1952
2769
  return component;
1953
2770
  }
2771
+ const WEEKDAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
2772
+ /** How many years past its last stored time a series' VTIMEZONE lists, before its rules go on. */
2773
+ const VTIMEZONE_SERIES_YEARS = 30;
2774
+ /** And at most, from its first year: a longer series is left to the rules. */
2775
+ const MAX_VTIMEZONE_YEARS = 60;
2776
+ /** Changes of a zone already found, by zone and years: Intl answers the same for a process. */
2777
+ const zoneChanges = new Map();
2778
+ /**
2779
+ * A VTIMEZONE (RFC 5545 §3.6.5) for `timezone` as Intl reads it from `fromYear` to `toYear`: the
2780
+ * offset at the start of `fromYear`, then every change (zoneTransitions). Changes in consecutive
2781
+ * years on the same weekday of the same month, at the same local time and between the same
2782
+ * offsets, are one observance with a yearly rule (`BYMONTH=3;BYDAY=-1SU`), with an UNTIL where they
2783
+ * stop; one still running at `toYear` after three years at least is left open, so a reader carries
2784
+ * the zone on past `toYear` as the IANA rules do. Any other change is an observance of its own. An
2785
+ * observance starts at the local time before the change, as §3.6.5 has it, and offsets are written
2786
+ * in whole minutes: ical.js 2.2.1 reads no seconds in one (UtcOffset.fromString).
2787
+ */
2788
+ function vtimezoneComponent(timezone, fromYear, toYear) {
2789
+ const from = Date.UTC(fromYear, 0, 1);
2790
+ const key = `${timezone}|${fromYear}|${toYear}`;
2791
+ let changes = zoneChanges.get(key);
2792
+ if (!changes) {
2793
+ changes = zoneTransitions(timezone, from, Date.UTC(toYear + 1, 0, 1));
2794
+ if (zoneChanges.size >= 64)
2795
+ zoneChanges.clear();
2796
+ zoneChanges.set(key, changes);
2797
+ }
2798
+ const zone = new ICAL.Component('vtimezone');
2799
+ zone.updatePropertyWithValue('tzid', timezone);
2800
+ const offset = (ms) => ICAL.UtcOffset.fromSeconds(Math.round(ms / 60_000) * 60);
2801
+ const observance = (at, before, after) => {
2802
+ const component = new ICAL.Component(after > before ? 'daylight' : 'standard');
2803
+ const wall = new Date(at + before);
2804
+ component.updatePropertyWithValue('dtstart', ICAL.Time.fromData({
2805
+ year: wall.getUTCFullYear(),
2806
+ month: wall.getUTCMonth() + 1,
2807
+ day: wall.getUTCDate(),
2808
+ hour: wall.getUTCHours(),
2809
+ minute: wall.getUTCMinutes(),
2810
+ second: wall.getUTCSeconds(),
2811
+ isDate: false,
2812
+ }));
2813
+ component.updatePropertyWithValue('tzoffsetfrom', offset(before));
2814
+ component.updatePropertyWithValue('tzoffsetto', offset(after));
2815
+ zone.addSubcomponent(component);
2816
+ return component;
2817
+ };
2818
+ const initial = changes[0]?.before ?? zonedOffsetMinutes(new Date(from), timezone) * 60_000;
2819
+ observance(from - initial, initial, initial);
2820
+ const open = new Map();
2821
+ const runs = [];
2822
+ for (const change of changes) {
2823
+ const wall = new Date(change.at + change.before);
2824
+ const year = wall.getUTCFullYear();
2825
+ const month = wall.getUTCMonth() + 1;
2826
+ const day = wall.getUTCDate();
2827
+ const weekday = WEEKDAYS[wall.getUTCDay()];
2828
+ const days = [`${Math.ceil(day / 7)}${weekday}`];
2829
+ if (day > new Date(Date.UTC(year, month, 0)).getUTCDate() - 7)
2830
+ days.push(`-1${weekday}`);
2831
+ const kind = `${change.before}|${change.after}|${month}|${wall.getTime() % DAY_MS}`;
2832
+ const run = open.get(kind);
2833
+ const shared = run?.days.filter((value) => days.includes(value)) ?? [];
2834
+ if (run && run.lastYear === year - 1 && shared.length > 0) {
2835
+ run.changes.push(change);
2836
+ run.days = shared;
2837
+ run.lastYear = year;
2838
+ }
2839
+ else {
2840
+ const started = { changes: [change], days, lastYear: year };
2841
+ open.set(kind, started);
2842
+ runs.push(started);
2843
+ }
2844
+ }
2845
+ for (const run of runs) {
2846
+ const first = run.changes[0];
2847
+ const last = run.changes[run.changes.length - 1];
2848
+ const running = run.lastYear >= toYear;
2849
+ if (run.changes.length >= 3 || (run.changes.length === 2 && !running)) {
2850
+ const month = new Date(first.at + first.before).getUTCMonth() + 1;
2851
+ const until = running
2852
+ ? ''
2853
+ : `;UNTIL=${ICAL.Time.fromJSDate(new Date(last.at), true).toICALString()}`;
2854
+ observance(first.at, first.before, first.after).updatePropertyWithValue('rrule', ICAL.Recur.fromString(`FREQ=YEARLY;BYMONTH=${month};BYDAY=${run.days[0]}${until}`));
2855
+ }
2856
+ else {
2857
+ for (const change of run.changes)
2858
+ observance(change.at, change.before, change.after);
2859
+ }
2860
+ }
2861
+ return zone;
2862
+ }
1954
2863
  /** Deterministic RFC 5545 + iTIP payload, validated by the installed ical.js implementation. */
1955
2864
  export function emitITipCalendar(input, method) {
1956
2865
  try {
@@ -2014,8 +2923,19 @@ export function emitITipCalendar(input, method) {
2014
2923
  };
2015
2924
  if (status !== undefined)
2016
2925
  masterParams.status = status;
2017
- calendar.addSubcomponent(eventComponent(masterParams));
2018
- for (const exception of event.recurrence?.exceptions ?? []) {
2926
+ const events = [eventComponent(masterParams)];
2927
+ // A series whose first instance starts in the second pass of an hour the clocks repeat has a
2928
+ // DTSTART that reads as the first pass: an override (keyed by that first pass) puts the
2929
+ // instance at its own instants, unless an exception already says where it is.
2930
+ const firstInstance = event.recurrence && !wallTimeNames(event.start, event.timezone)
2931
+ ? zonedDateTimeToUtc(zonedDateTimeFields(event.start, event.timezone), event.timezone)
2932
+ : undefined;
2933
+ const exceptions = [...(event.recurrence?.exceptions ?? [])];
2934
+ if (firstInstance &&
2935
+ !exceptions.some((exception) => exception.recurrenceId.getTime() === event.start.getTime())) {
2936
+ exceptions.push({ recurrenceId: event.start, start: event.start, end: event.end });
2937
+ }
2938
+ for (const exception of exceptions) {
2019
2939
  const exceptionEvent = { ...event };
2020
2940
  delete exceptionEvent.recurrence;
2021
2941
  if (exception.start && exception.end) {
@@ -2031,13 +2951,39 @@ export function emitITipCalendar(input, method) {
2031
2951
  uid,
2032
2952
  sequence,
2033
2953
  updatedAt,
2034
- recurrenceId: exception.recurrenceId,
2954
+ recurrenceId: firstInstance && exception.recurrenceId.getTime() === event.start.getTime()
2955
+ ? firstInstance
2956
+ : exception.recurrenceId,
2035
2957
  };
2036
2958
  const exceptionStatus = exception.cancelled ? 'cancelled' : status;
2037
2959
  if (exceptionStatus !== undefined)
2038
2960
  exceptionParams.status = exceptionStatus;
2039
- calendar.addSubcomponent(eventComponent(exceptionParams));
2961
+ events.push(eventComponent(exceptionParams));
2040
2962
  }
2963
+ // RFC 5545 §3.6.5: a VTIMEZONE for the zone a time names, over every year the event's times
2964
+ // fall in, and a series' rule's (to its UNTIL, else thirty years on, where its rules go on).
2965
+ const zoned = events.some((component) => component.getAllProperties().some((property) => property.getParameter('tzid')));
2966
+ if (zoned) {
2967
+ const times = [event.start, event.end];
2968
+ for (const exception of exceptions) {
2969
+ times.push(exception.recurrenceId);
2970
+ if (exception.start)
2971
+ times.push(exception.start);
2972
+ if (exception.end)
2973
+ times.push(exception.end);
2974
+ }
2975
+ const years = times.map((time) => time.getUTCFullYear());
2976
+ const fromYear = Math.min(...years) - 1;
2977
+ let toYear = Math.max(...years) + 1;
2978
+ if (event.recurrence) {
2979
+ const until = ICAL.Recur.fromString(event.recurrence.rrule).until;
2980
+ toYear = until ? Math.max(toYear, until.year + 1) : toYear + VTIMEZONE_SERIES_YEARS;
2981
+ }
2982
+ const lastYear = Math.min(toYear, fromYear + MAX_VTIMEZONE_YEARS);
2983
+ calendar.addSubcomponent(vtimezoneComponent(event.timezone, fromYear, lastYear));
2984
+ }
2985
+ for (const component of events)
2986
+ calendar.addSubcomponent(component);
2041
2987
  const output = `${calendar
2042
2988
  .toString()
2043
2989
  .replace(/\r?\n/g, '\r\n')
@@ -2182,11 +3128,10 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
2182
3128
  const root = new ICAL.Component(ICAL.parse(input));
2183
3129
  if (root.name !== 'vcalendar')
2184
3130
  throw new CalendarContractError('invalid_icalendar');
2185
- budget.check();
2186
3131
  let Budgeted;
2187
3132
  budgetTimezoneRules(root, () => (Budgeted ??= budgetedRecurIterator(budget)));
2188
3133
  budgetTimezoneCoverage(root, budget);
2189
- const timeValue = (value, zone, fallback) => budget.search(() => calendarTimeValue(value, zone, fallback));
3134
+ const timeValue = (value, zone, fallback) => calendarTimeValue(value, zone, fallback);
2190
3135
  const embeddedTimezone = root.getFirstPropertyValue('x-wr-timezone');
2191
3136
  const defaultTimezone = calendarTimezone?.trim() ||
2192
3137
  (typeof embeddedTimezone === 'string' ? embeddedTimezone.trim() : '') ||
@@ -2195,13 +3140,13 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
2195
3140
  if (components.length > MAX_ICAL_COMPONENTS) {
2196
3141
  throw new CalendarContractError('invalid_icalendar');
2197
3142
  }
2198
- boundComponents(components, budget);
3143
+ boundComponents(components);
2199
3144
  // Times are read as a feed's are, so a stored series occupies what the feed reports: zones it
2200
3145
  // names without defining them, floating times in the calendar's zone, all-day EXDATEs as days.
2201
3146
  frameEventTimes(root, components, defaultTimezone, budget);
2202
3147
  const masters = new Map();
2203
3148
  const exceptionsByUid = new Map();
2204
- for (const event of feedEvents(components, budget)) {
3149
+ for (const event of feedEvents(components)) {
2205
3150
  if (event.isRecurrenceException()) {
2206
3151
  const exceptions = exceptionsByUid.get(event.uid) ?? [];
2207
3152
  if (exceptions.length >= MAX_ICAL_EXCEPTIONS_PER_SERIES) {
@@ -2220,11 +3165,47 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
2220
3165
  if (!masters.has(uid))
2221
3166
  throw new CalendarContractError('invalid_icalendar');
2222
3167
  }
3168
+ // Every VEVENT's rule as written must be one RFC 5545 §3.3.10 allows: ical.js has already read
3169
+ // `COUNT=0` as no count, `COUNT=3X` as 3 and the last of two FREQs, so the text is read again.
3170
+ for (const rule of veventRuleTexts(input)) {
3171
+ if (canonicalRecurrenceRule(rule) === null) {
3172
+ throw new CalendarContractError('invalid_icalendar');
3173
+ }
3174
+ }
3175
+ for (const component of components) {
3176
+ // RFC 5545 §3.6.1: 'dtend' and 'duration' MUST NOT occur in the same eventprop.
3177
+ if (component.hasProperty('dtend') && component.hasProperty('duration')) {
3178
+ throw new CalendarContractError('invalid_icalendar');
3179
+ }
3180
+ }
3181
+ /** An event's start and end as instants: DTEND as RFC 5545 §3.3.5 reads it, DURATION §3.3.6. */
3182
+ const interval = (event, fallbackLengthMs) => {
3183
+ const component = event.component;
3184
+ const startZone = component.getFirstProperty('dtstart')?.getParameter('tzid');
3185
+ const reader = zoneReader(event.startDate, startZone, defaultTimezone);
3186
+ const startWall = wallFrameOf(event.startDate);
3187
+ const start = reader.toInstant(startWall);
3188
+ const endProperty = component.getFirstProperty('dtend');
3189
+ const duration = component.getFirstPropertyValue('duration');
3190
+ let end;
3191
+ if (endProperty) {
3192
+ end = timeValue(endProperty.getFirstValue(), endProperty.getParameter('tzid') ?? startZone, defaultTimezone).getTime();
3193
+ }
3194
+ else if (duration instanceof ICAL.Duration && !event.startDate.isDate) {
3195
+ end = durationEnd(reader, startWall, start, duration);
3196
+ }
3197
+ else if (fallbackLengthMs !== undefined && !duration) {
3198
+ end = start + fallbackLengthMs;
3199
+ }
3200
+ else {
3201
+ end = timeValue(event.endDate, startZone, defaultTimezone).getTime();
3202
+ }
3203
+ return { reader, startWall, start, end };
3204
+ };
2223
3205
  const trusted = [];
2224
3206
  for (const [uid, event] of masters) {
2225
3207
  const component = event.component;
2226
3208
  const startProperty = component.getFirstProperty('dtstart');
2227
- const endProperty = component.getFirstProperty('dtend');
2228
3209
  // An all-day event (DATE-valued DTSTART) covers whole local days of the calendar's zone. Its
2229
3210
  // end must be a DATE too (RFC 5545 §3.6.1), and a DURATION may only count days or weeks.
2230
3211
  const allDay = event.startDate.isDate;
@@ -2251,9 +3232,10 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
2251
3232
  if (transparencyValue !== 'opaque' && transparencyValue !== 'transparent') {
2252
3233
  throw new CalendarContractError('invalid_icalendar');
2253
3234
  }
3235
+ const own = interval(event);
2254
3236
  const contentInput = {
2255
- start: timeValue(event.startDate, startProperty?.getParameter('tzid'), defaultTimezone),
2256
- end: timeValue(event.endDate, endProperty?.getParameter('tzid') ?? startProperty?.getParameter('tzid'), defaultTimezone),
3237
+ start: new Date(own.start),
3238
+ end: new Date(own.end),
2257
3239
  timezone: validTimezone(timezone),
2258
3240
  summary: String(component.getFirstPropertyValue('summary') ?? ''),
2259
3241
  transparency: transparencyValue,
@@ -2284,53 +3266,104 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
2284
3266
  }
2285
3267
  const rrule = rrules[0]?.getFirstValue();
2286
3268
  if (rrule) {
2287
- const exceptions = [];
3269
+ // A series is stored as an instant and a zone, and recurs at that instant's wall time. A
3270
+ // DTSTART the clocks skip (02:30 the night 02:00 becomes 03:00, or an all-day series'
3271
+ // midnight where the clocks go forward at midnight, as in Santiago and Havana) has no
3272
+ // instant that reads as it, so storing it would move every occurrence an hour: it is
3273
+ // refused.
3274
+ if (own.reader.toWall(own.start) !== own.startWall) {
3275
+ throw new CalendarContractError('invalid_icalendar');
3276
+ }
3277
+ // Its later occurrences end on the wall clock where its first one's end instant reads, and
3278
+ // last at least as long. A DTEND the clocks skip reads an hour later than written, and a
3279
+ // DURATION that counts days lasts a day on the calendar, 23 or 25 hours across a change,
3280
+ // where the first one's instants say 24: either would hold an hour the feed does not. They
3281
+ // are refused (days only outside UTC, where every day is 24 hours).
3282
+ if (!allDay) {
3283
+ const endProperty = component.getFirstProperty('dtend');
3284
+ const endTime = endProperty?.getFirstValue();
3285
+ const startTime = event.startDate;
3286
+ const sameZone = endTime instanceof ICAL.Time &&
3287
+ (ownZone(endTime) ?? endProperty?.getParameter('tzid') ?? timezoneParameter) ===
3288
+ (ownZone(startTime) ?? timezoneParameter);
3289
+ if (sameZone && own.reader.toWall(own.end) !== wallFrameOf(endTime)) {
3290
+ throw new CalendarContractError('invalid_icalendar');
3291
+ }
3292
+ const duration = component.getFirstPropertyValue('duration');
3293
+ if (duration instanceof ICAL.Duration &&
3294
+ duration.weeks * 7 + duration.days > 0 &&
3295
+ own.reader !== UTC_READER) {
3296
+ throw new CalendarContractError('invalid_icalendar');
3297
+ }
3298
+ }
3299
+ const lengthMs = own.end - own.start;
3300
+ const exceptions = new Map();
2288
3301
  for (const exdateProperty of component.getAllProperties('exdate')) {
2289
3302
  for (const exdate of exdateProperty.getValues()) {
2290
- if (exceptions.length >= MAX_ICAL_EXCEPTIONS_PER_SERIES) {
3303
+ if (exceptions.size >= MAX_ICAL_EXCEPTIONS_PER_SERIES) {
2291
3304
  throw new CalendarContractError('invalid_icalendar');
2292
3305
  }
2293
- exceptions.push({
2294
- recurrenceId: timeValue(exdate, exdateProperty.getParameter('tzid') ?? startProperty?.getParameter('tzid'), defaultTimezone),
2295
- cancelled: true,
2296
- });
3306
+ const recurrenceId = timeValue(exdate, exdateProperty.getParameter('tzid') ?? startProperty?.getParameter('tzid'), defaultTimezone);
3307
+ exceptions.set(recurrenceId.getTime(), { recurrenceId, cancelled: true });
2297
3308
  }
2298
3309
  }
2299
3310
  for (const exception of exceptionsByUid.get(uid) ?? []) {
2300
- if (exceptions.length >= MAX_ICAL_EXCEPTIONS_PER_SERIES) {
2301
- throw new CalendarContractError('invalid_icalendar');
2302
- }
2303
3311
  const recurrenceProperty = exception.component.getFirstProperty('recurrence-id');
2304
3312
  const range = recurrenceProperty?.getFirstParameter('range');
2305
3313
  if (range)
2306
3314
  throw new CalendarContractError('invalid_icalendar');
3315
+ // A stored series has one transparency; an instance that differs (an opaque instance
3316
+ // of a free series, a free one of a busy series) cannot be stored as it is.
3317
+ const transparency = String(exception.component.getFirstPropertyValue('transp') ?? 'opaque').toLowerCase();
3318
+ if (transparency !== transparencyValue) {
3319
+ throw new CalendarContractError('invalid_icalendar');
3320
+ }
2307
3321
  const recurrenceId = timeValue(exception.recurrenceId, recurrenceProperty?.getParameter('tzid') ?? startProperty?.getParameter('tzid'), defaultTimezone);
2308
3322
  const cancelled = String(exception.component.getFirstPropertyValue('status') ?? '').toLowerCase() ===
2309
3323
  'cancelled';
3324
+ // An instance an EXDATE removes but an override still describes is the override, as
3325
+ // the provider shows it. One that keeps its time (a new title, a reply) is still kept,
3326
+ // and one with no end lasts its series' length.
2310
3327
  const parsedException = { recurrenceId };
2311
- if (cancelled)
3328
+ if (cancelled) {
2312
3329
  parsedException.cancelled = true;
2313
- const replacementStart = timeValue(exception.startDate, exception.component.getFirstProperty('dtstart')?.getParameter('tzid'), defaultTimezone);
2314
- const replacementEnd = timeValue(exception.endDate, exception.component.getFirstProperty('dtend')?.getParameter('tzid') ??
2315
- exception.component.getFirstProperty('dtstart')?.getParameter('tzid'), defaultTimezone);
2316
- if (replacementStart.getTime() !== recurrenceId.getTime() ||
2317
- replacementEnd.getTime() - replacementStart.getTime() !==
2318
- contentInput.end.getTime() - contentInput.start.getTime()) {
2319
- parsedException.start = replacementStart;
2320
- parsedException.end = replacementEnd;
2321
3330
  }
2322
- exceptions.push(parsedException);
3331
+ else {
3332
+ const replacement = interval(exception, lengthMs);
3333
+ parsedException.start = new Date(replacement.start);
3334
+ parsedException.end = new Date(replacement.end);
3335
+ }
3336
+ exceptions.set(recurrenceId.getTime(), parsedException);
3337
+ if (exceptions.size > MAX_ICAL_EXCEPTIONS_PER_SERIES) {
3338
+ throw new CalendarContractError('invalid_icalendar');
3339
+ }
2323
3340
  }
2324
3341
  contentInput.recurrence = {
2325
3342
  rrule: allDay ? allDayRecurrenceRule(rrule.toString(), timezone) : rrule.toString(),
2326
- exceptions,
3343
+ exceptions: [...exceptions.values()],
2327
3344
  };
2328
3345
  }
2329
3346
  else if (component.getAllProperties('exdate').length > 0 ||
2330
3347
  (exceptionsByUid.get(uid)?.length ?? 0) > 0) {
2331
3348
  throw new CalendarContractError('invalid_icalendar');
2332
3349
  }
2333
- const content = normalizeCalendarEventContent(contentInput);
3350
+ let content = normalizeCalendarEventContent(contentInput);
3351
+ // An EXDATE or a cancelled instance that names no occurrence frees nothing in the feed (RFC
3352
+ // 5545 §3.8.5.1), so it is dropped rather than stored, which the store refuses. A moved one
3353
+ // that names none is busy in the feed at its own time, which a stored series cannot hold.
3354
+ const recurrence = content.recurrence;
3355
+ if (recurrence?.exceptions && recurrence.exceptions.length > 0) {
3356
+ const unmatched = unmatchedExceptions(content, budget);
3357
+ const named = (exception) => !unmatched.has(exception.recurrenceId.getTime());
3358
+ if (recurrence.exceptions.some((exception) => !exception.cancelled && !named(exception))) {
3359
+ throw new CalendarContractError('invalid_icalendar');
3360
+ }
3361
+ const kept = recurrence.exceptions.filter(named);
3362
+ content = {
3363
+ ...content,
3364
+ recurrence: kept.length > 0 ? { ...recurrence, exceptions: kept } : { rrule: recurrence.rrule },
3365
+ };
3366
+ }
2334
3367
  const sequence = Number(component.getFirstPropertyValue('sequence') ?? 0);
2335
3368
  if (!Number.isSafeInteger(sequence) || sequence < 0) {
2336
3369
  throw new CalendarContractError('invalid_icalendar');
@@ -2378,6 +3411,160 @@ export function expandCalendarEventSeries(input, window, options) {
2378
3411
  return expandOccurrences(input, window, options.stored ? FEED_LIMITS : EVENT_LIMITS, options.cursor, options.resumeOnly === true);
2379
3412
  }
2380
3413
  function expandOccurrences(input, window, limits, cursor, resumeOnly = false) {
3414
+ try {
3415
+ return expandEventOccurrences(input, window, limits, cursor, resumeOnly);
3416
+ }
3417
+ catch (error) {
3418
+ // A series stored by an earlier Slotlock that this one refuses (a rule shape it no longer
3419
+ // expands, an exception that names no occurrence, a zone written as a UTC offset) is refused as
3420
+ // maintenance rolls it, so the roll counts it and the series keeps its old coverage, rather than
3421
+ // stopping every series after it. One no Slotlock stored is an error, as it always was.
3422
+ if (limits === FEED_LIMITS &&
3423
+ error instanceof CalendarContractError &&
3424
+ !(error instanceof CalendarExpansionLimitError) &&
3425
+ storedByAnEarlierRelease(input)) {
3426
+ throw new CalendarExpansionLimitError();
3427
+ }
3428
+ throw error;
3429
+ }
3430
+ }
3431
+ /**
3432
+ * Whether a Slotlock before this one could have stored `event`. Slotlock 0.2.0 kept any rule
3433
+ * ical.js reads that starts with FREQ= (`FREQ=DAILY;COUNT=0`, `FREQ=HOURLY`), in any zone Intl
3434
+ * knows (`+05:00` included), and checked the rest of an event as this release does; a rule such as
3435
+ * `FREQ=NEVERLY`, which ical.js cannot read, it refused.
3436
+ */
3437
+ function storedByAnEarlierRelease(event) {
3438
+ const rule = event.recurrence?.rrule;
3439
+ if (rule !== undefined) {
3440
+ if (typeof rule !== 'string')
3441
+ return false;
3442
+ const written = rule.trim().toUpperCase();
3443
+ if (!written.startsWith('FREQ=') ||
3444
+ Buffer.byteLength(written, 'utf8') > 4_096 ||
3445
+ /[\r\n]/.test(written)) {
3446
+ return false;
3447
+ }
3448
+ try {
3449
+ ICAL.Recur.fromString(written);
3450
+ }
3451
+ catch {
3452
+ return false;
3453
+ }
3454
+ }
3455
+ if (typeof event.timezone !== 'string' || !isKnownTimezone(event.timezone))
3456
+ return false;
3457
+ const { recurrence: _rule, ...rest } = event;
3458
+ try {
3459
+ normalizeCalendarEventContent({ ...rest, timezone: 'UTC' });
3460
+ normalizeExceptions(event.recurrence?.exceptions);
3461
+ }
3462
+ catch {
3463
+ return false;
3464
+ }
3465
+ return true;
3466
+ }
3467
+ /** The UID a stored series is expanded under: the store's series carry none of their own. */
3468
+ function seriesUid(event) {
3469
+ return `event-${createHash('sha256')
3470
+ .update(`${event.start.toISOString()}|${event.summary}`)
3471
+ .digest('hex')}@slotlock.local`;
3472
+ }
3473
+ /**
3474
+ * The instants of a normalized series' exceptions that name none of its occurrences, searched from
3475
+ * its start to the last of them within `budget`, as the store searches them when the series is
3476
+ * written.
3477
+ */
3478
+ function unmatchedExceptions(event, budget) {
3479
+ const unmatched = new Set();
3480
+ parseCalendarChanges(seriesCalendar(event, seriesUid(event)), event.timezone, { start: event.start, end: new Date(event.start.getTime() + DAY_MS) }, budget, {
3481
+ cursor: null,
3482
+ resumeLimitMs: Number.NEGATIVE_INFINITY,
3483
+ left: null,
3484
+ resumed: false,
3485
+ resumeOnly: false,
3486
+ stored: {
3487
+ start: event.start,
3488
+ end: event.end,
3489
+ exceptions: event.recurrence?.exceptions ?? [],
3490
+ },
3491
+ unmatched,
3492
+ });
3493
+ return unmatched;
3494
+ }
3495
+ /**
3496
+ * Why this Slotlock refuses an event an earlier one stored, in words, or null when it expands it.
3497
+ * Maintenance counts such a series as refused on every roll and leaves it its old coverage: a rule
3498
+ * shape ical.js expands wrongly (recurrenceRuleProblem), COUNT with a yearly day some years lack, a
3499
+ * rule it no longer stores at all, or exceptions that name no occurrence (searched from the series'
3500
+ * start to the last of them, within a roll's budget). It writes nothing, so it can be run over
3501
+ * stored events before an upgrade.
3502
+ */
3503
+ export function storedCalendarEventProblem(event) {
3504
+ const rule = event.recurrence?.rrule;
3505
+ const shape = rule === undefined ? null : recurrenceRuleProblem(rule);
3506
+ if (shape !== null)
3507
+ return shape;
3508
+ let content;
3509
+ try {
3510
+ if (rule !== undefined) {
3511
+ const recur = ICAL.Recur.fromString(canonicalRecurrenceRule(rule));
3512
+ const start = zonedDateTimeFields(event.start, validTimezone(event.timezone));
3513
+ const series = seriesShapeProblem(recur, start);
3514
+ if (series !== null)
3515
+ return series;
3516
+ }
3517
+ content = normalizeCalendarEventContent(event);
3518
+ }
3519
+ catch (error) {
3520
+ if (!(error instanceof CalendarContractError))
3521
+ throw error;
3522
+ return rule === undefined
3523
+ ? 'not an event Slotlock stores'
3524
+ : 'not a rule Slotlock stores: daily or less often, able to occur, in an IANA time zone';
3525
+ }
3526
+ if (!content.recurrence?.exceptions?.length)
3527
+ return null;
3528
+ let unmatched;
3529
+ try {
3530
+ unmatched = unmatchedExceptions(content, new RecurrenceBudget(FEED_LIMITS));
3531
+ }
3532
+ catch (error) {
3533
+ if (error instanceof CalendarExpansionLimitError) {
3534
+ return 'its exceptions take more work to check than a roll may spend';
3535
+ }
3536
+ if (error instanceof CalendarContractError)
3537
+ return 'a series Slotlock cannot expand';
3538
+ throw error;
3539
+ }
3540
+ if (unmatched.size === 0)
3541
+ return null;
3542
+ const named = [...unmatched].sort((a, b) => a - b).map((at) => new Date(at).toISOString());
3543
+ const which = named.length === 1 ? 'an exception that names' : `${named.length} exceptions that name`;
3544
+ return `${which} no occurrence: ${named.join(', ')}`;
3545
+ }
3546
+ /**
3547
+ * The text a stored series is expanded from: its DTSTART's wall time in its zone and its rule, for
3548
+ * the iterator. Its first occurrence and its exceptions are not read back from text: a wall time
3549
+ * in an hour the clocks repeat names its first instant (RFC 5545 §3.3.5) whichever one was stored,
3550
+ * so they go to the expansion as the instants they are (SeriesResume.stored).
3551
+ */
3552
+ function seriesCalendar(event, uid) {
3553
+ const calendar = new ICAL.Component(['vcalendar', [], []]);
3554
+ calendar.updatePropertyWithValue('prodid', '-//Slotlock Project//Agent Calendar//EN');
3555
+ calendar.updatePropertyWithValue('version', '2.0');
3556
+ const component = new ICAL.Component('vevent');
3557
+ component.updatePropertyWithValue('uid', uid);
3558
+ component.updatePropertyWithValue('dtstamp', ICAL.Time.fromJSDate(event.start, true));
3559
+ addDateTimeProperty(component, 'dtstart', event.start, event.timezone);
3560
+ addDateTimeProperty(component, 'dtend', event.end, 'UTC');
3561
+ component.updatePropertyWithValue('status', (event.status ?? 'confirmed').toUpperCase());
3562
+ component.updatePropertyWithValue('transp', event.transparency === 'transparent' ? 'TRANSPARENT' : 'OPAQUE');
3563
+ component.updatePropertyWithValue('rrule', ICAL.Recur.fromString(event.recurrence.rrule));
3564
+ calendar.addSubcomponent(component);
3565
+ return `${calendar.toString().replace(/\r?\n/g, '\r\n')}\r\n`;
3566
+ }
3567
+ function expandEventOccurrences(input, window, limits, cursor, resumeOnly) {
2381
3568
  const event = normalizeCalendarEventContent(input);
2382
3569
  if (!Number.isFinite(window.start.getTime()) ||
2383
3570
  !Number.isFinite(window.end.getTime()) ||
@@ -2395,9 +3582,7 @@ function expandOccurrences(input, window, limits, cursor, resumeOnly = false) {
2395
3582
  if (window.end.getTime() - window.start.getTime() > MAX_ICAL_WINDOW_MS) {
2396
3583
  throw new CalendarContractError('invalid_icalendar');
2397
3584
  }
2398
- const uid = `event-${createHash('sha256')
2399
- .update(`${event.start.toISOString()}|${event.summary}`)
2400
- .digest('hex')}@slotlock.local`;
3585
+ const uid = seriesUid(event);
2401
3586
  // A cursor may stand after an occurrence only if that occurrence ends before the window: unmoved,
2402
3587
  // within the series' length (and a clock change) of its start; moved, where its exception says.
2403
3588
  let resumeLimitMs = window.start.getTime() -
@@ -2408,8 +3593,15 @@ function expandOccurrences(input, window, limits, cursor, resumeOnly = false) {
2408
3593
  resumeLimitMs = Math.min(resumeLimitMs, exception.recurrenceId.getTime() - 1000);
2409
3594
  }
2410
3595
  }
2411
- const series = { cursor, resumeLimitMs, left: null, resumed: false, resumeOnly };
2412
- const parsed = parseCalendarChanges(emitITipCalendar({ ...event, uid, sequence: 0 }, 'PUBLISH'), event.timezone, window, limits, series);
3596
+ const series = {
3597
+ cursor,
3598
+ resumeLimitMs,
3599
+ left: null,
3600
+ resumed: false,
3601
+ resumeOnly,
3602
+ stored: { start: event.start, end: event.end, exceptions: event.recurrence.exceptions ?? [] },
3603
+ };
3604
+ const parsed = parseCalendarChanges(seriesCalendar(event, uid), event.timezone, window, limits, series);
2413
3605
  const occurrences = parsed
2414
3606
  .filter((change) => !change.deleted && change.start && change.end)
2415
3607
  .map((change) => ({