@wirecat/cli-core 0.454.0 → 0.456.0

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/calendar/cli/index.d.ts +24 -0
  3. package/dist/calendar/cli/index.js +173 -0
  4. package/dist/calendar/index.d.ts +3 -0
  5. package/dist/calendar/index.js +1 -0
  6. package/dist/calendar/mcp/index.d.ts +307 -0
  7. package/dist/calendar/mcp/index.js +132 -0
  8. package/dist/calendar/reads.d.ts +149 -0
  9. package/dist/calendar/reads.js +214 -0
  10. package/dist/calendar/source.d.ts +54 -0
  11. package/dist/calendar/source.js +1 -0
  12. package/dist/calendar/sync.d.ts +65 -0
  13. package/dist/calendar/sync.js +167 -0
  14. package/dist/calendar/testing/index.d.ts +5 -0
  15. package/dist/calendar/testing/index.js +5 -0
  16. package/dist/calendar/testing/memory.d.ts +6 -0
  17. package/dist/calendar/testing/memory.js +478 -0
  18. package/dist/calendar/testing/source-contract.d.ts +31 -0
  19. package/dist/calendar/testing/source-contract.js +171 -0
  20. package/dist/calendar/testing/source.d.ts +38 -0
  21. package/dist/calendar/testing/source.js +110 -0
  22. package/dist/calendar/testing/sync-contract.d.ts +15 -0
  23. package/dist/calendar/testing/sync-contract.js +211 -0
  24. package/dist/cli/index.d.ts +2 -1
  25. package/dist/cli/index.js +2 -1
  26. package/dist/cli/messenger/ask.js +1 -1
  27. package/dist/errors/reasons.js +1 -0
  28. package/dist/meetings/calendar-text.d.ts +1 -1
  29. package/dist/meetings/calendar-text.js +0 -1
  30. package/dist/meetings/event-store.d.ts +42 -15
  31. package/dist/meetings/index.d.ts +1 -1
  32. package/dist/meetings/testing/event-store-contract.js +83 -4
  33. package/dist/sends/guard.d.ts +2 -0
  34. package/dist/sends/guard.js +1 -0
  35. package/dist/services/index.d.ts +1 -1
  36. package/dist/services/index.js +1 -1
  37. package/dist/services/people.d.ts +6 -1
  38. package/dist/services/people.js +14 -11
  39. package/dist/store/sqlite/calendar.js +68 -31
  40. package/dist/store/sqlite/manifest.js +2 -0
  41. package/dist/store/sqlite/migrations.generated.js +17 -0
  42. package/dist/store/sqlite/note-index.d.ts +1 -1
  43. package/dist/store/sqlite/note-index.js +6 -1
  44. package/dist/store/sqlite/schema.d.ts +16 -0
  45. package/dist/store/sqlite/schema.js +4 -0
  46. package/package.json +17 -1
@@ -0,0 +1,167 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { isCliFailure } from "../errors/failures.js";
3
+ const DAY = 86_400_000;
4
+ export const parseCalendarCursor = (value) => {
5
+ if (value === null)
6
+ return null;
7
+ try {
8
+ const parsed = JSON.parse(value);
9
+ if (parsed?.v !== 1 ||
10
+ (parsed.token !== null && typeof parsed.token !== "string") ||
11
+ typeof parsed.fullAt !== "number" ||
12
+ typeof parsed.window?.from !== "number" ||
13
+ typeof parsed.window.to !== "number")
14
+ return null;
15
+ return { v: 1, token: parsed.token, window: parsed.window, fullAt: parsed.fullAt };
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ };
21
+ /**
22
+ * One year back and three months ahead (NEED-1706), from the start of today in UTC. Whole days, so the window moves
23
+ * once a day: a source whose cursor holds the window lists everything again once a day, not on every run.
24
+ */
25
+ export const calendarSyncWindow = (now) => {
26
+ const day = new Date(now);
27
+ const [year, month, date] = [day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate()];
28
+ return { from: Date.UTC(year - 1, month, date), to: Date.UTC(year, month + 3, date) };
29
+ };
30
+ const roundOf = async (source, store, calendar, cursor, window, now) => {
31
+ const round = { token: null, saved: 0, removed: 0, series: [] };
32
+ const seen = new Set();
33
+ let page;
34
+ for (;;) {
35
+ const result = await source.changes(calendar, { cursor, window, ...(page === undefined ? {} : { page }) });
36
+ if ("reset" in result)
37
+ return "reset";
38
+ if (result.entries.length) {
39
+ await store.saveEntries(calendar.id, result.entries, now);
40
+ round.saved += result.entries.length;
41
+ for (const entry of result.entries)
42
+ if (entry.kind === "series")
43
+ round.series.push(entry);
44
+ }
45
+ if (result.removed.length)
46
+ round.removed += await store.removeEntries(calendar.id, result.removed, now);
47
+ if (result.nextPage === undefined) {
48
+ round.token = result.cursor ?? null;
49
+ return round;
50
+ }
51
+ if (seen.has(result.nextPage))
52
+ throw new CliError("invalid_response", `${source.provider} returned the same page twice`);
53
+ seen.add(result.nextPage);
54
+ page = result.nextPage;
55
+ }
56
+ };
57
+ const occurrencesOf = async (source, store, calendar, series, window, now, expand) => {
58
+ if (source.traits.expands === "in-changes")
59
+ return 0;
60
+ let saved = 0;
61
+ for (const one of series) {
62
+ if (one.status === "cancelled")
63
+ continue;
64
+ const slots = source.traits.expands === "local"
65
+ ? await expand(one, window)
66
+ : await source.occurrences(calendar, one.externalId, window);
67
+ if (!slots.length)
68
+ continue;
69
+ await store.saveEntries(calendar.id, slots, now);
70
+ saved += slots.length;
71
+ }
72
+ return saved;
73
+ };
74
+ const syncOne = async (source, store, calendar, { full: asked, window, now, expand }) => {
75
+ const stored = parseCalendarCursor(await store.cursor(calendar.id));
76
+ const windowMoved = stored !== null && (stored.window.from !== window.from || stored.window.to !== window.to);
77
+ // A full round once a day also asks every series for its slots again, which an incremental round does only for
78
+ // the series that changed (foundation §6.2 step 7).
79
+ let full = asked ||
80
+ source.traits.fullListOnly ||
81
+ stored === null ||
82
+ stored.token === null ||
83
+ (source.traits.cursorHoldsWindow && windowMoved) ||
84
+ now - stored.fullAt >= DAY;
85
+ let reset = false;
86
+ let round = await roundOf(source, store, calendar, full ? null : (stored?.token ?? null), window, now);
87
+ if (round === "reset") {
88
+ if (full)
89
+ throw new CliError("invalid_response", `${source.provider} refused a full list as a dead cursor`);
90
+ reset = true;
91
+ full = true;
92
+ await store.setCursor(calendar.id, null, now);
93
+ round = await roundOf(source, store, calendar, null, window, now);
94
+ if (round === "reset")
95
+ throw new CliError("invalid_response", `${source.provider} refused a full list as a dead cursor`);
96
+ }
97
+ const occurrences = await occurrencesOf(source, store, calendar, round.series, window, now, expand);
98
+ const unseen = full ? await store.markUnseen(calendar.id, now, window, now) : 0;
99
+ const cursor = { v: 1, token: round.token, window, fullAt: full ? now : (stored?.fullAt ?? now) };
100
+ await store.setCursor(calendar.id, JSON.stringify(cursor), now);
101
+ return {
102
+ calendarId: calendar.id,
103
+ externalId: calendar.externalId,
104
+ title: calendar.title,
105
+ round: full ? "full" : "changes",
106
+ reset,
107
+ saved: round.saved,
108
+ removed: round.removed,
109
+ occurrences,
110
+ unseen,
111
+ };
112
+ };
113
+ /**
114
+ * Brings one calendar account's synced calendars up to date (foundation §6.2): the calendar list first, then for
115
+ * each synced calendar its changes since the cursor, or a full list of the window, then the slots of the series that
116
+ * changed. Each calendar's cursor is saved last, only when all of it saved; a calendar that fails keeps its old one
117
+ * and the others go on.
118
+ */
119
+ export const syncCalendars = async (source, store, options) => {
120
+ const now = options.now ?? Date.now();
121
+ const window = options.window ?? calendarSyncWindow(now);
122
+ if (source.traits.expands === "local" && !options.expand)
123
+ throw new CliError("configuration_error", `${source.provider} needs a local expander for its repeating events`);
124
+ if (source.traits.expands === "on-request" && !source.occurrences)
125
+ throw new CliError("configuration_error", `${source.provider} expands on request but has no occurrences()`);
126
+ const listed = await store.saveCalendars(options.accountId, await source.calendars(), now);
127
+ const report = {
128
+ provider: source.provider,
129
+ accountId: options.accountId,
130
+ window,
131
+ calendars: listed.length,
132
+ synced: [],
133
+ notSynced: [],
134
+ complete: true,
135
+ };
136
+ for (const calendar of listed) {
137
+ if (!calendar.synced) {
138
+ report.notSynced.push({ calendarId: calendar.id, externalId: calendar.externalId, title: calendar.title });
139
+ continue;
140
+ }
141
+ try {
142
+ report.synced.push(await syncOne(source, store, calendar, {
143
+ full: options.full === true,
144
+ window,
145
+ now,
146
+ ...(options.expand ? { expand: options.expand } : {}),
147
+ }));
148
+ }
149
+ catch (error) {
150
+ report.complete = false;
151
+ report.synced.push({
152
+ calendarId: calendar.id,
153
+ externalId: calendar.externalId,
154
+ title: calendar.title,
155
+ round: options.full ? "full" : "changes",
156
+ reset: false,
157
+ saved: 0,
158
+ removed: 0,
159
+ occurrences: 0,
160
+ unseen: 0,
161
+ // An adapter's own failure says what went wrong; anything else may carry a request URL with a token in it.
162
+ error: isCliFailure(error) ? error.message : "the calendar could not be synced",
163
+ });
164
+ }
165
+ }
166
+ return report;
167
+ };
@@ -0,0 +1,5 @@
1
+ export { CONTRACT_NOW, type EventStoreFixture, eventStoreContract } from "../../meetings/testing/index.js";
2
+ export { memoryEventStore } from "./memory.js";
3
+ export { type FakeCalendarSource, type FakeCalendarSourceOptions, fakeCalendarSource } from "./source.js";
4
+ export { type CalendarSourceFixture, calendarSourceContract } from "./source-contract.js";
5
+ export { CALENDAR_TRAIT_COMBINATIONS, type CalendarSyncFixture, calendarSyncContract } from "./sync-contract.js";
@@ -0,0 +1,5 @@
1
+ export { CONTRACT_NOW, eventStoreContract } from "../../meetings/testing/index.js";
2
+ export { memoryEventStore } from "./memory.js";
3
+ export { fakeCalendarSource } from "./source.js";
4
+ export { calendarSourceContract } from "./source-contract.js";
5
+ export { CALENDAR_TRAIT_COMBINATIONS, calendarSyncContract } from "./sync-contract.js";
@@ -0,0 +1,6 @@
1
+ import type { EventStore } from "../../meetings/index.js";
2
+ /**
3
+ * An `EventStore` in memory, for CLI and engine tests: it passes `eventStoreContract`. Rule 4 (an entry adopting a
4
+ * Zoom meeting's event by its room) needs meetings, which this store does not hold; a person is one contact.
5
+ */
6
+ export declare const memoryEventStore: () => EventStore;
@@ -0,0 +1,478 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { calendarText, joinKeyOf, joinUrlWithoutQuery } from "../../meetings/index.js";
3
+ const TRACKED = ["title", "startsAt", "endsAt", "location", "status", "recurrence", "recurrenceEndsAt"];
4
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
5
+ /**
6
+ * An `EventStore` in memory, for CLI and engine tests: it passes `eventStoreContract`. Rule 4 (an entry adopting a
7
+ * Zoom meeting's event by its room) needs meetings, which this store does not hold; a person is one contact.
8
+ */
9
+ export const memoryEventStore = () => {
10
+ const calendars = [];
11
+ const entries = [];
12
+ const events = new Map();
13
+ const series = new Map();
14
+ const revisions = [];
15
+ let participants = [];
16
+ let attachments = [];
17
+ const reviews = [];
18
+ const cursors = new Map();
19
+ const unlinked = new Set();
20
+ const contacts = new Map();
21
+ let nextId = 1;
22
+ const calendarOf = (id) => {
23
+ const calendar = calendars.find((one) => one.id === id);
24
+ if (!calendar)
25
+ throw new CliError("not_found", `no calendar ${id} in the local store`);
26
+ return calendar;
27
+ };
28
+ const contactOf = (email) => {
29
+ const address = email.trim().toLowerCase();
30
+ if (!contacts.has(address))
31
+ contacts.set(address, nextId++);
32
+ return contacts.get(address);
33
+ };
34
+ const stored = ({ updatedAt: _, ...entry }) => ({ ...entry });
35
+ const entityOf = (entry) => (entry.kind === "series" ? entry.eventSeriesId : entry.eventId);
36
+ const setEntity = (entry, to, now) => {
37
+ if (entry.kind === "series")
38
+ entry.eventSeriesId = to;
39
+ else
40
+ entry.eventId = to;
41
+ entry.updatedAt = now;
42
+ };
43
+ const revise = (entry, now) => revisions.push({
44
+ entryId: entry.id,
45
+ title: entry.title,
46
+ startsAt: entry.startsAt,
47
+ endsAt: entry.endsAt,
48
+ location: entry.location,
49
+ status: entry.status,
50
+ recurrence: entry.recurrence,
51
+ recurrenceEndsAt: entry.recurrenceEndsAt,
52
+ externalUpdatedAt: entry.externalUpdatedAt,
53
+ createdAt: now,
54
+ });
55
+ const candidatesFor = (entry) => {
56
+ if (!entry.icalUid)
57
+ return [];
58
+ const ids = entries
59
+ .filter((one) => one.icalUid === entry.icalUid &&
60
+ one.calendarId !== entry.calendarId &&
61
+ one.kind === entry.kind &&
62
+ one.deletedAt === null &&
63
+ entityOf(one) !== null &&
64
+ one.originalStartsAt === entry.originalStartsAt)
65
+ .map((one) => entityOf(one));
66
+ return [...new Set(ids)].sort((a, b) => a - b);
67
+ };
68
+ const link = (entry, now) => {
69
+ if (entityOf(entry) !== null || unlinked.has(entry.id))
70
+ return;
71
+ const ids = candidatesFor(entry);
72
+ if (ids.length > 1) {
73
+ const entity = entry.kind === "series" ? "event_series" : "event";
74
+ const open = reviews.find((one) => one.linkableType === "calendar_entry" && one.linkableId === entry.id && one.entityType === entity);
75
+ if (!open)
76
+ reviews.push({
77
+ id: nextId++,
78
+ linkableType: "calendar_entry",
79
+ linkableId: entry.id,
80
+ entityType: entity,
81
+ candidates: ids,
82
+ reason: "ical-uid",
83
+ status: "open",
84
+ chosenId: null,
85
+ decidedBy: null,
86
+ decidedAt: null,
87
+ createdAt: now,
88
+ });
89
+ else if (open.status === "open")
90
+ open.candidates = ids;
91
+ return;
92
+ }
93
+ if (ids.length === 1)
94
+ return setEntity(entry, ids[0], now);
95
+ if (entry.status === "cancelled")
96
+ return;
97
+ setEntity(entry, newEntity(entry, now), now);
98
+ };
99
+ const newEntity = (entry, now) => {
100
+ const id = nextId++;
101
+ if (entry.kind === "series")
102
+ series.set(id, { id, title: entry.title, recurrence: entry.recurrence });
103
+ else
104
+ events.set(id, {
105
+ id,
106
+ eventSeriesId: null,
107
+ title: entry.title,
108
+ description: null,
109
+ location: null,
110
+ startsAt: entry.startsAt,
111
+ endsAt: entry.endsAt,
112
+ timezone: entry.timezone,
113
+ origin: "calendar",
114
+ deletedAt: null,
115
+ status: null,
116
+ allDay: false,
117
+ sourceDeletedAt: null,
118
+ createdAt: now,
119
+ updatedAt: now,
120
+ });
121
+ return id;
122
+ };
123
+ const hasText = (entry) => `${entry.title ?? ""}${entry.description ?? ""}${entry.location ?? ""}`.trim() !== "";
124
+ const own = (entry) => participants.some((p) => p.entryId === entry.id && p.self && p.organizer);
125
+ const refreshEvent = (eventId, now) => {
126
+ const event = events.get(eventId);
127
+ if (!event)
128
+ return;
129
+ const live = entries
130
+ .filter((one) => one.eventId === eventId && one.deletedAt === null)
131
+ .sort((a, b) => Number(hasText(b)) - Number(hasText(a)) ||
132
+ Number(own(b)) - Number(own(a)) ||
133
+ (b.externalUpdatedAt ?? b.updatedAt) - (a.externalUpdatedAt ?? a.updatedAt) ||
134
+ b.id - a.id);
135
+ const preferred = live[0];
136
+ if (!preferred) {
137
+ if (!entries.some((one) => one.eventId === eventId))
138
+ return;
139
+ if (event.startsAt !== null && event.startsAt < now)
140
+ event.sourceDeletedAt ??= now;
141
+ else
142
+ event.status = "cancelled";
143
+ event.updatedAt = now;
144
+ return;
145
+ }
146
+ const cancelled = live.every((one) => one.status === "cancelled");
147
+ const startsAt = preferred.startsAt ?? event.startsAt;
148
+ const past = startsAt !== null && startsAt < now;
149
+ const seriesEntry = preferred.seriesEntryId === null ? null : entries.find((one) => one.id === preferred.seriesEntryId);
150
+ if (!(cancelled && past))
151
+ Object.assign(event, {
152
+ title: preferred.title,
153
+ description: preferred.description,
154
+ location: preferred.location,
155
+ startsAt: preferred.startsAt,
156
+ endsAt: preferred.endsAt,
157
+ });
158
+ event.timezone = preferred.timezone;
159
+ event.allDay = preferred.allDay;
160
+ event.status = cancelled ? (past ? event.status : "cancelled") : preferred.status;
161
+ event.sourceDeletedAt = cancelled && past ? (event.sourceDeletedAt ?? now) : null;
162
+ event.eventSeriesId = seriesEntry?.eventSeriesId ?? event.eventSeriesId;
163
+ event.updatedAt = now;
164
+ };
165
+ const refreshSeries = (seriesId) => {
166
+ const preferred = entries
167
+ .filter((one) => one.eventSeriesId === seriesId && one.deletedAt === null)
168
+ .sort((a, b) => (b.externalUpdatedAt ?? b.updatedAt) - (a.externalUpdatedAt ?? a.updatedAt) || b.id - a.id)[0];
169
+ const held = series.get(seriesId);
170
+ if (preferred && held)
171
+ Object.assign(held, { title: preferred.title, recurrence: preferred.recurrence });
172
+ };
173
+ const refreshAround = (ids, now) => {
174
+ const touched = entries.filter((one) => ids.includes(one.id) || (one.seriesEntryId !== null && ids.includes(one.seriesEntryId)));
175
+ for (const one of touched) {
176
+ if (one.eventSeriesId !== null)
177
+ refreshSeries(one.eventSeriesId);
178
+ if (one.eventId !== null)
179
+ refreshEvent(one.eventId, now);
180
+ }
181
+ };
182
+ const cancel = (entry, now) => {
183
+ revise(entry, now);
184
+ entry.status = "cancelled";
185
+ entry.updatedAt = now;
186
+ };
187
+ const cancelOccurrences = (seriesEntryId, now) => {
188
+ for (const one of entries)
189
+ if (one.seriesEntryId === seriesEntryId && one.status !== "cancelled" && one.deletedAt === null)
190
+ cancel(one, now);
191
+ };
192
+ const existingFor = (calendarId, input, seriesEntryId) => {
193
+ const found = entries.find((one) => one.calendarId === calendarId && one.externalId === input.externalId);
194
+ if (found || input.kind !== "occurrence" || input.originalStartsAt == null)
195
+ return found;
196
+ return entries.find((one) => seriesEntryId !== null
197
+ ? one.seriesEntryId === seriesEntryId && one.originalStartsAt === input.originalStartsAt
198
+ : one.calendarId === calendarId &&
199
+ one.seriesEntryId === null &&
200
+ one.originalStartsAt === input.originalStartsAt &&
201
+ one.seriesExternalId === (input.seriesExternalId ?? null));
202
+ };
203
+ const saveEntry = (calendarId, input, now) => {
204
+ if (input.startsAt != null && input.endsAt != null && input.endsAt < input.startsAt)
205
+ throw new CliError("validation_error", `calendar entry ${input.externalId} ends before it starts`);
206
+ const seriesEntry = input.kind === "occurrence" && input.seriesExternalId
207
+ ? entries.find((one) => one.calendarId === calendarId && one.externalId === input.seriesExternalId && one.kind === "series")
208
+ : undefined;
209
+ const seriesEntryId = seriesEntry?.id ?? null;
210
+ const joinUrl = joinUrlWithoutQuery(input.joinUrl);
211
+ const values = {
212
+ kind: input.kind,
213
+ seriesEntryId,
214
+ seriesExternalId: input.kind === "occurrence" ? (input.seriesExternalId ?? null) : null,
215
+ externalId: input.externalId,
216
+ icalUid: input.icalUid ?? null,
217
+ originalStartsAt: input.originalStartsAt ?? null,
218
+ exception: input.exception === true,
219
+ status: input.status,
220
+ title: calendarText(input.title, { dropSecretLines: false }),
221
+ description: calendarText(input.description),
222
+ location: calendarText(input.location),
223
+ startsAt: input.startsAt ?? null,
224
+ endsAt: input.endsAt ?? null,
225
+ allDay: input.allDay === true,
226
+ startDate: input.startDate ?? null,
227
+ endDate: input.endDate ?? null,
228
+ timezone: input.timezone ?? null,
229
+ recurrence: input.recurrence ?? null,
230
+ recurrenceEndsAt: input.recurrenceEndsAt ?? null,
231
+ eventType: input.eventType ?? null,
232
+ ownResponse: input.ownResponse ?? null,
233
+ organizerIdentityId: input.organizer ? contactOf(input.organizer.email) : null,
234
+ joinUrl,
235
+ joinKey: joinUrl ? (joinKeyOf(joinUrl) ?? "") : null,
236
+ url: input.url ?? null,
237
+ externalUpdatedAt: input.externalUpdatedAt ?? null,
238
+ seenAt: now,
239
+ deletedAt: null,
240
+ updatedAt: now,
241
+ };
242
+ let entry = existingFor(calendarId, input, seriesEntryId);
243
+ if (entry) {
244
+ const found = entry;
245
+ if (TRACKED.some((key) => !same(found[key], values[key])))
246
+ revise(found, now);
247
+ Object.assign(found, values);
248
+ }
249
+ else {
250
+ entry = { id: nextId++, calendarId, eventId: null, eventSeriesId: null, ...values };
251
+ entries.push(entry);
252
+ }
253
+ const id = entry.id;
254
+ if (input.participants) {
255
+ const kept = new Map();
256
+ for (const one of input.participants) {
257
+ const email = one.email.trim().toLowerCase();
258
+ if (!email || kept.has(email))
259
+ continue;
260
+ kept.set(email, {
261
+ entryId: id,
262
+ identityId: one.resource ? null : contactOf(email),
263
+ email,
264
+ name: one.name ?? null,
265
+ response: one.response ?? null,
266
+ optional: one.optional === true,
267
+ organizer: one.organizer === true,
268
+ self: one.self === true,
269
+ resource: one.resource === true,
270
+ });
271
+ }
272
+ participants = [
273
+ ...participants.filter((p) => p.entryId !== id),
274
+ ...[...kept.values()].sort((a, b) => a.email.localeCompare(b.email)),
275
+ ];
276
+ }
277
+ if (input.attachments) {
278
+ const files = [...new Set(input.attachments.map(({ fileId }) => fileId.trim()).filter(Boolean))];
279
+ attachments = [
280
+ ...attachments.filter((a) => a.entryId !== id),
281
+ ...files.map((fileId) => ({
282
+ entryId: id,
283
+ fileId,
284
+ title: input.attachments?.find((a) => a.fileId.trim() === fileId)?.title ?? null,
285
+ documentId: null,
286
+ })),
287
+ ];
288
+ }
289
+ if (input.kind === "series")
290
+ for (const one of entries)
291
+ if (one.calendarId === calendarId &&
292
+ one.kind === "occurrence" &&
293
+ one.seriesEntryId === null &&
294
+ one.seriesExternalId === input.externalId)
295
+ one.seriesEntryId = id;
296
+ return entry;
297
+ };
298
+ const entryOf = (id) => {
299
+ const entry = entries.find((one) => one.id === id);
300
+ if (!entry)
301
+ throw new CliError("not_found", `no calendar entry ${id} in the local store`);
302
+ return entry;
303
+ };
304
+ return {
305
+ async saveCalendars(accountId, list, now) {
306
+ return list.map((one) => {
307
+ let calendar = calendars.find((held) => held.accountId === accountId && held.externalId === one.externalId);
308
+ if (!calendar) {
309
+ calendar = {
310
+ id: nextId++,
311
+ accountId,
312
+ externalId: one.externalId,
313
+ title: null,
314
+ timezone: null,
315
+ accessRole: null,
316
+ primary: false,
317
+ synced: one.syncByDefault,
318
+ seenAt: null,
319
+ deletedAt: null,
320
+ };
321
+ calendars.push(calendar);
322
+ }
323
+ Object.assign(calendar, {
324
+ title: one.title,
325
+ timezone: one.timezone,
326
+ accessRole: one.accessRole,
327
+ primary: one.primary,
328
+ seenAt: now,
329
+ deletedAt: null,
330
+ });
331
+ return { ...calendar };
332
+ });
333
+ },
334
+ async calendars(accountId) {
335
+ return calendars
336
+ .filter((one) => one.accountId === accountId)
337
+ .sort((a, b) => Number(b.primary) - Number(a.primary) || (a.title ?? "").localeCompare(b.title ?? "") || a.id - b.id)
338
+ .map((one) => ({ ...one }));
339
+ },
340
+ async setCalendarSynced(calendarId, synced) {
341
+ calendarOf(calendarId).synced = synced;
342
+ },
343
+ async saveEntries(calendarId, list, now) {
344
+ calendarOf(calendarId);
345
+ for (const input of list)
346
+ if (input.startsAt != null && input.endsAt != null && input.endsAt < input.startsAt)
347
+ throw new CliError("validation_error", `calendar entry ${input.externalId} ends before it starts`);
348
+ const ordered = [...list].sort((a, b) => Number(b.kind === "series") - Number(a.kind === "series"));
349
+ const saved = new Map();
350
+ for (const input of ordered)
351
+ saved.set(input, saveEntry(calendarId, input, now));
352
+ for (const entry of saved.values())
353
+ if (entry.kind === "series" && entry.status === "cancelled")
354
+ cancelOccurrences(entry.id, now);
355
+ for (const entry of saved.values())
356
+ link(entry, now);
357
+ refreshAround([...saved.values()].map(({ id }) => id), now);
358
+ return list.map((input) => stored(saved.get(input)));
359
+ },
360
+ async removeEntries(calendarId, externalIds, now) {
361
+ calendarOf(calendarId);
362
+ const removed = entries.filter((one) => one.calendarId === calendarId &&
363
+ externalIds.includes(one.externalId) &&
364
+ one.status !== "cancelled" &&
365
+ one.deletedAt === null);
366
+ for (const one of removed) {
367
+ cancel(one, now);
368
+ one.seenAt = now;
369
+ if (one.kind === "series")
370
+ cancelOccurrences(one.id, now);
371
+ }
372
+ refreshAround(removed.map(({ id }) => id), now);
373
+ return removed.length;
374
+ },
375
+ async markUnseen(calendarId, seenBefore, window, now) {
376
+ const marked = entries.filter((one) => one.calendarId === calendarId &&
377
+ one.deletedAt === null &&
378
+ (one.seenAt === null || one.seenAt < seenBefore) &&
379
+ (one.kind !== "occurrence" ||
380
+ one.exception ||
381
+ (one.originalStartsAt !== null && one.originalStartsAt >= window.from && one.originalStartsAt < window.to)));
382
+ for (const one of marked)
383
+ Object.assign(one, { deletedAt: now, updatedAt: now });
384
+ for (const eventId of new Set(marked.map(({ eventId }) => eventId)))
385
+ if (eventId !== null)
386
+ refreshEvent(eventId, now);
387
+ return marked.length;
388
+ },
389
+ async cursor(calendarId) {
390
+ return cursors.get(calendarId) ?? null;
391
+ },
392
+ async setCursor(calendarId, value) {
393
+ calendarOf(calendarId);
394
+ if (value === null)
395
+ cursors.delete(calendarId);
396
+ else
397
+ cursors.set(calendarId, value);
398
+ },
399
+ async events(filter = {}) {
400
+ const limit = filter.limit ?? 100;
401
+ const offset = filter.offset ?? 0;
402
+ if (!Number.isInteger(limit) || limit < 1 || limit > 1000 || !Number.isInteger(offset) || offset < 0)
403
+ throw new CliError("validation_error", "limit takes 1–1000 and offset 0 or more");
404
+ const has = (event, check) => entries.some((one) => one.eventId === event.id && check(one));
405
+ return [...events.values()]
406
+ .filter((event) => event.deletedAt === null &&
407
+ (filter.from === undefined || (event.endsAt ?? (event.startsAt ?? 0) + 1) > filter.from) &&
408
+ (filter.to === undefined || (event.startsAt !== null && event.startsAt < filter.to)) &&
409
+ (filter.calendarId === undefined || has(event, (one) => one.calendarId === filter.calendarId)) &&
410
+ (filter.accountId === undefined ||
411
+ has(event, (one) => calendarOf(one.calendarId).accountId === filter.accountId)) &&
412
+ (filter.personId === undefined ||
413
+ has(event, (one) => participants.some((p) => p.entryId === one.id && p.identityId === filter.personId))))
414
+ .sort((a, b) => ((a.startsAt ?? -1) - (b.startsAt ?? -1) || a.id - b.id) * (filter.newest ? -1 : 1))
415
+ .slice(offset, offset + limit)
416
+ .map((event) => ({ ...event }));
417
+ },
418
+ async event(id) {
419
+ const event = events.get(id);
420
+ if (!event)
421
+ return null;
422
+ const held = entries.filter((one) => one.eventId === id).sort((a, b) => a.id - b.id);
423
+ const ids = new Set(held.map(({ id }) => id));
424
+ return {
425
+ event: { ...event },
426
+ entries: held.map(stored),
427
+ participants: participants
428
+ .filter((p) => ids.has(p.entryId))
429
+ .sort((a, b) => a.entryId - b.entryId || a.email.localeCompare(b.email))
430
+ .map((p) => ({ ...p })),
431
+ revisions: revisions.filter((r) => ids.has(r.entryId)).map((r) => ({ ...r })),
432
+ attachments: attachments.filter((a) => ids.has(a.entryId)).map((a) => ({ ...a })),
433
+ };
434
+ },
435
+ async linkEntry(entryId, eventId, now) {
436
+ const entry = entryOf(entryId);
437
+ if (!(entry.kind === "series" ? series.has(eventId) : events.has(eventId)))
438
+ throw new CliError("not_found", `no ${entry.kind === "series" ? "event series" : "event"} ${eventId}`);
439
+ unlinked.delete(entryId);
440
+ setEntity(entry, eventId, now);
441
+ if (entry.kind !== "series")
442
+ refreshEvent(eventId, now);
443
+ },
444
+ async unlinkEntry(entryId, now) {
445
+ const entry = entryOf(entryId);
446
+ unlinked.add(entryId);
447
+ setEntity(entry, null, now);
448
+ },
449
+ async reviews(filter = {}) {
450
+ return reviews
451
+ .filter((one) => !filter.status || one.status === filter.status)
452
+ .map((one) => ({ ...one, candidates: [...one.candidates] }));
453
+ },
454
+ async resolveReview(id, decision, now) {
455
+ const review = reviews.find((one) => one.id === id);
456
+ if (!review)
457
+ throw new CliError("not_found", `no review ${id} in the local store`);
458
+ if (review.status !== "open")
459
+ throw new CliError("validation_error", `review ${id} is already ${review.status}`);
460
+ if ("choose" in decision && !review.candidates.includes(decision.choose))
461
+ throw new CliError("validation_error", `${decision.choose} is not one of review ${id}'s candidates`);
462
+ const chosen = "choose" in decision ? decision.choose : null;
463
+ if (review.linkableType === "calendar_entry") {
464
+ const entry = entryOf(review.linkableId);
465
+ setEntity(entry, chosen ?? newEntity(entry, now), now);
466
+ if (chosen !== null && review.entityType === "event")
467
+ refreshEvent(chosen, now);
468
+ }
469
+ Object.assign(review, {
470
+ status: chosen === null ? "apart" : "linked",
471
+ chosenId: chosen,
472
+ decidedBy: "owner",
473
+ decidedAt: now,
474
+ });
475
+ return { ...review, candidates: [...review.candidates] };
476
+ },
477
+ };
478
+ };