@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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { CalendarCollectionItem, ICalendarStore } from "@remit/data-ports";
|
|
2
|
+
import { type CalendarResult } from "./errors.js";
|
|
3
|
+
export declare const CALENDAR_SUBSCRIPTION_TIMEOUT_MS = 30000;
|
|
4
|
+
export declare const CALENDAR_SUBSCRIPTION_MAX_BYTES: number;
|
|
5
|
+
export type CalendarFeedFetcher = (url: string, init: RequestInit) => Promise<Response>;
|
|
6
|
+
export type CalendarFeedFetch = {
|
|
7
|
+
ok: true;
|
|
8
|
+
value: string;
|
|
9
|
+
} | {
|
|
10
|
+
ok: false;
|
|
11
|
+
reason: string;
|
|
12
|
+
};
|
|
13
|
+
export interface CalendarFeedResource {
|
|
14
|
+
uid: string;
|
|
15
|
+
resourceName: string;
|
|
16
|
+
icalData: string;
|
|
17
|
+
}
|
|
18
|
+
export interface CalendarFeedSplit {
|
|
19
|
+
resources: CalendarFeedResource[];
|
|
20
|
+
unreadable: number;
|
|
21
|
+
}
|
|
22
|
+
export interface CalendarFeedWrite {
|
|
23
|
+
/** The subscription was paused while its feed was being read, so nothing was written. */
|
|
24
|
+
paused: boolean;
|
|
25
|
+
written: number;
|
|
26
|
+
unchanged: number;
|
|
27
|
+
removed: number;
|
|
28
|
+
skipped: number;
|
|
29
|
+
}
|
|
30
|
+
export interface CalendarSubscriptionTarget {
|
|
31
|
+
accountConfigId: string;
|
|
32
|
+
calendarId: string;
|
|
33
|
+
}
|
|
34
|
+
export type CalendarSubscriptionRefresh = {
|
|
35
|
+
ok: true;
|
|
36
|
+
value: CalendarFeedWrite;
|
|
37
|
+
} | {
|
|
38
|
+
ok: false;
|
|
39
|
+
reason: string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The URL a subscription is fetched from. `webcal:` is what calendar products
|
|
43
|
+
* hand out for "subscribe", and means the same address over HTTPS.
|
|
44
|
+
*/
|
|
45
|
+
export declare const readSubscriptionUrl: (raw: string) => CalendarResult<string>;
|
|
46
|
+
/**
|
|
47
|
+
* Reads a subscription's feed. Every failure is a returned reason that a
|
|
48
|
+
* person can act on and that never repeats the URL: for a Google secret
|
|
49
|
+
* address the URL is the whole credential, and the reason is stored on the
|
|
50
|
+
* collection and drawn in the calendar view.
|
|
51
|
+
*/
|
|
52
|
+
export declare const fetchCalendarFeed: (url: string, fetcher?: CalendarFeedFetcher) => Promise<CalendarFeedFetch>;
|
|
53
|
+
/**
|
|
54
|
+
* Cuts a whole feed into one resource per UID, the shape the calendar write
|
|
55
|
+
* path stores: a series and its RECURRENCE-ID overrides travel together, with
|
|
56
|
+
* the VTIMEZONEs they name, so a DST-crossing series resolves against the zone
|
|
57
|
+
* the provider published.
|
|
58
|
+
*
|
|
59
|
+
* The feed's calendar-level properties are kept on every resource except
|
|
60
|
+
* METHOD, which a stored resource never carries. A VEVENT with no UID has
|
|
61
|
+
* nothing to key it by across refreshes, so it is counted and left out.
|
|
62
|
+
*/
|
|
63
|
+
export declare const splitCalendarFeed: (icalData: string) => Promise<CalendarResult<CalendarFeedSplit>>;
|
|
64
|
+
/**
|
|
65
|
+
* Makes a subscribed collection hold what the feed holds, in one commit.
|
|
66
|
+
*
|
|
67
|
+
* Every resource goes through `prepareCalendarObject`, the one write path, so
|
|
68
|
+
* a subscribed event is stored, projected and expanded the way any other event
|
|
69
|
+
* is. A resource whose bytes have not changed is left alone, and one the feed
|
|
70
|
+
* no longer carries is removed. A resource the write path refuses is counted
|
|
71
|
+
* and recorded on the collection rather than failing the whole refresh.
|
|
72
|
+
*
|
|
73
|
+
* Repeating a refresh converges: a resource is keyed by its name, so a second
|
|
74
|
+
* run over the same feed finds every resource unchanged.
|
|
75
|
+
*/
|
|
76
|
+
export declare const writeCalendarFeed: (store: ICalendarStore, target: CalendarSubscriptionTarget, split: CalendarFeedSplit, now: number) => Promise<CalendarFeedWrite>;
|
|
77
|
+
/** Records a refresh that stored nothing, and why. Every stored event stays. */
|
|
78
|
+
export declare const recordCalendarFeedFailure: (store: ICalendarStore, target: CalendarSubscriptionTarget, reason: string, now: number) => Promise<void>;
|
|
79
|
+
/** Fetches and cuts a feed, or answers why it cannot be stored. */
|
|
80
|
+
export declare const readCalendarFeed: (url: string, fetcher?: CalendarFeedFetcher) => Promise<{
|
|
81
|
+
ok: true;
|
|
82
|
+
value: CalendarFeedSplit;
|
|
83
|
+
} | {
|
|
84
|
+
ok: false;
|
|
85
|
+
reason: string;
|
|
86
|
+
}>;
|
|
87
|
+
/**
|
|
88
|
+
* One scheduled refresh of one subscription. A feed that cannot be read
|
|
89
|
+
* records its reason on the collection and removes nothing.
|
|
90
|
+
*/
|
|
91
|
+
export declare const refreshCalendarSubscription: (store: ICalendarStore, collection: CalendarCollectionItem, now: number, fetcher?: CalendarFeedFetcher) => Promise<CalendarSubscriptionRefresh>;
|
|
92
|
+
/**
|
|
93
|
+
* Whether a subscription is due a refresh: its last fetch, successful or not,
|
|
94
|
+
* is at least `intervalMs` old.
|
|
95
|
+
*/
|
|
96
|
+
export declare const isSubscriptionDue: (collection: CalendarCollectionItem, now: number, intervalMs: number) => boolean;
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import ICAL from "ical.js";
|
|
3
|
+
import { calendarFailure } from "./errors.js";
|
|
4
|
+
import { readCalendarComponent, serializeCalendar } from "./parse.js";
|
|
5
|
+
import { deleteCalendarObjectWrite, prepareCalendarObject } from "./put.js";
|
|
6
|
+
export const CALENDAR_SUBSCRIPTION_TIMEOUT_MS = 30_000;
|
|
7
|
+
export const CALENDAR_SUBSCRIPTION_MAX_BYTES = 10 * 1024 * 1024;
|
|
8
|
+
const SUBSCRIPTION_ERROR_MAX_LENGTH = 512;
|
|
9
|
+
const SCHEMES = {
|
|
10
|
+
"http:": "http:",
|
|
11
|
+
"https:": "https:",
|
|
12
|
+
"webcal:": "https:",
|
|
13
|
+
"webcals:": "https:",
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The URL a subscription is fetched from. `webcal:` is what calendar products
|
|
17
|
+
* hand out for "subscribe", and means the same address over HTTPS.
|
|
18
|
+
*/
|
|
19
|
+
export const readSubscriptionUrl = (raw) => {
|
|
20
|
+
const trimmed = raw.trim();
|
|
21
|
+
if (!URL.canParse(trimmed)) {
|
|
22
|
+
return calendarFailure("InvalidSubscriptionUrl", "the subscription address is not a URL");
|
|
23
|
+
}
|
|
24
|
+
const url = new URL(trimmed);
|
|
25
|
+
const scheme = SCHEMES[url.protocol];
|
|
26
|
+
if (!scheme) {
|
|
27
|
+
return calendarFailure("InvalidSubscriptionUrl", `a subscription address starts with http, https or webcal, not ${url.protocol.replace(/:$/, "")}`);
|
|
28
|
+
}
|
|
29
|
+
if (url.username !== "" || url.password !== "") {
|
|
30
|
+
return calendarFailure("InvalidSubscriptionUrl", "a subscription address carries no user name or password");
|
|
31
|
+
}
|
|
32
|
+
const address = new URL(`${scheme}${trimmed.slice(url.protocol.length)}`);
|
|
33
|
+
return { ok: true, value: address.toString() };
|
|
34
|
+
};
|
|
35
|
+
const truncate = (reason) => reason.length <= SUBSCRIPTION_ERROR_MAX_LENGTH
|
|
36
|
+
? reason
|
|
37
|
+
: `${reason.slice(0, SUBSCRIPTION_ERROR_MAX_LENGTH - 1)}…`;
|
|
38
|
+
const unreachableReason = (error) => {
|
|
39
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
40
|
+
return `the feed did not answer within ${CALENDAR_SUBSCRIPTION_TIMEOUT_MS / 1000} seconds`;
|
|
41
|
+
}
|
|
42
|
+
const cause = error instanceof Error && error.cause instanceof Error
|
|
43
|
+
? error.cause
|
|
44
|
+
: undefined;
|
|
45
|
+
const code = cause && "code" in cause && typeof cause.code === "string"
|
|
46
|
+
? cause.code
|
|
47
|
+
: undefined;
|
|
48
|
+
return code
|
|
49
|
+
? `the feed could not be reached (${code})`
|
|
50
|
+
: "the feed could not be reached";
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Reads a subscription's feed. Every failure is a returned reason that a
|
|
54
|
+
* person can act on and that never repeats the URL: for a Google secret
|
|
55
|
+
* address the URL is the whole credential, and the reason is stored on the
|
|
56
|
+
* collection and drawn in the calendar view.
|
|
57
|
+
*/
|
|
58
|
+
export const fetchCalendarFeed = async (url, fetcher = fetch) => {
|
|
59
|
+
const response = await fetcher(url, {
|
|
60
|
+
headers: { accept: "text/calendar, */*;q=0.5" },
|
|
61
|
+
redirect: "follow",
|
|
62
|
+
signal: AbortSignal.timeout(CALENDAR_SUBSCRIPTION_TIMEOUT_MS),
|
|
63
|
+
}).then((value) => ({ ok: true, value }), (error) => ({ ok: false, reason: unreachableReason(error) }));
|
|
64
|
+
if (!response.ok)
|
|
65
|
+
return response;
|
|
66
|
+
const { status } = response.value;
|
|
67
|
+
if (status < 200 || status > 299) {
|
|
68
|
+
await response.value.body?.cancel();
|
|
69
|
+
return { ok: false, reason: `the feed answered HTTP ${status}` };
|
|
70
|
+
}
|
|
71
|
+
const declared = Number(response.value.headers.get("content-length") ?? "0");
|
|
72
|
+
if (declared > CALENDAR_SUBSCRIPTION_MAX_BYTES) {
|
|
73
|
+
await response.value.body?.cancel();
|
|
74
|
+
return { ok: false, reason: TOO_LARGE };
|
|
75
|
+
}
|
|
76
|
+
const { body } = response.value;
|
|
77
|
+
if (!body)
|
|
78
|
+
return { ok: true, value: "" };
|
|
79
|
+
return readCapped(body).then((read) => read, (error) => ({ ok: false, reason: unreachableReason(error) }));
|
|
80
|
+
};
|
|
81
|
+
const TOO_LARGE = "the feed is larger than 10 MB";
|
|
82
|
+
/**
|
|
83
|
+
* Reads a body a chunk at a time and stops at the cap. A feed sent chunked
|
|
84
|
+
* carries no length to refuse up front, so counting as it arrives is the only
|
|
85
|
+
* thing that keeps an endless body out of memory.
|
|
86
|
+
*/
|
|
87
|
+
const readCapped = async (body) => {
|
|
88
|
+
const reader = body.getReader();
|
|
89
|
+
const decoder = new TextDecoder();
|
|
90
|
+
let received = 0;
|
|
91
|
+
let text = "";
|
|
92
|
+
for (;;) {
|
|
93
|
+
const { done, value } = await reader.read();
|
|
94
|
+
if (done)
|
|
95
|
+
break;
|
|
96
|
+
received += value.byteLength;
|
|
97
|
+
if (received > CALENDAR_SUBSCRIPTION_MAX_BYTES) {
|
|
98
|
+
await reader.cancel();
|
|
99
|
+
return { ok: false, reason: TOO_LARGE };
|
|
100
|
+
}
|
|
101
|
+
text += decoder.decode(value, { stream: true });
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, value: text + decoder.decode() };
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* A resource as it would compare across two fetches. DTSTAMP is when the
|
|
107
|
+
* provider exported the feed, not when the event changed — Google restamps it
|
|
108
|
+
* on every fetch — so it is left out, or every refresh would rewrite every
|
|
109
|
+
* event.
|
|
110
|
+
*/
|
|
111
|
+
const comparable = (icalData) => icalData.replace(/^DTSTAMP[;:][^\r\n]*\r?\n/gim, "");
|
|
112
|
+
const resourceNameOf = (uid) => `${createHash("sha256").update(uid, "utf8").digest("hex").slice(0, 40)}.ics`;
|
|
113
|
+
const zonesReferencedBy = (events) => {
|
|
114
|
+
const zones = new Set();
|
|
115
|
+
for (const event of events) {
|
|
116
|
+
for (const property of event.getAllProperties()) {
|
|
117
|
+
const tzid = property.getParameter("tzid");
|
|
118
|
+
if (typeof tzid === "string")
|
|
119
|
+
zones.add(tzid);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return zones;
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Cuts a whole feed into one resource per UID, the shape the calendar write
|
|
126
|
+
* path stores: a series and its RECURRENCE-ID overrides travel together, with
|
|
127
|
+
* the VTIMEZONEs they name, so a DST-crossing series resolves against the zone
|
|
128
|
+
* the provider published.
|
|
129
|
+
*
|
|
130
|
+
* The feed's calendar-level properties are kept on every resource except
|
|
131
|
+
* METHOD, which a stored resource never carries. A VEVENT with no UID has
|
|
132
|
+
* nothing to key it by across refreshes, so it is counted and left out.
|
|
133
|
+
*/
|
|
134
|
+
export const splitCalendarFeed = async (icalData) => {
|
|
135
|
+
const read = await readCalendarComponent(icalData);
|
|
136
|
+
if (!read.ok)
|
|
137
|
+
return read;
|
|
138
|
+
const feed = read.value;
|
|
139
|
+
if (feed.name !== "vcalendar") {
|
|
140
|
+
return calendarFailure("NotACalendar", feed.name
|
|
141
|
+
? `expected a VCALENDAR, found ${feed.name.toUpperCase()}`
|
|
142
|
+
: "expected a VCALENDAR, found nothing");
|
|
143
|
+
}
|
|
144
|
+
const timezones = feed.getAllSubcomponents("vtimezone");
|
|
145
|
+
const byUid = new Map();
|
|
146
|
+
let unreadable = 0;
|
|
147
|
+
for (const event of feed.getAllSubcomponents("vevent")) {
|
|
148
|
+
const uid = event.getFirstPropertyValue("uid");
|
|
149
|
+
if (typeof uid !== "string" || uid === "") {
|
|
150
|
+
unreadable += 1;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
byUid.set(uid, [...(byUid.get(uid) ?? []), event]);
|
|
154
|
+
}
|
|
155
|
+
const resources = [...byUid].map(([uid, events]) => {
|
|
156
|
+
const calendar = new ICAL.Component("vcalendar");
|
|
157
|
+
for (const property of feed.getAllProperties()) {
|
|
158
|
+
if (property.name === "method")
|
|
159
|
+
continue;
|
|
160
|
+
calendar.addProperty(new ICAL.Property(property.toJSON()));
|
|
161
|
+
}
|
|
162
|
+
const zones = zonesReferencedBy(events);
|
|
163
|
+
for (const timezone of timezones) {
|
|
164
|
+
const tzid = timezone.getFirstPropertyValue("tzid");
|
|
165
|
+
if (typeof tzid === "string" && zones.has(tzid)) {
|
|
166
|
+
calendar.addSubcomponent(new ICAL.Component(timezone.toJSON()));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
for (const event of events) {
|
|
170
|
+
calendar.addSubcomponent(new ICAL.Component(event.toJSON()));
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
uid,
|
|
174
|
+
resourceName: resourceNameOf(uid),
|
|
175
|
+
icalData: serializeCalendar(calendar),
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
return { ok: true, value: { resources, unreadable } };
|
|
179
|
+
};
|
|
180
|
+
const feedErrorOf = (write) => write.skipped === 0
|
|
181
|
+
? ""
|
|
182
|
+
: `${write.skipped} ${write.skipped === 1 ? "event" : "events"} in the feed could not be stored`;
|
|
183
|
+
/**
|
|
184
|
+
* Makes a subscribed collection hold what the feed holds, in one commit.
|
|
185
|
+
*
|
|
186
|
+
* Every resource goes through `prepareCalendarObject`, the one write path, so
|
|
187
|
+
* a subscribed event is stored, projected and expanded the way any other event
|
|
188
|
+
* is. A resource whose bytes have not changed is left alone, and one the feed
|
|
189
|
+
* no longer carries is removed. A resource the write path refuses is counted
|
|
190
|
+
* and recorded on the collection rather than failing the whole refresh.
|
|
191
|
+
*
|
|
192
|
+
* Repeating a refresh converges: a resource is keyed by its name, so a second
|
|
193
|
+
* run over the same feed finds every resource unchanged.
|
|
194
|
+
*/
|
|
195
|
+
export const writeCalendarFeed = async (store, target, split, now) => {
|
|
196
|
+
const current = await store.repos.calendarCollection.get(target.accountConfigId, target.calendarId);
|
|
197
|
+
if (!current.subscriptionEnabled) {
|
|
198
|
+
return { paused: true, written: 0, unchanged: 0, removed: 0, skipped: 0 };
|
|
199
|
+
}
|
|
200
|
+
const stored = await store.repos.calendarObject.listByCalendar(target.calendarId);
|
|
201
|
+
const storedByName = new Map(stored.map((object) => [object.resourceName, object]));
|
|
202
|
+
const kept = new Set();
|
|
203
|
+
const writes = [];
|
|
204
|
+
const write = {
|
|
205
|
+
paused: false,
|
|
206
|
+
written: 0,
|
|
207
|
+
unchanged: 0,
|
|
208
|
+
removed: 0,
|
|
209
|
+
skipped: split.unreadable,
|
|
210
|
+
};
|
|
211
|
+
for (const resource of split.resources) {
|
|
212
|
+
const existing = storedByName.get(resource.resourceName);
|
|
213
|
+
if (existing &&
|
|
214
|
+
comparable(existing.icalData) === comparable(resource.icalData)) {
|
|
215
|
+
kept.add(resource.resourceName);
|
|
216
|
+
write.unchanged += 1;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const put = await prepareCalendarObject(current, {
|
|
220
|
+
accountConfigId: target.accountConfigId,
|
|
221
|
+
calendarId: target.calendarId,
|
|
222
|
+
resourceName: resource.resourceName,
|
|
223
|
+
icalData: resource.icalData,
|
|
224
|
+
});
|
|
225
|
+
if (!put.ok) {
|
|
226
|
+
write.skipped += 1;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
writes.push(put.value);
|
|
230
|
+
kept.add(resource.resourceName);
|
|
231
|
+
write.written += 1;
|
|
232
|
+
}
|
|
233
|
+
for (const object of stored) {
|
|
234
|
+
if (kept.has(object.resourceName))
|
|
235
|
+
continue;
|
|
236
|
+
writes.push(deleteCalendarObjectWrite({
|
|
237
|
+
accountConfigId: target.accountConfigId,
|
|
238
|
+
calendarId: target.calendarId,
|
|
239
|
+
calendarObjectId: object.calendarObjectId,
|
|
240
|
+
}));
|
|
241
|
+
write.removed += 1;
|
|
242
|
+
}
|
|
243
|
+
writes.push({
|
|
244
|
+
kind: "UpdateCollection",
|
|
245
|
+
accountConfigId: target.accountConfigId,
|
|
246
|
+
calendarId: target.calendarId,
|
|
247
|
+
patch: {
|
|
248
|
+
subscriptionCheckedAt: now,
|
|
249
|
+
subscriptionFetchedAt: now,
|
|
250
|
+
subscriptionError: truncate(feedErrorOf(write)),
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
await store.commit(writes);
|
|
254
|
+
return write;
|
|
255
|
+
};
|
|
256
|
+
/** Records a refresh that stored nothing, and why. Every stored event stays. */
|
|
257
|
+
export const recordCalendarFeedFailure = async (store, target, reason, now) => {
|
|
258
|
+
await store.repos.calendarCollection.update(target.accountConfigId, target.calendarId, {
|
|
259
|
+
subscriptionCheckedAt: now,
|
|
260
|
+
subscriptionError: truncate(reason),
|
|
261
|
+
});
|
|
262
|
+
};
|
|
263
|
+
/** Fetches and cuts a feed, or answers why it cannot be stored. */
|
|
264
|
+
export const readCalendarFeed = async (url, fetcher = fetch) => {
|
|
265
|
+
const fetched = await fetchCalendarFeed(url, fetcher);
|
|
266
|
+
if (!fetched.ok)
|
|
267
|
+
return fetched;
|
|
268
|
+
const split = await splitCalendarFeed(fetched.value);
|
|
269
|
+
if (!split.ok) {
|
|
270
|
+
return {
|
|
271
|
+
ok: false,
|
|
272
|
+
reason: `the feed is not a calendar: ${split.error.message}`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
return split;
|
|
276
|
+
};
|
|
277
|
+
/**
|
|
278
|
+
* One scheduled refresh of one subscription. A feed that cannot be read
|
|
279
|
+
* records its reason on the collection and removes nothing.
|
|
280
|
+
*/
|
|
281
|
+
export const refreshCalendarSubscription = async (store, collection, now, fetcher = fetch) => {
|
|
282
|
+
const target = {
|
|
283
|
+
accountConfigId: collection.accountConfigId,
|
|
284
|
+
calendarId: collection.calendarId,
|
|
285
|
+
};
|
|
286
|
+
const feed = await readCalendarFeed(collection.subscriptionUrl, fetcher);
|
|
287
|
+
if (!feed.ok) {
|
|
288
|
+
await recordCalendarFeedFailure(store, target, feed.reason, now);
|
|
289
|
+
return feed;
|
|
290
|
+
}
|
|
291
|
+
const write = await writeCalendarFeed(store, target, feed.value, now);
|
|
292
|
+
return { ok: true, value: write };
|
|
293
|
+
};
|
|
294
|
+
/**
|
|
295
|
+
* Whether a subscription is due a refresh: its last fetch, successful or not,
|
|
296
|
+
* is at least `intervalMs` old.
|
|
297
|
+
*/
|
|
298
|
+
export const isSubscriptionDue = (collection, now, intervalMs) => now - collection.subscriptionCheckedAt >= intervalMs;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { CalendarSuggestionItem, ICalendarSuggestionRepository, PutCalendarSuggestionInput } from "@remit/data-ports";
|
|
2
|
+
import type ICAL from "ical.js";
|
|
3
|
+
import type { CalendarResult } from "./errors.js";
|
|
4
|
+
/**
|
|
5
|
+
* A `CAL-ADDRESS` as a plain mail address. RFC 5545 3.3.3 writes one as a URI,
|
|
6
|
+
* which in practice is always `mailto:`; a card shows a person, not a URI.
|
|
7
|
+
*/
|
|
8
|
+
export declare const mailAddressOf: (calAddress: string) => string;
|
|
9
|
+
/** The iTIP method a VCALENDAR declared, `None` when it declared no METHOD. */
|
|
10
|
+
export declare const inviteMethodOf: (component: ICAL.Component) => CalendarSuggestionItem["method"];
|
|
11
|
+
/**
|
|
12
|
+
* The facts a suggestion carries beside its bytes, read out of the message's
|
|
13
|
+
* iCalendar and nowhere else. Shares `projectCalendar` with the stored
|
|
14
|
+
* resource, so a card and the event it becomes cannot disagree about when the
|
|
15
|
+
* meeting is.
|
|
16
|
+
*/
|
|
17
|
+
export type CalendarSuggestionProjection = Pick<PutCalendarSuggestionInput, "icalUid" | "sequence" | "method" | "summary" | "dtStart" | "dtEnd" | "endsAtUtc" | "allDay" | "location" | "organizer" | "zoneCertainty">;
|
|
18
|
+
/**
|
|
19
|
+
* Reads a message's `text/calendar` bytes into the facts a card is drawn from.
|
|
20
|
+
*
|
|
21
|
+
* Refusal is a returned value, as everywhere else in this package: the bytes
|
|
22
|
+
* come from whatever client the organizer uses, and a mail carrying broken
|
|
23
|
+
* iCalendar is an ordinary thing that must not fail the message's body sync.
|
|
24
|
+
*/
|
|
25
|
+
export declare const projectSuggestion: (icalData: string, timezone: string) => Promise<CalendarResult<CalendarSuggestionProjection>>;
|
|
26
|
+
export interface RecordCalendarSuggestionInput {
|
|
27
|
+
accountConfigId: string;
|
|
28
|
+
messageId: string;
|
|
29
|
+
bodyPartId: string;
|
|
30
|
+
source: CalendarSuggestionItem["source"];
|
|
31
|
+
/** The VCALENDAR text as it arrived in the message. */
|
|
32
|
+
icalData: string;
|
|
33
|
+
/**
|
|
34
|
+
* IANA zone a floating time in the invitation is read in — the zone of the
|
|
35
|
+
* collection the event would land in. An invitation whose DTSTART names no
|
|
36
|
+
* zone is RFC 5545 floating time, and reading it anywhere but where the
|
|
37
|
+
* user's calendar lives puts the meeting at the wrong hour.
|
|
38
|
+
*/
|
|
39
|
+
timezone: string;
|
|
40
|
+
}
|
|
41
|
+
export interface RecordedCalendarSuggestion {
|
|
42
|
+
suggestion: CalendarSuggestionItem;
|
|
43
|
+
/** The revisions this one replaced, already marked `Superseded`. */
|
|
44
|
+
superseded: CalendarSuggestionItem[];
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Records what one message offers, and retires the revision it replaces.
|
|
48
|
+
*
|
|
49
|
+
* A revision is a new row, never an edit. The organizer's `SEQUENCE` is what
|
|
50
|
+
* orders them (RFC 5545 3.8.7.4): a later message carrying the same `UID` with
|
|
51
|
+
* a higher value leaves the earlier card in place as `Superseded` and writes a
|
|
52
|
+
* new `Pending` one, so the message the old card came from still has its card
|
|
53
|
+
* and the user can see which revision they are being asked about.
|
|
54
|
+
*
|
|
55
|
+
* Only a `Pending` suggestion is superseded. An answered one is a decision the
|
|
56
|
+
* user already took, and a later message does not get to erase it — a
|
|
57
|
+
* cancellation of an accepted event arrives as its own `Cancel` suggestion and
|
|
58
|
+
* waits for the user like everything else.
|
|
59
|
+
*
|
|
60
|
+
* The new row is written before the old ones are retired. A failure between
|
|
61
|
+
* the two leaves two live cards, which a person can see and act on; the other
|
|
62
|
+
* order would leave the event with none.
|
|
63
|
+
*
|
|
64
|
+
* Retiring is a compare-and-set for the same reason the read and the write are
|
|
65
|
+
* two steps: a person can accept a card between them, and an unconditional
|
|
66
|
+
* write would replace their acceptance with `Superseded` and blank the id of
|
|
67
|
+
* the event it put in their calendar. A card answered in the gap keeps its
|
|
68
|
+
* answer and is simply not reported as superseded.
|
|
69
|
+
*
|
|
70
|
+
* Every earlier revision already retired is pointed at this one too, so a
|
|
71
|
+
* card three revisions back opens the newest in one step rather than walking
|
|
72
|
+
* the chain a revision at a time.
|
|
73
|
+
*/
|
|
74
|
+
export declare const recordCalendarSuggestion: (repo: ICalendarSuggestionRepository, input: RecordCalendarSuggestionInput) => Promise<CalendarResult<RecordedCalendarSuggestion>>;
|
package/dist/suggest.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { CalendarInviteMethod, CalendarSuggestionState, } from "@remit/domain-enums";
|
|
2
|
+
import { expandCalendar } from "./expand.js";
|
|
3
|
+
import { parseCalendar } from "./parse.js";
|
|
4
|
+
import { projectCalendar, readString } from "./project.js";
|
|
5
|
+
import { toUtcIso } from "./time.js";
|
|
6
|
+
const OPEN_ENDED_SERIES = "9999-12-31T23:59:59Z";
|
|
7
|
+
const EARLIEST_INSTANT = new Date(0).toISOString();
|
|
8
|
+
const METHOD_BY_ICAL = {
|
|
9
|
+
REQUEST: CalendarInviteMethod.Request,
|
|
10
|
+
REPLY: CalendarInviteMethod.Reply,
|
|
11
|
+
CANCEL: CalendarInviteMethod.Cancel,
|
|
12
|
+
COUNTER: CalendarInviteMethod.Counter,
|
|
13
|
+
PUBLISH: CalendarInviteMethod.Publish,
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* A `CAL-ADDRESS` as a plain mail address. RFC 5545 3.3.3 writes one as a URI,
|
|
17
|
+
* which in practice is always `mailto:`; a card shows a person, not a URI.
|
|
18
|
+
*/
|
|
19
|
+
export const mailAddressOf = (calAddress) => calAddress.replace(/^mailto:/i, "");
|
|
20
|
+
/** The iTIP method a VCALENDAR declared, `None` when it declared no METHOD. */
|
|
21
|
+
export const inviteMethodOf = (component) => METHOD_BY_ICAL[readString(component, "method").toUpperCase()] ??
|
|
22
|
+
CalendarInviteMethod.None;
|
|
23
|
+
const endsAtUtcOf = (calendar, timezone, dtEnd) => {
|
|
24
|
+
const expansion = expandCalendar(calendar, timezone);
|
|
25
|
+
if (expansion.expandedThrough !== "")
|
|
26
|
+
return OPEN_ENDED_SERIES;
|
|
27
|
+
const ends = expansion.occurrences.map((occurrence) => occurrence.endAt);
|
|
28
|
+
if (ends.length === 0)
|
|
29
|
+
return toUtcIso(Date.parse(dtEnd));
|
|
30
|
+
return ends.reduce((latest, end) => (end > latest ? end : latest));
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Reads a message's `text/calendar` bytes into the facts a card is drawn from.
|
|
34
|
+
*
|
|
35
|
+
* Refusal is a returned value, as everywhere else in this package: the bytes
|
|
36
|
+
* come from whatever client the organizer uses, and a mail carrying broken
|
|
37
|
+
* iCalendar is an ordinary thing that must not fail the message's body sync.
|
|
38
|
+
*/
|
|
39
|
+
export const projectSuggestion = async (icalData, timezone) => {
|
|
40
|
+
const parsed = await parseCalendar(icalData);
|
|
41
|
+
if (!parsed.ok)
|
|
42
|
+
return parsed;
|
|
43
|
+
const projection = projectCalendar(parsed.value, timezone);
|
|
44
|
+
if (!projection.ok)
|
|
45
|
+
return projection;
|
|
46
|
+
return {
|
|
47
|
+
ok: true,
|
|
48
|
+
value: {
|
|
49
|
+
icalUid: projection.value.icalUid,
|
|
50
|
+
sequence: projection.value.sequence,
|
|
51
|
+
method: inviteMethodOf(parsed.value.component),
|
|
52
|
+
summary: projection.value.summary,
|
|
53
|
+
dtStart: projection.value.dtStart,
|
|
54
|
+
dtEnd: projection.value.dtEnd,
|
|
55
|
+
endsAtUtc: endsAtUtcOf(parsed.value, timezone, projection.value.dtEnd),
|
|
56
|
+
allDay: projection.value.allDay,
|
|
57
|
+
location: readString(parsed.value.master, "location"),
|
|
58
|
+
organizer: mailAddressOf(readString(parsed.value.master, "organizer")),
|
|
59
|
+
zoneCertainty: projection.value.zoneCertainty,
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Every suggestion the account holds in one state. Drained rather than read as
|
|
65
|
+
* one page: the page size is a repository detail, and a supersession that
|
|
66
|
+
* missed the older revision because it sat on page two would leave two live
|
|
67
|
+
* cards for one event.
|
|
68
|
+
*/
|
|
69
|
+
const listInState = async (repo, accountConfigId, state) => {
|
|
70
|
+
const items = [];
|
|
71
|
+
let continuationToken;
|
|
72
|
+
do {
|
|
73
|
+
const page = await repo.listByState(accountConfigId, state, {
|
|
74
|
+
endsAfter: EARLIEST_INSTANT,
|
|
75
|
+
continuationToken,
|
|
76
|
+
});
|
|
77
|
+
items.push(...page.items);
|
|
78
|
+
continuationToken = page.continuationToken;
|
|
79
|
+
} while (continuationToken);
|
|
80
|
+
return items;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Records what one message offers, and retires the revision it replaces.
|
|
84
|
+
*
|
|
85
|
+
* A revision is a new row, never an edit. The organizer's `SEQUENCE` is what
|
|
86
|
+
* orders them (RFC 5545 3.8.7.4): a later message carrying the same `UID` with
|
|
87
|
+
* a higher value leaves the earlier card in place as `Superseded` and writes a
|
|
88
|
+
* new `Pending` one, so the message the old card came from still has its card
|
|
89
|
+
* and the user can see which revision they are being asked about.
|
|
90
|
+
*
|
|
91
|
+
* Only a `Pending` suggestion is superseded. An answered one is a decision the
|
|
92
|
+
* user already took, and a later message does not get to erase it — a
|
|
93
|
+
* cancellation of an accepted event arrives as its own `Cancel` suggestion and
|
|
94
|
+
* waits for the user like everything else.
|
|
95
|
+
*
|
|
96
|
+
* The new row is written before the old ones are retired. A failure between
|
|
97
|
+
* the two leaves two live cards, which a person can see and act on; the other
|
|
98
|
+
* order would leave the event with none.
|
|
99
|
+
*
|
|
100
|
+
* Retiring is a compare-and-set for the same reason the read and the write are
|
|
101
|
+
* two steps: a person can accept a card between them, and an unconditional
|
|
102
|
+
* write would replace their acceptance with `Superseded` and blank the id of
|
|
103
|
+
* the event it put in their calendar. A card answered in the gap keeps its
|
|
104
|
+
* answer and is simply not reported as superseded.
|
|
105
|
+
*
|
|
106
|
+
* Every earlier revision already retired is pointed at this one too, so a
|
|
107
|
+
* card three revisions back opens the newest in one step rather than walking
|
|
108
|
+
* the chain a revision at a time.
|
|
109
|
+
*/
|
|
110
|
+
export const recordCalendarSuggestion = async (repo, input) => {
|
|
111
|
+
const projection = await projectSuggestion(input.icalData, input.timezone);
|
|
112
|
+
if (!projection.ok)
|
|
113
|
+
return projection;
|
|
114
|
+
const suggestion = await repo.put({
|
|
115
|
+
accountConfigId: input.accountConfigId,
|
|
116
|
+
messageId: input.messageId,
|
|
117
|
+
bodyPartId: input.bodyPartId,
|
|
118
|
+
source: input.source,
|
|
119
|
+
icalData: input.icalData,
|
|
120
|
+
...projection.value,
|
|
121
|
+
});
|
|
122
|
+
const olderRevision = (candidate) => candidate.icalUid === suggestion.icalUid &&
|
|
123
|
+
candidate.suggestionId !== suggestion.suggestionId &&
|
|
124
|
+
candidate.sequence < suggestion.sequence;
|
|
125
|
+
const stale = (await listInState(repo, input.accountConfigId, CalendarSuggestionState.Pending)).filter(olderRevision);
|
|
126
|
+
const superseded = [];
|
|
127
|
+
for (const candidate of stale) {
|
|
128
|
+
const retired = await repo.supersedeIfPending(input.accountConfigId, candidate.suggestionId, suggestion.messageId);
|
|
129
|
+
if (retired)
|
|
130
|
+
superseded.push(retired);
|
|
131
|
+
}
|
|
132
|
+
const retiredEarlier = (await listInState(repo, input.accountConfigId, CalendarSuggestionState.Superseded)).filter((candidate) => olderRevision(candidate) &&
|
|
133
|
+
candidate.supersededByMessageId !== suggestion.messageId);
|
|
134
|
+
for (const candidate of retiredEarlier) {
|
|
135
|
+
await repo.repointSuperseded(input.accountConfigId, candidate.suggestionId, suggestion.messageId);
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, value: { suggestion, superseded } };
|
|
138
|
+
};
|
package/dist/time.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { CalendarObjectItem } from "@remit/data-ports";
|
|
2
|
+
import ICAL from "ical.js";
|
|
3
|
+
/**
|
|
4
|
+
* A resolved iCalendar time, in the two forms the store keeps.
|
|
5
|
+
*
|
|
6
|
+
* `isoUtc` is what a sort key holds: fixed width, so lexicographic order is
|
|
7
|
+
* chronological order. `isoOffset` is what a client displays: the event's own
|
|
8
|
+
* wall time with the offset it was written in, which `isoUtc` cannot express
|
|
9
|
+
* for an all-day event without turning a civil date into an instant.
|
|
10
|
+
*
|
|
11
|
+
* `certainty` says how much either of them is worth. A zone that could not be
|
|
12
|
+
* resolved still produces an instant — there is nothing else to produce — and
|
|
13
|
+
* without this marker that guess is indistinguishable from a fact.
|
|
14
|
+
*/
|
|
15
|
+
export interface ResolvedTime {
|
|
16
|
+
instantMs: number;
|
|
17
|
+
isoUtc: string;
|
|
18
|
+
isoOffset: string;
|
|
19
|
+
isDate: boolean;
|
|
20
|
+
certainty: CalendarObjectItem["zoneCertainty"];
|
|
21
|
+
}
|
|
22
|
+
export declare const toUtcIso: (instantMs: number) => string;
|
|
23
|
+
/** Whether this platform can be asked about a zone by name. */
|
|
24
|
+
export declare const isResolvableZone: (timeZone: string) => boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The wall-clock fields an instant reads as in a zone, with the offset that
|
|
27
|
+
* zone was at. The zone falls back to UTC when it is one this platform cannot
|
|
28
|
+
* name, which is the same fallback `resolveTime` takes.
|
|
29
|
+
*/
|
|
30
|
+
export declare const civilInZone: (instantMs: number, timeZone: string) => {
|
|
31
|
+
year: number;
|
|
32
|
+
month: number;
|
|
33
|
+
day: number;
|
|
34
|
+
hour: number;
|
|
35
|
+
minute: number;
|
|
36
|
+
second: number;
|
|
37
|
+
offsetMinutes: number;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* An instant as the wall time it reads as in a zone, carrying that zone's
|
|
41
|
+
* offset — the form a client renders.
|
|
42
|
+
*
|
|
43
|
+
* The store keeps occurrences as UTC instants because only a fixed-width form
|
|
44
|
+
* sorts correctly, and a client that was handed those would have to re-derive
|
|
45
|
+
* the event's own zone to draw it anywhere. This is the other direction, done
|
|
46
|
+
* once on the server: an occurrence of a 09:00 Amsterdam meeting comes back as
|
|
47
|
+
* `09:00+02:00` in summer and `09:00+01:00` in winter rather than as two
|
|
48
|
+
* different times of day.
|
|
49
|
+
*/
|
|
50
|
+
export declare const toOffsetIso: (instantMs: number, timeZone: string) => string;
|
|
51
|
+
/**
|
|
52
|
+
* Resolves one iCalendar time to an instant and to its own wall-clock form.
|
|
53
|
+
*
|
|
54
|
+
* The zone is taken from the value itself when ical.js could bind one — a
|
|
55
|
+
* VTIMEZONE defined in this very resource, or UTC. A floating value, and a
|
|
56
|
+
* value whose TZID has no VTIMEZONE, falls back to `tzidHint` (the property's
|
|
57
|
+
* own TZID parameter) and then to the collection's timezone, so a resource
|
|
58
|
+
* written by a client that assumes the server knows the zone database still
|
|
59
|
+
* lands on the right instant.
|
|
60
|
+
*
|
|
61
|
+
* Which of those happened is reported, never swallowed: a named zone that no
|
|
62
|
+
* fallback could resolve comes back `Ambiguous`, so a caller can say the event
|
|
63
|
+
* may be hours out instead of drawing it in the wrong place in silence.
|
|
64
|
+
*/
|
|
65
|
+
export declare const resolveTime: (time: ICAL.Time, tzidHint: string, collectionTimezone: string) => ResolvedTime;
|
|
66
|
+
/** The TZID a property was written with, or `""` when it names none. */
|
|
67
|
+
export declare const tzidOf: (property: ICAL.Property | null) => string;
|
|
68
|
+
/** The zone a component's DTSTART was written in. */
|
|
69
|
+
export declare const dtStartTzid: (component: ICAL.Component) => string;
|
|
70
|
+
/**
|
|
71
|
+
* The zone a component's end was written in. DTEND carries its own TZID and
|
|
72
|
+
* need not match DTSTART's, so an end read with the start's zone silently
|
|
73
|
+
* changes the event's length. Only a stated DTEND has a zone of its own: an end
|
|
74
|
+
* derived from a duration is already in the start's zone.
|
|
75
|
+
*/
|
|
76
|
+
export declare const dtEndTzid: (component: ICAL.Component) => string;
|