@remit/calendar-service 0.0.18 → 0.0.19
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/dist/accept.d.ts +71 -0
- package/dist/accept.js +159 -0
- package/dist/build.d.ts +79 -0
- package/dist/build.js +196 -0
- package/dist/correct.d.ts +4 -0
- package/dist/correct.js +22 -0
- package/dist/errors.d.ts +56 -0
- package/dist/errors.js +1 -0
- package/dist/etag.d.ts +9 -0
- package/{src/etag.ts → dist/etag.js} +1 -3
- package/dist/expand.d.ts +73 -0
- package/dist/expand.js +188 -0
- package/dist/feed.d.ts +81 -0
- package/dist/feed.js +157 -0
- package/dist/fixtures.d.ts +19 -0
- package/dist/fixtures.js +37 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +15 -0
- package/dist/memory-store.d.ts +105 -0
- package/dist/memory-store.js +319 -0
- package/dist/parse.d.ts +39 -0
- package/dist/parse.js +87 -0
- package/dist/project.d.ts +30 -0
- package/dist/project.js +57 -0
- package/dist/put.d.ts +52 -0
- package/dist/put.js +78 -0
- package/dist/reopen.d.ts +14 -0
- package/dist/reopen.js +92 -0
- package/dist/scope.d.ts +70 -0
- package/dist/scope.js +687 -0
- package/dist/subscribe.d.ts +96 -0
- package/dist/subscribe.js +298 -0
- package/dist/suggest.d.ts +74 -0
- package/dist/suggest.js +138 -0
- package/dist/time.d.ts +76 -0
- package/dist/time.js +164 -0
- package/dist/window.d.ts +85 -0
- package/dist/window.js +161 -0
- package/package.json +40 -35
- package/src/accept.test.ts +0 -403
- package/src/accept.ts +0 -250
- package/src/build.test.ts +0 -113
- package/src/build.ts +0 -311
- package/src/correct.test.ts +0 -173
- package/src/correct.ts +0 -40
- package/src/errors.ts +0 -62
- package/src/etag.test.ts +0 -54
- package/src/expand.test.ts +0 -361
- package/src/expand.ts +0 -303
- package/src/feed.test.ts +0 -334
- package/src/feed.ts +0 -203
- package/src/feeds/google-secret-address.ics +0 -99
- package/src/fixtures.ts +0 -52
- package/src/index.ts +0 -128
- package/src/memory-store.ts +0 -485
- package/src/parse.test.ts +0 -264
- package/src/parse.ts +0 -138
- package/src/project.test.ts +0 -247
- package/src/project.ts +0 -122
- package/src/put.test.ts +0 -318
- package/src/put.ts +0 -133
- package/src/reopen.test.ts +0 -529
- package/src/reopen.ts +0 -199
- package/src/scope.test.ts +0 -1421
- package/src/scope.ts +0 -1003
- package/src/subscribe.test.ts +0 -582
- package/src/subscribe.ts +0 -433
- package/src/suggest.test.ts +0 -376
- package/src/suggest.ts +0 -244
- package/src/time.ts +0 -260
- package/src/window.test.ts +0 -648
- package/src/window.ts +0 -281
- package/tsconfig.json +0 -8
package/src/parse.test.ts
DELETED
|
@@ -1,264 +0,0 @@
|
|
|
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 empty bytes as a value, naming nothing rather than crashing", async () => {
|
|
60
|
-
// A suggestion read out of prose carries no iCalendar at all, and the
|
|
61
|
-
// accept path reaches this with `""`. ical.js reads empty bytes as a
|
|
62
|
-
// component with no name, so naming what arrived would throw here — a
|
|
63
|
-
// crash on ordinary input rather than the refusal every caller renders.
|
|
64
|
-
const refusal = await refusalCode("");
|
|
65
|
-
|
|
66
|
-
assert.equal(refusal.code, "NotACalendar");
|
|
67
|
-
assert.match(refusal.message, /found nothing/);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("refuses a document whose root is not a VCALENDAR", async () => {
|
|
71
|
-
assert.equal(
|
|
72
|
-
(
|
|
73
|
-
await refusalCode(
|
|
74
|
-
ical("BEGIN:VCARD", "VERSION:4.0", "FN:Someone", "END:VCARD"),
|
|
75
|
-
)
|
|
76
|
-
).code,
|
|
77
|
-
"NotACalendar",
|
|
78
|
-
);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("refuses a component this collection does not store, naming it", async () => {
|
|
82
|
-
const error = await refusalCode(
|
|
83
|
-
ical(
|
|
84
|
-
"BEGIN:VCALENDAR",
|
|
85
|
-
"VERSION:2.0",
|
|
86
|
-
"BEGIN:VTODO",
|
|
87
|
-
"UID:todo@example.com",
|
|
88
|
-
"SUMMARY:Buy milk",
|
|
89
|
-
"END:VTODO",
|
|
90
|
-
"END:VCALENDAR",
|
|
91
|
-
),
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
assert.equal(error.code, "UnsupportedComponent");
|
|
95
|
-
assert.match(error.message, /VTODO/);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("refuses a VCALENDAR with no VEVENT", async () => {
|
|
99
|
-
assert.equal(
|
|
100
|
-
(
|
|
101
|
-
await refusalCode(
|
|
102
|
-
ical("BEGIN:VCALENDAR", "VERSION:2.0", "END:VCALENDAR"),
|
|
103
|
-
)
|
|
104
|
-
).code,
|
|
105
|
-
"NoEvent",
|
|
106
|
-
);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("refuses overrides with no event to override", async () => {
|
|
110
|
-
const error = await refusalCode(
|
|
111
|
-
ical(
|
|
112
|
-
"BEGIN:VCALENDAR",
|
|
113
|
-
"VERSION:2.0",
|
|
114
|
-
"BEGIN:VEVENT",
|
|
115
|
-
"UID:orphan@example.com",
|
|
116
|
-
"RECURRENCE-ID:20260902T090000Z",
|
|
117
|
-
"DTSTART:20260902T110000Z",
|
|
118
|
-
"END:VEVENT",
|
|
119
|
-
"END:VCALENDAR",
|
|
120
|
-
),
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
assert.equal(error.code, "NoMasterEvent");
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("refuses two unrelated events in one resource", async () => {
|
|
127
|
-
const error = await refusalCode(
|
|
128
|
-
ical(
|
|
129
|
-
"BEGIN:VCALENDAR",
|
|
130
|
-
"VERSION:2.0",
|
|
131
|
-
"BEGIN:VEVENT",
|
|
132
|
-
"UID:one@example.com",
|
|
133
|
-
"DTSTART:20260826T090000Z",
|
|
134
|
-
"END:VEVENT",
|
|
135
|
-
"BEGIN:VEVENT",
|
|
136
|
-
"UID:two@example.com",
|
|
137
|
-
"DTSTART:20260827T090000Z",
|
|
138
|
-
"END:VEVENT",
|
|
139
|
-
"END:VCALENDAR",
|
|
140
|
-
),
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
assert.equal(error.code, "MultipleMasterEvents");
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it("refuses a VEVENT with no UID", async () => {
|
|
147
|
-
const error = await refusalCode(
|
|
148
|
-
ical(
|
|
149
|
-
"BEGIN:VCALENDAR",
|
|
150
|
-
"VERSION:2.0",
|
|
151
|
-
"BEGIN:VEVENT",
|
|
152
|
-
"DTSTART:20260826T090000Z",
|
|
153
|
-
"END:VEVENT",
|
|
154
|
-
"END:VCALENDAR",
|
|
155
|
-
),
|
|
156
|
-
);
|
|
157
|
-
|
|
158
|
-
assert.equal(error.code, "MissingUid");
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("refuses an override that belongs to a different event", async () => {
|
|
162
|
-
const error = await refusalCode(
|
|
163
|
-
ical(
|
|
164
|
-
"BEGIN:VCALENDAR",
|
|
165
|
-
"VERSION:2.0",
|
|
166
|
-
"BEGIN:VEVENT",
|
|
167
|
-
"UID:series@example.com",
|
|
168
|
-
"DTSTART:20260826T090000Z",
|
|
169
|
-
"RRULE:FREQ=WEEKLY;COUNT=3",
|
|
170
|
-
"END:VEVENT",
|
|
171
|
-
"BEGIN:VEVENT",
|
|
172
|
-
"UID:stranger@example.com",
|
|
173
|
-
"RECURRENCE-ID:20260902T090000Z",
|
|
174
|
-
"DTSTART:20260902T110000Z",
|
|
175
|
-
"END:VEVENT",
|
|
176
|
-
"END:VCALENDAR",
|
|
177
|
-
),
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
assert.equal(error.code, "MismatchedUid");
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it("refuses a VEVENT with no DTSTART", async () => {
|
|
184
|
-
assert.equal(
|
|
185
|
-
(await refusalCode(singleEvent("SUMMARY:Undated"))).code,
|
|
186
|
-
"MissingDtStart",
|
|
187
|
-
);
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
describe("serializeCalendar", () => {
|
|
192
|
-
it("keeps the line endings RFC 5545 requires", async () => {
|
|
193
|
-
const source = singleEvent(
|
|
194
|
-
"DTSTART:20260826T090000Z",
|
|
195
|
-
"DTEND:20260826T100000Z",
|
|
196
|
-
);
|
|
197
|
-
|
|
198
|
-
const serialized = serializeCalendar((await parsed(source)).component);
|
|
199
|
-
|
|
200
|
-
assert.ok(serialized.includes("\r\n"));
|
|
201
|
-
assert.doesNotMatch(
|
|
202
|
-
serialized,
|
|
203
|
-
/(?<!\r)\n/,
|
|
204
|
-
"every line break is a CRLF, with no bare LF left behind",
|
|
205
|
-
);
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it("reads an LF-only resource and writes it back as CRLF", async () => {
|
|
209
|
-
// RFC 5545 says CRLF; real files arrive both ways, and a parser that only
|
|
210
|
-
// recognises CRLF reads an LF-only resource as one enormous line.
|
|
211
|
-
const source = asLf(
|
|
212
|
-
singleEvent("DTSTART:20260826T090000Z", "DTEND:20260826T100000Z"),
|
|
213
|
-
);
|
|
214
|
-
assert.ok(!source.includes("\r"), "the fixture is LF-only");
|
|
215
|
-
|
|
216
|
-
const calendar = await parsed(source);
|
|
217
|
-
const serialized = serializeCalendar(calendar.component);
|
|
218
|
-
|
|
219
|
-
assert.equal(calendar.uid, "fixture@example.com");
|
|
220
|
-
assert.ok(calendar.master.hasProperty("dtstart"));
|
|
221
|
-
assert.doesNotMatch(serialized, /(?<!\r)\n/);
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it("round-trips a resource losslessly, unknown properties included", async () => {
|
|
225
|
-
const source = singleEvent(
|
|
226
|
-
"DTSTART:20260826T090000Z",
|
|
227
|
-
"DTEND:20260826T100000Z",
|
|
228
|
-
"SUMMARY:Quarterly review",
|
|
229
|
-
"X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC",
|
|
230
|
-
"X-MICROSOFT-CDO-BUSYSTATUS:BUSY",
|
|
231
|
-
"ATTENDEE;CN=Someone;X-NUM-GUESTS=0:mailto:someone@example.com",
|
|
232
|
-
);
|
|
233
|
-
|
|
234
|
-
const once = serializeCalendar((await parsed(source)).component);
|
|
235
|
-
const twice = serializeCalendar((await parsed(once)).component);
|
|
236
|
-
|
|
237
|
-
assert.equal(twice, once);
|
|
238
|
-
for (const line of [
|
|
239
|
-
"X-APPLE-TRAVEL-ADVISORY-BEHAVIOR:AUTOMATIC",
|
|
240
|
-
"X-MICROSOFT-CDO-BUSYSTATUS:BUSY",
|
|
241
|
-
"X-NUM-GUESTS=0",
|
|
242
|
-
"SUMMARY:Quarterly review",
|
|
243
|
-
]) {
|
|
244
|
-
assert.ok(once.includes(line), `${line} did not survive the round trip`);
|
|
245
|
-
}
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
it("keeps a whole unknown subcomponent of the event", async () => {
|
|
249
|
-
const source = singleEvent(
|
|
250
|
-
"DTSTART:20260826T090000Z",
|
|
251
|
-
"DTEND:20260826T100000Z",
|
|
252
|
-
"BEGIN:VALARM",
|
|
253
|
-
"ACTION:DISPLAY",
|
|
254
|
-
"DESCRIPTION:Reminder",
|
|
255
|
-
"TRIGGER:-PT15M",
|
|
256
|
-
"END:VALARM",
|
|
257
|
-
);
|
|
258
|
-
|
|
259
|
-
const serialized = serializeCalendar((await parsed(source)).component);
|
|
260
|
-
|
|
261
|
-
assert.ok(serialized.includes("BEGIN:VALARM"));
|
|
262
|
-
assert.ok(serialized.includes("TRIGGER:-PT15M"));
|
|
263
|
-
});
|
|
264
|
-
});
|
package/src/parse.ts
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
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
|
-
export const readCalendarComponent = (
|
|
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 readCalendarComponent(icalData);
|
|
63
|
-
if (!read.ok) return read;
|
|
64
|
-
const component = read.value;
|
|
65
|
-
|
|
66
|
-
if (component.name !== "vcalendar") {
|
|
67
|
-
// A document with nothing in it names nothing: ical.js reads empty bytes
|
|
68
|
-
// as a component with no name at all, and a suggestion read out of prose
|
|
69
|
-
// carries exactly that. Naming what arrived is for a document that has a
|
|
70
|
-
// root to name.
|
|
71
|
-
return calendarFailure(
|
|
72
|
-
"NotACalendar",
|
|
73
|
-
component.name
|
|
74
|
-
? `expected a VCALENDAR, found ${component.name.toUpperCase()}`
|
|
75
|
-
: "expected a VCALENDAR, found nothing",
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const unsupported = component
|
|
80
|
-
.getAllSubcomponents()
|
|
81
|
-
.map((child) => child.name)
|
|
82
|
-
.find((name) => !ACCEPTED_COMPONENTS.has(name));
|
|
83
|
-
if (unsupported) {
|
|
84
|
-
return calendarFailure(
|
|
85
|
-
"UnsupportedComponent",
|
|
86
|
-
`this calendar stores VEVENT only, and the resource carries a ${unsupported.toUpperCase()}`,
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const events = component.getAllSubcomponents("vevent");
|
|
91
|
-
if (events.length === 0) {
|
|
92
|
-
return calendarFailure("NoEvent", "the resource carries no VEVENT");
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const masters = events.filter((event) => !event.hasProperty("recurrence-id"));
|
|
96
|
-
if (masters.length === 0) {
|
|
97
|
-
return calendarFailure(
|
|
98
|
-
"NoMasterEvent",
|
|
99
|
-
"every VEVENT carries a RECURRENCE-ID, so there is no event for them to override",
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
if (masters.length > 1) {
|
|
103
|
-
return calendarFailure(
|
|
104
|
-
"MultipleMasterEvents",
|
|
105
|
-
`one resource holds one event, and this one holds ${masters.length}`,
|
|
106
|
-
);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const master = masters[0] as ICAL.Component;
|
|
110
|
-
const uid = master.getFirstPropertyValue("uid");
|
|
111
|
-
if (typeof uid !== "string" || uid === "") {
|
|
112
|
-
return calendarFailure("MissingUid", "the VEVENT declares no UID");
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const mismatched = events.find(
|
|
116
|
-
(event) => event.getFirstPropertyValue("uid") !== uid,
|
|
117
|
-
);
|
|
118
|
-
if (mismatched) {
|
|
119
|
-
return calendarFailure(
|
|
120
|
-
"MismatchedUid",
|
|
121
|
-
"the VEVENTs in this resource declare different UIDs",
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (!master.hasProperty("dtstart")) {
|
|
126
|
-
return calendarFailure("MissingDtStart", "the VEVENT declares no DTSTART");
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
return {
|
|
130
|
-
ok: true,
|
|
131
|
-
value: {
|
|
132
|
-
component,
|
|
133
|
-
master,
|
|
134
|
-
overrides: events.filter((event) => event.hasProperty("recurrence-id")),
|
|
135
|
-
uid,
|
|
136
|
-
},
|
|
137
|
-
};
|
|
138
|
-
};
|
package/src/project.test.ts
DELETED
|
@@ -1,247 +0,0 @@
|
|
|
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
|
-
});
|