@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/package.json +1 -1
- package/src/build.test.ts +113 -0
- package/src/build.ts +311 -0
- package/src/errors.ts +13 -1
- package/src/expand.test.ts +10 -0
- package/src/expand.ts +221 -111
- package/src/index.ts +38 -1
- package/src/project.ts +26 -8
- package/src/put.test.ts +17 -0
- package/src/scope.test.ts +472 -0
- package/src/scope.ts +490 -0
- package/src/time.ts +66 -0
- package/src/window.test.ts +524 -0
- package/src/window.ts +232 -0
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);
|