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/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 remains `floating` and cannot
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 end:
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.getFirstPropertyValue('dtstart');
645
- const end = component.getFirstPropertyValue('dtend');
646
- if (start instanceof ICAL.Time && end instanceof ICAL.Time) {
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(fieldsMs(end) - fieldsMs(start)) > maxMs + 24 * 60 * 60 * 1000)
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
- starts[0].toICALString(),
974
- rules[0].toICALString(),
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
- const timeValue = (value, zone, fallback) => budget.search(() => calendarTimeValue(value, zone, fallback));
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
- const normalizeEvent = (event, startValue, endValue, recurrenceValue) => {
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: timeValue(startValue, startProperty?.getParameter('tzid'), defaultTimezone),
1219
- end: timeValue(endValue, endProperty?.getParameter('tzid') ?? startProperty?.getParameter('tzid'), defaultTimezone),
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
- return normalizeExternalCalendarChange(change);
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
- changes.push(normalizeEvent(event, event.startDate, event.endDate, event.recurrenceId ?? undefined));
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
- const end = timeValue(details.endDate, details.item.component.getFirstProperty('dtend')?.getParameter('tzid') ??
1311
- details.item.component.getFirstProperty('dtstart')?.getParameter('tzid'), defaultTimezone);
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
- found.push(normalizeEvent(details.item, details.startDate, details.endDate, occurrence));
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({ ...partsInZone(value, timezone), isDate: false }));
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
- if (error instanceof CalendarContractError)
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
  }