@remit/calendar-service 0.0.18 → 0.0.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/accept.d.ts +71 -0
- package/dist/accept.js +159 -0
- package/dist/build.d.ts +79 -0
- package/dist/build.js +196 -0
- package/dist/correct.d.ts +4 -0
- package/dist/correct.js +22 -0
- package/dist/errors.d.ts +56 -0
- package/dist/errors.js +1 -0
- package/dist/etag.d.ts +9 -0
- package/{src/etag.ts → dist/etag.js} +1 -3
- package/dist/expand.d.ts +73 -0
- package/dist/expand.js +188 -0
- package/dist/feed.d.ts +81 -0
- package/dist/feed.js +157 -0
- package/dist/fixtures.d.ts +19 -0
- package/dist/fixtures.js +37 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +15 -0
- package/dist/memory-store.d.ts +105 -0
- package/dist/memory-store.js +319 -0
- package/dist/parse.d.ts +39 -0
- package/dist/parse.js +87 -0
- package/dist/project.d.ts +30 -0
- package/dist/project.js +57 -0
- package/dist/put.d.ts +52 -0
- package/dist/put.js +78 -0
- package/dist/reopen.d.ts +14 -0
- package/dist/reopen.js +92 -0
- package/dist/scope.d.ts +70 -0
- package/dist/scope.js +687 -0
- package/dist/subscribe.d.ts +96 -0
- package/dist/subscribe.js +298 -0
- package/dist/suggest.d.ts +74 -0
- package/dist/suggest.js +138 -0
- package/dist/time.d.ts +76 -0
- package/dist/time.js +164 -0
- package/dist/window.d.ts +85 -0
- package/dist/window.js +161 -0
- package/package.json +40 -35
- package/src/accept.test.ts +0 -403
- package/src/accept.ts +0 -250
- package/src/build.test.ts +0 -113
- package/src/build.ts +0 -311
- package/src/correct.test.ts +0 -173
- package/src/correct.ts +0 -40
- package/src/errors.ts +0 -62
- package/src/etag.test.ts +0 -54
- package/src/expand.test.ts +0 -361
- package/src/expand.ts +0 -303
- package/src/feed.test.ts +0 -334
- package/src/feed.ts +0 -203
- package/src/feeds/google-secret-address.ics +0 -99
- package/src/fixtures.ts +0 -52
- package/src/index.ts +0 -128
- package/src/memory-store.ts +0 -485
- package/src/parse.test.ts +0 -264
- package/src/parse.ts +0 -138
- package/src/project.test.ts +0 -247
- package/src/project.ts +0 -122
- package/src/put.test.ts +0 -318
- package/src/put.ts +0 -133
- package/src/reopen.test.ts +0 -529
- package/src/reopen.ts +0 -199
- package/src/scope.test.ts +0 -1421
- package/src/scope.ts +0 -1003
- package/src/subscribe.test.ts +0 -582
- package/src/subscribe.ts +0 -433
- package/src/suggest.test.ts +0 -376
- package/src/suggest.ts +0 -244
- package/src/time.ts +0 -260
- package/src/window.test.ts +0 -648
- package/src/window.ts +0 -281
- package/tsconfig.json +0 -8
package/dist/time.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { ZoneCertainty } from "@remit/domain-enums";
|
|
2
|
+
import ICAL from "ical.js";
|
|
3
|
+
const pad = (value, width = 2) => String(Math.abs(value)).padStart(width, "0");
|
|
4
|
+
const civilOf = (time) => ({
|
|
5
|
+
year: time.year,
|
|
6
|
+
month: time.month,
|
|
7
|
+
day: time.day,
|
|
8
|
+
hour: time.isDate ? 0 : time.hour,
|
|
9
|
+
minute: time.isDate ? 0 : time.minute,
|
|
10
|
+
second: time.isDate ? 0 : time.second,
|
|
11
|
+
});
|
|
12
|
+
/**
|
|
13
|
+
* The offset an IANA zone was at on a given instant, in minutes.
|
|
14
|
+
*
|
|
15
|
+
* Read from the platform's own zone database through `Intl` rather than from a
|
|
16
|
+
* bundled copy: ical.js ships no zone data, and a resource whose TZID names a
|
|
17
|
+
* zone it carries no VTIMEZONE for is otherwise unresolvable — which is how a
|
|
18
|
+
* DST-crossing series silently drifts by an hour.
|
|
19
|
+
*/
|
|
20
|
+
const zoneOffsetMinutes = (timeZone, instantMs) => {
|
|
21
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
22
|
+
timeZone,
|
|
23
|
+
hourCycle: "h23",
|
|
24
|
+
year: "numeric",
|
|
25
|
+
month: "2-digit",
|
|
26
|
+
day: "2-digit",
|
|
27
|
+
hour: "2-digit",
|
|
28
|
+
minute: "2-digit",
|
|
29
|
+
second: "2-digit",
|
|
30
|
+
}).formatToParts(new Date(instantMs));
|
|
31
|
+
const field = (type) => Number(parts.find((part) => part.type === type)?.value ?? "0");
|
|
32
|
+
const asUtc = Date.UTC(field("year"), field("month") - 1, field("day"), field("hour"), field("minute"), field("second"));
|
|
33
|
+
return (asUtc - instantMs) / 60_000;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* The zones this platform can be asked about.
|
|
37
|
+
*
|
|
38
|
+
* Read once from the runtime rather than probed per value: an unusable TZID —
|
|
39
|
+
* a Windows zone name, a typo — makes `Intl` throw, and a resource written by a
|
|
40
|
+
* client with a zone we cannot name is a resource to read in the next fallback,
|
|
41
|
+
* never a crashed write. The list is the canonical IANA set, so a deprecated
|
|
42
|
+
* alias falls through to the collection's own zone.
|
|
43
|
+
*/
|
|
44
|
+
const KNOWN_ZONES = new Set(Intl.supportedValuesOf("timeZone"));
|
|
45
|
+
const isKnownZone = (timeZone) => KNOWN_ZONES.has(timeZone);
|
|
46
|
+
/**
|
|
47
|
+
* The offset a civil wall time carries in a zone. Applied twice because the
|
|
48
|
+
* first lookup asks the zone about the wrong instant — the wall time read as if
|
|
49
|
+
* it were UTC — which lands in the wrong side of a DST transition for wall
|
|
50
|
+
* times within an hour or so of the cutover.
|
|
51
|
+
*/
|
|
52
|
+
const offsetForCivil = (timeZone, civil) => {
|
|
53
|
+
const asUtc = Date.UTC(civil.year, civil.month - 1, civil.day, civil.hour, civil.minute, civil.second);
|
|
54
|
+
const firstPass = zoneOffsetMinutes(timeZone, asUtc);
|
|
55
|
+
return zoneOffsetMinutes(timeZone, asUtc - firstPass * 60_000);
|
|
56
|
+
};
|
|
57
|
+
const formatOffset = (offsetMinutes) => {
|
|
58
|
+
const sign = offsetMinutes < 0 ? "-" : "+";
|
|
59
|
+
return `${sign}${pad(Math.trunc(offsetMinutes / 60))}:${pad(offsetMinutes % 60)}`;
|
|
60
|
+
};
|
|
61
|
+
const formatCivil = (civil, offsetMinutes) => `${pad(civil.year, 4)}-${pad(civil.month)}-${pad(civil.day)}T${pad(civil.hour)}:${pad(civil.minute)}:${pad(civil.second)}${formatOffset(offsetMinutes)}`;
|
|
62
|
+
export const toUtcIso = (instantMs) => `${new Date(instantMs).toISOString().slice(0, 19)}Z`;
|
|
63
|
+
/** Whether this platform can be asked about a zone by name. */
|
|
64
|
+
export const isResolvableZone = (timeZone) => isKnownZone(timeZone);
|
|
65
|
+
/**
|
|
66
|
+
* The wall-clock fields an instant reads as in a zone, with the offset that
|
|
67
|
+
* zone was at. The zone falls back to UTC when it is one this platform cannot
|
|
68
|
+
* name, which is the same fallback `resolveTime` takes.
|
|
69
|
+
*/
|
|
70
|
+
export const civilInZone = (instantMs, timeZone) => {
|
|
71
|
+
const zone = isKnownZone(timeZone) ? timeZone : "UTC";
|
|
72
|
+
const offsetMinutes = zoneOffsetMinutes(zone, instantMs);
|
|
73
|
+
const shifted = new Date(instantMs + offsetMinutes * 60_000);
|
|
74
|
+
return {
|
|
75
|
+
year: shifted.getUTCFullYear(),
|
|
76
|
+
month: shifted.getUTCMonth() + 1,
|
|
77
|
+
day: shifted.getUTCDate(),
|
|
78
|
+
hour: shifted.getUTCHours(),
|
|
79
|
+
minute: shifted.getUTCMinutes(),
|
|
80
|
+
second: shifted.getUTCSeconds(),
|
|
81
|
+
offsetMinutes,
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* An instant as the wall time it reads as in a zone, carrying that zone's
|
|
86
|
+
* offset — the form a client renders.
|
|
87
|
+
*
|
|
88
|
+
* The store keeps occurrences as UTC instants because only a fixed-width form
|
|
89
|
+
* sorts correctly, and a client that was handed those would have to re-derive
|
|
90
|
+
* the event's own zone to draw it anywhere. This is the other direction, done
|
|
91
|
+
* once on the server: an occurrence of a 09:00 Amsterdam meeting comes back as
|
|
92
|
+
* `09:00+02:00` in summer and `09:00+01:00` in winter rather than as two
|
|
93
|
+
* different times of day.
|
|
94
|
+
*/
|
|
95
|
+
export const toOffsetIso = (instantMs, timeZone) => {
|
|
96
|
+
const civil = civilInZone(instantMs, timeZone);
|
|
97
|
+
return formatCivil(civil, civil.offsetMinutes);
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Resolves one iCalendar time to an instant and to its own wall-clock form.
|
|
101
|
+
*
|
|
102
|
+
* The zone is taken from the value itself when ical.js could bind one — a
|
|
103
|
+
* VTIMEZONE defined in this very resource, or UTC. A floating value, and a
|
|
104
|
+
* value whose TZID has no VTIMEZONE, falls back to `tzidHint` (the property's
|
|
105
|
+
* own TZID parameter) and then to the collection's timezone, so a resource
|
|
106
|
+
* written by a client that assumes the server knows the zone database still
|
|
107
|
+
* lands on the right instant.
|
|
108
|
+
*
|
|
109
|
+
* Which of those happened is reported, never swallowed: a named zone that no
|
|
110
|
+
* fallback could resolve comes back `Ambiguous`, so a caller can say the event
|
|
111
|
+
* may be hours out instead of drawing it in the wrong place in silence.
|
|
112
|
+
*/
|
|
113
|
+
export const resolveTime = (time, tzidHint, collectionTimezone) => {
|
|
114
|
+
const civil = civilOf(time);
|
|
115
|
+
let offsetMinutes;
|
|
116
|
+
let certainty;
|
|
117
|
+
if (time.zone === ICAL.Timezone.utcTimezone) {
|
|
118
|
+
offsetMinutes = 0;
|
|
119
|
+
certainty = ZoneCertainty.Explicit;
|
|
120
|
+
}
|
|
121
|
+
else if (time.zone !== ICAL.Timezone.localTimezone) {
|
|
122
|
+
offsetMinutes = time.utcOffset() / 60;
|
|
123
|
+
certainty = ZoneCertainty.Explicit;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const fallback = [tzidHint, collectionTimezone].find(isKnownZone) ?? "UTC";
|
|
127
|
+
offsetMinutes = offsetForCivil(fallback, civil);
|
|
128
|
+
if (tzidHint === "") {
|
|
129
|
+
// No zone was ever named. RFC 5545 floating time, read where the
|
|
130
|
+
// collection lives.
|
|
131
|
+
certainty = ZoneCertainty.Local;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
certainty = isKnownZone(tzidHint)
|
|
135
|
+
? ZoneCertainty.Explicit
|
|
136
|
+
: ZoneCertainty.Ambiguous;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const instantMs = Date.UTC(civil.year, civil.month - 1, civil.day, civil.hour, civil.minute, civil.second) -
|
|
140
|
+
offsetMinutes * 60_000;
|
|
141
|
+
return {
|
|
142
|
+
instantMs,
|
|
143
|
+
isoUtc: toUtcIso(instantMs),
|
|
144
|
+
isoOffset: formatCivil(civil, offsetMinutes),
|
|
145
|
+
isDate: time.isDate,
|
|
146
|
+
certainty,
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
/** The TZID a property was written with, or `""` when it names none. */
|
|
150
|
+
export const tzidOf = (property) => {
|
|
151
|
+
const tzid = property?.getParameter("tzid");
|
|
152
|
+
return typeof tzid === "string" ? tzid : "";
|
|
153
|
+
};
|
|
154
|
+
/** The zone a component's DTSTART was written in. */
|
|
155
|
+
export const dtStartTzid = (component) => tzidOf(component.getFirstProperty("dtstart"));
|
|
156
|
+
/**
|
|
157
|
+
* The zone a component's end was written in. DTEND carries its own TZID and
|
|
158
|
+
* need not match DTSTART's, so an end read with the start's zone silently
|
|
159
|
+
* changes the event's length. Only a stated DTEND has a zone of its own: an end
|
|
160
|
+
* derived from a duration is already in the start's zone.
|
|
161
|
+
*/
|
|
162
|
+
export const dtEndTzid = (component) => component.hasProperty("dtend")
|
|
163
|
+
? tzidOf(component.getFirstProperty("dtend"))
|
|
164
|
+
: dtStartTzid(component);
|
package/dist/window.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { CalendarCollectionItem, CalendarObjectItem, ICalendarEventIndexRepository, ICalendarObjectRepository } from "@remit/data-ports";
|
|
2
|
+
/**
|
|
3
|
+
* How far before the window occurrences are read, so one that started earlier
|
|
4
|
+
* and runs into it is still returned.
|
|
5
|
+
*
|
|
6
|
+
* The occurrence index is keyed by start, which is the only key a range read
|
|
7
|
+
* can use, so an event that began before the window is invisible to it. A
|
|
8
|
+
* bounded lookback buys back the events people actually have — a meeting that
|
|
9
|
+
* started last night, a week away, a fortnight of leave — without turning a
|
|
10
|
+
* day's read into a read of the whole calendar. An all-day block longer than
|
|
11
|
+
* this that began before the window is the one thing it misses.
|
|
12
|
+
*/
|
|
13
|
+
export declare const CALENDAR_WINDOW_LOOKBACK_DAYS = 31;
|
|
14
|
+
/** One occurrence, in the form a client renders. */
|
|
15
|
+
export interface CalendarInstance {
|
|
16
|
+
calendarId: string;
|
|
17
|
+
calendarObjectId: string;
|
|
18
|
+
recurrenceId: string;
|
|
19
|
+
icalUid: string;
|
|
20
|
+
summary: string;
|
|
21
|
+
/** ISO 8601 with the collection's own offset — what a client draws. */
|
|
22
|
+
start: string;
|
|
23
|
+
end: string;
|
|
24
|
+
/** ISO 8601 UTC instant — what sorts and compares. */
|
|
25
|
+
startAt: string;
|
|
26
|
+
endAt: string;
|
|
27
|
+
allDay: boolean;
|
|
28
|
+
status: CalendarObjectItem["status"];
|
|
29
|
+
transparency: CalendarObjectItem["transparency"];
|
|
30
|
+
zoneCertainty: CalendarObjectItem["zoneCertainty"];
|
|
31
|
+
etag: string;
|
|
32
|
+
hasRecurrence: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface CalendarWindowRepositories {
|
|
35
|
+
calendarObject: Pick<ICalendarObjectRepository, "find" | "listIncompleteExpansions">;
|
|
36
|
+
calendarEventIndex: Pick<ICalendarEventIndexRepository, "listByStartRange">;
|
|
37
|
+
}
|
|
38
|
+
export interface CalendarWindow {
|
|
39
|
+
/** ISO 8601 UTC instant, inclusive. */
|
|
40
|
+
from: string;
|
|
41
|
+
/** ISO 8601 UTC instant, exclusive. */
|
|
42
|
+
to: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Every occurrence in a window, across the collections given.
|
|
46
|
+
*
|
|
47
|
+
* Served from the stored occurrence index, except for the series the index
|
|
48
|
+
* does not reach: those are expanded live for this window and nothing is
|
|
49
|
+
* written to serve the read. A series is served from one source or the other,
|
|
50
|
+
* never merged from both, so an occurrence cannot appear twice.
|
|
51
|
+
*/
|
|
52
|
+
export declare const listCalendarInstances: (repositories: CalendarWindowRepositories, collections: CalendarCollectionItem[], window: CalendarWindow) => Promise<CalendarInstance[]>;
|
|
53
|
+
/**
|
|
54
|
+
* The occurrences of one resource in a window, read from that resource alone:
|
|
55
|
+
* its own index rows where the index reaches the window, and a live expansion
|
|
56
|
+
* of it where it does not.
|
|
57
|
+
*/
|
|
58
|
+
export declare const listObjectInstances: (repositories: {
|
|
59
|
+
calendarEventIndex: Pick<ICalendarEventIndexRepository, "listForObject">;
|
|
60
|
+
}, collection: CalendarCollectionItem, object: CalendarObjectItem, window: CalendarWindow) => Promise<CalendarInstance[]>;
|
|
61
|
+
/** A stretch of time somebody is busy in, as instants. */
|
|
62
|
+
export interface BusySpan {
|
|
63
|
+
startMs: number;
|
|
64
|
+
endMs: number;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Whether an occurrence consumes free/busy time. A cancelled event is not
|
|
68
|
+
* something to work around, and one marked TRANSP:TRANSPARENT was written
|
|
69
|
+
* precisely to say so.
|
|
70
|
+
*/
|
|
71
|
+
export declare const isBusy: (instance: CalendarInstance) => boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Collapses overlapping and touching spans into the stretches they cover.
|
|
74
|
+
*
|
|
75
|
+
* Touching counts: two meetings back to back are one stretch of being busy, and
|
|
76
|
+
* reporting them separately invites a caller to offer the zero-length gap
|
|
77
|
+
* between them as free.
|
|
78
|
+
*/
|
|
79
|
+
export declare const mergeBusySpans: (spans: BusySpan[]) => BusySpan[];
|
|
80
|
+
/**
|
|
81
|
+
* The busy stretches in a window, merged across every collection given, and
|
|
82
|
+
* clipped to the window so a span never claims time outside what was asked
|
|
83
|
+
* for.
|
|
84
|
+
*/
|
|
85
|
+
export declare const listBusySpans: (repositories: CalendarWindowRepositories, collections: CalendarCollectionItem[], window: CalendarWindow) => Promise<BusySpan[]>;
|
package/dist/window.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { CalendarEventStatus, CalendarTransparency } from "@remit/domain-enums";
|
|
2
|
+
import { expandCalendarWindow } from "./expand.js";
|
|
3
|
+
import { parseCalendar } from "./parse.js";
|
|
4
|
+
import { toOffsetIso, toUtcIso } from "./time.js";
|
|
5
|
+
/**
|
|
6
|
+
* How far before the window occurrences are read, so one that started earlier
|
|
7
|
+
* and runs into it is still returned.
|
|
8
|
+
*
|
|
9
|
+
* The occurrence index is keyed by start, which is the only key a range read
|
|
10
|
+
* can use, so an event that began before the window is invisible to it. A
|
|
11
|
+
* bounded lookback buys back the events people actually have — a meeting that
|
|
12
|
+
* started last night, a week away, a fortnight of leave — without turning a
|
|
13
|
+
* day's read into a read of the whole calendar. An all-day block longer than
|
|
14
|
+
* this that began before the window is the one thing it misses.
|
|
15
|
+
*/
|
|
16
|
+
export const CALENDAR_WINDOW_LOOKBACK_DAYS = 31;
|
|
17
|
+
const LOOKBACK_MS = CALENDAR_WINDOW_LOOKBACK_DAYS * 24 * 60 * 60 * 1000;
|
|
18
|
+
const instanceOf = (collection, object, occurrence) => ({
|
|
19
|
+
calendarId: collection.calendarId,
|
|
20
|
+
calendarObjectId: object.calendarObjectId,
|
|
21
|
+
recurrenceId: occurrence.recurrenceId,
|
|
22
|
+
icalUid: object.icalUid,
|
|
23
|
+
summary: occurrence.summary,
|
|
24
|
+
start: toOffsetIso(Date.parse(occurrence.startAt), collection.timezone),
|
|
25
|
+
end: toOffsetIso(Date.parse(occurrence.endAt), collection.timezone),
|
|
26
|
+
startAt: occurrence.startAt,
|
|
27
|
+
endAt: occurrence.endAt,
|
|
28
|
+
allDay: occurrence.allDay,
|
|
29
|
+
status: occurrence.status,
|
|
30
|
+
transparency: occurrence.transparency,
|
|
31
|
+
zoneCertainty: object.zoneCertainty,
|
|
32
|
+
etag: object.etag,
|
|
33
|
+
hasRecurrence: object.hasRecurrence,
|
|
34
|
+
});
|
|
35
|
+
/**
|
|
36
|
+
* Every occurrence in a window, across the collections given.
|
|
37
|
+
*
|
|
38
|
+
* Served from the stored occurrence index, except for the series the index
|
|
39
|
+
* does not reach: those are expanded live for this window and nothing is
|
|
40
|
+
* written to serve the read. A series is served from one source or the other,
|
|
41
|
+
* never merged from both, so an occurrence cannot appear twice.
|
|
42
|
+
*/
|
|
43
|
+
export const listCalendarInstances = async (repositories, collections, window) => {
|
|
44
|
+
const fromMs = Date.parse(window.from);
|
|
45
|
+
const toMs = Date.parse(window.to);
|
|
46
|
+
const lookbackFrom = toUtcIso(fromMs - LOOKBACK_MS);
|
|
47
|
+
const instances = [];
|
|
48
|
+
for (const collection of collections) {
|
|
49
|
+
// Both reads are bounded by the window: the occurrence rows that start in
|
|
50
|
+
// it, and the handful of series whose index stops short of it. Neither
|
|
51
|
+
// grows with the size of the calendar.
|
|
52
|
+
const live = await repositories.calendarObject.listIncompleteExpansions(collection.calendarId, window.to);
|
|
53
|
+
const liveIds = new Set(live.map((object) => object.calendarObjectId));
|
|
54
|
+
const rows = await repositories.calendarEventIndex.listByStartRange(collection.calendarId, lookbackFrom, window.to);
|
|
55
|
+
// One read per resource that actually appears in the window, cached across
|
|
56
|
+
// its own occurrences.
|
|
57
|
+
const byId = new Map();
|
|
58
|
+
for (const row of rows) {
|
|
59
|
+
if (liveIds.has(row.calendarObjectId))
|
|
60
|
+
continue;
|
|
61
|
+
if (!byId.has(row.calendarObjectId)) {
|
|
62
|
+
byId.set(row.calendarObjectId, await repositories.calendarObject.find(collection.calendarId, row.calendarObjectId));
|
|
63
|
+
}
|
|
64
|
+
const object = byId.get(row.calendarObjectId);
|
|
65
|
+
if (!object)
|
|
66
|
+
continue;
|
|
67
|
+
instances.push(instanceOf(collection, object, row));
|
|
68
|
+
}
|
|
69
|
+
for (const object of live) {
|
|
70
|
+
const parsed = await parseCalendar(object.icalData);
|
|
71
|
+
if (!parsed.ok) {
|
|
72
|
+
throw new Error(`stored calendar object ${object.calendarObjectId} no longer parses: ${parsed.error.message}`);
|
|
73
|
+
}
|
|
74
|
+
const occurrences = expandCalendarWindow(parsed.value, collection.timezone, { fromMs: fromMs - LOOKBACK_MS, toMs });
|
|
75
|
+
for (const occurrence of occurrences) {
|
|
76
|
+
instances.push(instanceOf(collection, object, occurrence));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return instances
|
|
81
|
+
.filter((instance) => overlapsWindow(instance, fromMs, toMs))
|
|
82
|
+
.sort((left, right) => left.startAt.localeCompare(right.startAt));
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* The occurrences of one resource in a window, read from that resource alone:
|
|
86
|
+
* its own index rows where the index reaches the window, and a live expansion
|
|
87
|
+
* of it where it does not.
|
|
88
|
+
*/
|
|
89
|
+
export const listObjectInstances = async (repositories, collection, object, window) => {
|
|
90
|
+
const fromMs = Date.parse(window.from);
|
|
91
|
+
const toMs = Date.parse(window.to);
|
|
92
|
+
const indexReaches = object.expandedThrough === "" || object.expandedThrough >= window.to;
|
|
93
|
+
const occurrences = indexReaches
|
|
94
|
+
? await repositories.calendarEventIndex.listForObject(collection.calendarId, object.calendarObjectId)
|
|
95
|
+
: await expandObject(object, collection.timezone, fromMs, toMs);
|
|
96
|
+
return occurrences
|
|
97
|
+
.map((occurrence) => instanceOf(collection, object, occurrence))
|
|
98
|
+
.filter((instance) => overlapsWindow(instance, fromMs, toMs))
|
|
99
|
+
.sort((left, right) => left.startAt.localeCompare(right.startAt));
|
|
100
|
+
};
|
|
101
|
+
const expandObject = async (object, timezone, fromMs, toMs) => {
|
|
102
|
+
const parsed = await parseCalendar(object.icalData);
|
|
103
|
+
if (!parsed.ok) {
|
|
104
|
+
throw new Error(`stored calendar object ${object.calendarObjectId} no longer parses: ${parsed.error.message}`);
|
|
105
|
+
}
|
|
106
|
+
return expandCalendarWindow(parsed.value, timezone, {
|
|
107
|
+
fromMs: fromMs - LOOKBACK_MS,
|
|
108
|
+
toMs,
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
const overlapsWindow = (instance, fromMs, toMs) => {
|
|
112
|
+
const startMs = Date.parse(instance.startAt);
|
|
113
|
+
if (startMs >= toMs)
|
|
114
|
+
return false;
|
|
115
|
+
if (startMs >= fromMs)
|
|
116
|
+
return true;
|
|
117
|
+
return Date.parse(instance.endAt) > fromMs;
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Whether an occurrence consumes free/busy time. A cancelled event is not
|
|
121
|
+
* something to work around, and one marked TRANSP:TRANSPARENT was written
|
|
122
|
+
* precisely to say so.
|
|
123
|
+
*/
|
|
124
|
+
export const isBusy = (instance) => instance.transparency === CalendarTransparency.Opaque &&
|
|
125
|
+
instance.status !== CalendarEventStatus.Cancelled;
|
|
126
|
+
/**
|
|
127
|
+
* Collapses overlapping and touching spans into the stretches they cover.
|
|
128
|
+
*
|
|
129
|
+
* Touching counts: two meetings back to back are one stretch of being busy, and
|
|
130
|
+
* reporting them separately invites a caller to offer the zero-length gap
|
|
131
|
+
* between them as free.
|
|
132
|
+
*/
|
|
133
|
+
export const mergeBusySpans = (spans) => {
|
|
134
|
+
const sorted = [...spans].sort((left, right) => left.startMs - right.startMs);
|
|
135
|
+
const merged = [];
|
|
136
|
+
for (const span of sorted) {
|
|
137
|
+
if (span.endMs <= span.startMs)
|
|
138
|
+
continue;
|
|
139
|
+
const last = merged[merged.length - 1];
|
|
140
|
+
if (last && span.startMs <= last.endMs) {
|
|
141
|
+
last.endMs = Math.max(last.endMs, span.endMs);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
merged.push({ ...span });
|
|
145
|
+
}
|
|
146
|
+
return merged;
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* The busy stretches in a window, merged across every collection given, and
|
|
150
|
+
* clipped to the window so a span never claims time outside what was asked
|
|
151
|
+
* for.
|
|
152
|
+
*/
|
|
153
|
+
export const listBusySpans = async (repositories, collections, window) => {
|
|
154
|
+
const fromMs = Date.parse(window.from);
|
|
155
|
+
const toMs = Date.parse(window.to);
|
|
156
|
+
const instances = await listCalendarInstances(repositories, collections, window);
|
|
157
|
+
return mergeBusySpans(instances.filter(isBusy).map((instance) => ({
|
|
158
|
+
startMs: Math.max(Date.parse(instance.startAt), fromMs),
|
|
159
|
+
endMs: Math.min(Date.parse(instance.endAt), toMs),
|
|
160
|
+
})));
|
|
161
|
+
};
|
package/package.json
CHANGED
|
@@ -1,37 +1,42 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
2
|
+
"name": "@remit/calendar-service",
|
|
3
|
+
"version": "0.0.19",
|
|
4
|
+
"description": "iCalendar (RFC 5545) parsing, projection and recurrence expansion over the calendar store",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./memory-store": {
|
|
14
|
+
"types": "./dist/memory-store.d.ts",
|
|
15
|
+
"default": "./dist/memory-store.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"prepack": "node ../../npm-scripts/pack-dist.mjs prepack",
|
|
20
|
+
"postpack": "node ../../npm-scripts/pack-dist.mjs postpack",
|
|
21
|
+
"test:typecheck": "tsgo --noEmit",
|
|
22
|
+
"test:run": "node $NODE_TEST_FLAGS --import tsx --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/memory-store.ts' --test-coverage-lines=90 --test 'src/**/*.test.ts'"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@remit/api-openapi-types": "*",
|
|
26
|
+
"@remit/data-ports": "*",
|
|
27
|
+
"@remit/domain-enums": "*",
|
|
28
|
+
"ical.js": "^2.2.1"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/remit-mail/reader.git",
|
|
37
|
+
"directory": "packages/calendar-service"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist"
|
|
41
|
+
]
|
|
37
42
|
}
|