slotlock 0.6.2 → 0.6.3

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 (62) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +13 -6
  3. package/agent-server.js +1 -5
  4. package/agent-store-backend.d.ts +5 -6
  5. package/agent-store-backend.js +41 -67
  6. package/api-keys.js +1 -1
  7. package/application-api.d.ts +3 -3
  8. package/application-api.js +16 -24
  9. package/assistant.d.ts +18 -27
  10. package/assistant.js +36 -53
  11. package/booking-rules.d.ts +30 -41
  12. package/booking-rules.js +17 -25
  13. package/change-watch.d.ts +1 -0
  14. package/change-watch.js +1 -1
  15. package/cli.js +3 -4
  16. package/config.d.ts +17 -2
  17. package/config.js +44 -6
  18. package/dashboard.js +13 -0
  19. package/ddl.d.ts +20 -27
  20. package/ddl.js +166 -108
  21. package/engine.d.ts +6 -9
  22. package/engine.js +5 -9
  23. package/event-feed.d.ts +23 -31
  24. package/event-feed.js +30 -45
  25. package/event-schema.js +6 -9
  26. package/hooks.d.ts +2 -4
  27. package/hooks.js +2 -4
  28. package/ical-worker-thread.js +3 -4
  29. package/ical-worker.d.ts +8 -10
  30. package/ical-worker.js +7 -9
  31. package/interop.js +1 -4
  32. package/maintenance.d.ts +14 -10
  33. package/maintenance.js +43 -26
  34. package/mcp-app.d.ts +7 -9
  35. package/mcp-app.js +7 -9
  36. package/mcp-modern.d.ts +14 -16
  37. package/mcp-modern.js +23 -27
  38. package/metrics.js +0 -1
  39. package/node-server.d.ts +9 -9
  40. package/node-server.js +25 -34
  41. package/package-version.js +2 -4
  42. package/package.json +1 -1
  43. package/rate-limit.d.ts +6 -1
  44. package/rate-limit.js +24 -8
  45. package/rest-api.d.ts +13 -14
  46. package/rest-api.js +11 -12
  47. package/rules.d.ts +6 -7
  48. package/rules.js +30 -42
  49. package/self-host.d.ts +16 -2
  50. package/self-host.js +42 -21
  51. package/slotlock.manifest.json +1 -1
  52. package/store.d.ts +109 -161
  53. package/store.js +253 -371
  54. package/sync.d.ts +44 -59
  55. package/sync.js +325 -469
  56. package/timezone.d.ts +25 -33
  57. package/timezone.js +37 -52
  58. package/types.d.ts +35 -56
  59. package/webhooks.d.ts +22 -25
  60. package/webhooks.js +35 -40
  61. package/weekly-hours.d.ts +3 -0
  62. package/weekly-hours.js +1 -1
package/sync.js CHANGED
@@ -7,52 +7,38 @@ 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
- // 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).
10
+ // Up to SLOTLOCK_MATERIALIZATION_WINDOW_DAYS: a 367-day search from any moment of the day, plus the
11
+ // day before.
12
12
  const MAX_ICAL_WINDOW_MS = 369 * 24 * 60 * 60 * 1000;
13
13
  /**
14
- * A feed's own time zones (VTIMEZONE), their STANDARD and DAYLIGHT observances, and the RDATE values
15
- * those list. A real feed carries one zone per zone its events use, each a few observances (a zone's
16
- * full history, a few hundred). ical.js expands every observance of a zone again whenever it reads a
17
- * later year than it has, appending all of them each time, and finds a zone by scanning them all for
18
- * each value whose TZID it does not know: 40,000 RDATE values ran a feed out of memory, and 20,000
19
- * zones held one for 31 seconds.
14
+ * A feed's own VTIMEZONEs, their observances, and the RDATE values those list. ical.js re-expands
15
+ * every observance whenever it reads a later year and scans them all for each unknown TZID, so
16
+ * large counts exhaust memory and time.
20
17
  */
21
18
  const MAX_ICAL_TIMEZONES = 100;
22
19
  /**
23
- * The name and parameters of one property, before its value: ical.js reads each parameter by
24
- * searching from it to the end of the line for the value, so 490,000 parameters on one line held a
25
- * parse for 7.9 seconds before anything else ran. A real property has a few parameters; a group
26
- * attendee listing its members, a few thousand characters.
20
+ * The name and parameters of one property, before its value. ical.js searches from each parameter
21
+ * to the end of the line, so cost is quadratic in the parameter count.
27
22
  */
28
23
  const MAX_ICAL_PROPERTY_HEAD_BYTES = 8_192;
29
24
  const MAX_ICAL_PROPERTY_PARAMETERS = 100;
30
25
  const MAX_ICAL_TIMEZONE_OBSERVANCES = 1_000;
31
26
  const MAX_ICAL_TIMEZONE_DATES = 2_000;
32
27
  /**
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.
28
+ * Work ical.js may do in one parse looking for occurrences, in steps of about a microsecond.
29
+ * RecurIterator.next() searches a SECONDLY to WEEKLY rule one step at a time with no limit, and a
30
+ * MONTHLY or YEARLY one gives up only when whole periods fail, so a rule that never occurs
31
+ * (`FREQ=DAILY;BYMONTH=2;BYMONTHDAY=30`) would hold the process. Only this count decides, never a
32
+ * clock, so a calendar is read or refused the same way however loaded the machine
33
+ * (ICAL_STEP_CHARGES).
44
34
  */
45
35
  const MAX_ICAL_STEPS = 500_000;
46
- /** A feed, and a stored series as maintenance rolls it. */
47
36
  const FEED_LIMITS = { steps: MAX_ICAL_STEPS };
48
37
  /**
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.
38
+ * One event as written or asked about (expandCalendarEventOccurrences): a tenth of a feed's, so a
39
+ * write is refused within about a tenth of a second whatever its rule, and a series written today
40
+ * still fits when the roll searches it again from its start (it resumes from a cursor when it has
41
+ * one).
56
42
  */
57
43
  const EVENT_LIMITS = { steps: 50_000 };
58
44
  /** RDATE and EXDATE values one event may list: ical.js sorts them in as it reads them. */
@@ -63,9 +49,8 @@ const MAX_ICAL_RULES_PER_EVENT = 8;
63
49
  const MAX_ICAL_DATES_PER_FEED = 20 * MAX_ICAL_DATES_PER_EVENT;
64
50
  /**
65
51
  * The furthest one step of a SECONDLY to WEEKLY rule may move, in days: about a century. ical.js
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.
52
+ * moves a day at a time inside one step (RecurIterator#increment_monthday); the days are charged
53
+ * (ICAL_STEP_CHARGES).
69
54
  */
70
55
  const MAX_ICAL_STEP_DAYS = 36_600;
71
56
  const MAX_ICAL_INTERVAL = {
@@ -76,23 +61,13 @@ const MAX_ICAL_INTERVAL = {
76
61
  WEEKLY: Math.floor(MAX_ICAL_STEP_DAYS / 7),
77
62
  };
78
63
  /**
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.
64
+ * The RecurIterator methods charged, and what each call costs in steps of about a microsecond
65
+ * (measured on ical.js 2.2.1, with the costliest rule shapes each method serves). Every search step
66
+ * ends in exactly one `next_second` (the coarser `next_*` methods call down to it) followed by a
67
+ * check of the rule's parts. `ruleDayOfWeek` is charged per BYDAY value compared;
68
+ * `increment_monthday` per day walked; `expand_year_days` and `expand_by_day` per day listed.
69
+ * Outlook writes every zone's observances from 1601 as `BYMONTH` with one numbered `BYDAY`: about
70
+ * 6,000 steps per zone.
96
71
  */
97
72
  const ICAL_STEP_CHARGES = {
98
73
  next_second: { before: () => 3 },
@@ -107,15 +82,10 @@ const ICAL_STEP_CHARGES = {
107
82
  expand_by_day: { before: () => 0, after: (_iterator, days) => listedDays(days) / 2 },
108
83
  };
109
84
  /**
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.
85
+ * What Slotlock's own work on what ical.js finds costs, in the same steps: each occurrence passed;
86
+ * each wall-time/instant conversion (half a step in an IANA zone, one and a half in a feed-defined
87
+ * zone); and each instant Intl is asked for to read a day of an IANA zone's offsets, the first time
88
+ * a budget reads that day.
119
89
  */
120
90
  const OCCURRENCE_STEPS = 1;
121
91
  /** Each further RRULE whose next time an expansion step compares, choosing among them. */
@@ -123,9 +93,7 @@ const RULE_SCAN_STEPS = 1;
123
93
  const ZONE_READ_STEPS = { conversion: 0.5, ownZone: 1.5, intl: 5 };
124
94
  /**
125
95
  * 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.
96
+ * it adjusts and sorts every one, about 0.4 µs each. The zone's rule steps are charged besides.
129
97
  */
130
98
  const ZONE_CHANGE_STEPS = 0.4;
131
99
  /** How many days a list holds, or a whole year's when ical.js no longer gives one. */
@@ -133,20 +101,17 @@ function listedDays(days) {
133
101
  return Array.isArray(days) ? days.length : 366;
134
102
  }
135
103
  /**
136
- * The ical.js release a series cursor holds the iterator state of. A cursor is ical.js's own
137
- * RecurIterator and RecurExpansion state, which only that release is known to continue exactly, so
138
- * a cursor another release wrote is ignored and the series searched from its start. A test pins this
139
- * to the installed package: upgrading ical.js means checking the state these cursors keep again.
104
+ * The ical.js release whose RecurIterator/RecurExpansion state a series cursor holds; only that
105
+ * release is known to continue it exactly, so a cursor another release wrote is ignored. A test
106
+ * pins this to the installed package: upgrading ical.js means rechecking cursor state.
140
107
  */
141
108
  export const ICAL_JS_VERSION = '2.2.1';
142
- /** The shape of the cursor this code writes and reads. */
143
109
  const SERIES_CURSOR_FORMAT = 1;
144
110
  /** The most a cursor may take as JSON; a bigger one is not kept (its series is searched from the start). */
145
111
  const MAX_SERIES_CURSOR_BYTES = 16_384;
146
112
  /**
147
- * How much later than its start plus the series' length an unmoved occurrence may end: ical.js adds
148
- * the length in wall-clock time, so a clock change between the two moves the end by up to a few
149
- * hours. A day covers every zone.
113
+ * ical.js adds the series' length in wall-clock time, so a clock change can move an unmoved
114
+ * occurrence's end by a few hours; a day covers every zone.
150
115
  */
151
116
  const OCCURRENCE_END_SLACK_MS = 24 * 60 * 60 * 1000;
152
117
  const BY_PARTS = new Set([
@@ -175,8 +140,7 @@ export class CalendarContractError extends Error {
175
140
  }
176
141
  }
177
142
  /**
178
- * A calendar that needs more work than Slotlock does to read or expand it: steps, time, a step or a
179
- * span longer than a century, or more occurrences, exceptions or dates than it keeps. Still
143
+ * A calendar that needs more work than Slotlock does to read or expand it. Still
180
144
  * `invalid_icalendar` to callers; the horizon roll counts these and skips the series.
181
145
  */
182
146
  export class CalendarExpansionLimitError extends CalendarContractError {
@@ -204,7 +168,7 @@ function optionalIdentity(value) {
204
168
  }
205
169
  function instant(value) {
206
170
  const parsed = value instanceof Date ? new Date(value.getTime()) : new Date(value);
207
- // Years 1-9999: what an RFC 3339 date-time, as the events are returned, can write.
171
+ // Years 1-9999: what an RFC 3339 date-time can write.
208
172
  const year = parsed.getUTCFullYear();
209
173
  if (!Number.isFinite(parsed.getTime()) || year < 1 || year > 9999) {
210
174
  throw new CalendarContractError('invalid_external_interval');
@@ -212,9 +176,9 @@ function instant(value) {
212
176
  return parsed;
213
177
  }
214
178
  /**
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.
179
+ * Trimmed, line breaks LF. Tab and LF are kept (ical.js escapes LF); other control characters are
180
+ * refused: ical.js leaves a bare CR as is, and a reader splitting lines on it would see a new
181
+ * property.
218
182
  */
219
183
  function boundedText(value, maxBytes, required) {
220
184
  const normalized = value.replace(/\r\n/g, '\n').trim();
@@ -226,9 +190,8 @@ function boundedText(value, maxBytes, required) {
226
190
  return normalized || undefined;
227
191
  }
228
192
  /**
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.
193
+ * An IANA name in one spelling (canonicalTimeZoneName). A UTC offset such as `+05:00` is refused:
194
+ * RFC 5545 §3.2.19 wants a VTIMEZONE for each TZID, and an offset has no rules.
232
195
  */
233
196
  function validTimezone(timezone) {
234
197
  const normalized = timezone.trim();
@@ -378,9 +341,7 @@ const RECUR_RULE_PARTS = new Map([
378
341
  ['BYSETPOS', list('[+-]?\\d{1,3}')],
379
342
  ['WKST', new RegExp(`^${WEEKDAY}$`)],
380
343
  ]);
381
- /** The longest each month can be, February in a leap year. */
382
344
  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
345
  const MIN_MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
385
346
  /** The numeric BY parts, which RFC 5545 §3.3.10 reads as sets and ical.js walks in written order. */
386
347
  const NUMERIC_BY_PARTS = [
@@ -394,9 +355,9 @@ const NUMERIC_BY_PARTS = [
394
355
  'BYSETPOS',
395
356
  ];
396
357
  /**
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.
358
+ * Sort and dedupe a rule's numeric BY lists. RFC 5545 §3.3.10 reads each as a set, but ical.js
359
+ * 2.2.1 walks BYHOUR, BYMINUTE, BYSECOND and BYMONTH in written order (`FREQ=DAILY;BYHOUR=17,8`
360
+ * gave 17:00 then an earlier 08:00).
400
361
  */
401
362
  function sortRuleParts(recur) {
402
363
  const parts = recur.parts;
@@ -408,24 +369,18 @@ function sortRuleParts(recur) {
408
369
  }
409
370
  }
410
371
  /**
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);
372
+ * Why ical.js 2.2.1 would expand `recur` other than RFC 5545 §3.3.10 says, or null. Each refusal is
373
+ * a shape found wrong against a brute-force reading of the RFC (the tests' rrule-oracle):
374
+ * - BYWEEKNO;
375
+ * - a two-digit BYDAY ordinal (read by its last digit);
376
+ * - BYSETPOS other than with FREQ=MONTHLY and BYDAY alone;
377
+ * - BYMONTH with FREQ=MONTHLY;
378
+ * - a negative BYMONTHDAY except with FREQ=MONTHLY and no BYDAY;
379
+ * - BYMONTHDAY with FREQ=YEARLY but no BYMONTH, naming only days its months lack, or a day one can
380
+ * lack with COUNT or BYDAY;
381
+ * - with FREQ=YEARLY, BYYEARDAY with BYMONTH or BYMONTHDAY, and a numbered BYDAY with BYMONTHDAY;
382
+ * - BYHOUR with HOURLY, BYMINUTE or BYSECOND with MINUTELY, and any of the three with MONTHLY or
383
+ * YEARLY;
429
384
  * - FREQ=SECONDLY, which nothing Slotlock reads needs.
430
385
  */
431
386
  function recurShapeProblem(recur) {
@@ -476,10 +431,10 @@ function recurShapeProblem(recur) {
476
431
  return null;
477
432
  }
478
433
  /**
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.
434
+ * Why a rule cannot be expanded from this DTSTART, or null. FREQ=YEARLY with no BYMONTHDAY, BYDAY
435
+ * or BYYEARDAY takes DTSTART's day, and ical.js moves a missing day into the next month (29
436
+ * February to 1 March) instead of skipping the year; yearlyDays filters those, but COUNT has
437
+ * already counted them.
483
438
  */
484
439
  function seriesShapeProblem(recur, start) {
485
440
  return recur.count != null && yearlyDays(recur, start) !== null
@@ -487,11 +442,9 @@ function seriesShapeProblem(recur, start) {
487
442
  : null;
488
443
  }
489
444
  /**
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).
445
+ * The months and days a FREQ=YEARLY rule's occurrences fall on when it names a day a month can lack
446
+ * (29 February, 31 April), or null. ical.js 2.2.1 moves such a day into the next month where RFC
447
+ * 5545 §3.3.10 ignores it.
495
448
  */
496
449
  function yearlyDays(recur, start) {
497
450
  const parts = recur.parts;
@@ -514,13 +467,11 @@ export function recurrenceRuleProblem(rule) {
514
467
  return recurShapeProblem(ICAL.Recur.fromString(canonical));
515
468
  }
516
469
  /**
517
- * A recurrence rule as RFC 5545 §3.3.10 writes it, in ical.js's canonical spelling, or `null`.
518
- * ical.js accepts more than it should: it drops unknown parts, lets a repeated part replace the
519
- * first, reads `COUNT=3X` as 3 and `COUNT=0` as no count at all. So every part must be one RFC 5545
520
- * names, appear once and match its grammar, with FREQ present and never UNTIL and COUNT together,
521
- * and the parts RFC 5545 forbids for a frequency (BYWEEKNO but YEARLY, BYYEARDAY with DAILY, WEEKLY
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).
470
+ * A recurrence rule in ical.js's canonical spelling, or `null`. ical.js is too lenient (it drops
471
+ * unknown parts, lets a repeated part win, reads `COUNT=3X` as 3 and `COUNT=0` as none), so every
472
+ * part must be RFC 5545's, appear once and match its grammar, with FREQ present, never UNTIL with
473
+ * COUNT, and no part RFC 5545 forbids for the frequency. Numeric BY lists come back sorted
474
+ * (sortRuleParts).
524
475
  */
525
476
  export function canonicalRecurrenceRule(rule) {
526
477
  const text = rule.trim().toUpperCase();
@@ -558,10 +509,9 @@ export function canonicalRecurrenceRule(rule) {
558
509
  }
559
510
  }
560
511
  /**
561
- * A rule Slotlock books from an agent: valid as RFC 5545 writes it, daily or less often, and able to
562
- * occur. ical.js searches day by day (or faster) for a DAILY, HOURLY, MINUTELY or SECONDLY rule's
563
- * next occurrence with no limit, so one that can never occur (`BYMONTH=2;BYMONTHDAY=30`), or a
564
- * sub-daily one that rarely does, holds the process forever.
512
+ * A rule Slotlock books from an agent: valid, daily or less often, and able to occur. ical.js
513
+ * searches sub-daily rules step by step with no limit, so one that never occurs
514
+ * (`BYMONTH=2;BYMONTHDAY=30`) would hold the process forever.
565
515
  */
566
516
  export function isBookableRecurrenceRule(rule) {
567
517
  const canonical = canonicalRecurrenceRule(rule);
@@ -579,11 +529,9 @@ export function isBookableRecurrenceRule(rule) {
579
529
  return months.some((month) => days.some((day) => day >= 1 && day <= (MONTH_DAYS[month - 1] ?? 0)));
580
530
  }
581
531
  /**
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.
532
+ * A stored series' rule: bookable for this start, in canonical spelling. A DATE UNTIL becomes the
533
+ * UTC instant its day ends in the event's zone: RFC 5545 §3.3.10 wants a zoned DTSTART's UNTIL in
534
+ * UTC, inclusive; midnight UTC would lose the last day east of UTC.
587
535
  */
588
536
  function normalizeRecurrence(input, start, timezone) {
589
537
  if (!input)
@@ -717,19 +665,17 @@ function intlReader(timezone) {
717
665
  return utcFrame(fields.year, fields.month, fields.day, fields.hour, fields.minute, fields.second, 0).getTime();
718
666
  },
719
667
  };
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.
668
+ // Feeds can name unknown zones freely, so the cache is emptied rather than grown without bound.
722
669
  if (intlReaders.size >= 256)
723
670
  intlReaders.clear();
724
671
  intlReaders.set(timezone, reader);
725
672
  return reader;
726
673
  }
727
674
  /**
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).
675
+ * A zone only the calendar defines, read from its own observances. ical.js reads wall times in such
676
+ * a zone with `want_daylight = false; // TODO` (timezone.js), mishandling folds and gaps; here
677
+ * offsets come from the changes ical.js expands, and wall times are read as in any other zone
678
+ * (wallFrameToInstant).
733
679
  */
734
680
  function vtimezoneReader(zone) {
735
681
  const known = vtimezoneReaders.get(zone);
@@ -775,13 +721,11 @@ function vtimezoneReader(zone) {
775
721
  vtimezoneReaders.set(zone, reader);
776
722
  return reader;
777
723
  }
778
- /** One reader per zone a calendar defines, for as long as its parse holds the zone. */
779
724
  const vtimezoneReaders = new WeakMap();
780
725
  /**
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.
726
+ * The zone a calendar time is read in: UTC, the IANA zone its TZID (or the calendar's zone, for a
727
+ * floating time) names, or the calendar's own VTIMEZONE. A floating time with no calendar zone, or
728
+ * an unknown TZID, is refused: a server has no local zone.
785
729
  */
786
730
  function zoneReader(value, propertyTimezone, calendarTimezone) {
787
731
  if (!value || typeof value !== 'object' || !('toJSDate' in value)) {
@@ -798,8 +742,7 @@ function zoneReader(value, propertyTimezone, calendarTimezone) {
798
742
  if (timezone) {
799
743
  if (isKnownTimezone(timezone))
800
744
  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.
745
+ // A compliant VTIMEZONE may use a private TZID Intl does not know.
803
746
  if (zone && valueZone === zone && defined && own)
804
747
  return vtimezoneReader(own);
805
748
  throw new CalendarContractError('invalid_icalendar');
@@ -816,9 +759,8 @@ function wallFrameOf(value) {
816
759
  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
760
  }
818
761
  /**
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.
762
+ * Where a DURATION from `start` ends, RFC 5545 §3.3.6: weeks and days on the calendar first, then
763
+ * hours, minutes and seconds elapsed. PT48H is always 48 hours; P2D may be 47 or 49.
822
764
  */
823
765
  function durationEnd(reader, startWall, start, duration) {
824
766
  const days = duration.weeks * 7 + duration.days;
@@ -826,9 +768,8 @@ function durationEnd(reader, startWall, start, duration) {
826
768
  return (days > 0 ? reader.toInstant(startWall + days * 24 * 60 * 60 * 1000) : start) + ms;
827
769
  }
828
770
  /**
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.
771
+ * Every RRULE value written inside a VEVENT, as written: ical.js's parsed form has lost what it
772
+ * misreads (`COUNT=0`, `COUNT=3X`, a second FREQ). VTIMEZONE rules are left out.
832
773
  */
833
774
  function veventRuleTexts(input) {
834
775
  const rules = [];
@@ -847,9 +788,9 @@ function veventRuleTexts(input) {
847
788
  return rules;
848
789
  }
849
790
  /**
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.
791
+ * The instant a calendar time names (zoneReader), per RFC 5545 §3.3.5 (first instant of a repeated
792
+ * time, a skipped time moved forward), charged to `budget` when given. ical.js would frame a
793
+ * floating value as a local JS date.
853
794
  */
854
795
  function calendarTimeValue(value, propertyTimezone, calendarTimezone, budget) {
855
796
  const shared = zoneReader(value, propertyTimezone, calendarTimezone);
@@ -867,16 +808,16 @@ function ownZone(value) {
867
808
  return tzid && tzid !== 'floating' && tzid !== 'local' ? tzid : undefined;
868
809
  }
869
810
  /**
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).
811
+ * What one parse may spend: steps searching (ICAL_STEP_CHARGES), reading each occurrence, and
812
+ * reading times in their zones.
872
813
  */
873
814
  class RecurrenceBudget {
874
815
  steps;
875
816
  /** Each zone's readers as this budget charges them, so a parse's readers stay one per zone. */
876
817
  readers = new Map();
877
818
  /**
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).
819
+ * Which days of each IANA zone's offsets this budget has paid for: UTC day `d`'s midnight as
820
+ * `2d`, its changes as `2d + 1` (zoneOffsetDay).
880
821
  */
881
822
  read = new Map();
882
823
  constructor(limits) {
@@ -912,14 +853,12 @@ class RecurrenceBudget {
912
853
  return charged;
913
854
  }
914
855
  /**
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.
856
+ * Charge one conversion in `zone` reading offsets from `fromMs` to `toMs` (at most three UTC
857
+ * days), plus the days not yet paid for. The process caches offsets, but each parse pays as if it
858
+ * read them now, so a refusal never depends on what ran before.
920
859
  */
921
860
  chargeZoneRead(zone, fromMs, toMs) {
922
- // Without an IANA zone, a reader reads one only the feed defines (UTC's is never charged).
861
+ // No IANA zone: a feed-defined one (UTC's is never charged).
923
862
  if (zone === undefined) {
924
863
  this.charge(ZONE_READ_STEPS.ownZone);
925
864
  return;
@@ -949,11 +888,10 @@ class RecurrenceBudget {
949
888
  }
950
889
  }
951
890
  /**
952
- * A RecurIterator subclass, made for one parse, whose every step is charged to `budget` and throws
953
- * past it. The constructor's own scans (for a YEARLY rule, a year with a matching day, as far as the
954
- * year 20000) run through these methods on the instance being built, so they are charged too.
955
- * ical.js itself is not changed for anything else in the process; and if a new ical.js no longer
956
- * has a method this charges, every recurring event is refused rather than expanded without a limit.
891
+ * A RecurIterator subclass for one parse whose every step is charged to `budget` and throws past
892
+ * it. The constructor's own scans run through these methods, so they are charged too. ical.js
893
+ * itself is untouched; if a new ical.js drops a charged method, every recurring event is refused
894
+ * rather than expanded unbounded.
957
895
  */
958
896
  function budgetedRecurIterator(budget) {
959
897
  class BudgetedRecurIterator extends ICAL.RecurIterator {
@@ -975,8 +913,8 @@ function budgetedRecurIterator(budget) {
975
913
  return BudgetedRecurIterator;
976
914
  }
977
915
  /**
978
- * Make `rule` build `Budgeted` iterators, or refuse it: anything but a parsed rule, or a SECONDLY to
979
- * WEEKLY one whose step moves further than MAX_ICAL_STEP_DAYS.
916
+ * Make `rule` build `Budgeted` iterators, or refuse it (not a parsed rule, or a step longer than
917
+ * MAX_ICAL_STEP_DAYS).
980
918
  */
981
919
  function budgetRule(rule, Budgeted) {
982
920
  if (!(rule instanceof ICAL.Recur))
@@ -987,10 +925,9 @@ function budgetRule(rule, Budgeted) {
987
925
  rule.iterator = (start) => new Budgeted({ rule, dtstart: start });
988
926
  }
989
927
  /**
990
- * `event.iterator()`, with every rule iterator built as `Budgeted`. RecurExpansion builds them from
991
- * the event's own RRULE values (`rule.iterator(dtstart)`) and takes the first step at once, so the
992
- * values are changed before it is called: wrapping its iterators afterwards left that step, the
993
- * one most rules that never occur stop in, unbounded.
928
+ * `event.iterator()` with every rule iterator built as `Budgeted`. RecurExpansion takes its first
929
+ * step at once, so the rules are changed before it is called; wrapping afterwards would leave that
930
+ * step unbounded.
994
931
  */
995
932
  function budgetedExpansion(event, Budgeted, budget, start) {
996
933
  for (const property of event.component.getAllProperties('rrule')) {
@@ -1004,10 +941,9 @@ function budgetedExpansion(event, Budgeted, budget, start) {
1004
941
  return withEveryRule(expansion, budget);
1005
942
  }
1006
943
  /**
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.
944
+ * `expansion`, choosing among its RRULE iterators correctly: ical.js 2.2.1's
945
+ * RecurExpansion#_nextRecurrenceIter removes the wrong iterator by index once a rule ends, losing
946
+ * occurrences of multi-RRULE series.
1011
947
  */
1012
948
  function withEveryRule(expansion, budget) {
1013
949
  const state = expansion;
@@ -1034,10 +970,7 @@ function withEveryRule(expansion, budget) {
1034
970
  };
1035
971
  return expansion;
1036
972
  }
1037
- /**
1038
- * How many values a property lists, read from its raw form: reading the values themselves makes
1039
- * ical.js resolve each one's zone.
1040
- */
973
+ /** Counted from the raw form: reading values would make ical.js resolve each one's zone. */
1041
974
  function valueCount(property) {
1042
975
  return Math.max(0, property.jCal.length - 3);
1043
976
  }
@@ -1050,8 +983,8 @@ function dayOf(time) {
1050
983
  return `${time.year}-${time.month}-${time.day}`;
1051
984
  }
1052
985
  /**
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.
986
+ * Milliseconds of a DATE or DATE-TIME property's first value as written, ignoring its zone; read
987
+ * from the raw form so no zone resolves yet.
1055
988
  */
1056
989
  function writtenFieldsMs(property) {
1057
990
  const value = property?.jCal[3];
@@ -1064,13 +997,10 @@ function writtenFieldsMs(property) {
1064
997
  return utcFrame(year, month, day, hour, minute, second, 0).getTime();
1065
998
  }
1066
999
  /**
1067
- * Refuse a VEVENT that ical.js would spend unbounded work reading, before anything reads its times:
1068
- * a DURATION, or a DTEND after its DTSTART, longer than MAX_ICAL_STEP_DAYS (ical.js adds a span to
1069
- * a time a month at a time, Time#adjust, and `DURATION:PT99999999999999999999S` held the process),
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.
1000
+ * Refuse a VEVENT ical.js would spend unbounded work reading, before any time is read: a DURATION
1001
+ * or DTEND span over MAX_ICAL_STEP_DAYS (ical.js adds spans a month at a time, Time#adjust), a
1002
+ * negative DURATION, too many RDATE/EXDATE values or RRULEs. Returns how many dates it lists, for
1003
+ * the feed's own limit. It reads times only as written, since framing reads every date value.
1074
1004
  */
1075
1005
  function boundComponent(component) {
1076
1006
  const maxMs = MAX_ICAL_STEP_DAYS * 24 * 60 * 60 * 1000;
@@ -1109,10 +1039,9 @@ function boundComponents(components) {
1109
1039
  }
1110
1040
  }
1111
1041
  /**
1112
- * The feed's VEVENTs as ical.js events, each related to no exception yet. Left to itself, ical.js
1113
- * relates every VEVENT with a RECURRENCE-ID in the calendar to every series, whatever its UID, by
1114
- * local time, so one series' cancelled 09:00 cancelled every other series' 09:00 that day; the
1115
- * parsers relate each series' own exceptions themselves.
1042
+ * The feed's VEVENTs as ical.js events, each related to no exception yet. ical.js relates every
1043
+ * RECURRENCE-ID in the calendar to every series by local time whatever its UID, so the parsers
1044
+ * relate each series' exceptions themselves.
1116
1045
  */
1117
1046
  function feedEvents(components) {
1118
1047
  return components.map((component) => new ICAL.Event(component, { exceptions: [] }));
@@ -1128,17 +1057,15 @@ function boundInput(input) {
1128
1057
  boundPropertyHeads(input);
1129
1058
  }
1130
1059
  /**
1131
- * Refuse a property whose name and parameters run past MAX_ICAL_PROPERTY_HEAD_BYTES or list more
1132
- * than MAX_ICAL_PROPERTY_PARAMETERS, as ical.js will read them. ical.js searches again from every
1133
- * parameter for the next `;`, `:` and `"`, so the parameters on a line cost it their number times
1134
- * their length; this finds them the same way, reading each character once.
1060
+ * Refuse a property whose head runs past MAX_ICAL_PROPERTY_HEAD_BYTES or
1061
+ * MAX_ICAL_PROPERTY_PARAMETERS as ical.js will read it. ical.js re-searches from every parameter,
1062
+ * so cost is count times length; this reads each character once.
1135
1063
  */
1136
1064
  function boundPropertyHeads(input) {
1137
1065
  const limits = { parameters: MAX_ICAL_PROPERTY_PARAMETERS, end: MAX_ICAL_PROPERTY_HEAD_BYTES };
1138
1066
  for (const head of icalendarPropertyHeads(input, limits)) {
1139
- // A VCARD moves ical.js to the vCard 3 parameters for the rest of the feed, where TYPE takes
1140
- // several quoted values, so what follows would be read differently than counted here. An
1141
- // iCalendar feed has no place for one.
1067
+ // A VCARD switches ical.js to vCard 3 parameters for the rest of the feed, which this count
1068
+ // does not follow; an iCalendar feed has no place for one.
1142
1069
  if (head.begins === 'vcard')
1143
1070
  throw new CalendarContractError('invalid_icalendar');
1144
1071
  if (head.end > MAX_ICAL_PROPERTY_HEAD_BYTES || head.parameters > MAX_ICAL_PROPERTY_PARAMETERS) {
@@ -1147,13 +1074,11 @@ function boundPropertyHeads(input) {
1147
1074
  }
1148
1075
  }
1149
1076
  /**
1150
- * Each line of `input` as ical.js 2.2.1's parser takes it, with the parameters it reads there: the
1151
- * lines as parse._eachLine unfolds them, read with the design set ical.js picks at the first BEGIN,
1152
- * and for a BEGIN line the component it begins. Ends at a line before that BEGIN, where ical.js
1153
- * throws. ical.js changes its design set in one other place: a VCARD component's first property
1154
- * (unless it is VERSION:4.0) moves it to vCard 3's for the rest of the feed. This does not follow
1155
- * that move; boundPropertyHeads refuses a feed with a VCARD instead. With `limits`, a line's count
1156
- * stops once it is past them. Exported for the tests, which hold it to ical.js's own reading.
1077
+ * Each line of `input` as ical.js 2.2.1's parser takes it, with the parameters it reads there
1078
+ * (parse._eachLine, with the design set picked at the first BEGIN), and for a BEGIN line the
1079
+ * component. Stops before the first BEGIN, where ical.js throws. Does not follow a VCARD's switch
1080
+ * to vCard 3 (boundPropertyHeads refuses those). With `limits`, a line's count stops once past
1081
+ * them. Exported for the tests.
1157
1082
  */
1158
1083
  export function* icalendarPropertyHeads(input, limits) {
1159
1084
  // undefined: no BEGIN yet. null: a design set without parameters, which ical.js picks for a first
@@ -1242,12 +1167,11 @@ function forwardSearch(text, char) {
1242
1167
  };
1243
1168
  }
1244
1169
  /**
1245
- * How many parameters ical.js 2.2.1 reads on one unfolded line and where it stops reading them
1246
- * (parse._handleContentLine, then parse._parseParameters, step for step): a `"` opens a quoted value
1247
- * only straight after `=`, a quoted value of a parameter that takes several (MEMBER, DELEGATED-TO)
1248
- * goes on past `","`, and a `:` ends the parameters only where ical.js looks for one. ical.js throws
1249
- * where this stops early (an empty name, an unclosed quote), and stops once past `limits` if given.
1250
- * Exported for the tests, which hold it to ical.js's own count.
1170
+ * How many parameters ical.js 2.2.1 reads on one unfolded line and where it stops
1171
+ * (parse._handleContentLine, then _parseParameters, step for step): a `"` opens a quoted value only
1172
+ * right after `=`, a multi-valued parameter (MEMBER, DELEGATED-TO) continues past `","`, and `:`
1173
+ * ends the parameters only where ical.js looks for one. Stops early where ical.js throws, and past
1174
+ * `limits` if given. Exported for the tests.
1251
1175
  */
1252
1176
  export function icalendarPropertyHead(line, params, limits) {
1253
1177
  const paramPos = line.indexOf(';');
@@ -1308,10 +1232,9 @@ export function icalendarPropertyHead(line, params, limits) {
1308
1232
  return { parameters, end: paramPos + end };
1309
1233
  }
1310
1234
  /**
1311
- * Give every rule of the feed's own time zones (VTIMEZONE STANDARD and DAYLIGHT) a budgeted
1312
- * iterator, before any time is read. ical.js expands a zone's rule (Timezone#_expandComponent, from
1313
- * the same rule values) whenever it converts a time in a zone Intl does not know, so a private TZID
1314
- * whose rule never occurs held the process on a feed's first event, recurring or not.
1235
+ * Give every VTIMEZONE rule a budgeted iterator before any time is read: ical.js expands a zone's
1236
+ * rules whenever it converts a time in a zone Intl does not know, so a private TZID whose rule
1237
+ * never occurs would hang the first read.
1315
1238
  */
1316
1239
  function budgetTimezoneRules(root, Budgeted) {
1317
1240
  const zones = root.getAllSubcomponents('vtimezone');
@@ -1338,15 +1261,11 @@ function budgetTimezoneRules(root, Budgeted) {
1338
1261
  }
1339
1262
  }
1340
1263
  /**
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
1343
- * again from its start and appends all of them to what it already had: 1,000 date values in years
1344
- * six apart, sorted inside one call, expanded a zone of 3,000 changes 1,000 times into 3 million
1345
- * (26 to 35 seconds, 650 MB). Each expansion starts again from DTSTART, so emptying the list first
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.
1264
+ * Keep one copy of each feed-defined zone's changes. Whenever ical.js reads past what it expanded,
1265
+ * it re-expands every observance from its start and appends them all, growing without bound; each
1266
+ * expansion restarts from DTSTART, so emptying the list first leaves the same changes once. Each
1267
+ * expansion is charged per change (ZONE_CHANGE_STEPS), and each offset lookup as a conversion. If
1268
+ * ical.js no longer expands zones this way, the feed is refused.
1350
1269
  */
1351
1270
  function budgetTimezoneCoverage(root, budget) {
1352
1271
  for (const component of root.getAllSubcomponents('vtimezone')) {
@@ -1399,9 +1318,8 @@ function isRecord(value) {
1399
1318
  return typeof value === 'object' && value !== null && !Array.isArray(value);
1400
1319
  }
1401
1320
  /**
1402
- * The key a cursor for this series must carry, or `null` for a series no cursor can serve: one that
1403
- * is not exactly one DTSTART and one RRULE (RDATE and EXDATE values move the expansion's own state,
1404
- * which a cursor does not keep; a stored series never has them).
1321
+ * The key a cursor for this series must carry, or `null` when no cursor can serve it: anything but
1322
+ * exactly one DTSTART and one RRULE (RDATE and EXDATE move state a cursor does not keep).
1405
1323
  */
1406
1324
  function seriesKey(component) {
1407
1325
  const starts = component.getAllProperties('dtstart');
@@ -1414,9 +1332,8 @@ function seriesKey(component) {
1414
1332
  }
1415
1333
  const start = starts[0];
1416
1334
  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.
1335
+ // Slotlock 0.1 compared a zoned series with its UTC UNTIL as UTC, so its cursors may be off by
1336
+ // one: such a series gets a key of its own and is searched from the start.
1420
1337
  const zone = start.getParameter('tzid');
1421
1338
  const zonedUntil = typeof zone === 'string' &&
1422
1339
  zone.trim() !== 'UTC' &&
@@ -1475,7 +1392,7 @@ function readSeriesCursor(value, key) {
1475
1392
  }
1476
1393
  return value;
1477
1394
  }
1478
- /** The expansion's state now, copied: what a cursor would keep if the window needs nothing before it. */
1395
+ /** A copy of the expansion's state: what a cursor keeps when the window needs nothing before it. */
1479
1396
  function snapshotSeries(expansion, key, position) {
1480
1397
  const state = expansion;
1481
1398
  if (state.ruleIterators.length !== 1 ||
@@ -1506,8 +1423,8 @@ function snapshotSeries(expansion, key, position) {
1506
1423
  };
1507
1424
  }
1508
1425
  /**
1509
- * A snapshot as the store will keep it, or `null`: it must survive JSON whole (a part JSON cannot
1510
- * hold, such as an `undefined` ical.js reads with `in`, would resume a different search) and fit.
1426
+ * A snapshot as stored, or `null`: it must survive JSON whole (an `undefined` ical.js reads with
1427
+ * `in` would resume a different search) and fit.
1511
1428
  */
1512
1429
  function keptSeriesCursor(snapshot) {
1513
1430
  if (snapshot === null)
@@ -1525,15 +1442,15 @@ function keptSeriesCursor(snapshot) {
1525
1442
  return kept;
1526
1443
  }
1527
1444
  /**
1528
- * The series' expansion as `cursor` left it: ical.js's RecurExpansion and its one RecurIterator,
1529
- * rebuilt from their state (`initialized`, so the iterator does not search again from DTSTART) with
1530
- * this parse's rule, start and zone, and its steps charged to this parse's budget.
1445
+ * The expansion as `cursor` left it: RecurExpansion and its one RecurIterator rebuilt from state
1446
+ * (`initialized`, so it does not restart from DTSTART) with this parse's rule, start, zone and
1447
+ * budget.
1531
1448
  */
1532
1449
  function resumedExpansion(event, Budgeted, budget, cursor) {
1533
1450
  const rule = event.component.getFirstPropertyValue('rrule');
1534
1451
  budgetRule(rule, Budgeted);
1535
1452
  const dtstart = event.startDate;
1536
- // What a search from the start begins with, built as ical.js builds it (its steps charged too).
1453
+ // A search from the start, built as ical.js builds it (its steps charged too).
1537
1454
  const reference = new Budgeted({ rule, dtstart });
1538
1455
  if (!cursorFitsRule(cursor.iterator, reference, rule))
1539
1456
  return null;
@@ -1566,11 +1483,9 @@ function resumedExpansion(event, Budgeted, budget, cursor) {
1566
1483
  }
1567
1484
  /**
1568
1485
  * Whether a cursor's iterator state agrees with `reference`, an iterator ical.js built for the same
1569
- * rule and start: the same BY lists (the rule's, and the start's defaults; BYMONTHDAY holds the
1570
- * current month's days, which ical.js sets as it goes, so only its days are checked), an index
1571
- * within each list, days of the year, and a count and an end the rule allows. Anything else is a
1572
- * cursor no expansion of this rule left, and it is not resumed: an hour of 10^9, say, would have
1573
- * ical.js walk it a month at a time inside one step.
1486
+ * rule and start: same BY lists, indexes within them, valid days of the year, and a count and end
1487
+ * the rule allows. Anything else did not come from this rule and is not resumed (an hour of 10^9
1488
+ * would have ical.js walk it a month at a time inside one step).
1574
1489
  */
1575
1490
  function cursorFitsRule(state, reference, rule) {
1576
1491
  const expected = reference.by_data;
@@ -1595,25 +1510,22 @@ function cursorFitsRule(state, reference, rule) {
1595
1510
  if (values === undefined ? index !== 0 : index >= Math.max(values.length, 1))
1596
1511
  return false;
1597
1512
  }
1598
- // Days of the year, 1-366, except that a rule with BYYEARDAY alone keeps its values as written,
1599
- // so -366 to -1 count from the end of the year (ical.js expand_year_days).
1513
+ // Days of the year 1-366, or -366 to -1 for a rule with BYYEARDAY alone (ical.js
1514
+ // expand_year_days).
1600
1515
  if (!state.days.every((day) => Number.isInteger(day) && day !== 0 && Math.abs(day) <= 366)) {
1601
1516
  return false;
1602
1517
  }
1603
1518
  if (rule.count != null && state.occurrence_number > rule.count)
1604
1519
  return false;
1605
- // Only a rule that ends can have ended.
1606
1520
  if (state.completed && rule.count == null && rule.until == null)
1607
1521
  return false;
1608
1522
  return true;
1609
1523
  }
1610
1524
  /**
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.
1525
+ * An IANA zone ical.js reads through Intl, for a TZID named without a VTIMEZONE (as real feeds and
1526
+ * stored series do). Otherwise ical.js reads such a time as floating and compares it with UTC
1527
+ * values as if UTC, losing UNTIL, EXDATE and RECURRENCE-ID matches. Offsets come from Slotlock's
1528
+ * converter: a gap moves forward, a fold takes its first instant.
1617
1529
  */
1618
1530
  class IntlTimezone extends ICAL.Timezone {
1619
1531
  zone;
@@ -1624,7 +1536,7 @@ class IntlTimezone extends ICAL.Timezone {
1624
1536
  offsets = new Map();
1625
1537
  constructor(tzid, budget) {
1626
1538
  super({ tzid });
1627
- // A TZID is matched as written (" Europe/Berlin" is that TZID); Slotlock reads its zone trimmed.
1539
+ // A TZID is matched as written; Slotlock reads its zone trimmed.
1628
1540
  this.zone = tzid.trim();
1629
1541
  this.converter = createTimeZoneConverter(this.zone);
1630
1542
  this.budget = budget;
@@ -1634,7 +1546,6 @@ class IntlTimezone extends ICAL.Timezone {
1634
1546
  const wallMs = wall.getTime();
1635
1547
  let offset = this.offsets.get(wallMs);
1636
1548
  if (offset === undefined) {
1637
- // ical.js compares a series' times at every step of its search, each in this zone.
1638
1549
  this.budget.chargeZoneRead(this.zone, wallMs - ZONE_OFFSET_REACH_MS, wallMs + ZONE_OFFSET_REACH_MS);
1639
1550
  offset = Math.round((wallMs - this.converter.wallFrameToUtc(wallMs).getTime()) / 1000);
1640
1551
  if (this.offsets.size >= 4_096)
@@ -1648,10 +1559,8 @@ class IntlTimezone extends ICAL.Timezone {
1648
1559
  const COMPARED_TIME_PROPERTIES = ['dtstart', 'dtend', 'recurrence-id', 'rdate', 'exdate'];
1649
1560
  const knownTimezones = new Map();
1650
1561
  /**
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).
1562
+ * Whether Intl reads `timezone` (trimmed) as a zone. Each check builds a formatter (~30 µs) and
1563
+ * every occurrence asks again, so answers are cached and the map emptied when full.
1655
1564
  */
1656
1565
  function isKnownTimezone(timezone) {
1657
1566
  const name = timezone.trim();
@@ -1699,7 +1608,6 @@ function localDateOf(value, tzid, timezone) {
1699
1608
  instant = zonedDateTimeToUtc(fields, tzid.trim());
1700
1609
  }
1701
1610
  else {
1702
- // Floating: the wall time is the calendar's own, and so is its date.
1703
1611
  return value.slice(0, 10);
1704
1612
  }
1705
1613
  return jCalDateTime(zonedDateTimeFields(instant, timezone)).slice(0, 10);
@@ -1707,17 +1615,15 @@ function localDateOf(value, tzid, timezone) {
1707
1615
  /**
1708
1616
  * Prepare a calendar's events before anything reads their times, so ical.js compares them at their
1709
1617
  * 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.
1618
+ * - a floating time is framed in the calendar's zone;
1619
+ * - each zone the events name that Intl knows becomes an IntlTimezone, defined by the feed or not
1620
+ * (undefined zones are capped at MAX_ICAL_TIMEZONES);
1621
+ * - an all-day series' UTC or zoned UNTIL, EXDATE and RECURRENCE-ID become their local dates (RFC
1622
+ * 5545 wants dates);
1623
+ * - a PERIOD RDATE becomes its start, its end kept in the returned map: ical.js mis-orders periods
1624
+ * among times;
1625
+ * - then a timed series' own times are aligned (alignSeriesTimes).
1626
+ * Every value read here was counted by boundComponents.
1721
1627
  */
1722
1628
  function frameEventTimes(root, components, calendarTimezone, budget) {
1723
1629
  const timezone = calendarTimezone !== undefined && isKnownTimezone(calendarTimezone)
@@ -1809,13 +1715,11 @@ function frameEventTimes(root, components, calendarTimezone, budget) {
1809
1715
  }
1810
1716
  const cache = root._timezoneCache;
1811
1717
  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.
1718
+ // A zone Intl knows is read through Intl even when the feed defines it, so ical.js and Slotlock
1719
+ // never place one time in two places (a stale VTIMEZONE's rules can differ from Intl's).
1816
1720
  if (ICAL.TimezoneService.has(tzid) || !isKnownTimezone(tzid))
1817
1721
  continue;
1818
- // ical.js keeps a calendar's zones here; without it, it could not use one of these.
1722
+ // ical.js keeps a calendar's zones here; without it, it could not use these.
1819
1723
  if (!(cache instanceof Map))
1820
1724
  throw new CalendarContractError('invalid_icalendar');
1821
1725
  cache.set(tzid, new IntlTimezone(tzid, budget));
@@ -1824,19 +1728,17 @@ function frameEventTimes(root, components, calendarTimezone, budget) {
1824
1728
  return periods;
1825
1729
  }
1826
1730
  /**
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.
1731
+ * With every zone resolving, put what ical.js compares with a timed series' times in the terms it
1732
+ * compares them in:
1733
+ * - a floating UNTIL becomes the UTC instant it names on the series' clock (RFC 5545 §3.3.10;
1734
+ * ical.js read it as UTC);
1735
+ * - a RECURRENCE-ID in another zone becomes the same instant in UTC, which ical.js can match; a
1736
+ * RANGE=THISANDFUTURE one becomes the series' own wall time, since ical.js moves later
1737
+ * occurrences by the wall-clock distance from it;
1738
+ * - a DATE EXDATE of a timed series becomes the series' start time that day, so the feed and the
1739
+ * stored series agree;
1740
+ * - an RDATE-only series gets its DTSTART as one: it is the first instance (RFC 5545 §3.8.2.4),
1741
+ * which ical.js left out.
1840
1742
  */
1841
1743
  function alignSeriesTimes(components) {
1842
1744
  const seriesStarts = new Map();
@@ -1873,9 +1775,8 @@ function alignSeriesTimes(components) {
1873
1775
  const rule = property.jCal[3];
1874
1776
  const until = rule?.until;
1875
1777
  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.
1778
+ // A DATE UNTIL on a timed series names its whole day on the series' clock: up to the
1779
+ // instant before the next day begins there.
1879
1780
  const next = utcFrame(Number(until.slice(0, 4)), Number(until.slice(5, 7)), Number(until.slice(8, 10)) + 1, 0, 0, 0, 0);
1880
1781
  // A DTSTART in UTC keeps the series' clock in UTC.
1881
1782
  const midnight = value.endsWith('Z')
@@ -1903,7 +1804,7 @@ function alignSeriesTimes(components) {
1903
1804
  const value = property.jCal[3];
1904
1805
  if (property.type !== 'date-time' || typeof value !== 'string')
1905
1806
  continue;
1906
- // Named in the series' own zone, it is matched by its wall time as it stands.
1807
+ // In the series' own zone it is matched by its wall time as is.
1907
1808
  if (typeof series.tzid === 'string' && property.getParameter('tzid') === series.tzid)
1908
1809
  continue;
1909
1810
  const time = ICAL.Time.fromDateTimeString(value, property);
@@ -1936,19 +1837,18 @@ function alignSeriesTimes(components) {
1936
1837
  }
1937
1838
  /**
1938
1839
  * 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.
1840
+ * nothing: a series Slotlock cannot read refuses the whole calendar, so a caller reading the result
1841
+ * as busy time never takes its time as free. parseICalendarFeed keeps the rest of the calendar
1842
+ * instead.
1942
1843
  */
1943
1844
  export function parseICalendarChanges(input, calendarTimezone, expansionWindow) {
1944
1845
  return parseCalendarChanges(input, calendarTimezone, expansionWindow, FEED_LIMITS);
1945
1846
  }
1946
1847
  /**
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.
1848
+ * parseICalendarChanges, refusing one series at a time: a series Slotlock cannot read (a
1849
+ * misexpanded rule shape, an end the clocks move before its start, DTEND with DURATION, or over
1850
+ * budget) is named in `refused` and the rest is read. A calendar that is not one (size, time zones,
1851
+ * a missing UID, a window over 367 days) is still refused whole, with CalendarContractError.
1952
1852
  */
1953
1853
  export function parseICalendarFeed(input, calendarTimezone, expansionWindow) {
1954
1854
  const refused = [];
@@ -1958,21 +1858,20 @@ export function parseICalendarFeed(input, calendarTimezone, expansionWindow) {
1958
1858
  const DAY_MS = 24 * 60 * 60 * 1000;
1959
1859
  function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits, series, refused) {
1960
1860
  try {
1961
- // Bound work before ical.js allocates and normalizes the full component tree. The parsed-tree
1962
- // checks below remain authoritative; these cheap standards-shaped counts are an early DoS guard.
1861
+ // Cheap early DoS guards before ical.js builds the full tree; the parsed-tree checks below stay
1862
+ // authoritative.
1963
1863
  boundInput(input);
1964
1864
  const budget = limits instanceof RecurrenceBudget ? limits : new RecurrenceBudget(limits);
1965
1865
  const root = new ICAL.Component(ICAL.parse(input));
1966
- // A bare VEVENT (or anything else) would read as a calendar with no events.
1866
+ // A bare VEVENT would read as a calendar with no events.
1967
1867
  if (root.name !== 'vcalendar')
1968
1868
  throw new CalendarContractError('invalid_icalendar');
1969
1869
  let Budgeted;
1970
1870
  const budgeted = () => (Budgeted ??= budgetedRecurIterator(budget));
1971
1871
  budgetTimezoneRules(root, budgeted);
1972
1872
  budgetTimezoneCoverage(root, budget);
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.
1873
+ // A time in a zone of its own is read in that zone: an RDATE may name a different zone than
1874
+ // DTSTART, and its occurrence keeps it. Every read goes through the budget.
1976
1875
  const timeValue = (value, zone, fallback) => calendarTimeValue(value, ownZone(value) ?? zone, fallback, budget);
1977
1876
  const readerOf = (value, zone) => budget.zoneReader(zoneReader(value, ownZone(value) ?? zone, defaultTimezone));
1978
1877
  const embeddedTimezone = root.getFirstPropertyValue('x-wr-timezone');
@@ -1986,9 +1885,8 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
1986
1885
  boundComponents(components);
1987
1886
  const periods = frameEventTimes(root, components, defaultTimezone, budget);
1988
1887
  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.
1888
+ // A series is a master and the overrides sharing its UID, each read on its own: with `refused`,
1889
+ // an unreadable series is named there; without, it refuses the calendar.
1992
1890
  const groups = new Map();
1993
1891
  for (const event of events) {
1994
1892
  const uid = event.uid;
@@ -2008,9 +1906,9 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2008
1906
  groups.set(uid, group);
2009
1907
  }
2010
1908
  /**
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.
1909
+ * A change for `start` to `end` with `component`'s status, transparency and sequence, or null
1910
+ * when it has no length (RFC 5545 §3.6.1: a DATE-TIME DTSTART without DTEND or DURATION
1911
+ * occupies no time). A cancelled one still says so.
2014
1912
  */
2015
1913
  const makeChange = (component, uid, start, end, recurrenceId) => {
2016
1914
  const status = String(component.getFirstPropertyValue('status') ?? 'confirmed').toLowerCase();
@@ -2042,9 +1940,8 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2042
1940
  }
2043
1941
  };
2044
1942
  /**
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.
1943
+ * How long an event's occurrences last (SPEC.md, "Daylight-saving time"): DTSTART as written (a
1944
+ * stored series' own instant), DTEND as an instant per RFC 5545 §3.3.5, a DURATION per §3.3.6.
2048
1945
  */
2049
1946
  const lengthsOf = (event, anchor) => {
2050
1947
  const component = event.component;
@@ -2073,17 +1970,16 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2073
1970
  const end = anchor
2074
1971
  ? anchor.end.getTime()
2075
1972
  : 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.
1973
+ // An end the clocks moved onto or before its start is refused; an end written as its start
1974
+ // is no length.
2078
1975
  const sameZone = (ownZone(endTime) ?? endZone) === (ownZone(startTime) ?? startZone);
2079
1976
  const writtenAsStart = wallFrameOf(endTime) === startWall && sameZone;
2080
1977
  if (end < start || (end === start && !writtenAsStart)) {
2081
1978
  throw new CalendarContractError('invalid_icalendar');
2082
1979
  }
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.
1980
+ // DTEND's wall time as written when in DTSTART's zone (carried to later days as written,
1981
+ // even in a skipped hour); otherwise, or for a stored series, the wall time its instant
1982
+ // reads in DTSTART's zone.
2087
1983
  const endWall = !anchor && sameZone ? wallFrameOf(endTime) : reader.toWall(end);
2088
1984
  return { ...base, exact: end - start, wall: endWall - startWall, ends: end > start };
2089
1985
  }
@@ -2115,10 +2011,8 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2115
2011
  return start + (lengths.exact ?? 0);
2116
2012
  };
2117
2013
  /**
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.
2014
+ * The later of an occurrence's exact end and its wall-clock end: where clocks change inside an
2015
+ * occurrence, either reading alone leaves time free that people mean as busy.
2122
2016
  */
2123
2017
  const occurrenceEnd = (lengths, reader, wall, start) => {
2124
2018
  const exact = exactEnd(lengths, reader, wall, start);
@@ -2127,9 +2021,9 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2127
2021
  return Math.max(exact, reader.toInstant(wall + lengths.wall, { fold: 'later' }));
2128
2022
  };
2129
2023
  /**
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).
2024
+ * An override: the instant of the occurrence it replaces and its own exact interval. Without
2025
+ * DTEND or DURATION it lasts its series' length (§3.6.1 alone would leave the moved occurrence
2026
+ * free).
2133
2027
  */
2134
2028
  const readOverride = (override, uid, seriesLengths, seriesZone) => {
2135
2029
  const component = override.component;
@@ -2169,7 +2063,6 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2169
2063
  const startMs = reader.toInstant(startWall);
2170
2064
  let endMs;
2171
2065
  if (/^[+-]?P/.test(period.end)) {
2172
- // Bounded first, as boundComponent bounds a DURATION.
2173
2066
  const duration = ICAL.Duration.fromString(period.end);
2174
2067
  if (duration.isNegative || duration.toSeconds() * 1000 > maxMs)
2175
2068
  expansionLimit();
@@ -2190,10 +2083,8 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2190
2083
  return ends;
2191
2084
  };
2192
2085
  /**
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.
2086
+ * A series' EXDATEs: instants for DATE-TIME values, days for DATE values. ical.js applies them
2087
+ * as it goes, so some occurrences escaped; RFC 5545 §3.8.5.1 has every EXDATE win.
2197
2088
  */
2198
2089
  const exclusionsOf = (event) => {
2199
2090
  const instants = new Set();
@@ -2223,9 +2114,8 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2223
2114
  if (change)
2224
2115
  changes.push(change);
2225
2116
  }
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.
2117
+ // A server-expanded CalDAV series may send its first instance without a RECURRENCE-ID and
2118
+ // every other one with one (RFC 4791 §9.6.5): each override occupies its own time.
2229
2119
  for (const override of overrides)
2230
2120
  if (override.change)
2231
2121
  changes.push(override.change);
@@ -2277,9 +2167,8 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2277
2167
  }
2278
2168
  sortRuleParts(recur);
2279
2169
  }
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.
2170
+ // yearlyDays needs the rule to be the series' only source: ical.js mixes RDATEs and other
2171
+ // rules in, which could not be told from a moved day.
2283
2172
  const ruleValues = event.component.getAllProperties('rrule');
2284
2173
  const lacking = startTime instanceof ICAL.Time
2285
2174
  ? ruleValues.map((rule) => yearlyDays(rule.getFirstValue(), startTime))
@@ -2294,7 +2183,7 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2294
2183
  const overrides = stored
2295
2184
  ? stored.exceptions.map((exception) => storedOverride(uid, event, lengths, exception))
2296
2185
  : 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.
2186
+ // Per series, so one series' age never refuses another's.
2298
2187
  let remainingWork = MAX_ICAL_WORK;
2299
2188
  let maxBackwardExceptionShiftMs = 0;
2300
2189
  let lastOverrideAt = Number.NEGATIVE_INFINITY;
@@ -2306,16 +2195,15 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2306
2195
  if (backwardShiftMs > MAX_EXCEPTION_BACKWARD_SHIFT_MS) {
2307
2196
  throw new CalendarContractError('invalid_icalendar');
2308
2197
  }
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.
2198
+ // ical.js moves an occurrence a month at a time (Time#adjust).
2311
2199
  if (-backwardShiftMs > MAX_ICAL_STEP_DAYS * DAY_MS)
2312
2200
  expansionLimit();
2313
2201
  maxBackwardExceptionShiftMs = Math.max(maxBackwardExceptionShiftMs, backwardShiftMs);
2314
2202
  lastOverrideAt = Math.max(lastOverrideAt, override.at);
2315
2203
  }
2316
2204
  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.
2205
+ // A direct override replaces its occurrence; a RANGE=THISANDFUTURE one also moves every later
2206
+ // one by its shift, with its own length.
2319
2207
  const byInstant = new Map(overrides.map((override) => [override.at, override]));
2320
2208
  const ranges = overrides
2321
2209
  .filter((override) => override.range)
@@ -2330,8 +2218,8 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2330
2218
  }
2331
2219
  return found;
2332
2220
  };
2333
- // A stored series (the only master) resumes from its cursor when the cursor is this series'
2334
- // and stands where the window needs nothing before it, and leaves one for the next window.
2221
+ // A stored series (the only master) resumes from its cursor when the cursor fits and the
2222
+ // window needs nothing before it, and leaves one for the next window.
2335
2223
  const key = series && groups.size === 1 ? seriesKey(event.component) : null;
2336
2224
  const kept = key === null ? null : readSeriesCursor(series?.cursor, key);
2337
2225
  const usable = kept !== null && series !== undefined && kept.position <= series.resumeLimitMs
@@ -2339,12 +2227,10 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2339
2227
  : null;
2340
2228
  let periodEnds;
2341
2229
  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.
2230
+ // A feed series that repeats every so many days (one DAILY or WEEKLY RRULE, no COUNT, RDATE
2231
+ // or THISANDFUTURE override) repeats identically from DTSTART moved by whole periods, so the
2232
+ // search starts at the last such time no earlier occurrence could reach the window from: a
2233
+ // daily standup since 2014 reads like one from this year.
2348
2234
  let from;
2349
2235
  const rules = event.component.getAllProperties('rrule');
2350
2236
  const only = rules.length === 1 ? rules[0]?.getFirstValue() : undefined;
@@ -2366,21 +2252,20 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2366
2252
  from.day = day.getUTCDate();
2367
2253
  }
2368
2254
  }
2369
- // The occurrences in the window from `resume` (or the start), and the cursor to keep; null
2370
- // when `resume` does not fit this series.
2255
+ // The window's occurrences from `resume` (or the start) and the cursor to keep; null when
2256
+ // `resume` does not fit.
2371
2257
  const expand = (resume) => {
2372
2258
  const iterator = resume
2373
2259
  ? resumedExpansion(event, budgeted(), budget, resume)
2374
2260
  : budgetedExpansion(event, budgeted(), budget, from);
2375
2261
  if (iterator === null)
2376
2262
  return null;
2377
- // The cursor stands on the occurrence it last passed: the same time, read the same way.
2378
2263
  if (resume &&
2379
2264
  timeValue(iterator.last, startZone, defaultTimezone).getTime() !== resume.position) {
2380
2265
  return null;
2381
2266
  }
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.
2267
+ // Searched from the start, a stored series' exceptions are checked against every occurrence
2268
+ // up to the last of them, so one naming no occurrence is refused.
2384
2269
  const scanEndMs = stored && !resume
2385
2270
  ? Math.max(recurrenceScanEndMs, lastOverrideAt + 1)
2386
2271
  : recurrenceScanEndMs;
@@ -2398,8 +2283,8 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2398
2283
  ? startTime
2399
2284
  : null;
2400
2285
  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.
2286
+ // One reader per zone for the whole series: RRULE occurrences are in DTSTART's zone, an
2287
+ // RDATE's in its own.
2403
2288
  const readers = new Map();
2404
2289
  const readerFor = (time) => {
2405
2290
  let reader = readers.get(time.zone);
@@ -2415,8 +2300,8 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2415
2300
  if (remainingWork < 0)
2416
2301
  expansionLimit();
2417
2302
  budget.charge(OCCURRENCE_STEPS);
2418
- // Before the next step, everything the iterator has passed may end before the window:
2419
- // then this is where the next expansion can start.
2303
+ // Before the next step: if everything passed ends before the window, the next expansion
2304
+ // can start here.
2420
2305
  if (leaving && key !== null && series && position !== undefined && held === undefined) {
2421
2306
  if (position <= series.resumeLimitMs)
2422
2307
  snapshot = snapshotSeries(iterator, key, position);
@@ -2467,9 +2352,8 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2467
2352
  }
2468
2353
  // The first instance is DTSTART's own instant: for a stored series, the instant stored.
2469
2354
  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.
2355
+ // An instant is one occurrence however many RRULEs and RDATEs name it (RFC 5545 §3.8.5.3;
2356
+ // ical.js yields duplicates), and none where an EXDATE names it.
2473
2357
  if (seen.has(at))
2474
2358
  continue;
2475
2359
  seen.add(at);
@@ -2486,7 +2370,6 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2486
2370
  }
2487
2371
  continue;
2488
2372
  }
2489
- // A PERIOD RDATE's occurrence ends where its period does.
2490
2373
  const periodEnd = (periodEnds ??= periodEndsOf(event)).get(position);
2491
2374
  const range = periodEnd === undefined ? rangeBefore(at) : undefined;
2492
2375
  let start = at;
@@ -2496,8 +2379,8 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2496
2379
  end = periodEnd;
2497
2380
  }
2498
2381
  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.
2382
+ // Moved by the range override's wall-clock shift in its zone (as ical.js moves it),
2383
+ // lasting the override's length.
2501
2384
  const shifted = wall + (range.lengths.startWall - range.recurrenceWall);
2502
2385
  start = range.lengths.reader.toInstant(shifted);
2503
2386
  end = occurrenceEnd(range.lengths, range.lengths.reader, shifted, start);
@@ -2522,8 +2405,8 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2522
2405
  expanded = expand(usable);
2523
2406
  }
2524
2407
  catch (error) {
2525
- // A cursor ical.js cannot go on from is not this series' to use, like one that does not
2526
- // fit it: search from the start. Running out of budget is the series' own doing.
2408
+ // A cursor ical.js cannot continue is treated as not fitting: search from the start.
2409
+ // Running out of budget is the series' own doing.
2527
2410
  if (error instanceof CalendarExpansionLimitError)
2528
2411
  throw error;
2529
2412
  }
@@ -2540,10 +2423,9 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2540
2423
  for (const override of overrides) {
2541
2424
  if (expanded.matched.has(override))
2542
2425
  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.
2426
+ // An override whose occurrence was removed, or that names none, still occupies its own
2427
+ // interval, as the provider shows it. A stored exception naming no occurrence is refused
2428
+ // where this can tell, or collected in `series.unmatched`.
2547
2429
  const passed = resumed ? override.at <= (usable?.position ?? 0) : false;
2548
2430
  if (stored &&
2549
2431
  !passed &&
@@ -2562,7 +2444,6 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2562
2444
  series.left = keptSeriesCursor(expanded.snapshot);
2563
2445
  }
2564
2446
  };
2565
- /** A stored series' exception, read from its instants: they are what the store holds. */
2566
2447
  const storedOverride = (uid, event, lengths, exception) => {
2567
2448
  const at = exception.recurrenceId.getTime();
2568
2449
  const start = exception.start?.getTime() ?? at;
@@ -2611,13 +2492,11 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
2611
2492
  }
2612
2493
  }
2613
2494
  /**
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.
2495
+ * Make one series' busy occurrences disjoint without freeing a minute: where one ends after the
2496
+ * next starts, it ends there and the next ends at the later of the two. The union rule can make
2497
+ * day-long occurrences overlap across a spring-forward, and the store's EXCLUDE constraint refuses
2498
+ * overlaps. Same-instant starts are left alone; an occurrence cut to end before the window is
2499
+ * dropped.
2621
2500
  */
2622
2501
  function separateOccurrences(changes, from, window) {
2623
2502
  const busy = changes
@@ -2664,8 +2543,8 @@ function addDateTimeProperty(component, name, value, timezone) {
2664
2543
  component.addProperty(property);
2665
2544
  }
2666
2545
  /**
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.
2546
+ * Whether `instant`'s wall time in `timezone` names it again per RFC 5545 §3.3.5: not for the
2547
+ * second pass of a repeated hour.
2669
2548
  */
2670
2549
  function wallTimeNames(instant, timezone) {
2671
2550
  if (timezone === 'UTC')
@@ -2734,20 +2613,18 @@ function eventComponent(params) {
2734
2613
  component.updatePropertyWithValue('last-modified', ICAL.Time.fromJSDate(params.updatedAt, true));
2735
2614
  const { start, end, timezone } = params.event;
2736
2615
  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.
2616
+ // DTSTART in its zone, since a series recurs at its wall time. Any other time is written in its
2617
+ // zone only where that wall time names it (wallTimeNames), else in UTC.
2740
2618
  addDateTimeProperty(component, 'dtstart', start, recurring || wallTimeNames(start, timezone) ? timezone : 'UTC');
2741
2619
  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.
2620
+ // Its length as an exact DURATION (§3.3.6), since its DTSTART reads an hour early:
2621
+ // emitITipCalendar adds an override putting the first instance at its own instants.
2744
2622
  component.updatePropertyWithValue('duration', ICAL.Duration.fromSeconds((end.getTime() - start.getTime()) / 1000));
2745
2623
  }
2746
2624
  else {
2747
2625
  addDateTimeProperty(component, 'dtend', end, wallTimeNames(end, timezone) ? timezone : 'UTC');
2748
2626
  }
2749
2627
  if (params.recurrenceId) {
2750
- // In UTC: the instant the occurrence starts names it, wherever the clocks change.
2751
2628
  addDateTimeProperty(component, 'recurrence-id', params.recurrenceId, 'UTC');
2752
2629
  }
2753
2630
  component.updatePropertyWithValue('summary', params.event.summary);
@@ -2773,17 +2650,14 @@ const WEEKDAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
2773
2650
  const VTIMEZONE_SERIES_YEARS = 30;
2774
2651
  /** And at most, from its first year: a longer series is left to the rules. */
2775
2652
  const MAX_VTIMEZONE_YEARS = 60;
2776
- /** Changes of a zone already found, by zone and years: Intl answers the same for a process. */
2653
+ /** Cached: Intl answers the same for a process. */
2777
2654
  const zoneChanges = new Map();
2778
2655
  /**
2779
2656
  * 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).
2657
+ * offset at the start, then every change. Runs of yearly changes on the same weekday rule, local
2658
+ * time and offsets become one observance with a yearly rule (UNTIL where they stop; left open if
2659
+ * still running at `toYear` after at least three years). Other changes are observances of their
2660
+ * own. Offsets are whole minutes: ical.js 2.2.1 reads no seconds (UtcOffset.fromString).
2787
2661
  */
2788
2662
  function vtimezoneComponent(timezone, fromYear, toYear) {
2789
2663
  const from = Date.UTC(fromYear, 0, 1);
@@ -2924,9 +2798,9 @@ export function emitITipCalendar(input, method) {
2924
2798
  if (status !== undefined)
2925
2799
  masterParams.status = status;
2926
2800
  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.
2801
+ // A series whose first instance starts in the second pass of a repeated hour has a DTSTART
2802
+ // reading as the first pass: an override puts the instance at its own instants, unless an
2803
+ // exception already does.
2930
2804
  const firstInstance = event.recurrence && !wallTimeNames(event.start, event.timezone)
2931
2805
  ? zonedDateTimeToUtc(zonedDateTimeFields(event.start, event.timezone), event.timezone)
2932
2806
  : undefined;
@@ -2960,8 +2834,8 @@ export function emitITipCalendar(input, method) {
2960
2834
  exceptionParams.status = exceptionStatus;
2961
2835
  events.push(eventComponent(exceptionParams));
2962
2836
  }
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).
2837
+ // RFC 5545 §3.6.5: a VTIMEZONE covering every year the event's times fall in, and a series'
2838
+ // rule's (to its UNTIL, else thirty years on).
2965
2839
  const zoned = events.some((component) => component.getAllProperties().some((property) => property.getParameter('tzid')));
2966
2840
  if (zoned) {
2967
2841
  const times = [event.start, event.end];
@@ -2991,7 +2865,7 @@ export function emitITipCalendar(input, method) {
2991
2865
  if (Buffer.byteLength(output, 'utf8') > MAX_ICAL_INPUT_BYTES) {
2992
2866
  throw new CalendarContractError('invalid_icalendar');
2993
2867
  }
2994
- // A real-library round trip catches an invalid decorated property before provider delivery.
2868
+ // A real-library round trip catches an invalid property before provider delivery.
2995
2869
  new ICAL.Component(ICAL.parse(output));
2996
2870
  return output;
2997
2871
  }
@@ -3102,10 +2976,9 @@ function parseReminders(component) {
3102
2976
  });
3103
2977
  }
3104
2978
  /**
3105
- * An all-day series is stored with local-midnight DATE-TIME bounds, so a DATE `UNTIL` must become
3106
- * the matching DATE-TIME: RFC 5545 §3.3.10 requires UNTIL to share DTSTART's value type, and in UTC
3107
- * when DTSTART carries a zone. The instant chosen is that day's local midnight, which is exactly the
3108
- * start of the last occurrence the rule allows.
2979
+ * An all-day series is stored with local-midnight DATE-TIME bounds, so a DATE `UNTIL` becomes that
2980
+ * day's local midnight as DATE-TIME (RFC 5545 §3.3.10: UNTIL shares DTSTART's type, in UTC when
2981
+ * zoned): exactly the last allowed start.
3109
2982
  */
3110
2983
  function allDayRecurrenceRule(rule, timezone) {
3111
2984
  const recur = ICAL.Recur.fromString(rule);
@@ -3116,10 +2989,9 @@ function allDayRecurrenceRule(rule, timezone) {
3116
2989
  return recur.toString();
3117
2990
  }
3118
2991
  /**
3119
- * Parse full, trusted event content. This is intentionally a different API from
3120
- * parseICalendarChanges, whose return shape can never carry summaries, participants or notes.
3121
- * All-day (DATE) events are converted to local-midnight bounds in the calendar's timezone: the
3122
- * `calendarTimezone` argument, else the feed's X-WR-TIMEZONE; with neither they are refused.
2992
+ * Parse full, trusted event content: deliberately a different API from parseICalendarChanges, whose
2993
+ * result can never carry summaries, participants or notes. All-day events become local-midnight
2994
+ * bounds in `calendarTimezone`, else X-WR-TIMEZONE; with neither they are refused.
3123
2995
  */
3124
2996
  export function parseTrustedICalendarEvents(input, calendarTimezone) {
3125
2997
  try {
@@ -3141,8 +3013,7 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
3141
3013
  throw new CalendarContractError('invalid_icalendar');
3142
3014
  }
3143
3015
  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.
3016
+ // Times are read as a feed's are, so a stored series occupies what the feed reports.
3146
3017
  frameEventTimes(root, components, defaultTimezone, budget);
3147
3018
  const masters = new Map();
3148
3019
  const exceptionsByUid = new Map();
@@ -3165,8 +3036,8 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
3165
3036
  if (!masters.has(uid))
3166
3037
  throw new CalendarContractError('invalid_icalendar');
3167
3038
  }
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.
3039
+ // Every VEVENT's rule as written must be one RFC 5545 §3.3.10 allows: ical.js has already
3040
+ // misread some, so the text is read again.
3170
3041
  for (const rule of veventRuleTexts(input)) {
3171
3042
  if (canonicalRecurrenceRule(rule) === null) {
3172
3043
  throw new CalendarContractError('invalid_icalendar');
@@ -3206,8 +3077,8 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
3206
3077
  for (const [uid, event] of masters) {
3207
3078
  const component = event.component;
3208
3079
  const startProperty = component.getFirstProperty('dtstart');
3209
- // An all-day event (DATE-valued DTSTART) covers whole local days of the calendar's zone. Its
3210
- // end must be a DATE too (RFC 5545 §3.6.1), and a DURATION may only count days or weeks.
3080
+ // An all-day event covers whole local days; its end must be a DATE too (§3.6.1), and a
3081
+ // DURATION may only count days or weeks.
3211
3082
  const allDay = event.startDate.isDate;
3212
3083
  if (event.endDate.isDate !== allDay)
3213
3084
  throw new CalendarContractError('invalid_icalendar');
@@ -3266,19 +3137,15 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
3266
3137
  }
3267
3138
  const rrule = rrules[0]?.getFirstValue();
3268
3139
  if (rrule) {
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.
3140
+ // A series is stored as an instant and a zone and recurs at that wall time. A DTSTART the
3141
+ // clocks skip has no instant reading as it, so storing it would shift every occurrence an
3142
+ // hour: refused.
3274
3143
  if (own.reader.toWall(own.start) !== own.startWall) {
3275
3144
  throw new CalendarContractError('invalid_icalendar');
3276
3145
  }
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).
3146
+ // Later occurrences end at the first one's end wall time and last at least as long. A
3147
+ // skipped DTEND or a day-counting DURATION across a change would hold an hour the feed does
3148
+ // not, so they are refused (days only outside UTC).
3282
3149
  if (!allDay) {
3283
3150
  const endProperty = component.getFirstProperty('dtend');
3284
3151
  const endTime = endProperty?.getFirstValue();
@@ -3312,8 +3179,7 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
3312
3179
  const range = recurrenceProperty?.getFirstParameter('range');
3313
3180
  if (range)
3314
3181
  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.
3182
+ // A stored series has one transparency; an instance that differs cannot be stored as is.
3317
3183
  const transparency = String(exception.component.getFirstPropertyValue('transp') ?? 'opaque').toLowerCase();
3318
3184
  if (transparency !== transparencyValue) {
3319
3185
  throw new CalendarContractError('invalid_icalendar');
@@ -3321,9 +3187,8 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
3321
3187
  const recurrenceId = timeValue(exception.recurrenceId, recurrenceProperty?.getParameter('tzid') ?? startProperty?.getParameter('tzid'), defaultTimezone);
3322
3188
  const cancelled = String(exception.component.getFirstPropertyValue('status') ?? '').toLowerCase() ===
3323
3189
  '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.
3190
+ // An instance an EXDATE removes but an override describes is the override, as the
3191
+ // provider shows it; one with no end lasts its series' length.
3327
3192
  const parsedException = { recurrenceId };
3328
3193
  if (cancelled) {
3329
3194
  parsedException.cancelled = true;
@@ -3348,9 +3213,9 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
3348
3213
  throw new CalendarContractError('invalid_icalendar');
3349
3214
  }
3350
3215
  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.
3216
+ // An EXDATE or cancellation naming no occurrence frees nothing (RFC 5545 §3.8.5.1), so it is
3217
+ // dropped rather than stored. A moved one naming none is busy at its own time, which a stored
3218
+ // series cannot hold.
3354
3219
  const recurrence = content.recurrence;
3355
3220
  if (recurrence?.exceptions && recurrence.exceptions.length > 0) {
3356
3221
  const unmatched = unmatchedExceptions(content, budget);
@@ -3380,7 +3245,6 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
3380
3245
  return trusted;
3381
3246
  }
3382
3247
  catch (error) {
3383
- // An event the calendar gives no length or no UID is the calendar's fault, as in a feed.
3384
3248
  if (error instanceof CalendarContractError && error.code === 'invalid_icalendar')
3385
3249
  throw error;
3386
3250
  throw new CalendarContractError('invalid_icalendar');
@@ -3394,18 +3258,18 @@ export function expandCalendarEventOccurrences(input, window) {
3394
3258
  return expandOccurrences(input, window, EVENT_LIMITS, null).occurrences;
3395
3259
  }
3396
3260
  /**
3397
- * The same expansion for a stored series as maintenance rolls it, within FEED_LIMITS: twice what it
3398
- * was written within, so a series accepted once keeps rolling as its search grows.
3261
+ * The same expansion for a stored series as maintenance rolls it, within FEED_LIMITS (more than a
3262
+ * write gets), so a series accepted once keeps rolling as its search grows.
3399
3263
  */
3400
3264
  export function expandStoredCalendarEventOccurrences(input, window) {
3401
3265
  return expandOccurrences(input, window, FEED_LIMITS, null).occurrences;
3402
3266
  }
3403
3267
  /**
3404
- * Expand a stored series, resuming from the cursor its last expansion left when that cursor can
3405
- * vouch for everything it skips, and leave a cursor for the next. Within FEED_LIMITS as maintenance
3406
- * rolls it (`stored`), or EVENT_LIMITS as it is written. Whatever a cursor cannot serve (another
3407
- * rule, start, zone or ical.js; a window starting before it; a longer event or an exception that
3408
- * could reach the window from before it; anything malformed) is searched from the series' start.
3268
+ * Expand a stored series, resuming from its last cursor when that cursor can vouch for everything
3269
+ * it skips, and leave a cursor for the next. Within FEED_LIMITS when maintenance rolls it
3270
+ * (`stored`), else EVENT_LIMITS. Anything a cursor cannot serve (other rule, start, zone or
3271
+ * ical.js; a window before it; something that could reach the window from before it; malformed) is
3272
+ * searched from the start.
3409
3273
  */
3410
3274
  export function expandCalendarEventSeries(input, window, options) {
3411
3275
  return expandOccurrences(input, window, options.stored ? FEED_LIMITS : EVENT_LIMITS, options.cursor, options.resumeOnly === true);
@@ -3415,10 +3279,9 @@ function expandOccurrences(input, window, limits, cursor, resumeOnly = false) {
3415
3279
  return expandEventOccurrences(input, window, limits, cursor, resumeOnly);
3416
3280
  }
3417
3281
  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.
3282
+ // A series an earlier Slotlock stored that this one refuses is counted by the roll and keeps
3283
+ // its old coverage rather than stopping every series after it. One no Slotlock stored is an
3284
+ // error.
3422
3285
  if (limits === FEED_LIMITS &&
3423
3286
  error instanceof CalendarContractError &&
3424
3287
  !(error instanceof CalendarExpansionLimitError) &&
@@ -3429,10 +3292,8 @@ function expandOccurrences(input, window, limits, cursor, resumeOnly = false) {
3429
3292
  }
3430
3293
  }
3431
3294
  /**
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.
3295
+ * Whether an earlier Slotlock could have stored `event`: 0.2.0 kept any rule ical.js reads starting
3296
+ * with FREQ= (`FREQ=DAILY;COUNT=0`, `FREQ=HOURLY`), in any zone Intl knows (`+05:00` included).
3436
3297
  */
3437
3298
  function storedByAnEarlierRelease(event) {
3438
3299
  const rule = event.recurrence?.rrule;
@@ -3471,9 +3332,8 @@ function seriesUid(event) {
3471
3332
  .digest('hex')}@slotlock.local`;
3472
3333
  }
3473
3334
  /**
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.
3335
+ * The instants of a series' exceptions naming none of its occurrences, searched from its start to
3336
+ * the last of them within `budget`, as the store does on write.
3477
3337
  */
3478
3338
  function unmatchedExceptions(event, budget) {
3479
3339
  const unmatched = new Set();
@@ -3493,12 +3353,9 @@ function unmatchedExceptions(event, budget) {
3493
3353
  return unmatched;
3494
3354
  }
3495
3355
  /**
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.
3356
+ * Why this Slotlock refuses an event an earlier one stored, or null. Maintenance counts such a
3357
+ * series as refused on every roll and leaves its old coverage. Writes nothing, so it can be run
3358
+ * over stored events before an upgrade.
3502
3359
  */
3503
3360
  export function storedCalendarEventProblem(event) {
3504
3361
  const rule = event.recurrence?.rrule;
@@ -3544,10 +3401,9 @@ export function storedCalendarEventProblem(event) {
3544
3401
  return `${which} no occurrence: ${named.join(', ')}`;
3545
3402
  }
3546
3403
  /**
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).
3404
+ * The text a stored series is expanded from: DTSTART's wall time in its zone and its rule. The
3405
+ * first occurrence and exceptions go as instants instead (SeriesResume.stored): a repeated-hour
3406
+ * wall time would name the wrong one.
3551
3407
  */
3552
3408
  function seriesCalendar(event, uid) {
3553
3409
  const calendar = new ICAL.Component(['vcalendar', [], []]);
@@ -3571,8 +3427,8 @@ function expandEventOccurrences(input, window, limits, cursor, resumeOnly) {
3571
3427
  window.end <= window.start) {
3572
3428
  throw new CalendarContractError('invalid_icalendar');
3573
3429
  }
3574
- // A one-off event is one occurrence whatever its length (a multi-year lease included), so only
3575
- // recurrence expansion — whose work grows with the window — is bounded by the window cap.
3430
+ // A one-off event is one occurrence whatever its length, so only recurrence expansion is bounded
3431
+ // by the window cap.
3576
3432
  if (!event.recurrence) {
3577
3433
  const occurrences = event.end <= window.start || event.start >= window.end
3578
3434
  ? []
@@ -3583,8 +3439,8 @@ function expandEventOccurrences(input, window, limits, cursor, resumeOnly) {
3583
3439
  throw new CalendarContractError('invalid_icalendar');
3584
3440
  }
3585
3441
  const uid = seriesUid(event);
3586
- // A cursor may stand after an occurrence only if that occurrence ends before the window: unmoved,
3587
- // within the series' length (and a clock change) of its start; moved, where its exception says.
3442
+ // A cursor may stand after an occurrence only if it ends before the window: unmoved, within the
3443
+ // series' length (plus a clock change) of its start; moved, where its exception says.
3588
3444
  let resumeLimitMs = window.start.getTime() -
3589
3445
  (event.end.getTime() - event.start.getTime()) -
3590
3446
  OCCURRENCE_END_SLACK_MS;