@remit/calendar-service 0.0.1
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 +32 -0
- package/src/errors.ts +44 -0
- package/src/etag.test.ts +54 -0
- package/src/etag.ts +12 -0
- package/src/expand.test.ts +333 -0
- package/src/expand.ts +186 -0
- package/src/fixtures.ts +52 -0
- package/src/index.ts +29 -0
- package/src/parse.test.ts +253 -0
- package/src/parse.ts +132 -0
- package/src/project.test.ts +247 -0
- package/src/project.ts +104 -0
- package/src/put.test.ts +512 -0
- package/src/put.ts +123 -0
- package/src/time.ts +194 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { AMSTERDAM_VTIMEZONE, ical, singleEvent } from "./fixtures.js";
|
|
4
|
+
import { parseCalendar } from "./parse.js";
|
|
5
|
+
import { projectCalendar } from "./project.js";
|
|
6
|
+
|
|
7
|
+
const project = async (icalData: string, timezone = "") => {
|
|
8
|
+
const parsed = await parseCalendar(icalData);
|
|
9
|
+
assert.ok(parsed.ok, `expected a parse, got ${JSON.stringify(parsed)}`);
|
|
10
|
+
return projectCalendar(parsed.value, timezone);
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const projection = async (icalData: string, timezone = "") => {
|
|
14
|
+
const result = await project(icalData, timezone);
|
|
15
|
+
assert.ok(result.ok, `expected a projection, got ${JSON.stringify(result)}`);
|
|
16
|
+
return result.value;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("projectCalendar", () => {
|
|
20
|
+
it("projects the columns a UTC event carries", async () => {
|
|
21
|
+
const value = await projection(
|
|
22
|
+
singleEvent(
|
|
23
|
+
"DTSTART:20260826T090000Z",
|
|
24
|
+
"DTEND:20260826T100000Z",
|
|
25
|
+
"SUMMARY:Quarterly review",
|
|
26
|
+
"STATUS:TENTATIVE",
|
|
27
|
+
"TRANSP:TRANSPARENT",
|
|
28
|
+
"SEQUENCE:3",
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
assert.deepEqual(value, {
|
|
33
|
+
icalUid: "fixture@example.com",
|
|
34
|
+
summary: "Quarterly review",
|
|
35
|
+
dtStart: "2026-08-26T09:00:00+00:00",
|
|
36
|
+
dtEnd: "2026-08-26T10:00:00+00:00",
|
|
37
|
+
allDay: false,
|
|
38
|
+
zoneCertainty: "Explicit",
|
|
39
|
+
status: "Tentative",
|
|
40
|
+
transparency: "Transparent",
|
|
41
|
+
hasRecurrence: false,
|
|
42
|
+
sequence: 3,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("gives an event that states neither the RFC's defaults", async () => {
|
|
47
|
+
const value = await projection(
|
|
48
|
+
singleEvent("DTSTART:20260826T090000Z", "DTEND:20260826T100000Z"),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
assert.equal(value.status, "Confirmed");
|
|
52
|
+
assert.equal(value.transparency, "Opaque");
|
|
53
|
+
assert.equal(value.summary, "");
|
|
54
|
+
assert.equal(value.sequence, 0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("keeps the event's own offset, resolved from the VTIMEZONE it carries", async () => {
|
|
58
|
+
const value = await projection(
|
|
59
|
+
ical(
|
|
60
|
+
"BEGIN:VCALENDAR",
|
|
61
|
+
"VERSION:2.0",
|
|
62
|
+
...AMSTERDAM_VTIMEZONE,
|
|
63
|
+
"BEGIN:VEVENT",
|
|
64
|
+
"UID:fixture@example.com",
|
|
65
|
+
"DTSTART;TZID=Europe/Amsterdam:20260826T090000",
|
|
66
|
+
"DTEND;TZID=Europe/Amsterdam:20260826T100000",
|
|
67
|
+
"END:VEVENT",
|
|
68
|
+
"END:VCALENDAR",
|
|
69
|
+
),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
assert.equal(value.dtStart, "2026-08-26T09:00:00+02:00");
|
|
73
|
+
assert.equal(value.dtEnd, "2026-08-26T10:00:00+02:00");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("resolves a TZID the resource carries no VTIMEZONE for", async () => {
|
|
77
|
+
const value = await projection(
|
|
78
|
+
singleEvent(
|
|
79
|
+
"DTSTART;TZID=Europe/Berlin:20260826T090000",
|
|
80
|
+
"DTEND;TZID=Europe/Berlin:20260826T100000",
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
assert.equal(value.dtStart, "2026-08-26T09:00:00+02:00");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("reads a floating time in the collection's timezone", async () => {
|
|
88
|
+
const value = await projection(
|
|
89
|
+
singleEvent("DTSTART:20260826T090000", "DTEND:20260826T100000"),
|
|
90
|
+
"Europe/Berlin",
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
assert.equal(value.dtStart, "2026-08-26T09:00:00+02:00");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("marks a zone nothing could resolve as ambiguous rather than reading it as UTC in silence", async () => {
|
|
97
|
+
// A Windows zone name with no VTIMEZONE. The event still gets an instant —
|
|
98
|
+
// there is nothing else to give it — but a two-hour error that presents as
|
|
99
|
+
// a fact is the failure this marker exists to prevent.
|
|
100
|
+
const value = await projection(
|
|
101
|
+
singleEvent(
|
|
102
|
+
"DTSTART;TZID=W. Europe Standard Time:20260826T090000",
|
|
103
|
+
"DTEND;TZID=W. Europe Standard Time:20260826T100000",
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
assert.equal(value.dtStart, "2026-08-26T09:00:00+00:00");
|
|
108
|
+
assert.equal(value.zoneCertainty, "Ambiguous");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("marks a floating time as local to the collection", async () => {
|
|
112
|
+
const value = await projection(
|
|
113
|
+
singleEvent("DTSTART:20260826T090000", "DTEND:20260826T100000"),
|
|
114
|
+
"Europe/Berlin",
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
assert.equal(value.zoneCertainty, "Local");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("marks a zone that resolved as explicit, however it resolved", async () => {
|
|
121
|
+
const fromVtimezone = await projection(
|
|
122
|
+
ical(
|
|
123
|
+
"BEGIN:VCALENDAR",
|
|
124
|
+
"VERSION:2.0",
|
|
125
|
+
...AMSTERDAM_VTIMEZONE,
|
|
126
|
+
"BEGIN:VEVENT",
|
|
127
|
+
"UID:fixture@example.com",
|
|
128
|
+
"DTSTART;TZID=Europe/Amsterdam:20260826T090000",
|
|
129
|
+
"DTEND;TZID=Europe/Amsterdam:20260826T100000",
|
|
130
|
+
"END:VEVENT",
|
|
131
|
+
"END:VCALENDAR",
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
const fromPlatform = await projection(
|
|
135
|
+
singleEvent(
|
|
136
|
+
"DTSTART;TZID=Europe/Berlin:20260826T090000",
|
|
137
|
+
"DTEND;TZID=Europe/Berlin:20260826T100000",
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
const fromUtc = await projection(
|
|
141
|
+
singleEvent("DTSTART:20260826T090000Z", "DTEND:20260826T100000Z"),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
assert.equal(fromVtimezone.zoneCertainty, "Explicit");
|
|
145
|
+
assert.equal(fromPlatform.zoneCertainty, "Explicit");
|
|
146
|
+
assert.equal(fromUtc.zoneCertainty, "Explicit");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("projects an all-day event as midnight with an exclusive end", async () => {
|
|
150
|
+
const value = await projection(
|
|
151
|
+
singleEvent("DTSTART;VALUE=DATE:20260826", "DTEND;VALUE=DATE:20260828"),
|
|
152
|
+
"Europe/Berlin",
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
assert.equal(value.allDay, true);
|
|
156
|
+
assert.equal(value.dtStart, "2026-08-26T00:00:00+02:00");
|
|
157
|
+
assert.equal(value.dtEnd, "2026-08-28T00:00:00+02:00");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("ends a one-day all-day event on the following day, per RFC 5545", async () => {
|
|
161
|
+
const value = await projection(
|
|
162
|
+
singleEvent("DTSTART;VALUE=DATE:20260826"),
|
|
163
|
+
"Europe/Berlin",
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
assert.equal(value.dtEnd, "2026-08-27T00:00:00+02:00");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("resolves an end stated as a DURATION", async () => {
|
|
170
|
+
const value = await projection(
|
|
171
|
+
singleEvent("DTSTART:20260826T090000Z", "DURATION:PT90M"),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
assert.equal(value.dtEnd, "2026-08-26T10:30:00+00:00");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("marks an event that recurs", async () => {
|
|
178
|
+
const withRrule = await projection(
|
|
179
|
+
singleEvent("DTSTART:20260826T090000Z", "RRULE:FREQ=WEEKLY;COUNT=3"),
|
|
180
|
+
);
|
|
181
|
+
const withRdate = await projection(
|
|
182
|
+
singleEvent("DTSTART:20260826T090000Z", "RDATE:20260902T090000Z"),
|
|
183
|
+
);
|
|
184
|
+
const withOnlyOverrides = await projection(
|
|
185
|
+
ical(
|
|
186
|
+
"BEGIN:VCALENDAR",
|
|
187
|
+
"VERSION:2.0",
|
|
188
|
+
"BEGIN:VEVENT",
|
|
189
|
+
"UID:fixture@example.com",
|
|
190
|
+
"DTSTART:20260826T090000Z",
|
|
191
|
+
"DTEND:20260826T100000Z",
|
|
192
|
+
"END:VEVENT",
|
|
193
|
+
"BEGIN:VEVENT",
|
|
194
|
+
"UID:fixture@example.com",
|
|
195
|
+
"RECURRENCE-ID:20260902T090000Z",
|
|
196
|
+
"DTSTART:20260902T110000Z",
|
|
197
|
+
"DTEND:20260902T120000Z",
|
|
198
|
+
"END:VEVENT",
|
|
199
|
+
"END:VCALENDAR",
|
|
200
|
+
),
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
assert.equal(withRrule.hasRecurrence, true);
|
|
204
|
+
assert.equal(withRdate.hasRecurrence, true);
|
|
205
|
+
assert.equal(withOnlyOverrides.hasRecurrence, true);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("refuses an event that ends before it starts, naming both times", async () => {
|
|
209
|
+
const result = await project(
|
|
210
|
+
singleEvent("DTSTART:20260826T100000Z", "DTEND:20260826T090000Z"),
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
assert.ok(!result.ok);
|
|
214
|
+
assert.equal(result.error.code, "BackwardsEnd");
|
|
215
|
+
assert.match(result.error.message, /2026-08-26T09:00:00Z/);
|
|
216
|
+
assert.match(result.error.message, /2026-08-26T10:00:00Z/);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("refuses an event whose end precedes its start only once zones are applied", async () => {
|
|
220
|
+
// 09:30 in Amsterdam is 07:30Z, half an hour before the 08:00Z start — a
|
|
221
|
+
// comparison of the wall-clock strings would have called this fine.
|
|
222
|
+
const result = await project(
|
|
223
|
+
ical(
|
|
224
|
+
"BEGIN:VCALENDAR",
|
|
225
|
+
"VERSION:2.0",
|
|
226
|
+
...AMSTERDAM_VTIMEZONE,
|
|
227
|
+
"BEGIN:VEVENT",
|
|
228
|
+
"UID:fixture@example.com",
|
|
229
|
+
"DTSTART:20260826T080000Z",
|
|
230
|
+
"DTEND;TZID=Europe/Amsterdam:20260826T093000",
|
|
231
|
+
"END:VEVENT",
|
|
232
|
+
"END:VCALENDAR",
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
assert.ok(!result.ok);
|
|
237
|
+
assert.equal(result.error.code, "BackwardsEnd");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("accepts a zero-length event", async () => {
|
|
241
|
+
const value = await projection(
|
|
242
|
+
singleEvent("DTSTART:20260826T090000Z", "DTEND:20260826T090000Z"),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
assert.equal(value.dtEnd, "2026-08-26T09:00:00+00:00");
|
|
246
|
+
});
|
|
247
|
+
});
|
package/src/project.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { CalendarObjectItem } from "@remit/data-ports";
|
|
2
|
+
import { CalendarEventStatus, CalendarTransparency } from "@remit/domain-enums";
|
|
3
|
+
import ICAL from "ical.js";
|
|
4
|
+
import { type CalendarResult, calendarFailure } from "./errors.js";
|
|
5
|
+
import type { ParsedCalendar } from "./parse.js";
|
|
6
|
+
import { resolveTime, tzidOf } from "./time.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The columns `CalendarObject` carries beside its bytes. Every one of them is
|
|
10
|
+
* read out of the VEVENT here and nowhere else, so the row can only ever
|
|
11
|
+
* describe the `icalData` it was written with.
|
|
12
|
+
*/
|
|
13
|
+
export type CalendarObjectProjection = Pick<
|
|
14
|
+
CalendarObjectItem,
|
|
15
|
+
| "icalUid"
|
|
16
|
+
| "summary"
|
|
17
|
+
| "dtStart"
|
|
18
|
+
| "dtEnd"
|
|
19
|
+
| "allDay"
|
|
20
|
+
| "zoneCertainty"
|
|
21
|
+
| "status"
|
|
22
|
+
| "transparency"
|
|
23
|
+
| "hasRecurrence"
|
|
24
|
+
| "sequence"
|
|
25
|
+
>;
|
|
26
|
+
|
|
27
|
+
const STATUS_BY_ICAL: Record<string, CalendarObjectItem["status"]> = {
|
|
28
|
+
CONFIRMED: CalendarEventStatus.Confirmed,
|
|
29
|
+
TENTATIVE: CalendarEventStatus.Tentative,
|
|
30
|
+
CANCELLED: CalendarEventStatus.Cancelled,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const TRANSPARENCY_BY_ICAL: Record<string, CalendarObjectItem["transparency"]> =
|
|
34
|
+
{
|
|
35
|
+
OPAQUE: CalendarTransparency.Opaque,
|
|
36
|
+
TRANSPARENT: CalendarTransparency.Transparent,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const readString = (component: ICAL.Component, name: string): string => {
|
|
40
|
+
const value = component.getFirstPropertyValue(name);
|
|
41
|
+
return typeof value === "string" ? value : "";
|
|
42
|
+
};
|
|
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
|
+
export const hasRecurrence = (calendar: ParsedCalendar): boolean =>
|
|
52
|
+
calendar.master.hasProperty("rrule") ||
|
|
53
|
+
calendar.master.hasProperty("rdate") ||
|
|
54
|
+
calendar.overrides.length > 0;
|
|
55
|
+
|
|
56
|
+
export const projectCalendar = (
|
|
57
|
+
calendar: ParsedCalendar,
|
|
58
|
+
collectionTimezone: string,
|
|
59
|
+
): CalendarResult<CalendarObjectProjection> => {
|
|
60
|
+
const event = new ICAL.Event(calendar.master);
|
|
61
|
+
const start = resolveTime(
|
|
62
|
+
event.startDate,
|
|
63
|
+
tzidOf(calendar.master.getFirstProperty("dtstart")),
|
|
64
|
+
collectionTimezone,
|
|
65
|
+
);
|
|
66
|
+
const end = resolveTime(
|
|
67
|
+
event.endDate,
|
|
68
|
+
tzidOf(
|
|
69
|
+
calendar.master.getFirstProperty("dtend") ??
|
|
70
|
+
calendar.master.getFirstProperty("dtstart"),
|
|
71
|
+
),
|
|
72
|
+
collectionTimezone,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (end.instantMs < start.instantMs) {
|
|
76
|
+
return calendarFailure(
|
|
77
|
+
"BackwardsEnd",
|
|
78
|
+
`the event ends at ${end.isoUtc}, before it starts at ${start.isoUtc}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const sequence = calendar.master.getFirstPropertyValue("sequence");
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
value: {
|
|
87
|
+
icalUid: calendar.uid,
|
|
88
|
+
summary: readString(calendar.master, "summary"),
|
|
89
|
+
dtStart: start.isoOffset,
|
|
90
|
+
dtEnd: end.isoOffset,
|
|
91
|
+
allDay: start.isDate,
|
|
92
|
+
zoneCertainty: start.certainty,
|
|
93
|
+
status:
|
|
94
|
+
STATUS_BY_ICAL[readString(calendar.master, "status").toUpperCase()] ??
|
|
95
|
+
CalendarEventStatus.Confirmed,
|
|
96
|
+
transparency:
|
|
97
|
+
TRANSPARENCY_BY_ICAL[
|
|
98
|
+
readString(calendar.master, "transp").toUpperCase()
|
|
99
|
+
] ?? CalendarTransparency.Opaque,
|
|
100
|
+
hasRecurrence: hasRecurrence(calendar),
|
|
101
|
+
sequence: typeof sequence === "number" ? sequence : 0,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
};
|