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