@remit/calendar-service 0.0.17 → 0.0.19

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.
Files changed (73) hide show
  1. package/dist/accept.d.ts +71 -0
  2. package/dist/accept.js +159 -0
  3. package/dist/build.d.ts +79 -0
  4. package/dist/build.js +196 -0
  5. package/dist/correct.d.ts +4 -0
  6. package/dist/correct.js +22 -0
  7. package/dist/errors.d.ts +56 -0
  8. package/dist/errors.js +1 -0
  9. package/dist/etag.d.ts +9 -0
  10. package/{src/etag.ts → dist/etag.js} +1 -3
  11. package/dist/expand.d.ts +73 -0
  12. package/dist/expand.js +188 -0
  13. package/dist/feed.d.ts +81 -0
  14. package/dist/feed.js +157 -0
  15. package/dist/fixtures.d.ts +19 -0
  16. package/dist/fixtures.js +37 -0
  17. package/dist/index.d.ts +16 -0
  18. package/dist/index.js +15 -0
  19. package/dist/memory-store.d.ts +105 -0
  20. package/dist/memory-store.js +319 -0
  21. package/dist/parse.d.ts +39 -0
  22. package/dist/parse.js +87 -0
  23. package/dist/project.d.ts +30 -0
  24. package/dist/project.js +57 -0
  25. package/dist/put.d.ts +52 -0
  26. package/dist/put.js +78 -0
  27. package/dist/reopen.d.ts +14 -0
  28. package/dist/reopen.js +92 -0
  29. package/dist/scope.d.ts +70 -0
  30. package/dist/scope.js +687 -0
  31. package/dist/subscribe.d.ts +96 -0
  32. package/dist/subscribe.js +298 -0
  33. package/dist/suggest.d.ts +74 -0
  34. package/dist/suggest.js +138 -0
  35. package/dist/time.d.ts +76 -0
  36. package/dist/time.js +164 -0
  37. package/dist/window.d.ts +85 -0
  38. package/dist/window.js +161 -0
  39. package/package.json +40 -35
  40. package/src/accept.test.ts +0 -403
  41. package/src/accept.ts +0 -229
  42. package/src/build.test.ts +0 -113
  43. package/src/build.ts +0 -311
  44. package/src/correct.test.ts +0 -173
  45. package/src/correct.ts +0 -40
  46. package/src/errors.ts +0 -62
  47. package/src/etag.test.ts +0 -54
  48. package/src/expand.test.ts +0 -361
  49. package/src/expand.ts +0 -303
  50. package/src/feed.test.ts +0 -334
  51. package/src/feed.ts +0 -203
  52. package/src/feeds/google-secret-address.ics +0 -99
  53. package/src/fixtures.ts +0 -52
  54. package/src/index.ts +0 -125
  55. package/src/memory-store.ts +0 -404
  56. package/src/parse.test.ts +0 -264
  57. package/src/parse.ts +0 -138
  58. package/src/project.test.ts +0 -247
  59. package/src/project.ts +0 -122
  60. package/src/put.test.ts +0 -320
  61. package/src/put.ts +0 -123
  62. package/src/reopen.test.ts +0 -531
  63. package/src/reopen.ts +0 -190
  64. package/src/scope.test.ts +0 -1421
  65. package/src/scope.ts +0 -1003
  66. package/src/subscribe.test.ts +0 -578
  67. package/src/subscribe.ts +0 -420
  68. package/src/suggest.test.ts +0 -376
  69. package/src/suggest.ts +0 -244
  70. package/src/time.ts +0 -260
  71. package/src/window.test.ts +0 -648
  72. package/src/window.ts +0 -281
  73. package/tsconfig.json +0 -8
package/src/scope.ts DELETED
@@ -1,1003 +0,0 @@
1
- import { RecurrenceScope } from "@remit/domain-enums";
2
- import ICAL from "ical.js";
3
- import { applyEventFields, type CalendarEventFields } from "./build.js";
4
- import { type CalendarResult, calendarFailure } from "./errors.js";
5
- import { CALENDAR_WINDOW_MAX_STEPS, overridesBySlot } from "./expand.js";
6
- import {
7
- type ParsedCalendar,
8
- parseCalendar,
9
- serializeCalendar,
10
- } from "./parse.js";
11
- import { hasRecurrence } from "./project.js";
12
- import { dtStartTzid, resolveTime, toUtcIso, tzidOf } from "./time.js";
13
-
14
- export type RecurrenceScopeValue =
15
- (typeof RecurrenceScope)[keyof typeof RecurrenceScope];
16
-
17
- /**
18
- * What a scoped write turns into on the store.
19
- *
20
- * `Split` is the only one that is not a single resource: iCalendar has no way
21
- * to say "the rest of this series is different", so a `Following` edit really
22
- * is two resources — the truncated original and a new one under a UID of its
23
- * own. Both go through the one write path rather than through a second.
24
- */
25
- export type ScopedWrite =
26
- | { kind: "Replace"; icalData: string }
27
- | { kind: "Split"; icalData: string; following: string }
28
- | { kind: "Delete" };
29
-
30
- export interface ScopedWriteInput {
31
- scope: RecurrenceScopeValue;
32
- /** ISO 8601 UTC instant naming the occurrence the write was made from, or `""`. */
33
- recurrenceId: string;
34
- /** UID the resource a `Following` split creates is written under. */
35
- followingUid: string;
36
- }
37
-
38
- const FOLLOWING_UID_SEPARATOR = "/following/";
39
-
40
- export const followingUidPrefix = (icalUid: string): string =>
41
- `${icalUid.split(FOLLOWING_UID_SEPARATOR)[0]}${FOLLOWING_UID_SEPARATOR}`;
42
-
43
- export const followingUidOf = (icalUid: string, id: string): string =>
44
- `${followingUidPrefix(icalUid)}${id}`;
45
-
46
- interface FoundOccurrence {
47
- /** The rule slot, in whatever form the master's DTSTART is written in. */
48
- slot: ICAL.Time;
49
- slotUtc: string;
50
- /** The occurrence as the series produces it; absent when only an override names the slot. */
51
- details: ReturnType<ICAL.Event["getOccurrenceDetails"]> | null;
52
- /** Position in the rule's own sequence, `-1` when the rule never reaches it. */
53
- index: number;
54
- override: ICAL.Component | null;
55
- }
56
-
57
- /** A deep copy of a resource, taken the one way that cannot share structure. */
58
- const cloneCalendar = (
59
- calendar: ParsedCalendar,
60
- ): Promise<CalendarResult<ParsedCalendar>> =>
61
- parseCalendar(serializeCalendar(calendar.component));
62
-
63
- const timeProperty = (
64
- name: string,
65
- time: ICAL.Time,
66
- tzid: string,
67
- ): ICAL.Property => {
68
- const property = new ICAL.Property(name);
69
- property.setValue(time);
70
- if (!time.isDate && tzid !== "") property.setParameter("tzid", tzid);
71
- return property;
72
- };
73
-
74
- const setTimeValue = (
75
- component: ICAL.Component,
76
- name: string,
77
- time: ICAL.Time,
78
- tzid: string,
79
- ): void => {
80
- const existing = component.getFirstProperty(name);
81
- if (existing) {
82
- existing.setValue(time);
83
- return;
84
- }
85
- component.addProperty(timeProperty(name, time, tzid));
86
- };
87
-
88
- /**
89
- * The occurrence a scoped write is anchored at.
90
- *
91
- * Walks the series rather than trusting the caller's instant: a RECURRENCE-ID
92
- * naming no occurrence is the shape of a stale client acting on a series
93
- * somebody has since edited, and writing an override or an EXDATE for a slot
94
- * the rule never produces leaves a resource nothing can reconcile afterwards.
95
- */
96
- export const findOccurrence = (
97
- calendar: ParsedCalendar,
98
- collectionTimezone: string,
99
- recurrenceId: string,
100
- ): CalendarResult<FoundOccurrence> => {
101
- const targetMs = Date.parse(recurrenceId);
102
- if (Number.isNaN(targetMs)) {
103
- return calendarFailure(
104
- "InvalidDateTime",
105
- `"${recurrenceId}" is not a RECURRENCE-ID this server can read`,
106
- );
107
- }
108
- const targetUtc = toUtcIso(targetMs);
109
-
110
- const overrideBySlot = overridesBySlot(calendar, collectionTimezone);
111
- const masterStartTzid = dtStartTzid(calendar.master);
112
- const event = new ICAL.Event(calendar.master);
113
- for (const override of calendar.overrides) {
114
- event.relateException(override);
115
- }
116
-
117
- const iterator = event.iterator();
118
- let next = iterator.next();
119
- let index = 0;
120
- while (next && index < CALENDAR_WINDOW_MAX_STEPS) {
121
- const slot = resolveTime(next, masterStartTzid, collectionTimezone);
122
- if (slot.isoUtc === targetUtc) {
123
- return {
124
- ok: true,
125
- value: {
126
- slot: next.clone(),
127
- slotUtc: slot.isoUtc,
128
- details: event.getOccurrenceDetails(next),
129
- index,
130
- override: overrideBySlot.get(slot.isoUtc) ?? null,
131
- },
132
- };
133
- }
134
- if (slot.instantMs > targetMs) break;
135
- index += 1;
136
- next = iterator.next();
137
- }
138
-
139
- // An override the rule never reaches is still an occurrence somebody can
140
- // see, so it is still one they can edit or drop.
141
- const stranded = overrideBySlot.get(targetUtc);
142
- const strandedSlot = stranded?.getFirstPropertyValue("recurrence-id");
143
- if (stranded && strandedSlot instanceof ICAL.Time) {
144
- return {
145
- ok: true,
146
- value: {
147
- slot: strandedSlot.clone(),
148
- slotUtc: targetUtc,
149
- details: null,
150
- index: -1,
151
- override: stranded,
152
- },
153
- };
154
- }
155
-
156
- return calendarFailure(
157
- "UnknownOccurrence",
158
- `this series has no occurrence at ${recurrenceId}`,
159
- );
160
- };
161
-
162
- const slotUtcOf = (
163
- override: ICAL.Component,
164
- collectionTimezone: string,
165
- ): string => {
166
- const recurrenceId = override.getFirstPropertyValue("recurrence-id");
167
- if (!(recurrenceId instanceof ICAL.Time)) return "";
168
- return resolveTime(recurrenceId, dtStartTzid(override), collectionTimezone)
169
- .isoUtc;
170
- };
171
-
172
- /** Drops the override VEVENTs on one side of a split point. */
173
- const keepOverrides = (
174
- calendar: ParsedCalendar,
175
- collectionTimezone: string,
176
- keep: (slotMs: number) => boolean,
177
- ): void => {
178
- const kept: ICAL.Component[] = [];
179
- for (const override of calendar.overrides) {
180
- const slot = slotUtcOf(override, collectionTimezone);
181
- if (slot !== "" && keep(Date.parse(slot))) {
182
- kept.push(override);
183
- continue;
184
- }
185
- calendar.component.removeSubcomponent(override);
186
- }
187
- calendar.overrides = kept;
188
- };
189
-
190
- /** Drops the values of one repeating date property on a side of a split point. */
191
- const keepDateValues = (
192
- master: ICAL.Component,
193
- name: string,
194
- collectionTimezone: string,
195
- keep: (slotMs: number) => boolean,
196
- ): void => {
197
- for (const property of master.getAllProperties(name)) {
198
- const kept = property.getValues().filter((value: unknown) => {
199
- if (!(value instanceof ICAL.Time)) return true;
200
- return keep(
201
- resolveTime(value, dtStartTzid(master), collectionTimezone).instantMs,
202
- );
203
- });
204
- if (kept.length === 0) {
205
- master.removeProperty(property);
206
- continue;
207
- }
208
- property.setValues(kept);
209
- }
210
- };
211
-
212
- /**
213
- * The last value the truncated series still covers, in the frame its own
214
- * DTSTART is written in.
215
- *
216
- * UNTIL has to be comparable to the values the rule produces, and an expander
217
- * compares them as they are written rather than as this server resolves them.
218
- * A UTC instant against a floating or all-day series is therefore off by the
219
- * collection's offset, and in a zone behind UTC that leaves the split
220
- * occurrence in both halves of the split. So the value is derived from the slot
221
- * itself: a date for a date series, the same wall clock for a floating one, and
222
- * UTC where the slot really is an instant — which is also what RFC 5545 3.3.10
223
- * asks for in each of those cases.
224
- */
225
- const untilBefore = (slot: ICAL.Time): ICAL.Time => {
226
- const until = slot.clone();
227
- if (until.isDate) {
228
- until.adjust(-1, 0, 0, 0);
229
- return until;
230
- }
231
- until.adjust(0, 0, 0, -1);
232
- return until.zone === ICAL.Timezone.localTimezone
233
- ? until
234
- : until.convertToZone(ICAL.Timezone.utcTimezone);
235
- };
236
-
237
- /**
238
- * Ends the master's rule just before an occurrence.
239
- *
240
- * A COUNT rule is truncated by count and an open or UNTIL rule by UNTIL,
241
- * because rewriting one as the other changes what the series means: a rule
242
- * counting ten meetings and a rule running to a date agree today and stop
243
- * agreeing the moment anything is added to or dropped from the series.
244
- */
245
- const truncateRule = (
246
- master: ICAL.Component,
247
- occurrence: FoundOccurrence,
248
- ): void => {
249
- const property = master.getFirstProperty("rrule");
250
- if (!property) return;
251
- const rule = property.getFirstValue();
252
- if (!(rule instanceof ICAL.Recur)) return;
253
-
254
- if (rule.count !== null && occurrence.index >= 0) {
255
- rule.count = occurrence.index;
256
- rule.until = null;
257
- } else {
258
- rule.until = untilBefore(occurrence.slot);
259
- rule.count = null;
260
- }
261
- property.setValue(rule);
262
- };
263
-
264
- /** The rule the remainder of a split series carries. */
265
- const applyRemainderRule = (
266
- master: ICAL.Component,
267
- occurrence: FoundOccurrence,
268
- ): void => {
269
- const property = master.getFirstProperty("rrule");
270
- if (!property) return;
271
- const rule = property.getFirstValue();
272
- if (!(rule instanceof ICAL.Recur)) return;
273
- if (rule.count === null || occurrence.index < 0) return;
274
- rule.count = Math.max(rule.count - occurrence.index, 1);
275
- property.setValue(rule);
276
- };
277
-
278
- const replaceWith = (calendar: ParsedCalendar): ScopedWrite => ({
279
- kind: "Replace",
280
- icalData: serializeCalendar(calendar.component),
281
- });
282
-
283
- const applyToMaster = async (
284
- calendar: ParsedCalendar,
285
- collectionTimezone: string,
286
- patch: Partial<CalendarEventFields>,
287
- ): Promise<CalendarResult<ScopedWrite>> => {
288
- const applied = await applyEventFields(
289
- calendar.master,
290
- patch,
291
- collectionTimezone,
292
- );
293
- if (!applied.ok) return applied;
294
- return { ok: true, value: replaceWith(calendar) };
295
- };
296
-
297
- const touchesTime = (patch: Partial<CalendarEventFields>): boolean =>
298
- patch.start !== undefined ||
299
- patch.end !== undefined ||
300
- patch.allDay !== undefined ||
301
- patch.timeZone !== undefined;
302
-
303
- const civilDay = (time: ICAL.Time): number =>
304
- Date.UTC(time.year, time.month - 1, time.day) / 86_400_000;
305
-
306
- const shiftProperty = (
307
- component: ICAL.Component,
308
- name: string,
309
- days: number,
310
- ): void => {
311
- const property = component.getFirstProperty(name);
312
- const value = property?.getFirstValue();
313
- if (!property || !(value instanceof ICAL.Time)) return;
314
- const shifted = value.clone();
315
- shifted.adjust(days, 0, 0, 0);
316
- property.setValue(shifted);
317
- };
318
-
319
- const shownStart = (occurrence: FoundOccurrence): ICAL.Time | null => {
320
- if (occurrence.details) return occurrence.details.startDate;
321
- const start = occurrence.override?.getFirstPropertyValue("dtstart");
322
- return start instanceof ICAL.Time ? start : null;
323
- };
324
-
325
- const reslot = (
326
- value: ICAL.Time,
327
- before: ICAL.Time,
328
- after: ICAL.Time,
329
- ): ICAL.Time => {
330
- const moved = after.clone();
331
- moved.adjust(civilDay(value) - civilDay(before), 0, 0, 0);
332
- return moved;
333
- };
334
-
335
- interface SeriesShape {
336
- start: ICAL.Time;
337
- tzid: string;
338
- rule: ICAL.Recur | null;
339
- durationSeconds: number;
340
- }
341
-
342
- interface SeriesSlot {
343
- time: ICAL.Time;
344
- instantMs: number;
345
- }
346
-
347
- const seriesShape = (master: ICAL.Component): SeriesShape | null => {
348
- const start = master.getFirstPropertyValue("dtstart");
349
- if (!(start instanceof ICAL.Time)) return null;
350
- const rule = master.getFirstPropertyValue("rrule");
351
- return {
352
- start: start.clone(),
353
- tzid: dtStartTzid(master),
354
- rule: rule instanceof ICAL.Recur ? rule.clone() : null,
355
- durationSeconds: new ICAL.Event(master).duration.toSeconds(),
356
- };
357
- };
358
-
359
- const ruleSlots = (
360
- shape: SeriesShape,
361
- collectionTimezone: string,
362
- stop: (instantMs: number, count: number) => boolean,
363
- ): SeriesSlot[] => {
364
- if (!shape.rule) return [];
365
- const slots: SeriesSlot[] = [];
366
- const iterator = shape.rule.iterator(shape.start);
367
- let next: ICAL.Time | null = iterator.next();
368
- while (next && slots.length < CALENDAR_WINDOW_MAX_STEPS) {
369
- const instantMs = resolveTime(
370
- next,
371
- shape.tzid,
372
- collectionTimezone,
373
- ).instantMs;
374
- if (stop(instantMs, slots.length)) break;
375
- slots.push({ time: next.clone(), instantMs });
376
- next = iterator.next();
377
- }
378
- return slots;
379
- };
380
-
381
- const WEEKDAYS: readonly string[] = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
382
-
383
- const WORKWEEK: readonly number[] = [1, 2, 3, 4, 5];
384
-
385
- const BY_WEEKDAY = /^([+-]?\d{1,2})?(SU|MO|TU|WE|TH|FR|SA)$/;
386
-
387
- interface ByWeekday {
388
- ordinal: number;
389
- weekday: number;
390
- }
391
-
392
- interface FollowedRule {
393
- rule: ICAL.Recur;
394
- startShift: number;
395
- }
396
-
397
- const readByWeekday = (value: string): ByWeekday | null => {
398
- const match = BY_WEEKDAY.exec(value);
399
- if (!match) return null;
400
- return {
401
- ordinal: Number(match[1] ?? 0),
402
- weekday: WEEKDAYS.indexOf(match[2] ?? ""),
403
- };
404
- };
405
-
406
- const weekdayOf = (time: ICAL.Time): number => time.dayOfWeek() - 1;
407
-
408
- const weekdayOfDate = (year: number, month: number, day: number): number =>
409
- new Date(Date.UTC(year, month - 1, day)).getUTCDay();
410
-
411
- const ordinalOf = (
412
- position: number,
413
- length: number,
414
- original: number,
415
- ): number => {
416
- const fromStart = Math.floor((position - 1) / 7) + 1;
417
- const fromLast = -(Math.floor((length - position) / 7) + 1);
418
- const preferred = original < 0 ? fromLast : fromStart;
419
- if (Math.abs(preferred) < 5 || Math.abs(original) >= 5) return preferred;
420
- return original < 0 ? fromStart : fromLast;
421
- };
422
-
423
- const nthWeekday = (
424
- year: number,
425
- month: number,
426
- weekday: number,
427
- ordinal: number,
428
- ): number | null => {
429
- const length = ICAL.Time.daysInMonth(month, year);
430
- if (ordinal > 0) {
431
- const first = 1 + ((weekday - weekdayOfDate(year, month, 1) + 7) % 7);
432
- const day = first + 7 * (ordinal - 1);
433
- return day <= length ? day : null;
434
- }
435
- const last =
436
- length - ((weekdayOfDate(year, month, length) - weekday + 7) % 7);
437
- const day = last + 7 * (ordinal + 1);
438
- return day >= 1 ? day : null;
439
- };
440
-
441
- const firstNumberedSlot = (
442
- from: ICAL.Time,
443
- month: number,
444
- yearly: boolean,
445
- weekday: number,
446
- ordinal: number,
447
- ): ICAL.Time | null => {
448
- for (let step = 0; step < 48; step += 1) {
449
- const monthIndex = yearly ? month - 1 : from.month - 1 + step;
450
- const year = from.year + (yearly ? step : Math.floor(monthIndex / 12));
451
- const candidateMonth = (monthIndex % 12) + 1;
452
- const day = nthWeekday(year, candidateMonth, weekday, ordinal);
453
- if (day !== null) {
454
- const slot = from.clone();
455
- slot.adjust(
456
- civilDay(ICAL.Time.fromData({ year, month: candidateMonth, day })) -
457
- civilDay(from),
458
- 0,
459
- 0,
460
- 0,
461
- );
462
- return slot;
463
- }
464
- }
465
- return null;
466
- };
467
-
468
- const unmovable = <T>(after: ICAL.Time, reason: string): CalendarResult<T> =>
469
- calendarFailure(
470
- "UnmovableRecurrenceRule",
471
- `This series repeats ${reason}, so it cannot move to ${after.toString().slice(0, 10)}. Pick a new repeat together with the new date, or move only this event.`,
472
- );
473
-
474
- const followStart = (
475
- rule: ICAL.Recur,
476
- seriesStart: ICAL.Time,
477
- newStart: ICAL.Time,
478
- anchor: ICAL.Time,
479
- ): CalendarResult<FollowedRule> => {
480
- const days = civilDay(newStart) - civilDay(seriesStart);
481
- if (days === 0) return { ok: true, value: { rule, startShift: 0 } };
482
- const anchorBefore = anchor.clone();
483
- anchorBefore.adjust(-days, 0, 0, 0);
484
- const moved = rule.clone();
485
- const parts = moved.parts;
486
- const kept = (startShift: number): CalendarResult<FollowedRule> => ({
487
- ok: true,
488
- value: { rule: moved, startShift },
489
- });
490
-
491
- const pinned = (["BYSETPOS", "BYYEARDAY", "BYWEEKNO"] as const).some(
492
- (name) => (parts[name]?.length ?? 0) > 0,
493
- );
494
- if (pinned) return unmovable(anchor, "on a pattern of positions");
495
-
496
- const byMonthDay = parts.BYMONTHDAY ?? [];
497
- if (byMonthDay.length > 0) {
498
- if (byMonthDay.length !== 1 || byMonthDay[0] !== anchorBefore.day) {
499
- return unmovable(anchor, "on several days of the month");
500
- }
501
- parts.BYMONTHDAY = [anchor.day];
502
- }
503
-
504
- const byMonth = parts.BYMONTH ?? [];
505
- if (byMonth.length > 0 && anchor.month !== anchorBefore.month) {
506
- if (byMonth.length !== 1 || byMonth[0] !== anchorBefore.month) {
507
- return unmovable(anchor, "in several months");
508
- }
509
- parts.BYMONTH = [anchor.month];
510
- }
511
-
512
- const byDay = parts.BYDAY ?? [];
513
- const weekdays = byDay
514
- .map(readByWeekday)
515
- .filter((weekday): weekday is ByWeekday => weekday !== null);
516
- if (weekdays.length !== byDay.length) {
517
- return unmovable(anchor, "on days the calendar cannot read");
518
- }
519
- if (weekdays.length === 0) return kept(0);
520
-
521
- const numbered = weekdays.find((weekday) => weekday.ordinal !== 0);
522
- if (numbered) {
523
- if (weekdays.length !== 1) {
524
- return unmovable(anchor, "on more than one numbered weekday");
525
- }
526
- const yearly = moved.freq === "YEARLY";
527
- if (yearly && (parts.BYMONTH?.length ?? 0) === 0) {
528
- return unmovable(anchor, "on a numbered weekday of the year");
529
- }
530
- const ordinal = ordinalOf(
531
- anchor.day,
532
- ICAL.Time.daysInMonth(anchor.month, anchor.year),
533
- numbered.ordinal,
534
- );
535
- const weekday = weekdayOf(anchor);
536
- parts.BYDAY = [`${ordinal}${WEEKDAYS[weekday]}`];
537
- const first = firstNumberedSlot(
538
- seriesStart,
539
- anchor.month,
540
- yearly,
541
- weekday,
542
- ordinal,
543
- );
544
- if (!first) return unmovable(anchor, "on a weekday that never comes");
545
- return kept(civilDay(first) - civilDay(newStart));
546
- }
547
-
548
- const set = weekdays.map((weekday) => weekday.weekday);
549
- const workweek =
550
- set.length === WORKWEEK.length &&
551
- WORKWEEK.every((weekday) => set.includes(weekday));
552
- if (moved.freq !== "WEEKLY" || workweek) {
553
- if (!set.includes(weekdayOf(anchor))) {
554
- return unmovable(anchor, "on a set of weekdays without that day");
555
- }
556
- return kept(-days);
557
- }
558
-
559
- const shift = (((weekdayOf(newStart) - weekdayOf(seriesStart)) % 7) + 7) % 7;
560
- if (moved.interval > 1 && set.length > 1) {
561
- const weekStart = moved.wkst - 1;
562
- const wraps = set.map(
563
- (weekday) => ((weekday - weekStart + 7) % 7) + shift >= 7,
564
- );
565
- if (wraps.some((wrap) => wrap !== wraps[0])) {
566
- return unmovable(anchor, "on several days every few weeks");
567
- }
568
- }
569
- parts.BYDAY = set.map((weekday) => WEEKDAYS[(weekday + shift) % 7] ?? "");
570
- return kept(0);
571
- };
572
-
573
- const shiftStart = (master: ICAL.Component, days: number): void => {
574
- if (days === 0) return;
575
- shiftProperty(master, "dtstart", days);
576
- shiftProperty(master, "dtend", days);
577
- };
578
-
579
- const followRule = (
580
- master: ICAL.Component,
581
- before: SeriesShape,
582
- daysIn: number,
583
- ): CalendarResult<null> => {
584
- const property = master.getFirstProperty("rrule");
585
- const rule = property?.getFirstValue();
586
- const start = master.getFirstPropertyValue("dtstart");
587
- if (!property || !(rule instanceof ICAL.Recur)) {
588
- return { ok: true, value: null };
589
- }
590
- if (!(start instanceof ICAL.Time)) return { ok: true, value: null };
591
- const anchor = start.clone();
592
- anchor.adjust(daysIn, 0, 0, 0);
593
- const followed = followStart(rule, before.start, start, anchor);
594
- if (!followed.ok) return followed;
595
- property.setValue(followed.value.rule);
596
- shiftStart(master, followed.value.startShift);
597
- return { ok: true, value: null };
598
- };
599
-
600
- const untilAt = (slot: SeriesSlot, shape: SeriesShape): ICAL.Time => {
601
- const floating =
602
- shape.tzid === "" && slot.time.zone !== ICAL.Timezone.utcTimezone;
603
- if (slot.time.isDate || floating) return slot.time.clone();
604
- return ICAL.Time.fromJSDate(new Date(slot.instantMs), true);
605
- };
606
-
607
- const followUntil = (
608
- master: ICAL.Component,
609
- before: SeriesShape,
610
- collectionTimezone: string,
611
- ): void => {
612
- if (!before.rule || before.rule.until === null) return;
613
- const property = master.getFirstProperty("rrule");
614
- const rule = property?.getFirstValue();
615
- const now = seriesShape(master);
616
- if (!property || !(rule instanceof ICAL.Recur) || !now) return;
617
- if (rule.until === null) return;
618
-
619
- const previous = ruleSlots(before, collectionTimezone, () => false);
620
- if (previous.length === 0 || previous.length >= CALENDAR_WINDOW_MAX_STEPS) {
621
- return;
622
- }
623
- const open = rule.clone();
624
- open.until = null;
625
- const next = ruleSlots(
626
- { ...now, rule: open },
627
- collectionTimezone,
628
- (_instantMs, count) => count >= previous.length,
629
- );
630
- const last = next[previous.length - 1];
631
- if (!last) return;
632
- rule.until = untilAt(last, now);
633
- property.setValue(rule);
634
- };
635
-
636
- const replaceTime = (
637
- component: ICAL.Component,
638
- name: string,
639
- time: ICAL.Time,
640
- tzid: string,
641
- ): void => {
642
- component.removeAllProperties(name);
643
- component.addProperty(timeProperty(name, time, tzid));
644
- };
645
-
646
- const followSlots = (
647
- calendar: ParsedCalendar,
648
- collectionTimezone: string,
649
- before: SeriesShape,
650
- after: SeriesShape,
651
- ): void => {
652
- const instantOf = (value: ICAL.Time, tzid: string): number =>
653
- resolveTime(value, tzid, collectionTimezone).instantMs;
654
-
655
- const exceptionInstants: number[] = [];
656
- for (const override of calendar.overrides) {
657
- const property = override.getFirstProperty("recurrence-id");
658
- const value = property?.getFirstValue();
659
- if (value instanceof ICAL.Time) {
660
- exceptionInstants.push(instantOf(value, tzidOf(property)));
661
- }
662
- }
663
- for (const property of calendar.master.getAllProperties("exdate")) {
664
- for (const value of property.getValues()) {
665
- if (value instanceof ICAL.Time) {
666
- exceptionInstants.push(instantOf(value, tzidOf(property)));
667
- }
668
- }
669
- }
670
- const latest = Math.max(Number.NEGATIVE_INFINITY, ...exceptionInstants);
671
-
672
- const previous = ruleSlots(
673
- before,
674
- collectionTimezone,
675
- (instantMs) => instantMs > latest,
676
- );
677
- const indexByInstant = new Map(
678
- previous.map((slot, index) => [slot.instantMs, index]),
679
- );
680
- const next = ruleSlots(
681
- after,
682
- collectionTimezone,
683
- (_instantMs, count) => count >= previous.length,
684
- );
685
- const moved = (value: ICAL.Time, tzid: string): ICAL.Time => {
686
- const index = indexByInstant.get(instantOf(value, tzid));
687
- const slot = index === undefined ? undefined : next[index];
688
- return slot ? slot.time.clone() : reslot(value, before.start, after.start);
689
- };
690
-
691
- for (const override of calendar.overrides) {
692
- const property = override.getFirstProperty("recurrence-id");
693
- const recurrenceId = property?.getFirstValue();
694
- if (!(recurrenceId instanceof ICAL.Time)) continue;
695
- const slot = moved(recurrenceId, tzidOf(property));
696
- const start = override.getFirstPropertyValue("dtstart");
697
- const untouched =
698
- start instanceof ICAL.Time &&
699
- instantOf(start, dtStartTzid(override)) ===
700
- instantOf(recurrenceId, tzidOf(property)) &&
701
- new ICAL.Event(override).duration.toSeconds() === before.durationSeconds;
702
- replaceTime(override, "recurrence-id", slot, after.tzid);
703
- if (!untouched) continue;
704
- const end = slot.clone();
705
- end.addDuration(ICAL.Duration.fromSeconds(after.durationSeconds));
706
- override.removeAllProperties("duration");
707
- replaceTime(override, "dtstart", slot, after.tzid);
708
- replaceTime(override, "dtend", end, after.tzid);
709
- }
710
-
711
- for (const name of ["exdate", "rdate"]) {
712
- for (const property of calendar.master.getAllProperties(name)) {
713
- const values = property.getValues();
714
- if (!values.every((value: unknown) => value instanceof ICAL.Time)) {
715
- continue;
716
- }
717
- const tzid = tzidOf(property);
718
- calendar.master.removeProperty(property);
719
- for (const value of values as ICAL.Time[]) {
720
- const target =
721
- name === "exdate"
722
- ? moved(value, tzid)
723
- : reslot(value, before.start, after.start);
724
- calendar.master.addProperty(timeProperty(name, target, after.tzid));
725
- }
726
- }
727
- }
728
- };
729
-
730
- const occurrenceOffset = (
731
- calendar: ParsedCalendar,
732
- collectionTimezone: string,
733
- recurrenceId: string,
734
- seriesStart: ICAL.Time,
735
- ): CalendarResult<number> => {
736
- if (recurrenceId === "") return { ok: true, value: 0 };
737
- const found = findOccurrence(calendar, collectionTimezone, recurrenceId);
738
- if (!found.ok) return found;
739
- const shown = shownStart(found.value);
740
- if (!shown) {
741
- return calendarFailure(
742
- "UnknownOccurrence",
743
- `${recurrenceId} has no start to move the series from`,
744
- );
745
- }
746
- return { ok: true, value: civilDay(shown) - civilDay(seriesStart) };
747
- };
748
-
749
- const applyToSeries = async (
750
- calendar: ParsedCalendar,
751
- collectionTimezone: string,
752
- recurrenceId: string,
753
- patch: Partial<CalendarEventFields>,
754
- ): Promise<CalendarResult<ScopedWrite>> => {
755
- const before = seriesShape(calendar.master);
756
- if (!before) {
757
- return calendarFailure(
758
- "UnknownOccurrence",
759
- "this series has no start to move",
760
- );
761
- }
762
- const daysIn = occurrenceOffset(
763
- calendar,
764
- collectionTimezone,
765
- recurrenceId,
766
- before.start,
767
- );
768
- if (!daysIn.ok) return daysIn;
769
-
770
- const applied = await applyEventFields(
771
- calendar.master,
772
- patch,
773
- collectionTimezone,
774
- );
775
- if (!applied.ok) return applied;
776
- if (patch.start !== undefined) {
777
- shiftProperty(calendar.master, "dtstart", -daysIn.value);
778
- }
779
- if (patch.end !== undefined) {
780
- shiftProperty(calendar.master, "dtend", -daysIn.value);
781
- }
782
- if (patch.recurrenceRule === undefined) {
783
- const followed = followRule(calendar.master, before, daysIn.value);
784
- if (!followed.ok) return followed;
785
- followUntil(calendar.master, before, collectionTimezone);
786
- }
787
-
788
- const after = seriesShape(calendar.master);
789
- if (after) followSlots(calendar, collectionTimezone, before, after);
790
- return { ok: true, value: replaceWith(calendar) };
791
- };
792
-
793
- /**
794
- * The occurrence a `This` or `Following` write names, or `null` when the scope
795
- * collapses to the whole series — which is what "everything from the first
796
- * occurrence on" means.
797
- */
798
- const anchorOf = (
799
- calendar: ParsedCalendar,
800
- collectionTimezone: string,
801
- input: ScopedWriteInput,
802
- ): CalendarResult<FoundOccurrence | null> => {
803
- if (!hasRecurrence(calendar)) {
804
- return calendarFailure(
805
- "NotRecurring",
806
- "this event happens once, so there is no occurrence to single out — use scope=All",
807
- );
808
- }
809
- if (input.recurrenceId === "") {
810
- return calendarFailure(
811
- "MissingRecurrenceId",
812
- `scope=${input.scope} needs the recurrenceId of the occurrence it applies to`,
813
- );
814
- }
815
-
816
- const found = findOccurrence(
817
- calendar,
818
- collectionTimezone,
819
- input.recurrenceId,
820
- );
821
- if (!found.ok) return found;
822
- if (input.scope !== RecurrenceScope.Following) {
823
- return { ok: true, value: found.value };
824
- }
825
- if (found.value.index === 0) return { ok: true, value: null };
826
- if (found.value.index < 0) {
827
- return calendarFailure(
828
- "UnknownOccurrence",
829
- `${input.recurrenceId} is a moved instance rather than a point in the rule, so there is nothing to split there`,
830
- );
831
- }
832
- return { ok: true, value: found.value };
833
- };
834
-
835
- /**
836
- * The override VEVENT for one occurrence, built from the master when the
837
- * resource does not already carry one.
838
- */
839
- const overrideFor = async (
840
- calendar: ParsedCalendar,
841
- occurrence: FoundOccurrence,
842
- ): Promise<CalendarResult<ICAL.Component>> => {
843
- if (occurrence.override) return { ok: true, value: occurrence.override };
844
-
845
- const clone = await cloneCalendar(calendar);
846
- if (!clone.ok) return clone;
847
- const override = clone.value.master;
848
- for (const name of ["rrule", "rdate", "exdate"]) {
849
- override.removeAllProperties(name);
850
- }
851
-
852
- const tzid = dtStartTzid(calendar.master);
853
- override.addProperty(timeProperty("recurrence-id", occurrence.slot, tzid));
854
- if (occurrence.details) {
855
- setTimeValue(override, "dtstart", occurrence.details.startDate, tzid);
856
- if (override.hasProperty("dtend")) {
857
- setTimeValue(override, "dtend", occurrence.details.endDate, tzid);
858
- }
859
- }
860
-
861
- calendar.component.addSubcomponent(override);
862
- calendar.overrides.push(override);
863
- return { ok: true, value: override };
864
- };
865
-
866
- /**
867
- * Turns an edit of one drawing of a series into the resource writes it means.
868
- *
869
- * `All` rewrites the master, moved from the occurrence it names when it names
870
- * one. `This` writes a RECURRENCE-ID override, which is
871
- * the only thing iCalendar has for "this one is different". `Following` splits,
872
- * because a rule cannot change halfway through.
873
- */
874
- export const applyScopedUpdate = async (
875
- calendar: ParsedCalendar,
876
- collectionTimezone: string,
877
- input: ScopedWriteInput,
878
- patch: Partial<CalendarEventFields>,
879
- ): Promise<CalendarResult<ScopedWrite>> => {
880
- if (input.scope === RecurrenceScope.All) {
881
- if (!hasRecurrence(calendar) || !touchesTime(patch)) {
882
- return applyToMaster(calendar, collectionTimezone, patch);
883
- }
884
- return applyToSeries(
885
- calendar,
886
- collectionTimezone,
887
- input.recurrenceId,
888
- patch,
889
- );
890
- }
891
-
892
- const anchored = anchorOf(calendar, collectionTimezone, input);
893
- if (!anchored.ok) return anchored;
894
- if (anchored.value === null) {
895
- return applyToMaster(calendar, collectionTimezone, patch);
896
- }
897
- const occurrence = anchored.value;
898
-
899
- if (input.scope === RecurrenceScope.This) {
900
- const override = await overrideFor(calendar, occurrence);
901
- if (!override.ok) return override;
902
- // A rule belongs to a series; an override is one occurrence of it and
903
- // carries no rule of its own.
904
- const { recurrenceRule: _seriesOnly, ...occurrencePatch } = patch;
905
- const applied = await applyEventFields(
906
- override.value,
907
- occurrencePatch,
908
- collectionTimezone,
909
- );
910
- if (!applied.ok) return applied;
911
- return { ok: true, value: replaceWith(calendar) };
912
- }
913
-
914
- const splitMs = Date.parse(occurrence.slotUtc);
915
- const tail = await cloneCalendar(calendar);
916
- if (!tail.ok) return tail;
917
-
918
- const before = (slotMs: number) => slotMs < splitMs;
919
- const fromHere = (slotMs: number) => slotMs >= splitMs;
920
-
921
- truncateRule(calendar.master, occurrence);
922
- keepOverrides(calendar, collectionTimezone, before);
923
- keepDateValues(calendar.master, "rdate", collectionTimezone, before);
924
- keepDateValues(calendar.master, "exdate", collectionTimezone, before);
925
-
926
- applyRemainderRule(tail.value.master, occurrence);
927
- keepOverrides(tail.value, collectionTimezone, fromHere);
928
- keepDateValues(tail.value.master, "rdate", collectionTimezone, fromHere);
929
- keepDateValues(tail.value.master, "exdate", collectionTimezone, fromHere);
930
-
931
- const tzid = dtStartTzid(tail.value.master);
932
- const tailEnd = occurrence.slot.clone();
933
- tailEnd.addDuration(new ICAL.Event(tail.value.master).duration);
934
- setTimeValue(tail.value.master, "dtstart", occurrence.slot, tzid);
935
- if (tail.value.master.hasProperty("dtend")) {
936
- setTimeValue(tail.value.master, "dtend", tailEnd, tzid);
937
- }
938
- for (const component of [tail.value.master, ...tail.value.overrides]) {
939
- component.removeAllProperties("uid");
940
- component.addPropertyWithValue("uid", input.followingUid);
941
- }
942
-
943
- const applied = await applyEventFields(
944
- tail.value.master,
945
- patch,
946
- collectionTimezone,
947
- );
948
- if (!applied.ok) return applied;
949
-
950
- return {
951
- ok: true,
952
- value: {
953
- kind: "Split",
954
- icalData: serializeCalendar(calendar.component),
955
- following: serializeCalendar(tail.value.component),
956
- },
957
- };
958
- };
959
-
960
- /**
961
- * Turns a delete of one drawing of a series into the resource writes it means.
962
- *
963
- * `This` is an EXDATE and `Following` truncates the rule; neither removes the
964
- * resource, because the rest of the series is still somebody's calendar. `All`
965
- * removes it.
966
- */
967
- export const applyScopedDelete = async (
968
- calendar: ParsedCalendar,
969
- collectionTimezone: string,
970
- input: ScopedWriteInput,
971
- ): Promise<CalendarResult<ScopedWrite>> => {
972
- if (input.scope === RecurrenceScope.All) {
973
- return { ok: true, value: { kind: "Delete" } };
974
- }
975
-
976
- const anchored = anchorOf(calendar, collectionTimezone, input);
977
- if (!anchored.ok) return anchored;
978
- if (anchored.value === null) {
979
- return { ok: true, value: { kind: "Delete" } };
980
- }
981
- const occurrence = anchored.value;
982
- const splitMs = Date.parse(occurrence.slotUtc);
983
-
984
- if (input.scope === RecurrenceScope.Following) {
985
- const before = (slotMs: number) => slotMs < splitMs;
986
- truncateRule(calendar.master, occurrence);
987
- keepOverrides(calendar, collectionTimezone, before);
988
- keepDateValues(calendar.master, "rdate", collectionTimezone, before);
989
- keepDateValues(calendar.master, "exdate", collectionTimezone, before);
990
- return { ok: true, value: replaceWith(calendar) };
991
- }
992
-
993
- if (occurrence.override) {
994
- calendar.component.removeSubcomponent(occurrence.override);
995
- calendar.overrides = calendar.overrides.filter(
996
- (override) => override !== occurrence.override,
997
- );
998
- }
999
- calendar.master.addProperty(
1000
- timeProperty("exdate", occurrence.slot, dtStartTzid(calendar.master)),
1001
- );
1002
- return { ok: true, value: replaceWith(calendar) };
1003
- };