@remit/calendar-service 0.0.11 → 0.0.12
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 +3 -1
- package/src/feeds/google-secret-address.ics +99 -0
- package/src/index.ts +19 -0
- package/src/memory-store.ts +10 -0
- package/src/parse.ts +2 -2
- package/src/subscribe.test.ts +578 -0
- package/src/subscribe.ts +420 -0
- package/src/window.test.ts +5 -0
package/package.json
CHANGED
package/src/errors.ts
CHANGED
|
@@ -35,7 +35,9 @@ export type CalendarValidationCode =
|
|
|
35
35
|
| "NotRecurring"
|
|
36
36
|
/** A RECURRENCE-ID naming no occurrence this series produces. */
|
|
37
37
|
| "UnknownOccurrence"
|
|
38
|
-
| "UnmovableRecurrenceRule"
|
|
38
|
+
| "UnmovableRecurrenceRule"
|
|
39
|
+
/** A subscription address that is not an http, https or webcal URL. */
|
|
40
|
+
| "InvalidSubscriptionUrl";
|
|
39
41
|
|
|
40
42
|
export interface CalendarValidationError {
|
|
41
43
|
code: CalendarValidationCode;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
BEGIN:VCALENDAR
|
|
2
|
+
PRODID:-//Google Inc//Google Calendar 70.9054//EN
|
|
3
|
+
VERSION:2.0
|
|
4
|
+
CALSCALE:GREGORIAN
|
|
5
|
+
METHOD:PUBLISH
|
|
6
|
+
X-WR-CALNAME:Harbour rota
|
|
7
|
+
X-WR-TIMEZONE:Europe/Amsterdam
|
|
8
|
+
BEGIN:VTIMEZONE
|
|
9
|
+
TZID:Europe/Amsterdam
|
|
10
|
+
X-LIC-LOCATION:Europe/Amsterdam
|
|
11
|
+
BEGIN:DAYLIGHT
|
|
12
|
+
TZOFFSETFROM:+0100
|
|
13
|
+
TZOFFSETTO:+0200
|
|
14
|
+
TZNAME:CEST
|
|
15
|
+
DTSTART:19700329T020000
|
|
16
|
+
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
|
|
17
|
+
END:DAYLIGHT
|
|
18
|
+
BEGIN:STANDARD
|
|
19
|
+
TZOFFSETFROM:+0200
|
|
20
|
+
TZOFFSETTO:+0100
|
|
21
|
+
TZNAME:CET
|
|
22
|
+
DTSTART:19701025T030000
|
|
23
|
+
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
|
|
24
|
+
END:STANDARD
|
|
25
|
+
END:VTIMEZONE
|
|
26
|
+
BEGIN:VTIMEZONE
|
|
27
|
+
TZID:America/New_York
|
|
28
|
+
BEGIN:DAYLIGHT
|
|
29
|
+
TZOFFSETFROM:-0500
|
|
30
|
+
TZOFFSETTO:-0400
|
|
31
|
+
TZNAME:EDT
|
|
32
|
+
DTSTART:19700308T020000
|
|
33
|
+
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
|
|
34
|
+
END:DAYLIGHT
|
|
35
|
+
BEGIN:STANDARD
|
|
36
|
+
TZOFFSETFROM:-0400
|
|
37
|
+
TZOFFSETTO:-0500
|
|
38
|
+
TZNAME:EST
|
|
39
|
+
DTSTART:19701101T020000
|
|
40
|
+
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
|
|
41
|
+
END:STANDARD
|
|
42
|
+
END:VTIMEZONE
|
|
43
|
+
BEGIN:VEVENT
|
|
44
|
+
DTSTART;TZID=Europe/Amsterdam:20261005T090000
|
|
45
|
+
DTEND;TZID=Europe/Amsterdam:20261005T093000
|
|
46
|
+
RRULE:FREQ=WEEKLY;COUNT=6;BYDAY=MO
|
|
47
|
+
EXDATE;TZID=Europe/Amsterdam:20261019T090000
|
|
48
|
+
DTSTAMP:20260925T120000Z
|
|
49
|
+
UID:pilot-standup@google.com
|
|
50
|
+
CREATED:20260901T080000Z
|
|
51
|
+
LAST-MODIFIED:20260920T080000Z
|
|
52
|
+
SEQUENCE:0
|
|
53
|
+
STATUS:CONFIRMED
|
|
54
|
+
SUMMARY:Pilot standup
|
|
55
|
+
TRANSP:OPAQUE
|
|
56
|
+
END:VEVENT
|
|
57
|
+
BEGIN:VEVENT
|
|
58
|
+
DTSTART;TZID=Europe/Amsterdam:20261012T100000
|
|
59
|
+
DTEND;TZID=Europe/Amsterdam:20261012T103000
|
|
60
|
+
DTSTAMP:20260925T120000Z
|
|
61
|
+
UID:pilot-standup@google.com
|
|
62
|
+
RECURRENCE-ID;TZID=Europe/Amsterdam:20261012T090000
|
|
63
|
+
SEQUENCE:1
|
|
64
|
+
STATUS:CONFIRMED
|
|
65
|
+
SUMMARY:Pilot standup (moved)
|
|
66
|
+
TRANSP:OPAQUE
|
|
67
|
+
END:VEVENT
|
|
68
|
+
BEGIN:VEVENT
|
|
69
|
+
DTSTART;TZID=Europe/Amsterdam:20261102T090000
|
|
70
|
+
DTEND;TZID=Europe/Amsterdam:20261102T093000
|
|
71
|
+
DTSTAMP:20260925T120000Z
|
|
72
|
+
UID:pilot-standup@google.com
|
|
73
|
+
RECURRENCE-ID;TZID=Europe/Amsterdam:20261102T090000
|
|
74
|
+
SEQUENCE:1
|
|
75
|
+
STATUS:CANCELLED
|
|
76
|
+
SUMMARY:Pilot standup
|
|
77
|
+
TRANSP:OPAQUE
|
|
78
|
+
END:VEVENT
|
|
79
|
+
BEGIN:VEVENT
|
|
80
|
+
DTSTART;VALUE=DATE:20261007
|
|
81
|
+
DTEND;VALUE=DATE:20261009
|
|
82
|
+
DTSTAMP:20260925T120000Z
|
|
83
|
+
UID:quay-offsite@google.com
|
|
84
|
+
SEQUENCE:0
|
|
85
|
+
STATUS:CONFIRMED
|
|
86
|
+
SUMMARY:Quay offsite
|
|
87
|
+
TRANSP:TRANSPARENT
|
|
88
|
+
END:VEVENT
|
|
89
|
+
BEGIN:VEVENT
|
|
90
|
+
DTSTART:20261008T140000Z
|
|
91
|
+
DTEND:20261008T150000Z
|
|
92
|
+
DTSTAMP:20260925T120000Z
|
|
93
|
+
UID:tide-review@google.com
|
|
94
|
+
SEQUENCE:0
|
|
95
|
+
STATUS:CONFIRMED
|
|
96
|
+
SUMMARY:Tide table review
|
|
97
|
+
TRANSP:OPAQUE
|
|
98
|
+
END:VEVENT
|
|
99
|
+
END:VCALENDAR
|
package/src/index.ts
CHANGED
|
@@ -67,6 +67,25 @@ export {
|
|
|
67
67
|
type ScopedWrite,
|
|
68
68
|
type ScopedWriteInput,
|
|
69
69
|
} from "./scope.js";
|
|
70
|
+
export {
|
|
71
|
+
CALENDAR_SUBSCRIPTION_MAX_BYTES,
|
|
72
|
+
CALENDAR_SUBSCRIPTION_TIMEOUT_MS,
|
|
73
|
+
type CalendarFeedFetch,
|
|
74
|
+
type CalendarFeedFetcher,
|
|
75
|
+
type CalendarFeedResource,
|
|
76
|
+
type CalendarFeedSplit,
|
|
77
|
+
type CalendarFeedWrite,
|
|
78
|
+
type CalendarSubscriptionRefresh,
|
|
79
|
+
type CalendarSubscriptionTarget,
|
|
80
|
+
fetchCalendarFeed,
|
|
81
|
+
isSubscriptionDue,
|
|
82
|
+
readCalendarFeed,
|
|
83
|
+
readSubscriptionUrl,
|
|
84
|
+
recordCalendarFeedFailure,
|
|
85
|
+
refreshCalendarSubscription,
|
|
86
|
+
splitCalendarFeed,
|
|
87
|
+
writeCalendarFeed,
|
|
88
|
+
} from "./subscribe.js";
|
|
70
89
|
export {
|
|
71
90
|
type CalendarSuggestionProjection,
|
|
72
91
|
inviteMethodOf,
|
package/src/memory-store.ts
CHANGED
|
@@ -65,6 +65,11 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
|
65
65
|
source: input.source ?? "UserCreated",
|
|
66
66
|
timezone: input.timezone ?? "",
|
|
67
67
|
syncSequence: 0,
|
|
68
|
+
subscriptionUrl: input.subscriptionUrl ?? "",
|
|
69
|
+
subscriptionEnabled: input.subscriptionEnabled ?? false,
|
|
70
|
+
subscriptionCheckedAt: 0,
|
|
71
|
+
subscriptionFetchedAt: 0,
|
|
72
|
+
subscriptionError: "",
|
|
68
73
|
createdAt: now,
|
|
69
74
|
updatedAt: now,
|
|
70
75
|
};
|
|
@@ -102,6 +107,11 @@ export class MemoryCalendarStore implements ICalendarUnitOfWork {
|
|
|
102
107
|
if (this.collections.has(calendarId)) return null;
|
|
103
108
|
return this.collectionRepo.create(input);
|
|
104
109
|
},
|
|
110
|
+
listEnabledSubscriptions: async () =>
|
|
111
|
+
[...this.collections.values()].filter(
|
|
112
|
+
(collection) =>
|
|
113
|
+
collection.source === "Subscribed" && collection.subscriptionEnabled,
|
|
114
|
+
),
|
|
105
115
|
findByUrlSegment: async (accountConfigId: string, urlSegment: string) =>
|
|
106
116
|
this.collections.get(deriveCalendarId(accountConfigId, urlSegment)) ??
|
|
107
117
|
null,
|
package/src/parse.ts
CHANGED
|
@@ -35,7 +35,7 @@ export const serializeCalendar = (component: ICAL.Component): string =>
|
|
|
35
35
|
* ical.js's parser in a promise, so unreadable bytes arrive as a rejection to
|
|
36
36
|
* branch on rather than a synchronous throw.
|
|
37
37
|
*/
|
|
38
|
-
const
|
|
38
|
+
export const readCalendarComponent = (
|
|
39
39
|
icalData: string,
|
|
40
40
|
): Promise<CalendarResult<ICAL.Component>> =>
|
|
41
41
|
new Promise<ICAL.Component>((resolve) => {
|
|
@@ -59,7 +59,7 @@ const readComponent = (
|
|
|
59
59
|
export const parseCalendar = async (
|
|
60
60
|
icalData: string,
|
|
61
61
|
): Promise<CalendarResult<ParsedCalendar>> => {
|
|
62
|
-
const read = await
|
|
62
|
+
const read = await readCalendarComponent(icalData);
|
|
63
63
|
if (!read.ok) return read;
|
|
64
64
|
const component = read.value;
|
|
65
65
|
|
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { describe, it } from "node:test";
|
|
4
|
+
import type { CalendarCollectionItem } from "@remit/data-ports";
|
|
5
|
+
import { CalendarSource } from "@remit/domain-enums";
|
|
6
|
+
import { ical } from "./fixtures.js";
|
|
7
|
+
import { MemoryCalendarStore } from "./memory-store.js";
|
|
8
|
+
import { parseCalendar } from "./parse.js";
|
|
9
|
+
import {
|
|
10
|
+
type CalendarFeedFetcher,
|
|
11
|
+
fetchCalendarFeed,
|
|
12
|
+
isSubscriptionDue,
|
|
13
|
+
readSubscriptionUrl,
|
|
14
|
+
refreshCalendarSubscription,
|
|
15
|
+
splitCalendarFeed,
|
|
16
|
+
writeCalendarFeed,
|
|
17
|
+
} from "./subscribe.js";
|
|
18
|
+
import { listCalendarInstances } from "./window.js";
|
|
19
|
+
|
|
20
|
+
const ACCOUNT_CONFIG_ID = "account-config-1";
|
|
21
|
+
const SECRET_URL =
|
|
22
|
+
"https://calendar.example/calendar/ical/harbour%40example.com/private-5f1c0ffee/basic.ics";
|
|
23
|
+
const GOOGLE_FEED = readFileSync(
|
|
24
|
+
new URL("./feeds/google-secret-address.ics", import.meta.url),
|
|
25
|
+
"utf8",
|
|
26
|
+
);
|
|
27
|
+
const WINDOW = {
|
|
28
|
+
from: "2026-10-01T00:00:00Z",
|
|
29
|
+
to: "2026-11-16T00:00:00Z",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const answering =
|
|
33
|
+
(body: string, init: ResponseInit = {}): CalendarFeedFetcher =>
|
|
34
|
+
async () =>
|
|
35
|
+
new Response(body, init);
|
|
36
|
+
|
|
37
|
+
const subscribed = async (): Promise<{
|
|
38
|
+
store: MemoryCalendarStore;
|
|
39
|
+
collection: CalendarCollectionItem;
|
|
40
|
+
}> => {
|
|
41
|
+
const store = new MemoryCalendarStore();
|
|
42
|
+
const collection = await store.transaction((repos) =>
|
|
43
|
+
repos.calendarCollection.create({
|
|
44
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
45
|
+
urlSegment: "harbour-rota",
|
|
46
|
+
displayName: "Harbour rota",
|
|
47
|
+
source: CalendarSource.Subscribed,
|
|
48
|
+
subscriptionUrl: SECRET_URL,
|
|
49
|
+
subscriptionEnabled: true,
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
return { store, collection };
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const drawn = async (
|
|
56
|
+
store: MemoryCalendarStore,
|
|
57
|
+
collection: CalendarCollectionItem,
|
|
58
|
+
) => {
|
|
59
|
+
const current = await store.transaction((repos) =>
|
|
60
|
+
repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
|
|
61
|
+
);
|
|
62
|
+
const instances = await store.transaction((repos) =>
|
|
63
|
+
listCalendarInstances(repos, [current], WINDOW),
|
|
64
|
+
);
|
|
65
|
+
return instances.map((instance) => ({
|
|
66
|
+
summary: instance.summary,
|
|
67
|
+
startAt: instance.startAt,
|
|
68
|
+
allDay: instance.allDay,
|
|
69
|
+
status: instance.status,
|
|
70
|
+
}));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const writeFeed = async (
|
|
74
|
+
store: MemoryCalendarStore,
|
|
75
|
+
collection: CalendarCollectionItem,
|
|
76
|
+
feed: string,
|
|
77
|
+
now: number,
|
|
78
|
+
) => {
|
|
79
|
+
const split = await splitCalendarFeed(feed);
|
|
80
|
+
assert.ok(split.ok);
|
|
81
|
+
return writeCalendarFeed(
|
|
82
|
+
store,
|
|
83
|
+
{
|
|
84
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
85
|
+
calendarId: collection.calendarId,
|
|
86
|
+
},
|
|
87
|
+
split.value,
|
|
88
|
+
now,
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
describe("readSubscriptionUrl", () => {
|
|
93
|
+
it("reads webcal as the same address over https", () => {
|
|
94
|
+
const url = readSubscriptionUrl(
|
|
95
|
+
" webcal://calendar.example/feeds/rota.ics ",
|
|
96
|
+
);
|
|
97
|
+
assert.deepEqual(url, {
|
|
98
|
+
ok: true,
|
|
99
|
+
value: "https://calendar.example/feeds/rota.ics",
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("keeps http and https as given", () => {
|
|
104
|
+
assert.deepEqual(readSubscriptionUrl("http://127.0.0.1:8080/a.ics"), {
|
|
105
|
+
ok: true,
|
|
106
|
+
value: "http://127.0.0.1:8080/a.ics",
|
|
107
|
+
});
|
|
108
|
+
assert.deepEqual(readSubscriptionUrl(SECRET_URL), {
|
|
109
|
+
ok: true,
|
|
110
|
+
value: SECRET_URL,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("refuses what is not a web address", () => {
|
|
115
|
+
for (const raw of [
|
|
116
|
+
"",
|
|
117
|
+
"calendar.example/rota.ics",
|
|
118
|
+
"ftp://calendar.example/rota.ics",
|
|
119
|
+
"file:///etc/passwd",
|
|
120
|
+
"https://user:secret@calendar.example/rota.ics",
|
|
121
|
+
]) {
|
|
122
|
+
const url = readSubscriptionUrl(raw);
|
|
123
|
+
assert.equal(url.ok, false, raw);
|
|
124
|
+
if (!url.ok) assert.equal(url.error.code, "InvalidSubscriptionUrl");
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("fetchCalendarFeed", () => {
|
|
130
|
+
it("answers the feed's text", async () => {
|
|
131
|
+
const fetched = await fetchCalendarFeed(
|
|
132
|
+
SECRET_URL,
|
|
133
|
+
answering(GOOGLE_FEED, { status: 200 }),
|
|
134
|
+
);
|
|
135
|
+
assert.deepEqual(fetched, { ok: true, value: GOOGLE_FEED });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("names the status of a refusal and never the address", async () => {
|
|
139
|
+
const fetched = await fetchCalendarFeed(
|
|
140
|
+
SECRET_URL,
|
|
141
|
+
answering("gone", { status: 404 }),
|
|
142
|
+
);
|
|
143
|
+
assert.deepEqual(fetched, {
|
|
144
|
+
ok: false,
|
|
145
|
+
reason: "the feed answered HTTP 404",
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("names why an address could not be reached, without the address", async () => {
|
|
150
|
+
const fetched = await fetchCalendarFeed(SECRET_URL, async (url) => {
|
|
151
|
+
throw new TypeError("fetch failed", {
|
|
152
|
+
cause: Object.assign(new Error(`connect ECONNREFUSED ${url}`), {
|
|
153
|
+
code: "ECONNREFUSED",
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
assert.deepEqual(fetched, {
|
|
158
|
+
ok: false,
|
|
159
|
+
reason: "the feed could not be reached (ECONNREFUSED)",
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("says so when the feed does not answer in time", async () => {
|
|
164
|
+
const fetched = await fetchCalendarFeed(SECRET_URL, async () => {
|
|
165
|
+
throw new DOMException("timed out", "TimeoutError");
|
|
166
|
+
});
|
|
167
|
+
assert.deepEqual(fetched, {
|
|
168
|
+
ok: false,
|
|
169
|
+
reason: "the feed did not answer within 30 seconds",
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("stops reading a chunked feed once it passes the cap", async () => {
|
|
174
|
+
let pulled = 0;
|
|
175
|
+
let cancelled = false;
|
|
176
|
+
const chunk = new Uint8Array(1024 * 1024).fill(0x41);
|
|
177
|
+
const endless = new ReadableStream<Uint8Array>({
|
|
178
|
+
pull(controller) {
|
|
179
|
+
pulled += 1;
|
|
180
|
+
controller.enqueue(chunk);
|
|
181
|
+
},
|
|
182
|
+
cancel() {
|
|
183
|
+
cancelled = true;
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const fetched = await fetchCalendarFeed(SECRET_URL, async () => {
|
|
188
|
+
const response = new Response(endless, { status: 200 });
|
|
189
|
+
assert.equal(response.headers.get("content-length"), null);
|
|
190
|
+
return response;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
assert.deepEqual(fetched, {
|
|
194
|
+
ok: false,
|
|
195
|
+
reason: "the feed is larger than 10 MB",
|
|
196
|
+
});
|
|
197
|
+
assert.equal(cancelled, true);
|
|
198
|
+
assert.ok(pulled <= 12, `read ${pulled} MB of a feed capped at 10`);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("refuses a feed larger than it will store", async () => {
|
|
202
|
+
const fetched = await fetchCalendarFeed(
|
|
203
|
+
SECRET_URL,
|
|
204
|
+
answering("x", {
|
|
205
|
+
status: 200,
|
|
206
|
+
headers: { "content-length": String(64 * 1024 * 1024) },
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
assert.deepEqual(fetched, {
|
|
210
|
+
ok: false,
|
|
211
|
+
reason: "the feed is larger than 10 MB",
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("splitCalendarFeed", () => {
|
|
217
|
+
it("cuts a feed into one resource per UID, each one the write path accepts", async () => {
|
|
218
|
+
const split = await splitCalendarFeed(GOOGLE_FEED);
|
|
219
|
+
assert.ok(split.ok);
|
|
220
|
+
assert.equal(split.value.unreadable, 0);
|
|
221
|
+
assert.deepEqual(
|
|
222
|
+
split.value.resources.map((resource) => resource.uid),
|
|
223
|
+
[
|
|
224
|
+
"pilot-standup@google.com",
|
|
225
|
+
"quay-offsite@google.com",
|
|
226
|
+
"tide-review@google.com",
|
|
227
|
+
],
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
for (const resource of split.value.resources) {
|
|
231
|
+
assert.match(resource.resourceName, /^[0-9a-f]{40}\.ics$/);
|
|
232
|
+
const parsed = await parseCalendar(resource.icalData);
|
|
233
|
+
assert.ok(parsed.ok, resource.uid);
|
|
234
|
+
assert.equal(parsed.value.uid, resource.uid);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("keeps a series with its overrides and only the zones it names", async () => {
|
|
239
|
+
const split = await splitCalendarFeed(GOOGLE_FEED);
|
|
240
|
+
assert.ok(split.ok);
|
|
241
|
+
const standup = split.value.resources[0];
|
|
242
|
+
assert.ok(standup);
|
|
243
|
+
|
|
244
|
+
const parsed = await parseCalendar(standup.icalData);
|
|
245
|
+
assert.ok(parsed.ok);
|
|
246
|
+
assert.equal(parsed.value.overrides.length, 2);
|
|
247
|
+
assert.deepEqual(
|
|
248
|
+
parsed.value.component
|
|
249
|
+
.getAllSubcomponents("vtimezone")
|
|
250
|
+
.map((zone) => zone.getFirstPropertyValue("tzid")),
|
|
251
|
+
["Europe/Amsterdam"],
|
|
252
|
+
);
|
|
253
|
+
assert.equal(parsed.value.component.hasProperty("method"), false);
|
|
254
|
+
assert.equal(
|
|
255
|
+
parsed.value.component.getFirstPropertyValue("x-wr-calname"),
|
|
256
|
+
"Harbour rota",
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const offsite = split.value.resources[1];
|
|
260
|
+
assert.ok(offsite);
|
|
261
|
+
assert.doesNotMatch(offsite.icalData, /BEGIN:VTIMEZONE/);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("names the same resource on every read of the same feed", async () => {
|
|
265
|
+
const first = await splitCalendarFeed(GOOGLE_FEED);
|
|
266
|
+
const second = await splitCalendarFeed(GOOGLE_FEED);
|
|
267
|
+
assert.ok(first.ok && second.ok);
|
|
268
|
+
assert.deepEqual(first.value.resources, second.value.resources);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("counts an event with no UID instead of storing it", async () => {
|
|
272
|
+
const split = await splitCalendarFeed(
|
|
273
|
+
ical(
|
|
274
|
+
"BEGIN:VCALENDAR",
|
|
275
|
+
"VERSION:2.0",
|
|
276
|
+
"PRODID:-//Example//Feed//EN",
|
|
277
|
+
"BEGIN:VEVENT",
|
|
278
|
+
"DTSTART:20261008T140000Z",
|
|
279
|
+
"SUMMARY:Nobody's event",
|
|
280
|
+
"END:VEVENT",
|
|
281
|
+
"END:VCALENDAR",
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
assert.ok(split.ok);
|
|
285
|
+
assert.equal(split.value.resources.length, 0);
|
|
286
|
+
assert.equal(split.value.unreadable, 1);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("refuses bytes that are not a calendar", async () => {
|
|
290
|
+
const split = await splitCalendarFeed(
|
|
291
|
+
"<!doctype html><title>Sign in</title>",
|
|
292
|
+
);
|
|
293
|
+
assert.equal(split.ok, false);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe("writeCalendarFeed", () => {
|
|
298
|
+
it("keeps recurrence, a moved and a cancelled instance, all-day and zoned times", async () => {
|
|
299
|
+
const { store, collection } = await subscribed();
|
|
300
|
+
|
|
301
|
+
const write = await writeFeed(store, collection, GOOGLE_FEED, 5_000);
|
|
302
|
+
|
|
303
|
+
assert.deepEqual(write, {
|
|
304
|
+
paused: false,
|
|
305
|
+
written: 3,
|
|
306
|
+
unchanged: 0,
|
|
307
|
+
removed: 0,
|
|
308
|
+
skipped: 0,
|
|
309
|
+
});
|
|
310
|
+
assert.deepEqual(await drawn(store, collection), [
|
|
311
|
+
{
|
|
312
|
+
summary: "Pilot standup",
|
|
313
|
+
startAt: "2026-10-05T07:00:00Z",
|
|
314
|
+
allDay: false,
|
|
315
|
+
status: "Confirmed",
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
summary: "Quay offsite",
|
|
319
|
+
startAt: "2026-10-07T00:00:00Z",
|
|
320
|
+
allDay: true,
|
|
321
|
+
status: "Confirmed",
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
summary: "Tide table review",
|
|
325
|
+
startAt: "2026-10-08T14:00:00Z",
|
|
326
|
+
allDay: false,
|
|
327
|
+
status: "Confirmed",
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
summary: "Pilot standup (moved)",
|
|
331
|
+
startAt: "2026-10-12T08:00:00Z",
|
|
332
|
+
allDay: false,
|
|
333
|
+
status: "Confirmed",
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
summary: "Pilot standup",
|
|
337
|
+
startAt: "2026-10-26T08:00:00Z",
|
|
338
|
+
allDay: false,
|
|
339
|
+
status: "Confirmed",
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
summary: "Pilot standup",
|
|
343
|
+
startAt: "2026-11-02T08:00:00Z",
|
|
344
|
+
allDay: false,
|
|
345
|
+
status: "Cancelled",
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
summary: "Pilot standup",
|
|
349
|
+
startAt: "2026-11-09T08:00:00Z",
|
|
350
|
+
allDay: false,
|
|
351
|
+
status: "Confirmed",
|
|
352
|
+
},
|
|
353
|
+
]);
|
|
354
|
+
|
|
355
|
+
const stored = await store.transaction((repos) =>
|
|
356
|
+
repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
|
|
357
|
+
);
|
|
358
|
+
assert.equal(stored.subscriptionCheckedAt, 5_000);
|
|
359
|
+
assert.equal(stored.subscriptionFetchedAt, 5_000);
|
|
360
|
+
assert.equal(stored.subscriptionError, "");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("leaves unchanged events alone and removes what the feed dropped", async () => {
|
|
364
|
+
const { store, collection } = await subscribed();
|
|
365
|
+
await writeFeed(store, collection, GOOGLE_FEED, 5_000);
|
|
366
|
+
const sequenceBefore = (
|
|
367
|
+
await store.transaction((repos) =>
|
|
368
|
+
repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
|
|
369
|
+
)
|
|
370
|
+
).syncSequence;
|
|
371
|
+
|
|
372
|
+
const dropped = GOOGLE_FEED.replace(
|
|
373
|
+
/BEGIN:VEVENT\r\nDTSTART:20261008T140000Z[\s\S]*?END:VEVENT\r\n/,
|
|
374
|
+
"",
|
|
375
|
+
);
|
|
376
|
+
const write = await writeFeed(store, collection, dropped, 9_000);
|
|
377
|
+
|
|
378
|
+
assert.deepEqual(write, {
|
|
379
|
+
paused: false,
|
|
380
|
+
written: 0,
|
|
381
|
+
unchanged: 2,
|
|
382
|
+
removed: 1,
|
|
383
|
+
skipped: 0,
|
|
384
|
+
});
|
|
385
|
+
const summaries = (await drawn(store, collection)).map(
|
|
386
|
+
(instance) => instance.summary,
|
|
387
|
+
);
|
|
388
|
+
assert.equal(summaries.includes("Tide table review"), false);
|
|
389
|
+
assert.equal(summaries.length, 6);
|
|
390
|
+
const after = await store.transaction((repos) =>
|
|
391
|
+
repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
|
|
392
|
+
);
|
|
393
|
+
assert.equal(after.syncSequence, sequenceBefore + 1);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("writes nothing when a second fetch differs only in its export stamps", async () => {
|
|
397
|
+
const { store, collection } = await subscribed();
|
|
398
|
+
await writeFeed(store, collection, GOOGLE_FEED, 5_000);
|
|
399
|
+
const before = await store.transaction((repos) =>
|
|
400
|
+
repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
const restamped = GOOGLE_FEED.replaceAll(
|
|
404
|
+
"DTSTAMP:20260925T120000Z",
|
|
405
|
+
"DTSTAMP:20260926T081500Z",
|
|
406
|
+
);
|
|
407
|
+
const write = await writeFeed(store, collection, restamped, 9_000);
|
|
408
|
+
|
|
409
|
+
assert.deepEqual(write, {
|
|
410
|
+
paused: false,
|
|
411
|
+
written: 0,
|
|
412
|
+
unchanged: 3,
|
|
413
|
+
removed: 0,
|
|
414
|
+
skipped: 0,
|
|
415
|
+
});
|
|
416
|
+
const after = await store.transaction((repos) =>
|
|
417
|
+
repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
|
|
418
|
+
);
|
|
419
|
+
assert.equal(after.syncSequence, before.syncSequence);
|
|
420
|
+
assert.equal(after.subscriptionFetchedAt, 9_000);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("writes nothing for a subscription paused while its feed was read", async () => {
|
|
424
|
+
const { store, collection } = await subscribed();
|
|
425
|
+
await store.transaction((repos) =>
|
|
426
|
+
repos.calendarCollection.update(
|
|
427
|
+
ACCOUNT_CONFIG_ID,
|
|
428
|
+
collection.calendarId,
|
|
429
|
+
{ subscriptionEnabled: false },
|
|
430
|
+
),
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
const write = await writeFeed(store, collection, GOOGLE_FEED, 5_000);
|
|
434
|
+
|
|
435
|
+
assert.deepEqual(write, {
|
|
436
|
+
paused: true,
|
|
437
|
+
written: 0,
|
|
438
|
+
unchanged: 0,
|
|
439
|
+
removed: 0,
|
|
440
|
+
skipped: 0,
|
|
441
|
+
});
|
|
442
|
+
assert.deepEqual(await drawn(store, collection), []);
|
|
443
|
+
const stored = await store.transaction((repos) =>
|
|
444
|
+
repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
|
|
445
|
+
);
|
|
446
|
+
assert.equal(stored.subscriptionFetchedAt, 0);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("records the events it could not store and keeps the rest", async () => {
|
|
450
|
+
const { store, collection } = await subscribed();
|
|
451
|
+
|
|
452
|
+
const write = await writeFeed(
|
|
453
|
+
store,
|
|
454
|
+
collection,
|
|
455
|
+
ical(
|
|
456
|
+
"BEGIN:VCALENDAR",
|
|
457
|
+
"VERSION:2.0",
|
|
458
|
+
"PRODID:-//Example//Feed//EN",
|
|
459
|
+
"BEGIN:VEVENT",
|
|
460
|
+
"UID:kept@example.com",
|
|
461
|
+
"DTSTART:20261008T140000Z",
|
|
462
|
+
"DTEND:20261008T150000Z",
|
|
463
|
+
"SUMMARY:Kept",
|
|
464
|
+
"END:VEVENT",
|
|
465
|
+
"BEGIN:VEVENT",
|
|
466
|
+
"UID:backwards@example.com",
|
|
467
|
+
"DTSTART:20261008T140000Z",
|
|
468
|
+
"DTEND:20261008T130000Z",
|
|
469
|
+
"SUMMARY:Backwards",
|
|
470
|
+
"END:VEVENT",
|
|
471
|
+
"BEGIN:VEVENT",
|
|
472
|
+
"DTSTART:20261009T140000Z",
|
|
473
|
+
"SUMMARY:No UID",
|
|
474
|
+
"END:VEVENT",
|
|
475
|
+
"END:VCALENDAR",
|
|
476
|
+
),
|
|
477
|
+
7_000,
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
assert.equal(write.written, 1);
|
|
481
|
+
assert.equal(write.skipped, 2);
|
|
482
|
+
const stored = await store.transaction((repos) =>
|
|
483
|
+
repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
|
|
484
|
+
);
|
|
485
|
+
assert.equal(
|
|
486
|
+
stored.subscriptionError,
|
|
487
|
+
"2 events in the feed could not be stored",
|
|
488
|
+
);
|
|
489
|
+
assert.deepEqual(
|
|
490
|
+
(await drawn(store, collection)).map((instance) => instance.summary),
|
|
491
|
+
["Kept"],
|
|
492
|
+
);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
describe("refreshCalendarSubscription", () => {
|
|
497
|
+
it("records a failed fetch on the collection and keeps every event", async () => {
|
|
498
|
+
const { store, collection } = await subscribed();
|
|
499
|
+
await writeFeed(store, collection, GOOGLE_FEED, 5_000);
|
|
500
|
+
|
|
501
|
+
const refreshed = await refreshCalendarSubscription(
|
|
502
|
+
store,
|
|
503
|
+
collection,
|
|
504
|
+
9_000,
|
|
505
|
+
answering("unavailable", { status: 503 }),
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
assert.deepEqual(refreshed, {
|
|
509
|
+
ok: false,
|
|
510
|
+
reason: "the feed answered HTTP 503",
|
|
511
|
+
});
|
|
512
|
+
const stored = await store.transaction((repos) =>
|
|
513
|
+
repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
|
|
514
|
+
);
|
|
515
|
+
assert.equal(stored.subscriptionCheckedAt, 9_000);
|
|
516
|
+
assert.equal(stored.subscriptionFetchedAt, 5_000);
|
|
517
|
+
assert.equal(stored.subscriptionError, "the feed answered HTTP 503");
|
|
518
|
+
assert.equal(stored.subscriptionError.includes(SECRET_URL), false);
|
|
519
|
+
assert.equal((await drawn(store, collection)).length, 7);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("states that a page which is not a calendar is not one", async () => {
|
|
523
|
+
const { store, collection } = await subscribed();
|
|
524
|
+
|
|
525
|
+
const refreshed = await refreshCalendarSubscription(
|
|
526
|
+
store,
|
|
527
|
+
collection,
|
|
528
|
+
9_000,
|
|
529
|
+
answering("<!doctype html><title>Sign in</title>", { status: 200 }),
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
assert.equal(refreshed.ok, false);
|
|
533
|
+
if (!refreshed.ok) {
|
|
534
|
+
assert.match(refreshed.reason, /^the feed is not a calendar: /);
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("fetches the collection's own address and clears an earlier failure", async () => {
|
|
539
|
+
const { store, collection } = await subscribed();
|
|
540
|
+
await refreshCalendarSubscription(
|
|
541
|
+
store,
|
|
542
|
+
collection,
|
|
543
|
+
3_000,
|
|
544
|
+
answering("", { status: 500 }),
|
|
545
|
+
);
|
|
546
|
+
const asked: string[] = [];
|
|
547
|
+
|
|
548
|
+
const refreshed = await refreshCalendarSubscription(
|
|
549
|
+
store,
|
|
550
|
+
collection,
|
|
551
|
+
6_000,
|
|
552
|
+
async (url) => {
|
|
553
|
+
asked.push(url);
|
|
554
|
+
return new Response(GOOGLE_FEED, { status: 200 });
|
|
555
|
+
},
|
|
556
|
+
);
|
|
557
|
+
|
|
558
|
+
assert.deepEqual(asked, [SECRET_URL]);
|
|
559
|
+
assert.ok(refreshed.ok);
|
|
560
|
+
const stored = await store.transaction((repos) =>
|
|
561
|
+
repos.calendarCollection.get(ACCOUNT_CONFIG_ID, collection.calendarId),
|
|
562
|
+
);
|
|
563
|
+
assert.equal(stored.subscriptionError, "");
|
|
564
|
+
assert.equal(stored.subscriptionFetchedAt, 6_000);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
describe("isSubscriptionDue", () => {
|
|
569
|
+
it("is due once the last fetch is an interval old, failed or not", async () => {
|
|
570
|
+
const { collection } = await subscribed();
|
|
571
|
+
const checked = { ...collection, subscriptionCheckedAt: 10_000 };
|
|
572
|
+
|
|
573
|
+
assert.equal(isSubscriptionDue(checked, 10_000 + 899, 900), false);
|
|
574
|
+
assert.equal(isSubscriptionDue(checked, 10_000 + 900, 900), true);
|
|
575
|
+
assert.equal(isSubscriptionDue(collection, 1, 900), false);
|
|
576
|
+
assert.equal(isSubscriptionDue(collection, 900, 900), true);
|
|
577
|
+
});
|
|
578
|
+
});
|
package/src/subscribe.ts
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
CalendarCollectionItem,
|
|
4
|
+
ICalendarUnitOfWork,
|
|
5
|
+
} from "@remit/data-ports";
|
|
6
|
+
import ICAL from "ical.js";
|
|
7
|
+
import { type CalendarResult, calendarFailure } from "./errors.js";
|
|
8
|
+
import { readCalendarComponent, serializeCalendar } from "./parse.js";
|
|
9
|
+
import { deleteCalendarObject, putCalendarObject } from "./put.js";
|
|
10
|
+
|
|
11
|
+
export const CALENDAR_SUBSCRIPTION_TIMEOUT_MS = 30_000;
|
|
12
|
+
|
|
13
|
+
export const CALENDAR_SUBSCRIPTION_MAX_BYTES = 10 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
const SUBSCRIPTION_ERROR_MAX_LENGTH = 512;
|
|
16
|
+
|
|
17
|
+
const SCHEMES: Record<string, string> = {
|
|
18
|
+
"http:": "http:",
|
|
19
|
+
"https:": "https:",
|
|
20
|
+
"webcal:": "https:",
|
|
21
|
+
"webcals:": "https:",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type CalendarFeedFetcher = (
|
|
25
|
+
url: string,
|
|
26
|
+
init: RequestInit,
|
|
27
|
+
) => Promise<Response>;
|
|
28
|
+
|
|
29
|
+
export type CalendarFeedFetch =
|
|
30
|
+
| { ok: true; value: string }
|
|
31
|
+
| { ok: false; reason: string };
|
|
32
|
+
|
|
33
|
+
export interface CalendarFeedResource {
|
|
34
|
+
uid: string;
|
|
35
|
+
resourceName: string;
|
|
36
|
+
icalData: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CalendarFeedSplit {
|
|
40
|
+
resources: CalendarFeedResource[];
|
|
41
|
+
unreadable: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CalendarFeedWrite {
|
|
45
|
+
/** The subscription was paused while its feed was being read, so nothing was written. */
|
|
46
|
+
paused: boolean;
|
|
47
|
+
written: number;
|
|
48
|
+
unchanged: number;
|
|
49
|
+
removed: number;
|
|
50
|
+
skipped: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CalendarSubscriptionTarget {
|
|
54
|
+
accountConfigId: string;
|
|
55
|
+
calendarId: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type CalendarSubscriptionRefresh =
|
|
59
|
+
| { ok: true; value: CalendarFeedWrite }
|
|
60
|
+
| { ok: false; reason: string };
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The URL a subscription is fetched from. `webcal:` is what calendar products
|
|
64
|
+
* hand out for "subscribe", and means the same address over HTTPS.
|
|
65
|
+
*/
|
|
66
|
+
export const readSubscriptionUrl = (raw: string): CalendarResult<string> => {
|
|
67
|
+
const trimmed = raw.trim();
|
|
68
|
+
if (!URL.canParse(trimmed)) {
|
|
69
|
+
return calendarFailure(
|
|
70
|
+
"InvalidSubscriptionUrl",
|
|
71
|
+
"the subscription address is not a URL",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const url = new URL(trimmed);
|
|
75
|
+
const scheme = SCHEMES[url.protocol];
|
|
76
|
+
if (!scheme) {
|
|
77
|
+
return calendarFailure(
|
|
78
|
+
"InvalidSubscriptionUrl",
|
|
79
|
+
`a subscription address starts with http, https or webcal, not ${url.protocol.replace(/:$/, "")}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (url.username !== "" || url.password !== "") {
|
|
83
|
+
return calendarFailure(
|
|
84
|
+
"InvalidSubscriptionUrl",
|
|
85
|
+
"a subscription address carries no user name or password",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const address = new URL(`${scheme}${trimmed.slice(url.protocol.length)}`);
|
|
89
|
+
return { ok: true, value: address.toString() };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const truncate = (reason: string): string =>
|
|
93
|
+
reason.length <= SUBSCRIPTION_ERROR_MAX_LENGTH
|
|
94
|
+
? reason
|
|
95
|
+
: `${reason.slice(0, SUBSCRIPTION_ERROR_MAX_LENGTH - 1)}…`;
|
|
96
|
+
|
|
97
|
+
const unreachableReason = (error: unknown): string => {
|
|
98
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
99
|
+
return `the feed did not answer within ${CALENDAR_SUBSCRIPTION_TIMEOUT_MS / 1000} seconds`;
|
|
100
|
+
}
|
|
101
|
+
const cause =
|
|
102
|
+
error instanceof Error && error.cause instanceof Error
|
|
103
|
+
? error.cause
|
|
104
|
+
: undefined;
|
|
105
|
+
const code =
|
|
106
|
+
cause && "code" in cause && typeof cause.code === "string"
|
|
107
|
+
? cause.code
|
|
108
|
+
: undefined;
|
|
109
|
+
return code
|
|
110
|
+
? `the feed could not be reached (${code})`
|
|
111
|
+
: "the feed could not be reached";
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Reads a subscription's feed. Every failure is a returned reason that a
|
|
116
|
+
* person can act on and that never repeats the URL: for a Google secret
|
|
117
|
+
* address the URL is the whole credential, and the reason is stored on the
|
|
118
|
+
* collection and drawn in the calendar view.
|
|
119
|
+
*/
|
|
120
|
+
export const fetchCalendarFeed = async (
|
|
121
|
+
url: string,
|
|
122
|
+
fetcher: CalendarFeedFetcher = fetch,
|
|
123
|
+
): Promise<CalendarFeedFetch> => {
|
|
124
|
+
const response = await fetcher(url, {
|
|
125
|
+
headers: { accept: "text/calendar, */*;q=0.5" },
|
|
126
|
+
redirect: "follow",
|
|
127
|
+
signal: AbortSignal.timeout(CALENDAR_SUBSCRIPTION_TIMEOUT_MS),
|
|
128
|
+
}).then(
|
|
129
|
+
(value) => ({ ok: true, value }) as const,
|
|
130
|
+
(error: unknown) =>
|
|
131
|
+
({ ok: false, reason: unreachableReason(error) }) as const,
|
|
132
|
+
);
|
|
133
|
+
if (!response.ok) return response;
|
|
134
|
+
|
|
135
|
+
const { status } = response.value;
|
|
136
|
+
if (status < 200 || status > 299) {
|
|
137
|
+
await response.value.body?.cancel();
|
|
138
|
+
return { ok: false, reason: `the feed answered HTTP ${status}` };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const declared = Number(response.value.headers.get("content-length") ?? "0");
|
|
142
|
+
if (declared > CALENDAR_SUBSCRIPTION_MAX_BYTES) {
|
|
143
|
+
await response.value.body?.cancel();
|
|
144
|
+
return { ok: false, reason: TOO_LARGE };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const { body } = response.value;
|
|
148
|
+
if (!body) return { ok: true, value: "" };
|
|
149
|
+
return readCapped(body).then(
|
|
150
|
+
(read) => read,
|
|
151
|
+
(error: unknown) => ({ ok: false, reason: unreachableReason(error) }),
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const TOO_LARGE = "the feed is larger than 10 MB";
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Reads a body a chunk at a time and stops at the cap. A feed sent chunked
|
|
159
|
+
* carries no length to refuse up front, so counting as it arrives is the only
|
|
160
|
+
* thing that keeps an endless body out of memory.
|
|
161
|
+
*/
|
|
162
|
+
const readCapped = async (
|
|
163
|
+
body: ReadableStream<Uint8Array>,
|
|
164
|
+
): Promise<CalendarFeedFetch> => {
|
|
165
|
+
const reader = body.getReader();
|
|
166
|
+
const decoder = new TextDecoder();
|
|
167
|
+
let received = 0;
|
|
168
|
+
let text = "";
|
|
169
|
+
for (;;) {
|
|
170
|
+
const { done, value } = await reader.read();
|
|
171
|
+
if (done) break;
|
|
172
|
+
received += value.byteLength;
|
|
173
|
+
if (received > CALENDAR_SUBSCRIPTION_MAX_BYTES) {
|
|
174
|
+
await reader.cancel();
|
|
175
|
+
return { ok: false, reason: TOO_LARGE };
|
|
176
|
+
}
|
|
177
|
+
text += decoder.decode(value, { stream: true });
|
|
178
|
+
}
|
|
179
|
+
return { ok: true, value: text + decoder.decode() };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A resource as it would compare across two fetches. DTSTAMP is when the
|
|
184
|
+
* provider exported the feed, not when the event changed — Google restamps it
|
|
185
|
+
* on every fetch — so it is left out, or every refresh would rewrite every
|
|
186
|
+
* event.
|
|
187
|
+
*/
|
|
188
|
+
const comparable = (icalData: string): string =>
|
|
189
|
+
icalData.replace(/^DTSTAMP[;:][^\r\n]*\r?\n/gim, "");
|
|
190
|
+
|
|
191
|
+
const resourceNameOf = (uid: string): string =>
|
|
192
|
+
`${createHash("sha256").update(uid, "utf8").digest("hex").slice(0, 40)}.ics`;
|
|
193
|
+
|
|
194
|
+
const zonesReferencedBy = (events: ICAL.Component[]): Set<string> => {
|
|
195
|
+
const zones = new Set<string>();
|
|
196
|
+
for (const event of events) {
|
|
197
|
+
for (const property of event.getAllProperties()) {
|
|
198
|
+
const tzid = property.getParameter("tzid");
|
|
199
|
+
if (typeof tzid === "string") zones.add(tzid);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return zones;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Cuts a whole feed into one resource per UID, the shape the calendar write
|
|
207
|
+
* path stores: a series and its RECURRENCE-ID overrides travel together, with
|
|
208
|
+
* the VTIMEZONEs they name, so a DST-crossing series resolves against the zone
|
|
209
|
+
* the provider published.
|
|
210
|
+
*
|
|
211
|
+
* The feed's calendar-level properties are kept on every resource except
|
|
212
|
+
* METHOD, which a stored resource never carries. A VEVENT with no UID has
|
|
213
|
+
* nothing to key it by across refreshes, so it is counted and left out.
|
|
214
|
+
*/
|
|
215
|
+
export const splitCalendarFeed = async (
|
|
216
|
+
icalData: string,
|
|
217
|
+
): Promise<CalendarResult<CalendarFeedSplit>> => {
|
|
218
|
+
const read = await readCalendarComponent(icalData);
|
|
219
|
+
if (!read.ok) return read;
|
|
220
|
+
const feed = read.value;
|
|
221
|
+
if (feed.name !== "vcalendar") {
|
|
222
|
+
return calendarFailure(
|
|
223
|
+
"NotACalendar",
|
|
224
|
+
feed.name
|
|
225
|
+
? `expected a VCALENDAR, found ${feed.name.toUpperCase()}`
|
|
226
|
+
: "expected a VCALENDAR, found nothing",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const timezones = feed.getAllSubcomponents("vtimezone");
|
|
231
|
+
const byUid = new Map<string, ICAL.Component[]>();
|
|
232
|
+
let unreadable = 0;
|
|
233
|
+
for (const event of feed.getAllSubcomponents("vevent")) {
|
|
234
|
+
const uid = event.getFirstPropertyValue("uid");
|
|
235
|
+
if (typeof uid !== "string" || uid === "") {
|
|
236
|
+
unreadable += 1;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
byUid.set(uid, [...(byUid.get(uid) ?? []), event]);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const resources = [...byUid].map(([uid, events]) => {
|
|
243
|
+
const calendar = new ICAL.Component("vcalendar");
|
|
244
|
+
for (const property of feed.getAllProperties()) {
|
|
245
|
+
if (property.name === "method") continue;
|
|
246
|
+
calendar.addProperty(new ICAL.Property(property.toJSON()));
|
|
247
|
+
}
|
|
248
|
+
const zones = zonesReferencedBy(events);
|
|
249
|
+
for (const timezone of timezones) {
|
|
250
|
+
const tzid = timezone.getFirstPropertyValue("tzid");
|
|
251
|
+
if (typeof tzid === "string" && zones.has(tzid)) {
|
|
252
|
+
calendar.addSubcomponent(new ICAL.Component(timezone.toJSON()));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
for (const event of events) {
|
|
256
|
+
calendar.addSubcomponent(new ICAL.Component(event.toJSON()));
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
uid,
|
|
260
|
+
resourceName: resourceNameOf(uid),
|
|
261
|
+
icalData: serializeCalendar(calendar),
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
return { ok: true, value: { resources, unreadable } };
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const feedErrorOf = (write: CalendarFeedWrite): string =>
|
|
269
|
+
write.skipped === 0
|
|
270
|
+
? ""
|
|
271
|
+
: `${write.skipped} ${write.skipped === 1 ? "event" : "events"} in the feed could not be stored`;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Makes a subscribed collection hold what the feed holds, in one unit.
|
|
275
|
+
*
|
|
276
|
+
* Every resource goes through `putCalendarObject`, the one write path, so a
|
|
277
|
+
* subscribed event is stored, projected and expanded the way any other event
|
|
278
|
+
* is. A resource whose bytes have not changed is left alone, and one the feed
|
|
279
|
+
* no longer carries is removed. A resource the write path refuses is counted
|
|
280
|
+
* and recorded on the collection rather than failing the whole refresh.
|
|
281
|
+
*/
|
|
282
|
+
export const writeCalendarFeed = async (
|
|
283
|
+
unitOfWork: ICalendarUnitOfWork,
|
|
284
|
+
target: CalendarSubscriptionTarget,
|
|
285
|
+
split: CalendarFeedSplit,
|
|
286
|
+
now: number,
|
|
287
|
+
): Promise<CalendarFeedWrite> =>
|
|
288
|
+
unitOfWork.transaction(async (repos) => {
|
|
289
|
+
const current = await repos.calendarCollection.get(
|
|
290
|
+
target.accountConfigId,
|
|
291
|
+
target.calendarId,
|
|
292
|
+
);
|
|
293
|
+
if (!current.subscriptionEnabled) {
|
|
294
|
+
return { paused: true, written: 0, unchanged: 0, removed: 0, skipped: 0 };
|
|
295
|
+
}
|
|
296
|
+
const stored = await repos.calendarObject.listByCalendar(target.calendarId);
|
|
297
|
+
const storedByName = new Map(
|
|
298
|
+
stored.map((object) => [object.resourceName, object]),
|
|
299
|
+
);
|
|
300
|
+
const kept = new Set<string>();
|
|
301
|
+
const write: CalendarFeedWrite = {
|
|
302
|
+
paused: false,
|
|
303
|
+
written: 0,
|
|
304
|
+
unchanged: 0,
|
|
305
|
+
removed: 0,
|
|
306
|
+
skipped: split.unreadable,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
for (const resource of split.resources) {
|
|
310
|
+
const existing = storedByName.get(resource.resourceName);
|
|
311
|
+
if (
|
|
312
|
+
existing &&
|
|
313
|
+
comparable(existing.icalData) === comparable(resource.icalData)
|
|
314
|
+
) {
|
|
315
|
+
kept.add(resource.resourceName);
|
|
316
|
+
write.unchanged += 1;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
const put = await putCalendarObject(unitOfWork, {
|
|
320
|
+
accountConfigId: target.accountConfigId,
|
|
321
|
+
calendarId: target.calendarId,
|
|
322
|
+
resourceName: resource.resourceName,
|
|
323
|
+
icalData: resource.icalData,
|
|
324
|
+
});
|
|
325
|
+
if (!put.ok) {
|
|
326
|
+
write.skipped += 1;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
kept.add(resource.resourceName);
|
|
330
|
+
write.written += 1;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
for (const object of stored) {
|
|
334
|
+
if (kept.has(object.resourceName)) continue;
|
|
335
|
+
await deleteCalendarObject(unitOfWork, {
|
|
336
|
+
accountConfigId: target.accountConfigId,
|
|
337
|
+
calendarId: target.calendarId,
|
|
338
|
+
calendarObjectId: object.calendarObjectId,
|
|
339
|
+
});
|
|
340
|
+
write.removed += 1;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
await repos.calendarCollection.update(
|
|
344
|
+
target.accountConfigId,
|
|
345
|
+
target.calendarId,
|
|
346
|
+
{
|
|
347
|
+
subscriptionCheckedAt: now,
|
|
348
|
+
subscriptionFetchedAt: now,
|
|
349
|
+
subscriptionError: truncate(feedErrorOf(write)),
|
|
350
|
+
},
|
|
351
|
+
);
|
|
352
|
+
return write;
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
/** Records a refresh that stored nothing, and why. Every stored event stays. */
|
|
356
|
+
export const recordCalendarFeedFailure = async (
|
|
357
|
+
unitOfWork: ICalendarUnitOfWork,
|
|
358
|
+
target: CalendarSubscriptionTarget,
|
|
359
|
+
reason: string,
|
|
360
|
+
now: number,
|
|
361
|
+
): Promise<void> => {
|
|
362
|
+
await unitOfWork.transaction((repos) =>
|
|
363
|
+
repos.calendarCollection.update(target.accountConfigId, target.calendarId, {
|
|
364
|
+
subscriptionCheckedAt: now,
|
|
365
|
+
subscriptionError: truncate(reason),
|
|
366
|
+
}),
|
|
367
|
+
);
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/** Fetches and cuts a feed, or answers why it cannot be stored. */
|
|
371
|
+
export const readCalendarFeed = async (
|
|
372
|
+
url: string,
|
|
373
|
+
fetcher: CalendarFeedFetcher = fetch,
|
|
374
|
+
): Promise<
|
|
375
|
+
{ ok: true; value: CalendarFeedSplit } | { ok: false; reason: string }
|
|
376
|
+
> => {
|
|
377
|
+
const fetched = await fetchCalendarFeed(url, fetcher);
|
|
378
|
+
if (!fetched.ok) return fetched;
|
|
379
|
+
const split = await splitCalendarFeed(fetched.value);
|
|
380
|
+
if (!split.ok) {
|
|
381
|
+
return {
|
|
382
|
+
ok: false,
|
|
383
|
+
reason: `the feed is not a calendar: ${split.error.message}`,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
return split;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* One scheduled refresh of one subscription. A feed that cannot be read
|
|
391
|
+
* records its reason on the collection and removes nothing.
|
|
392
|
+
*/
|
|
393
|
+
export const refreshCalendarSubscription = async (
|
|
394
|
+
unitOfWork: ICalendarUnitOfWork,
|
|
395
|
+
collection: CalendarCollectionItem,
|
|
396
|
+
now: number,
|
|
397
|
+
fetcher: CalendarFeedFetcher = fetch,
|
|
398
|
+
): Promise<CalendarSubscriptionRefresh> => {
|
|
399
|
+
const target = {
|
|
400
|
+
accountConfigId: collection.accountConfigId,
|
|
401
|
+
calendarId: collection.calendarId,
|
|
402
|
+
};
|
|
403
|
+
const feed = await readCalendarFeed(collection.subscriptionUrl, fetcher);
|
|
404
|
+
if (!feed.ok) {
|
|
405
|
+
await recordCalendarFeedFailure(unitOfWork, target, feed.reason, now);
|
|
406
|
+
return feed;
|
|
407
|
+
}
|
|
408
|
+
const write = await writeCalendarFeed(unitOfWork, target, feed.value, now);
|
|
409
|
+
return { ok: true, value: write };
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Whether a subscription is due a refresh: its last fetch, successful or not,
|
|
414
|
+
* is at least `intervalMs` old.
|
|
415
|
+
*/
|
|
416
|
+
export const isSubscriptionDue = (
|
|
417
|
+
collection: CalendarCollectionItem,
|
|
418
|
+
now: number,
|
|
419
|
+
intervalMs: number,
|
|
420
|
+
): boolean => now - collection.subscriptionCheckedAt >= intervalMs;
|
package/src/window.test.ts
CHANGED
|
@@ -36,6 +36,11 @@ const collection = (
|
|
|
36
36
|
source: CalendarSource.Default,
|
|
37
37
|
timezone: "",
|
|
38
38
|
syncSequence: 1,
|
|
39
|
+
subscriptionUrl: "",
|
|
40
|
+
subscriptionEnabled: false,
|
|
41
|
+
subscriptionCheckedAt: 0,
|
|
42
|
+
subscriptionFetchedAt: 0,
|
|
43
|
+
subscriptionError: "",
|
|
39
44
|
createdAt: 0,
|
|
40
45
|
updatedAt: 0,
|
|
41
46
|
...overrides,
|