@remit/calendar-service 0.0.7 → 0.0.9
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/feed.test.ts +4 -3
- package/src/feed.ts +0 -3
- package/src/index.ts +0 -1
- package/src/project.ts +8 -8
- package/src/scope.test.ts +216 -0
- package/src/scope.ts +148 -3
- package/src/suggest.ts +1 -6
package/package.json
CHANGED
package/src/feed.test.ts
CHANGED
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
CALENDAR_FEED_TOKEN_BYTES,
|
|
10
10
|
calendarFeedIsUnchanged,
|
|
11
11
|
calendarFeedIsUnmodifiedSince,
|
|
12
|
-
calendarFeedPath,
|
|
13
12
|
hashCalendarFeedToken,
|
|
14
13
|
isCalendarFeedToken,
|
|
15
14
|
mintCalendarFeedToken,
|
|
@@ -99,7 +98,7 @@ describe("a feed address", () => {
|
|
|
99
98
|
const minted = mintCalendarFeedToken();
|
|
100
99
|
|
|
101
100
|
assert.equal(
|
|
102
|
-
readCalendarFeedToken(
|
|
101
|
+
readCalendarFeedToken(`/feeds/calendar/${minted.token}.ics`),
|
|
103
102
|
minted.token,
|
|
104
103
|
);
|
|
105
104
|
});
|
|
@@ -279,7 +278,9 @@ describe("a feed path in a log line", () => {
|
|
|
279
278
|
it("keeps the route and drops the token", () => {
|
|
280
279
|
const minted = mintCalendarFeedToken();
|
|
281
280
|
|
|
282
|
-
const redacted = redactCalendarFeedPath(
|
|
281
|
+
const redacted = redactCalendarFeedPath(
|
|
282
|
+
`/feeds/calendar/${minted.token}.ics`,
|
|
283
|
+
);
|
|
283
284
|
|
|
284
285
|
assert.equal(redacted.includes(minted.token), false);
|
|
285
286
|
assert.equal(redacted, "/feeds/calendar/<redacted>.ics");
|
package/src/feed.ts
CHANGED
|
@@ -50,9 +50,6 @@ export const mintCalendarFeedToken = (): CalendarFeedSecret => {
|
|
|
50
50
|
export const isCalendarFeedToken = (token: string): boolean =>
|
|
51
51
|
TOKEN_SHAPE.test(token);
|
|
52
52
|
|
|
53
|
-
export const calendarFeedPath = (token: string): string =>
|
|
54
|
-
`${CALENDAR_FEED_PATH_PREFIX}${token}${CALENDAR_FEED_PATH_SUFFIX}`;
|
|
55
|
-
|
|
56
53
|
/**
|
|
57
54
|
* The token a request path carries, or `null` when the path is not a feed
|
|
58
55
|
* address. One segment only: a slash inside it would name a different route.
|
package/src/index.ts
CHANGED
package/src/project.ts
CHANGED
|
@@ -36,18 +36,11 @@ const TRANSPARENCY_BY_ICAL: Record<string, CalendarObjectItem["transparency"]> =
|
|
|
36
36
|
TRANSPARENT: CalendarTransparency.Transparent,
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
-
const readString = (component: ICAL.Component, name: string): string => {
|
|
39
|
+
export const readString = (component: ICAL.Component, name: string): string => {
|
|
40
40
|
const value = component.getFirstPropertyValue(name);
|
|
41
41
|
return typeof value === "string" ? value : "";
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
-
/**
|
|
45
|
-
* Whether the resource expands to more than its master instance — an RRULE, an
|
|
46
|
-
* RDATE, or an override VEVENT. The overrides count: a client that edits one
|
|
47
|
-
* instance of a series and then deletes the rule leaves a resource with nothing
|
|
48
|
-
* but a master and its exceptions, and every one of those is still an
|
|
49
|
-
* occurrence somebody has to see.
|
|
50
|
-
*/
|
|
51
44
|
/**
|
|
52
45
|
* The fields that describe one VEVENT rather than the series it belongs to.
|
|
53
46
|
*
|
|
@@ -73,6 +66,13 @@ export const projectEventDisplay = (
|
|
|
73
66
|
CalendarTransparency.Opaque,
|
|
74
67
|
});
|
|
75
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Whether the resource expands to more than its master instance — an RRULE, an
|
|
71
|
+
* RDATE, or an override VEVENT. The overrides count: a client that edits one
|
|
72
|
+
* instance of a series and then deletes the rule leaves a resource with nothing
|
|
73
|
+
* but a master and its exceptions, and every one of those is still an
|
|
74
|
+
* occurrence somebody has to see.
|
|
75
|
+
*/
|
|
76
76
|
export const hasRecurrence = (calendar: ParsedCalendar): boolean =>
|
|
77
77
|
calendar.master.hasProperty("rrule") ||
|
|
78
78
|
calendar.master.hasProperty("rdate") ||
|
package/src/scope.test.ts
CHANGED
|
@@ -98,6 +98,222 @@ describe("applyScopedUpdate", () => {
|
|
|
98
98
|
assert.ok(master?.includes("DTSTART:20260907T090000Z"));
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
it("moves the series by what changed on the occurrence a scope=All edit was made from", async () => {
|
|
102
|
+
const calendar = await read(WEEKLY);
|
|
103
|
+
|
|
104
|
+
const write = await applyScopedUpdate(
|
|
105
|
+
calendar,
|
|
106
|
+
"",
|
|
107
|
+
input(RecurrenceScope.All, "2026-09-21T09:00:00Z"),
|
|
108
|
+
{ start: "2026-09-21T14:00:00Z", end: "2026-09-21T15:30:00Z" },
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
assert.ok(write.ok);
|
|
112
|
+
assert.ok(write.value.kind === "Replace");
|
|
113
|
+
const [master, ...rest] = events(write.value.icalData);
|
|
114
|
+
assert.deepEqual(rest, []);
|
|
115
|
+
assert.ok(
|
|
116
|
+
master?.includes("DTSTART:20260907T140000Z"),
|
|
117
|
+
`the series still starts on its first occurrence: ${master?.join(" | ")}`,
|
|
118
|
+
);
|
|
119
|
+
assert.ok(master?.includes("DTEND:20260907T153000Z"));
|
|
120
|
+
assert.ok(master?.includes("RRULE:FREQ=WEEKLY;COUNT=5"));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("keeps a zoned series on its own first day when a scope=All edit crosses a clock change", async () => {
|
|
124
|
+
const calendar = await read(AMSTERDAM_WEEKLY);
|
|
125
|
+
|
|
126
|
+
const write = await applyScopedUpdate(
|
|
127
|
+
calendar,
|
|
128
|
+
"Europe/Amsterdam",
|
|
129
|
+
input(RecurrenceScope.All, "2026-10-29T08:00:00Z"),
|
|
130
|
+
{
|
|
131
|
+
start: "2026-10-29T10:00:00+01:00",
|
|
132
|
+
end: "2026-10-29T11:00:00+01:00",
|
|
133
|
+
timeZone: "Europe/Amsterdam",
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
assert.ok(write.ok);
|
|
138
|
+
assert.ok(write.value.kind === "Replace");
|
|
139
|
+
const [master] = events(write.value.icalData);
|
|
140
|
+
assert.ok(
|
|
141
|
+
master?.includes("DTSTART;TZID=Europe/Amsterdam:20261015T100000"),
|
|
142
|
+
`the series moves on its own wall clock: ${master?.join(" | ")}`,
|
|
143
|
+
);
|
|
144
|
+
assert.ok(master?.includes("DTEND;TZID=Europe/Amsterdam:20261015T110000"));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("keeps a series on its weekday when a scope=All edit is made from an occurrence moved on its own", async () => {
|
|
148
|
+
const calendar = await read(
|
|
149
|
+
ical(
|
|
150
|
+
"BEGIN:VCALENDAR",
|
|
151
|
+
"VERSION:2.0",
|
|
152
|
+
"BEGIN:VEVENT",
|
|
153
|
+
"UID:weekly@example.com",
|
|
154
|
+
"DTSTART:20260907T090000Z",
|
|
155
|
+
"DTEND:20260907T100000Z",
|
|
156
|
+
"SUMMARY:Stand-up",
|
|
157
|
+
"RRULE:FREQ=WEEKLY;COUNT=5",
|
|
158
|
+
"END:VEVENT",
|
|
159
|
+
"BEGIN:VEVENT",
|
|
160
|
+
"UID:weekly@example.com",
|
|
161
|
+
"RECURRENCE-ID:20260921T090000Z",
|
|
162
|
+
"DTSTART:20260923T090000Z",
|
|
163
|
+
"DTEND:20260923T100000Z",
|
|
164
|
+
"SUMMARY:Stand-up",
|
|
165
|
+
"END:VEVENT",
|
|
166
|
+
"END:VCALENDAR",
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const write = await applyScopedUpdate(
|
|
171
|
+
calendar,
|
|
172
|
+
"",
|
|
173
|
+
input(RecurrenceScope.All, "2026-09-21T09:00:00Z"),
|
|
174
|
+
{ start: "2026-09-23T10:00:00Z", end: "2026-09-23T11:00:00Z" },
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
assert.ok(write.ok);
|
|
178
|
+
assert.ok(write.value.kind === "Replace");
|
|
179
|
+
const [master, override] = events(write.value.icalData);
|
|
180
|
+
assert.ok(
|
|
181
|
+
master?.includes("DTSTART:20260907T100000Z"),
|
|
182
|
+
`the series stays on Mondays: ${master?.join(" | ")}`,
|
|
183
|
+
);
|
|
184
|
+
assert.ok(master?.includes("DTEND:20260907T110000Z"));
|
|
185
|
+
assert.ok(
|
|
186
|
+
override?.includes("RECURRENCE-ID:20260921T100000Z"),
|
|
187
|
+
`the moved occurrence still replaces its own slot: ${override?.join(" | ")}`,
|
|
188
|
+
);
|
|
189
|
+
assert.ok(override?.includes("DTSTART:20260923T090000Z"));
|
|
190
|
+
|
|
191
|
+
const expanded = expandCalendar(await read(write.value.icalData), "");
|
|
192
|
+
assert.deepEqual(
|
|
193
|
+
expanded.occurrences.map((occurrence) => occurrence.startAt),
|
|
194
|
+
[
|
|
195
|
+
"2026-09-07T10:00:00Z",
|
|
196
|
+
"2026-09-14T10:00:00Z",
|
|
197
|
+
"2026-09-23T09:00:00Z",
|
|
198
|
+
"2026-09-28T10:00:00Z",
|
|
199
|
+
"2026-10-05T10:00:00Z",
|
|
200
|
+
],
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("keeps an excluded occurrence excluded when a scope=All edit moves the series' time", async () => {
|
|
205
|
+
const calendar = await read(
|
|
206
|
+
ical(
|
|
207
|
+
"BEGIN:VCALENDAR",
|
|
208
|
+
"VERSION:2.0",
|
|
209
|
+
"BEGIN:VEVENT",
|
|
210
|
+
"UID:weekly@example.com",
|
|
211
|
+
"DTSTART:20260907T090000Z",
|
|
212
|
+
"DTEND:20260907T100000Z",
|
|
213
|
+
"SUMMARY:Stand-up",
|
|
214
|
+
"RRULE:FREQ=WEEKLY;COUNT=5",
|
|
215
|
+
"EXDATE:20260914T090000Z",
|
|
216
|
+
"END:VEVENT",
|
|
217
|
+
"END:VCALENDAR",
|
|
218
|
+
),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const write = await applyScopedUpdate(
|
|
222
|
+
calendar,
|
|
223
|
+
"",
|
|
224
|
+
input(RecurrenceScope.All, "2026-09-21T09:00:00Z"),
|
|
225
|
+
{ start: "2026-09-21T10:00:00Z", end: "2026-09-21T11:00:00Z" },
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
assert.ok(write.ok);
|
|
229
|
+
assert.ok(write.value.kind === "Replace");
|
|
230
|
+
const expanded = expandCalendar(await read(write.value.icalData), "");
|
|
231
|
+
assert.deepEqual(
|
|
232
|
+
expanded.occurrences.map((occurrence) => occurrence.startAt),
|
|
233
|
+
[
|
|
234
|
+
"2026-09-07T10:00:00Z",
|
|
235
|
+
"2026-09-21T10:00:00Z",
|
|
236
|
+
"2026-09-28T10:00:00Z",
|
|
237
|
+
"2026-10-05T10:00:00Z",
|
|
238
|
+
],
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("moves only the end when a scope=All edit changes only the end", async () => {
|
|
243
|
+
const calendar = await read(WEEKLY);
|
|
244
|
+
|
|
245
|
+
const write = await applyScopedUpdate(
|
|
246
|
+
calendar,
|
|
247
|
+
"",
|
|
248
|
+
input(RecurrenceScope.All, "2026-09-21T09:00:00Z"),
|
|
249
|
+
{ end: "2026-09-21T11:00:00Z" },
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
assert.ok(write.ok);
|
|
253
|
+
assert.ok(write.value.kind === "Replace");
|
|
254
|
+
const [master] = events(write.value.icalData);
|
|
255
|
+
assert.ok(
|
|
256
|
+
master?.includes("DTSTART:20260907T090000Z"),
|
|
257
|
+
`the start is untouched: ${master?.join(" | ")}`,
|
|
258
|
+
);
|
|
259
|
+
assert.ok(
|
|
260
|
+
master?.includes("DTEND:20260907T110000Z"),
|
|
261
|
+
`the end lands on the series' first day: ${master?.join(" | ")}`,
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("keeps the series' first day when a scope=All edit changes only the zone", async () => {
|
|
266
|
+
const calendar = await read(WEEKLY);
|
|
267
|
+
|
|
268
|
+
const write = await applyScopedUpdate(
|
|
269
|
+
calendar,
|
|
270
|
+
"",
|
|
271
|
+
input(RecurrenceScope.All, "2026-09-21T09:00:00Z"),
|
|
272
|
+
{ timeZone: "Europe/Amsterdam" },
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
assert.ok(write.ok);
|
|
276
|
+
assert.ok(write.value.kind === "Replace");
|
|
277
|
+
const [master] = events(write.value.icalData);
|
|
278
|
+
assert.ok(
|
|
279
|
+
master?.includes("DTSTART;TZID=Europe/Amsterdam:20260907T110000"),
|
|
280
|
+
`the same instant, read in the new zone: ${master?.join(" | ")}`,
|
|
281
|
+
);
|
|
282
|
+
assert.ok(master?.includes("DTEND;TZID=Europe/Amsterdam:20260907T120000"));
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("keeps the series' first day when a scope=All edit changes only all-day", async () => {
|
|
286
|
+
const calendar = await read(WEEKLY);
|
|
287
|
+
|
|
288
|
+
const write = await applyScopedUpdate(
|
|
289
|
+
calendar,
|
|
290
|
+
"",
|
|
291
|
+
input(RecurrenceScope.All, "2026-09-21T09:00:00Z"),
|
|
292
|
+
{ allDay: true },
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
assert.ok(write.ok);
|
|
296
|
+
assert.ok(write.value.kind === "Replace");
|
|
297
|
+
const [master] = events(write.value.icalData);
|
|
298
|
+
assert.ok(
|
|
299
|
+
master?.includes("DTSTART;VALUE=DATE:20260907"),
|
|
300
|
+
`the series still starts on its first day: ${master?.join(" | ")}`,
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("refuses a scope=All time change made from an occurrence the series does not have", async () => {
|
|
305
|
+
const calendar = await read(WEEKLY);
|
|
306
|
+
|
|
307
|
+
const write = await applyScopedUpdate(
|
|
308
|
+
calendar,
|
|
309
|
+
"",
|
|
310
|
+
input(RecurrenceScope.All, "2026-09-22T09:00:00Z"),
|
|
311
|
+
{ start: "2026-09-22T14:00:00Z", end: "2026-09-22T15:00:00Z" },
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
assert.equal(write.ok, false);
|
|
315
|
+
});
|
|
316
|
+
|
|
101
317
|
it("writes a RECURRENCE-ID override for one occurrence under scope=This", async () => {
|
|
102
318
|
const calendar = await read(WEEKLY);
|
|
103
319
|
|
package/src/scope.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type ScopedWrite =
|
|
|
29
29
|
|
|
30
30
|
export interface ScopedWriteInput {
|
|
31
31
|
scope: RecurrenceScopeValue;
|
|
32
|
-
/** ISO 8601 UTC instant naming the occurrence
|
|
32
|
+
/** ISO 8601 UTC instant naming the occurrence the write was made from, or `""`. */
|
|
33
33
|
recurrenceId: string;
|
|
34
34
|
/** UID the resource a `Following` split creates is written under. */
|
|
35
35
|
followingUid: string;
|
|
@@ -286,6 +286,138 @@ const applyToMaster = async (
|
|
|
286
286
|
return { ok: true, value: replaceWith(calendar) };
|
|
287
287
|
};
|
|
288
288
|
|
|
289
|
+
const touchesTime = (patch: Partial<CalendarEventFields>): boolean =>
|
|
290
|
+
patch.start !== undefined ||
|
|
291
|
+
patch.end !== undefined ||
|
|
292
|
+
patch.allDay !== undefined ||
|
|
293
|
+
patch.timeZone !== undefined;
|
|
294
|
+
|
|
295
|
+
const civilDay = (time: ICAL.Time): number =>
|
|
296
|
+
Date.UTC(time.year, time.month - 1, time.day) / 86_400_000;
|
|
297
|
+
|
|
298
|
+
const shiftProperty = (
|
|
299
|
+
component: ICAL.Component,
|
|
300
|
+
name: string,
|
|
301
|
+
days: number,
|
|
302
|
+
): void => {
|
|
303
|
+
const property = component.getFirstProperty(name);
|
|
304
|
+
const value = property?.getFirstValue();
|
|
305
|
+
if (!property || !(value instanceof ICAL.Time)) return;
|
|
306
|
+
const shifted = value.clone();
|
|
307
|
+
shifted.adjust(days, 0, 0, 0);
|
|
308
|
+
property.setValue(shifted);
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
/** Where an occurrence is drawn, which a moved one no longer shares with its slot. */
|
|
312
|
+
const shownStart = (occurrence: FoundOccurrence): ICAL.Time | null => {
|
|
313
|
+
if (occurrence.details) return occurrence.details.startDate;
|
|
314
|
+
const start = occurrence.override?.getFirstPropertyValue("dtstart");
|
|
315
|
+
return start instanceof ICAL.Time ? start : null;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The slot a value named under the old DTSTART, named under the new one: the
|
|
320
|
+
* same day of the series, at the series' new time and in its new zone.
|
|
321
|
+
*/
|
|
322
|
+
const reslot = (
|
|
323
|
+
value: ICAL.Time,
|
|
324
|
+
before: ICAL.Time,
|
|
325
|
+
after: ICAL.Time,
|
|
326
|
+
): ICAL.Time => {
|
|
327
|
+
const moved = after.clone();
|
|
328
|
+
moved.adjust(civilDay(value) - civilDay(before), 0, 0, 0);
|
|
329
|
+
return moved;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Keeps every override and excluded date on the slot it was written for once
|
|
334
|
+
* the series' own start has moved. A RECURRENCE-ID or an EXDATE matches a slot
|
|
335
|
+
* by its exact time, so leaving them behind would detach a moved occurrence
|
|
336
|
+
* from the one it replaces and bring a deleted one back.
|
|
337
|
+
*/
|
|
338
|
+
const followSlots = (
|
|
339
|
+
calendar: ParsedCalendar,
|
|
340
|
+
before: ICAL.Time,
|
|
341
|
+
after: ICAL.Time,
|
|
342
|
+
): void => {
|
|
343
|
+
if (before.isDate !== after.isDate) return;
|
|
344
|
+
const tzid = dtStartTzid(calendar.master);
|
|
345
|
+
for (const override of calendar.overrides) {
|
|
346
|
+
const recurrenceId = override.getFirstPropertyValue("recurrence-id");
|
|
347
|
+
if (!(recurrenceId instanceof ICAL.Time)) continue;
|
|
348
|
+
override.removeAllProperties("recurrence-id");
|
|
349
|
+
override.addProperty(
|
|
350
|
+
timeProperty("recurrence-id", reslot(recurrenceId, before, after), tzid),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
for (const name of ["exdate", "rdate"]) {
|
|
354
|
+
for (const property of calendar.master.getAllProperties(name)) {
|
|
355
|
+
const values = property.getValues();
|
|
356
|
+
if (!values.every((value: unknown) => value instanceof ICAL.Time)) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
calendar.master.removeProperty(property);
|
|
360
|
+
for (const value of values as ICAL.Time[]) {
|
|
361
|
+
calendar.master.addProperty(
|
|
362
|
+
timeProperty(name, reslot(value, before, after), tzid),
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* A whole-series edit made from one occurrence of it.
|
|
371
|
+
*
|
|
372
|
+
* A start or end in the patch is that occurrence's new time, so each is moved
|
|
373
|
+
* back by the days between where that occurrence is drawn and where the series
|
|
374
|
+
* begins. The series keeps its first day and its weekday, and only what was
|
|
375
|
+
* edited changes.
|
|
376
|
+
*/
|
|
377
|
+
const applyToSeriesFrom = async (
|
|
378
|
+
calendar: ParsedCalendar,
|
|
379
|
+
collectionTimezone: string,
|
|
380
|
+
recurrenceId: string,
|
|
381
|
+
patch: Partial<CalendarEventFields>,
|
|
382
|
+
): Promise<CalendarResult<ScopedWrite>> => {
|
|
383
|
+
const found = findOccurrence(calendar, collectionTimezone, recurrenceId);
|
|
384
|
+
if (!found.ok) return found;
|
|
385
|
+
const seriesStart = calendar.master.getFirstPropertyValue("dtstart");
|
|
386
|
+
const shown = shownStart(found.value);
|
|
387
|
+
if (!(seriesStart instanceof ICAL.Time) || !shown) {
|
|
388
|
+
return calendarFailure(
|
|
389
|
+
"UnknownOccurrence",
|
|
390
|
+
`${recurrenceId} has no start to move the series from`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
const before = seriesStart.clone();
|
|
394
|
+
const beforeTzid = dtStartTzid(calendar.master);
|
|
395
|
+
const daysIn = civilDay(shown) - civilDay(before);
|
|
396
|
+
|
|
397
|
+
const applied = await applyEventFields(
|
|
398
|
+
calendar.master,
|
|
399
|
+
patch,
|
|
400
|
+
collectionTimezone,
|
|
401
|
+
);
|
|
402
|
+
if (!applied.ok) return applied;
|
|
403
|
+
if (patch.start !== undefined) {
|
|
404
|
+
shiftProperty(calendar.master, "dtstart", -daysIn);
|
|
405
|
+
}
|
|
406
|
+
if (patch.end !== undefined) {
|
|
407
|
+
shiftProperty(calendar.master, "dtend", -daysIn);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const after = calendar.master.getFirstPropertyValue("dtstart");
|
|
411
|
+
if (
|
|
412
|
+
after instanceof ICAL.Time &&
|
|
413
|
+
(after.toString() !== before.toString() ||
|
|
414
|
+
dtStartTzid(calendar.master) !== beforeTzid)
|
|
415
|
+
) {
|
|
416
|
+
followSlots(calendar, before, after);
|
|
417
|
+
}
|
|
418
|
+
return { ok: true, value: replaceWith(calendar) };
|
|
419
|
+
};
|
|
420
|
+
|
|
289
421
|
/**
|
|
290
422
|
* The occurrence a `This` or `Following` write names, or `null` when the scope
|
|
291
423
|
* collapses to the whole series — which is what "everything from the first
|
|
@@ -362,7 +494,8 @@ const overrideFor = async (
|
|
|
362
494
|
/**
|
|
363
495
|
* Turns an edit of one drawing of a series into the resource writes it means.
|
|
364
496
|
*
|
|
365
|
-
* `All` rewrites the master
|
|
497
|
+
* `All` rewrites the master, moved from the occurrence it names when it names
|
|
498
|
+
* one. `This` writes a RECURRENCE-ID override, which is
|
|
366
499
|
* the only thing iCalendar has for "this one is different". `Following` splits,
|
|
367
500
|
* because a rule cannot change halfway through.
|
|
368
501
|
*/
|
|
@@ -373,7 +506,19 @@ export const applyScopedUpdate = async (
|
|
|
373
506
|
patch: Partial<CalendarEventFields>,
|
|
374
507
|
): Promise<CalendarResult<ScopedWrite>> => {
|
|
375
508
|
if (input.scope === RecurrenceScope.All) {
|
|
376
|
-
|
|
509
|
+
if (
|
|
510
|
+
input.recurrenceId === "" ||
|
|
511
|
+
!hasRecurrence(calendar) ||
|
|
512
|
+
!touchesTime(patch)
|
|
513
|
+
) {
|
|
514
|
+
return applyToMaster(calendar, collectionTimezone, patch);
|
|
515
|
+
}
|
|
516
|
+
return applyToSeriesFrom(
|
|
517
|
+
calendar,
|
|
518
|
+
collectionTimezone,
|
|
519
|
+
input.recurrenceId,
|
|
520
|
+
patch,
|
|
521
|
+
);
|
|
377
522
|
}
|
|
378
523
|
|
|
379
524
|
const anchored = anchorOf(calendar, collectionTimezone, input);
|
package/src/suggest.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import type ICAL from "ical.js";
|
|
11
11
|
import type { CalendarResult } from "./errors.js";
|
|
12
12
|
import { parseCalendar } from "./parse.js";
|
|
13
|
-
import { projectCalendar } from "./project.js";
|
|
13
|
+
import { projectCalendar, readString } from "./project.js";
|
|
14
14
|
|
|
15
15
|
const METHOD_BY_ICAL: Record<string, CalendarSuggestionItem["method"]> = {
|
|
16
16
|
REQUEST: CalendarInviteMethod.Request,
|
|
@@ -20,11 +20,6 @@ const METHOD_BY_ICAL: Record<string, CalendarSuggestionItem["method"]> = {
|
|
|
20
20
|
PUBLISH: CalendarInviteMethod.Publish,
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
const readString = (component: ICAL.Component, name: string): string => {
|
|
24
|
-
const value = component.getFirstPropertyValue(name);
|
|
25
|
-
return typeof value === "string" ? value : "";
|
|
26
|
-
};
|
|
27
|
-
|
|
28
23
|
/**
|
|
29
24
|
* A `CAL-ADDRESS` as a plain mail address. RFC 5545 3.3.3 writes one as a URI,
|
|
30
25
|
* which in practice is always `mailto:`; a card shows a person, not a URI.
|