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