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.
- package/CHANGELOG.md +25 -0
- package/README.md +13 -6
- package/agent-server.js +1 -5
- package/agent-store-backend.d.ts +5 -6
- package/agent-store-backend.js +41 -67
- package/api-keys.js +1 -1
- package/application-api.d.ts +3 -3
- package/application-api.js +16 -24
- package/assistant.d.ts +18 -27
- package/assistant.js +36 -53
- package/booking-rules.d.ts +30 -41
- package/booking-rules.js +17 -25
- package/change-watch.d.ts +1 -0
- package/change-watch.js +1 -1
- package/cli.js +3 -4
- package/config.d.ts +17 -2
- package/config.js +44 -6
- package/dashboard.js +13 -0
- package/ddl.d.ts +20 -27
- package/ddl.js +166 -108
- package/engine.d.ts +6 -9
- package/engine.js +5 -9
- package/event-feed.d.ts +23 -31
- package/event-feed.js +30 -45
- package/event-schema.js +6 -9
- package/hooks.d.ts +2 -4
- package/hooks.js +2 -4
- package/ical-worker-thread.js +3 -4
- package/ical-worker.d.ts +8 -10
- package/ical-worker.js +7 -9
- package/interop.js +1 -4
- package/maintenance.d.ts +14 -10
- package/maintenance.js +43 -26
- package/mcp-app.d.ts +7 -9
- package/mcp-app.js +7 -9
- package/mcp-modern.d.ts +14 -16
- package/mcp-modern.js +23 -27
- package/metrics.js +0 -1
- package/node-server.d.ts +9 -9
- package/node-server.js +25 -34
- package/package-version.js +2 -4
- package/package.json +1 -1
- package/rate-limit.d.ts +6 -1
- package/rate-limit.js +24 -8
- package/rest-api.d.ts +13 -14
- package/rest-api.js +11 -12
- package/rules.d.ts +6 -7
- package/rules.js +30 -42
- package/self-host.d.ts +16 -2
- package/self-host.js +42 -21
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +109 -161
- package/store.js +253 -371
- package/sync.d.ts +44 -59
- package/sync.js +325 -469
- package/timezone.d.ts +25 -33
- package/timezone.js +37 -52
- package/types.d.ts +35 -56
- package/webhooks.d.ts +22 -25
- package/webhooks.js +35 -40
- package/weekly-hours.d.ts +3 -0
- 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
|
-
//
|
|
11
|
-
//
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
24
|
-
*
|
|
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
|
|
34
|
-
* RecurIterator.next() searches
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
|
67
|
-
*
|
|
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
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* `
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
|
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
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
-
*
|
|
148
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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
|
|
230
|
-
*
|
|
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
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
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
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
* -
|
|
415
|
-
* -
|
|
416
|
-
* -
|
|
417
|
-
* -
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
* -
|
|
421
|
-
*
|
|
422
|
-
*
|
|
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
|
|
480
|
-
* BYYEARDAY takes
|
|
481
|
-
*
|
|
482
|
-
*
|
|
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
|
|
491
|
-
*
|
|
492
|
-
*
|
|
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
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
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
|
|
562
|
-
*
|
|
563
|
-
*
|
|
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:
|
|
583
|
-
*
|
|
584
|
-
*
|
|
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
|
-
//
|
|
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
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
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
|
|
782
|
-
*
|
|
783
|
-
*
|
|
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
|
|
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`
|
|
820
|
-
*
|
|
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
|
-
*
|
|
830
|
-
*
|
|
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
|
|
851
|
-
* time
|
|
852
|
-
* floating value as a local JS date
|
|
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
|
|
871
|
-
*
|
|
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
|
-
*
|
|
879
|
-
*
|
|
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
|
|
916
|
-
*
|
|
917
|
-
*
|
|
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
|
-
//
|
|
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
|
|
953
|
-
*
|
|
954
|
-
*
|
|
955
|
-
*
|
|
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
|
|
979
|
-
*
|
|
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()
|
|
991
|
-
*
|
|
992
|
-
*
|
|
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
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
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
|
|
1054
|
-
*
|
|
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
|
|
1068
|
-
*
|
|
1069
|
-
*
|
|
1070
|
-
*
|
|
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.
|
|
1113
|
-
*
|
|
1114
|
-
*
|
|
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
|
|
1132
|
-
*
|
|
1133
|
-
*
|
|
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
|
|
1140
|
-
//
|
|
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
|
|
1151
|
-
*
|
|
1152
|
-
*
|
|
1153
|
-
*
|
|
1154
|
-
*
|
|
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
|
|
1246
|
-
* (parse._handleContentLine, then
|
|
1247
|
-
*
|
|
1248
|
-
*
|
|
1249
|
-
*
|
|
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
|
|
1312
|
-
*
|
|
1313
|
-
*
|
|
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
|
|
1342
|
-
*
|
|
1343
|
-
*
|
|
1344
|
-
*
|
|
1345
|
-
*
|
|
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`
|
|
1403
|
-
*
|
|
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
|
|
1418
|
-
//
|
|
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
|
-
/**
|
|
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
|
|
1510
|
-
*
|
|
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
|
|
1529
|
-
*
|
|
1530
|
-
*
|
|
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
|
-
//
|
|
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:
|
|
1570
|
-
*
|
|
1571
|
-
*
|
|
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
|
|
1599
|
-
//
|
|
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
|
|
1612
|
-
*
|
|
1613
|
-
*
|
|
1614
|
-
*
|
|
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
|
|
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
|
|
1652
|
-
*
|
|
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
|
|
1711
|
-
* - each zone the events name that Intl knows becomes an IntlTimezone,
|
|
1712
|
-
*
|
|
1713
|
-
*
|
|
1714
|
-
*
|
|
1715
|
-
* -
|
|
1716
|
-
*
|
|
1717
|
-
* - a
|
|
1718
|
-
*
|
|
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
|
|
1813
|
-
//
|
|
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
|
|
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'
|
|
1828
|
-
*
|
|
1829
|
-
* - a floating UNTIL becomes the UTC instant it names on the series' clock
|
|
1830
|
-
*
|
|
1831
|
-
* - a RECURRENCE-ID
|
|
1832
|
-
*
|
|
1833
|
-
*
|
|
1834
|
-
*
|
|
1835
|
-
*
|
|
1836
|
-
*
|
|
1837
|
-
*
|
|
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
|
|
1877
|
-
//
|
|
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
|
-
//
|
|
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
|
|
1940
|
-
*
|
|
1941
|
-
*
|
|
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
|
|
1948
|
-
*
|
|
1949
|
-
*
|
|
1950
|
-
*
|
|
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
|
-
//
|
|
1962
|
-
//
|
|
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
|
|
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
|
|
1974
|
-
//
|
|
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
|
|
1990
|
-
//
|
|
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
|
|
2012
|
-
*
|
|
2013
|
-
*
|
|
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")
|
|
2046
|
-
*
|
|
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
|
|
2077
|
-
//
|
|
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
|
|
2084
|
-
//
|
|
2085
|
-
//
|
|
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
|
|
2119
|
-
*
|
|
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
|
|
2131
|
-
*
|
|
2132
|
-
*
|
|
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:
|
|
2194
|
-
*
|
|
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
|
|
2227
|
-
//
|
|
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
|
-
//
|
|
2281
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
2318
|
-
//
|
|
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
|
|
2334
|
-
//
|
|
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
|
|
2343
|
-
//
|
|
2344
|
-
//
|
|
2345
|
-
//
|
|
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
|
|
2370
|
-
//
|
|
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
|
-
//
|
|
2383
|
-
//
|
|
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
|
-
//
|
|
2402
|
-
//
|
|
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
|
|
2419
|
-
//
|
|
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
|
-
//
|
|
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
|
|
2500
|
-
//
|
|
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
|
|
2526
|
-
//
|
|
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
|
|
2544
|
-
//
|
|
2545
|
-
//
|
|
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
|
|
2615
|
-
*
|
|
2616
|
-
*
|
|
2617
|
-
*
|
|
2618
|
-
*
|
|
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
|
|
2668
|
-
*
|
|
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
|
-
//
|
|
2738
|
-
//
|
|
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
|
|
2743
|
-
// emitITipCalendar adds an override
|
|
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
|
-
/**
|
|
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
|
|
2781
|
-
*
|
|
2782
|
-
*
|
|
2783
|
-
*
|
|
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
|
|
2928
|
-
//
|
|
2929
|
-
//
|
|
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
|
|
2964
|
-
//
|
|
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
|
|
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`
|
|
3106
|
-
*
|
|
3107
|
-
*
|
|
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
|
|
3120
|
-
*
|
|
3121
|
-
*
|
|
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
|
|
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
|
|
3169
|
-
//
|
|
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
|
|
3210
|
-
//
|
|
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
|
|
3270
|
-
//
|
|
3271
|
-
//
|
|
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
|
-
//
|
|
3278
|
-
//
|
|
3279
|
-
//
|
|
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
|
|
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
|
|
3325
|
-
//
|
|
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
|
|
3352
|
-
//
|
|
3353
|
-
//
|
|
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
|
|
3398
|
-
*
|
|
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
|
|
3405
|
-
*
|
|
3406
|
-
*
|
|
3407
|
-
*
|
|
3408
|
-
*
|
|
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
|
|
3419
|
-
//
|
|
3420
|
-
//
|
|
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
|
|
3433
|
-
*
|
|
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
|
|
3475
|
-
*
|
|
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,
|
|
3497
|
-
*
|
|
3498
|
-
*
|
|
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:
|
|
3548
|
-
*
|
|
3549
|
-
*
|
|
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
|
|
3575
|
-
//
|
|
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
|
|
3587
|
-
//
|
|
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;
|