@remit/calendar-service 0.0.9 → 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 +733 -0
- package/src/scope.ts +416 -56
package/package.json
CHANGED
package/src/errors.ts
CHANGED
|
@@ -34,7 +34,8 @@ export type CalendarValidationCode =
|
|
|
34
34
|
/** A per-occurrence scope against an event that has one occurrence. */
|
|
35
35
|
| "NotRecurring"
|
|
36
36
|
/** A RECURRENCE-ID naming no occurrence this series produces. */
|
|
37
|
-
| "UnknownOccurrence"
|
|
37
|
+
| "UnknownOccurrence"
|
|
38
|
+
| "UnmovableRecurrenceRule";
|
|
38
39
|
|
|
39
40
|
export interface CalendarValidationError {
|
|
40
41
|
code: CalendarValidationCode;
|
package/src/scope.test.ts
CHANGED
|
@@ -686,3 +686,736 @@ describe("splitting a series whose DTSTART is not a UTC instant", () => {
|
|
|
686
686
|
assert.equal(rule, "RRULE:FREQ=WEEKLY;UNTIL=20260608T085959Z");
|
|
687
687
|
});
|
|
688
688
|
});
|
|
689
|
+
|
|
690
|
+
const series = (...eventLines: string[]) =>
|
|
691
|
+
ical(
|
|
692
|
+
"BEGIN:VCALENDAR",
|
|
693
|
+
"VERSION:2.0",
|
|
694
|
+
"BEGIN:VEVENT",
|
|
695
|
+
"UID:series@example.com",
|
|
696
|
+
"SUMMARY:Stand-up",
|
|
697
|
+
...eventLines,
|
|
698
|
+
"END:VEVENT",
|
|
699
|
+
"END:VCALENDAR",
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
const withOverride = (masterLines: string[], overrideLines: string[]) =>
|
|
703
|
+
ical(
|
|
704
|
+
"BEGIN:VCALENDAR",
|
|
705
|
+
"VERSION:2.0",
|
|
706
|
+
...AMSTERDAM_VTIMEZONE,
|
|
707
|
+
"BEGIN:VEVENT",
|
|
708
|
+
"UID:series@example.com",
|
|
709
|
+
"SUMMARY:Stand-up",
|
|
710
|
+
...masterLines,
|
|
711
|
+
"END:VEVENT",
|
|
712
|
+
"BEGIN:VEVENT",
|
|
713
|
+
"UID:series@example.com",
|
|
714
|
+
...overrideLines,
|
|
715
|
+
"END:VEVENT",
|
|
716
|
+
"END:VCALENDAR",
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
const starts = async (icalData: string, collectionTimezone = "") =>
|
|
720
|
+
expandCalendar(await read(icalData), collectionTimezone).occurrences.map(
|
|
721
|
+
(occurrence) => occurrence.startAt,
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
const replaced = (
|
|
725
|
+
write: Awaited<ReturnType<typeof applyScopedUpdate>>,
|
|
726
|
+
): string => {
|
|
727
|
+
assert.ok(write.ok, `expected a write, got ${JSON.stringify(write)}`);
|
|
728
|
+
assert.ok(write.value.kind === "Replace");
|
|
729
|
+
return write.value.icalData;
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
describe("a whole-series edit to another day", () => {
|
|
733
|
+
it("moves a weekly rule's weekday with a Monday series moved to Tuesday", async () => {
|
|
734
|
+
const calendar = await read(
|
|
735
|
+
series(
|
|
736
|
+
"DTSTART:20260907T090000Z",
|
|
737
|
+
"DTEND:20260907T100000Z",
|
|
738
|
+
"RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4",
|
|
739
|
+
),
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
const icalData = replaced(
|
|
743
|
+
await applyScopedUpdate(
|
|
744
|
+
calendar,
|
|
745
|
+
"",
|
|
746
|
+
input(RecurrenceScope.All, "2026-09-14T09:00:00Z"),
|
|
747
|
+
{ start: "2026-09-15T09:00:00Z", end: "2026-09-15T10:00:00Z" },
|
|
748
|
+
),
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
const [master] = events(icalData);
|
|
752
|
+
assert.ok(
|
|
753
|
+
master?.includes("RRULE:FREQ=WEEKLY;COUNT=4;BYDAY=TU"),
|
|
754
|
+
`the rule names the new day: ${master?.join(" | ")}`,
|
|
755
|
+
);
|
|
756
|
+
assert.deepEqual(await starts(icalData), [
|
|
757
|
+
"2026-09-08T09:00:00Z",
|
|
758
|
+
"2026-09-15T09:00:00Z",
|
|
759
|
+
"2026-09-22T09:00:00Z",
|
|
760
|
+
"2026-09-29T09:00:00Z",
|
|
761
|
+
]);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it("moves a weekly rule's weekday when the edit names no occurrence", async () => {
|
|
765
|
+
const calendar = await read(
|
|
766
|
+
series(
|
|
767
|
+
"DTSTART:20260907T090000Z",
|
|
768
|
+
"DTEND:20260907T100000Z",
|
|
769
|
+
"RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=3",
|
|
770
|
+
),
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
const icalData = replaced(
|
|
774
|
+
await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
|
|
775
|
+
start: "2026-09-08T09:00:00Z",
|
|
776
|
+
end: "2026-09-08T10:00:00Z",
|
|
777
|
+
}),
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
assert.deepEqual(await starts(icalData), [
|
|
781
|
+
"2026-09-08T09:00:00Z",
|
|
782
|
+
"2026-09-15T09:00:00Z",
|
|
783
|
+
"2026-09-22T09:00:00Z",
|
|
784
|
+
]);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it("moves every weekday of a rule that names several", async () => {
|
|
788
|
+
const calendar = await read(
|
|
789
|
+
series(
|
|
790
|
+
"DTSTART:20260907T090000Z",
|
|
791
|
+
"DTEND:20260907T100000Z",
|
|
792
|
+
"RRULE:FREQ=WEEKLY;BYDAY=MO,WE;COUNT=4",
|
|
793
|
+
),
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
const icalData = replaced(
|
|
797
|
+
await applyScopedUpdate(
|
|
798
|
+
calendar,
|
|
799
|
+
"",
|
|
800
|
+
input(RecurrenceScope.All, "2026-09-09T09:00:00Z"),
|
|
801
|
+
{ start: "2026-09-10T09:00:00Z", end: "2026-09-10T10:00:00Z" },
|
|
802
|
+
),
|
|
803
|
+
);
|
|
804
|
+
|
|
805
|
+
assert.deepEqual(await starts(icalData), [
|
|
806
|
+
"2026-09-08T09:00:00Z",
|
|
807
|
+
"2026-09-10T09:00:00Z",
|
|
808
|
+
"2026-09-15T09:00:00Z",
|
|
809
|
+
"2026-09-17T09:00:00Z",
|
|
810
|
+
]);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it("names the new weekday and its place in the month for a monthly rule", async () => {
|
|
814
|
+
const calendar = await read(
|
|
815
|
+
series(
|
|
816
|
+
"DTSTART:20260914T090000Z",
|
|
817
|
+
"DTEND:20260914T100000Z",
|
|
818
|
+
"RRULE:FREQ=MONTHLY;BYDAY=2MO;COUNT=3",
|
|
819
|
+
),
|
|
820
|
+
);
|
|
821
|
+
|
|
822
|
+
const icalData = replaced(
|
|
823
|
+
await applyScopedUpdate(
|
|
824
|
+
calendar,
|
|
825
|
+
"",
|
|
826
|
+
input(RecurrenceScope.All, "2026-09-14T09:00:00Z"),
|
|
827
|
+
{ start: "2026-09-15T09:00:00Z", end: "2026-09-15T10:00:00Z" },
|
|
828
|
+
),
|
|
829
|
+
);
|
|
830
|
+
|
|
831
|
+
assert.deepEqual(await starts(icalData), [
|
|
832
|
+
"2026-09-15T09:00:00Z",
|
|
833
|
+
"2026-10-20T09:00:00Z",
|
|
834
|
+
"2026-11-17T09:00:00Z",
|
|
835
|
+
]);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it("keeps a monthly rule counting from the end of the month", async () => {
|
|
839
|
+
const calendar = await read(
|
|
840
|
+
series(
|
|
841
|
+
"DTSTART:20260925T090000Z",
|
|
842
|
+
"DTEND:20260925T100000Z",
|
|
843
|
+
"RRULE:FREQ=MONTHLY;BYDAY=-1FR;COUNT=3",
|
|
844
|
+
),
|
|
845
|
+
);
|
|
846
|
+
|
|
847
|
+
const icalData = replaced(
|
|
848
|
+
await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
|
|
849
|
+
start: "2026-09-24T09:00:00Z",
|
|
850
|
+
end: "2026-09-24T10:00:00Z",
|
|
851
|
+
}),
|
|
852
|
+
);
|
|
853
|
+
|
|
854
|
+
assert.deepEqual(await starts(icalData), [
|
|
855
|
+
"2026-09-24T09:00:00Z",
|
|
856
|
+
"2026-10-29T09:00:00Z",
|
|
857
|
+
"2026-11-26T09:00:00Z",
|
|
858
|
+
]);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it("moves the month of a yearly rule pinned to a month and weekday", async () => {
|
|
862
|
+
const calendar = await read(
|
|
863
|
+
series(
|
|
864
|
+
"DTSTART:20260930T090000Z",
|
|
865
|
+
"DTEND:20260930T100000Z",
|
|
866
|
+
"RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1WE;COUNT=2",
|
|
867
|
+
),
|
|
868
|
+
);
|
|
869
|
+
|
|
870
|
+
const icalData = replaced(
|
|
871
|
+
await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
|
|
872
|
+
start: "2026-10-01T09:00:00Z",
|
|
873
|
+
end: "2026-10-01T10:00:00Z",
|
|
874
|
+
}),
|
|
875
|
+
);
|
|
876
|
+
|
|
877
|
+
assert.deepEqual(await starts(icalData), [
|
|
878
|
+
"2026-10-01T09:00:00Z",
|
|
879
|
+
"2027-10-07T09:00:00Z",
|
|
880
|
+
]);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
it("leaves a rule alone when the edit also sets the rule", async () => {
|
|
884
|
+
const calendar = await read(
|
|
885
|
+
series(
|
|
886
|
+
"DTSTART:20260907T090000Z",
|
|
887
|
+
"DTEND:20260907T100000Z",
|
|
888
|
+
"RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=3",
|
|
889
|
+
),
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
const icalData = replaced(
|
|
893
|
+
await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
|
|
894
|
+
start: "2026-09-08T09:00:00Z",
|
|
895
|
+
end: "2026-09-08T10:00:00Z",
|
|
896
|
+
recurrenceRule: "FREQ=WEEKLY;BYDAY=TU,TH;COUNT=3",
|
|
897
|
+
}),
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
assert.deepEqual(await starts(icalData), [
|
|
901
|
+
"2026-09-08T09:00:00Z",
|
|
902
|
+
"2026-09-10T09:00:00Z",
|
|
903
|
+
"2026-09-15T09:00:00Z",
|
|
904
|
+
]);
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it("refuses a move a rule with two numbered weekdays cannot follow", async () => {
|
|
908
|
+
const calendar = await read(
|
|
909
|
+
series(
|
|
910
|
+
"DTSTART:20260907T090000Z",
|
|
911
|
+
"DTEND:20260907T100000Z",
|
|
912
|
+
"RRULE:FREQ=MONTHLY;BYDAY=1MO,3MO;COUNT=4",
|
|
913
|
+
),
|
|
914
|
+
);
|
|
915
|
+
|
|
916
|
+
const write = await applyScopedUpdate(
|
|
917
|
+
calendar,
|
|
918
|
+
"",
|
|
919
|
+
input(RecurrenceScope.All, "2026-09-07T09:00:00Z"),
|
|
920
|
+
{ start: "2026-09-08T09:00:00Z", end: "2026-09-08T10:00:00Z" },
|
|
921
|
+
);
|
|
922
|
+
|
|
923
|
+
assert.equal(write.ok, false);
|
|
924
|
+
assert.ok(!write.ok);
|
|
925
|
+
assert.equal(write.error.code, "UnmovableRecurrenceRule");
|
|
926
|
+
assert.match(write.error.message, /Pick a new repeat/);
|
|
927
|
+
assert.match(write.error.message, /move only this event/);
|
|
928
|
+
assert.doesNotMatch(write.error.message, /recurrenceRule|scope=|BYDAY/);
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
it("refuses a move a rule pinned by BYSETPOS cannot follow", async () => {
|
|
932
|
+
const calendar = await read(
|
|
933
|
+
series(
|
|
934
|
+
"DTSTART:20260930T090000Z",
|
|
935
|
+
"DTEND:20260930T100000Z",
|
|
936
|
+
"RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1;COUNT=3",
|
|
937
|
+
),
|
|
938
|
+
);
|
|
939
|
+
|
|
940
|
+
const write = await applyScopedUpdate(
|
|
941
|
+
calendar,
|
|
942
|
+
"",
|
|
943
|
+
input(RecurrenceScope.All),
|
|
944
|
+
{ start: "2026-09-29T09:00:00Z", end: "2026-09-29T10:00:00Z" },
|
|
945
|
+
);
|
|
946
|
+
|
|
947
|
+
assert.ok(!write.ok);
|
|
948
|
+
assert.equal(write.error.code, "UnmovableRecurrenceRule");
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it("keeps a weekly rule without a weekday on the new start's day", async () => {
|
|
952
|
+
const calendar = await read(WEEKLY);
|
|
953
|
+
|
|
954
|
+
const icalData = replaced(
|
|
955
|
+
await applyScopedUpdate(
|
|
956
|
+
calendar,
|
|
957
|
+
"",
|
|
958
|
+
input(RecurrenceScope.All, "2026-09-14T09:00:00Z"),
|
|
959
|
+
{ start: "2026-09-15T09:00:00Z", end: "2026-09-15T10:00:00Z" },
|
|
960
|
+
),
|
|
961
|
+
);
|
|
962
|
+
|
|
963
|
+
const [master] = events(icalData);
|
|
964
|
+
assert.ok(master?.includes("RRULE:FREQ=WEEKLY;COUNT=5"));
|
|
965
|
+
assert.equal((await starts(icalData))[0], "2026-09-08T09:00:00Z");
|
|
966
|
+
});
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
describe("a whole-series time change with per-occurrence changes", () => {
|
|
970
|
+
const deletedAndEdited = withOverride(
|
|
971
|
+
[
|
|
972
|
+
"DTSTART:20260907T090000Z",
|
|
973
|
+
"DTEND:20260907T100000Z",
|
|
974
|
+
"RRULE:FREQ=WEEKLY;COUNT=5",
|
|
975
|
+
"EXDATE:20260914T090000Z",
|
|
976
|
+
],
|
|
977
|
+
[
|
|
978
|
+
"RECURRENCE-ID:20260921T090000Z",
|
|
979
|
+
"DTSTART:20260921T130000Z",
|
|
980
|
+
"DTEND:20260921T140000Z",
|
|
981
|
+
"SUMMARY:Stand-up, moved",
|
|
982
|
+
],
|
|
983
|
+
);
|
|
984
|
+
|
|
985
|
+
it("keeps a deleted occurrence deleted and an edited one shown once", async () => {
|
|
986
|
+
const calendar = await read(deletedAndEdited);
|
|
987
|
+
|
|
988
|
+
const icalData = replaced(
|
|
989
|
+
await applyScopedUpdate(
|
|
990
|
+
calendar,
|
|
991
|
+
"",
|
|
992
|
+
input(RecurrenceScope.All, "2026-09-28T09:00:00Z"),
|
|
993
|
+
{ start: "2026-09-28T10:00:00Z", end: "2026-09-28T11:00:00Z" },
|
|
994
|
+
),
|
|
995
|
+
);
|
|
996
|
+
|
|
997
|
+
assert.deepEqual(await starts(icalData), [
|
|
998
|
+
"2026-09-07T10:00:00Z",
|
|
999
|
+
"2026-09-21T13:00:00Z",
|
|
1000
|
+
"2026-09-28T10:00:00Z",
|
|
1001
|
+
"2026-10-05T10:00:00Z",
|
|
1002
|
+
]);
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
it("keeps them when the edit names no occurrence", async () => {
|
|
1006
|
+
const calendar = await read(deletedAndEdited);
|
|
1007
|
+
|
|
1008
|
+
const icalData = replaced(
|
|
1009
|
+
await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
|
|
1010
|
+
start: "2026-09-07T10:00:00Z",
|
|
1011
|
+
end: "2026-09-07T11:00:00Z",
|
|
1012
|
+
}),
|
|
1013
|
+
);
|
|
1014
|
+
|
|
1015
|
+
assert.deepEqual(await starts(icalData), [
|
|
1016
|
+
"2026-09-07T10:00:00Z",
|
|
1017
|
+
"2026-09-21T13:00:00Z",
|
|
1018
|
+
"2026-09-28T10:00:00Z",
|
|
1019
|
+
"2026-10-05T10:00:00Z",
|
|
1020
|
+
]);
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
it("keeps them when the series moves to another weekday", async () => {
|
|
1024
|
+
const calendar = await read(
|
|
1025
|
+
withOverride(
|
|
1026
|
+
[
|
|
1027
|
+
"DTSTART:20260907T090000Z",
|
|
1028
|
+
"DTEND:20260907T100000Z",
|
|
1029
|
+
"RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=5",
|
|
1030
|
+
"EXDATE:20260914T090000Z",
|
|
1031
|
+
],
|
|
1032
|
+
[
|
|
1033
|
+
"RECURRENCE-ID:20260921T090000Z",
|
|
1034
|
+
"DTSTART:20260921T130000Z",
|
|
1035
|
+
"DTEND:20260921T140000Z",
|
|
1036
|
+
"SUMMARY:Stand-up, moved",
|
|
1037
|
+
],
|
|
1038
|
+
),
|
|
1039
|
+
);
|
|
1040
|
+
|
|
1041
|
+
const icalData = replaced(
|
|
1042
|
+
await applyScopedUpdate(
|
|
1043
|
+
calendar,
|
|
1044
|
+
"",
|
|
1045
|
+
input(RecurrenceScope.All, "2026-09-28T09:00:00Z"),
|
|
1046
|
+
{ start: "2026-09-29T10:00:00Z", end: "2026-09-29T11:00:00Z" },
|
|
1047
|
+
),
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
assert.deepEqual(await starts(icalData), [
|
|
1051
|
+
"2026-09-08T10:00:00Z",
|
|
1052
|
+
"2026-09-21T13:00:00Z",
|
|
1053
|
+
"2026-09-29T10:00:00Z",
|
|
1054
|
+
"2026-10-06T10:00:00Z",
|
|
1055
|
+
]);
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
it("keeps them on a monthly rule whose occurrences do not move by the same days", async () => {
|
|
1059
|
+
const calendar = await read(
|
|
1060
|
+
series(
|
|
1061
|
+
"DTSTART:20260914T090000Z",
|
|
1062
|
+
"DTEND:20260914T100000Z",
|
|
1063
|
+
"RRULE:FREQ=MONTHLY;BYDAY=2MO;COUNT=3",
|
|
1064
|
+
"EXDATE:20261012T090000Z",
|
|
1065
|
+
),
|
|
1066
|
+
);
|
|
1067
|
+
|
|
1068
|
+
const icalData = replaced(
|
|
1069
|
+
await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
|
|
1070
|
+
start: "2026-09-15T09:00:00Z",
|
|
1071
|
+
end: "2026-09-15T10:00:00Z",
|
|
1072
|
+
}),
|
|
1073
|
+
);
|
|
1074
|
+
|
|
1075
|
+
assert.deepEqual(await starts(icalData), [
|
|
1076
|
+
"2026-09-15T09:00:00Z",
|
|
1077
|
+
"2026-11-17T09:00:00Z",
|
|
1078
|
+
]);
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
it("moves an occurrence edited only in its title along with the series", async () => {
|
|
1082
|
+
const calendar = await read(
|
|
1083
|
+
withOverride(
|
|
1084
|
+
[
|
|
1085
|
+
"DTSTART:20260907T090000Z",
|
|
1086
|
+
"DTEND:20260907T100000Z",
|
|
1087
|
+
"RRULE:FREQ=WEEKLY;COUNT=3",
|
|
1088
|
+
],
|
|
1089
|
+
[
|
|
1090
|
+
"RECURRENCE-ID:20260914T090000Z",
|
|
1091
|
+
"DTSTART:20260914T090000Z",
|
|
1092
|
+
"DTEND:20260914T100000Z",
|
|
1093
|
+
"SUMMARY:Stand-up, renamed",
|
|
1094
|
+
],
|
|
1095
|
+
),
|
|
1096
|
+
);
|
|
1097
|
+
|
|
1098
|
+
const icalData = replaced(
|
|
1099
|
+
await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
|
|
1100
|
+
start: "2026-09-07T10:00:00Z",
|
|
1101
|
+
end: "2026-09-07T11:30:00Z",
|
|
1102
|
+
}),
|
|
1103
|
+
);
|
|
1104
|
+
|
|
1105
|
+
const expanded = expandCalendar(await read(icalData), "").occurrences;
|
|
1106
|
+
assert.deepEqual(
|
|
1107
|
+
expanded.map((occurrence) => [occurrence.startAt, occurrence.endAt]),
|
|
1108
|
+
[
|
|
1109
|
+
["2026-09-07T10:00:00Z", "2026-09-07T11:30:00Z"],
|
|
1110
|
+
["2026-09-14T10:00:00Z", "2026-09-14T11:30:00Z"],
|
|
1111
|
+
["2026-09-21T10:00:00Z", "2026-09-21T11:30:00Z"],
|
|
1112
|
+
],
|
|
1113
|
+
);
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
it("keeps them on a zoned series across a clock change", async () => {
|
|
1117
|
+
const calendar = await read(
|
|
1118
|
+
withOverride(
|
|
1119
|
+
[
|
|
1120
|
+
"DTSTART;TZID=Europe/Amsterdam:20261015T090000",
|
|
1121
|
+
"DTEND;TZID=Europe/Amsterdam:20261015T100000",
|
|
1122
|
+
"RRULE:FREQ=WEEKLY;COUNT=4",
|
|
1123
|
+
"EXDATE;TZID=Europe/Amsterdam:20261022T090000",
|
|
1124
|
+
],
|
|
1125
|
+
[
|
|
1126
|
+
"RECURRENCE-ID;TZID=Europe/Amsterdam:20261029T090000",
|
|
1127
|
+
"DTSTART;TZID=Europe/Amsterdam:20261029T150000",
|
|
1128
|
+
"DTEND;TZID=Europe/Amsterdam:20261029T160000",
|
|
1129
|
+
"SUMMARY:Stand-up, moved",
|
|
1130
|
+
],
|
|
1131
|
+
),
|
|
1132
|
+
);
|
|
1133
|
+
|
|
1134
|
+
const icalData = replaced(
|
|
1135
|
+
await applyScopedUpdate(
|
|
1136
|
+
calendar,
|
|
1137
|
+
"Europe/Amsterdam",
|
|
1138
|
+
input(RecurrenceScope.All, "2026-11-05T08:00:00Z"),
|
|
1139
|
+
{
|
|
1140
|
+
start: "2026-11-05T11:00:00+01:00",
|
|
1141
|
+
end: "2026-11-05T12:00:00+01:00",
|
|
1142
|
+
timeZone: "Europe/Amsterdam",
|
|
1143
|
+
},
|
|
1144
|
+
),
|
|
1145
|
+
);
|
|
1146
|
+
|
|
1147
|
+
assert.deepEqual(await starts(icalData, "Europe/Amsterdam"), [
|
|
1148
|
+
"2026-10-15T09:00:00Z",
|
|
1149
|
+
"2026-10-29T14:00:00Z",
|
|
1150
|
+
"2026-11-05T10:00:00Z",
|
|
1151
|
+
]);
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
it("keeps them when the series becomes all-day", async () => {
|
|
1155
|
+
const calendar = await read(deletedAndEdited);
|
|
1156
|
+
|
|
1157
|
+
const icalData = replaced(
|
|
1158
|
+
await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
|
|
1159
|
+
allDay: true,
|
|
1160
|
+
start: "2026-09-07",
|
|
1161
|
+
end: "2026-09-08",
|
|
1162
|
+
}),
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1165
|
+
const expanded = await starts(icalData);
|
|
1166
|
+
assert.equal(expanded.length, 4);
|
|
1167
|
+
assert.ok(
|
|
1168
|
+
!expanded.some((start) => start.startsWith("2026-09-14")),
|
|
1169
|
+
`the deleted occurrence stays deleted: ${expanded.join(", ")}`,
|
|
1170
|
+
);
|
|
1171
|
+
assert.equal(
|
|
1172
|
+
expanded.filter((start) => start.startsWith("2026-09-21")).length,
|
|
1173
|
+
1,
|
|
1174
|
+
`the edited occurrence shows once: ${expanded.join(", ")}`,
|
|
1175
|
+
);
|
|
1176
|
+
});
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
describe("a whole-series move the review found", () => {
|
|
1180
|
+
const firstMonday = () =>
|
|
1181
|
+
read(
|
|
1182
|
+
series(
|
|
1183
|
+
"DTSTART:20260907T090000Z",
|
|
1184
|
+
"DTEND:20260907T100000Z",
|
|
1185
|
+
"RRULE:FREQ=MONTHLY;BYDAY=1MO;COUNT=4",
|
|
1186
|
+
),
|
|
1187
|
+
);
|
|
1188
|
+
|
|
1189
|
+
it("takes a monthly weekday from the moved second occurrence", async () => {
|
|
1190
|
+
const icalData = replaced(
|
|
1191
|
+
await applyScopedUpdate(
|
|
1192
|
+
await firstMonday(),
|
|
1193
|
+
"",
|
|
1194
|
+
input(RecurrenceScope.All, "2026-10-05T09:00:00Z"),
|
|
1195
|
+
{ start: "2026-10-06T09:00:00Z", end: "2026-10-06T10:00:00Z" },
|
|
1196
|
+
),
|
|
1197
|
+
);
|
|
1198
|
+
|
|
1199
|
+
const [master] = events(icalData);
|
|
1200
|
+
assert.ok(
|
|
1201
|
+
master?.includes("RRULE:FREQ=MONTHLY;COUNT=4;BYDAY=1TU"),
|
|
1202
|
+
`the rule is the first Tuesday: ${master?.join(" | ")}`,
|
|
1203
|
+
);
|
|
1204
|
+
assert.deepEqual(await starts(icalData), [
|
|
1205
|
+
"2026-09-01T09:00:00Z",
|
|
1206
|
+
"2026-10-06T09:00:00Z",
|
|
1207
|
+
"2026-11-03T09:00:00Z",
|
|
1208
|
+
"2026-12-01T09:00:00Z",
|
|
1209
|
+
]);
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
it("takes a monthly weekday from the moved third occurrence", async () => {
|
|
1213
|
+
const icalData = replaced(
|
|
1214
|
+
await applyScopedUpdate(
|
|
1215
|
+
await firstMonday(),
|
|
1216
|
+
"",
|
|
1217
|
+
input(RecurrenceScope.All, "2026-11-02T09:00:00Z"),
|
|
1218
|
+
{ start: "2026-11-03T09:00:00Z", end: "2026-11-03T10:00:00Z" },
|
|
1219
|
+
),
|
|
1220
|
+
);
|
|
1221
|
+
|
|
1222
|
+
assert.deepEqual(await starts(icalData), [
|
|
1223
|
+
"2026-09-01T09:00:00Z",
|
|
1224
|
+
"2026-10-06T09:00:00Z",
|
|
1225
|
+
"2026-11-03T09:00:00Z",
|
|
1226
|
+
"2026-12-01T09:00:00Z",
|
|
1227
|
+
]);
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
it("keeps the moved occurrence's place in its month for a second weekday", async () => {
|
|
1231
|
+
const calendar = await read(
|
|
1232
|
+
series(
|
|
1233
|
+
"DTSTART:20260914T090000Z",
|
|
1234
|
+
"DTEND:20260914T100000Z",
|
|
1235
|
+
"RRULE:FREQ=MONTHLY;BYDAY=2MO;COUNT=3",
|
|
1236
|
+
),
|
|
1237
|
+
);
|
|
1238
|
+
|
|
1239
|
+
const icalData = replaced(
|
|
1240
|
+
await applyScopedUpdate(
|
|
1241
|
+
calendar,
|
|
1242
|
+
"",
|
|
1243
|
+
input(RecurrenceScope.All, "2026-10-12T09:00:00Z"),
|
|
1244
|
+
{ start: "2026-10-13T09:00:00Z", end: "2026-10-13T10:00:00Z" },
|
|
1245
|
+
),
|
|
1246
|
+
);
|
|
1247
|
+
|
|
1248
|
+
assert.deepEqual(await starts(icalData), [
|
|
1249
|
+
"2026-09-08T09:00:00Z",
|
|
1250
|
+
"2026-10-13T09:00:00Z",
|
|
1251
|
+
"2026-11-10T09:00:00Z",
|
|
1252
|
+
]);
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
it("keeps a fifth weekday a fifth weekday", async () => {
|
|
1256
|
+
const calendar = await read(
|
|
1257
|
+
series(
|
|
1258
|
+
"DTSTART:20260330T090000Z",
|
|
1259
|
+
"DTEND:20260330T100000Z",
|
|
1260
|
+
"RRULE:FREQ=MONTHLY;BYDAY=5MO;COUNT=2",
|
|
1261
|
+
),
|
|
1262
|
+
);
|
|
1263
|
+
|
|
1264
|
+
const icalData = replaced(
|
|
1265
|
+
await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
|
|
1266
|
+
start: "2026-03-31T09:00:00Z",
|
|
1267
|
+
end: "2026-03-31T10:00:00Z",
|
|
1268
|
+
}),
|
|
1269
|
+
);
|
|
1270
|
+
|
|
1271
|
+
const [master] = events(icalData);
|
|
1272
|
+
assert.ok(
|
|
1273
|
+
master?.includes("RRULE:FREQ=MONTHLY;COUNT=2;BYDAY=5TU"),
|
|
1274
|
+
`the rule is the fifth Tuesday: ${master?.join(" | ")}`,
|
|
1275
|
+
);
|
|
1276
|
+
assert.deepEqual(await starts(icalData), [
|
|
1277
|
+
"2026-03-31T09:00:00Z",
|
|
1278
|
+
"2026-06-30T09:00:00Z",
|
|
1279
|
+
]);
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
it("keeps a daily Monday-to-Friday rule when the move stays on a weekday", async () => {
|
|
1283
|
+
const calendar = await read(
|
|
1284
|
+
series(
|
|
1285
|
+
"DTSTART:20260907T090000Z",
|
|
1286
|
+
"DTEND:20260907T100000Z",
|
|
1287
|
+
"RRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;COUNT=6",
|
|
1288
|
+
),
|
|
1289
|
+
);
|
|
1290
|
+
|
|
1291
|
+
const icalData = replaced(
|
|
1292
|
+
await applyScopedUpdate(
|
|
1293
|
+
calendar,
|
|
1294
|
+
"",
|
|
1295
|
+
input(RecurrenceScope.All, "2026-09-07T09:00:00Z"),
|
|
1296
|
+
{ start: "2026-09-08T10:00:00Z", end: "2026-09-08T11:00:00Z" },
|
|
1297
|
+
),
|
|
1298
|
+
);
|
|
1299
|
+
|
|
1300
|
+
const [master] = events(icalData);
|
|
1301
|
+
assert.ok(
|
|
1302
|
+
master?.includes("RRULE:FREQ=DAILY;COUNT=6;BYDAY=MO,TU,WE,TH,FR"),
|
|
1303
|
+
`the weekday set is kept: ${master?.join(" | ")}`,
|
|
1304
|
+
);
|
|
1305
|
+
assert.deepEqual(await starts(icalData), [
|
|
1306
|
+
"2026-09-07T10:00:00Z",
|
|
1307
|
+
"2026-09-08T10:00:00Z",
|
|
1308
|
+
"2026-09-09T10:00:00Z",
|
|
1309
|
+
"2026-09-10T10:00:00Z",
|
|
1310
|
+
"2026-09-11T10:00:00Z",
|
|
1311
|
+
"2026-09-14T10:00:00Z",
|
|
1312
|
+
]);
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
it("keeps the app's every-weekday rule when the move stays on a weekday", async () => {
|
|
1316
|
+
const calendar = await read(
|
|
1317
|
+
series(
|
|
1318
|
+
"DTSTART:20260907T090000Z",
|
|
1319
|
+
"DTEND:20260907T100000Z",
|
|
1320
|
+
"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=6",
|
|
1321
|
+
),
|
|
1322
|
+
);
|
|
1323
|
+
|
|
1324
|
+
const icalData = replaced(
|
|
1325
|
+
await applyScopedUpdate(
|
|
1326
|
+
calendar,
|
|
1327
|
+
"",
|
|
1328
|
+
input(RecurrenceScope.All, "2026-09-09T09:00:00Z"),
|
|
1329
|
+
{ start: "2026-09-10T09:00:00Z", end: "2026-09-10T10:00:00Z" },
|
|
1330
|
+
),
|
|
1331
|
+
);
|
|
1332
|
+
|
|
1333
|
+
const [master] = events(icalData);
|
|
1334
|
+
assert.ok(
|
|
1335
|
+
master?.includes("RRULE:FREQ=WEEKLY;COUNT=6;BYDAY=MO,TU,WE,TH,FR"),
|
|
1336
|
+
);
|
|
1337
|
+
assert.deepEqual(await starts(icalData), [
|
|
1338
|
+
"2026-09-07T09:00:00Z",
|
|
1339
|
+
"2026-09-08T09:00:00Z",
|
|
1340
|
+
"2026-09-09T09:00:00Z",
|
|
1341
|
+
"2026-09-10T09:00:00Z",
|
|
1342
|
+
"2026-09-11T09:00:00Z",
|
|
1343
|
+
"2026-09-14T09:00:00Z",
|
|
1344
|
+
]);
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
it("refuses to move an every-weekday series onto a weekend", async () => {
|
|
1348
|
+
const calendar = await read(
|
|
1349
|
+
series(
|
|
1350
|
+
"DTSTART:20260907T090000Z",
|
|
1351
|
+
"DTEND:20260907T100000Z",
|
|
1352
|
+
"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;COUNT=6",
|
|
1353
|
+
),
|
|
1354
|
+
);
|
|
1355
|
+
|
|
1356
|
+
const write = await applyScopedUpdate(
|
|
1357
|
+
calendar,
|
|
1358
|
+
"",
|
|
1359
|
+
input(RecurrenceScope.All, "2026-09-11T09:00:00Z"),
|
|
1360
|
+
{ start: "2026-09-12T09:00:00Z", end: "2026-09-12T10:00:00Z" },
|
|
1361
|
+
);
|
|
1362
|
+
|
|
1363
|
+
assert.ok(!write.ok);
|
|
1364
|
+
assert.equal(write.error.code, "UnmovableRecurrenceRule");
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
it("moves an end date with the series so the last occurrence stays", async () => {
|
|
1368
|
+
const calendar = await read(
|
|
1369
|
+
series(
|
|
1370
|
+
"DTSTART:20260907T090000Z",
|
|
1371
|
+
"DTEND:20260907T100000Z",
|
|
1372
|
+
"RRULE:FREQ=WEEKLY;BYDAY=MO;UNTIL=20260928T090000Z",
|
|
1373
|
+
"EXDATE:20260928T090000Z",
|
|
1374
|
+
),
|
|
1375
|
+
);
|
|
1376
|
+
|
|
1377
|
+
const icalData = replaced(
|
|
1378
|
+
await applyScopedUpdate(
|
|
1379
|
+
calendar,
|
|
1380
|
+
"",
|
|
1381
|
+
input(RecurrenceScope.All, "2026-09-14T09:00:00Z"),
|
|
1382
|
+
{ start: "2026-09-15T09:00:00Z", end: "2026-09-15T10:00:00Z" },
|
|
1383
|
+
),
|
|
1384
|
+
);
|
|
1385
|
+
|
|
1386
|
+
const [master] = events(icalData);
|
|
1387
|
+
assert.ok(
|
|
1388
|
+
master?.includes("RRULE:FREQ=WEEKLY;BYDAY=TU;UNTIL=20260929T090000Z"),
|
|
1389
|
+
`the end date moves with the series: ${master?.join(" | ")}`,
|
|
1390
|
+
);
|
|
1391
|
+
assert.deepEqual(await starts(icalData), [
|
|
1392
|
+
"2026-09-08T09:00:00Z",
|
|
1393
|
+
"2026-09-15T09:00:00Z",
|
|
1394
|
+
"2026-09-22T09:00:00Z",
|
|
1395
|
+
]);
|
|
1396
|
+
});
|
|
1397
|
+
|
|
1398
|
+
it("keeps the last occurrence of a series with an end date moved later in the day", async () => {
|
|
1399
|
+
const calendar = await read(
|
|
1400
|
+
series(
|
|
1401
|
+
"DTSTART:20260907T090000Z",
|
|
1402
|
+
"DTEND:20260907T100000Z",
|
|
1403
|
+
"RRULE:FREQ=WEEKLY;UNTIL=20260928T090000Z",
|
|
1404
|
+
"EXDATE:20260921T090000Z",
|
|
1405
|
+
),
|
|
1406
|
+
);
|
|
1407
|
+
|
|
1408
|
+
const icalData = replaced(
|
|
1409
|
+
await applyScopedUpdate(calendar, "", input(RecurrenceScope.All), {
|
|
1410
|
+
start: "2026-09-07T10:00:00Z",
|
|
1411
|
+
end: "2026-09-07T11:00:00Z",
|
|
1412
|
+
}),
|
|
1413
|
+
);
|
|
1414
|
+
|
|
1415
|
+
assert.deepEqual(await starts(icalData), [
|
|
1416
|
+
"2026-09-07T10:00:00Z",
|
|
1417
|
+
"2026-09-14T10:00:00Z",
|
|
1418
|
+
"2026-09-28T10:00:00Z",
|
|
1419
|
+
]);
|
|
1420
|
+
});
|
|
1421
|
+
});
|
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];
|
|
@@ -308,17 +308,12 @@ const shiftProperty = (
|
|
|
308
308
|
property.setValue(shifted);
|
|
309
309
|
};
|
|
310
310
|
|
|
311
|
-
/** Where an occurrence is drawn, which a moved one no longer shares with its slot. */
|
|
312
311
|
const shownStart = (occurrence: FoundOccurrence): ICAL.Time | null => {
|
|
313
312
|
if (occurrence.details) return occurrence.details.startDate;
|
|
314
313
|
const start = occurrence.override?.getFirstPropertyValue("dtstart");
|
|
315
314
|
return start instanceof ICAL.Time ? start : null;
|
|
316
315
|
};
|
|
317
316
|
|
|
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
317
|
const reslot = (
|
|
323
318
|
value: ICAL.Time,
|
|
324
319
|
before: ICAL.Time,
|
|
@@ -329,70 +324,440 @@ const reslot = (
|
|
|
329
324
|
return moved;
|
|
330
325
|
};
|
|
331
326
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
+
|
|
338
638
|
const followSlots = (
|
|
339
639
|
calendar: ParsedCalendar,
|
|
340
|
-
|
|
341
|
-
|
|
640
|
+
collectionTimezone: string,
|
|
641
|
+
before: SeriesShape,
|
|
642
|
+
after: SeriesShape,
|
|
342
643
|
): void => {
|
|
343
|
-
|
|
344
|
-
|
|
644
|
+
const instantOf = (value: ICAL.Time, tzid: string): number =>
|
|
645
|
+
resolveTime(value, tzid, collectionTimezone).instantMs;
|
|
646
|
+
|
|
647
|
+
const exceptionInstants: number[] = [];
|
|
345
648
|
for (const override of calendar.overrides) {
|
|
346
|
-
const
|
|
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();
|
|
347
686
|
if (!(recurrenceId instanceof ICAL.Time)) continue;
|
|
348
|
-
|
|
349
|
-
override.
|
|
350
|
-
|
|
351
|
-
|
|
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);
|
|
352
701
|
}
|
|
702
|
+
|
|
353
703
|
for (const name of ["exdate", "rdate"]) {
|
|
354
704
|
for (const property of calendar.master.getAllProperties(name)) {
|
|
355
705
|
const values = property.getValues();
|
|
356
706
|
if (!values.every((value: unknown) => value instanceof ICAL.Time)) {
|
|
357
707
|
continue;
|
|
358
708
|
}
|
|
709
|
+
const tzid = tzidOf(property);
|
|
359
710
|
calendar.master.removeProperty(property);
|
|
360
711
|
for (const value of values as ICAL.Time[]) {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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));
|
|
364
717
|
}
|
|
365
718
|
}
|
|
366
719
|
}
|
|
367
720
|
};
|
|
368
721
|
|
|
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 (
|
|
722
|
+
const occurrenceOffset = (
|
|
378
723
|
calendar: ParsedCalendar,
|
|
379
724
|
collectionTimezone: string,
|
|
380
725
|
recurrenceId: string,
|
|
381
|
-
|
|
382
|
-
):
|
|
726
|
+
seriesStart: ICAL.Time,
|
|
727
|
+
): CalendarResult<number> => {
|
|
728
|
+
if (recurrenceId === "") return { ok: true, value: 0 };
|
|
383
729
|
const found = findOccurrence(calendar, collectionTimezone, recurrenceId);
|
|
384
730
|
if (!found.ok) return found;
|
|
385
|
-
const seriesStart = calendar.master.getFirstPropertyValue("dtstart");
|
|
386
731
|
const shown = shownStart(found.value);
|
|
387
|
-
if (!
|
|
732
|
+
if (!shown) {
|
|
388
733
|
return calendarFailure(
|
|
389
734
|
"UnknownOccurrence",
|
|
390
735
|
`${recurrenceId} has no start to move the series from`,
|
|
391
736
|
);
|
|
392
737
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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;
|
|
396
761
|
|
|
397
762
|
const applied = await applyEventFields(
|
|
398
763
|
calendar.master,
|
|
@@ -401,20 +766,19 @@ const applyToSeriesFrom = async (
|
|
|
401
766
|
);
|
|
402
767
|
if (!applied.ok) return applied;
|
|
403
768
|
if (patch.start !== undefined) {
|
|
404
|
-
shiftProperty(calendar.master, "dtstart", -daysIn);
|
|
769
|
+
shiftProperty(calendar.master, "dtstart", -daysIn.value);
|
|
405
770
|
}
|
|
406
771
|
if (patch.end !== undefined) {
|
|
407
|
-
shiftProperty(calendar.master, "dtend", -daysIn);
|
|
772
|
+
shiftProperty(calendar.master, "dtend", -daysIn.value);
|
|
408
773
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
(after.toString() !== before.toString() ||
|
|
414
|
-
dtStartTzid(calendar.master) !== beforeTzid)
|
|
415
|
-
) {
|
|
416
|
-
followSlots(calendar, before, after);
|
|
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);
|
|
417
778
|
}
|
|
779
|
+
|
|
780
|
+
const after = seriesShape(calendar.master);
|
|
781
|
+
if (after) followSlots(calendar, collectionTimezone, before, after);
|
|
418
782
|
return { ok: true, value: replaceWith(calendar) };
|
|
419
783
|
};
|
|
420
784
|
|
|
@@ -506,14 +870,10 @@ export const applyScopedUpdate = async (
|
|
|
506
870
|
patch: Partial<CalendarEventFields>,
|
|
507
871
|
): Promise<CalendarResult<ScopedWrite>> => {
|
|
508
872
|
if (input.scope === RecurrenceScope.All) {
|
|
509
|
-
if (
|
|
510
|
-
input.recurrenceId === "" ||
|
|
511
|
-
!hasRecurrence(calendar) ||
|
|
512
|
-
!touchesTime(patch)
|
|
513
|
-
) {
|
|
873
|
+
if (!hasRecurrence(calendar) || !touchesTime(patch)) {
|
|
514
874
|
return applyToMaster(calendar, collectionTimezone, patch);
|
|
515
875
|
}
|
|
516
|
-
return
|
|
876
|
+
return applyToSeries(
|
|
517
877
|
calendar,
|
|
518
878
|
collectionTimezone,
|
|
519
879
|
input.recurrenceId,
|