@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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * iCalendar fixtures, assembled from lines so the CRLF the RFC requires is
3
+ * explicit rather than a property of this file's own line endings.
4
+ */
5
+ export const ical = (...lines: string[]): string => `${lines.join("\r\n")}\r\n`;
6
+
7
+ /**
8
+ * The same resource as a client that ignores RFC 5545 3.1 would send it. Real
9
+ * `.ics` files arrive LF-only often enough — anything that has been through a
10
+ * text-mode checkout, an editor, or a naive copy — that reading them is part of
11
+ * the job, not an edge case.
12
+ */
13
+ export const asLf = (icalData: string): string =>
14
+ icalData.replace(/\r\n/g, "\n");
15
+
16
+ /**
17
+ * Europe/Amsterdam as a client writes it into a resource. Present so a
18
+ * DST-crossing series resolves against the definition the resource itself
19
+ * carries, which is the only zone data RFC 5545 promises a server.
20
+ */
21
+ export const AMSTERDAM_VTIMEZONE = [
22
+ "BEGIN:VTIMEZONE",
23
+ "TZID:Europe/Amsterdam",
24
+ "BEGIN:DAYLIGHT",
25
+ "TZOFFSETFROM:+0100",
26
+ "TZOFFSETTO:+0200",
27
+ "TZNAME:CEST",
28
+ "DTSTART:19700329T020000",
29
+ "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU",
30
+ "END:DAYLIGHT",
31
+ "BEGIN:STANDARD",
32
+ "TZOFFSETFROM:+0200",
33
+ "TZOFFSETTO:+0100",
34
+ "TZNAME:CET",
35
+ "DTSTART:19701025T030000",
36
+ "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU",
37
+ "END:STANDARD",
38
+ "END:VTIMEZONE",
39
+ ];
40
+
41
+ export const singleEvent = (...eventLines: string[]): string =>
42
+ ical(
43
+ "BEGIN:VCALENDAR",
44
+ "VERSION:2.0",
45
+ "PRODID:-//Remit//Calendar Tests//EN",
46
+ "BEGIN:VEVENT",
47
+ "UID:fixture@example.com",
48
+ "DTSTAMP:20260801T090000Z",
49
+ ...eventLines,
50
+ "END:VEVENT",
51
+ "END:VCALENDAR",
52
+ );
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ export type {
2
+ CalendarResult,
3
+ CalendarValidationCode,
4
+ CalendarValidationError,
5
+ } from "./errors.js";
6
+ export { computeEtag } from "./etag.js";
7
+ export {
8
+ CALENDAR_EXPANSION_HORIZON_DAYS,
9
+ CALENDAR_EXPANSION_MAX_OCCURRENCES,
10
+ type CalendarExpansion,
11
+ expandCalendar,
12
+ } from "./expand.js";
13
+ export {
14
+ type ParsedCalendar,
15
+ parseCalendar,
16
+ serializeCalendar,
17
+ } from "./parse.js";
18
+ export {
19
+ type CalendarObjectProjection,
20
+ projectCalendar,
21
+ } from "./project.js";
22
+ export {
23
+ DEFAULT_CALENDAR_URL_SEGMENT,
24
+ deleteCalendarObject,
25
+ type PutCalendarObjectInput,
26
+ provisionDefaultCalendar,
27
+ putCalendarObject,
28
+ } from "./put.js";
29
+ export { type ResolvedTime, resolveTime, toUtcIso } from "./time.js";
@@ -0,0 +1,253 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { asLf, ical, singleEvent } from "./fixtures.js";
4
+ import { parseCalendar, serializeCalendar } from "./parse.js";
5
+
6
+ const parsed = async (icalData: string) => {
7
+ const result = await parseCalendar(icalData);
8
+ assert.ok(result.ok, `expected a parse, got ${JSON.stringify(result)}`);
9
+ return result.value;
10
+ };
11
+
12
+ const refusalCode = async (icalData: string) => {
13
+ const result = await parseCalendar(icalData);
14
+ assert.ok(!result.ok, "expected a refusal");
15
+ return result.error;
16
+ };
17
+
18
+ describe("parseCalendar", () => {
19
+ it("reads the master event and its UID", async () => {
20
+ const calendar = await parsed(
21
+ singleEvent("DTSTART:20260826T090000Z", "DTEND:20260826T100000Z"),
22
+ );
23
+
24
+ assert.equal(calendar.uid, "fixture@example.com");
25
+ assert.equal(calendar.master.name, "vevent");
26
+ assert.deepEqual(calendar.overrides, []);
27
+ });
28
+
29
+ it("separates the overrides from the master", async () => {
30
+ const calendar = await parsed(
31
+ ical(
32
+ "BEGIN:VCALENDAR",
33
+ "VERSION:2.0",
34
+ "BEGIN:VEVENT",
35
+ "UID:series@example.com",
36
+ "DTSTART:20260826T090000Z",
37
+ "RRULE:FREQ=WEEKLY;COUNT=3",
38
+ "END:VEVENT",
39
+ "BEGIN:VEVENT",
40
+ "UID:series@example.com",
41
+ "RECURRENCE-ID:20260902T090000Z",
42
+ "DTSTART:20260902T110000Z",
43
+ "END:VEVENT",
44
+ "END:VCALENDAR",
45
+ ),
46
+ );
47
+
48
+ assert.equal(calendar.overrides.length, 1);
49
+ assert.ok(!calendar.master.hasProperty("recurrence-id"));
50
+ });
51
+
52
+ it("refuses bytes that are not iCalendar", async () => {
53
+ assert.equal(
54
+ (await refusalCode("this is not a calendar")).code,
55
+ "MalformedIcalendar",
56
+ );
57
+ });
58
+
59
+ it("refuses a document whose root is not a VCALENDAR", async () => {
60
+ assert.equal(
61
+ (
62
+ await refusalCode(
63
+ ical("BEGIN:VCARD", "VERSION:4.0", "FN:Someone", "END:VCARD"),
64
+ )
65
+ ).code,
66
+ "NotACalendar",
67
+ );
68
+ });
69
+
70
+ it("refuses a component this collection does not store, naming it", async () => {
71
+ const error = await refusalCode(
72
+ ical(
73
+ "BEGIN:VCALENDAR",
74
+ "VERSION:2.0",
75
+ "BEGIN:VTODO",
76
+ "UID:todo@example.com",
77
+ "SUMMARY:Buy milk",
78
+ "END:VTODO",
79
+ "END:VCALENDAR",
80
+ ),
81
+ );
82
+
83
+ assert.equal(error.code, "UnsupportedComponent");
84
+ assert.match(error.message, /VTODO/);
85
+ });
86
+
87
+ it("refuses a VCALENDAR with no VEVENT", async () => {
88
+ assert.equal(
89
+ (
90
+ await refusalCode(
91
+ ical("BEGIN:VCALENDAR", "VERSION:2.0", "END:VCALENDAR"),
92
+ )
93
+ ).code,
94
+ "NoEvent",
95
+ );
96
+ });
97
+
98
+ it("refuses overrides with no event to override", async () => {
99
+ const error = await refusalCode(
100
+ ical(
101
+ "BEGIN:VCALENDAR",
102
+ "VERSION:2.0",
103
+ "BEGIN:VEVENT",
104
+ "UID:orphan@example.com",
105
+ "RECURRENCE-ID:20260902T090000Z",
106
+ "DTSTART:20260902T110000Z",
107
+ "END:VEVENT",
108
+ "END:VCALENDAR",
109
+ ),
110
+ );
111
+
112
+ assert.equal(error.code, "NoMasterEvent");
113
+ });
114
+
115
+ it("refuses two unrelated events in one resource", async () => {
116
+ const error = await refusalCode(
117
+ ical(
118
+ "BEGIN:VCALENDAR",
119
+ "VERSION:2.0",
120
+ "BEGIN:VEVENT",
121
+ "UID:one@example.com",
122
+ "DTSTART:20260826T090000Z",
123
+ "END:VEVENT",
124
+ "BEGIN:VEVENT",
125
+ "UID:two@example.com",
126
+ "DTSTART:20260827T090000Z",
127
+ "END:VEVENT",
128
+ "END:VCALENDAR",
129
+ ),
130
+ );
131
+
132
+ assert.equal(error.code, "MultipleMasterEvents");
133
+ });
134
+
135
+ it("refuses a VEVENT with no UID", async () => {
136
+ const error = await refusalCode(
137
+ ical(
138
+ "BEGIN:VCALENDAR",
139
+ "VERSION:2.0",
140
+ "BEGIN:VEVENT",
141
+ "DTSTART:20260826T090000Z",
142
+ "END:VEVENT",
143
+ "END:VCALENDAR",
144
+ ),
145
+ );
146
+
147
+ assert.equal(error.code, "MissingUid");
148
+ });
149
+
150
+ it("refuses an override that belongs to a different event", async () => {
151
+ const error = await refusalCode(
152
+ ical(
153
+ "BEGIN:VCALENDAR",
154
+ "VERSION:2.0",
155
+ "BEGIN:VEVENT",
156
+ "UID:series@example.com",
157
+ "DTSTART:20260826T090000Z",
158
+ "RRULE:FREQ=WEEKLY;COUNT=3",
159
+ "END:VEVENT",
160
+ "BEGIN:VEVENT",
161
+ "UID:stranger@example.com",
162
+ "RECURRENCE-ID:20260902T090000Z",
163
+ "DTSTART:20260902T110000Z",
164
+ "END:VEVENT",
165
+ "END:VCALENDAR",
166
+ ),
167
+ );
168
+
169
+ assert.equal(error.code, "MismatchedUid");
170
+ });
171
+
172
+ it("refuses a VEVENT with no DTSTART", async () => {
173
+ assert.equal(
174
+ (await refusalCode(singleEvent("SUMMARY:Undated"))).code,
175
+ "MissingDtStart",
176
+ );
177
+ });
178
+ });
179
+
180
+ describe("serializeCalendar", () => {
181
+ it("keeps the line endings RFC 5545 requires", async () => {
182
+ const source = singleEvent(
183
+ "DTSTART:20260826T090000Z",
184
+ "DTEND:20260826T100000Z",
185
+ );
186
+
187
+ const serialized = serializeCalendar((await parsed(source)).component);
188
+
189
+ assert.ok(serialized.includes("\r\n"));
190
+ assert.doesNotMatch(
191
+ serialized,
192
+ /(?<!\r)\n/,
193
+ "every line break is a CRLF, with no bare LF left behind",
194
+ );
195
+ });
196
+
197
+ it("reads an LF-only resource and writes it back as CRLF", async () => {
198
+ // RFC 5545 says CRLF; real files arrive both ways, and a parser that only
199
+ // recognises CRLF reads an LF-only resource as one enormous line.
200
+ const source = asLf(
201
+ singleEvent("DTSTART:20260826T090000Z", "DTEND:20260826T100000Z"),
202
+ );
203
+ assert.ok(!source.includes("\r"), "the fixture is LF-only");
204
+
205
+ const calendar = await parsed(source);
206
+ const serialized = serializeCalendar(calendar.component);
207
+
208
+ assert.equal(calendar.uid, "fixture@example.com");
209
+ assert.ok(calendar.master.hasProperty("dtstart"));
210
+ assert.doesNotMatch(serialized, /(?<!\r)\n/);
211
+ });
212
+
213
+ it("round-trips a resource losslessly, unknown properties included", async () => {
214
+ const source = singleEvent(
215
+ "DTSTART:20260826T090000Z",
216
+ "DTEND:20260826T100000Z",
217
+ "SUMMARY:Quarterly review",
218
+ "X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC",
219
+ "X-MICROSOFT-CDO-BUSYSTATUS:BUSY",
220
+ "ATTENDEE;CN=Someone;X-NUM-GUESTS=0:mailto:someone@example.com",
221
+ );
222
+
223
+ const once = serializeCalendar((await parsed(source)).component);
224
+ const twice = serializeCalendar((await parsed(once)).component);
225
+
226
+ assert.equal(twice, once);
227
+ for (const line of [
228
+ "X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC",
229
+ "X-MICROSOFT-CDO-BUSYSTATUS:BUSY",
230
+ "X-NUM-GUESTS=0",
231
+ "SUMMARY:Quarterly review",
232
+ ]) {
233
+ assert.ok(once.includes(line), `${line} did not survive the round trip`);
234
+ }
235
+ });
236
+
237
+ it("keeps a whole unknown subcomponent of the event", async () => {
238
+ const source = singleEvent(
239
+ "DTSTART:20260826T090000Z",
240
+ "DTEND:20260826T100000Z",
241
+ "BEGIN:VALARM",
242
+ "ACTION:DISPLAY",
243
+ "DESCRIPTION:Reminder",
244
+ "TRIGGER:-PT15M",
245
+ "END:VALARM",
246
+ );
247
+
248
+ const serialized = serializeCalendar((await parsed(source)).component);
249
+
250
+ assert.ok(serialized.includes("BEGIN:VALARM"));
251
+ assert.ok(serialized.includes("TRIGGER:-PT15M"));
252
+ });
253
+ });
package/src/parse.ts ADDED
@@ -0,0 +1,132 @@
1
+ import ICAL from "ical.js";
2
+ import { type CalendarResult, calendarFailure } from "./errors.js";
3
+
4
+ /** Components a VEVENT-only collection accepts at the top level of a resource. */
5
+ const ACCEPTED_COMPONENTS = new Set(["vevent", "vtimezone"]);
6
+
7
+ export interface ParsedCalendar {
8
+ /** The VCALENDAR, with every property and component the input carried. */
9
+ component: ICAL.Component;
10
+ /** The VEVENT without a RECURRENCE-ID. */
11
+ master: ICAL.Component;
12
+ /** The VEVENTs carrying a RECURRENCE-ID, in document order. */
13
+ overrides: ICAL.Component[];
14
+ uid: string;
15
+ }
16
+
17
+ /**
18
+ * Serializes a VCALENDAR back to iCalendar text.
19
+ *
20
+ * Lossless by construction: ical.js keeps every property it parsed, including
21
+ * ones it has no design rule for, so an X- property or a whole unknown
22
+ * component survives the round trip. The output is always CRLF (RFC 5545 3.1),
23
+ * whatever the input used.
24
+ *
25
+ * The write path does not call this. A resource is stored as the bytes it
26
+ * arrived in — LF-only ones included — because the etag is a digest of exactly
27
+ * what the writer sent; running a stored resource through here would rewrite
28
+ * its line endings, refold its long lines, and move the tag of a resource
29
+ * nobody edited. This is for building a VCALENDAR, not for keeping one.
30
+ */
31
+ export const serializeCalendar = (component: ICAL.Component): string =>
32
+ component.toString();
33
+
34
+ /**
35
+ * ical.js's parser in a promise, so unreadable bytes arrive as a rejection to
36
+ * branch on rather than a synchronous throw.
37
+ */
38
+ const readComponent = (
39
+ icalData: string,
40
+ ): Promise<CalendarResult<ICAL.Component>> =>
41
+ new Promise<ICAL.Component>((resolve) => {
42
+ resolve(new ICAL.Component(ICAL.parse(icalData)));
43
+ }).then(
44
+ (component) => ({ ok: true, value: component }) as const,
45
+ (error: unknown) =>
46
+ calendarFailure<ICAL.Component>(
47
+ "MalformedIcalendar",
48
+ error instanceof Error ? error.message : "unreadable iCalendar data",
49
+ ),
50
+ );
51
+
52
+ /**
53
+ * Reads and validates a VCALENDAR carrying exactly one event.
54
+ *
55
+ * Every refusal is a returned value. The input is client-supplied bytes, so a
56
+ * malformed resource is an ordinary outcome of this function rather than a
57
+ * fault.
58
+ */
59
+ export const parseCalendar = async (
60
+ icalData: string,
61
+ ): Promise<CalendarResult<ParsedCalendar>> => {
62
+ const read = await readComponent(icalData);
63
+ if (!read.ok) return read;
64
+ const component = read.value;
65
+
66
+ if (component.name !== "vcalendar") {
67
+ return calendarFailure(
68
+ "NotACalendar",
69
+ `expected a VCALENDAR, found ${component.name.toUpperCase()}`,
70
+ );
71
+ }
72
+
73
+ const unsupported = component
74
+ .getAllSubcomponents()
75
+ .map((child) => child.name)
76
+ .find((name) => !ACCEPTED_COMPONENTS.has(name));
77
+ if (unsupported) {
78
+ return calendarFailure(
79
+ "UnsupportedComponent",
80
+ `this calendar stores VEVENT only, and the resource carries a ${unsupported.toUpperCase()}`,
81
+ );
82
+ }
83
+
84
+ const events = component.getAllSubcomponents("vevent");
85
+ if (events.length === 0) {
86
+ return calendarFailure("NoEvent", "the resource carries no VEVENT");
87
+ }
88
+
89
+ const masters = events.filter((event) => !event.hasProperty("recurrence-id"));
90
+ if (masters.length === 0) {
91
+ return calendarFailure(
92
+ "NoMasterEvent",
93
+ "every VEVENT carries a RECURRENCE-ID, so there is no event for them to override",
94
+ );
95
+ }
96
+ if (masters.length > 1) {
97
+ return calendarFailure(
98
+ "MultipleMasterEvents",
99
+ `one resource holds one event, and this one holds ${masters.length}`,
100
+ );
101
+ }
102
+
103
+ const master = masters[0] as ICAL.Component;
104
+ const uid = master.getFirstPropertyValue("uid");
105
+ if (typeof uid !== "string" || uid === "") {
106
+ return calendarFailure("MissingUid", "the VEVENT declares no UID");
107
+ }
108
+
109
+ const mismatched = events.find(
110
+ (event) => event.getFirstPropertyValue("uid") !== uid,
111
+ );
112
+ if (mismatched) {
113
+ return calendarFailure(
114
+ "MismatchedUid",
115
+ "the VEVENTs in this resource declare different UIDs",
116
+ );
117
+ }
118
+
119
+ if (!master.hasProperty("dtstart")) {
120
+ return calendarFailure("MissingDtStart", "the VEVENT declares no DTSTART");
121
+ }
122
+
123
+ return {
124
+ ok: true,
125
+ value: {
126
+ component,
127
+ master,
128
+ overrides: events.filter((event) => event.hasProperty("recurrence-id")),
129
+ uid,
130
+ },
131
+ };
132
+ };