@remit/calendar-service 0.0.1 → 0.0.2

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/src/scope.ts ADDED
@@ -0,0 +1,490 @@
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 } 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; `""` outside `This`/`Following`. */
33
+ recurrenceId: string;
34
+ /** UID the resource a `Following` split creates is written under. */
35
+ followingUid: string;
36
+ }
37
+
38
+ interface FoundOccurrence {
39
+ /** The rule slot, in whatever form the master's DTSTART is written in. */
40
+ slot: ICAL.Time;
41
+ slotUtc: string;
42
+ /** The occurrence as the series produces it; absent when only an override names the slot. */
43
+ details: ReturnType<ICAL.Event["getOccurrenceDetails"]> | null;
44
+ /** Position in the rule's own sequence, `-1` when the rule never reaches it. */
45
+ index: number;
46
+ override: ICAL.Component | null;
47
+ }
48
+
49
+ /** A deep copy of a resource, taken the one way that cannot share structure. */
50
+ const cloneCalendar = (
51
+ calendar: ParsedCalendar,
52
+ ): Promise<CalendarResult<ParsedCalendar>> =>
53
+ parseCalendar(serializeCalendar(calendar.component));
54
+
55
+ const timeProperty = (
56
+ name: string,
57
+ time: ICAL.Time,
58
+ tzid: string,
59
+ ): ICAL.Property => {
60
+ const property = new ICAL.Property(name);
61
+ property.setValue(time);
62
+ if (!time.isDate && tzid !== "") property.setParameter("tzid", tzid);
63
+ return property;
64
+ };
65
+
66
+ const setTimeValue = (
67
+ component: ICAL.Component,
68
+ name: string,
69
+ time: ICAL.Time,
70
+ tzid: string,
71
+ ): void => {
72
+ const existing = component.getFirstProperty(name);
73
+ if (existing) {
74
+ existing.setValue(time);
75
+ return;
76
+ }
77
+ component.addProperty(timeProperty(name, time, tzid));
78
+ };
79
+
80
+ /**
81
+ * The occurrence a scoped write is anchored at.
82
+ *
83
+ * Walks the series rather than trusting the caller's instant: a RECURRENCE-ID
84
+ * naming no occurrence is the shape of a stale client acting on a series
85
+ * somebody has since edited, and writing an override or an EXDATE for a slot
86
+ * the rule never produces leaves a resource nothing can reconcile afterwards.
87
+ */
88
+ export const findOccurrence = (
89
+ calendar: ParsedCalendar,
90
+ collectionTimezone: string,
91
+ recurrenceId: string,
92
+ ): CalendarResult<FoundOccurrence> => {
93
+ const targetMs = Date.parse(recurrenceId);
94
+ if (Number.isNaN(targetMs)) {
95
+ return calendarFailure(
96
+ "InvalidDateTime",
97
+ `"${recurrenceId}" is not a RECURRENCE-ID this server can read`,
98
+ );
99
+ }
100
+ const targetUtc = toUtcIso(targetMs);
101
+
102
+ const overrideBySlot = overridesBySlot(calendar, collectionTimezone);
103
+ const masterStartTzid = dtStartTzid(calendar.master);
104
+ const event = new ICAL.Event(calendar.master);
105
+ for (const override of calendar.overrides) {
106
+ event.relateException(override);
107
+ }
108
+
109
+ const iterator = event.iterator();
110
+ let next = iterator.next();
111
+ let index = 0;
112
+ while (next && index < CALENDAR_WINDOW_MAX_STEPS) {
113
+ const slot = resolveTime(next, masterStartTzid, collectionTimezone);
114
+ if (slot.isoUtc === targetUtc) {
115
+ return {
116
+ ok: true,
117
+ value: {
118
+ slot: next.clone(),
119
+ slotUtc: slot.isoUtc,
120
+ details: event.getOccurrenceDetails(next),
121
+ index,
122
+ override: overrideBySlot.get(slot.isoUtc) ?? null,
123
+ },
124
+ };
125
+ }
126
+ if (slot.instantMs > targetMs) break;
127
+ index += 1;
128
+ next = iterator.next();
129
+ }
130
+
131
+ // An override the rule never reaches is still an occurrence somebody can
132
+ // see, so it is still one they can edit or drop.
133
+ const stranded = overrideBySlot.get(targetUtc);
134
+ const strandedSlot = stranded?.getFirstPropertyValue("recurrence-id");
135
+ if (stranded && strandedSlot instanceof ICAL.Time) {
136
+ return {
137
+ ok: true,
138
+ value: {
139
+ slot: strandedSlot.clone(),
140
+ slotUtc: targetUtc,
141
+ details: null,
142
+ index: -1,
143
+ override: stranded,
144
+ },
145
+ };
146
+ }
147
+
148
+ return calendarFailure(
149
+ "UnknownOccurrence",
150
+ `this series has no occurrence at ${recurrenceId}`,
151
+ );
152
+ };
153
+
154
+ const slotUtcOf = (
155
+ override: ICAL.Component,
156
+ collectionTimezone: string,
157
+ ): string => {
158
+ const recurrenceId = override.getFirstPropertyValue("recurrence-id");
159
+ if (!(recurrenceId instanceof ICAL.Time)) return "";
160
+ return resolveTime(recurrenceId, dtStartTzid(override), collectionTimezone)
161
+ .isoUtc;
162
+ };
163
+
164
+ /** Drops the override VEVENTs on one side of a split point. */
165
+ const keepOverrides = (
166
+ calendar: ParsedCalendar,
167
+ collectionTimezone: string,
168
+ keep: (slotMs: number) => boolean,
169
+ ): void => {
170
+ const kept: ICAL.Component[] = [];
171
+ for (const override of calendar.overrides) {
172
+ const slot = slotUtcOf(override, collectionTimezone);
173
+ if (slot !== "" && keep(Date.parse(slot))) {
174
+ kept.push(override);
175
+ continue;
176
+ }
177
+ calendar.component.removeSubcomponent(override);
178
+ }
179
+ calendar.overrides = kept;
180
+ };
181
+
182
+ /** Drops the values of one repeating date property on a side of a split point. */
183
+ const keepDateValues = (
184
+ master: ICAL.Component,
185
+ name: string,
186
+ collectionTimezone: string,
187
+ keep: (slotMs: number) => boolean,
188
+ ): void => {
189
+ for (const property of master.getAllProperties(name)) {
190
+ const kept = property.getValues().filter((value: unknown) => {
191
+ if (!(value instanceof ICAL.Time)) return true;
192
+ return keep(
193
+ resolveTime(value, dtStartTzid(master), collectionTimezone).instantMs,
194
+ );
195
+ });
196
+ if (kept.length === 0) {
197
+ master.removeProperty(property);
198
+ continue;
199
+ }
200
+ property.setValues(kept);
201
+ }
202
+ };
203
+
204
+ /**
205
+ * The last value the truncated series still covers, in the frame its own
206
+ * DTSTART is written in.
207
+ *
208
+ * UNTIL has to be comparable to the values the rule produces, and an expander
209
+ * compares them as they are written rather than as this server resolves them.
210
+ * A UTC instant against a floating or all-day series is therefore off by the
211
+ * collection's offset, and in a zone behind UTC that leaves the split
212
+ * occurrence in both halves of the split. So the value is derived from the slot
213
+ * itself: a date for a date series, the same wall clock for a floating one, and
214
+ * UTC where the slot really is an instant — which is also what RFC 5545 3.3.10
215
+ * asks for in each of those cases.
216
+ */
217
+ const untilBefore = (slot: ICAL.Time): ICAL.Time => {
218
+ const until = slot.clone();
219
+ if (until.isDate) {
220
+ until.adjust(-1, 0, 0, 0);
221
+ return until;
222
+ }
223
+ until.adjust(0, 0, 0, -1);
224
+ return until.zone === ICAL.Timezone.localTimezone
225
+ ? until
226
+ : until.convertToZone(ICAL.Timezone.utcTimezone);
227
+ };
228
+
229
+ /**
230
+ * Ends the master's rule just before an occurrence.
231
+ *
232
+ * A COUNT rule is truncated by count and an open or UNTIL rule by UNTIL,
233
+ * because rewriting one as the other changes what the series means: a rule
234
+ * counting ten meetings and a rule running to a date agree today and stop
235
+ * agreeing the moment anything is added to or dropped from the series.
236
+ */
237
+ const truncateRule = (
238
+ master: ICAL.Component,
239
+ occurrence: FoundOccurrence,
240
+ ): void => {
241
+ const property = master.getFirstProperty("rrule");
242
+ if (!property) return;
243
+ const rule = property.getFirstValue();
244
+ if (!(rule instanceof ICAL.Recur)) return;
245
+
246
+ if (rule.count !== null && occurrence.index >= 0) {
247
+ rule.count = occurrence.index;
248
+ rule.until = null;
249
+ } else {
250
+ rule.until = untilBefore(occurrence.slot);
251
+ rule.count = null;
252
+ }
253
+ property.setValue(rule);
254
+ };
255
+
256
+ /** The rule the remainder of a split series carries. */
257
+ const applyRemainderRule = (
258
+ master: ICAL.Component,
259
+ occurrence: FoundOccurrence,
260
+ ): void => {
261
+ const property = master.getFirstProperty("rrule");
262
+ if (!property) return;
263
+ const rule = property.getFirstValue();
264
+ if (!(rule instanceof ICAL.Recur)) return;
265
+ if (rule.count === null || occurrence.index < 0) return;
266
+ rule.count = Math.max(rule.count - occurrence.index, 1);
267
+ property.setValue(rule);
268
+ };
269
+
270
+ const replaceWith = (calendar: ParsedCalendar): ScopedWrite => ({
271
+ kind: "Replace",
272
+ icalData: serializeCalendar(calendar.component),
273
+ });
274
+
275
+ const applyToMaster = async (
276
+ calendar: ParsedCalendar,
277
+ collectionTimezone: string,
278
+ patch: Partial<CalendarEventFields>,
279
+ ): Promise<CalendarResult<ScopedWrite>> => {
280
+ const applied = await applyEventFields(
281
+ calendar.master,
282
+ patch,
283
+ collectionTimezone,
284
+ );
285
+ if (!applied.ok) return applied;
286
+ return { ok: true, value: replaceWith(calendar) };
287
+ };
288
+
289
+ /**
290
+ * The occurrence a `This` or `Following` write names, or `null` when the scope
291
+ * collapses to the whole series — which is what "everything from the first
292
+ * occurrence on" means.
293
+ */
294
+ const anchorOf = (
295
+ calendar: ParsedCalendar,
296
+ collectionTimezone: string,
297
+ input: ScopedWriteInput,
298
+ ): CalendarResult<FoundOccurrence | null> => {
299
+ if (!hasRecurrence(calendar)) {
300
+ return calendarFailure(
301
+ "NotRecurring",
302
+ "this event happens once, so there is no occurrence to single out — use scope=All",
303
+ );
304
+ }
305
+ if (input.recurrenceId === "") {
306
+ return calendarFailure(
307
+ "MissingRecurrenceId",
308
+ `scope=${input.scope} needs the recurrenceId of the occurrence it applies to`,
309
+ );
310
+ }
311
+
312
+ const found = findOccurrence(
313
+ calendar,
314
+ collectionTimezone,
315
+ input.recurrenceId,
316
+ );
317
+ if (!found.ok) return found;
318
+ if (input.scope !== RecurrenceScope.Following) {
319
+ return { ok: true, value: found.value };
320
+ }
321
+ if (found.value.index === 0) return { ok: true, value: null };
322
+ if (found.value.index < 0) {
323
+ return calendarFailure(
324
+ "UnknownOccurrence",
325
+ `${input.recurrenceId} is a moved instance rather than a point in the rule, so there is nothing to split there`,
326
+ );
327
+ }
328
+ return { ok: true, value: found.value };
329
+ };
330
+
331
+ /**
332
+ * The override VEVENT for one occurrence, built from the master when the
333
+ * resource does not already carry one.
334
+ */
335
+ const overrideFor = async (
336
+ calendar: ParsedCalendar,
337
+ occurrence: FoundOccurrence,
338
+ ): Promise<CalendarResult<ICAL.Component>> => {
339
+ if (occurrence.override) return { ok: true, value: occurrence.override };
340
+
341
+ const clone = await cloneCalendar(calendar);
342
+ if (!clone.ok) return clone;
343
+ const override = clone.value.master;
344
+ for (const name of ["rrule", "rdate", "exdate"]) {
345
+ override.removeAllProperties(name);
346
+ }
347
+
348
+ const tzid = dtStartTzid(calendar.master);
349
+ override.addProperty(timeProperty("recurrence-id", occurrence.slot, tzid));
350
+ if (occurrence.details) {
351
+ setTimeValue(override, "dtstart", occurrence.details.startDate, tzid);
352
+ if (override.hasProperty("dtend")) {
353
+ setTimeValue(override, "dtend", occurrence.details.endDate, tzid);
354
+ }
355
+ }
356
+
357
+ calendar.component.addSubcomponent(override);
358
+ calendar.overrides.push(override);
359
+ return { ok: true, value: override };
360
+ };
361
+
362
+ /**
363
+ * Turns an edit of one drawing of a series into the resource writes it means.
364
+ *
365
+ * `All` rewrites the master. `This` writes a RECURRENCE-ID override, which is
366
+ * the only thing iCalendar has for "this one is different". `Following` splits,
367
+ * because a rule cannot change halfway through.
368
+ */
369
+ export const applyScopedUpdate = async (
370
+ calendar: ParsedCalendar,
371
+ collectionTimezone: string,
372
+ input: ScopedWriteInput,
373
+ patch: Partial<CalendarEventFields>,
374
+ ): Promise<CalendarResult<ScopedWrite>> => {
375
+ if (input.scope === RecurrenceScope.All) {
376
+ return applyToMaster(calendar, collectionTimezone, patch);
377
+ }
378
+
379
+ const anchored = anchorOf(calendar, collectionTimezone, input);
380
+ if (!anchored.ok) return anchored;
381
+ if (anchored.value === null) {
382
+ return applyToMaster(calendar, collectionTimezone, patch);
383
+ }
384
+ const occurrence = anchored.value;
385
+
386
+ if (input.scope === RecurrenceScope.This) {
387
+ const override = await overrideFor(calendar, occurrence);
388
+ if (!override.ok) return override;
389
+ // A rule belongs to a series; an override is one occurrence of it and
390
+ // carries no rule of its own.
391
+ const { recurrenceRule: _seriesOnly, ...occurrencePatch } = patch;
392
+ const applied = await applyEventFields(
393
+ override.value,
394
+ occurrencePatch,
395
+ collectionTimezone,
396
+ );
397
+ if (!applied.ok) return applied;
398
+ return { ok: true, value: replaceWith(calendar) };
399
+ }
400
+
401
+ const splitMs = Date.parse(occurrence.slotUtc);
402
+ const tail = await cloneCalendar(calendar);
403
+ if (!tail.ok) return tail;
404
+
405
+ const before = (slotMs: number) => slotMs < splitMs;
406
+ const fromHere = (slotMs: number) => slotMs >= splitMs;
407
+
408
+ truncateRule(calendar.master, occurrence);
409
+ keepOverrides(calendar, collectionTimezone, before);
410
+ keepDateValues(calendar.master, "rdate", collectionTimezone, before);
411
+ keepDateValues(calendar.master, "exdate", collectionTimezone, before);
412
+
413
+ applyRemainderRule(tail.value.master, occurrence);
414
+ keepOverrides(tail.value, collectionTimezone, fromHere);
415
+ keepDateValues(tail.value.master, "rdate", collectionTimezone, fromHere);
416
+ keepDateValues(tail.value.master, "exdate", collectionTimezone, fromHere);
417
+
418
+ const tzid = dtStartTzid(tail.value.master);
419
+ const tailEnd = occurrence.slot.clone();
420
+ tailEnd.addDuration(new ICAL.Event(tail.value.master).duration);
421
+ setTimeValue(tail.value.master, "dtstart", occurrence.slot, tzid);
422
+ if (tail.value.master.hasProperty("dtend")) {
423
+ setTimeValue(tail.value.master, "dtend", tailEnd, tzid);
424
+ }
425
+ for (const component of [tail.value.master, ...tail.value.overrides]) {
426
+ component.removeAllProperties("uid");
427
+ component.addPropertyWithValue("uid", input.followingUid);
428
+ }
429
+
430
+ const applied = await applyEventFields(
431
+ tail.value.master,
432
+ patch,
433
+ collectionTimezone,
434
+ );
435
+ if (!applied.ok) return applied;
436
+
437
+ return {
438
+ ok: true,
439
+ value: {
440
+ kind: "Split",
441
+ icalData: serializeCalendar(calendar.component),
442
+ following: serializeCalendar(tail.value.component),
443
+ },
444
+ };
445
+ };
446
+
447
+ /**
448
+ * Turns a delete of one drawing of a series into the resource writes it means.
449
+ *
450
+ * `This` is an EXDATE and `Following` truncates the rule; neither removes the
451
+ * resource, because the rest of the series is still somebody's calendar. `All`
452
+ * removes it.
453
+ */
454
+ export const applyScopedDelete = async (
455
+ calendar: ParsedCalendar,
456
+ collectionTimezone: string,
457
+ input: ScopedWriteInput,
458
+ ): Promise<CalendarResult<ScopedWrite>> => {
459
+ if (input.scope === RecurrenceScope.All) {
460
+ return { ok: true, value: { kind: "Delete" } };
461
+ }
462
+
463
+ const anchored = anchorOf(calendar, collectionTimezone, input);
464
+ if (!anchored.ok) return anchored;
465
+ if (anchored.value === null) {
466
+ return { ok: true, value: { kind: "Delete" } };
467
+ }
468
+ const occurrence = anchored.value;
469
+ const splitMs = Date.parse(occurrence.slotUtc);
470
+
471
+ if (input.scope === RecurrenceScope.Following) {
472
+ const before = (slotMs: number) => slotMs < splitMs;
473
+ truncateRule(calendar.master, occurrence);
474
+ keepOverrides(calendar, collectionTimezone, before);
475
+ keepDateValues(calendar.master, "rdate", collectionTimezone, before);
476
+ keepDateValues(calendar.master, "exdate", collectionTimezone, before);
477
+ return { ok: true, value: replaceWith(calendar) };
478
+ }
479
+
480
+ if (occurrence.override) {
481
+ calendar.component.removeSubcomponent(occurrence.override);
482
+ calendar.overrides = calendar.overrides.filter(
483
+ (override) => override !== occurrence.override,
484
+ );
485
+ }
486
+ calendar.master.addProperty(
487
+ timeProperty("exdate", occurrence.slot, dtStartTzid(calendar.master)),
488
+ );
489
+ return { ok: true, value: replaceWith(calendar) };
490
+ };
package/src/time.ts CHANGED
@@ -124,6 +124,57 @@ const formatCivil = (
124
124
  export const toUtcIso = (instantMs: number): string =>
125
125
  `${new Date(instantMs).toISOString().slice(0, 19)}Z`;
126
126
 
127
+ /** Whether this platform can be asked about a zone by name. */
128
+ export const isResolvableZone = (timeZone: string): boolean =>
129
+ isKnownZone(timeZone);
130
+
131
+ /**
132
+ * The wall-clock fields an instant reads as in a zone, with the offset that
133
+ * zone was at. The zone falls back to UTC when it is one this platform cannot
134
+ * name, which is the same fallback `resolveTime` takes.
135
+ */
136
+ export const civilInZone = (
137
+ instantMs: number,
138
+ timeZone: string,
139
+ ): {
140
+ year: number;
141
+ month: number;
142
+ day: number;
143
+ hour: number;
144
+ minute: number;
145
+ second: number;
146
+ offsetMinutes: number;
147
+ } => {
148
+ const zone = isKnownZone(timeZone) ? timeZone : "UTC";
149
+ const offsetMinutes = zoneOffsetMinutes(zone, instantMs);
150
+ const shifted = new Date(instantMs + offsetMinutes * 60_000);
151
+ return {
152
+ year: shifted.getUTCFullYear(),
153
+ month: shifted.getUTCMonth() + 1,
154
+ day: shifted.getUTCDate(),
155
+ hour: shifted.getUTCHours(),
156
+ minute: shifted.getUTCMinutes(),
157
+ second: shifted.getUTCSeconds(),
158
+ offsetMinutes,
159
+ };
160
+ };
161
+
162
+ /**
163
+ * An instant as the wall time it reads as in a zone, carrying that zone's
164
+ * offset — the form a client renders.
165
+ *
166
+ * The store keeps occurrences as UTC instants because only a fixed-width form
167
+ * sorts correctly, and a client that was handed those would have to re-derive
168
+ * the event's own zone to draw it anywhere. This is the other direction, done
169
+ * once on the server: an occurrence of a 09:00 Amsterdam meeting comes back as
170
+ * `09:00+02:00` in summer and `09:00+01:00` in winter rather than as two
171
+ * different times of day.
172
+ */
173
+ export const toOffsetIso = (instantMs: number, timeZone: string): string => {
174
+ const civil = civilInZone(instantMs, timeZone);
175
+ return formatCivil(civil, civil.offsetMinutes);
176
+ };
177
+
127
178
  /**
128
179
  * Resolves one iCalendar time to an instant and to its own wall-clock form.
129
180
  *
@@ -192,3 +243,18 @@ export const tzidOf = (property: ICAL.Property | null): string => {
192
243
  const tzid = property?.getParameter("tzid");
193
244
  return typeof tzid === "string" ? tzid : "";
194
245
  };
246
+
247
+ /** The zone a component's DTSTART was written in. */
248
+ export const dtStartTzid = (component: ICAL.Component): string =>
249
+ tzidOf(component.getFirstProperty("dtstart"));
250
+
251
+ /**
252
+ * The zone a component's end was written in. DTEND carries its own TZID and
253
+ * need not match DTSTART's, so an end read with the start's zone silently
254
+ * changes the event's length. Only a stated DTEND has a zone of its own: an end
255
+ * derived from a duration is already in the start's zone.
256
+ */
257
+ export const dtEndTzid = (component: ICAL.Component): string =>
258
+ component.hasProperty("dtend")
259
+ ? tzidOf(component.getFirstProperty("dtend"))
260
+ : dtStartTzid(component);