@remit/calendar-service 0.0.18 → 0.0.20
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/dist/expand.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { CalendarOccurrenceInput } from "@remit/data-ports";
|
|
2
|
+
import ICAL from "ical.js";
|
|
3
|
+
import type { ParsedCalendar } from "./parse.js";
|
|
4
|
+
/**
|
|
5
|
+
* How far past a series' own start its occurrences are written out.
|
|
6
|
+
*
|
|
7
|
+
* A recurring event may have no end at all, so something has to bound the
|
|
8
|
+
* write. Two years covers every view a client asks for today and keeps the
|
|
9
|
+
* index one bounded write rather than an unbounded one; a resource whose series
|
|
10
|
+
* runs past it is marked with `expandedThrough` and stays the business of the
|
|
11
|
+
* live expansion that reads it.
|
|
12
|
+
*/
|
|
13
|
+
export declare const CALENDAR_EXPANSION_HORIZON_DAYS = 730;
|
|
14
|
+
/**
|
|
15
|
+
* Ceiling on occurrences written for one resource, independent of the horizon.
|
|
16
|
+
* A per-minute RRULE fits three quarters of a million instances inside two
|
|
17
|
+
* years; the ceiling turns that into a marked, truncated index instead of a
|
|
18
|
+
* write that takes the process down.
|
|
19
|
+
*/
|
|
20
|
+
export declare const CALENDAR_EXPANSION_MAX_OCCURRENCES = 1000;
|
|
21
|
+
/**
|
|
22
|
+
* Ceiling on iterator steps a live window expansion may take.
|
|
23
|
+
*
|
|
24
|
+
* A window expansion walks the series from its own start, because a rule is
|
|
25
|
+
* only meaningful from where it was anchored. A daily event written in 2005 is
|
|
26
|
+
* a few thousand steps away from today, which is cheap; a per-minute one is
|
|
27
|
+
* millions, which is a read that never returns. The cap turns the second case
|
|
28
|
+
* into a short answer rather than a hung request.
|
|
29
|
+
*/
|
|
30
|
+
export declare const CALENDAR_WINDOW_MAX_STEPS = 50000;
|
|
31
|
+
export interface CalendarExpansion {
|
|
32
|
+
occurrences: CalendarOccurrenceInput[];
|
|
33
|
+
/**
|
|
34
|
+
* The instant through which `occurrences` is complete, or `""` when it holds
|
|
35
|
+
* the whole series.
|
|
36
|
+
*/
|
|
37
|
+
expandedThrough: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The slot each override claims, canonicalized the way the iterator's slots
|
|
41
|
+
* are, so the two can be compared at all.
|
|
42
|
+
*/
|
|
43
|
+
export declare const overridesBySlot: (calendar: ParsedCalendar, collectionTimezone: string) => Map<string, ICAL.Component>;
|
|
44
|
+
/**
|
|
45
|
+
* Flattens a resource into the occurrence rows a date-range read returns.
|
|
46
|
+
*
|
|
47
|
+
* A non-recurring resource is one row under an empty `recurrenceId`. A
|
|
48
|
+
* recurring one is a row per occurrence, each keyed by its RECURRENCE-ID slot
|
|
49
|
+
* as a UTC instant — the same canonical form an override's own RECURRENCE-ID
|
|
50
|
+
* resolves to, so an override lands on the occurrence it replaces instead of
|
|
51
|
+
* beside it.
|
|
52
|
+
*
|
|
53
|
+
* The horizon is anchored at the series' own start, not at today: two years of
|
|
54
|
+
* a series is a bounded write whatever a caller's clock says. A series running
|
|
55
|
+
* past it comes back marked with `expandedThrough`, which is what tells a later
|
|
56
|
+
* read to expand that resource live instead of trusting the index.
|
|
57
|
+
*/
|
|
58
|
+
export declare const expandCalendar: (calendar: ParsedCalendar, collectionTimezone: string) => CalendarExpansion;
|
|
59
|
+
/**
|
|
60
|
+
* The occurrences of a resource that fall inside one window, computed rather
|
|
61
|
+
* than read.
|
|
62
|
+
*
|
|
63
|
+
* This is what serves a view of a series whose stored index stops before the
|
|
64
|
+
* window — an open-ended weekly meeting written years ago, whose horizon ran
|
|
65
|
+
* out long before today. The alternative is extending the index on read, which
|
|
66
|
+
* turns every view of an old series into an unbounded write; a bounded
|
|
67
|
+
* computation that persists nothing is the cheaper half of that trade, and the
|
|
68
|
+
* one that cannot leave anything behind to be wrong later.
|
|
69
|
+
*/
|
|
70
|
+
export declare const expandCalendarWindow: (calendar: ParsedCalendar, collectionTimezone: string, window: {
|
|
71
|
+
fromMs: number;
|
|
72
|
+
toMs: number;
|
|
73
|
+
}) => CalendarOccurrenceInput[];
|
package/dist/expand.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import ICAL from "ical.js";
|
|
2
|
+
import { hasRecurrence, projectEventDisplay } from "./project.js";
|
|
3
|
+
import { dtEndTzid, dtStartTzid, resolveTime, toUtcIso, tzidOf, } from "./time.js";
|
|
4
|
+
/**
|
|
5
|
+
* How far past a series' own start its occurrences are written out.
|
|
6
|
+
*
|
|
7
|
+
* A recurring event may have no end at all, so something has to bound the
|
|
8
|
+
* write. Two years covers every view a client asks for today and keeps the
|
|
9
|
+
* index one bounded write rather than an unbounded one; a resource whose series
|
|
10
|
+
* runs past it is marked with `expandedThrough` and stays the business of the
|
|
11
|
+
* live expansion that reads it.
|
|
12
|
+
*/
|
|
13
|
+
export const CALENDAR_EXPANSION_HORIZON_DAYS = 730;
|
|
14
|
+
/**
|
|
15
|
+
* Ceiling on occurrences written for one resource, independent of the horizon.
|
|
16
|
+
* A per-minute RRULE fits three quarters of a million instances inside two
|
|
17
|
+
* years; the ceiling turns that into a marked, truncated index instead of a
|
|
18
|
+
* write that takes the process down.
|
|
19
|
+
*/
|
|
20
|
+
export const CALENDAR_EXPANSION_MAX_OCCURRENCES = 1000;
|
|
21
|
+
/**
|
|
22
|
+
* Ceiling on iterator steps a live window expansion may take.
|
|
23
|
+
*
|
|
24
|
+
* A window expansion walks the series from its own start, because a rule is
|
|
25
|
+
* only meaningful from where it was anchored. A daily event written in 2005 is
|
|
26
|
+
* a few thousand steps away from today, which is cheap; a per-minute one is
|
|
27
|
+
* millions, which is a read that never returns. The cap turns the second case
|
|
28
|
+
* into a short answer rather than a hung request.
|
|
29
|
+
*/
|
|
30
|
+
export const CALENDAR_WINDOW_MAX_STEPS = 50_000;
|
|
31
|
+
const HORIZON_MS = CALENDAR_EXPANSION_HORIZON_DAYS * 24 * 60 * 60 * 1000;
|
|
32
|
+
const occurrenceOf = (collectionTimezone, recurrenceId, source, startDate, endDate, startTzid, endTzid) => {
|
|
33
|
+
const start = resolveTime(startDate, startTzid, collectionTimezone);
|
|
34
|
+
const end = resolveTime(endDate, endTzid, collectionTimezone);
|
|
35
|
+
return {
|
|
36
|
+
recurrenceId,
|
|
37
|
+
startAt: start.isoUtc,
|
|
38
|
+
endAt: end.isoUtc,
|
|
39
|
+
allDay: start.isDate,
|
|
40
|
+
...projectEventDisplay(source),
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The slot each override claims, canonicalized the way the iterator's slots
|
|
45
|
+
* are, so the two can be compared at all.
|
|
46
|
+
*/
|
|
47
|
+
export const overridesBySlot = (calendar, collectionTimezone) => {
|
|
48
|
+
const bySlot = new Map();
|
|
49
|
+
for (const override of calendar.overrides) {
|
|
50
|
+
const recurrenceId = override.getFirstPropertyValue("recurrence-id");
|
|
51
|
+
if (!(recurrenceId instanceof ICAL.Time))
|
|
52
|
+
continue;
|
|
53
|
+
const slot = resolveTime(recurrenceId, tzidOf(override.getFirstProperty("recurrence-id")), collectionTimezone).isoUtc;
|
|
54
|
+
bySlot.set(slot, override);
|
|
55
|
+
}
|
|
56
|
+
return bySlot;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Walks a recurring resource's occurrences between the bounds it is given.
|
|
60
|
+
*
|
|
61
|
+
* EXDATEs are ical.js's business — `ICAL.Event` skips them. Overrides are not
|
|
62
|
+
* left entirely to it: the iterator yields the master's own RRULE and RDATE
|
|
63
|
+
* slots, so an override whose RECURRENCE-ID names a slot the rule never
|
|
64
|
+
* produces — the shape a client writes after moving one instance and then
|
|
65
|
+
* editing the rule — would never be reached. Those are walked explicitly, so
|
|
66
|
+
* every VEVENT in the resource is an occurrence somebody can find.
|
|
67
|
+
*/
|
|
68
|
+
const walkOccurrences = (calendar, collectionTimezone, bounds) => {
|
|
69
|
+
const event = new ICAL.Event(calendar.master);
|
|
70
|
+
for (const override of calendar.overrides) {
|
|
71
|
+
event.relateException(override);
|
|
72
|
+
}
|
|
73
|
+
const overrideBySlot = overridesBySlot(calendar, collectionTimezone);
|
|
74
|
+
const masterStartTzid = dtStartTzid(calendar.master);
|
|
75
|
+
const occurrences = [];
|
|
76
|
+
const claimed = new Set();
|
|
77
|
+
const iterator = event.iterator();
|
|
78
|
+
let truncated = false;
|
|
79
|
+
let steps = 0;
|
|
80
|
+
let lastIteratedStart = "";
|
|
81
|
+
let next = iterator.next();
|
|
82
|
+
while (next) {
|
|
83
|
+
const slot = resolveTime(next, masterStartTzid, collectionTimezone);
|
|
84
|
+
if (slot.instantMs > bounds.throughMs ||
|
|
85
|
+
occurrences.length >= bounds.maxOccurrences ||
|
|
86
|
+
steps >= bounds.maxSteps) {
|
|
87
|
+
truncated = true;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
steps += 1;
|
|
91
|
+
claimed.add(slot.isoUtc);
|
|
92
|
+
if (slot.instantMs >= bounds.fromMs) {
|
|
93
|
+
// An overridden slot hands back the override's own DTSTART and DTEND, so
|
|
94
|
+
// the zones to read them in are the override's. A plain slot hands back
|
|
95
|
+
// the rule's start and an end derived from the master's duration, both in
|
|
96
|
+
// the master's start zone.
|
|
97
|
+
const source = overrideBySlot.get(slot.isoUtc);
|
|
98
|
+
const details = event.getOccurrenceDetails(next);
|
|
99
|
+
occurrences.push(occurrenceOf(collectionTimezone, slot.isoUtc, source ?? calendar.master, details.startDate, details.endDate, dtStartTzid(source ?? calendar.master), source ? dtEndTzid(source) : masterStartTzid));
|
|
100
|
+
lastIteratedStart = occurrences[occurrences.length - 1]?.startAt ?? "";
|
|
101
|
+
}
|
|
102
|
+
next = iterator.next();
|
|
103
|
+
}
|
|
104
|
+
// Overrides the rule never reached. Kept regardless of the horizon: they are
|
|
105
|
+
// a bounded, explicit list, and one moved instance is exactly the thing a
|
|
106
|
+
// user goes looking for.
|
|
107
|
+
for (const [slot, override] of overrideBySlot) {
|
|
108
|
+
if (claimed.has(slot))
|
|
109
|
+
continue;
|
|
110
|
+
const overrideEvent = new ICAL.Event(override);
|
|
111
|
+
const occurrence = occurrenceOf(collectionTimezone, slot, override, overrideEvent.startDate, overrideEvent.endDate, dtStartTzid(override), dtEndTzid(override));
|
|
112
|
+
const startMs = Date.parse(occurrence.startAt);
|
|
113
|
+
if (startMs < bounds.fromMs || startMs > bounds.throughMs)
|
|
114
|
+
continue;
|
|
115
|
+
occurrences.push(occurrence);
|
|
116
|
+
}
|
|
117
|
+
occurrences.sort((left, right) => left.startAt.localeCompare(right.startAt));
|
|
118
|
+
return { occurrences, truncated, lastIteratedStart };
|
|
119
|
+
};
|
|
120
|
+
/** The one occurrence a resource that does not recur produces. */
|
|
121
|
+
const singleOccurrence = (calendar, collectionTimezone) => {
|
|
122
|
+
const event = new ICAL.Event(calendar.master);
|
|
123
|
+
return occurrenceOf(collectionTimezone, "", calendar.master, event.startDate, event.endDate, dtStartTzid(calendar.master), dtEndTzid(calendar.master));
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* Flattens a resource into the occurrence rows a date-range read returns.
|
|
127
|
+
*
|
|
128
|
+
* A non-recurring resource is one row under an empty `recurrenceId`. A
|
|
129
|
+
* recurring one is a row per occurrence, each keyed by its RECURRENCE-ID slot
|
|
130
|
+
* as a UTC instant — the same canonical form an override's own RECURRENCE-ID
|
|
131
|
+
* resolves to, so an override lands on the occurrence it replaces instead of
|
|
132
|
+
* beside it.
|
|
133
|
+
*
|
|
134
|
+
* The horizon is anchored at the series' own start, not at today: two years of
|
|
135
|
+
* a series is a bounded write whatever a caller's clock says. A series running
|
|
136
|
+
* past it comes back marked with `expandedThrough`, which is what tells a later
|
|
137
|
+
* read to expand that resource live instead of trusting the index.
|
|
138
|
+
*/
|
|
139
|
+
export const expandCalendar = (calendar, collectionTimezone) => {
|
|
140
|
+
if (!hasRecurrence(calendar)) {
|
|
141
|
+
return {
|
|
142
|
+
occurrences: [singleOccurrence(calendar, collectionTimezone)],
|
|
143
|
+
expandedThrough: "",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const event = new ICAL.Event(calendar.master);
|
|
147
|
+
const seriesStart = resolveTime(event.startDate, dtStartTzid(calendar.master), collectionTimezone).instantMs;
|
|
148
|
+
const walk = walkOccurrences(calendar, collectionTimezone, {
|
|
149
|
+
fromMs: Number.NEGATIVE_INFINITY,
|
|
150
|
+
throughMs: seriesStart + HORIZON_MS,
|
|
151
|
+
maxOccurrences: CALENDAR_EXPANSION_MAX_OCCURRENCES,
|
|
152
|
+
maxSteps: Number.POSITIVE_INFINITY,
|
|
153
|
+
});
|
|
154
|
+
// Every truncated walk is marked, including one that collected nothing — a
|
|
155
|
+
// series whose first occurrence lands past the horizon. Its index is empty
|
|
156
|
+
// from the series start, which is the honest floor to write; `""` would
|
|
157
|
+
// claim the whole series is held and the live expansion would never run.
|
|
158
|
+
return {
|
|
159
|
+
occurrences: walk.occurrences,
|
|
160
|
+
expandedThrough: walk.truncated
|
|
161
|
+
? toUtcIso(walk.lastIteratedStart
|
|
162
|
+
? Date.parse(walk.lastIteratedStart)
|
|
163
|
+
: seriesStart)
|
|
164
|
+
: "",
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* The occurrences of a resource that fall inside one window, computed rather
|
|
169
|
+
* than read.
|
|
170
|
+
*
|
|
171
|
+
* This is what serves a view of a series whose stored index stops before the
|
|
172
|
+
* window — an open-ended weekly meeting written years ago, whose horizon ran
|
|
173
|
+
* out long before today. The alternative is extending the index on read, which
|
|
174
|
+
* turns every view of an old series into an unbounded write; a bounded
|
|
175
|
+
* computation that persists nothing is the cheaper half of that trade, and the
|
|
176
|
+
* one that cannot leave anything behind to be wrong later.
|
|
177
|
+
*/
|
|
178
|
+
export const expandCalendarWindow = (calendar, collectionTimezone, window) => {
|
|
179
|
+
if (!hasRecurrence(calendar)) {
|
|
180
|
+
return [singleOccurrence(calendar, collectionTimezone)];
|
|
181
|
+
}
|
|
182
|
+
return walkOccurrences(calendar, collectionTimezone, {
|
|
183
|
+
fromMs: window.fromMs,
|
|
184
|
+
throughMs: window.toMs,
|
|
185
|
+
maxOccurrences: CALENDAR_EXPANSION_MAX_OCCURRENCES,
|
|
186
|
+
maxSteps: CALENDAR_WINDOW_MAX_STEPS,
|
|
187
|
+
}).occurrences;
|
|
188
|
+
};
|
package/dist/feed.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { CalendarCollectionItem, CalendarObjectItem } from "@remit/data-ports";
|
|
2
|
+
/** Random bytes behind a feed token (issue #1067). */
|
|
3
|
+
export declare const CALENDAR_FEED_TOKEN_BYTES = 32;
|
|
4
|
+
/** Where a feed lives, as the one place the shape of that path is written. */
|
|
5
|
+
export declare const CALENDAR_FEED_PATH_PREFIX = "/feeds/calendar/";
|
|
6
|
+
export declare const CALENDAR_FEED_PATH_SUFFIX = ".ics";
|
|
7
|
+
/** A minted feed address: the secret, handed out once, and what is stored. */
|
|
8
|
+
export interface CalendarFeedSecret {
|
|
9
|
+
token: string;
|
|
10
|
+
tokenHash: string;
|
|
11
|
+
}
|
|
12
|
+
export declare const hashCalendarFeedToken: (token: string) => string;
|
|
13
|
+
/**
|
|
14
|
+
* A fresh feed token and the hash that will stand in for it.
|
|
15
|
+
*
|
|
16
|
+
* 32 random bytes rather than a stretched password: nothing about this value is
|
|
17
|
+
* user-chosen, so there is no dictionary to search and a slow hash would only
|
|
18
|
+
* tax every poll of every subscribed client.
|
|
19
|
+
*/
|
|
20
|
+
export declare const mintCalendarFeedToken: () => CalendarFeedSecret;
|
|
21
|
+
/**
|
|
22
|
+
* Whether a path segment can be a feed token at all.
|
|
23
|
+
*
|
|
24
|
+
* Checked before anything is hashed or read, so a request carrying a megabyte
|
|
25
|
+
* of path or a `../` never reaches the store. A refusal here is the same 404 a
|
|
26
|
+
* revoked token gets — the caller learns nothing from the difference.
|
|
27
|
+
*/
|
|
28
|
+
export declare const isCalendarFeedToken: (token: string) => boolean;
|
|
29
|
+
/**
|
|
30
|
+
* The token a request path carries, or `null` when the path is not a feed
|
|
31
|
+
* address. One segment only: a slash inside it would name a different route.
|
|
32
|
+
*/
|
|
33
|
+
export declare const readCalendarFeedToken: (path: string) => string | null;
|
|
34
|
+
/**
|
|
35
|
+
* The feed path with its token taken out, for a log line or a metric label.
|
|
36
|
+
*
|
|
37
|
+
* The token is the whole credential, so a path that carries one is not a field
|
|
38
|
+
* that can be recorded. Redacted rather than dropped: which route was served is
|
|
39
|
+
* what an operator reads a log for, and a blank path loses that too.
|
|
40
|
+
*/
|
|
41
|
+
export declare const redactCalendarFeedPath: (path: string) => string;
|
|
42
|
+
/** The bytes a feed serves, and what a conditional request needs to skip them. */
|
|
43
|
+
export interface CalendarFeed {
|
|
44
|
+
icalData: string;
|
|
45
|
+
etag: string;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* One VCALENDAR carrying every event in a collection.
|
|
49
|
+
*
|
|
50
|
+
* The stored components are copied across as they are: a recurring event
|
|
51
|
+
* travels as its master plus its RRULE, never as an expansion, because a
|
|
52
|
+
* subscriber renders occurrences itself and expects to keep doing so past any
|
|
53
|
+
* horizon this server would have chosen. VTIMEZONE definitions come along for
|
|
54
|
+
* the same reason — without them a client reads a TZID it cannot resolve.
|
|
55
|
+
*
|
|
56
|
+
* `X-WR-CALNAME` is a property of the calendar, not of the response: Apple
|
|
57
|
+
* Calendar, Google Calendar and Thunderbird all name a subscription from it.
|
|
58
|
+
*/
|
|
59
|
+
export declare const buildCalendarFeed: (collection: CalendarCollectionItem, objects: readonly CalendarObjectItem[]) => CalendarFeed;
|
|
60
|
+
/**
|
|
61
|
+
* Whether an `If-None-Match` covers the tag the feed would serve.
|
|
62
|
+
*
|
|
63
|
+
* Accepts the weak form and `*`, and reads the header as the list RFC 9110
|
|
64
|
+
* 13.1.2 says it is — a subscriber that has polled through a proxy may get its
|
|
65
|
+
* own tag back with a `W/` on it, and answering 200 to that resends the whole
|
|
66
|
+
* calendar for nothing.
|
|
67
|
+
*/
|
|
68
|
+
export declare const calendarFeedIsUnchanged: (ifNoneMatch: string | undefined, etag: string) => boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Whether the calendar has stood still since the date an `If-Modified-Since`
|
|
71
|
+
* carries.
|
|
72
|
+
*
|
|
73
|
+
* Compared at whole seconds, which is the resolution an HTTP-date has: a
|
|
74
|
+
* `Last-Modified` served from a millisecond timestamp is rounded down on the
|
|
75
|
+
* way out, and comparing the unrounded value against what comes back declares
|
|
76
|
+
* every calendar modified within the second it was last written.
|
|
77
|
+
*
|
|
78
|
+
* An unreadable date is not a condition (RFC 9110 13.1.3) — the full calendar
|
|
79
|
+
* is served rather than a 304 nobody asked for.
|
|
80
|
+
*/
|
|
81
|
+
export declare const calendarFeedIsUnmodifiedSince: (ifModifiedSince: string | undefined, lastModifiedAt: number) => boolean;
|
package/dist/feed.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import ICAL from "ical.js";
|
|
3
|
+
import { CALENDAR_PRODID } from "./build.js";
|
|
4
|
+
import { computeEtag } from "./etag.js";
|
|
5
|
+
import { serializeCalendar } from "./parse.js";
|
|
6
|
+
/** Random bytes behind a feed token (issue #1067). */
|
|
7
|
+
export const CALENDAR_FEED_TOKEN_BYTES = 32;
|
|
8
|
+
/** Where a feed lives, as the one place the shape of that path is written. */
|
|
9
|
+
export const CALENDAR_FEED_PATH_PREFIX = "/feeds/calendar/";
|
|
10
|
+
export const CALENDAR_FEED_PATH_SUFFIX = ".ics";
|
|
11
|
+
/** base64url over `CALENDAR_FEED_TOKEN_BYTES`, so the length is fixed. */
|
|
12
|
+
const TOKEN_LENGTH = Math.ceil((CALENDAR_FEED_TOKEN_BYTES * 4) / 3);
|
|
13
|
+
const TOKEN_SHAPE = new RegExp(`^[A-Za-z0-9_-]{${TOKEN_LENGTH}}$`);
|
|
14
|
+
export const hashCalendarFeedToken = (token) => createHash("sha256").update(token, "utf8").digest("hex");
|
|
15
|
+
/**
|
|
16
|
+
* A fresh feed token and the hash that will stand in for it.
|
|
17
|
+
*
|
|
18
|
+
* 32 random bytes rather than a stretched password: nothing about this value is
|
|
19
|
+
* user-chosen, so there is no dictionary to search and a slow hash would only
|
|
20
|
+
* tax every poll of every subscribed client.
|
|
21
|
+
*/
|
|
22
|
+
export const mintCalendarFeedToken = () => {
|
|
23
|
+
const token = randomBytes(CALENDAR_FEED_TOKEN_BYTES).toString("base64url");
|
|
24
|
+
return { token, tokenHash: hashCalendarFeedToken(token) };
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Whether a path segment can be a feed token at all.
|
|
28
|
+
*
|
|
29
|
+
* Checked before anything is hashed or read, so a request carrying a megabyte
|
|
30
|
+
* of path or a `../` never reaches the store. A refusal here is the same 404 a
|
|
31
|
+
* revoked token gets — the caller learns nothing from the difference.
|
|
32
|
+
*/
|
|
33
|
+
export const isCalendarFeedToken = (token) => TOKEN_SHAPE.test(token);
|
|
34
|
+
/**
|
|
35
|
+
* The token a request path carries, or `null` when the path is not a feed
|
|
36
|
+
* address. One segment only: a slash inside it would name a different route.
|
|
37
|
+
*/
|
|
38
|
+
export const readCalendarFeedToken = (path) => {
|
|
39
|
+
if (!path.startsWith(CALENDAR_FEED_PATH_PREFIX))
|
|
40
|
+
return null;
|
|
41
|
+
if (!path.endsWith(CALENDAR_FEED_PATH_SUFFIX))
|
|
42
|
+
return null;
|
|
43
|
+
const token = path.slice(CALENDAR_FEED_PATH_PREFIX.length, -CALENDAR_FEED_PATH_SUFFIX.length);
|
|
44
|
+
if (token.includes("/"))
|
|
45
|
+
return null;
|
|
46
|
+
return token;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* The feed path with its token taken out, for a log line or a metric label.
|
|
50
|
+
*
|
|
51
|
+
* The token is the whole credential, so a path that carries one is not a field
|
|
52
|
+
* that can be recorded. Redacted rather than dropped: which route was served is
|
|
53
|
+
* what an operator reads a log for, and a blank path loses that too.
|
|
54
|
+
*/
|
|
55
|
+
export const redactCalendarFeedPath = (path) => readCalendarFeedToken(path) === null
|
|
56
|
+
? path
|
|
57
|
+
: `${CALENDAR_FEED_PATH_PREFIX}<redacted>${CALENDAR_FEED_PATH_SUFFIX}`;
|
|
58
|
+
/**
|
|
59
|
+
* A TEXT value as RFC 5545 3.3.11 writes one.
|
|
60
|
+
*
|
|
61
|
+
* ical.js escapes TEXT only for the properties in its design set, and no `X-`
|
|
62
|
+
* property is in it, so a value goes out verbatim: a comma or a semicolon
|
|
63
|
+
* reads back as a value list, and a line break as the start of a whole
|
|
64
|
+
* component the subscriber never had. TEXT has no encoding for a control
|
|
65
|
+
* character, so one is dropped rather than served.
|
|
66
|
+
*/
|
|
67
|
+
const escapeIcalText = (value) => value
|
|
68
|
+
.replace(/\\/g, "\\\\")
|
|
69
|
+
.replace(/;/g, "\\;")
|
|
70
|
+
.replace(/,/g, "\\,")
|
|
71
|
+
.replace(/\r\n|\r|\n/g, "\\n")
|
|
72
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: RFC 5545 TEXT excludes CONTROL.
|
|
73
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "");
|
|
74
|
+
/**
|
|
75
|
+
* One VCALENDAR carrying every event in a collection.
|
|
76
|
+
*
|
|
77
|
+
* The stored components are copied across as they are: a recurring event
|
|
78
|
+
* travels as its master plus its RRULE, never as an expansion, because a
|
|
79
|
+
* subscriber renders occurrences itself and expects to keep doing so past any
|
|
80
|
+
* horizon this server would have chosen. VTIMEZONE definitions come along for
|
|
81
|
+
* the same reason — without them a client reads a TZID it cannot resolve.
|
|
82
|
+
*
|
|
83
|
+
* `X-WR-CALNAME` is a property of the calendar, not of the response: Apple
|
|
84
|
+
* Calendar, Google Calendar and Thunderbird all name a subscription from it.
|
|
85
|
+
*/
|
|
86
|
+
export const buildCalendarFeed = (collection, objects) => {
|
|
87
|
+
const feed = new ICAL.Component("vcalendar");
|
|
88
|
+
feed.updatePropertyWithValue("prodid", CALENDAR_PRODID);
|
|
89
|
+
feed.updatePropertyWithValue("version", "2.0");
|
|
90
|
+
feed.updatePropertyWithValue("calscale", "GREGORIAN");
|
|
91
|
+
feed.updatePropertyWithValue("x-wr-calname", escapeIcalText(collection.displayName));
|
|
92
|
+
if (collection.timezone !== "") {
|
|
93
|
+
feed.updatePropertyWithValue("x-wr-timezone", escapeIcalText(collection.timezone));
|
|
94
|
+
}
|
|
95
|
+
const zonesSeen = new Set();
|
|
96
|
+
for (const object of objects) {
|
|
97
|
+
const stored = new ICAL.Component(ICAL.parse(object.icalData));
|
|
98
|
+
// A copy of the list, not the list: adding a component reparents it,
|
|
99
|
+
// which splices it out of the array `getAllSubcomponents` handed back and
|
|
100
|
+
// would drop whatever followed it.
|
|
101
|
+
for (const child of [...stored.getAllSubcomponents()]) {
|
|
102
|
+
if (child.name === "vtimezone") {
|
|
103
|
+
const tzid = child.getFirstPropertyValue("tzid");
|
|
104
|
+
const key = typeof tzid === "string" ? tzid : "";
|
|
105
|
+
if (zonesSeen.has(key))
|
|
106
|
+
continue;
|
|
107
|
+
zonesSeen.add(key);
|
|
108
|
+
}
|
|
109
|
+
feed.addSubcomponent(child);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// ical.js leaves the last content line unterminated. Every line in an
|
|
113
|
+
// iCalendar stream ends with CRLF (RFC 5545 3.1), END:VCALENDAR included.
|
|
114
|
+
const serialized = serializeCalendar(feed);
|
|
115
|
+
const icalData = serialized.endsWith("\r\n")
|
|
116
|
+
? serialized
|
|
117
|
+
: `${serialized}\r\n`;
|
|
118
|
+
return { icalData, etag: computeEtag(icalData) };
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Whether an `If-None-Match` covers the tag the feed would serve.
|
|
122
|
+
*
|
|
123
|
+
* Accepts the weak form and `*`, and reads the header as the list RFC 9110
|
|
124
|
+
* 13.1.2 says it is — a subscriber that has polled through a proxy may get its
|
|
125
|
+
* own tag back with a `W/` on it, and answering 200 to that resends the whole
|
|
126
|
+
* calendar for nothing.
|
|
127
|
+
*/
|
|
128
|
+
export const calendarFeedIsUnchanged = (ifNoneMatch, etag) => {
|
|
129
|
+
if (ifNoneMatch === undefined || ifNoneMatch === "")
|
|
130
|
+
return false;
|
|
131
|
+
if (ifNoneMatch.trim() === "*")
|
|
132
|
+
return true;
|
|
133
|
+
return ifNoneMatch
|
|
134
|
+
.split(",")
|
|
135
|
+
.map((candidate) => candidate.trim().replace(/^W\//, "").replace(/^"|"$/g, ""))
|
|
136
|
+
.includes(etag);
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Whether the calendar has stood still since the date an `If-Modified-Since`
|
|
140
|
+
* carries.
|
|
141
|
+
*
|
|
142
|
+
* Compared at whole seconds, which is the resolution an HTTP-date has: a
|
|
143
|
+
* `Last-Modified` served from a millisecond timestamp is rounded down on the
|
|
144
|
+
* way out, and comparing the unrounded value against what comes back declares
|
|
145
|
+
* every calendar modified within the second it was last written.
|
|
146
|
+
*
|
|
147
|
+
* An unreadable date is not a condition (RFC 9110 13.1.3) — the full calendar
|
|
148
|
+
* is served rather than a 304 nobody asked for.
|
|
149
|
+
*/
|
|
150
|
+
export const calendarFeedIsUnmodifiedSince = (ifModifiedSince, lastModifiedAt) => {
|
|
151
|
+
if (ifModifiedSince === undefined || ifModifiedSince === "")
|
|
152
|
+
return false;
|
|
153
|
+
const since = Date.parse(ifModifiedSince);
|
|
154
|
+
if (Number.isNaN(since))
|
|
155
|
+
return false;
|
|
156
|
+
return Math.floor(lastModifiedAt / 1000) * 1000 <= since;
|
|
157
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iCalendar fixtures, assembled from lines so the CRLF the RFC requires is
|
|
3
|
+
* explicit rather than a property of this file's own line endings.
|
|
4
|
+
*/
|
|
5
|
+
export declare const ical: (...lines: string[]) => string;
|
|
6
|
+
/**
|
|
7
|
+
* The same resource as a client that ignores RFC 5545 3.1 would send it. Real
|
|
8
|
+
* `.ics` files arrive LF-only often enough — anything that has been through a
|
|
9
|
+
* text-mode checkout, an editor, or a naive copy — that reading them is part of
|
|
10
|
+
* the job, not an edge case.
|
|
11
|
+
*/
|
|
12
|
+
export declare const asLf: (icalData: string) => string;
|
|
13
|
+
/**
|
|
14
|
+
* Europe/Amsterdam as a client writes it into a resource. Present so a
|
|
15
|
+
* DST-crossing series resolves against the definition the resource itself
|
|
16
|
+
* carries, which is the only zone data RFC 5545 promises a server.
|
|
17
|
+
*/
|
|
18
|
+
export declare const AMSTERDAM_VTIMEZONE: string[];
|
|
19
|
+
export declare const singleEvent: (...eventLines: string[]) => string;
|
package/dist/fixtures.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* iCalendar fixtures, assembled from lines so the CRLF the RFC requires is
|
|
3
|
+
* explicit rather than a property of this file's own line endings.
|
|
4
|
+
*/
|
|
5
|
+
export const ical = (...lines) => `${lines.join("\r\n")}\r\n`;
|
|
6
|
+
/**
|
|
7
|
+
* The same resource as a client that ignores RFC 5545 3.1 would send it. Real
|
|
8
|
+
* `.ics` files arrive LF-only often enough — anything that has been through a
|
|
9
|
+
* text-mode checkout, an editor, or a naive copy — that reading them is part of
|
|
10
|
+
* the job, not an edge case.
|
|
11
|
+
*/
|
|
12
|
+
export const asLf = (icalData) => icalData.replace(/\r\n/g, "\n");
|
|
13
|
+
/**
|
|
14
|
+
* Europe/Amsterdam as a client writes it into a resource. Present so a
|
|
15
|
+
* DST-crossing series resolves against the definition the resource itself
|
|
16
|
+
* carries, which is the only zone data RFC 5545 promises a server.
|
|
17
|
+
*/
|
|
18
|
+
export const AMSTERDAM_VTIMEZONE = [
|
|
19
|
+
"BEGIN:VTIMEZONE",
|
|
20
|
+
"TZID:Europe/Amsterdam",
|
|
21
|
+
"BEGIN:DAYLIGHT",
|
|
22
|
+
"TZOFFSETFROM:+0100",
|
|
23
|
+
"TZOFFSETTO:+0200",
|
|
24
|
+
"TZNAME:CEST",
|
|
25
|
+
"DTSTART:19700329T020000",
|
|
26
|
+
"RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU",
|
|
27
|
+
"END:DAYLIGHT",
|
|
28
|
+
"BEGIN:STANDARD",
|
|
29
|
+
"TZOFFSETFROM:+0200",
|
|
30
|
+
"TZOFFSETTO:+0100",
|
|
31
|
+
"TZNAME:CET",
|
|
32
|
+
"DTSTART:19701025T030000",
|
|
33
|
+
"RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU",
|
|
34
|
+
"END:STANDARD",
|
|
35
|
+
"END:VTIMEZONE",
|
|
36
|
+
];
|
|
37
|
+
export const singleEvent = (...eventLines) => ical("BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Remit//Calendar Tests//EN", "BEGIN:VEVENT", "UID:fixture@example.com", "DTSTAMP:20260801T090000Z", ...eventLines, "END:VEVENT", "END:VCALENDAR");
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { type AcceptCalendarSuggestionInput, type AcceptedCalendarSuggestion, acceptCalendarSuggestion, buildAcceptedCalendar, } from "./accept.js";
|
|
2
|
+
export { applyEventFields, buildEventCalendar, CALENDAR_PRODID, type CalendarEventFields, eventTimeFields, OFFSET_BEARING, readEventTime, readRecurrenceRule, } from "./build.js";
|
|
3
|
+
export { correctCalendarSuggestion } from "./correct.js";
|
|
4
|
+
export type { CalendarResult, CalendarValidationCode, CalendarValidationError, } from "./errors.js";
|
|
5
|
+
export { computeEtag } from "./etag.js";
|
|
6
|
+
export { CALENDAR_EXPANSION_HORIZON_DAYS, CALENDAR_EXPANSION_MAX_OCCURRENCES, CALENDAR_WINDOW_MAX_STEPS, type CalendarExpansion, expandCalendar, expandCalendarWindow, } from "./expand.js";
|
|
7
|
+
export { buildCalendarFeed, CALENDAR_FEED_PATH_PREFIX, CALENDAR_FEED_PATH_SUFFIX, CALENDAR_FEED_TOKEN_BYTES, type CalendarFeed, type CalendarFeedSecret, calendarFeedIsUnchanged, calendarFeedIsUnmodifiedSince, hashCalendarFeedToken, isCalendarFeedToken, mintCalendarFeedToken, readCalendarFeedToken, redactCalendarFeedPath, } from "./feed.js";
|
|
8
|
+
export { type ParsedCalendar, parseCalendar, serializeCalendar, } from "./parse.js";
|
|
9
|
+
export { type CalendarObjectProjection, projectCalendar, } from "./project.js";
|
|
10
|
+
export { DEFAULT_CALENDAR_URL_SEGMENT, deleteCalendarObject, deleteCalendarObjectWrite, type PutCalendarObjectInput, prepareCalendarObject, provisionDefaultCalendar, putCalendarObject, readWrittenObject, } from "./put.js";
|
|
11
|
+
export { answersOvertakenBy, type ReopenCalendarSuggestionInput, reopenCalendarSuggestion, } from "./reopen.js";
|
|
12
|
+
export { applyScopedDelete, applyScopedUpdate, findOccurrence, followingUidOf, followingUidPrefix, type RecurrenceScopeValue, type ScopedWrite, type ScopedWriteInput, } from "./scope.js";
|
|
13
|
+
export { CALENDAR_SUBSCRIPTION_MAX_BYTES, CALENDAR_SUBSCRIPTION_TIMEOUT_MS, type CalendarFeedFetch, type CalendarFeedFetcher, type CalendarFeedResource, type CalendarFeedSplit, type CalendarFeedWrite, type CalendarSubscriptionRefresh, type CalendarSubscriptionTarget, fetchCalendarFeed, isSubscriptionDue, readCalendarFeed, readSubscriptionUrl, recordCalendarFeedFailure, refreshCalendarSubscription, splitCalendarFeed, writeCalendarFeed, } from "./subscribe.js";
|
|
14
|
+
export { type CalendarSuggestionProjection, inviteMethodOf, mailAddressOf, projectSuggestion, type RecordCalendarSuggestionInput, type RecordedCalendarSuggestion, recordCalendarSuggestion, } from "./suggest.js";
|
|
15
|
+
export { civilInZone, isResolvableZone, type ResolvedTime, resolveTime, toOffsetIso, toUtcIso, } from "./time.js";
|
|
16
|
+
export { type BusySpan, CALENDAR_WINDOW_LOOKBACK_DAYS, type CalendarInstance, type CalendarWindow, type CalendarWindowRepositories, isBusy, listBusySpans, listCalendarInstances, listObjectInstances, mergeBusySpans, } from "./window.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { acceptCalendarSuggestion, buildAcceptedCalendar, } from "./accept.js";
|
|
2
|
+
export { applyEventFields, buildEventCalendar, CALENDAR_PRODID, eventTimeFields, OFFSET_BEARING, readEventTime, readRecurrenceRule, } from "./build.js";
|
|
3
|
+
export { correctCalendarSuggestion } from "./correct.js";
|
|
4
|
+
export { computeEtag } from "./etag.js";
|
|
5
|
+
export { CALENDAR_EXPANSION_HORIZON_DAYS, CALENDAR_EXPANSION_MAX_OCCURRENCES, CALENDAR_WINDOW_MAX_STEPS, expandCalendar, expandCalendarWindow, } from "./expand.js";
|
|
6
|
+
export { buildCalendarFeed, CALENDAR_FEED_PATH_PREFIX, CALENDAR_FEED_PATH_SUFFIX, CALENDAR_FEED_TOKEN_BYTES, calendarFeedIsUnchanged, calendarFeedIsUnmodifiedSince, hashCalendarFeedToken, isCalendarFeedToken, mintCalendarFeedToken, readCalendarFeedToken, redactCalendarFeedPath, } from "./feed.js";
|
|
7
|
+
export { parseCalendar, serializeCalendar, } from "./parse.js";
|
|
8
|
+
export { projectCalendar, } from "./project.js";
|
|
9
|
+
export { DEFAULT_CALENDAR_URL_SEGMENT, deleteCalendarObject, deleteCalendarObjectWrite, prepareCalendarObject, provisionDefaultCalendar, putCalendarObject, readWrittenObject, } from "./put.js";
|
|
10
|
+
export { answersOvertakenBy, reopenCalendarSuggestion, } from "./reopen.js";
|
|
11
|
+
export { applyScopedDelete, applyScopedUpdate, findOccurrence, followingUidOf, followingUidPrefix, } from "./scope.js";
|
|
12
|
+
export { CALENDAR_SUBSCRIPTION_MAX_BYTES, CALENDAR_SUBSCRIPTION_TIMEOUT_MS, fetchCalendarFeed, isSubscriptionDue, readCalendarFeed, readSubscriptionUrl, recordCalendarFeedFailure, refreshCalendarSubscription, splitCalendarFeed, writeCalendarFeed, } from "./subscribe.js";
|
|
13
|
+
export { inviteMethodOf, mailAddressOf, projectSuggestion, recordCalendarSuggestion, } from "./suggest.js";
|
|
14
|
+
export { civilInZone, isResolvableZone, resolveTime, toOffsetIso, toUtcIso, } from "./time.js";
|
|
15
|
+
export { CALENDAR_WINDOW_LOOKBACK_DAYS, isBusy, listBusySpans, listCalendarInstances, listObjectInstances, mergeBusySpans, } from "./window.js";
|