@wirecat/cli-core 0.488.0 → 0.489.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 (35) hide show
  1. package/CHANGELOG.md +97 -1
  2. package/dist/calendar/cli/index.js +48 -13
  3. package/dist/calendar/index.d.ts +1 -1
  4. package/dist/calendar/mcp/index.d.ts +390 -0
  5. package/dist/calendar/mcp/index.js +50 -12
  6. package/dist/calendar/reads.d.ts +9 -2
  7. package/dist/calendar/reads.js +60 -5
  8. package/dist/calendar/recurrence.d.ts +20 -0
  9. package/dist/calendar/recurrence.js +74 -0
  10. package/dist/calendar/sync.d.ts +6 -0
  11. package/dist/calendar/sync.js +123 -41
  12. package/dist/calendar/testing/memory.js +90 -7
  13. package/dist/calendar/testing/sync-contract.js +90 -0
  14. package/dist/calendar/testing/writer-contract.d.ts +2 -6
  15. package/dist/calendar/testing/writer-contract.js +157 -29
  16. package/dist/calendar/testing/writer.d.ts +3 -1
  17. package/dist/calendar/testing/writer.js +11 -0
  18. package/dist/calendar/writer.d.ts +20 -3
  19. package/dist/calendar/writer.js +8 -4
  20. package/dist/calendar/writes.d.ts +21 -12
  21. package/dist/calendar/writes.js +259 -20
  22. package/dist/errors/reasons.d.ts +1 -1
  23. package/dist/errors/reasons.js +5 -0
  24. package/dist/meetings/event-store.d.ts +80 -2
  25. package/dist/meetings/event-store.js +65 -1
  26. package/dist/meetings/index.d.ts +2 -1
  27. package/dist/meetings/index.js +1 -0
  28. package/dist/meetings/testing/event-store-contract.d.ts +1 -1
  29. package/dist/meetings/testing/event-store-contract.js +114 -0
  30. package/dist/store/drivers/node-sqlite.d.ts +9 -1
  31. package/dist/store/drivers/node-sqlite.js +30 -8
  32. package/dist/store/sqlite/calendar-events.d.ts +4 -0
  33. package/dist/store/sqlite/calendar-events.js +89 -0
  34. package/dist/store/sqlite/calendar.js +126 -94
  35. package/package.json +1 -1
@@ -6,7 +6,7 @@ import { guardedWrite } from "../sends/guarded.js";
6
6
  import { DEFAULT_PERMISSIONS, levelFor } from "../sends/permissions.js";
7
7
  import { newOperationId } from "../sends/send-id.js";
8
8
  import { calendarIn, entryItem, eventIdOf, eventOf, plusDays } from "./reads.js";
9
- import { kindBoundLines, repeatRule } from "./recurrence.js";
9
+ import { kindBoundLines, repeatRule, ruleCount, ruleEnd, ruleEndingBefore, ruleLine, splitDates } from "./recurrence.js";
10
10
  import { CALENDAR_WRITE_DEFAULTS, EVENTS_DELETE_SERIES_KEY, EVENTS_NOTIFY_KEY, } from "./writer.js";
11
11
  export const EVENT_RESPONSES = ["accepted", "tentative", "declined"];
12
12
  /** Each write is checked by its command's path, the key a declared CLI's `commandPermissions` already knows. */
@@ -15,6 +15,7 @@ const COMMAND_KEY = {
15
15
  update: "events.update",
16
16
  delete: "events.delete",
17
17
  respond: "events.respond",
18
+ move: "events.move",
18
19
  };
19
20
  const DATE = /^\d{4}-\d{2}-\d{2}$/u;
20
21
  const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u;
@@ -150,27 +151,44 @@ const answerOf = (operationId, saved, repeats = false) => ({
150
151
  /** An id every provider takes as a client-chosen one: Google's base32hex `[a-v0-9]`, a CalDAV UID, a Graph id. */
151
152
  export const newEventId = () => randomUUID().replaceAll("-", "");
152
153
  const ID = /^[a-v0-9]{5,1024}$/u;
153
- export const createEvent = async (context, calendarReference, fields, options = {}) => {
154
- const calendar = await calendarIn(context.store, context.accountId, calendarReference);
155
- const draft = draftOf(fields, context.zone, { whole: true });
156
- if (options.id !== undefined && !ID.test(options.id))
157
- throw new CliError("validation_error", `${context.names.id} is the id a create's outcome_unknown named: 5 to 1024 of a-v and 0-9`, {
158
- param: context.names.id,
159
- });
160
- const notify = notifyOf(context, options.notify, (draft.attendees?.length ?? 0) > 0);
161
- const id = options.id ?? newEventId();
162
- const guard = writeGuard(context, keysFor(context, "create", notify));
163
- let entry;
154
+ const idOf = (context, given) => {
155
+ if (given !== undefined && !ID.test(given))
156
+ throw new CliError("validation_error", `${context.names.id} is the id a create's outcome_unknown named: 5 to 1024 of a-v and 0-9`, { param: context.names.id });
157
+ return given ?? newEventId();
158
+ };
159
+ /** A create whose answer was lost names its id, so the owner's retry finds the event instead of making another. */
160
+ const retryable = async (context, id, write) => {
164
161
  try {
165
- entry = await guardedWrite(guard, { chatId: null, kind: "edit", key: COMMAND_KEY.create, operationId: id, sendId: id, notify }, () => context.writes.writer.create(calendar, draft, { id, notify }));
162
+ return await write();
166
163
  }
167
164
  catch (error) {
168
165
  if (isCliError(error) && error.code === "outcome_unknown")
169
- throw new CliError("outcome_unknown", `${error.message} — repeat the create with ${context.names.id} ${id}: the provider then finds the event ` +
166
+ throw new CliError("outcome_unknown", `${error.message} — repeat with ${context.names.id} ${id}: the provider then finds the event ` +
170
167
  "instead of making a second one", { ...error.details, operation_id: id });
171
168
  throw error;
172
169
  }
173
- const [saved] = await context.store.saveEntries(calendar.id, [entry], context.now);
170
+ };
171
+ /**
172
+ * The store's save of what the provider already made. A failure here names the id: without it the owner's retry makes
173
+ * the event a second time.
174
+ */
175
+ const savedAfterCreate = async (context, id, save) => {
176
+ try {
177
+ return await save();
178
+ }
179
+ catch (error) {
180
+ throw new CliError(isCliError(error) ? error.code : "invalid_response", `the provider made event ${id}, but the local store did not save it (${error instanceof Error ? error.message : String(error)}); ` +
181
+ `sync to store it — do not create it again: a create repeated with ${context.names.id} ${id} finds it`, { ...(isCliError(error) ? error.details : {}), operation_id: id, id });
182
+ }
183
+ };
184
+ export const createEvent = async (context, calendarReference, fields, options = {}) => {
185
+ const calendar = await calendarIn(context.store, context.accountId, calendarReference);
186
+ const draft = draftOf(fields, context.zone, { whole: true });
187
+ const id = idOf(context, options.id);
188
+ const notify = notifyOf(context, options.notify, (draft.attendees?.length ?? 0) > 0);
189
+ const guard = writeGuard(context, keysFor(context, "create", notify));
190
+ const entry = await retryable(context, id, () => guardedWrite(guard, { chatId: null, kind: "edit", key: COMMAND_KEY.create, operationId: id, sendId: id, notify }, () => context.writes.writer.create(calendar, draft, { id, notify })));
191
+ const [saved] = await savedAfterCreate(context, id, () => context.store.saveEntries(calendar.id, [entry], context.now));
174
192
  return answerOf(id, saved, draft.recurrence !== undefined);
175
193
  };
176
194
  const targetOf = async (context, reference, { calendar, scope }) => {
@@ -185,11 +203,10 @@ const targetOf = async (context, reference, { calendar, scope }) => {
185
203
  throw new CliError("validation_error", `${reference} is on calendars ${live.map(({ calendarId }) => calendarId).join(", ")}; name one with ` +
186
204
  `${context.names.calendar}`, { param: context.names.calendar });
187
205
  let target = entry;
188
- if (scope === "series") {
206
+ if (scope !== "this") {
207
+ const flag = scope === "series" ? context.names.series : context.names.following;
189
208
  if (entry.kind !== "occurrence" || !entry.seriesExternalId)
190
- throw new CliError("validation_error", `${reference} does not repeat; leave out ${context.names.series}`, {
191
- param: context.names.series,
192
- });
209
+ throw new CliError("validation_error", `${reference} does not repeat; leave out ${flag}`, { param: flag });
193
210
  const series = await context.store.entry(entry.calendarId, entry.seriesExternalId);
194
211
  if (!series)
195
212
  throw new CliError("not_found", `the series of ${reference} is not stored; sync and try again`);
@@ -207,7 +224,39 @@ const targetOf = async (context, reference, { calendar, scope }) => {
207
224
  participants: details.participants.filter(({ entryId }) => entryId === entry.id),
208
225
  };
209
226
  };
227
+ const scopeOf = (context, { series, following }) => {
228
+ if (series && following)
229
+ throw new CliError("validation_error", `choose ${context.names.series} or ${context.names.following}, not both`, {
230
+ param: context.names.following,
231
+ });
232
+ return following ? "following" : series ? "series" : "this";
233
+ };
234
+ /**
235
+ * A new COUNT or UNTIL that drops slots the series had deletes them as surely as a delete of the series does, so it
236
+ * takes that key too. A bound this cannot compare with the old one counts as shorter.
237
+ */
238
+ const shortens = (target, rule, zone) => {
239
+ // A date UNTIL takes in its whole day.
240
+ const endOf = (line, in_) => {
241
+ const end = ruleEnd(line, in_);
242
+ return end !== undefined && /UNTIL=\d{8}(?:;|$)/iu.test(line) ? end + 86_400_000 - 1 : end;
243
+ };
244
+ const [until, count] = [endOf(rule, zone), ruleCount(rule)];
245
+ if (until === undefined && count === undefined)
246
+ return false;
247
+ const before = ruleLine(target.recurrence ?? []);
248
+ const [wasUntil, wasCount] = before === undefined ? [] : [endOf(before, target.timezone ?? zone), ruleCount(before)];
249
+ if (until !== undefined)
250
+ return wasUntil === undefined || until < wasUntil;
251
+ return wasCount === undefined || Number(count) < Number(wasCount);
252
+ };
210
253
  export const updateEvent = async (context, reference, fields, options = {}) => {
254
+ if (scopeOf(context, options) === "following")
255
+ return updateFollowing(context, reference, fields, options);
256
+ if (options.id !== undefined)
257
+ throw new CliError("validation_error", `${context.names.id} goes with ${context.names.following} only`, {
258
+ param: context.names.id,
259
+ });
211
260
  const scope = options.series ? "series" : "this";
212
261
  if (fields.repeat !== undefined && scope !== "series")
213
262
  throw new CliError("validation_error", `a repeat rule belongs to the whole series: add ${context.names.series}`, {
@@ -226,12 +275,15 @@ export const updateEvent = async (context, reference, fields, options = {}) => {
226
275
  ? "change its kind in the calendar itself"
227
276
  : `give its rule again with ${context.names.repeat} and UNTIL as a ${target.allDay ? "UTC time" : "date"}`), { reason: "event.repeat-kind" });
228
277
  const notify = notifyOf(context, options.notify, hasOthers(participants) || (patch.attendees?.length ?? 0) > 0);
278
+ const ends = patch.recurrence !== undefined && shortens(target, patch.recurrence[0], context.zone);
229
279
  const operationId = newOperationId();
230
- const entry = await guardedWrite(writeGuard(context, keysFor(context, "update", notify)), { chatId: null, kind: "edit", key: COMMAND_KEY.update, operationId, notify }, () => context.writes.writer.update(calendar, target.externalId, patch, { scope, etag: target.etag, notify }));
280
+ const entry = await guardedWrite(writeGuard(context, [...keysFor(context, "update", notify), ...(ends ? [EVENTS_DELETE_SERIES_KEY] : [])]), { chatId: null, kind: "edit", key: COMMAND_KEY.update, operationId, notify }, () => context.writes.writer.update(calendar, target.externalId, patch, { scope, etag: target.etag, notify }));
231
281
  const [saved] = await context.store.saveEntries(calendar.id, [entry], context.now);
232
282
  return answerOf(operationId, saved, patch.recurrence !== undefined || (switching && scope === "series"));
233
283
  };
234
284
  export const deleteEvent = async (context, reference, options = {}) => {
285
+ if (scopeOf(context, options) === "following")
286
+ return deleteFollowing(context, reference, options);
235
287
  const scope = options.series ? "series" : "this";
236
288
  const { calendar, entry, target, participants } = await targetOf(context, reference, { ...options, scope });
237
289
  const notify = notifyOf(context, options.notify, hasOthers(participants));
@@ -261,3 +313,190 @@ export const respondToEvent = async (context, reference, response, options = {})
261
313
  const [saved] = await context.store.saveEntries(calendar.id, [entry], context.now);
262
314
  return answerOf(operationId, saved);
263
315
  };
316
+ const midnight = (date) => Date.parse(`${date}T00:00:00Z`);
317
+ const splitOf = (context, reference, { entry, target }) => {
318
+ if (entry.originalStartsAt === null)
319
+ throw new CliError("not_found", `the slot of ${reference} in its series is not stored; sync and try again`);
320
+ const at = entry.originalStartsAt;
321
+ const rule = ruleLine(target.recurrence ?? []);
322
+ if (!rule)
323
+ throw new CliError("validation_error", `the series of ${reference} repeats by single dates only; change it in the calendar itself`, { reason: "event.repeat-split" });
324
+ const zone = target.timezone ?? context.zone;
325
+ const end = ruleEnd(rule, zone);
326
+ const first = target.allDay
327
+ ? target.startDate !== null && at <= midnight(target.startDate)
328
+ : at <= (target.startsAt ?? 0);
329
+ return { at, first, rule, ended: end !== undefined && end < at, zone };
330
+ };
331
+ /** The old series' rule ending before the split, with the dates and other lines that belong before it. */
332
+ const endedRecurrence = (target, split) => [
333
+ ruleEndingBefore(split.rule, split.at, { allDay: target.allDay }),
334
+ ...(target.recurrence ?? []).filter((line) => !/^(RRULE|EXDATE|RDATE)[:;]/iu.test(line)),
335
+ ...splitDates(target.recurrence ?? [], split.at, split.zone).before,
336
+ ];
337
+ /** Each later write of a split carries what was done already, so the owner's retry completes it without a double. */
338
+ const afterCreate = async (context, id, write) => {
339
+ try {
340
+ return await write();
341
+ }
342
+ catch (error) {
343
+ if (!isCliError(error))
344
+ throw error;
345
+ // A lost answer may have ended the old series: after a sync this occurrence is gone, and a retry finds nothing.
346
+ const next = error.code === "outcome_unknown"
347
+ ? `sync: if the series now ends before this occurrence, the split is done; if not, repeat with ` +
348
+ `${context.names.id} ${id}, which finds the new series instead of making it again`
349
+ : `repeat with ${context.names.id} ${id}: the new series is found, not made again, and the old one ends`;
350
+ throw new CliError(error.code, `${error.message}; the new series ${id} is made — ${next}`, {
351
+ ...error.details,
352
+ operation_id: id,
353
+ });
354
+ }
355
+ };
356
+ /**
357
+ * "This and all following" from the writer's own writes, so every provider has it: neither RFC 5545 nor Google's API has
358
+ * such a change (https://developers.google.com/workspace/calendar/api/guides/recurringevents only warns against
359
+ * changing the instances one by one for it). The new series is made first, under an id journaled before the call, and then the old one ends before the slot:
360
+ * a conflict on the old series stops nothing being lost, and a retry with the id finds the new series. Until then
361
+ * later slots show twice, never none.
362
+ */
363
+ const updateFollowing = async (context, reference, fields, options) => {
364
+ const found = await targetOf(context, reference, { ...options, scope: "following" });
365
+ const { calendar, entry, target, participants } = found;
366
+ const split = splitOf(context, reference, found);
367
+ if (split.first && !split.ended) {
368
+ const { following: _following, id: _id, ...rest } = options;
369
+ return updateEvent(context, reference, fields, { ...rest, series: true });
370
+ }
371
+ const patch = draftOf(fields, context.zone, { whole: false, allDay: target.allDay });
372
+ if ((patch.startDate !== undefined && !target.allDay) || (patch.startsAt !== undefined && target.allDay))
373
+ throw new CliError("validation_error", `a switch between timed and all-day changes the whole series: use ${context.names.series}`, { param: context.names.series, reason: "event.repeat-kind" });
374
+ const count = ruleCount(split.rule);
375
+ if (count !== undefined && !patch.recurrence)
376
+ throw new CliError("validation_error", `the series repeats COUNT=${count} times, and only the provider knows how many come from this occurrence on; ` +
377
+ `give the new part its own rule with ${context.names.repeat}, ending with UNTIL or its own COUNT`, { param: context.names.repeat, reason: "event.repeat-count" });
378
+ if (split.ended && options.id === undefined)
379
+ throw new CliError("validation_error", `the series of ${reference} already ends before it; if a split ended in outcome_unknown, repeat it with ` +
380
+ `${context.names.id} and the id it named`, { param: context.names.id, reason: "event.repeat-split" });
381
+ const id = idOf(context, options.id);
382
+ const draft = {
383
+ title: patch.title ?? target.title ?? "",
384
+ ...(patch.startsAt !== undefined || patch.startDate !== undefined
385
+ ? { startsAt: patch.startsAt, endsAt: patch.endsAt, startDate: patch.startDate, endDate: patch.endDate }
386
+ : target.allDay
387
+ ? {
388
+ startDate: new Date(split.at).toISOString().slice(0, 10),
389
+ endDate: new Date(split.at + midnight(target.endDate) - midnight(target.startDate))
390
+ .toISOString()
391
+ .slice(0, 10),
392
+ }
393
+ : { startsAt: split.at, endsAt: split.at + (target.endsAt ?? 0) - (target.startsAt ?? 0) }),
394
+ // A repeating event needs its zone at Google; the caller's stands in for a series stored without one.
395
+ timezone: patch.timezone ?? target.timezone ?? context.zone,
396
+ location: patch.location === undefined ? target.location : patch.location,
397
+ description: patch.description === undefined ? target.description : patch.description,
398
+ attendees: patch.attendees ??
399
+ participants
400
+ .filter(({ self }) => !self)
401
+ .map(({ email, optional }) => ({ email, ...(optional ? { optional: true } : {}) })),
402
+ recurrence: [
403
+ patch.recurrence?.[0] ?? split.rule,
404
+ ...splitDates(target.recurrence ?? [], split.at, split.zone).after,
405
+ ],
406
+ };
407
+ for (const key of ["startsAt", "endsAt", "startDate", "endDate"])
408
+ if (draft[key] === undefined)
409
+ delete draft[key];
410
+ const notify = notifyOf(context, options.notify, hasOthers(participants) || (patch.attendees?.length ?? 0) > 0);
411
+ const guard = writeGuard(context, [
412
+ ...new Set([...keysFor(context, "create", notify), ...keysFor(context, "update", notify)]),
413
+ ]);
414
+ const made = await retryable(context, id, () => guardedWrite(guard, { chatId: null, kind: "edit", key: COMMAND_KEY.update, operationId: id, sendId: id, notify }, () => context.writes.writer.create(calendar, draft, { id, notify })));
415
+ const written = [made];
416
+ if (!split.ended)
417
+ written.push(await afterCreate(context, id, () => guardedWrite(guard, { chatId: null, kind: "edit", key: COMMAND_KEY.update, operationId: newOperationId(), notify }, () => context.writes.writer.update(calendar, target.externalId, { recurrence: endedRecurrence(target, split) }, { scope: "series", etag: target.etag, notify }))));
418
+ const [saved, ended] = await savedAfterCreate(context, id, () => context.store.saveEntries(calendar.id, written, context.now));
419
+ return {
420
+ operation_id: id,
421
+ event: entry.eventId === null ? null : `event:${entry.eventId}`,
422
+ scope: "following",
423
+ entry: saved ? entryItem(saved) : null,
424
+ ended: ended ? entryItem(ended) : null,
425
+ hint: "sync to store the occurrences of both series",
426
+ };
427
+ };
428
+ /**
429
+ * The series ends before the occurrence. The earlier ones stay, so it takes the key of a deletion that asks, not the
430
+ * series'.
431
+ */
432
+ const deleteFollowing = async (context, reference, options) => {
433
+ const found = await targetOf(context, reference, { ...options, scope: "following" });
434
+ const { calendar, entry, target, participants } = found;
435
+ const split = splitOf(context, reference, found);
436
+ if (split.first) {
437
+ const { following: _following, ...rest } = options;
438
+ return deleteEvent(context, reference, { ...rest, series: true });
439
+ }
440
+ if (split.ended)
441
+ throw new CliError("validation_error", `the series of ${reference} already ends before it; sync to see it`, {
442
+ reason: "event.repeat-split",
443
+ });
444
+ const notify = notifyOf(context, options.notify, hasOthers(participants));
445
+ const operationId = newOperationId();
446
+ const recurrence = endedRecurrence(target, split);
447
+ const written = await guardedWrite(writeGuard(context, keysFor(context, "update", notify)), { chatId: null, kind: "edit", key: COMMAND_KEY.delete, operationId, notify }, () => context.writes.writer.update(calendar, target.externalId, { recurrence }, { scope: "series", etag: target.etag, notify }));
448
+ const [ended] = await context.store.saveEntries(calendar.id, [written], context.now);
449
+ const removed = await context.store.removeEntries(calendar.id, [entry.externalId], context.now);
450
+ return {
451
+ operation_id: operationId,
452
+ event: entry.eventId === null ? null : `event:${entry.eventId}`,
453
+ scope: "following",
454
+ removed,
455
+ ended: ended ? entryItem(ended) : null,
456
+ ...(ruleCount(split.rule) === undefined ? {} : { converted: `COUNT became ${recurrence[0]?.slice(6)}` }),
457
+ hint: "sync to cancel the later occurrences stored",
458
+ };
459
+ };
460
+ const WRITABLE = new Set(["owner", "writer"]);
461
+ /**
462
+ * The event, under the same id, to another calendar of the same account. A repeating event moves whole: an
463
+ * occurrence alone cannot change calendar at Google, and moving one copy of a series apart would split it.
464
+ */
465
+ export const moveEvent = async (context, reference, toCalendar, options = {}) => {
466
+ const move = context.writes.writer.move?.bind(context.writes.writer);
467
+ if (!move)
468
+ throw new CliError("configuration_error", `${context.writes.writer.provider} cannot move events`);
469
+ const { calendar, target, participants } = await targetOf(context, reference, {
470
+ ...options,
471
+ scope: options.series ? "series" : "this",
472
+ });
473
+ if (target.kind === "occurrence")
474
+ throw new CliError("validation_error", `${reference} is one occurrence of a repeating event, which moves whole: add ${context.names.series}`, { param: context.names.series });
475
+ const destination = await calendarIn(context.store, context.accountId, toCalendar);
476
+ if (destination.id === calendar.id)
477
+ throw new CliError("validation_error", `${reference} is on calendar ${calendar.id} already`);
478
+ if (!WRITABLE.has(destination.accessRole ?? ""))
479
+ throw new CliError("permission_error", `calendar ${destination.id} takes no new events from this account`, {
480
+ reason: "calendar.read-only",
481
+ });
482
+ if (target.eventType && target.eventType !== "default")
483
+ // https://developers.google.com/workspace/calendar/api/v3/reference/events/move: only default events move.
484
+ throw new CliError("validation_error", `a ${target.eventType} event stays on its calendar`, {
485
+ reason: "event.unmovable",
486
+ });
487
+ const notify = notifyOf(context, options.notify, hasOthers(participants));
488
+ const operationId = newOperationId();
489
+ const moved = await guardedWrite(writeGuard(context, keysFor(context, "move", notify)), { chatId: null, kind: "edit", key: COMMAND_KEY.move, operationId, notify }, () => move(calendar, target.externalId, destination, { etag: target.etag, notify }));
490
+ await context.store.removeEntries(calendar.id, [target.externalId], context.now);
491
+ // The shared UID joins the new copy to the same event as the cancelled one; a provider may leave it out of a move.
492
+ const [saved] = await context.store.saveEntries(destination.id, [{ ...moved, icalUid: moved.icalUid ?? target.icalUid }], context.now);
493
+ return {
494
+ ...answerOf(operationId, saved, target.kind === "series"),
495
+ from_calendar: String(calendar.id),
496
+ calendar: String(destination.id),
497
+ // Stored once here, the copy would stay as it is now: no sync reads a calendar that does not sync.
498
+ ...(destination.synced
499
+ ? {}
500
+ : { hint: `calendar ${destination.id} does not sync: calendars sync ${destination.id} keeps the event current` }),
501
+ };
502
+ };
@@ -7,7 +7,7 @@ export declare const REASONS: ErrorReason[];
7
7
  * Detail keys an error from this package may carry, until each is registered on its own reason. Not `cause`:
8
8
  * that can hold a provider's or SQLite's raw text.
9
9
  */
10
- export declare const DETAILS: readonly ["contracts", "allowed", "alternative", "arguments", "at", "available", "batch", "budget", "built", "candidates", "chat", "chat_id", "cli", "complete", "emitted_bytes", "exact", "forum", "guide", "hint", "id", "index", "kind", "limit", "limit_source", "max_bytes", "model", "name", "operation_id", "operation_ids", "text_limit", "store_refused_in", "partial_output", "path", "permission", "person_ids", "previous_chat_id", "profiles", "provider_error", "provider", "recovery", "reference", "remembered", "retry_at", "runtime", "saved", "scheduled_for", "script", "sent", "sent_count", "settlement_failed", "span", "sqlite", "stage", "standing", "state", "stderr", "term", "total", "upgraded", "wanted"];
10
+ export declare const DETAILS: readonly ["contracts", "calendars", "allowed", "alternative", "arguments", "at", "available", "batch", "budget", "built", "candidates", "chat", "chat_id", "cli", "complete", "emitted_bytes", "exact", "forum", "guide", "hint", "id", "index", "kind", "limit", "limit_source", "max_bytes", "model", "name", "operation_id", "operation_ids", "text_limit", "store_refused_in", "partial_output", "path", "permission", "person_ids", "previous_chat_id", "profiles", "provider_error", "provider", "recovery", "reference", "remembered", "retry_at", "runtime", "saved", "scheduled_for", "script", "sent", "sent_count", "settlement_failed", "span", "sqlite", "stage", "standing", "state", "stderr", "term", "total", "upgraded", "wanted"];
11
11
  /** What a CLI adds to the shared registry: its own reasons, and the detail keys its adapters raise. */
12
12
  export interface AppErrors {
13
13
  reasons?: readonly ErrorReason[];
@@ -59,6 +59,10 @@ const OTHERS = [
59
59
  validation("event.notify-required", "the event has other attendees; say whether to notify them"),
60
60
  validation("event.repeat-invalid", "the repeat rule is outside the RFC 5545 subset the calendars take"),
61
61
  validation("event.repeat-kind", "the series' rule holds a date or a time of the kind the event is leaving"),
62
+ validation("event.repeat-split", "the series cannot be split at this occurrence, or was split there already"),
63
+ validation("event.repeat-count", "a COUNT series is split only with a rule for its new part"),
64
+ validation("event.unmovable", "the provider keeps this kind of event on its calendar"),
65
+ ["calendar.read-only", "permission_error", "the calendar takes no new events from this account"],
62
66
  ["busy.offline", "permission_error", "calendars busy asks the provider; --offline was given"],
63
67
  validation("busy.no-calendars", "no calendar syncs and no person was named, so there is nobody to ask"),
64
68
  validation("store.refused", "the local store refused the document text"),
@@ -150,6 +154,7 @@ export const REASONS = defineReasons([
150
154
  */
151
155
  export const DETAILS = [
152
156
  "contracts",
157
+ "calendars",
153
158
  "allowed",
154
159
  "alternative",
155
160
  "arguments",
@@ -144,6 +144,10 @@ export interface StoredEntry {
144
144
  etag: string | null;
145
145
  seenAt: number | null;
146
146
  deletedAt: number | null;
147
+ /** When this copy was first stored. */
148
+ createdAt: number;
149
+ /** `transparent`: the copy does not take the owner's time. */
150
+ transparency: EntryTransparency | null;
147
151
  }
148
152
  export interface StoredParticipant {
149
153
  entryId: number;
@@ -183,6 +187,13 @@ export interface EventFilter {
183
187
  to?: number;
184
188
  /** All-day events, which have dates and no instant, ending after this local day (`YYYY-MM-DD`); `from` without it. */
185
189
  fromDate?: string;
190
+ /** Events `eventChange` finds new, moved or cancelled at or after this instant. */
191
+ changedSince?: number;
192
+ /**
193
+ * Events whose time overlaps another's. Only timed events that take the owner's time count: not cancelled, and with
194
+ * a live copy the owner did not decline or mark free.
195
+ */
196
+ overlapping?: boolean;
186
197
  /** All-day events starting before this local day (exclusive); `to` without it. */
187
198
  toDate?: string;
188
199
  accountId?: number;
@@ -195,10 +206,17 @@ export interface EventFilter {
195
206
  offset?: number;
196
207
  }
197
208
  /** Pairs the rules left unlinked because two or more entities fit equally well (requirements 3.6). */
209
+ /** One series' slots for `EventStore.saveSlots`: what the provider gave, or `null` to keep what is stored. */
210
+ export interface SeriesSlots {
211
+ series: string;
212
+ slots: EntryInput[] | null;
213
+ }
198
214
  export interface LinkReview {
199
215
  id: number;
200
216
  linkableType: string;
201
217
  linkableId: number;
218
+ /** The calendar of a `calendar_entry` review's copy, so a choice is made only from that calendar's account. */
219
+ calendarId: number | null;
202
220
  entityType: string;
203
221
  candidates: number[];
204
222
  reason: string;
@@ -231,13 +249,22 @@ export interface EventStore {
231
249
  */
232
250
  removeEntries(calendarId: number, externalIds: string[], now: number): Promise<number>;
233
251
  /**
234
- * After a full list: entries not seen since `seenBefore` get `deleted_at`. Plain occurrences come only from the
235
- * window fetched, so only those inside it count. Events, series and links stay.
252
+ * After a full list: entries not seen since `seenBefore` get `deleted_at`. A full list holds only what falls in the
253
+ * window, so only entries `entryInWindow` puts there count. Events, series and links stay.
236
254
  */
237
255
  markUnseen(calendarId: number, seenBefore: number, window: {
238
256
  from: number;
239
257
  to: number;
240
258
  }, now: number): Promise<number>;
259
+ /**
260
+ * Takes `deleted_at` off the entries a full list marked although they were outside the window it listed:
261
+ * `windowAt(deletedAt)` is the window of the list that marked one. Their events take their fields again. Returns how
262
+ * many came back.
263
+ */
264
+ restoreUnseen(calendarId: number, windowAt: (deletedAt: number) => {
265
+ from: number;
266
+ to: number;
267
+ }, now: number): Promise<number>;
241
268
  /**
242
269
  * Where the calendar's last sync stopped, as the sync engine wrote it (`{"v":1,"token":…,"window":…,"fullAt":…}`),
243
270
  * or `null` before the first one.
@@ -247,8 +274,34 @@ export interface EventStore {
247
274
  setCursor(calendarId: number, value: string | null, now: number): Promise<void>;
248
275
  /** One entry by the provider's id; a series, which no event holds, is found only this way. */
249
276
  entry(calendarId: number, externalId: string): Promise<StoredEntry | null>;
277
+ /**
278
+ * Several series' slots in the window and the sync cursor, in one transaction: a sync commits once for many series
279
+ * instead of twice for each. A series' slots replace its stored plain slots in the window (one it no longer has is
280
+ * cancelled, as `removeEntries` does); `slots: null` keeps them and counts them as seen now.
281
+ */
282
+ saveSlots(calendarId: number, groups: SeriesSlots[], window: {
283
+ from: number;
284
+ to: number;
285
+ }, cursor: string, now: number): Promise<{
286
+ saved: number;
287
+ removed: number;
288
+ }>;
289
+ /** The series' stored slots, moved ones too, whose original start falls in the window; deleted ones left out. */
290
+ slotsOf(calendarId: number, seriesExternalId: string, window: {
291
+ from: number;
292
+ to: number;
293
+ }): Promise<StoredEntry[]>;
250
294
  events(filter?: EventFilter): Promise<Event[]>;
251
295
  event(id: number): Promise<EventDetails | null>;
296
+ /** These events' copies on these calendars, deleted ones too, and their revisions since the instant, for `eventChange`. */
297
+ historyOf(eventIds: number[], calendarIds: number[], since: number): Promise<{
298
+ entries: StoredEntry[];
299
+ revisions: EntryRevision[];
300
+ }>;
301
+ /** The participants of these events' copies on these calendars, deleted copies too, as `event` gives them. */
302
+ participantsOf(eventIds: number[], calendarIds: number[]): Promise<(StoredParticipant & {
303
+ eventId: number;
304
+ })[]>;
252
305
  /** The owner's link of an entry to an event; no rule replaces it. */
253
306
  linkEntry(entryId: number, eventId: number, now: number): Promise<void>;
254
307
  /** The owner's unlink: the rules leave the entry alone after it. */
@@ -258,3 +311,28 @@ export interface EventStore {
258
311
  }): Promise<LinkReview[]>;
259
312
  resolveReview(id: number, decision: ReviewDecision, now: number): Promise<LinkReview>;
260
313
  }
314
+ /**
315
+ * Whether a full list of the window would return the entry: a single or a moved slot by its own time, a plain slot by
316
+ * the time its series gave it, a series while it repeats into the window. An entry with no time is never counted.
317
+ */
318
+ export declare const entryInWindow: (entry: Pick<StoredEntry, "kind" | "exception" | "startsAt" | "endsAt" | "originalStartsAt" | "recurrence" | "recurrenceEndsAt">, window: {
319
+ from: number;
320
+ to: number;
321
+ }) => boolean;
322
+ /** What happened to an event at or after an instant, from its copies: first stored, a new time, or cancelled. */
323
+ export interface EventChange {
324
+ change: "new" | "moved" | "cancelled";
325
+ /** When the store saw it. */
326
+ at: number;
327
+ /** The time a moved event had at the instant. */
328
+ was?: {
329
+ startsAt: number | null;
330
+ endsAt: number | null;
331
+ };
332
+ }
333
+ /**
334
+ * The event's change since `since`, from its copies and their revisions (each revision holds the fields before one
335
+ * change): cancelled when the event is gone and a copy was cancelled or removed since; new when its first copy was
336
+ * stored since; moved when a live copy's time now differs from its time at `since`. `null` when none of these.
337
+ */
338
+ export declare const eventChange: (event: Pick<Event, "status" | "sourceDeletedAt">, entries: readonly StoredEntry[], revisions: readonly EntryRevision[], since: number) => EventChange | null;
@@ -1 +1,65 @@
1
- export {};
1
+ const UNTIL = /(?:^|;)UNTIL=(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})Z?)?(?:;|$)/iu;
2
+ /** The last moment a series can have a slot, when its rule says; a COUNT rule or none repeats as far as this knows. */
3
+ const seriesEnd = (entry) => {
4
+ if (entry.recurrenceEndsAt !== null)
5
+ return entry.recurrenceEndsAt;
6
+ const rule = entry.recurrence?.find((line) => /^RRULE:/iu.test(line));
7
+ const until = rule === undefined ? null : UNTIL.exec(rule.slice("RRULE:".length));
8
+ if (!until)
9
+ return Number.POSITIVE_INFINITY;
10
+ const [, year, month, day, hour = "23", minute = "59", second = "59"] = until;
11
+ // A floating or zoned UNTIL is read as UTC; a day later keeps a series near the edge counted as inside.
12
+ return (Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second)) + 86_400_000);
13
+ };
14
+ /**
15
+ * Whether a full list of the window would return the entry: a single or a moved slot by its own time, a plain slot by
16
+ * the time its series gave it, a series while it repeats into the window. An entry with no time is never counted.
17
+ */
18
+ export const entryInWindow = (entry, window) => {
19
+ if (entry.kind === "occurrence" && !entry.exception)
20
+ return (entry.originalStartsAt !== null && entry.originalStartsAt >= window.from && entry.originalStartsAt < window.to);
21
+ const start = entry.startsAt ?? (entry.kind === "occurrence" ? entry.originalStartsAt : null);
22
+ if (start === null || start >= window.to)
23
+ return false;
24
+ if (entry.kind === "series")
25
+ return seriesEnd(entry) >= window.from;
26
+ return Math.max(entry.endsAt ?? start, start + 1) > window.from;
27
+ };
28
+ /**
29
+ * The event's change since `since`, from its copies and their revisions (each revision holds the fields before one
30
+ * change): cancelled when the event is gone and a copy was cancelled or removed since; new when its first copy was
31
+ * stored since; moved when a live copy's time now differs from its time at `since`. `null` when none of these.
32
+ */
33
+ export const eventChange = (event, entries, revisions, since) => {
34
+ const after = (entryId) => revisions
35
+ .filter((one) => one.entryId === entryId && one.createdAt >= since)
36
+ .sort((a, b) => a.createdAt - b.createdAt);
37
+ if (event.status === "cancelled" || event.sourceDeletedAt !== null) {
38
+ const times = entries.flatMap((entry) => [
39
+ ...(entry.deletedAt !== null && entry.deletedAt >= since ? [entry.deletedAt] : []),
40
+ ...(entry.status === "cancelled"
41
+ ? after(entry.id)
42
+ .filter((one) => one.status !== "cancelled")
43
+ .map(({ createdAt }) => createdAt)
44
+ : []),
45
+ ]);
46
+ if (times.length)
47
+ return { change: "cancelled", at: Math.max(...times) };
48
+ }
49
+ const first = Math.min(...entries.map(({ createdAt }) => createdAt));
50
+ if (entries.length && first >= since)
51
+ return { change: "new", at: first };
52
+ for (const entry of entries) {
53
+ if (entry.deletedAt !== null || entry.status === "cancelled")
54
+ continue;
55
+ const changes = after(entry.id);
56
+ const [was] = changes;
57
+ if (was && (was.startsAt !== entry.startsAt || was.endsAt !== entry.endsAt))
58
+ return {
59
+ change: "moved",
60
+ at: changes.at(-1).createdAt,
61
+ was: { startsAt: was.startsAt, endsAt: was.endsAt },
62
+ };
63
+ }
64
+ return null;
65
+ };
@@ -4,7 +4,8 @@ export { calendarText, ENTRY_METADATA_KEYS, entryMetadata, withoutLinkQueries }
4
4
  export type { MeetingContextStore, MeetingEventLinkInput, MeetingEventLinkReceipt, ParticipantPerson, ParticipantPersonInput, ParticipantPersonLinkInput, ParticipantPersonLinkReceipt, } from "./context-store.js";
5
5
  export { type MeetingCueIdentity, meetingCueFingerprint } from "./cue-fingerprint.js";
6
6
  export { EVENT_QUERY } from "./event-query.js";
7
- export type { CalendarAccessRole, CalendarInput, EntryAttachment, EntryAttachmentInput, EntryEventType, EntryInput, EntryKind, EntryParticipantInput, EntryRevision, EntryStatus, EntryTransparency, EntryVisibility, EventDetails, EventFilter, EventStore, LinkReview, ParticipantResponse, ReviewDecision, StoredCalendar, StoredEntry, StoredParticipant, } from "./event-store.js";
7
+ export type { CalendarAccessRole, CalendarInput, EntryAttachment, EntryAttachmentInput, EntryEventType, EntryInput, EntryKind, EntryParticipantInput, EntryRevision, EntryStatus, EntryTransparency, EntryVisibility, EventChange, EventDetails, EventFilter, EventStore, LinkReview, ParticipantResponse, ReviewDecision, SeriesSlots, StoredCalendar, StoredEntry, StoredParticipant, } from "./event-store.js";
8
+ export { entryInWindow, eventChange } from "./event-store.js";
8
9
  export { linkEvent } from "./events.js";
9
10
  export { exportMeeting, type MeetingExport, type MeetingExportFormat, type MeetingExportOptions, serializeVtt, } from "./export.js";
10
11
  export { type FindingEvidence, type FindingOutcome, findingHash, MEETING_FINDINGS_KIND, type MeetingFactFinding, type MeetingFinding, type MeetingFindingsInput, type MeetingFindingsReceipt, type MeetingTaskFinding, meetingFindingFingerprint, validateMeetingFindings, } from "./findings.js";
@@ -2,6 +2,7 @@ export { MEETING_STEPS, meetingDetails, meetingHit, meetingHitsList, meetingSumm
2
2
  export { calendarText, ENTRY_METADATA_KEYS, entryMetadata, withoutLinkQueries } from "./calendar-text.js";
3
3
  export { meetingCueFingerprint } from "./cue-fingerprint.js";
4
4
  export { EVENT_QUERY } from "./event-query.js";
5
+ export { entryInWindow, eventChange } from "./event-store.js";
5
6
  export { linkEvent } from "./events.js";
6
7
  export { exportMeeting, serializeVtt, } from "./export.js";
7
8
  export { findingHash, MEETING_FINDINGS_KIND, meetingFindingFingerprint, validateMeetingFindings, } from "./findings.js";
@@ -1,4 +1,4 @@
1
- import type { EventStore } from "../event-store.js";
1
+ import { type EventStore } from "../event-store.js";
2
2
  import type { ContractCase } from "../store.js";
3
3
  /** A fresh, empty store and one calendar account in it. */
4
4
  export interface EventStoreFixture {