slotlock 0.1.1 → 0.2.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 +125 -0
- package/CONTRIBUTING.md +25 -16
- package/README.md +150 -74
- package/SECURITY.md +40 -4
- package/SPEC.md +19 -6
- package/agent-server.d.ts +64 -7
- package/agent-server.js +273 -68
- package/agent-store-backend.d.ts +16 -0
- package/agent-store-backend.js +257 -2
- package/api-keys.d.ts +11 -4
- package/api-keys.js +9 -2
- package/application-api.d.ts +54 -0
- package/application-api.js +290 -0
- package/dashboard.d.ts +4 -1
- package/dashboard.js +6 -4
- package/ddl.d.ts +2 -1
- package/ddl.js +143 -15
- package/hooks.d.ts +7 -0
- package/hooks.js +13 -0
- package/index.d.ts +8 -4
- package/index.js +4 -2
- package/maintenance.d.ts +57 -0
- package/maintenance.js +181 -0
- package/node-server.js +121 -5
- package/package.json +1 -1
- package/rest-api.d.ts +33 -18
- package/rest-api.js +131 -63
- package/rules.d.ts +19 -0
- package/rules.js +32 -4
- package/self-host.d.ts +5 -2
- package/self-host.js +127 -20
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +30 -8
- package/store.js +69 -27
- package/sync.js +530 -49
- package/timezone.d.ts +19 -0
- package/timezone.js +16 -1
- package/types.d.ts +4 -1
- package/weekly-hours.js +2 -2
package/sync.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import ICAL from 'ical.js';
|
|
3
|
-
import { zonedDateTimeToUtc } from './timezone.js';
|
|
3
|
+
import { createTimeZoneConverter, utcFrame, zonedDateTimeFields, zonedDateTimeToUtc, } 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;
|
|
@@ -513,8 +513,8 @@ function calendarTimeValue(value, propertyTimezone, calendarTimezone) {
|
|
|
513
513
|
}
|
|
514
514
|
catch {
|
|
515
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
|
|
517
|
-
// reach this fallback.
|
|
516
|
+
// ical.js resolves that definition itself; an undeclared TZID Intl does not know either
|
|
517
|
+
// remains `floating` and cannot reach this fallback.
|
|
518
518
|
if (zone && valueZone === zone && valueZone !== 'floating' && valueZone !== 'local') {
|
|
519
519
|
const resolved = time.toJSDate();
|
|
520
520
|
if (Number.isFinite(resolved.getTime()))
|
|
@@ -527,6 +527,13 @@ function calendarTimeValue(value, propertyTimezone, calendarTimezone) {
|
|
|
527
527
|
return time.toJSDate();
|
|
528
528
|
throw new CalendarContractError('invalid_icalendar');
|
|
529
529
|
}
|
|
530
|
+
/** The TZID of a time ical.js reads in a zone (a VTIMEZONE's, an IntlTimezone, UTC); else none. */
|
|
531
|
+
function ownZone(value) {
|
|
532
|
+
if (!(value instanceof ICAL.Time))
|
|
533
|
+
return undefined;
|
|
534
|
+
const tzid = value.zone?.tzid;
|
|
535
|
+
return tzid && tzid !== 'floating' && tzid !== 'local' ? tzid : undefined;
|
|
536
|
+
}
|
|
530
537
|
/**
|
|
531
538
|
* What one parse may spend: steps searching for occurrences, and time from its start. The time is
|
|
532
539
|
* checked at every step and after every piece of ical.js work the parse does, so work that takes no
|
|
@@ -612,6 +619,35 @@ function budgetedExpansion(event, Budgeted) {
|
|
|
612
619
|
if (!Array.isArray(iterators) || !iterators.every((iterator) => iterator instanceof Budgeted)) {
|
|
613
620
|
throw new CalendarContractError('invalid_icalendar');
|
|
614
621
|
}
|
|
622
|
+
return withEveryRule(expansion);
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* `expansion`, choosing among its RRULEs' iterators correctly. ical.js 2.2.1's own choice
|
|
626
|
+
* (RecurExpansion#_nextRecurrenceIter) drops one that has ended by index as it walks them: it
|
|
627
|
+
* skipped the iterator after an ended first one, and for any other removed the one before it,
|
|
628
|
+
* which had not ended, so a series with two RRULEs lost occurrences once either rule ended.
|
|
629
|
+
*/
|
|
630
|
+
function withEveryRule(expansion) {
|
|
631
|
+
const state = expansion;
|
|
632
|
+
if (typeof state._nextRecurrenceIter !== 'function') {
|
|
633
|
+
throw new CalendarContractError('invalid_icalendar');
|
|
634
|
+
}
|
|
635
|
+
state._nextRecurrenceIter = () => {
|
|
636
|
+
const iterators = state.ruleIterators;
|
|
637
|
+
let chosen = null;
|
|
638
|
+
for (let index = 0; index < iterators.length;) {
|
|
639
|
+
const iterator = iterators[index];
|
|
640
|
+
if (iterator.completed) {
|
|
641
|
+
iterators.splice(index, 1);
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
// The earliest next time; the first of equal ones, as ical.js chose.
|
|
645
|
+
if (chosen === null || chosen.last.compare(iterator.last) > 0)
|
|
646
|
+
chosen = iterator;
|
|
647
|
+
index += 1;
|
|
648
|
+
}
|
|
649
|
+
return chosen;
|
|
650
|
+
};
|
|
615
651
|
return expansion;
|
|
616
652
|
}
|
|
617
653
|
/**
|
|
@@ -625,12 +661,31 @@ function valueCount(property) {
|
|
|
625
661
|
function fieldsMs(time) {
|
|
626
662
|
return Date.UTC(time.year, time.month - 1, time.day, time.hour, time.minute, time.second);
|
|
627
663
|
}
|
|
664
|
+
/** The day a time's own fields name: how ical.js matches a DATE EXDATE with an occurrence. */
|
|
665
|
+
function dayOf(time) {
|
|
666
|
+
return `${time.year}-${time.month}-${time.day}`;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Milliseconds since 1970 of a DATE or DATE-TIME property's first value as written, ignoring its
|
|
670
|
+
* zone, or undefined for anything else. Read from the raw form, so nothing resolves a zone yet.
|
|
671
|
+
*/
|
|
672
|
+
function writtenFieldsMs(property) {
|
|
673
|
+
const value = property?.jCal[3];
|
|
674
|
+
if (typeof value !== 'string')
|
|
675
|
+
return undefined;
|
|
676
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})Z?)?$/.exec(value);
|
|
677
|
+
if (!match)
|
|
678
|
+
return undefined;
|
|
679
|
+
const [year, month, day, hour, minute, second] = match.slice(1).map((part) => Number(part ?? 0));
|
|
680
|
+
return utcFrame(year, month, day, hour, minute, second, 0).getTime();
|
|
681
|
+
}
|
|
628
682
|
/**
|
|
629
|
-
* Refuse a VEVENT that ical.js would spend unbounded work reading, before anything reads its
|
|
683
|
+
* Refuse a VEVENT that ical.js would spend unbounded work reading, before anything reads its times:
|
|
630
684
|
* a DURATION, or a DTEND after its DTSTART, longer than MAX_ICAL_STEP_DAYS (ical.js adds a span to
|
|
631
685
|
* a time a month at a time, Time#adjust, and `DURATION:PT99999999999999999999S` held the process),
|
|
632
686
|
* a negative DURATION, or more RDATE and EXDATE values than MAX_ICAL_DATES_PER_EVENT. Returns how
|
|
633
|
-
* many it lists, for the feed's own limit.
|
|
687
|
+
* many it lists, for the feed's own limit. It reads times only as written, so it runs before the
|
|
688
|
+
* times are framed: framing reads every date value, and 115,000 EXDATE values took 11 seconds.
|
|
634
689
|
*/
|
|
635
690
|
function boundComponent(component) {
|
|
636
691
|
const maxMs = MAX_ICAL_STEP_DAYS * 24 * 60 * 60 * 1000;
|
|
@@ -641,11 +696,11 @@ function boundComponent(component) {
|
|
|
641
696
|
if (duration.isNegative || duration.toSeconds() * 1000 > maxMs)
|
|
642
697
|
expansionLimit();
|
|
643
698
|
}
|
|
644
|
-
const start = component.
|
|
645
|
-
const end = component.
|
|
646
|
-
if (start
|
|
699
|
+
const start = writtenFieldsMs(component.getFirstProperty('dtstart'));
|
|
700
|
+
const end = writtenFieldsMs(component.getFirstProperty('dtend'));
|
|
701
|
+
if (start !== undefined && end !== undefined) {
|
|
647
702
|
// The fields ignore zones, so allow the day they can be out by.
|
|
648
|
-
if (Math.abs(
|
|
703
|
+
if (Math.abs(end - start) > maxMs + 24 * 60 * 60 * 1000)
|
|
649
704
|
expansionLimit();
|
|
650
705
|
}
|
|
651
706
|
let dates = 0;
|
|
@@ -966,12 +1021,22 @@ function seriesKey(component) {
|
|
|
966
1021
|
component.hasProperty('exdate')) {
|
|
967
1022
|
return null;
|
|
968
1023
|
}
|
|
1024
|
+
const start = starts[0];
|
|
1025
|
+
const rule = rules[0];
|
|
1026
|
+
// Slotlock 0.1 compared a zoned series' occurrences with its UTC UNTIL as if they were UTC, so a
|
|
1027
|
+
// cursor it kept for one that ends may have ended an occurrence early or gone one past: such a
|
|
1028
|
+
// series has a key of its own now, and those cursors are searched past from the start.
|
|
1029
|
+
const zone = start.getParameter('tzid');
|
|
1030
|
+
const zonedUntil = typeof zone === 'string' &&
|
|
1031
|
+
zone.trim() !== 'UTC' &&
|
|
1032
|
+
rule.jCal[3]?.until !== undefined;
|
|
969
1033
|
return createHash('sha256')
|
|
970
1034
|
.update(JSON.stringify([
|
|
971
1035
|
SERIES_CURSOR_FORMAT,
|
|
972
1036
|
ICAL_JS_VERSION,
|
|
973
|
-
|
|
974
|
-
|
|
1037
|
+
start.toICALString(),
|
|
1038
|
+
rule.toICALString(),
|
|
1039
|
+
...(zonedUntil ? ['zoned-until'] : []),
|
|
975
1040
|
]))
|
|
976
1041
|
.digest('hex');
|
|
977
1042
|
}
|
|
@@ -1106,7 +1171,7 @@ function resumedExpansion(event, Budgeted, cursor) {
|
|
|
1106
1171
|
if (iterators.length !== 1 || !(iterators[0] instanceof Budgeted)) {
|
|
1107
1172
|
throw new CalendarContractError('invalid_icalendar');
|
|
1108
1173
|
}
|
|
1109
|
-
return expansion;
|
|
1174
|
+
return withEveryRule(expansion);
|
|
1110
1175
|
}
|
|
1111
1176
|
/**
|
|
1112
1177
|
* Whether a cursor's iterator state agrees with `reference`, an iterator ical.js built for the same
|
|
@@ -1151,6 +1216,299 @@ function cursorFitsRule(state, reference, rule) {
|
|
|
1151
1216
|
return false;
|
|
1152
1217
|
return true;
|
|
1153
1218
|
}
|
|
1219
|
+
/**
|
|
1220
|
+
* An IANA zone ical.js reads through Intl, for a TZID a calendar names without a VTIMEZONE (RFC
|
|
1221
|
+
* 5545 wants one for each; real feeds, and the series Slotlock stores, name IANA zones bare). Left
|
|
1222
|
+
* alone, ical.js reads such a time as floating and compares it with UTC values (an UNTIL, EXDATE,
|
|
1223
|
+
* RDATE or RECURRENCE-ID) as if it were UTC: a 09:00 Berlin series lost the occurrence its UNTIL
|
|
1224
|
+
* named, and a moved or excluded occurrence was not found. Offsets come from the converter the rest
|
|
1225
|
+
* of Slotlock reads wall times with: a gap moves forward, a fold takes its first instant.
|
|
1226
|
+
*/
|
|
1227
|
+
class IntlTimezone extends ICAL.Timezone {
|
|
1228
|
+
converter;
|
|
1229
|
+
/** Offsets already found, by wall time: ical.js asks again at every comparison. */
|
|
1230
|
+
offsets = new Map();
|
|
1231
|
+
constructor(tzid) {
|
|
1232
|
+
super({ tzid });
|
|
1233
|
+
// A TZID is matched as written (" Europe/Berlin" is that TZID); Slotlock reads its zone trimmed.
|
|
1234
|
+
this.converter = createTimeZoneConverter(tzid.trim());
|
|
1235
|
+
}
|
|
1236
|
+
utcOffset(time) {
|
|
1237
|
+
const wall = utcFrame(time.year, time.month, time.day, time.hour, time.minute, time.second, 0);
|
|
1238
|
+
const wallMs = wall.getTime();
|
|
1239
|
+
let offset = this.offsets.get(wallMs);
|
|
1240
|
+
if (offset === undefined) {
|
|
1241
|
+
offset = Math.round((wallMs - this.converter.wallFrameToUtc(wallMs).getTime()) / 1000);
|
|
1242
|
+
if (this.offsets.size >= 4_096)
|
|
1243
|
+
this.offsets.clear();
|
|
1244
|
+
this.offsets.set(wallMs, offset);
|
|
1245
|
+
}
|
|
1246
|
+
return offset;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
/** VEVENT properties whose times ical.js compares with one another. */
|
|
1250
|
+
const COMPARED_TIME_PROPERTIES = ['dtstart', 'dtend', 'recurrence-id', 'rdate', 'exdate'];
|
|
1251
|
+
function isKnownTimezone(timezone) {
|
|
1252
|
+
try {
|
|
1253
|
+
validTimezone(timezone);
|
|
1254
|
+
return true;
|
|
1255
|
+
}
|
|
1256
|
+
catch {
|
|
1257
|
+
return false;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
/** Wall-clock fields as a jCal DATE-TIME, `2027-01-04T09:00:00`, with a `Z` when they are UTC's. */
|
|
1261
|
+
function jCalDateTime(fields, utc = false) {
|
|
1262
|
+
const pad = (value, width = 2) => String(value).padStart(width, '0');
|
|
1263
|
+
const date = `${pad(fields.year, 4)}-${pad(fields.month)}-${pad(fields.day)}`;
|
|
1264
|
+
const time = `${pad(fields.hour)}:${pad(fields.minute)}:${pad(fields.second)}`;
|
|
1265
|
+
return `${date}T${time}${utc ? 'Z' : ''}`;
|
|
1266
|
+
}
|
|
1267
|
+
/** The instant an ical.js time names, read in its own zone; undefined when it has none. */
|
|
1268
|
+
function zonedInstant(time) {
|
|
1269
|
+
return ownZone(time) === undefined ? undefined : new Date(time.toUnixTime() * 1000);
|
|
1270
|
+
}
|
|
1271
|
+
/** `value` (a jCal date-time) as the date it falls on in `timezone`; undefined if it cannot say. */
|
|
1272
|
+
function localDateOf(value, tzid, timezone) {
|
|
1273
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(Z?)$/.exec(value);
|
|
1274
|
+
if (!match)
|
|
1275
|
+
return undefined;
|
|
1276
|
+
const [year, month, day, hour, minute, second] = match.slice(1, 7).map(Number);
|
|
1277
|
+
const fields = { year, month, day, hour, minute, second };
|
|
1278
|
+
let instant;
|
|
1279
|
+
if (match[7] === 'Z') {
|
|
1280
|
+
instant = utcFrame(year, month, day, hour, minute, second, 0);
|
|
1281
|
+
}
|
|
1282
|
+
else if (typeof tzid === 'string' && isKnownTimezone(tzid)) {
|
|
1283
|
+
instant = zonedDateTimeToUtc(fields, tzid.trim());
|
|
1284
|
+
}
|
|
1285
|
+
else {
|
|
1286
|
+
// Floating: the wall time is the calendar's own, and so is its date.
|
|
1287
|
+
return value.slice(0, 10);
|
|
1288
|
+
}
|
|
1289
|
+
return jCalDateTime(zonedDateTimeFields(instant, timezone)).slice(0, 10);
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* Prepare a calendar's events before anything reads their times, so ical.js compares them at their
|
|
1293
|
+
* real instants:
|
|
1294
|
+
* - a floating time is framed in the calendar's zone, the zone Slotlock reads it in;
|
|
1295
|
+
* - each zone the events name that Intl knows becomes an IntlTimezone, whether or not the feed
|
|
1296
|
+
* defines it; a feed names no more zones it does not define than it may define
|
|
1297
|
+
* (MAX_ICAL_TIMEZONES): 50,000 spellings of one zone, each a zone of its own to ical.js, took
|
|
1298
|
+
* 3.4 seconds;
|
|
1299
|
+
* - an all-day series' UTC or zoned UNTIL, EXDATE and RECURRENCE-ID become the local dates they
|
|
1300
|
+
* fall on (RFC 5545 wants them as dates; a local midnight east of UTC is the day before in UTC);
|
|
1301
|
+
* - a PERIOD RDATE becomes its start, its end kept in the map returned, by event: ical.js orders a
|
|
1302
|
+
* period among times by whether a time falls inside it, and hands back the period itself;
|
|
1303
|
+
* - then, every zone resolving, a timed series' own times are aligned (alignSeriesTimes).
|
|
1304
|
+
* Every DATE and DATE-TIME value this reads is one boundComponents has counted.
|
|
1305
|
+
*/
|
|
1306
|
+
function frameEventTimes(root, components, calendarTimezone, budget) {
|
|
1307
|
+
const timezone = calendarTimezone !== undefined && isKnownTimezone(calendarTimezone)
|
|
1308
|
+
? calendarTimezone
|
|
1309
|
+
: undefined;
|
|
1310
|
+
const declared = new Set(root.getAllSubcomponents('vtimezone').map((zone) => zone.getFirstPropertyValue('tzid')));
|
|
1311
|
+
const named = new Set();
|
|
1312
|
+
let undeclared = 0;
|
|
1313
|
+
const nameZone = (tzid) => {
|
|
1314
|
+
if (named.has(tzid))
|
|
1315
|
+
return;
|
|
1316
|
+
named.add(tzid);
|
|
1317
|
+
if (!declared.has(tzid) && ++undeclared > MAX_ICAL_TIMEZONES)
|
|
1318
|
+
expansionLimit();
|
|
1319
|
+
};
|
|
1320
|
+
const periods = new Map();
|
|
1321
|
+
const allDayMasters = new Set();
|
|
1322
|
+
for (const component of components) {
|
|
1323
|
+
for (const name of COMPARED_TIME_PROPERTIES) {
|
|
1324
|
+
for (const property of component.getAllProperties(name)) {
|
|
1325
|
+
const values = property.jCal.slice(3);
|
|
1326
|
+
const starts = values.map((value) => (Array.isArray(value) ? value[0] : value));
|
|
1327
|
+
const tzid = property.getParameter('tzid');
|
|
1328
|
+
if (typeof tzid === 'string') {
|
|
1329
|
+
nameZone(tzid);
|
|
1330
|
+
}
|
|
1331
|
+
else if (timezone &&
|
|
1332
|
+
(property.type === 'date-time' || property.type === 'period') &&
|
|
1333
|
+
starts.some((start) => typeof start === 'string' && !start.endsWith('Z'))) {
|
|
1334
|
+
property.setParameter('tzid', timezone);
|
|
1335
|
+
nameZone(timezone);
|
|
1336
|
+
}
|
|
1337
|
+
if (name === 'rdate' && property.type === 'period') {
|
|
1338
|
+
const kept = periods.get(component) ?? [];
|
|
1339
|
+
for (const value of values) {
|
|
1340
|
+
const [start, end] = Array.isArray(value) ? value : [];
|
|
1341
|
+
if (typeof start !== 'string' || typeof end !== 'string') {
|
|
1342
|
+
throw new CalendarContractError('invalid_icalendar');
|
|
1343
|
+
}
|
|
1344
|
+
kept.push({ property, start, end });
|
|
1345
|
+
}
|
|
1346
|
+
periods.set(component, kept);
|
|
1347
|
+
property.resetType('date-time');
|
|
1348
|
+
for (const value of values)
|
|
1349
|
+
property.jCal.push(value[0]);
|
|
1350
|
+
}
|
|
1351
|
+
budget.check();
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
if (!component.hasProperty('recurrence-id') &&
|
|
1355
|
+
component.getFirstProperty('dtstart')?.type === 'date') {
|
|
1356
|
+
const uid = component.getFirstPropertyValue('uid');
|
|
1357
|
+
if (typeof uid === 'string')
|
|
1358
|
+
allDayMasters.add(uid);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
if (timezone) {
|
|
1362
|
+
const toDate = (property) => {
|
|
1363
|
+
budget.check();
|
|
1364
|
+
if (property.type !== 'date-time')
|
|
1365
|
+
return;
|
|
1366
|
+
const tzid = property.getParameter('tzid');
|
|
1367
|
+
const dates = property.jCal.slice(3).map((value) => typeof value === 'string' ? localDateOf(value, tzid, timezone) : undefined);
|
|
1368
|
+
if (dates.some((date) => date === undefined))
|
|
1369
|
+
return;
|
|
1370
|
+
property.resetType('date');
|
|
1371
|
+
property.removeParameter('tzid');
|
|
1372
|
+
for (const date of dates)
|
|
1373
|
+
property.jCal.push(date);
|
|
1374
|
+
};
|
|
1375
|
+
for (const component of components) {
|
|
1376
|
+
const uid = component.getFirstPropertyValue('uid');
|
|
1377
|
+
if (typeof uid !== 'string' || !allDayMasters.has(uid))
|
|
1378
|
+
continue;
|
|
1379
|
+
if (component.hasProperty('recurrence-id')) {
|
|
1380
|
+
for (const property of component.getAllProperties('recurrence-id'))
|
|
1381
|
+
toDate(property);
|
|
1382
|
+
continue;
|
|
1383
|
+
}
|
|
1384
|
+
for (const property of component.getAllProperties('exdate'))
|
|
1385
|
+
toDate(property);
|
|
1386
|
+
for (const property of component.getAllProperties('rrule')) {
|
|
1387
|
+
const rule = property.jCal[3];
|
|
1388
|
+
if (typeof rule?.until === 'string' && rule.until.length > 10) {
|
|
1389
|
+
const date = localDateOf(rule.until, undefined, timezone);
|
|
1390
|
+
if (date !== undefined)
|
|
1391
|
+
rule.until = date;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
const cache = root._timezoneCache;
|
|
1397
|
+
for (const tzid of named) {
|
|
1398
|
+
// A zone Intl knows by this name is read through Intl, as calendarTimeValue reads it, even when
|
|
1399
|
+
// the feed defines it: ical.js read a stale VTIMEZONE's rules (standard time only) while
|
|
1400
|
+
// Slotlock read Intl's, so the two placed one time an hour apart and a moved occurrence was
|
|
1401
|
+
// found in a feed but not in the series stored from it.
|
|
1402
|
+
if (ICAL.TimezoneService.has(tzid) || !isKnownTimezone(tzid))
|
|
1403
|
+
continue;
|
|
1404
|
+
// ical.js keeps a calendar's zones here; without it, it could not use one of these.
|
|
1405
|
+
if (!(cache instanceof Map))
|
|
1406
|
+
throw new CalendarContractError('invalid_icalendar');
|
|
1407
|
+
cache.set(tzid, new IntlTimezone(tzid));
|
|
1408
|
+
budget.check();
|
|
1409
|
+
}
|
|
1410
|
+
alignSeriesTimes(components, budget);
|
|
1411
|
+
return periods;
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* With every zone resolving, put what ical.js compares with a timed series' own times in the terms
|
|
1415
|
+
* it compares them in:
|
|
1416
|
+
* - a floating UNTIL becomes the UTC instant it names on the series' clock: RFC 5545 §3.3.10 reads
|
|
1417
|
+
* it as DTSTART is read, and ical.js read it as UTC, so a New York series lost its last day;
|
|
1418
|
+
* - a RECURRENCE-ID named in another zone becomes the same instant in UTC, which ical.js matches
|
|
1419
|
+
* with an occurrence (it matched one in New York time with none); one for this and every later
|
|
1420
|
+
* occurrence (RANGE=THISANDFUTURE) becomes the series' own wall time instead, since ical.js moves
|
|
1421
|
+
* those by the wall-clock distance from it to the exception's start, read in the start's zone.
|
|
1422
|
+
* A DATE EXDATE of a timed series becomes the series' start time that day: ical.js excluded every
|
|
1423
|
+
* occurrence that day, and a series stored from the feed, whose exceptions are instants, excluded
|
|
1424
|
+
* none (most series occur once a day; one that occurs more keeps the others). And a series of
|
|
1425
|
+
* RDATEs with no RRULE gets its DTSTART as one: it is the first instance (RFC 5545 §3.8.2.4), which
|
|
1426
|
+
* ical.js left out.
|
|
1427
|
+
*/
|
|
1428
|
+
function alignSeriesTimes(components, budget) {
|
|
1429
|
+
const seriesStarts = new Map();
|
|
1430
|
+
for (const component of components) {
|
|
1431
|
+
if (component.hasProperty('recurrence-id'))
|
|
1432
|
+
continue;
|
|
1433
|
+
const uid = component.getFirstPropertyValue('uid');
|
|
1434
|
+
const start = component.getFirstProperty('dtstart');
|
|
1435
|
+
const value = start?.jCal[3];
|
|
1436
|
+
if (typeof uid !== 'string' || !start || typeof value !== 'string')
|
|
1437
|
+
continue;
|
|
1438
|
+
if (component.hasProperty('rdate') && !component.hasProperty('rrule')) {
|
|
1439
|
+
const parameters = { ...start.jCal[1] };
|
|
1440
|
+
component.addProperty(new ICAL.Property(['rdate', parameters, start.type, value]));
|
|
1441
|
+
}
|
|
1442
|
+
if (start.type !== 'date-time')
|
|
1443
|
+
continue;
|
|
1444
|
+
const time = ICAL.Time.fromDateTimeString(value, start);
|
|
1445
|
+
if (zonedInstant(time) === undefined)
|
|
1446
|
+
continue;
|
|
1447
|
+
seriesStarts.set(uid, { time, tzid: start.getParameter('tzid') });
|
|
1448
|
+
for (const property of component.getAllProperties('exdate')) {
|
|
1449
|
+
const days = property.jCal.slice(3);
|
|
1450
|
+
if (property.type !== 'date' || !days.every((day) => typeof day === 'string'))
|
|
1451
|
+
continue;
|
|
1452
|
+
property.resetType('date-time');
|
|
1453
|
+
const zone = start.getParameter('tzid');
|
|
1454
|
+
if (typeof zone === 'string')
|
|
1455
|
+
property.setParameter('tzid', zone);
|
|
1456
|
+
for (const day of days)
|
|
1457
|
+
property.jCal.push(`${day}${value.slice(10)}`);
|
|
1458
|
+
}
|
|
1459
|
+
for (const property of component.getAllProperties('rrule')) {
|
|
1460
|
+
const rule = property.jCal[3];
|
|
1461
|
+
const until = rule?.until;
|
|
1462
|
+
if (typeof until !== 'string' || until.length <= 10 || until.endsWith('Z'))
|
|
1463
|
+
continue;
|
|
1464
|
+
const instant = zonedInstant(ICAL.Time.fromDateTimeString(until, start));
|
|
1465
|
+
if (instant !== undefined && rule) {
|
|
1466
|
+
rule.until = jCalDateTime(zonedDateTimeFields(instant, 'UTC'), true);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
budget.check();
|
|
1470
|
+
}
|
|
1471
|
+
for (const component of components) {
|
|
1472
|
+
const uid = component.getFirstPropertyValue('uid');
|
|
1473
|
+
const series = typeof uid === 'string' ? seriesStarts.get(uid) : undefined;
|
|
1474
|
+
if (series === undefined)
|
|
1475
|
+
continue;
|
|
1476
|
+
for (const property of component.getAllProperties('recurrence-id')) {
|
|
1477
|
+
budget.check();
|
|
1478
|
+
const value = property.jCal[3];
|
|
1479
|
+
if (property.type !== 'date-time' || typeof value !== 'string')
|
|
1480
|
+
continue;
|
|
1481
|
+
// Named in the series' own zone, it is matched by its wall time as it stands.
|
|
1482
|
+
if (typeof series.tzid === 'string' && property.getParameter('tzid') === series.tzid)
|
|
1483
|
+
continue;
|
|
1484
|
+
const time = ICAL.Time.fromDateTimeString(value, property);
|
|
1485
|
+
const instant = zonedInstant(time);
|
|
1486
|
+
if (instant === undefined)
|
|
1487
|
+
continue;
|
|
1488
|
+
const seriesZone = series.time.zone;
|
|
1489
|
+
const zone = property.getParameter('range') === undefined ? ICAL.Timezone.utcTimezone : seriesZone;
|
|
1490
|
+
if (time.zone?.tzid === zone.tzid)
|
|
1491
|
+
continue;
|
|
1492
|
+
let written;
|
|
1493
|
+
if (zone.tzid === 'UTC') {
|
|
1494
|
+
written = jCalDateTime(zonedDateTimeFields(instant, 'UTC'), true);
|
|
1495
|
+
}
|
|
1496
|
+
else if (zone instanceof IntlTimezone) {
|
|
1497
|
+
written = jCalDateTime(zonedDateTimeFields(instant, zone.tzid.trim()));
|
|
1498
|
+
}
|
|
1499
|
+
else {
|
|
1500
|
+
// A zone the calendar defines is ical.js's to read.
|
|
1501
|
+
written = time.convertToZone(zone).toString();
|
|
1502
|
+
}
|
|
1503
|
+
property.resetType('date-time');
|
|
1504
|
+
if (zone.tzid === 'UTC')
|
|
1505
|
+
property.removeParameter('tzid');
|
|
1506
|
+
else
|
|
1507
|
+
property.setParameter('tzid', series.tzid);
|
|
1508
|
+
property.jCal.push(written);
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1154
1512
|
/** Parse VEVENTs with the standards library; event content is deliberately discarded. */
|
|
1155
1513
|
export function parseICalendarChanges(input, calendarTimezone, expansionWindow) {
|
|
1156
1514
|
return parseCalendarChanges(input, calendarTimezone, expansionWindow, FEED_LIMITS);
|
|
@@ -1162,12 +1520,17 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
|
|
|
1162
1520
|
boundInput(input);
|
|
1163
1521
|
const budget = new RecurrenceBudget(limits);
|
|
1164
1522
|
const root = new ICAL.Component(ICAL.parse(input));
|
|
1523
|
+
// A bare VEVENT (or anything else) would read as a calendar with no events.
|
|
1524
|
+
if (root.name !== 'vcalendar')
|
|
1525
|
+
throw new CalendarContractError('invalid_icalendar');
|
|
1165
1526
|
budget.check();
|
|
1166
1527
|
let Budgeted;
|
|
1167
1528
|
const budgeted = () => (Budgeted ??= budgetedRecurIterator(budget));
|
|
1168
1529
|
budgetTimezoneRules(root, budgeted);
|
|
1169
1530
|
budgetTimezoneCoverage(root, budget);
|
|
1170
|
-
|
|
1531
|
+
// A time read in a zone of its own (a VTIMEZONE's, or an IntlTimezone) is read in that zone: an
|
|
1532
|
+
// RDATE may name another zone than its series' DTSTART, and an occurrence keeps the RDATE's.
|
|
1533
|
+
const timeValue = (value, zone, fallback) => budget.search(() => calendarTimeValue(value, ownZone(value) ?? zone, fallback));
|
|
1171
1534
|
const embeddedTimezone = root.getFirstPropertyValue('x-wr-timezone');
|
|
1172
1535
|
const defaultTimezone = calendarTimezone?.trim() ||
|
|
1173
1536
|
(typeof embeddedTimezone === 'string' ? embeddedTimezone.trim() : '') ||
|
|
@@ -1177,6 +1540,7 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
|
|
|
1177
1540
|
throw new CalendarContractError('invalid_icalendar');
|
|
1178
1541
|
}
|
|
1179
1542
|
boundComponents(components, budget);
|
|
1543
|
+
const periods = frameEventTimes(root, components, defaultTimezone, budget);
|
|
1180
1544
|
const events = feedEvents(components, budget);
|
|
1181
1545
|
const masters = new Map();
|
|
1182
1546
|
const exceptionsByUid = new Map();
|
|
@@ -1206,33 +1570,132 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
|
|
|
1206
1570
|
budget.check();
|
|
1207
1571
|
}
|
|
1208
1572
|
}
|
|
1209
|
-
|
|
1573
|
+
/**
|
|
1574
|
+
* One event or occurrence as a change, or null when it has no length: RFC 5545 §3.6.1 ends a
|
|
1575
|
+
* DATE-TIME DTSTART without DTEND or DURATION when it starts, so it occupies no time. One that
|
|
1576
|
+
* ends before it starts, or has no UID, makes the feed an invalid calendar. `knownEnd` is an end
|
|
1577
|
+
* already found: a PERIOD RDATE's, or an occurrence's that starts in an hour the clocks skip.
|
|
1578
|
+
*/
|
|
1579
|
+
const normalizeEvent = (event, startValue, endValue, recurrenceValue, knownEnd) => {
|
|
1210
1580
|
const component = event.component;
|
|
1211
1581
|
const startProperty = component.getFirstProperty('dtstart');
|
|
1212
1582
|
const endProperty = component.getFirstProperty('dtend');
|
|
1213
1583
|
const recurrenceProperty = component.getFirstProperty('recurrence-id');
|
|
1214
1584
|
const status = String(component.getFirstPropertyValue('status') ?? 'confirmed').toLowerCase();
|
|
1215
1585
|
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);
|
|
1216
1589
|
const change = {
|
|
1217
1590
|
externalId: event.uid,
|
|
1218
|
-
start
|
|
1219
|
-
end
|
|
1591
|
+
start,
|
|
1592
|
+
end,
|
|
1220
1593
|
status,
|
|
1221
1594
|
transparent: transparency === 'transparent',
|
|
1222
1595
|
sequence: Number(component.getFirstPropertyValue('sequence') ?? 0),
|
|
1223
1596
|
};
|
|
1597
|
+
if (end.getTime() === start.getTime()) {
|
|
1598
|
+
if (status !== 'cancelled')
|
|
1599
|
+
return null;
|
|
1600
|
+
// Cancelled, it still says so, with no time.
|
|
1601
|
+
change.start = null;
|
|
1602
|
+
change.end = null;
|
|
1603
|
+
}
|
|
1224
1604
|
if (recurrenceValue) {
|
|
1225
1605
|
change.recurrenceId = timeValue(recurrenceValue, recurrenceProperty?.getParameter('tzid') ?? startProperty?.getParameter('tzid'), defaultTimezone).toISOString();
|
|
1226
1606
|
}
|
|
1227
|
-
|
|
1607
|
+
try {
|
|
1608
|
+
return normalizeExternalCalendarChange(change);
|
|
1609
|
+
}
|
|
1610
|
+
catch (error) {
|
|
1611
|
+
if (error instanceof CalendarContractError) {
|
|
1612
|
+
throw new CalendarContractError('invalid_icalendar');
|
|
1613
|
+
}
|
|
1614
|
+
throw error;
|
|
1615
|
+
}
|
|
1616
|
+
};
|
|
1617
|
+
/** Each PERIOD RDATE of a series, by the instant it starts: the instant it ends. */
|
|
1618
|
+
const periodEndsOf = (event) => {
|
|
1619
|
+
const ends = new Map();
|
|
1620
|
+
const maxMs = MAX_ICAL_STEP_DAYS * 24 * 60 * 60 * 1000;
|
|
1621
|
+
for (const period of periods.get(event.component) ?? []) {
|
|
1622
|
+
const zone = period.property.getParameter('tzid');
|
|
1623
|
+
const start = ICAL.Time.fromDateTimeString(period.start, period.property);
|
|
1624
|
+
let end;
|
|
1625
|
+
if (/^[+-]?P/.test(period.end)) {
|
|
1626
|
+
// Bounded first, as boundComponent bounds a DURATION: ical.js adds a month at a time.
|
|
1627
|
+
const duration = ICAL.Duration.fromString(period.end);
|
|
1628
|
+
if (duration.isNegative || duration.toSeconds() * 1000 > maxMs)
|
|
1629
|
+
expansionLimit();
|
|
1630
|
+
end = start.clone();
|
|
1631
|
+
end.addDuration(duration);
|
|
1632
|
+
}
|
|
1633
|
+
else {
|
|
1634
|
+
end = ICAL.Time.fromDateTimeString(period.end, period.property);
|
|
1635
|
+
if (Math.abs(fieldsMs(end) - fieldsMs(start)) > maxMs + 24 * 60 * 60 * 1000) {
|
|
1636
|
+
expansionLimit();
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
const startMs = timeValue(start, zone, defaultTimezone).getTime();
|
|
1640
|
+
const endsAt = timeValue(end, zone, defaultTimezone);
|
|
1641
|
+
if (endsAt.getTime() < startMs)
|
|
1642
|
+
throw new CalendarContractError('invalid_icalendar');
|
|
1643
|
+
ends.set(startMs, endsAt);
|
|
1644
|
+
budget.check();
|
|
1645
|
+
}
|
|
1646
|
+
return ends;
|
|
1647
|
+
};
|
|
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
|
+
/**
|
|
1669
|
+
* A series' EXDATEs: the instants its DATE-TIME values name, and the days its DATE values name
|
|
1670
|
+
* (one excludes every occurrence that day, as ical.js reads it). ical.js passes them one at a
|
|
1671
|
+
* time as it goes, so an occurrence after two EXDATEs it had not passed, or the second of an
|
|
1672
|
+
* instant both RRULE and RDATE name, escaped its EXDATE; RFC 5545 §3.8.5.1 has every EXDATE win.
|
|
1673
|
+
*/
|
|
1674
|
+
const exclusionsOf = (event) => {
|
|
1675
|
+
const instants = new Set();
|
|
1676
|
+
const days = new Set();
|
|
1677
|
+
const startZone = event.component.getFirstProperty('dtstart')?.getParameter('tzid');
|
|
1678
|
+
for (const property of event.component.getAllProperties('exdate')) {
|
|
1679
|
+
const zone = property.getParameter('tzid') ?? startZone;
|
|
1680
|
+
for (const value of property.getValues()) {
|
|
1681
|
+
if (!(value instanceof ICAL.Time))
|
|
1682
|
+
throw new CalendarContractError('invalid_icalendar');
|
|
1683
|
+
if (value.isDate)
|
|
1684
|
+
days.add(dayOf(value));
|
|
1685
|
+
else
|
|
1686
|
+
instants.add(timeValue(value, zone, defaultTimezone).getTime());
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
return { instants, days };
|
|
1228
1690
|
};
|
|
1229
1691
|
const changes = [];
|
|
1230
|
-
let remainingWork = MAX_ICAL_WORK;
|
|
1231
1692
|
for (const event of events) {
|
|
1232
1693
|
if (event.isRecurrenceException() && masters.has(event.uid))
|
|
1233
1694
|
continue;
|
|
1234
1695
|
if (!event.isRecurring()) {
|
|
1235
|
-
|
|
1696
|
+
const change = normalizeEvent(event, event.startDate, event.endDate, event.recurrenceId ?? undefined);
|
|
1697
|
+
if (change)
|
|
1698
|
+
changes.push(change);
|
|
1236
1699
|
continue;
|
|
1237
1700
|
}
|
|
1238
1701
|
if (!expansionWindow ||
|
|
@@ -1242,6 +1705,8 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
|
|
|
1242
1705
|
expansionWindow.end.getTime() - expansionWindow.start.getTime() > MAX_ICAL_WINDOW_MS) {
|
|
1243
1706
|
throw new CalendarContractError('invalid_icalendar');
|
|
1244
1707
|
}
|
|
1708
|
+
// What one series may pass from its start, so that one series' age never refuses another's.
|
|
1709
|
+
let remainingWork = MAX_ICAL_WORK;
|
|
1245
1710
|
let maxBackwardExceptionShiftMs = 0;
|
|
1246
1711
|
for (const exception of exceptionsByUid.get(event.uid) ?? []) {
|
|
1247
1712
|
remainingWork -= 1;
|
|
@@ -1269,6 +1734,9 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
|
|
|
1269
1734
|
? kept
|
|
1270
1735
|
: null;
|
|
1271
1736
|
const startZone = event.component.getFirstProperty('dtstart')?.getParameter('tzid');
|
|
1737
|
+
let periodEnds;
|
|
1738
|
+
let exclusions;
|
|
1739
|
+
const exactLengthMs = exactLengthOf(event);
|
|
1272
1740
|
// The occurrences in the window from `resume` (or the start), and the cursor to keep; null
|
|
1273
1741
|
// when `resume` does not fit this series.
|
|
1274
1742
|
const expand = (resume) => {
|
|
@@ -1284,6 +1752,7 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
|
|
|
1284
1752
|
}
|
|
1285
1753
|
const found = [];
|
|
1286
1754
|
let position = resume?.position;
|
|
1755
|
+
let previous;
|
|
1287
1756
|
let snapshot = resume;
|
|
1288
1757
|
let leaving = key !== null;
|
|
1289
1758
|
while (true) {
|
|
@@ -1305,12 +1774,43 @@ function parseCalendarChanges(input, calendarTimezone, expansionWindow, limits,
|
|
|
1305
1774
|
position = occurrenceStart.getTime();
|
|
1306
1775
|
if (occurrenceStart.getTime() >= recurrenceScanEndMs)
|
|
1307
1776
|
break;
|
|
1777
|
+
// An instant is one occurrence however many RRULEs and RDATEs name it (RFC 5545
|
|
1778
|
+
// §3.8.5.3; ical.js yields it again), and none where an EXDATE names it.
|
|
1779
|
+
const repeated = position === previous;
|
|
1780
|
+
previous = position;
|
|
1781
|
+
if (repeated)
|
|
1782
|
+
continue;
|
|
1783
|
+
if (event.component.hasProperty('exdate')) {
|
|
1784
|
+
exclusions ??= exclusionsOf(event);
|
|
1785
|
+
if (exclusions.instants.has(position) || exclusions.days.has(dayOf(occurrence))) {
|
|
1786
|
+
continue;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1308
1789
|
const details = event.getOccurrenceDetails(occurrence);
|
|
1309
1790
|
const start = timeValue(details.startDate, details.item.component.getFirstProperty('dtstart')?.getParameter('tzid'), defaultTimezone);
|
|
1310
|
-
|
|
1311
|
-
|
|
1791
|
+
// A PERIOD RDATE's occurrence ends where its period does, unless an exception moved it.
|
|
1792
|
+
let knownEnd = details.item === event
|
|
1793
|
+
? (periodEnds ??= periodEndsOf(event)).get(occurrenceStart.getTime())
|
|
1794
|
+
: undefined;
|
|
1795
|
+
let end = knownEnd ??
|
|
1796
|
+
timeValue(details.endDate, details.item.component.getFirstProperty('dtend')?.getParameter('tzid') ??
|
|
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;
|
|
1809
|
+
}
|
|
1312
1810
|
if (end > expansionWindow.start && start < expansionWindow.end) {
|
|
1313
|
-
|
|
1811
|
+
const change = normalizeEvent(details.item, details.startDate, details.endDate, occurrence, knownEnd);
|
|
1812
|
+
if (change)
|
|
1813
|
+
found.push(change);
|
|
1314
1814
|
if (changes.length + found.length > MAX_ICAL_OCCURRENCES)
|
|
1315
1815
|
expansionLimit();
|
|
1316
1816
|
}
|
|
@@ -1358,31 +1858,6 @@ function requiredEventText(value, code) {
|
|
|
1358
1858
|
}
|
|
1359
1859
|
return normalized;
|
|
1360
1860
|
}
|
|
1361
|
-
function partsInZone(value, timezone) {
|
|
1362
|
-
const formatter = new Intl.DateTimeFormat('en-GB', {
|
|
1363
|
-
timeZone: timezone,
|
|
1364
|
-
hourCycle: 'h23',
|
|
1365
|
-
year: 'numeric',
|
|
1366
|
-
month: '2-digit',
|
|
1367
|
-
day: '2-digit',
|
|
1368
|
-
hour: '2-digit',
|
|
1369
|
-
minute: '2-digit',
|
|
1370
|
-
second: '2-digit',
|
|
1371
|
-
});
|
|
1372
|
-
const values = {};
|
|
1373
|
-
for (const part of formatter.formatToParts(value)) {
|
|
1374
|
-
if (part.type !== 'literal')
|
|
1375
|
-
values[part.type] = Number(part.value);
|
|
1376
|
-
}
|
|
1377
|
-
return {
|
|
1378
|
-
year: values.year ?? 0,
|
|
1379
|
-
month: values.month ?? 0,
|
|
1380
|
-
day: values.day ?? 0,
|
|
1381
|
-
hour: (values.hour ?? 0) % 24,
|
|
1382
|
-
minute: values.minute ?? 0,
|
|
1383
|
-
second: values.second ?? 0,
|
|
1384
|
-
};
|
|
1385
|
-
}
|
|
1386
1861
|
function addDateTimeProperty(component, name, value, timezone) {
|
|
1387
1862
|
const property = new ICAL.Property(name);
|
|
1388
1863
|
if (timezone === 'UTC') {
|
|
@@ -1390,7 +1865,7 @@ function addDateTimeProperty(component, name, value, timezone) {
|
|
|
1390
1865
|
}
|
|
1391
1866
|
else {
|
|
1392
1867
|
property.setParameter('tzid', timezone);
|
|
1393
|
-
property.setValue(ICAL.Time.fromData({ ...
|
|
1868
|
+
property.setValue(ICAL.Time.fromData({ ...zonedDateTimeFields(value, timezone), isDate: false }));
|
|
1394
1869
|
}
|
|
1395
1870
|
component.addProperty(property);
|
|
1396
1871
|
}
|
|
@@ -1705,6 +2180,8 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
|
|
|
1705
2180
|
boundInput(input);
|
|
1706
2181
|
const budget = new RecurrenceBudget(FEED_LIMITS);
|
|
1707
2182
|
const root = new ICAL.Component(ICAL.parse(input));
|
|
2183
|
+
if (root.name !== 'vcalendar')
|
|
2184
|
+
throw new CalendarContractError('invalid_icalendar');
|
|
1708
2185
|
budget.check();
|
|
1709
2186
|
let Budgeted;
|
|
1710
2187
|
budgetTimezoneRules(root, () => (Budgeted ??= budgetedRecurIterator(budget)));
|
|
@@ -1719,6 +2196,9 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
|
|
|
1719
2196
|
throw new CalendarContractError('invalid_icalendar');
|
|
1720
2197
|
}
|
|
1721
2198
|
boundComponents(components, budget);
|
|
2199
|
+
// Times are read as a feed's are, so a stored series occupies what the feed reports: zones it
|
|
2200
|
+
// names without defining them, floating times in the calendar's zone, all-day EXDATEs as days.
|
|
2201
|
+
frameEventTimes(root, components, defaultTimezone, budget);
|
|
1722
2202
|
const masters = new Map();
|
|
1723
2203
|
const exceptionsByUid = new Map();
|
|
1724
2204
|
for (const event of feedEvents(components, budget)) {
|
|
@@ -1867,7 +2347,8 @@ export function parseTrustedICalendarEvents(input, calendarTimezone) {
|
|
|
1867
2347
|
return trusted;
|
|
1868
2348
|
}
|
|
1869
2349
|
catch (error) {
|
|
1870
|
-
|
|
2350
|
+
// An event the calendar gives no length or no UID is the calendar's fault, as in a feed.
|
|
2351
|
+
if (error instanceof CalendarContractError && error.code === 'invalid_icalendar')
|
|
1871
2352
|
throw error;
|
|
1872
2353
|
throw new CalendarContractError('invalid_icalendar');
|
|
1873
2354
|
}
|