@remit/calendar-service 0.0.8 → 0.0.10
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/errors.ts +2 -1
- package/src/scope.test.ts +949 -0
- package/src/scope.ts +509 -4
package/src/scope.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
serializeCalendar,
|
|
10
10
|
} from "./parse.js";
|
|
11
11
|
import { hasRecurrence } from "./project.js";
|
|
12
|
-
import { dtStartTzid, resolveTime, toUtcIso } from "./time.js";
|
|
12
|
+
import { dtStartTzid, resolveTime, toUtcIso, tzidOf } from "./time.js";
|
|
13
13
|
|
|
14
14
|
export type RecurrenceScopeValue =
|
|
15
15
|
(typeof RecurrenceScope)[keyof typeof RecurrenceScope];
|
|
@@ -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,502 @@ 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
|
+
const shownStart = (occurrence: FoundOccurrence): ICAL.Time | null => {
|
|
312
|
+
if (occurrence.details) return occurrence.details.startDate;
|
|
313
|
+
const start = occurrence.override?.getFirstPropertyValue("dtstart");
|
|
314
|
+
return start instanceof ICAL.Time ? start : null;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const reslot = (
|
|
318
|
+
value: ICAL.Time,
|
|
319
|
+
before: ICAL.Time,
|
|
320
|
+
after: ICAL.Time,
|
|
321
|
+
): ICAL.Time => {
|
|
322
|
+
const moved = after.clone();
|
|
323
|
+
moved.adjust(civilDay(value) - civilDay(before), 0, 0, 0);
|
|
324
|
+
return moved;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
interface SeriesShape {
|
|
328
|
+
start: ICAL.Time;
|
|
329
|
+
tzid: string;
|
|
330
|
+
rule: ICAL.Recur | null;
|
|
331
|
+
durationSeconds: number;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
interface SeriesSlot {
|
|
335
|
+
time: ICAL.Time;
|
|
336
|
+
instantMs: number;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const seriesShape = (master: ICAL.Component): SeriesShape | null => {
|
|
340
|
+
const start = master.getFirstPropertyValue("dtstart");
|
|
341
|
+
if (!(start instanceof ICAL.Time)) return null;
|
|
342
|
+
const rule = master.getFirstPropertyValue("rrule");
|
|
343
|
+
return {
|
|
344
|
+
start: start.clone(),
|
|
345
|
+
tzid: dtStartTzid(master),
|
|
346
|
+
rule: rule instanceof ICAL.Recur ? rule.clone() : null,
|
|
347
|
+
durationSeconds: new ICAL.Event(master).duration.toSeconds(),
|
|
348
|
+
};
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const ruleSlots = (
|
|
352
|
+
shape: SeriesShape,
|
|
353
|
+
collectionTimezone: string,
|
|
354
|
+
stop: (instantMs: number, count: number) => boolean,
|
|
355
|
+
): SeriesSlot[] => {
|
|
356
|
+
if (!shape.rule) return [];
|
|
357
|
+
const slots: SeriesSlot[] = [];
|
|
358
|
+
const iterator = shape.rule.iterator(shape.start);
|
|
359
|
+
let next: ICAL.Time | null = iterator.next();
|
|
360
|
+
while (next && slots.length < CALENDAR_WINDOW_MAX_STEPS) {
|
|
361
|
+
const instantMs = resolveTime(
|
|
362
|
+
next,
|
|
363
|
+
shape.tzid,
|
|
364
|
+
collectionTimezone,
|
|
365
|
+
).instantMs;
|
|
366
|
+
if (stop(instantMs, slots.length)) break;
|
|
367
|
+
slots.push({ time: next.clone(), instantMs });
|
|
368
|
+
next = iterator.next();
|
|
369
|
+
}
|
|
370
|
+
return slots;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
const WEEKDAYS: readonly string[] = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
|
|
374
|
+
|
|
375
|
+
const WORKWEEK: readonly number[] = [1, 2, 3, 4, 5];
|
|
376
|
+
|
|
377
|
+
const BY_WEEKDAY = /^([+-]?\d{1,2})?(SU|MO|TU|WE|TH|FR|SA)$/;
|
|
378
|
+
|
|
379
|
+
interface ByWeekday {
|
|
380
|
+
ordinal: number;
|
|
381
|
+
weekday: number;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
interface FollowedRule {
|
|
385
|
+
rule: ICAL.Recur;
|
|
386
|
+
startShift: number;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const readByWeekday = (value: string): ByWeekday | null => {
|
|
390
|
+
const match = BY_WEEKDAY.exec(value);
|
|
391
|
+
if (!match) return null;
|
|
392
|
+
return {
|
|
393
|
+
ordinal: Number(match[1] ?? 0),
|
|
394
|
+
weekday: WEEKDAYS.indexOf(match[2] ?? ""),
|
|
395
|
+
};
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const weekdayOf = (time: ICAL.Time): number => time.dayOfWeek() - 1;
|
|
399
|
+
|
|
400
|
+
const weekdayOfDate = (year: number, month: number, day: number): number =>
|
|
401
|
+
new Date(Date.UTC(year, month - 1, day)).getUTCDay();
|
|
402
|
+
|
|
403
|
+
const ordinalOf = (
|
|
404
|
+
position: number,
|
|
405
|
+
length: number,
|
|
406
|
+
original: number,
|
|
407
|
+
): number => {
|
|
408
|
+
const fromStart = Math.floor((position - 1) / 7) + 1;
|
|
409
|
+
const fromLast = -(Math.floor((length - position) / 7) + 1);
|
|
410
|
+
const preferred = original < 0 ? fromLast : fromStart;
|
|
411
|
+
if (Math.abs(preferred) < 5 || Math.abs(original) >= 5) return preferred;
|
|
412
|
+
return original < 0 ? fromStart : fromLast;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const nthWeekday = (
|
|
416
|
+
year: number,
|
|
417
|
+
month: number,
|
|
418
|
+
weekday: number,
|
|
419
|
+
ordinal: number,
|
|
420
|
+
): number | null => {
|
|
421
|
+
const length = ICAL.Time.daysInMonth(month, year);
|
|
422
|
+
if (ordinal > 0) {
|
|
423
|
+
const first = 1 + ((weekday - weekdayOfDate(year, month, 1) + 7) % 7);
|
|
424
|
+
const day = first + 7 * (ordinal - 1);
|
|
425
|
+
return day <= length ? day : null;
|
|
426
|
+
}
|
|
427
|
+
const last =
|
|
428
|
+
length - ((weekdayOfDate(year, month, length) - weekday + 7) % 7);
|
|
429
|
+
const day = last + 7 * (ordinal + 1);
|
|
430
|
+
return day >= 1 ? day : null;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const firstNumberedSlot = (
|
|
434
|
+
from: ICAL.Time,
|
|
435
|
+
month: number,
|
|
436
|
+
yearly: boolean,
|
|
437
|
+
weekday: number,
|
|
438
|
+
ordinal: number,
|
|
439
|
+
): ICAL.Time | null => {
|
|
440
|
+
for (let step = 0; step < 48; step += 1) {
|
|
441
|
+
const monthIndex = yearly ? month - 1 : from.month - 1 + step;
|
|
442
|
+
const year = from.year + (yearly ? step : Math.floor(monthIndex / 12));
|
|
443
|
+
const candidateMonth = (monthIndex % 12) + 1;
|
|
444
|
+
const day = nthWeekday(year, candidateMonth, weekday, ordinal);
|
|
445
|
+
if (day !== null) {
|
|
446
|
+
const slot = from.clone();
|
|
447
|
+
slot.adjust(
|
|
448
|
+
civilDay(ICAL.Time.fromData({ year, month: candidateMonth, day })) -
|
|
449
|
+
civilDay(from),
|
|
450
|
+
0,
|
|
451
|
+
0,
|
|
452
|
+
0,
|
|
453
|
+
);
|
|
454
|
+
return slot;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return null;
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const unmovable = <T>(after: ICAL.Time, reason: string): CalendarResult<T> =>
|
|
461
|
+
calendarFailure(
|
|
462
|
+
"UnmovableRecurrenceRule",
|
|
463
|
+
`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.`,
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
const followStart = (
|
|
467
|
+
rule: ICAL.Recur,
|
|
468
|
+
seriesStart: ICAL.Time,
|
|
469
|
+
newStart: ICAL.Time,
|
|
470
|
+
anchor: ICAL.Time,
|
|
471
|
+
): CalendarResult<FollowedRule> => {
|
|
472
|
+
const days = civilDay(newStart) - civilDay(seriesStart);
|
|
473
|
+
if (days === 0) return { ok: true, value: { rule, startShift: 0 } };
|
|
474
|
+
const anchorBefore = anchor.clone();
|
|
475
|
+
anchorBefore.adjust(-days, 0, 0, 0);
|
|
476
|
+
const moved = rule.clone();
|
|
477
|
+
const parts = moved.parts;
|
|
478
|
+
const kept = (startShift: number): CalendarResult<FollowedRule> => ({
|
|
479
|
+
ok: true,
|
|
480
|
+
value: { rule: moved, startShift },
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const pinned = (["BYSETPOS", "BYYEARDAY", "BYWEEKNO"] as const).some(
|
|
484
|
+
(name) => (parts[name]?.length ?? 0) > 0,
|
|
485
|
+
);
|
|
486
|
+
if (pinned) return unmovable(anchor, "on a pattern of positions");
|
|
487
|
+
|
|
488
|
+
const byMonthDay = parts.BYMONTHDAY ?? [];
|
|
489
|
+
if (byMonthDay.length > 0) {
|
|
490
|
+
if (byMonthDay.length !== 1 || byMonthDay[0] !== anchorBefore.day) {
|
|
491
|
+
return unmovable(anchor, "on several days of the month");
|
|
492
|
+
}
|
|
493
|
+
parts.BYMONTHDAY = [anchor.day];
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const byMonth = parts.BYMONTH ?? [];
|
|
497
|
+
if (byMonth.length > 0 && anchor.month !== anchorBefore.month) {
|
|
498
|
+
if (byMonth.length !== 1 || byMonth[0] !== anchorBefore.month) {
|
|
499
|
+
return unmovable(anchor, "in several months");
|
|
500
|
+
}
|
|
501
|
+
parts.BYMONTH = [anchor.month];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const byDay = parts.BYDAY ?? [];
|
|
505
|
+
const weekdays = byDay
|
|
506
|
+
.map(readByWeekday)
|
|
507
|
+
.filter((weekday): weekday is ByWeekday => weekday !== null);
|
|
508
|
+
if (weekdays.length !== byDay.length) {
|
|
509
|
+
return unmovable(anchor, "on days the calendar cannot read");
|
|
510
|
+
}
|
|
511
|
+
if (weekdays.length === 0) return kept(0);
|
|
512
|
+
|
|
513
|
+
const numbered = weekdays.find((weekday) => weekday.ordinal !== 0);
|
|
514
|
+
if (numbered) {
|
|
515
|
+
if (weekdays.length !== 1) {
|
|
516
|
+
return unmovable(anchor, "on more than one numbered weekday");
|
|
517
|
+
}
|
|
518
|
+
const yearly = moved.freq === "YEARLY";
|
|
519
|
+
if (yearly && (parts.BYMONTH?.length ?? 0) === 0) {
|
|
520
|
+
return unmovable(anchor, "on a numbered weekday of the year");
|
|
521
|
+
}
|
|
522
|
+
const ordinal = ordinalOf(
|
|
523
|
+
anchor.day,
|
|
524
|
+
ICAL.Time.daysInMonth(anchor.month, anchor.year),
|
|
525
|
+
numbered.ordinal,
|
|
526
|
+
);
|
|
527
|
+
const weekday = weekdayOf(anchor);
|
|
528
|
+
parts.BYDAY = [`${ordinal}${WEEKDAYS[weekday]}`];
|
|
529
|
+
const first = firstNumberedSlot(
|
|
530
|
+
seriesStart,
|
|
531
|
+
anchor.month,
|
|
532
|
+
yearly,
|
|
533
|
+
weekday,
|
|
534
|
+
ordinal,
|
|
535
|
+
);
|
|
536
|
+
if (!first) return unmovable(anchor, "on a weekday that never comes");
|
|
537
|
+
return kept(civilDay(first) - civilDay(newStart));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const set = weekdays.map((weekday) => weekday.weekday);
|
|
541
|
+
const workweek =
|
|
542
|
+
set.length === WORKWEEK.length &&
|
|
543
|
+
WORKWEEK.every((weekday) => set.includes(weekday));
|
|
544
|
+
if (moved.freq !== "WEEKLY" || workweek) {
|
|
545
|
+
if (!set.includes(weekdayOf(anchor))) {
|
|
546
|
+
return unmovable(anchor, "on a set of weekdays without that day");
|
|
547
|
+
}
|
|
548
|
+
return kept(-days);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const shift = (((weekdayOf(newStart) - weekdayOf(seriesStart)) % 7) + 7) % 7;
|
|
552
|
+
if (moved.interval > 1 && set.length > 1) {
|
|
553
|
+
const weekStart = moved.wkst - 1;
|
|
554
|
+
const wraps = set.map(
|
|
555
|
+
(weekday) => ((weekday - weekStart + 7) % 7) + shift >= 7,
|
|
556
|
+
);
|
|
557
|
+
if (wraps.some((wrap) => wrap !== wraps[0])) {
|
|
558
|
+
return unmovable(anchor, "on several days every few weeks");
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
parts.BYDAY = set.map((weekday) => WEEKDAYS[(weekday + shift) % 7] ?? "");
|
|
562
|
+
return kept(0);
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const shiftStart = (master: ICAL.Component, days: number): void => {
|
|
566
|
+
if (days === 0) return;
|
|
567
|
+
shiftProperty(master, "dtstart", days);
|
|
568
|
+
shiftProperty(master, "dtend", days);
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const followRule = (
|
|
572
|
+
master: ICAL.Component,
|
|
573
|
+
before: SeriesShape,
|
|
574
|
+
daysIn: number,
|
|
575
|
+
): CalendarResult<null> => {
|
|
576
|
+
const property = master.getFirstProperty("rrule");
|
|
577
|
+
const rule = property?.getFirstValue();
|
|
578
|
+
const start = master.getFirstPropertyValue("dtstart");
|
|
579
|
+
if (!property || !(rule instanceof ICAL.Recur)) {
|
|
580
|
+
return { ok: true, value: null };
|
|
581
|
+
}
|
|
582
|
+
if (!(start instanceof ICAL.Time)) return { ok: true, value: null };
|
|
583
|
+
const anchor = start.clone();
|
|
584
|
+
anchor.adjust(daysIn, 0, 0, 0);
|
|
585
|
+
const followed = followStart(rule, before.start, start, anchor);
|
|
586
|
+
if (!followed.ok) return followed;
|
|
587
|
+
property.setValue(followed.value.rule);
|
|
588
|
+
shiftStart(master, followed.value.startShift);
|
|
589
|
+
return { ok: true, value: null };
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const untilAt = (slot: SeriesSlot, shape: SeriesShape): ICAL.Time => {
|
|
593
|
+
const floating =
|
|
594
|
+
shape.tzid === "" && slot.time.zone !== ICAL.Timezone.utcTimezone;
|
|
595
|
+
if (slot.time.isDate || floating) return slot.time.clone();
|
|
596
|
+
return ICAL.Time.fromJSDate(new Date(slot.instantMs), true);
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
const followUntil = (
|
|
600
|
+
master: ICAL.Component,
|
|
601
|
+
before: SeriesShape,
|
|
602
|
+
collectionTimezone: string,
|
|
603
|
+
): void => {
|
|
604
|
+
if (!before.rule || before.rule.until === null) return;
|
|
605
|
+
const property = master.getFirstProperty("rrule");
|
|
606
|
+
const rule = property?.getFirstValue();
|
|
607
|
+
const now = seriesShape(master);
|
|
608
|
+
if (!property || !(rule instanceof ICAL.Recur) || !now) return;
|
|
609
|
+
if (rule.until === null) return;
|
|
610
|
+
|
|
611
|
+
const previous = ruleSlots(before, collectionTimezone, () => false);
|
|
612
|
+
if (previous.length === 0 || previous.length >= CALENDAR_WINDOW_MAX_STEPS) {
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const open = rule.clone();
|
|
616
|
+
open.until = null;
|
|
617
|
+
const next = ruleSlots(
|
|
618
|
+
{ ...now, rule: open },
|
|
619
|
+
collectionTimezone,
|
|
620
|
+
(_instantMs, count) => count >= previous.length,
|
|
621
|
+
);
|
|
622
|
+
const last = next[previous.length - 1];
|
|
623
|
+
if (!last) return;
|
|
624
|
+
rule.until = untilAt(last, now);
|
|
625
|
+
property.setValue(rule);
|
|
626
|
+
};
|
|
627
|
+
|
|
628
|
+
const replaceTime = (
|
|
629
|
+
component: ICAL.Component,
|
|
630
|
+
name: string,
|
|
631
|
+
time: ICAL.Time,
|
|
632
|
+
tzid: string,
|
|
633
|
+
): void => {
|
|
634
|
+
component.removeAllProperties(name);
|
|
635
|
+
component.addProperty(timeProperty(name, time, tzid));
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
const followSlots = (
|
|
639
|
+
calendar: ParsedCalendar,
|
|
640
|
+
collectionTimezone: string,
|
|
641
|
+
before: SeriesShape,
|
|
642
|
+
after: SeriesShape,
|
|
643
|
+
): void => {
|
|
644
|
+
const instantOf = (value: ICAL.Time, tzid: string): number =>
|
|
645
|
+
resolveTime(value, tzid, collectionTimezone).instantMs;
|
|
646
|
+
|
|
647
|
+
const exceptionInstants: number[] = [];
|
|
648
|
+
for (const override of calendar.overrides) {
|
|
649
|
+
const property = override.getFirstProperty("recurrence-id");
|
|
650
|
+
const value = property?.getFirstValue();
|
|
651
|
+
if (value instanceof ICAL.Time) {
|
|
652
|
+
exceptionInstants.push(instantOf(value, tzidOf(property)));
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
for (const property of calendar.master.getAllProperties("exdate")) {
|
|
656
|
+
for (const value of property.getValues()) {
|
|
657
|
+
if (value instanceof ICAL.Time) {
|
|
658
|
+
exceptionInstants.push(instantOf(value, tzidOf(property)));
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
const latest = Math.max(Number.NEGATIVE_INFINITY, ...exceptionInstants);
|
|
663
|
+
|
|
664
|
+
const previous = ruleSlots(
|
|
665
|
+
before,
|
|
666
|
+
collectionTimezone,
|
|
667
|
+
(instantMs) => instantMs > latest,
|
|
668
|
+
);
|
|
669
|
+
const indexByInstant = new Map(
|
|
670
|
+
previous.map((slot, index) => [slot.instantMs, index]),
|
|
671
|
+
);
|
|
672
|
+
const next = ruleSlots(
|
|
673
|
+
after,
|
|
674
|
+
collectionTimezone,
|
|
675
|
+
(_instantMs, count) => count >= previous.length,
|
|
676
|
+
);
|
|
677
|
+
const moved = (value: ICAL.Time, tzid: string): ICAL.Time => {
|
|
678
|
+
const index = indexByInstant.get(instantOf(value, tzid));
|
|
679
|
+
const slot = index === undefined ? undefined : next[index];
|
|
680
|
+
return slot ? slot.time.clone() : reslot(value, before.start, after.start);
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
for (const override of calendar.overrides) {
|
|
684
|
+
const property = override.getFirstProperty("recurrence-id");
|
|
685
|
+
const recurrenceId = property?.getFirstValue();
|
|
686
|
+
if (!(recurrenceId instanceof ICAL.Time)) continue;
|
|
687
|
+
const slot = moved(recurrenceId, tzidOf(property));
|
|
688
|
+
const start = override.getFirstPropertyValue("dtstart");
|
|
689
|
+
const untouched =
|
|
690
|
+
start instanceof ICAL.Time &&
|
|
691
|
+
instantOf(start, dtStartTzid(override)) ===
|
|
692
|
+
instantOf(recurrenceId, tzidOf(property)) &&
|
|
693
|
+
new ICAL.Event(override).duration.toSeconds() === before.durationSeconds;
|
|
694
|
+
replaceTime(override, "recurrence-id", slot, after.tzid);
|
|
695
|
+
if (!untouched) continue;
|
|
696
|
+
const end = slot.clone();
|
|
697
|
+
end.addDuration(ICAL.Duration.fromSeconds(after.durationSeconds));
|
|
698
|
+
override.removeAllProperties("duration");
|
|
699
|
+
replaceTime(override, "dtstart", slot, after.tzid);
|
|
700
|
+
replaceTime(override, "dtend", end, after.tzid);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
for (const name of ["exdate", "rdate"]) {
|
|
704
|
+
for (const property of calendar.master.getAllProperties(name)) {
|
|
705
|
+
const values = property.getValues();
|
|
706
|
+
if (!values.every((value: unknown) => value instanceof ICAL.Time)) {
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const tzid = tzidOf(property);
|
|
710
|
+
calendar.master.removeProperty(property);
|
|
711
|
+
for (const value of values as ICAL.Time[]) {
|
|
712
|
+
const target =
|
|
713
|
+
name === "exdate"
|
|
714
|
+
? moved(value, tzid)
|
|
715
|
+
: reslot(value, before.start, after.start);
|
|
716
|
+
calendar.master.addProperty(timeProperty(name, target, after.tzid));
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
const occurrenceOffset = (
|
|
723
|
+
calendar: ParsedCalendar,
|
|
724
|
+
collectionTimezone: string,
|
|
725
|
+
recurrenceId: string,
|
|
726
|
+
seriesStart: ICAL.Time,
|
|
727
|
+
): CalendarResult<number> => {
|
|
728
|
+
if (recurrenceId === "") return { ok: true, value: 0 };
|
|
729
|
+
const found = findOccurrence(calendar, collectionTimezone, recurrenceId);
|
|
730
|
+
if (!found.ok) return found;
|
|
731
|
+
const shown = shownStart(found.value);
|
|
732
|
+
if (!shown) {
|
|
733
|
+
return calendarFailure(
|
|
734
|
+
"UnknownOccurrence",
|
|
735
|
+
`${recurrenceId} has no start to move the series from`,
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
return { ok: true, value: civilDay(shown) - civilDay(seriesStart) };
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
const applyToSeries = async (
|
|
742
|
+
calendar: ParsedCalendar,
|
|
743
|
+
collectionTimezone: string,
|
|
744
|
+
recurrenceId: string,
|
|
745
|
+
patch: Partial<CalendarEventFields>,
|
|
746
|
+
): Promise<CalendarResult<ScopedWrite>> => {
|
|
747
|
+
const before = seriesShape(calendar.master);
|
|
748
|
+
if (!before) {
|
|
749
|
+
return calendarFailure(
|
|
750
|
+
"UnknownOccurrence",
|
|
751
|
+
"this series has no start to move",
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
const daysIn = occurrenceOffset(
|
|
755
|
+
calendar,
|
|
756
|
+
collectionTimezone,
|
|
757
|
+
recurrenceId,
|
|
758
|
+
before.start,
|
|
759
|
+
);
|
|
760
|
+
if (!daysIn.ok) return daysIn;
|
|
761
|
+
|
|
762
|
+
const applied = await applyEventFields(
|
|
763
|
+
calendar.master,
|
|
764
|
+
patch,
|
|
765
|
+
collectionTimezone,
|
|
766
|
+
);
|
|
767
|
+
if (!applied.ok) return applied;
|
|
768
|
+
if (patch.start !== undefined) {
|
|
769
|
+
shiftProperty(calendar.master, "dtstart", -daysIn.value);
|
|
770
|
+
}
|
|
771
|
+
if (patch.end !== undefined) {
|
|
772
|
+
shiftProperty(calendar.master, "dtend", -daysIn.value);
|
|
773
|
+
}
|
|
774
|
+
if (patch.recurrenceRule === undefined) {
|
|
775
|
+
const followed = followRule(calendar.master, before, daysIn.value);
|
|
776
|
+
if (!followed.ok) return followed;
|
|
777
|
+
followUntil(calendar.master, before, collectionTimezone);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const after = seriesShape(calendar.master);
|
|
781
|
+
if (after) followSlots(calendar, collectionTimezone, before, after);
|
|
782
|
+
return { ok: true, value: replaceWith(calendar) };
|
|
783
|
+
};
|
|
784
|
+
|
|
289
785
|
/**
|
|
290
786
|
* The occurrence a `This` or `Following` write names, or `null` when the scope
|
|
291
787
|
* collapses to the whole series — which is what "everything from the first
|
|
@@ -362,7 +858,8 @@ const overrideFor = async (
|
|
|
362
858
|
/**
|
|
363
859
|
* Turns an edit of one drawing of a series into the resource writes it means.
|
|
364
860
|
*
|
|
365
|
-
* `All` rewrites the master
|
|
861
|
+
* `All` rewrites the master, moved from the occurrence it names when it names
|
|
862
|
+
* one. `This` writes a RECURRENCE-ID override, which is
|
|
366
863
|
* the only thing iCalendar has for "this one is different". `Following` splits,
|
|
367
864
|
* because a rule cannot change halfway through.
|
|
368
865
|
*/
|
|
@@ -373,7 +870,15 @@ export const applyScopedUpdate = async (
|
|
|
373
870
|
patch: Partial<CalendarEventFields>,
|
|
374
871
|
): Promise<CalendarResult<ScopedWrite>> => {
|
|
375
872
|
if (input.scope === RecurrenceScope.All) {
|
|
376
|
-
|
|
873
|
+
if (!hasRecurrence(calendar) || !touchesTime(patch)) {
|
|
874
|
+
return applyToMaster(calendar, collectionTimezone, patch);
|
|
875
|
+
}
|
|
876
|
+
return applyToSeries(
|
|
877
|
+
calendar,
|
|
878
|
+
collectionTimezone,
|
|
879
|
+
input.recurrenceId,
|
|
880
|
+
patch,
|
|
881
|
+
);
|
|
377
882
|
}
|
|
378
883
|
|
|
379
884
|
const anchored = anchorOf(calendar, collectionTimezone, input);
|