@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/src/build.test.ts
DELETED
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import { CalendarEventStatus, CalendarTransparency } from "@remit/domain-enums";
|
|
4
|
-
import { buildEventCalendar, type CalendarEventFields } from "./build.js";
|
|
5
|
-
import { expandCalendar } from "./expand.js";
|
|
6
|
-
import { parseCalendar } from "./parse.js";
|
|
7
|
-
|
|
8
|
-
const fields = (
|
|
9
|
-
overrides: Partial<CalendarEventFields> = {},
|
|
10
|
-
): CalendarEventFields => ({
|
|
11
|
-
summary: "Stand-up",
|
|
12
|
-
description: "",
|
|
13
|
-
location: "",
|
|
14
|
-
start: "2026-10-15T09:00:00+02:00",
|
|
15
|
-
end: "2026-10-15T10:00:00+02:00",
|
|
16
|
-
allDay: false,
|
|
17
|
-
timeZone: "",
|
|
18
|
-
status: CalendarEventStatus.Confirmed,
|
|
19
|
-
transparency: CalendarTransparency.Opaque,
|
|
20
|
-
recurrenceRule: "",
|
|
21
|
-
...overrides,
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
const build = async (overrides: Partial<CalendarEventFields> = {}) =>
|
|
25
|
-
buildEventCalendar(
|
|
26
|
-
fields(overrides),
|
|
27
|
-
"new@reader.remit",
|
|
28
|
-
new Date("2026-08-29T00:00:00Z"),
|
|
29
|
-
);
|
|
30
|
-
|
|
31
|
-
describe("buildEventCalendar", () => {
|
|
32
|
-
it("writes an event with no zone as the instant it names", async () => {
|
|
33
|
-
const built = await build();
|
|
34
|
-
|
|
35
|
-
assert.ok(built.ok, JSON.stringify(built));
|
|
36
|
-
assert.ok(built.value.includes("DTSTART:20261015T070000Z"));
|
|
37
|
-
assert.ok(built.value.includes("DTEND:20261015T080000Z"));
|
|
38
|
-
assert.ok(built.value.includes("UID:new@reader.remit"));
|
|
39
|
-
assert.ok(built.value.includes("SUMMARY:Stand-up"));
|
|
40
|
-
assert.equal(
|
|
41
|
-
built.value.includes("DESCRIPTION"),
|
|
42
|
-
false,
|
|
43
|
-
"an empty field is left out rather than written blank",
|
|
44
|
-
);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("anchors an event in the zone it names, not in the offset it arrived with", async () => {
|
|
48
|
-
const built = await build({ timeZone: "Europe/Amsterdam" });
|
|
49
|
-
|
|
50
|
-
assert.ok(built.ok);
|
|
51
|
-
assert.ok(
|
|
52
|
-
built.value.includes("DTSTART;TZID=Europe/Amsterdam:20261015T090000"),
|
|
53
|
-
);
|
|
54
|
-
assert.ok(
|
|
55
|
-
built.value.includes("DTEND;TZID=Europe/Amsterdam:20261015T100000"),
|
|
56
|
-
);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("keeps a series at its local hour across a DST transition", async () => {
|
|
60
|
-
const built = await build({
|
|
61
|
-
timeZone: "Europe/Amsterdam",
|
|
62
|
-
recurrenceRule: "FREQ=WEEKLY;COUNT=4",
|
|
63
|
-
});
|
|
64
|
-
assert.ok(built.ok);
|
|
65
|
-
|
|
66
|
-
const parsed = await parseCalendar(built.value);
|
|
67
|
-
assert.ok(parsed.ok);
|
|
68
|
-
const expansion = expandCalendar(parsed.value, "Europe/Amsterdam");
|
|
69
|
-
|
|
70
|
-
assert.deepEqual(
|
|
71
|
-
expansion.occurrences.map((occurrence) => occurrence.startAt),
|
|
72
|
-
[
|
|
73
|
-
"2026-10-15T07:00:00Z",
|
|
74
|
-
"2026-10-22T07:00:00Z",
|
|
75
|
-
"2026-10-29T08:00:00Z",
|
|
76
|
-
"2026-11-05T08:00:00Z",
|
|
77
|
-
],
|
|
78
|
-
);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("writes an all-day event as civil dates with an exclusive end", async () => {
|
|
82
|
-
const built = await build({
|
|
83
|
-
allDay: true,
|
|
84
|
-
start: "2026-10-15",
|
|
85
|
-
end: "2026-10-17",
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
assert.ok(built.ok);
|
|
89
|
-
assert.ok(built.value.includes("DTSTART;VALUE=DATE:20261015"));
|
|
90
|
-
assert.ok(built.value.includes("DTEND;VALUE=DATE:20261017"));
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it("refuses a date-time with no zone offset", async () => {
|
|
94
|
-
const built = await build({ start: "2026-10-15T09:00:00" });
|
|
95
|
-
|
|
96
|
-
assert.ok(!built.ok);
|
|
97
|
-
assert.equal(built.error.code, "InvalidDateTime");
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("refuses a zone this server cannot resolve", async () => {
|
|
101
|
-
const built = await build({ timeZone: "W. Europe Standard Time" });
|
|
102
|
-
|
|
103
|
-
assert.ok(!built.ok);
|
|
104
|
-
assert.equal(built.error.code, "UnknownTimeZone");
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("refuses a recurrence rule it cannot read", async () => {
|
|
108
|
-
const built = await build({ recurrenceRule: "EVERY OTHER TUESDAY" });
|
|
109
|
-
|
|
110
|
-
assert.ok(!built.ok);
|
|
111
|
-
assert.equal(built.error.code, "InvalidRecurrenceRule");
|
|
112
|
-
});
|
|
113
|
-
});
|
package/src/build.ts
DELETED
|
@@ -1,311 +0,0 @@
|
|
|
1
|
-
import type { CalendarObjectItem } from "@remit/data-ports";
|
|
2
|
-
import { CalendarEventStatus, CalendarTransparency } from "@remit/domain-enums";
|
|
3
|
-
import ICAL from "ical.js";
|
|
4
|
-
import { type CalendarResult, calendarFailure } from "./errors.js";
|
|
5
|
-
import { serializeCalendar } from "./parse.js";
|
|
6
|
-
import {
|
|
7
|
-
civilInZone,
|
|
8
|
-
dtEndTzid,
|
|
9
|
-
dtStartTzid,
|
|
10
|
-
isResolvableZone,
|
|
11
|
-
resolveTime,
|
|
12
|
-
} from "./time.js";
|
|
13
|
-
|
|
14
|
-
/** Identifies this server in the PRODID of every VCALENDAR it writes. */
|
|
15
|
-
export const CALENDAR_PRODID = "-//Remit//Reader Calendar//EN";
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* An event as a person describes it, before it is iCalendar.
|
|
19
|
-
*
|
|
20
|
-
* Deliberately not a subset of a VEVENT: a client should not have to know that
|
|
21
|
-
* an all-day end is exclusive, that a duration and an end are the same fact
|
|
22
|
-
* written twice, or that a zone lives in a parameter rather than in the value.
|
|
23
|
-
* The server owns iCalendar; this is the shape the API takes and the shape a
|
|
24
|
-
* patch is expressed in.
|
|
25
|
-
*/
|
|
26
|
-
export interface CalendarEventFields {
|
|
27
|
-
summary: string;
|
|
28
|
-
description: string;
|
|
29
|
-
location: string;
|
|
30
|
-
/** ISO 8601 with a zone offset, or `YYYY-MM-DD` when `allDay`. */
|
|
31
|
-
start: string;
|
|
32
|
-
/** Same form as `start`. Exclusive for an all-day event. */
|
|
33
|
-
end: string;
|
|
34
|
-
allDay: boolean;
|
|
35
|
-
/** IANA zone the event is anchored in. `""` anchors it in UTC. */
|
|
36
|
-
timeZone: string;
|
|
37
|
-
status: CalendarObjectItem["status"];
|
|
38
|
-
transparency: CalendarObjectItem["transparency"];
|
|
39
|
-
/** RRULE value without the property name. `""` for a single event. */
|
|
40
|
-
recurrenceRule: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const STATUS_TO_ICAL: Record<CalendarObjectItem["status"], string> = {
|
|
44
|
-
[CalendarEventStatus.Confirmed]: "CONFIRMED",
|
|
45
|
-
[CalendarEventStatus.Tentative]: "TENTATIVE",
|
|
46
|
-
[CalendarEventStatus.Cancelled]: "CANCELLED",
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
const TRANSPARENCY_TO_ICAL: Record<CalendarObjectItem["transparency"], string> =
|
|
50
|
-
{
|
|
51
|
-
[CalendarTransparency.Opaque]: "OPAQUE",
|
|
52
|
-
[CalendarTransparency.Transparent]: "TRANSPARENT",
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}/;
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* An offset is required on a date-time. A wall time with no offset names no
|
|
59
|
-
* instant — a client sending `2026-03-29T02:30:00` on the morning the clocks
|
|
60
|
-
* move is asking for a time that happens twice, or not at all — and guessing
|
|
61
|
-
* one is how a calendar quietly puts an event an hour out.
|
|
62
|
-
*/
|
|
63
|
-
export const OFFSET_BEARING = /(Z|[+-]\d{2}:\d{2})$/;
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Reads one API date-time into the iCalendar time the resource will carry.
|
|
67
|
-
*
|
|
68
|
-
* An all-day value becomes a DATE, which is a civil date and has no zone at
|
|
69
|
-
* all. A timed value becomes the wall time it reads as in `timeZone`, so the
|
|
70
|
-
* resource records the zone the event is anchored in rather than the offset
|
|
71
|
-
* that zone happened to be at — which is the whole difference between a weekly
|
|
72
|
-
* 09:00 meeting and a meeting that moves to 08:00 every autumn.
|
|
73
|
-
*/
|
|
74
|
-
export const readEventTime = (
|
|
75
|
-
value: string,
|
|
76
|
-
allDay: boolean,
|
|
77
|
-
timeZone: string,
|
|
78
|
-
): CalendarResult<ICAL.Time> => {
|
|
79
|
-
if (allDay) {
|
|
80
|
-
const date = DATE_ONLY.exec(value)?.[0];
|
|
81
|
-
if (!date) {
|
|
82
|
-
return calendarFailure(
|
|
83
|
-
"InvalidDateTime",
|
|
84
|
-
`an all-day event needs a YYYY-MM-DD date, and this one carries "${value}"`,
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
const [year, month, day] = date.split("-").map(Number) as [
|
|
88
|
-
number,
|
|
89
|
-
number,
|
|
90
|
-
number,
|
|
91
|
-
];
|
|
92
|
-
return {
|
|
93
|
-
ok: true,
|
|
94
|
-
value: ICAL.Time.fromData({ year, month, day, isDate: true }),
|
|
95
|
-
};
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (!OFFSET_BEARING.test(value)) {
|
|
99
|
-
return calendarFailure(
|
|
100
|
-
"InvalidDateTime",
|
|
101
|
-
`a date-time needs an explicit zone offset, and this one carries "${value}"`,
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
const instantMs = Date.parse(value);
|
|
105
|
-
if (Number.isNaN(instantMs)) {
|
|
106
|
-
return calendarFailure(
|
|
107
|
-
"InvalidDateTime",
|
|
108
|
-
`"${value}" is not a date-time this server can read`,
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
if (timeZone !== "" && !isResolvableZone(timeZone)) {
|
|
112
|
-
return calendarFailure(
|
|
113
|
-
"UnknownTimeZone",
|
|
114
|
-
`"${timeZone}" is not a time zone this server can resolve`,
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const civil = civilInZone(instantMs, timeZone === "" ? "UTC" : timeZone);
|
|
119
|
-
return {
|
|
120
|
-
ok: true,
|
|
121
|
-
value: ICAL.Time.fromData(
|
|
122
|
-
{
|
|
123
|
-
year: civil.year,
|
|
124
|
-
month: civil.month,
|
|
125
|
-
day: civil.day,
|
|
126
|
-
hour: civil.hour,
|
|
127
|
-
minute: civil.minute,
|
|
128
|
-
second: civil.second,
|
|
129
|
-
isDate: false,
|
|
130
|
-
},
|
|
131
|
-
timeZone === "" ? ICAL.Timezone.utcTimezone : ICAL.Timezone.localTimezone,
|
|
132
|
-
),
|
|
133
|
-
};
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
const setTimeProperty = (
|
|
137
|
-
event: ICAL.Component,
|
|
138
|
-
name: string,
|
|
139
|
-
time: ICAL.Time,
|
|
140
|
-
timeZone: string,
|
|
141
|
-
): void => {
|
|
142
|
-
event.removeAllProperties(name);
|
|
143
|
-
const property = new ICAL.Property(name);
|
|
144
|
-
event.addProperty(property);
|
|
145
|
-
property.setValue(time);
|
|
146
|
-
if (!time.isDate && timeZone !== "") {
|
|
147
|
-
property.setParameter("tzid", timeZone);
|
|
148
|
-
}
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
const setTextProperty = (
|
|
152
|
-
event: ICAL.Component,
|
|
153
|
-
name: string,
|
|
154
|
-
value: string,
|
|
155
|
-
): void => {
|
|
156
|
-
event.removeAllProperties(name);
|
|
157
|
-
if (value === "") return;
|
|
158
|
-
event.addPropertyWithValue(name, value);
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
/** The time fields a stored VEVENT already carries, in the API's own form. */
|
|
162
|
-
export const eventTimeFields = (
|
|
163
|
-
component: ICAL.Component,
|
|
164
|
-
collectionTimezone: string,
|
|
165
|
-
): Pick<CalendarEventFields, "start" | "end" | "allDay" | "timeZone"> => {
|
|
166
|
-
const event = new ICAL.Event(component);
|
|
167
|
-
const startTzid = dtStartTzid(component);
|
|
168
|
-
const start = resolveTime(event.startDate, startTzid, collectionTimezone);
|
|
169
|
-
const end = resolveTime(
|
|
170
|
-
event.endDate,
|
|
171
|
-
dtEndTzid(component),
|
|
172
|
-
collectionTimezone,
|
|
173
|
-
);
|
|
174
|
-
return {
|
|
175
|
-
start: start.isoOffset,
|
|
176
|
-
end: end.isoOffset,
|
|
177
|
-
allDay: start.isDate,
|
|
178
|
-
timeZone: startTzid,
|
|
179
|
-
};
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* ical.js's recurrence parser in a promise, so an unreadable rule arrives as a
|
|
184
|
-
* value to branch on rather than a synchronous throw.
|
|
185
|
-
*
|
|
186
|
-
* A rule with no FREQ is refused rather than stored. ical.js reads text it
|
|
187
|
-
* finds no parts in as a rule with a null frequency instead of failing, and
|
|
188
|
-
* storing that would give somebody an event marked as recurring that produces
|
|
189
|
-
* nothing — the failure a client cannot see and cannot fix.
|
|
190
|
-
*/
|
|
191
|
-
export const readRecurrenceRule = (
|
|
192
|
-
recurrenceRule: string,
|
|
193
|
-
): Promise<CalendarResult<ICAL.Recur>> =>
|
|
194
|
-
new Promise<ICAL.Recur>((resolve) => {
|
|
195
|
-
resolve(ICAL.Recur.fromString(recurrenceRule));
|
|
196
|
-
}).then(
|
|
197
|
-
(value) =>
|
|
198
|
-
value.freq
|
|
199
|
-
? ({ ok: true, value } as const)
|
|
200
|
-
: calendarFailure<ICAL.Recur>(
|
|
201
|
-
"InvalidRecurrenceRule",
|
|
202
|
-
`"${recurrenceRule}" names no FREQ, so it is not a recurrence rule`,
|
|
203
|
-
),
|
|
204
|
-
(error: unknown) =>
|
|
205
|
-
calendarFailure<ICAL.Recur>(
|
|
206
|
-
"InvalidRecurrenceRule",
|
|
207
|
-
error instanceof Error
|
|
208
|
-
? error.message
|
|
209
|
-
: `"${recurrenceRule}" is not a recurrence rule this server can read`,
|
|
210
|
-
),
|
|
211
|
-
);
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Writes a patch onto a VEVENT.
|
|
215
|
-
*
|
|
216
|
-
* A field the patch does not carry is untouched, so renaming an event moves
|
|
217
|
-
* nothing. The time fields are the exception: zone, all-day-ness, start and end
|
|
218
|
-
* are one fact between them, so touching any of them rewrites DTSTART and DTEND
|
|
219
|
-
* together from the values the event would then have. Rewriting only the one
|
|
220
|
-
* the caller named is what produces an event that starts in one zone and ends
|
|
221
|
-
* in another.
|
|
222
|
-
*/
|
|
223
|
-
export const applyEventFields = async (
|
|
224
|
-
component: ICAL.Component,
|
|
225
|
-
patch: Partial<CalendarEventFields>,
|
|
226
|
-
collectionTimezone: string,
|
|
227
|
-
): Promise<CalendarResult<null>> => {
|
|
228
|
-
if (patch.summary !== undefined) {
|
|
229
|
-
setTextProperty(component, "summary", patch.summary);
|
|
230
|
-
}
|
|
231
|
-
if (patch.description !== undefined) {
|
|
232
|
-
setTextProperty(component, "description", patch.description);
|
|
233
|
-
}
|
|
234
|
-
if (patch.location !== undefined) {
|
|
235
|
-
setTextProperty(component, "location", patch.location);
|
|
236
|
-
}
|
|
237
|
-
if (patch.status !== undefined) {
|
|
238
|
-
setTextProperty(component, "status", STATUS_TO_ICAL[patch.status]);
|
|
239
|
-
}
|
|
240
|
-
if (patch.transparency !== undefined) {
|
|
241
|
-
setTextProperty(
|
|
242
|
-
component,
|
|
243
|
-
"transp",
|
|
244
|
-
TRANSPARENCY_TO_ICAL[patch.transparency],
|
|
245
|
-
);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
if (patch.recurrenceRule !== undefined) {
|
|
249
|
-
component.removeAllProperties("rrule");
|
|
250
|
-
if (patch.recurrenceRule !== "") {
|
|
251
|
-
const rule = await readRecurrenceRule(patch.recurrenceRule);
|
|
252
|
-
if (!rule.ok) return rule;
|
|
253
|
-
component.addPropertyWithValue("rrule", rule.value);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const touchesTime =
|
|
258
|
-
patch.start !== undefined ||
|
|
259
|
-
patch.end !== undefined ||
|
|
260
|
-
patch.allDay !== undefined ||
|
|
261
|
-
patch.timeZone !== undefined;
|
|
262
|
-
if (!touchesTime) return { ok: true, value: null };
|
|
263
|
-
|
|
264
|
-
// A VEVENT being built from nothing has no times to read back, and every one
|
|
265
|
-
// of them is in the patch. The empty start then refuses a create that left
|
|
266
|
-
// one out, which is the same answer reading it back would have given.
|
|
267
|
-
const current = component.hasProperty("dtstart")
|
|
268
|
-
? eventTimeFields(component, collectionTimezone)
|
|
269
|
-
: { start: "", end: "", allDay: false, timeZone: "" };
|
|
270
|
-
const allDay = patch.allDay ?? current.allDay;
|
|
271
|
-
const timeZone = patch.timeZone ?? current.timeZone;
|
|
272
|
-
const start = readEventTime(patch.start ?? current.start, allDay, timeZone);
|
|
273
|
-
if (!start.ok) return start;
|
|
274
|
-
const end = readEventTime(patch.end ?? current.end, allDay, timeZone);
|
|
275
|
-
if (!end.ok) return end;
|
|
276
|
-
|
|
277
|
-
// A duration says the same thing DTEND does, and leaving one behind beside a
|
|
278
|
-
// rewritten DTEND leaves two answers to how long the event is.
|
|
279
|
-
component.removeAllProperties("duration");
|
|
280
|
-
setTimeProperty(component, "dtstart", start.value, timeZone);
|
|
281
|
-
setTimeProperty(component, "dtend", end.value, timeZone);
|
|
282
|
-
return { ok: true, value: null };
|
|
283
|
-
};
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Builds the VCALENDAR text for a new event.
|
|
287
|
-
*
|
|
288
|
-
* The output goes straight to the single write path, which parses it again to
|
|
289
|
-
* validate and project it. That second read is not waste: it is the same gate
|
|
290
|
-
* every other writer passes, so an event this function got wrong is refused
|
|
291
|
-
* here rather than stored.
|
|
292
|
-
*/
|
|
293
|
-
export const buildEventCalendar = async (
|
|
294
|
-
fields: CalendarEventFields,
|
|
295
|
-
uid: string,
|
|
296
|
-
now: Date = new Date(),
|
|
297
|
-
): Promise<CalendarResult<string>> => {
|
|
298
|
-
const calendar = new ICAL.Component("vcalendar");
|
|
299
|
-
calendar.addPropertyWithValue("version", "2.0");
|
|
300
|
-
calendar.addPropertyWithValue("prodid", CALENDAR_PRODID);
|
|
301
|
-
|
|
302
|
-
const event = new ICAL.Component("vevent");
|
|
303
|
-
calendar.addSubcomponent(event);
|
|
304
|
-
event.addPropertyWithValue("uid", uid);
|
|
305
|
-
event.addPropertyWithValue("dtstamp", ICAL.Time.fromJSDate(now, true));
|
|
306
|
-
|
|
307
|
-
const applied = await applyEventFields(event, fields, fields.timeZone);
|
|
308
|
-
if (!applied.ok) return applied;
|
|
309
|
-
|
|
310
|
-
return { ok: true, value: serializeCalendar(calendar) };
|
|
311
|
-
};
|
package/src/correct.test.ts
DELETED
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import type { CalendarSuggestionItem } from "@remit/data-ports";
|
|
4
|
-
import {
|
|
5
|
-
CalendarInviteAnswer,
|
|
6
|
-
CalendarSuggestionSource,
|
|
7
|
-
CalendarSuggestionState,
|
|
8
|
-
ZoneCertainty,
|
|
9
|
-
} from "@remit/domain-enums";
|
|
10
|
-
import { acceptCalendarSuggestion } from "./accept.js";
|
|
11
|
-
import type { CalendarEventFields } from "./build.js";
|
|
12
|
-
import { correctCalendarSuggestion } from "./correct.js";
|
|
13
|
-
import { ical } from "./fixtures.js";
|
|
14
|
-
import { MemoryCalendarStore } from "./memory-store.js";
|
|
15
|
-
import { provisionDefaultCalendar } from "./put.js";
|
|
16
|
-
import { recordCalendarSuggestion } from "./suggest.js";
|
|
17
|
-
|
|
18
|
-
const ACCOUNT_CONFIG_ID = "account-config-1";
|
|
19
|
-
const ATTENDEE = "user@example.test";
|
|
20
|
-
const UID = "invite-5120@example.test";
|
|
21
|
-
|
|
22
|
-
const invitation = ({
|
|
23
|
-
method = "REQUEST",
|
|
24
|
-
start = "DTSTART:20260901T080000Z",
|
|
25
|
-
end = "DTEND:20260901T090000Z",
|
|
26
|
-
}: {
|
|
27
|
-
method?: string;
|
|
28
|
-
start?: string;
|
|
29
|
-
end?: string;
|
|
30
|
-
}): string =>
|
|
31
|
-
ical(
|
|
32
|
-
"BEGIN:VCALENDAR",
|
|
33
|
-
"VERSION:2.0",
|
|
34
|
-
"PRODID:-//Example Corp//Scheduler//EN",
|
|
35
|
-
`METHOD:${method}`,
|
|
36
|
-
"BEGIN:VEVENT",
|
|
37
|
-
`UID:${UID}`,
|
|
38
|
-
"DTSTAMP:20260801T090000Z",
|
|
39
|
-
"SEQUENCE:2",
|
|
40
|
-
start,
|
|
41
|
-
end,
|
|
42
|
-
"SUMMARY:Quarterly review",
|
|
43
|
-
"LOCATION:Room 4",
|
|
44
|
-
"ORGANIZER:mailto:organizer@example.test",
|
|
45
|
-
"ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:user@example.test",
|
|
46
|
-
"X-EXAMPLE-TICKET:AB-5120",
|
|
47
|
-
"END:VEVENT",
|
|
48
|
-
"END:VCALENDAR",
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
const pendingIn = async (icalData: string) => {
|
|
52
|
-
const store = new MemoryCalendarStore();
|
|
53
|
-
const collection = await provisionDefaultCalendar(store, ACCOUNT_CONFIG_ID);
|
|
54
|
-
const recorded = await recordCalendarSuggestion(store.calendarSuggestion, {
|
|
55
|
-
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
56
|
-
messageId: "message-1",
|
|
57
|
-
bodyPartId: "body-part-1",
|
|
58
|
-
source: CalendarSuggestionSource.IcalendarPart,
|
|
59
|
-
icalData,
|
|
60
|
-
timezone: "UTC",
|
|
61
|
-
});
|
|
62
|
-
assert.ok(recorded.ok);
|
|
63
|
-
return {
|
|
64
|
-
store,
|
|
65
|
-
calendarId: collection.calendarId,
|
|
66
|
-
suggestion: recorded.value.suggestion,
|
|
67
|
-
};
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
const acceptCorrected = async (
|
|
71
|
-
store: MemoryCalendarStore,
|
|
72
|
-
calendarId: string,
|
|
73
|
-
suggestion: CalendarSuggestionItem,
|
|
74
|
-
patch: Partial<CalendarEventFields>,
|
|
75
|
-
) => {
|
|
76
|
-
const corrected = await correctCalendarSuggestion(suggestion, patch, "UTC");
|
|
77
|
-
if (!corrected.ok) return corrected;
|
|
78
|
-
return acceptCalendarSuggestion(store, {
|
|
79
|
-
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
80
|
-
calendarId,
|
|
81
|
-
suggestion: corrected.value,
|
|
82
|
-
attendee: ATTENDEE,
|
|
83
|
-
answer: CalendarInviteAnswer.Accepted,
|
|
84
|
-
});
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
describe("correctCalendarSuggestion", () => {
|
|
88
|
-
it("writes the corrected event and settles the card as accepted", async () => {
|
|
89
|
-
const { store, calendarId, suggestion } = await pendingIn(invitation({}));
|
|
90
|
-
|
|
91
|
-
const accepted = await acceptCorrected(store, calendarId, suggestion, {
|
|
92
|
-
summary: "Quarterly review, moved",
|
|
93
|
-
start: "2026-09-01T13:00:00+00:00",
|
|
94
|
-
end: "2026-09-01T14:30:00+00:00",
|
|
95
|
-
allDay: false,
|
|
96
|
-
timeZone: "",
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
assert.ok(accepted.ok);
|
|
100
|
-
const object = accepted.value.object;
|
|
101
|
-
assert.ok(object);
|
|
102
|
-
assert.equal(object.summary, "Quarterly review, moved");
|
|
103
|
-
assert.equal(Date.parse(object.dtStart), Date.parse("2026-09-01T13:00Z"));
|
|
104
|
-
assert.equal(Date.parse(object.dtEnd), Date.parse("2026-09-01T14:30Z"));
|
|
105
|
-
assert.equal(object.icalUid, UID);
|
|
106
|
-
assert.match(object.icalData, /LOCATION:Room 4/);
|
|
107
|
-
assert.match(object.icalData, /X-EXAMPLE-TICKET:AB-5120/);
|
|
108
|
-
assert.match(object.icalData, /PARTSTAT=ACCEPTED/);
|
|
109
|
-
|
|
110
|
-
const settled = await store.calendarSuggestion.get(
|
|
111
|
-
ACCOUNT_CONFIG_ID,
|
|
112
|
-
suggestion.suggestionId,
|
|
113
|
-
);
|
|
114
|
-
assert.equal(settled.state, CalendarSuggestionState.Accepted);
|
|
115
|
-
assert.equal(settled.acceptedCalendarObjectId, object.calendarObjectId);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("places an unresolvable zone on the clock the reader picked", async () => {
|
|
119
|
-
const { store, calendarId, suggestion } = await pendingIn(
|
|
120
|
-
invitation({
|
|
121
|
-
start: "DTSTART;TZID=Nowhere Standard Time:20260901T090000",
|
|
122
|
-
end: "DTEND;TZID=Nowhere Standard Time:20260901T100000",
|
|
123
|
-
}),
|
|
124
|
-
);
|
|
125
|
-
assert.equal(suggestion.zoneCertainty, ZoneCertainty.Ambiguous);
|
|
126
|
-
|
|
127
|
-
const accepted = await acceptCorrected(store, calendarId, suggestion, {
|
|
128
|
-
start: "2026-09-01T09:00:00+02:00",
|
|
129
|
-
end: "2026-09-01T10:00:00+02:00",
|
|
130
|
-
allDay: false,
|
|
131
|
-
timeZone: "Europe/Amsterdam",
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
assert.ok(accepted.ok);
|
|
135
|
-
const object = accepted.value.object;
|
|
136
|
-
assert.ok(object);
|
|
137
|
-
assert.equal(object.zoneCertainty, ZoneCertainty.Explicit);
|
|
138
|
-
assert.equal(Date.parse(object.dtStart), Date.parse("2026-09-01T07:00Z"));
|
|
139
|
-
assert.match(
|
|
140
|
-
object.icalData,
|
|
141
|
-
/DTSTART;TZID=Europe\/Amsterdam:20260901T090000/,
|
|
142
|
-
);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it("refuses a cancellation and leaves the card waiting", async () => {
|
|
146
|
-
const { store, calendarId, suggestion } = await pendingIn(
|
|
147
|
-
invitation({ method: "CANCEL" }),
|
|
148
|
-
);
|
|
149
|
-
|
|
150
|
-
const refused = await acceptCorrected(store, calendarId, suggestion, {
|
|
151
|
-
summary: "Anything",
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
assert.equal(refused.ok, false);
|
|
155
|
-
assert.equal(store.objects.size, 0);
|
|
156
|
-
const untouched = await store.calendarSuggestion.get(
|
|
157
|
-
ACCOUNT_CONFIG_ID,
|
|
158
|
-
suggestion.suggestionId,
|
|
159
|
-
);
|
|
160
|
-
assert.equal(untouched.state, CalendarSuggestionState.Pending);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("refuses a correction the event cannot take and writes nothing", async () => {
|
|
164
|
-
const { store, calendarId, suggestion } = await pendingIn(invitation({}));
|
|
165
|
-
|
|
166
|
-
const refused = await acceptCorrected(store, calendarId, suggestion, {
|
|
167
|
-
start: "2026-09-01T13:00:00",
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
assert.equal(refused.ok, false);
|
|
171
|
-
assert.equal(store.objects.size, 0);
|
|
172
|
-
});
|
|
173
|
-
});
|
package/src/correct.ts
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import type { CalendarSuggestionItem } from "@remit/data-ports";
|
|
2
|
-
import { CalendarInviteMethod, RecurrenceScope } from "@remit/domain-enums";
|
|
3
|
-
import type { CalendarEventFields } from "./build.js";
|
|
4
|
-
import { type CalendarResult, calendarFailure } from "./errors.js";
|
|
5
|
-
import { parseCalendar } from "./parse.js";
|
|
6
|
-
import { applyScopedUpdate } from "./scope.js";
|
|
7
|
-
|
|
8
|
-
export const correctCalendarSuggestion = async (
|
|
9
|
-
suggestion: CalendarSuggestionItem,
|
|
10
|
-
patch: Partial<CalendarEventFields>,
|
|
11
|
-
collectionTimezone: string,
|
|
12
|
-
): Promise<CalendarResult<CalendarSuggestionItem>> => {
|
|
13
|
-
if (suggestion.method === CalendarInviteMethod.Cancel) {
|
|
14
|
-
return calendarFailure(
|
|
15
|
-
"UneditableCancellation",
|
|
16
|
-
"a cancellation names no event to correct — add it or dismiss it as it is",
|
|
17
|
-
);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const parsed = await parseCalendar(suggestion.icalData);
|
|
21
|
-
if (!parsed.ok) return parsed;
|
|
22
|
-
|
|
23
|
-
const edited = await applyScopedUpdate(
|
|
24
|
-
parsed.value,
|
|
25
|
-
collectionTimezone,
|
|
26
|
-
{ scope: RecurrenceScope.All, recurrenceId: "", followingUid: "" },
|
|
27
|
-
patch,
|
|
28
|
-
);
|
|
29
|
-
if (!edited.ok) return edited;
|
|
30
|
-
if (edited.value.kind !== "Replace") {
|
|
31
|
-
throw new Error(
|
|
32
|
-
`an edit to the whole invitation resolved to a ${edited.value.kind}`,
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return {
|
|
37
|
-
ok: true,
|
|
38
|
-
value: { ...suggestion, icalData: edited.value.icalData },
|
|
39
|
-
};
|
|
40
|
-
};
|
package/src/errors.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Why a VCALENDAR was refused. Every value names something the writer can act
|
|
3
|
-
* on — a caller maps these to a 4xx and repeats the message.
|
|
4
|
-
*/
|
|
5
|
-
export type CalendarValidationCode =
|
|
6
|
-
/** The bytes are not parseable iCalendar at all. */
|
|
7
|
-
| "MalformedIcalendar"
|
|
8
|
-
/** The document's root is not a VCALENDAR. */
|
|
9
|
-
| "NotACalendar"
|
|
10
|
-
/** A component this collection does not store, e.g. VTODO or VJOURNAL. */
|
|
11
|
-
| "UnsupportedComponent"
|
|
12
|
-
/** A VCALENDAR with no VEVENT in it. */
|
|
13
|
-
| "NoEvent"
|
|
14
|
-
/** No VEVENT without a RECURRENCE-ID: overrides with nothing to override. */
|
|
15
|
-
| "NoMasterEvent"
|
|
16
|
-
/** More than one VEVENT without a RECURRENCE-ID. */
|
|
17
|
-
| "MultipleMasterEvents"
|
|
18
|
-
/** A VEVENT with no UID, or an empty one. */
|
|
19
|
-
| "MissingUid"
|
|
20
|
-
/** VEVENTs in one resource declaring different UIDs. */
|
|
21
|
-
| "MismatchedUid"
|
|
22
|
-
/** A VEVENT with no DTSTART. */
|
|
23
|
-
| "MissingDtStart"
|
|
24
|
-
/** A VEVENT whose end precedes its start. */
|
|
25
|
-
| "BackwardsEnd"
|
|
26
|
-
/** A start or end that is not a date-time this server can read. */
|
|
27
|
-
| "InvalidDateTime"
|
|
28
|
-
/** A time zone name nothing on this platform resolves. */
|
|
29
|
-
| "UnknownTimeZone"
|
|
30
|
-
/** An RRULE value ical.js could not read. */
|
|
31
|
-
| "InvalidRecurrenceRule"
|
|
32
|
-
/** A scoped write that names one occurrence without saying which. */
|
|
33
|
-
| "MissingRecurrenceId"
|
|
34
|
-
/** A per-occurrence scope against an event that has one occurrence. */
|
|
35
|
-
| "NotRecurring"
|
|
36
|
-
/** A RECURRENCE-ID naming no occurrence this series produces. */
|
|
37
|
-
| "UnknownOccurrence"
|
|
38
|
-
| "UnmovableRecurrenceRule"
|
|
39
|
-
| "UneditableCancellation"
|
|
40
|
-
/** A subscription address that is not an http, https or webcal URL. */
|
|
41
|
-
| "InvalidSubscriptionUrl"
|
|
42
|
-
| "NotReopenable"
|
|
43
|
-
| "AnswerOvertaken";
|
|
44
|
-
|
|
45
|
-
export interface CalendarValidationError {
|
|
46
|
-
code: CalendarValidationCode;
|
|
47
|
-
message: string;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* The outcome of reading a VCALENDAR. A refusal is a value, not a throw:
|
|
52
|
-
* malformed input from a client is an expected outcome of this boundary, and
|
|
53
|
-
* every caller has to render it rather than crash on it.
|
|
54
|
-
*/
|
|
55
|
-
export type CalendarResult<T> =
|
|
56
|
-
| { ok: true; value: T }
|
|
57
|
-
| { ok: false; error: CalendarValidationError };
|
|
58
|
-
|
|
59
|
-
export const calendarFailure = <T>(
|
|
60
|
-
code: CalendarValidationCode,
|
|
61
|
-
message: string,
|
|
62
|
-
): CalendarResult<T> => ({ ok: false, error: { code, message } });
|