slotlock 0.1.1 → 0.3.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 (56) hide show
  1. package/CHANGELOG.md +455 -0
  2. package/CONTRIBUTING.md +55 -19
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +403 -103
  7. package/SECURITY.md +70 -5
  8. package/SPEC.md +161 -21
  9. package/agent-server.d.ts +76 -10
  10. package/agent-server.js +381 -146
  11. package/agent-store-backend.d.ts +22 -0
  12. package/agent-store-backend.js +598 -24
  13. package/api-keys.d.ts +22 -7
  14. package/api-keys.js +38 -8
  15. package/application-api.d.ts +67 -0
  16. package/application-api.js +552 -0
  17. package/config.d.ts +93 -0
  18. package/config.js +192 -0
  19. package/dashboard.d.ts +10 -1
  20. package/dashboard.js +23 -5
  21. package/ddl.d.ts +47 -4
  22. package/ddl.js +679 -25
  23. package/engine.js +20 -6
  24. package/event-schema.d.ts +59 -0
  25. package/event-schema.js +85 -0
  26. package/hooks.d.ts +7 -0
  27. package/hooks.js +13 -0
  28. package/ical-worker-thread.js +4 -2
  29. package/ical-worker.d.ts +8 -5
  30. package/ical-worker.js +9 -2
  31. package/index.d.ts +12 -8
  32. package/index.js +7 -5
  33. package/maintenance.d.ts +67 -0
  34. package/maintenance.js +213 -0
  35. package/node-server.d.ts +9 -1
  36. package/node-server.js +127 -6
  37. package/openapi.json +6024 -0
  38. package/package.json +3 -2
  39. package/rate-limit.d.ts +71 -0
  40. package/rate-limit.js +718 -0
  41. package/rest-api.d.ts +33 -18
  42. package/rest-api.js +141 -63
  43. package/rules.d.ts +19 -0
  44. package/rules.js +38 -7
  45. package/self-host.d.ts +26 -9
  46. package/self-host.js +427 -47
  47. package/slotlock-manifest.schema.json +6 -1
  48. package/slotlock.manifest.json +7 -2
  49. package/store.d.ts +359 -27
  50. package/store.js +2077 -296
  51. package/sync.d.ts +43 -2
  52. package/sync.js +1950 -277
  53. package/timezone.d.ts +77 -3
  54. package/timezone.js +265 -21
  55. package/types.d.ts +217 -10
  56. package/weekly-hours.js +2 -2
package/sync.js CHANGED
@@ -1,13 +1,15 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import ICAL from 'ical.js';
3
- import { 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,70 +791,161 @@ 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 remains `floating` and cannot
517
- // 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
+ }
530
828
  /**
531
- * What one parse may spend: steps searching for occurrences, and time from its start. The time is
532
- * checked at every step and after every piece of ical.js work the parse does, so work that takes no
533
- * steps is stopped at the next check.
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
+ }
862
+ /** The TZID of a time ical.js reads in a zone (a VTIMEZONE's, an IntlTimezone, UTC); else none. */
863
+ function ownZone(value) {
864
+ if (!(value instanceof ICAL.Time))
865
+ return undefined;
866
+ const tzid = value.zone?.tzid;
867
+ return tzid && tzid !== 'floating' && tzid !== 'local' ? tzid : undefined;
868
+ }
869
+ /**
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).
534
872
  */
535
873
  class RecurrenceBudget {
536
- limits;
537
874
  steps;
538
- 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();
539
882
  constructor(limits) {
540
- this.limits = limits;
541
883
  this.steps = limits.steps;
542
884
  }
543
- /** Throw once the parse has run longer than its limit. */
544
- check() {
545
- 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)
546
889
  expansionLimit();
547
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
+ }
548
914
  /**
549
- * Run one piece of ical.js work, then check the time: building an event's expansion, its next
550
- * 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.
551
920
  */
552
- search(work) {
553
- const result = work();
554
- this.check();
555
- return result;
556
- }
557
- /** Charge one step, throwing past either limit. */
558
- charge() {
559
- this.steps -= 1;
560
- if (this.steps < 0)
561
- expansionLimit();
562
- 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
+ }
563
949
  }
564
950
  }
565
951
  /**
@@ -574,13 +960,16 @@ function budgetedRecurIterator(budget) {
574
960
  }
575
961
  const inherited = ICAL.RecurIterator.prototype;
576
962
  const own = BudgetedRecurIterator.prototype;
577
- for (const name of ICAL_STEP_METHODS) {
963
+ for (const [name, cost] of Object.entries(ICAL_STEP_CHARGES)) {
578
964
  const step = inherited[name];
579
965
  if (typeof step !== 'function')
580
966
  throw new CalendarContractError('invalid_icalendar');
581
967
  own[name] = function (...args) {
582
- budget.charge();
583
- 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;
584
973
  };
585
974
  }
586
975
  return BudgetedRecurIterator;
@@ -603,15 +992,46 @@ function budgetRule(rule, Budgeted) {
603
992
  * values are changed before it is called: wrapping its iterators afterwards left that step, the
604
993
  * one most rules that never occur stop in, unbounded.
605
994
  */
606
- function budgetedExpansion(event, Budgeted) {
995
+ function budgetedExpansion(event, Budgeted, budget, start) {
607
996
  for (const property of event.component.getAllProperties('rrule')) {
608
997
  budgetRule(property.getFirstValue(), Budgeted);
609
998
  }
610
- const expansion = event.iterator();
999
+ const expansion = event.iterator(start);
611
1000
  const iterators = expansion.ruleIterators;
612
1001
  if (!Array.isArray(iterators) || !iterators.every((iterator) => iterator instanceof Budgeted)) {
613
1002
  throw new CalendarContractError('invalid_icalendar');
614
1003
  }
1004
+ return withEveryRule(expansion, budget);
1005
+ }
1006
+ /**
1007
+ * `expansion`, choosing among its RRULEs' iterators correctly. ical.js 2.2.1's own choice
1008
+ * (RecurExpansion#_nextRecurrenceIter) drops one that has ended by index as it walks them: it
1009
+ * skipped the iterator after an ended first one, and for any other removed the one before it,
1010
+ * which had not ended, so a series with two RRULEs lost occurrences once either rule ended.
1011
+ */
1012
+ function withEveryRule(expansion, budget) {
1013
+ const state = expansion;
1014
+ if (typeof state._nextRecurrenceIter !== 'function') {
1015
+ throw new CalendarContractError('invalid_icalendar');
1016
+ }
1017
+ state._nextRecurrenceIter = () => {
1018
+ const iterators = state.ruleIterators;
1019
+ if (iterators.length > 1)
1020
+ budget.charge((iterators.length - 1) * RULE_SCAN_STEPS);
1021
+ let chosen = null;
1022
+ for (let index = 0; index < iterators.length;) {
1023
+ const iterator = iterators[index];
1024
+ if (iterator.completed) {
1025
+ iterators.splice(index, 1);
1026
+ continue;
1027
+ }
1028
+ // The earliest next time; the first of equal ones, as ical.js chose.
1029
+ if (chosen === null || chosen.last.compare(iterator.last) > 0)
1030
+ chosen = iterator;
1031
+ index += 1;
1032
+ }
1033
+ return chosen;
1034
+ };
615
1035
  return expansion;
616
1036
  }
617
1037
  /**
@@ -625,12 +1045,32 @@ function valueCount(property) {
625
1045
  function fieldsMs(time) {
626
1046
  return Date.UTC(time.year, time.month - 1, time.day, time.hour, time.minute, time.second);
627
1047
  }
1048
+ /** The day a time's own fields name: how ical.js matches a DATE EXDATE with an occurrence. */
1049
+ function dayOf(time) {
1050
+ return `${time.year}-${time.month}-${time.day}`;
1051
+ }
1052
+ /**
1053
+ * Milliseconds since 1970 of a DATE or DATE-TIME property's first value as written, ignoring its
1054
+ * zone, or undefined for anything else. Read from the raw form, so nothing resolves a zone yet.
1055
+ */
1056
+ function writtenFieldsMs(property) {
1057
+ const value = property?.jCal[3];
1058
+ if (typeof value !== 'string')
1059
+ return undefined;
1060
+ const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})Z?)?$/.exec(value);
1061
+ if (!match)
1062
+ return undefined;
1063
+ const [year, month, day, hour, minute, second] = match.slice(1).map((part) => Number(part ?? 0));
1064
+ return utcFrame(year, month, day, hour, minute, second, 0).getTime();
1065
+ }
628
1066
  /**
629
- * Refuse a VEVENT that ical.js would spend unbounded work reading, before anything reads its end:
1067
+ * Refuse a VEVENT that ical.js would spend unbounded work reading, before anything reads its times:
630
1068
  * a DURATION, or a DTEND after its DTSTART, longer than MAX_ICAL_STEP_DAYS (ical.js adds a span to
631
1069
  * a time a month at a time, Time#adjust, and `DURATION:PT99999999999999999999S` held the process),
632
- * a negative DURATION, or more RDATE and EXDATE values than MAX_ICAL_DATES_PER_EVENT. Returns how
633
- * many it lists, for the feed's own limit.
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
1072
+ * many it lists, for the feed's own limit. It reads times only as written, so it runs before the
1073
+ * times are framed: framing reads every date value, and 115,000 EXDATE values took 11 seconds.
634
1074
  */
635
1075
  function boundComponent(component) {
636
1076
  const maxMs = MAX_ICAL_STEP_DAYS * 24 * 60 * 60 * 1000;
@@ -641,11 +1081,11 @@ function boundComponent(component) {
641
1081
  if (duration.isNegative || duration.toSeconds() * 1000 > maxMs)
642
1082
  expansionLimit();
643
1083
  }
644
- const start = component.getFirstPropertyValue('dtstart');
645
- const end = component.getFirstPropertyValue('dtend');
646
- if (start instanceof ICAL.Time && end instanceof ICAL.Time) {
1084
+ const start = writtenFieldsMs(component.getFirstProperty('dtstart'));
1085
+ const end = writtenFieldsMs(component.getFirstProperty('dtend'));
1086
+ if (start !== undefined && end !== undefined) {
647
1087
  // The fields ignore zones, so allow the day they can be out by.
648
- if (Math.abs(fieldsMs(end) - fieldsMs(start)) > maxMs + 24 * 60 * 60 * 1000)
1088
+ if (Math.abs(end - start) > maxMs + 24 * 60 * 60 * 1000)
649
1089
  expansionLimit();
650
1090
  }
651
1091
  let dates = 0;
@@ -655,16 +1095,17 @@ function boundComponent(component) {
655
1095
  }
656
1096
  if (dates > MAX_ICAL_DATES_PER_EVENT)
657
1097
  expansionLimit();
1098
+ if (component.getAllProperties('rrule').length > MAX_ICAL_RULES_PER_EVENT)
1099
+ expansionLimit();
658
1100
  return dates;
659
1101
  }
660
1102
  /** Bound every VEVENT of a feed, and the date values of all of them together. */
661
- function boundComponents(components, budget) {
1103
+ function boundComponents(components) {
662
1104
  let dates = 0;
663
1105
  for (const component of components) {
664
1106
  dates += boundComponent(component);
665
1107
  if (dates > MAX_ICAL_DATES_PER_FEED)
666
1108
  expansionLimit();
667
- budget.check();
668
1109
  }
669
1110
  }
670
1111
  /**
@@ -673,12 +1114,8 @@ function boundComponents(components, budget) {
673
1114
  * local time, so one series' cancelled 09:00 cancelled every other series' 09:00 that day; the
674
1115
  * parsers relate each series' own exceptions themselves.
675
1116
  */
676
- function feedEvents(components, budget) {
677
- return components.map((component) => {
678
- const event = new ICAL.Event(component, { exceptions: [] });
679
- budget.check();
680
- return event;
681
- });
1117
+ function feedEvents(components) {
1118
+ return components.map((component) => new ICAL.Event(component, { exceptions: [] }));
682
1119
  }
683
1120
  /** Refuse a feed whose text alone shows it is past a limit, before ical.js parses it. */
684
1121
  function boundInput(input) {
@@ -901,12 +1338,15 @@ function budgetTimezoneRules(root, Budgeted) {
901
1338
  }
902
1339
  }
903
1340
  /**
904
- * Charge the time of each expansion of the feed's own zones, and keep one copy of their changes.
905
- * 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
906
1343
  * again from its start and appends all of them to what it already had: 1,000 date values in years
907
1344
  * six apart, sorted inside one call, expanded a zone of 3,000 changes 1,000 times into 3 million
908
1345
  * (26 to 35 seconds, 650 MB). Each expansion starts again from DTSTART, so emptying the list first
909
- * 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.
910
1350
  */
911
1351
  function budgetTimezoneCoverage(root, budget) {
912
1352
  for (const component of root.getAllSubcomponents('vtimezone')) {
@@ -917,14 +1357,20 @@ function budgetTimezoneCoverage(root, budget) {
917
1357
  if (!zone)
918
1358
  continue;
919
1359
  const ensure = zone._ensureCoverage;
920
- if (typeof ensure !== 'function')
1360
+ const offset = zone.utcOffset;
1361
+ if (typeof ensure !== 'function' || typeof offset !== 'function') {
921
1362
  throw new CalendarContractError('invalid_icalendar');
1363
+ }
922
1364
  zone._ensureCoverage = function (year) {
923
1365
  if (this.changes.length > 0 && this.expandedUntilYear >= year)
924
1366
  return;
925
- budget.check();
926
1367
  this.changes = [];
927
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);
928
1374
  };
929
1375
  }
930
1376
  }
@@ -966,12 +1412,22 @@ function seriesKey(component) {
966
1412
  component.hasProperty('exdate')) {
967
1413
  return null;
968
1414
  }
1415
+ const start = starts[0];
1416
+ const rule = rules[0];
1417
+ // Slotlock 0.1 compared a zoned series' occurrences with its UTC UNTIL as if they were UTC, so a
1418
+ // cursor it kept for one that ends may have ended an occurrence early or gone one past: such a
1419
+ // series has a key of its own now, and those cursors are searched past from the start.
1420
+ const zone = start.getParameter('tzid');
1421
+ const zonedUntil = typeof zone === 'string' &&
1422
+ zone.trim() !== 'UTC' &&
1423
+ rule.jCal[3]?.until !== undefined;
969
1424
  return createHash('sha256')
970
1425
  .update(JSON.stringify([
971
1426
  SERIES_CURSOR_FORMAT,
972
1427
  ICAL_JS_VERSION,
973
- starts[0].toICALString(),
974
- rules[0].toICALString(),
1428
+ start.toICALString(),
1429
+ rule.toICALString(),
1430
+ ...(zonedUntil ? ['zoned-until'] : []),
975
1431
  ]))
976
1432
  .digest('hex');
977
1433
  }
@@ -1073,7 +1529,7 @@ function keptSeriesCursor(snapshot) {
1073
1529
  * rebuilt from their state (`initialized`, so the iterator does not search again from DTSTART) with
1074
1530
  * this parse's rule, start and zone, and its steps charged to this parse's budget.
1075
1531
  */
1076
- function resumedExpansion(event, Budgeted, cursor) {
1532
+ function resumedExpansion(event, Budgeted, budget, cursor) {
1077
1533
  const rule = event.component.getFirstPropertyValue('rrule');
1078
1534
  budgetRule(rule, Budgeted);
1079
1535
  const dtstart = event.startDate;
@@ -1106,7 +1562,7 @@ function resumedExpansion(event, Budgeted, cursor) {
1106
1562
  if (iterators.length !== 1 || !(iterators[0] instanceof Budgeted)) {
1107
1563
  throw new CalendarContractError('invalid_icalendar');
1108
1564
  }
1109
- return expansion;
1565
+ return withEveryRule(expansion, budget);
1110
1566
  }
1111
1567
  /**
1112
1568
  * Whether a cursor's iterator state agrees with `reference`, an iterator ical.js built for the same
@@ -1151,23 +1607,374 @@ function cursorFitsRule(state, reference, rule) {
1151
1607
  return false;
1152
1608
  return true;
1153
1609
  }
1154
- /** Parse VEVENTs with the standards library; event content is deliberately discarded. */
1610
+ /**
1611
+ * An IANA zone ical.js reads through Intl, for a TZID a calendar names without a VTIMEZONE (RFC
1612
+ * 5545 wants one for each; real feeds, and the series Slotlock stores, name IANA zones bare). Left
1613
+ * alone, ical.js reads such a time as floating and compares it with UTC values (an UNTIL, EXDATE,
1614
+ * RDATE or RECURRENCE-ID) as if it were UTC: a 09:00 Berlin series lost the occurrence its UNTIL
1615
+ * named, and a moved or excluded occurrence was not found. Offsets come from the converter the rest
1616
+ * of Slotlock reads wall times with: a gap moves forward, a fold takes its first instant.
1617
+ */
1618
+ class IntlTimezone extends ICAL.Timezone {
1619
+ zone;
1620
+ converter;
1621
+ /** The parse's budget, which each offset found is charged to as any conversion is. */
1622
+ budget;
1623
+ /** Offsets already found, by wall time: ical.js asks again at every comparison. */
1624
+ offsets = new Map();
1625
+ constructor(tzid, budget) {
1626
+ super({ tzid });
1627
+ // A TZID is matched as written (" Europe/Berlin" is that TZID); Slotlock reads its zone trimmed.
1628
+ this.zone = tzid.trim();
1629
+ this.converter = createTimeZoneConverter(this.zone);
1630
+ this.budget = budget;
1631
+ }
1632
+ utcOffset(time) {
1633
+ const wall = utcFrame(time.year, time.month, time.day, time.hour, time.minute, time.second, 0);
1634
+ const wallMs = wall.getTime();
1635
+ let offset = this.offsets.get(wallMs);
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);
1639
+ offset = Math.round((wallMs - this.converter.wallFrameToUtc(wallMs).getTime()) / 1000);
1640
+ if (this.offsets.size >= 4_096)
1641
+ this.offsets.clear();
1642
+ this.offsets.set(wallMs, offset);
1643
+ }
1644
+ return offset;
1645
+ }
1646
+ }
1647
+ /** VEVENT properties whose times ical.js compares with one another. */
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
+ */
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;
1664
+ try {
1665
+ new Intl.DateTimeFormat('en-GB', { timeZone: name }).format(0);
1666
+ answer = true;
1667
+ }
1668
+ catch {
1669
+ answer = false;
1670
+ }
1671
+ if (knownTimezones.size >= 256)
1672
+ knownTimezones.clear();
1673
+ knownTimezones.set(name, answer);
1674
+ return answer;
1675
+ }
1676
+ /** Wall-clock fields as a jCal DATE-TIME, `2027-01-04T09:00:00`, with a `Z` when they are UTC's. */
1677
+ function jCalDateTime(fields, utc = false) {
1678
+ const pad = (value, width = 2) => String(value).padStart(width, '0');
1679
+ const date = `${pad(fields.year, 4)}-${pad(fields.month)}-${pad(fields.day)}`;
1680
+ const time = `${pad(fields.hour)}:${pad(fields.minute)}:${pad(fields.second)}`;
1681
+ return `${date}T${time}${utc ? 'Z' : ''}`;
1682
+ }
1683
+ /** The instant an ical.js time names, read in its own zone; undefined when it has none. */
1684
+ function zonedInstant(time) {
1685
+ return ownZone(time) === undefined ? undefined : new Date(time.toUnixTime() * 1000);
1686
+ }
1687
+ /** `value` (a jCal date-time) as the date it falls on in `timezone`; undefined if it cannot say. */
1688
+ function localDateOf(value, tzid, timezone) {
1689
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(Z?)$/.exec(value);
1690
+ if (!match)
1691
+ return undefined;
1692
+ const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number);
1693
+ const fields = { year, month, day, hour, minute, second };
1694
+ let instant;
1695
+ if (match[7] === 'Z') {
1696
+ instant = utcFrame(year, month, day, hour, minute, second, 0);
1697
+ }
1698
+ else if (typeof tzid === 'string' && isKnownTimezone(tzid)) {
1699
+ instant = zonedDateTimeToUtc(fields, tzid.trim());
1700
+ }
1701
+ else {
1702
+ // Floating: the wall time is the calendar's own, and so is its date.
1703
+ return value.slice(0, 10);
1704
+ }
1705
+ return jCalDateTime(zonedDateTimeFields(instant, timezone)).slice(0, 10);
1706
+ }
1707
+ /**
1708
+ * Prepare a calendar's events before anything reads their times, so ical.js compares them at their
1709
+ * real instants:
1710
+ * - a floating time is framed in the calendar's zone, the zone Slotlock reads it in;
1711
+ * - each zone the events name that Intl knows becomes an IntlTimezone, whether or not the feed
1712
+ * defines it; a feed names no more zones it does not define than it may define
1713
+ * (MAX_ICAL_TIMEZONES): 50,000 spellings of one zone, each a zone of its own to ical.js, took
1714
+ * 3.4 seconds;
1715
+ * - an all-day series' UTC or zoned UNTIL, EXDATE and RECURRENCE-ID become the local dates they
1716
+ * fall on (RFC 5545 wants them as dates; a local midnight east of UTC is the day before in UTC);
1717
+ * - a PERIOD RDATE becomes its start, its end kept in the map returned, by event: ical.js orders a
1718
+ * period among times by whether a time falls inside it, and hands back the period itself;
1719
+ * - then, every zone resolving, a timed series' own times are aligned (alignSeriesTimes).
1720
+ * Every DATE and DATE-TIME value this reads is one boundComponents has counted.
1721
+ */
1722
+ function frameEventTimes(root, components, calendarTimezone, budget) {
1723
+ const timezone = calendarTimezone !== undefined && isKnownTimezone(calendarTimezone)
1724
+ ? calendarTimezone
1725
+ : undefined;
1726
+ const declared = new Set(root.getAllSubcomponents('vtimezone').map((zone) => zone.getFirstPropertyValue('tzid')));
1727
+ const named = new Set();
1728
+ let undeclared = 0;
1729
+ const nameZone = (tzid) => {
1730
+ if (named.has(tzid))
1731
+ return;
1732
+ named.add(tzid);
1733
+ if (!declared.has(tzid) && ++undeclared > MAX_ICAL_TIMEZONES)
1734
+ expansionLimit();
1735
+ };
1736
+ const periods = new Map();
1737
+ const allDayMasters = new Set();
1738
+ for (const component of components) {
1739
+ for (const name of COMPARED_TIME_PROPERTIES) {
1740
+ for (const property of component.getAllProperties(name)) {
1741
+ const values = property.jCal.slice(3);
1742
+ const starts = values.map((value) => (Array.isArray(value) ? value[0] : value));
1743
+ const tzid = property.getParameter('tzid');
1744
+ if (typeof tzid === 'string') {
1745
+ nameZone(tzid);
1746
+ }
1747
+ else if (timezone &&
1748
+ (property.type === 'date-time' || property.type === 'period') &&
1749
+ starts.some((start) => typeof start === 'string' && !start.endsWith('Z'))) {
1750
+ property.setParameter('tzid', timezone);
1751
+ nameZone(timezone);
1752
+ }
1753
+ if (name === 'rdate' && property.type === 'period') {
1754
+ const kept = periods.get(component) ?? [];
1755
+ for (const value of values) {
1756
+ const [start, end] = Array.isArray(value) ? value : [];
1757
+ if (typeof start !== 'string' || typeof end !== 'string') {
1758
+ throw new CalendarContractError('invalid_icalendar');
1759
+ }
1760
+ kept.push({ property, start, end });
1761
+ }
1762
+ periods.set(component, kept);
1763
+ property.resetType('date-time');
1764
+ for (const value of values)
1765
+ property.jCal.push(value[0]);
1766
+ }
1767
+ }
1768
+ }
1769
+ if (!component.hasProperty('recurrence-id') &&
1770
+ component.getFirstProperty('dtstart')?.type === 'date') {
1771
+ const uid = component.getFirstPropertyValue('uid');
1772
+ if (typeof uid === 'string')
1773
+ allDayMasters.add(uid);
1774
+ }
1775
+ }
1776
+ if (timezone) {
1777
+ const toDate = (property) => {
1778
+ if (property.type !== 'date-time')
1779
+ return;
1780
+ const tzid = property.getParameter('tzid');
1781
+ const dates = property.jCal.slice(3).map((value) => typeof value === 'string' ? localDateOf(value, tzid, timezone) : undefined);
1782
+ if (dates.some((date) => date === undefined))
1783
+ return;
1784
+ property.resetType('date');
1785
+ property.removeParameter('tzid');
1786
+ for (const date of dates)
1787
+ property.jCal.push(date);
1788
+ };
1789
+ for (const component of components) {
1790
+ const uid = component.getFirstPropertyValue('uid');
1791
+ if (typeof uid !== 'string' || !allDayMasters.has(uid))
1792
+ continue;
1793
+ if (component.hasProperty('recurrence-id')) {
1794
+ for (const property of component.getAllProperties('recurrence-id'))
1795
+ toDate(property);
1796
+ continue;
1797
+ }
1798
+ for (const property of component.getAllProperties('exdate'))
1799
+ toDate(property);
1800
+ for (const property of component.getAllProperties('rrule')) {
1801
+ const rule = property.jCal[3];
1802
+ if (typeof rule?.until === 'string' && rule.until.length > 10) {
1803
+ const date = localDateOf(rule.until, undefined, timezone);
1804
+ if (date !== undefined)
1805
+ rule.until = date;
1806
+ }
1807
+ }
1808
+ }
1809
+ }
1810
+ const cache = root._timezoneCache;
1811
+ for (const tzid of named) {
1812
+ // A zone Intl knows by this name is read through Intl, as calendarTimeValue reads it, even when
1813
+ // the feed defines it: ical.js read a stale VTIMEZONE's rules (standard time only) while
1814
+ // Slotlock read Intl's, so the two placed one time an hour apart and a moved occurrence was
1815
+ // found in a feed but not in the series stored from it.
1816
+ if (ICAL.TimezoneService.has(tzid) || !isKnownTimezone(tzid))
1817
+ continue;
1818
+ // ical.js keeps a calendar's zones here; without it, it could not use one of these.
1819
+ if (!(cache instanceof Map))
1820
+ throw new CalendarContractError('invalid_icalendar');
1821
+ cache.set(tzid, new IntlTimezone(tzid, budget));
1822
+ }
1823
+ alignSeriesTimes(components);
1824
+ return periods;
1825
+ }
1826
+ /**
1827
+ * With every zone resolving, put what ical.js compares with a timed series' own times in the terms
1828
+ * it compares them in:
1829
+ * - a floating UNTIL becomes the UTC instant it names on the series' clock: RFC 5545 §3.3.10 reads
1830
+ * it as DTSTART is read, and ical.js read it as UTC, so a New York series lost its last day;
1831
+ * - a RECURRENCE-ID named in another zone becomes the same instant in UTC, which ical.js matches
1832
+ * with an occurrence (it matched one in New York time with none); one for this and every later
1833
+ * occurrence (RANGE=THISANDFUTURE) becomes the series' own wall time instead, since ical.js moves
1834
+ * those by the wall-clock distance from it to the exception's start, read in the start's zone.
1835
+ * A DATE EXDATE of a timed series becomes the series' start time that day: ical.js excluded every
1836
+ * occurrence that day, and a series stored from the feed, whose exceptions are instants, excluded
1837
+ * none (most series occur once a day; one that occurs more keeps the others). And a series of
1838
+ * RDATEs with no RRULE gets its DTSTART as one: it is the first instance (RFC 5545 §3.8.2.4), which
1839
+ * ical.js left out.
1840
+ */
1841
+ function alignSeriesTimes(components) {
1842
+ const seriesStarts = new Map();
1843
+ for (const component of components) {
1844
+ if (component.hasProperty('recurrence-id'))
1845
+ continue;
1846
+ const uid = component.getFirstPropertyValue('uid');
1847
+ const start = component.getFirstProperty('dtstart');
1848
+ const value = start?.jCal[3];
1849
+ if (typeof uid !== 'string' || !start || typeof value !== 'string')
1850
+ continue;
1851
+ if (component.hasProperty('rdate') && !component.hasProperty('rrule')) {
1852
+ const parameters = { ...start.jCal[1] };
1853
+ component.addProperty(new ICAL.Property(['rdate', parameters, start.type, value]));
1854
+ }
1855
+ if (start.type !== 'date-time')
1856
+ continue;
1857
+ const time = ICAL.Time.fromDateTimeString(value, start);
1858
+ if (zonedInstant(time) === undefined)
1859
+ continue;
1860
+ seriesStarts.set(uid, { time, tzid: start.getParameter('tzid') });
1861
+ for (const property of component.getAllProperties('exdate')) {
1862
+ const days = property.jCal.slice(3);
1863
+ if (property.type !== 'date' || !days.every((day) => typeof day === 'string'))
1864
+ continue;
1865
+ property.resetType('date-time');
1866
+ const zone = start.getParameter('tzid');
1867
+ if (typeof zone === 'string')
1868
+ property.setParameter('tzid', zone);
1869
+ for (const day of days)
1870
+ property.jCal.push(`${day}${value.slice(10)}`);
1871
+ }
1872
+ for (const property of component.getAllProperties('rrule')) {
1873
+ const rule = property.jCal[3];
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
+ }
1889
+ if (typeof until !== 'string' || until.length <= 10 || until.endsWith('Z'))
1890
+ continue;
1891
+ const instant = zonedInstant(ICAL.Time.fromDateTimeString(until, start));
1892
+ if (instant !== undefined && rule) {
1893
+ rule.until = jCalDateTime(zonedDateTimeFields(instant, 'UTC'), true);
1894
+ }
1895
+ }
1896
+ }
1897
+ for (const component of components) {
1898
+ const uid = component.getFirstPropertyValue('uid');
1899
+ const series = typeof uid === 'string' ? seriesStarts.get(uid) : undefined;
1900
+ if (series === undefined)
1901
+ continue;
1902
+ for (const property of component.getAllProperties('recurrence-id')) {
1903
+ const value = property.jCal[3];
1904
+ if (property.type !== 'date-time' || typeof value !== 'string')
1905
+ continue;
1906
+ // Named in the series' own zone, it is matched by its wall time as it stands.
1907
+ if (typeof series.tzid === 'string' && property.getParameter('tzid') === series.tzid)
1908
+ continue;
1909
+ const time = ICAL.Time.fromDateTimeString(value, property);
1910
+ const instant = zonedInstant(time);
1911
+ if (instant === undefined)
1912
+ continue;
1913
+ const seriesZone = series.time.zone;
1914
+ const zone = property.getParameter('range') === undefined ? ICAL.Timezone.utcTimezone : seriesZone;
1915
+ if (time.zone?.tzid === zone.tzid)
1916
+ continue;
1917
+ let written;
1918
+ if (zone.tzid === 'UTC') {
1919
+ written = jCalDateTime(zonedDateTimeFields(instant, 'UTC'), true);
1920
+ }
1921
+ else if (zone instanceof IntlTimezone) {
1922
+ written = jCalDateTime(zonedDateTimeFields(instant, zone.tzid.trim()));
1923
+ }
1924
+ else {
1925
+ // A zone the calendar defines is ical.js's to read.
1926
+ written = time.convertToZone(zone).toString();
1927
+ }
1928
+ property.resetType('date-time');
1929
+ if (zone.tzid === 'UTC')
1930
+ property.removeParameter('tzid');
1931
+ else
1932
+ property.setParameter('tzid', series.tzid);
1933
+ property.jCal.push(written);
1934
+ }
1935
+ }
1936
+ }
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
+ */
1155
1943
  export function parseICalendarChanges(input, calendarTimezone, expansionWindow) {
1156
1944
  return parseCalendarChanges(input, calendarTimezone, expansionWindow, FEED_LIMITS);
1157
1945
  }
1158
- 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) {
1159
1960
  try {
1160
1961
  // Bound work before ical.js allocates and normalizes the full component tree. The parsed-tree
1161
1962
  // checks below remain authoritative; these cheap standards-shaped counts are an early DoS guard.
1162
1963
  boundInput(input);
1163
- const budget = new RecurrenceBudget(limits);
1964
+ const budget = limits instanceof RecurrenceBudget ? limits : new RecurrenceBudget(limits);
1164
1965
  const root = new ICAL.Component(ICAL.parse(input));
1165
- budget.check();
1966
+ // A bare VEVENT (or anything else) would read as a calendar with no events.
1967
+ if (root.name !== 'vcalendar')
1968
+ throw new CalendarContractError('invalid_icalendar');
1166
1969
  let Budgeted;
1167
1970
  const budgeted = () => (Budgeted ??= budgetedRecurIterator(budget));
1168
1971
  budgetTimezoneRules(root, budgeted);
1169
1972
  budgetTimezoneCoverage(root, budget);
1170
- const timeValue = (value, zone, fallback) => budget.search(() => calendarTimeValue(value, zone, fallback));
1973
+ // A time read in a zone of its own (a VTIMEZONE's, or an IntlTimezone) is read in that zone: an
1974
+ // RDATE may name another zone than its series' DTSTART, and an occurrence keeps the RDATE's.
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));
1171
1978
  const embeddedTimezone = root.getFirstPropertyValue('x-wr-timezone');
1172
1979
  const defaultTimezone = calendarTimezone?.trim() ||
1173
1980
  (typeof embeddedTimezone === 'string' ? embeddedTimezone.trim() : '') ||
@@ -1176,65 +1983,282 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
1176
1983
  if (components.length > MAX_ICAL_COMPONENTS) {
1177
1984
  throw new CalendarContractError('invalid_icalendar');
1178
1985
  }
1179
- boundComponents(components, budget);
1180
- const events = feedEvents(components, budget);
1181
- const masters = new Map();
1182
- const exceptionsByUid = new Map();
1986
+ boundComponents(components);
1987
+ const periods = frameEventTimes(root, components, defaultTimezone, budget);
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();
1183
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: [] };
1184
1999
  if (event.isRecurrenceException()) {
1185
- const exceptions = exceptionsByUid.get(event.uid) ?? [];
1186
- if (exceptions.length >= MAX_ICAL_EXCEPTIONS_PER_SERIES) {
2000
+ if (group.overrides.length >= MAX_ICAL_EXCEPTIONS_PER_SERIES) {
1187
2001
  throw new CalendarContractError('invalid_icalendar');
1188
2002
  }
1189
- exceptions.push(event);
1190
- exceptionsByUid.set(event.uid, exceptions);
2003
+ group.overrides.push(event);
1191
2004
  }
1192
2005
  else {
1193
- if (masters.has(event.uid))
1194
- throw new CalendarContractError('invalid_icalendar');
1195
- masters.set(event.uid, event);
2006
+ group.masters.push(event);
1196
2007
  }
2008
+ groups.set(uid, group);
1197
2009
  }
1198
- for (const [uid, exceptions] of exceptionsByUid) {
1199
- const master = masters.get(uid);
1200
- if (!master)
1201
- continue;
1202
- for (const exception of exceptions) {
1203
- // An exception for this and every later occurrence (RANGE=THISANDFUTURE) is placed by its
1204
- // time, which reads its zone.
1205
- master.relateException(exception);
1206
- budget.check();
1207
- }
1208
- }
1209
- const normalizeEvent = (event, startValue, endValue, recurrenceValue) => {
1210
- const component = event.component;
1211
- const startProperty = component.getFirstProperty('dtstart');
1212
- const endProperty = component.getFirstProperty('dtend');
1213
- const recurrenceProperty = component.getFirstProperty('recurrence-id');
2010
+ /**
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.
2014
+ */
2015
+ const makeChange = (component, uid, start, end, recurrenceId) => {
1214
2016
  const status = String(component.getFirstPropertyValue('status') ?? 'confirmed').toLowerCase();
1215
2017
  const transparency = String(component.getFirstPropertyValue('transp') ?? 'opaque').toLowerCase();
1216
2018
  const change = {
1217
- externalId: event.uid,
1218
- start: timeValue(startValue, startProperty?.getParameter('tzid'), defaultTimezone),
1219
- end: timeValue(endValue, endProperty?.getParameter('tzid') ?? startProperty?.getParameter('tzid'), defaultTimezone),
2019
+ externalId: uid,
2020
+ start: new Date(start),
2021
+ end: new Date(end),
1220
2022
  status,
1221
2023
  transparent: transparency === 'transparent',
1222
2024
  sequence: Number(component.getFirstPropertyValue('sequence') ?? 0),
1223
2025
  };
1224
- if (recurrenceValue) {
1225
- change.recurrenceId = timeValue(recurrenceValue, recurrenceProperty?.getParameter('tzid') ?? startProperty?.getParameter('tzid'), defaultTimezone).toISOString();
2026
+ if (end === start) {
2027
+ if (status !== 'cancelled')
2028
+ return null;
2029
+ change.start = null;
2030
+ change.end = null;
2031
+ }
2032
+ if (recurrenceId !== undefined)
2033
+ change.recurrenceId = new Date(recurrenceId).toISOString();
2034
+ try {
2035
+ return normalizeExternalCalendarChange(change);
2036
+ }
2037
+ catch (error) {
2038
+ if (error instanceof CalendarContractError) {
2039
+ throw new CalendarContractError('invalid_icalendar');
2040
+ }
2041
+ throw error;
2042
+ }
2043
+ };
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;
1226
2114
  }
1227
- return normalizeExternalCalendarChange(change);
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
+ };
1228
2155
  };
1229
2156
  const changes = [];
1230
- let remainingWork = MAX_ICAL_WORK;
1231
- for (const event of events) {
1232
- if (event.isRecurrenceException() && masters.has(event.uid))
1233
- continue;
1234
- if (!event.isRecurring()) {
1235
- changes.push(normalizeEvent(event, event.startDate, event.endDate, event.recurrenceId ?? undefined));
1236
- continue;
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. */
2161
+ const periodEndsOf = (event) => {
2162
+ const ends = new Map();
2163
+ const maxMs = MAX_ICAL_STEP_DAYS * DAY_MS;
2164
+ for (const period of periods.get(event.component) ?? []) {
2165
+ const zone = period.property.getParameter('tzid');
2166
+ const start = ICAL.Time.fromDateTimeString(period.start, period.property);
2167
+ const reader = readerOf(start, zone);
2168
+ const startWall = wallFrameOf(start);
2169
+ const startMs = reader.toInstant(startWall);
2170
+ let endMs;
2171
+ if (/^[+-]?P/.test(period.end)) {
2172
+ // Bounded first, as boundComponent bounds a DURATION.
2173
+ const duration = ICAL.Duration.fromString(period.end);
2174
+ if (duration.isNegative || duration.toSeconds() * 1000 > maxMs)
2175
+ expansionLimit();
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;
2179
+ }
2180
+ else {
2181
+ const end = ICAL.Time.fromDateTimeString(period.end, period.property);
2182
+ if (Math.abs(fieldsMs(end) - fieldsMs(start)) > maxMs + DAY_MS)
2183
+ expansionLimit();
2184
+ endMs = timeValue(end, zone, defaultTimezone).getTime();
2185
+ }
2186
+ if (endMs < startMs)
2187
+ throw new CalendarContractError('invalid_icalendar');
2188
+ ends.set(startMs, endMs);
2189
+ }
2190
+ return ends;
2191
+ };
2192
+ /**
2193
+ * A series' EXDATEs: the instants its DATE-TIME values name, and the days its DATE values name
2194
+ * (one excludes every occurrence that day, as ical.js reads it). ical.js passes them one at a
2195
+ * time as it goes, so an occurrence after two EXDATEs it had not passed, or the second of an
2196
+ * instant both RRULE and RDATE name, escaped its EXDATE; RFC 5545 §3.8.5.1 has every EXDATE win.
2197
+ */
2198
+ const exclusionsOf = (event) => {
2199
+ const instants = new Set();
2200
+ const days = new Set();
2201
+ const startZone = event.component.getFirstProperty('dtstart')?.getParameter('tzid');
2202
+ for (const property of event.component.getAllProperties('exdate')) {
2203
+ const zone = property.getParameter('tzid') ?? startZone;
2204
+ for (const value of property.getValues()) {
2205
+ if (!(value instanceof ICAL.Time))
2206
+ throw new CalendarContractError('invalid_icalendar');
2207
+ if (value.isDate)
2208
+ days.add(dayOf(value));
2209
+ else
2210
+ instants.add(timeValue(value, zone, defaultTimezone).getTime());
2211
+ }
2212
+ }
2213
+ return { instants, days };
2214
+ };
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);
2223
+ if (change)
2224
+ changes.push(change);
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;
1237
2258
  }
2259
+ readRecurring(uid, master, group.overrides, startZone);
2260
+ };
2261
+ const readRecurring = (uid, event, overrideEvents, startZone) => {
1238
2262
  if (!expansionWindow ||
1239
2263
  !Number.isFinite(expansionWindow.start.getTime()) ||
1240
2264
  !Number.isFinite(expansionWindow.end.getTime()) ||
@@ -1242,39 +2266,112 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
1242
2266
  expansionWindow.end.getTime() - expansionWindow.start.getTime() > MAX_ICAL_WINDOW_MS) {
1243
2267
  throw new CalendarContractError('invalid_icalendar');
1244
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));
2297
+ // What one series may pass from its start, so that one series' age never refuses another's.
2298
+ let remainingWork = MAX_ICAL_WORK;
1245
2299
  let maxBackwardExceptionShiftMs = 0;
1246
- for (const exception of exceptionsByUid.get(event.uid) ?? []) {
2300
+ let lastOverrideAt = Number.NEGATIVE_INFINITY;
2301
+ for (const override of overrides) {
1247
2302
  remainingWork -= 1;
1248
2303
  if (remainingWork < 0)
1249
2304
  expansionLimit();
1250
- const recurrenceProperty = exception.component.getFirstProperty('recurrence-id');
1251
- const startProperty = exception.component.getFirstProperty('dtstart');
1252
- const originalStart = timeValue(exception.recurrenceId, recurrenceProperty?.getParameter('tzid'), defaultTimezone);
1253
- const movedStart = timeValue(exception.startDate, startProperty?.getParameter('tzid'), defaultTimezone);
1254
- const backwardShiftMs = originalStart.getTime() - movedStart.getTime();
2305
+ const backwardShiftMs = override.at - override.start;
1255
2306
  if (backwardShiftMs > MAX_EXCEPTION_BACKWARD_SHIFT_MS) {
1256
2307
  throw new CalendarContractError('invalid_icalendar');
1257
2308
  }
1258
- // ical.js moves the occurrence by the shift a month at a time (Time#adjust).
1259
- 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)
1260
2312
  expansionLimit();
1261
2313
  maxBackwardExceptionShiftMs = Math.max(maxBackwardExceptionShiftMs, backwardShiftMs);
2314
+ lastOverrideAt = Math.max(lastOverrideAt, override.at);
1262
2315
  }
1263
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
+ };
1264
2333
  // A stored series (the only master) resumes from its cursor when the cursor is this series'
1265
2334
  // and stands where the window needs nothing before it, and leaves one for the next window.
1266
- const key = series && masters.size === 1 ? seriesKey(event.component) : null;
2335
+ const key = series && groups.size === 1 ? seriesKey(event.component) : null;
1267
2336
  const kept = key === null ? null : readSeriesCursor(series?.cursor, key);
1268
2337
  const usable = kept !== null && series !== undefined && kept.position <= series.resumeLimitMs
1269
2338
  ? kept
1270
2339
  : null;
1271
- const startZone = event.component.getFirstProperty('dtstart')?.getParameter('tzid');
2340
+ let periodEnds;
2341
+ let exclusions;
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
+ }
1272
2369
  // The occurrences in the window from `resume` (or the start), and the cursor to keep; null
1273
2370
  // when `resume` does not fit this series.
1274
2371
  const expand = (resume) => {
1275
- const iterator = budget.search(() => resume
1276
- ? resumedExpansion(event, budgeted(), resume)
1277
- : budgetedExpansion(event, budgeted()));
2372
+ const iterator = resume
2373
+ ? resumedExpansion(event, budgeted(), budget, resume)
2374
+ : budgetedExpansion(event, budgeted(), budget, from);
1278
2375
  if (iterator === null)
1279
2376
  return null;
1280
2377
  // The cursor stands on the occurrence it last passed: the same time, read the same way.
@@ -1282,40 +2379,142 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
1282
2379
  timeValue(iterator.last, startZone, defaultTimezone).getTime() !== resume.position) {
1283
2380
  return null;
1284
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;
1285
2387
  const found = [];
2388
+ const matched = new Set();
1286
2389
  let position = resume?.position;
2390
+ const seen = new Set();
1287
2391
  let snapshot = resume;
1288
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;
1289
2413
  while (true) {
1290
2414
  remainingWork -= 1;
1291
2415
  if (remainingWork < 0)
1292
2416
  expansionLimit();
2417
+ budget.charge(OCCURRENCE_STEPS);
1293
2418
  // Before the next step, everything the iterator has passed may end before the window:
1294
2419
  // then this is where the next expansion can start.
1295
- if (leaving && key !== null && series && position !== undefined) {
2420
+ if (leaving && key !== null && series && position !== undefined && held === undefined) {
1296
2421
  if (position <= series.resumeLimitMs)
1297
2422
  snapshot = snapshotSeries(iterator, key, position);
1298
2423
  else
1299
2424
  leaving = false;
1300
2425
  }
1301
- const occurrence = budget.search(() => iterator.next());
1302
- 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;
1303
2452
  break;
1304
- const occurrenceStart = timeValue(occurrence, startZone, defaultTimezone);
1305
- position = occurrenceStart.getTime();
1306
- 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;
1307
2461
  break;
1308
- const details = event.getOccurrenceDetails(occurrence);
1309
- const start = timeValue(details.startDate, details.item.component.getFirstProperty('dtstart')?.getParameter('tzid'), defaultTimezone);
1310
- const end = timeValue(details.endDate, details.item.component.getFirstProperty('dtend')?.getParameter('tzid') ??
1311
- details.item.component.getFirstProperty('dtstart')?.getParameter('tzid'), defaultTimezone);
1312
- if (end > expansionWindow.start && start < expansionWindow.end) {
1313
- found.push(normalizeEvent(details.item, details.startDate, details.endDate, occurrence));
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;
2470
+ // An instant is one occurrence however many RRULEs and RDATEs name it (RFC 5545
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))
2474
+ continue;
2475
+ seen.add(at);
2476
+ if (event.component.hasProperty('exdate')) {
2477
+ exclusions ??= exclusionsOf(event);
2478
+ if (exclusions.instants.has(at) || exclusions.days.has(dayOf(occurrence)))
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);
2486
+ }
2487
+ continue;
2488
+ }
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;
2497
+ }
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);
2511
+ if (change)
2512
+ found.push(change);
1314
2513
  if (changes.length + found.length > MAX_ICAL_OCCURRENCES)
1315
2514
  expansionLimit();
1316
2515
  }
1317
2516
  }
1318
- return { found, snapshot };
2517
+ return { found, snapshot, matched, scannedTo };
1319
2518
  };
1320
2519
  let expanded = null;
1321
2520
  if (usable !== null) {
@@ -1338,10 +2537,70 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
1338
2537
  if (expanded === null)
1339
2538
  throw new CalendarContractError('invalid_icalendar');
1340
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
+ }
1341
2560
  if (series && key !== null) {
1342
2561
  series.resumed = resumed;
1343
2562
  series.left = keptSeriesCursor(expanded.snapshot);
1344
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
+ }
1345
2604
  }
1346
2605
  return changes;
1347
2606
  }
@@ -1351,6 +2610,41 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
1351
2610
  throw new CalendarContractError('invalid_icalendar');
1352
2611
  }
1353
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
+ }
1354
2648
  function requiredEventText(value, code) {
1355
2649
  const normalized = value.trim();
1356
2650
  if (!normalized || Buffer.byteLength(normalized, 'utf8') > MAX_TRUSTED_SUMMARY_BYTES) {
@@ -1358,31 +2652,6 @@ function requiredEventText(value, code) {
1358
2652
  }
1359
2653
  return normalized;
1360
2654
  }
1361
- function partsInZone(value, timezone) {
1362
- const formatter = new Intl.DateTimeFormat('en-GB', {
1363
- timeZone: timezone,
1364
- hourCycle: 'h23',
1365
- year: 'numeric',
1366
- month: '2-digit',
1367
- day: '2-digit',
1368
- hour: '2-digit',
1369
- minute: '2-digit',
1370
- second: '2-digit',
1371
- });
1372
- const values = {};
1373
- for (const part of formatter.formatToParts(value)) {
1374
- if (part.type !== 'literal')
1375
- values[part.type] = Number(part.value);
1376
- }
1377
- return {
1378
- year: values.year ?? 0,
1379
- month: values.month ?? 0,
1380
- day: values.day ?? 0,
1381
- hour: (values.hour ?? 0) % 24,
1382
- minute: values.minute ?? 0,
1383
- second: values.second ?? 0,
1384
- };
1385
- }
1386
2655
  function addDateTimeProperty(component, name, value, timezone) {
1387
2656
  const property = new ICAL.Property(name);
1388
2657
  if (timezone === 'UTC') {
@@ -1390,10 +2659,20 @@ function addDateTimeProperty(component, name, value, timezone) {
1390
2659
  }
1391
2660
  else {
1392
2661
  property.setParameter('tzid', timezone);
1393
- property.setValue(ICAL.Time.fromData({ ...partsInZone(value, timezone), isDate: false }));
2662
+ property.setValue(ICAL.Time.fromData({ ...zonedDateTimeFields(value, timezone), isDate: false }));
1394
2663
  }
1395
2664
  component.addProperty(property);
1396
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
+ }
1397
2676
  function attendeeRoleValue(role) {
1398
2677
  switch (role) {
1399
2678
  case 'chair':
@@ -1453,10 +2732,23 @@ function eventComponent(params) {
1453
2732
  component.updatePropertyWithValue('uid', params.uid);
1454
2733
  component.updatePropertyWithValue('dtstamp', ICAL.Time.fromJSDate(params.updatedAt, true));
1455
2734
  component.updatePropertyWithValue('last-modified', ICAL.Time.fromJSDate(params.updatedAt, true));
1456
- addDateTimeProperty(component, 'dtstart', params.event.start, params.event.timezone);
1457
- 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
+ }
1458
2749
  if (params.recurrenceId) {
1459
- 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');
1460
2752
  }
1461
2753
  component.updatePropertyWithValue('summary', params.event.summary);
1462
2754
  if (params.event.description) {
@@ -1476,6 +2768,98 @@ function eventComponent(params) {
1476
2768
  }
1477
2769
  return component;
1478
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
+ }
1479
2863
  /** Deterministic RFC 5545 + iTIP payload, validated by the installed ical.js implementation. */
1480
2864
  export function emitITipCalendar(input, method) {
1481
2865
  try {
@@ -1539,8 +2923,19 @@ export function emitITipCalendar(input, method) {
1539
2923
  };
1540
2924
  if (status !== undefined)
1541
2925
  masterParams.status = status;
1542
- calendar.addSubcomponent(eventComponent(masterParams));
1543
- 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) {
1544
2939
  const exceptionEvent = { ...event };
1545
2940
  delete exceptionEvent.recurrence;
1546
2941
  if (exception.start && exception.end) {
@@ -1556,13 +2951,39 @@ export function emitITipCalendar(input, method) {
1556
2951
  uid,
1557
2952
  sequence,
1558
2953
  updatedAt,
1559
- recurrenceId: exception.recurrenceId,
2954
+ recurrenceId: firstInstance && exception.recurrenceId.getTime() === event.start.getTime()
2955
+ ? firstInstance
2956
+ : exception.recurrenceId,
1560
2957
  };
1561
2958
  const exceptionStatus = exception.cancelled ? 'cancelled' : status;
1562
2959
  if (exceptionStatus !== undefined)
1563
2960
  exceptionParams.status = exceptionStatus;
1564
- calendar.addSubcomponent(eventComponent(exceptionParams));
2961
+ events.push(eventComponent(exceptionParams));
1565
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);
1566
2987
  const output = `${calendar
1567
2988
  .toString()
1568
2989
  .replace(/\r?\n/g, '\r\n')
@@ -1705,11 +3126,12 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
1705
3126
  boundInput(input);
1706
3127
  const budget = new RecurrenceBudget(FEED_LIMITS);
1707
3128
  const root = new ICAL.Component(ICAL.parse(input));
1708
- budget.check();
3129
+ if (root.name !== 'vcalendar')
3130
+ throw new CalendarContractError('invalid_icalendar');
1709
3131
  let Budgeted;
1710
3132
  budgetTimezoneRules(root, () => (Budgeted ??= budgetedRecurIterator(budget)));
1711
3133
  budgetTimezoneCoverage(root, budget);
1712
- const timeValue = (value, zone, fallback) => budget.search(() => calendarTimeValue(value, zone, fallback));
3134
+ const timeValue = (value, zone, fallback) => calendarTimeValue(value, zone, fallback);
1713
3135
  const embeddedTimezone = root.getFirstPropertyValue('x-wr-timezone');
1714
3136
  const defaultTimezone = calendarTimezone?.trim() ||
1715
3137
  (typeof embeddedTimezone === 'string' ? embeddedTimezone.trim() : '') ||
@@ -1718,10 +3140,13 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
1718
3140
  if (components.length > MAX_ICAL_COMPONENTS) {
1719
3141
  throw new CalendarContractError('invalid_icalendar');
1720
3142
  }
1721
- boundComponents(components, budget);
3143
+ boundComponents(components);
3144
+ // Times are read as a feed's are, so a stored series occupies what the feed reports: zones it
3145
+ // names without defining them, floating times in the calendar's zone, all-day EXDATEs as days.
3146
+ frameEventTimes(root, components, defaultTimezone, budget);
1722
3147
  const masters = new Map();
1723
3148
  const exceptionsByUid = new Map();
1724
- for (const event of feedEvents(components, budget)) {
3149
+ for (const event of feedEvents(components)) {
1725
3150
  if (event.isRecurrenceException()) {
1726
3151
  const exceptions = exceptionsByUid.get(event.uid) ?? [];
1727
3152
  if (exceptions.length >= MAX_ICAL_EXCEPTIONS_PER_SERIES) {
@@ -1740,11 +3165,47 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
1740
3165
  if (!masters.has(uid))
1741
3166
  throw new CalendarContractError('invalid_icalendar');
1742
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
+ };
1743
3205
  const trusted = [];
1744
3206
  for (const [uid, event] of masters) {
1745
3207
  const component = event.component;
1746
3208
  const startProperty = component.getFirstProperty('dtstart');
1747
- const endProperty = component.getFirstProperty('dtend');
1748
3209
  // An all-day event (DATE-valued DTSTART) covers whole local days of the calendar's zone. Its
1749
3210
  // end must be a DATE too (RFC 5545 §3.6.1), and a DURATION may only count days or weeks.
1750
3211
  const allDay = event.startDate.isDate;
@@ -1771,9 +3232,10 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
1771
3232
  if (transparencyValue !== 'opaque' && transparencyValue !== 'transparent') {
1772
3233
  throw new CalendarContractError('invalid_icalendar');
1773
3234
  }
3235
+ const own = interval(event);
1774
3236
  const contentInput = {
1775
- start: timeValue(event.startDate, startProperty?.getParameter('tzid'), defaultTimezone),
1776
- end: timeValue(event.endDate, endProperty?.getParameter('tzid') ?? startProperty?.getParameter('tzid'), defaultTimezone),
3237
+ start: new Date(own.start),
3238
+ end: new Date(own.end),
1777
3239
  timezone: validTimezone(timezone),
1778
3240
  summary: String(component.getFirstPropertyValue('summary') ?? ''),
1779
3241
  transparency: transparencyValue,
@@ -1804,53 +3266,104 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
1804
3266
  }
1805
3267
  const rrule = rrules[0]?.getFirstValue();
1806
3268
  if (rrule) {
1807
- 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();
1808
3301
  for (const exdateProperty of component.getAllProperties('exdate')) {
1809
3302
  for (const exdate of exdateProperty.getValues()) {
1810
- if (exceptions.length >= MAX_ICAL_EXCEPTIONS_PER_SERIES) {
3303
+ if (exceptions.size >= MAX_ICAL_EXCEPTIONS_PER_SERIES) {
1811
3304
  throw new CalendarContractError('invalid_icalendar');
1812
3305
  }
1813
- exceptions.push({
1814
- recurrenceId: timeValue(exdate, exdateProperty.getParameter('tzid') ?? startProperty?.getParameter('tzid'), defaultTimezone),
1815
- cancelled: true,
1816
- });
3306
+ const recurrenceId = timeValue(exdate, exdateProperty.getParameter('tzid') ?? startProperty?.getParameter('tzid'), defaultTimezone);
3307
+ exceptions.set(recurrenceId.getTime(), { recurrenceId, cancelled: true });
1817
3308
  }
1818
3309
  }
1819
3310
  for (const exception of exceptionsByUid.get(uid) ?? []) {
1820
- if (exceptions.length >= MAX_ICAL_EXCEPTIONS_PER_SERIES) {
1821
- throw new CalendarContractError('invalid_icalendar');
1822
- }
1823
3311
  const recurrenceProperty = exception.component.getFirstProperty('recurrence-id');
1824
3312
  const range = recurrenceProperty?.getFirstParameter('range');
1825
3313
  if (range)
1826
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
+ }
1827
3321
  const recurrenceId = timeValue(exception.recurrenceId, recurrenceProperty?.getParameter('tzid') ?? startProperty?.getParameter('tzid'), defaultTimezone);
1828
3322
  const cancelled = String(exception.component.getFirstPropertyValue('status') ?? '').toLowerCase() ===
1829
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.
1830
3327
  const parsedException = { recurrenceId };
1831
- if (cancelled)
3328
+ if (cancelled) {
1832
3329
  parsedException.cancelled = true;
1833
- const replacementStart = timeValue(exception.startDate, exception.component.getFirstProperty('dtstart')?.getParameter('tzid'), defaultTimezone);
1834
- const replacementEnd = timeValue(exception.endDate, exception.component.getFirstProperty('dtend')?.getParameter('tzid') ??
1835
- exception.component.getFirstProperty('dtstart')?.getParameter('tzid'), defaultTimezone);
1836
- if (replacementStart.getTime() !== recurrenceId.getTime() ||
1837
- replacementEnd.getTime() - replacementStart.getTime() !==
1838
- contentInput.end.getTime() - contentInput.start.getTime()) {
1839
- parsedException.start = replacementStart;
1840
- parsedException.end = replacementEnd;
1841
3330
  }
1842
- 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
+ }
1843
3340
  }
1844
3341
  contentInput.recurrence = {
1845
3342
  rrule: allDay ? allDayRecurrenceRule(rrule.toString(), timezone) : rrule.toString(),
1846
- exceptions,
3343
+ exceptions: [...exceptions.values()],
1847
3344
  };
1848
3345
  }
1849
3346
  else if (component.getAllProperties('exdate').length > 0 ||
1850
3347
  (exceptionsByUid.get(uid)?.length ?? 0) > 0) {
1851
3348
  throw new CalendarContractError('invalid_icalendar');
1852
3349
  }
1853
- 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
+ }
1854
3367
  const sequence = Number(component.getFirstPropertyValue('sequence') ?? 0);
1855
3368
  if (!Number.isSafeInteger(sequence) || sequence < 0) {
1856
3369
  throw new CalendarContractError('invalid_icalendar');
@@ -1867,7 +3380,8 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
1867
3380
  return trusted;
1868
3381
  }
1869
3382
  catch (error) {
1870
- if (error instanceof CalendarContractError)
3383
+ // An event the calendar gives no length or no UID is the calendar's fault, as in a feed.
3384
+ if (error instanceof CalendarContractError && error.code === 'invalid_icalendar')
1871
3385
  throw error;
1872
3386
  throw new CalendarContractError('invalid_icalendar');
1873
3387
  }
@@ -1897,6 +3411,160 @@ export function expandCalendarEventSeries(input, window, options) {
1897
3411
  return expandOccurrences(input, window, options.stored ? FEED_LIMITS : EVENT_LIMITS, options.cursor, options.resumeOnly === true);
1898
3412
  }
1899
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) {
1900
3568
  const event = normalizeCalendarEventContent(input);
1901
3569
  if (!Number.isFinite(window.start.getTime()) ||
1902
3570
  !Number.isFinite(window.end.getTime()) ||
@@ -1914,9 +3582,7 @@ function expandOccurrences(input, window, limits, cursor, resumeOnly = false) {
1914
3582
  if (window.end.getTime() - window.start.getTime() > MAX_ICAL_WINDOW_MS) {
1915
3583
  throw new CalendarContractError('invalid_icalendar');
1916
3584
  }
1917
- const uid = `event-${createHash('sha256')
1918
- .update(`${event.start.toISOString()}|${event.summary}`)
1919
- .digest('hex')}@slotlock.local`;
3585
+ const uid = seriesUid(event);
1920
3586
  // A cursor may stand after an occurrence only if that occurrence ends before the window: unmoved,
1921
3587
  // within the series' length (and a clock change) of its start; moved, where its exception says.
1922
3588
  let resumeLimitMs = window.start.getTime() -
@@ -1927,8 +3593,15 @@ function expandOccurrences(input, window, limits, cursor, resumeOnly = false) {
1927
3593
  resumeLimitMs = Math.min(resumeLimitMs, exception.recurrenceId.getTime() - 1000);
1928
3594
  }
1929
3595
  }
1930
- const series = { cursor, resumeLimitMs, left: null, resumed: false, resumeOnly };
1931
- 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);
1932
3605
  const occurrences = parsed
1933
3606
  .filter((change) => !change.deleted && change.start && change.end)
1934
3607
  .map((change) => ({