@wirecat/cli-core 0.487.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 (60) hide show
  1. package/CHANGELOG.md +108 -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/mail/attachments.d.ts +15 -0
  25. package/dist/mail/attachments.js +38 -0
  26. package/dist/mail/locations.d.ts +28 -0
  27. package/dist/mail/locations.js +54 -0
  28. package/dist/mail/quick.d.ts +3 -1
  29. package/dist/mail/quick.js +9 -6
  30. package/dist/mail/resume.d.ts +6 -0
  31. package/dist/mail/retry.d.ts +23 -0
  32. package/dist/mail/retry.js +110 -0
  33. package/dist/mail/sync.d.ts +2 -3
  34. package/dist/mail/sync.js +85 -152
  35. package/dist/mail/testing/fake.d.ts +2 -0
  36. package/dist/mail/testing/fake.js +1 -1
  37. package/dist/meetings/event-store.d.ts +80 -2
  38. package/dist/meetings/event-store.js +65 -1
  39. package/dist/meetings/index.d.ts +2 -1
  40. package/dist/meetings/index.js +1 -0
  41. package/dist/meetings/testing/event-store-contract.d.ts +1 -1
  42. package/dist/meetings/testing/event-store-contract.js +114 -0
  43. package/dist/store/drivers/node-sqlite.d.ts +9 -1
  44. package/dist/store/drivers/node-sqlite.js +30 -8
  45. package/dist/store/index.d.ts +1 -1
  46. package/dist/store/sqlite/accounts.js +1 -0
  47. package/dist/store/sqlite/calendar-events.d.ts +4 -0
  48. package/dist/store/sqlite/calendar-events.js +89 -0
  49. package/dist/store/sqlite/calendar.js +126 -94
  50. package/dist/store/sqlite/email-locations.d.ts +24 -0
  51. package/dist/store/sqlite/email-locations.js +56 -0
  52. package/dist/store/sqlite/email-state.d.ts +2 -0
  53. package/dist/store/sqlite/email-state.js +28 -0
  54. package/dist/store/sqlite/emails.d.ts +13 -0
  55. package/dist/store/sqlite/emails.js +9 -29
  56. package/dist/store/sqlite/manifest.js +1 -0
  57. package/dist/store/sqlite/migrations.generated.js +7 -0
  58. package/dist/store/sqlite/schema.d.ts +72 -0
  59. package/dist/store/sqlite/schema.js +13 -0
  60. 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",
@@ -0,0 +1,15 @@
1
+ import type { LoadEngine } from "../readers/types.js";
2
+ import type { AttachmentInput, EmailAddress } from "../store/index.js";
3
+ import type { Address, ParsedMail } from "./parse.js";
4
+ import type { MailSyncResult } from "./sync.js";
5
+ export declare const MAX_ATTACHMENT_CHARS = 200000;
6
+ /** One chunk of raw mail is held in memory at a time. */
7
+ export declare const FETCH_CHUNK = 50;
8
+ export declare const addressOf: ({ email, name }: Address) => EmailAddress;
9
+ export type AttachmentLog = NonNullable<MailSyncResult["attachments"]>;
10
+ /** A parsed email's attachments as the store keeps them, each with its text or why it has none. */
11
+ export declare const readAttachments: (mail: ParsedMail, messageId: string, { loadEngine, maxAttachmentChars, at }: {
12
+ loadEngine: LoadEngine;
13
+ maxAttachmentChars: number;
14
+ at: number;
15
+ }, log: AttachmentLog) => Promise<AttachmentInput[]>;
@@ -0,0 +1,38 @@
1
+ import { extractText } from "../attachments/extract.js";
2
+ export const MAX_ATTACHMENT_CHARS = 200_000;
3
+ /** One chunk of raw mail is held in memory at a time. */
4
+ export const FETCH_CHUNK = 50;
5
+ export const addressOf = ({ email, name }) => ({ address: email, name });
6
+ /** A parsed email's attachments as the store keeps them, each with its text or why it has none. */
7
+ export const readAttachments = async (mail, messageId, { loadEngine, maxAttachmentChars, at }, log) => {
8
+ const files = [];
9
+ for (const [position, part] of mail.attachments.entries()) {
10
+ const extraction = part.bytes === null
11
+ ? { status: "unavailable" }
12
+ : await extractText(part.bytes, { kind: "file", name: part.name, mime: part.mime, path: part.name }, loadEngine);
13
+ log.push({ message: messageId, part: part.id, status: extraction.status });
14
+ const extracted = extraction.status === "extracted" ? extraction : undefined;
15
+ files.push({
16
+ position,
17
+ kind: "file",
18
+ mime: part.mime,
19
+ name: part.name,
20
+ title: null,
21
+ url: null,
22
+ size: part.bytes === null ? null : part.bytes.byteLength,
23
+ width: null,
24
+ height: null,
25
+ duration: null,
26
+ providerRef: { message: messageId, part: part.id },
27
+ localPath: null,
28
+ text: extracted ? extracted.text.slice(0, maxAttachmentChars) : null,
29
+ normalizedText: null,
30
+ extraction: extracted ? "text" : "failed",
31
+ extractor: extracted ? extracted.extractor : null,
32
+ extractionError: extracted ? null : extraction.status,
33
+ contentSha256: null,
34
+ extractedAt: at,
35
+ });
36
+ }
37
+ return files;
38
+ };
@@ -0,0 +1,28 @@
1
+ import type { FolderEmail, MessageStore } from "../store/index.js";
2
+ import type { Quick, Searched } from "./source.js";
3
+ /** The flags a listing shows that differ from the stored ones, by Message-ID, for mail whose UID is stored. */
4
+ export declare const flagsToRefresh: (rows: ReadonlyMap<string, FolderEmail>, listing: readonly Quick[]) => Map<string, {
5
+ read?: boolean;
6
+ flagged?: boolean;
7
+ }>;
8
+ export interface LocationProof {
9
+ /** Each folder listed by UID: what the server searched, and the stored mail with a UID there, before this run saved. */
10
+ folders: ReadonlyMap<string, {
11
+ searched: Searched;
12
+ rows: readonly FolderEmail[];
13
+ }>;
14
+ /** Mail received before this is outside the window IMAP's SINCE can vouch for. */
15
+ windowFrom: number;
16
+ /** Messages this run saw in any folder: one moved to another folder is not gone. */
17
+ present: ReadonlySet<string>;
18
+ now: number;
19
+ }
20
+ /**
21
+ * A folder listed by UID proves a deletion from the UIDs it stores: mail in the window whose UID the server no
22
+ * longer lists is gone from that folder, and gone altogether when no other location of it is left. Nothing is
23
+ * changed when too many would go, as for a header listing.
24
+ */
25
+ export declare const proveByLocation: (store: MessageStore, accountId: number, { folders, windowFrom, present, now }: LocationProof, refuse: (gone: number, candidates: number) => string | undefined) => Promise<{
26
+ deleted: number;
27
+ skipped?: string;
28
+ }>;
@@ -0,0 +1,54 @@
1
+ /** The flags a listing shows that differ from the stored ones, by Message-ID, for mail whose UID is stored. */
2
+ export const flagsToRefresh = (rows, listing) => {
3
+ const changes = new Map();
4
+ for (const { uid, seen, flagged } of listing) {
5
+ const row = rows.get(uid);
6
+ if (row === undefined)
7
+ continue;
8
+ const changed = {
9
+ ...(seen === undefined || seen === row.read ? {} : { read: seen }),
10
+ ...(flagged === undefined || flagged === row.flagged ? {} : { flagged }),
11
+ };
12
+ if (Object.keys(changed).length)
13
+ changes.set(row.externalId, changed);
14
+ }
15
+ return changes;
16
+ };
17
+ /**
18
+ * A folder listed by UID proves a deletion from the UIDs it stores: mail in the window whose UID the server no
19
+ * longer lists is gone from that folder, and gone altogether when no other location of it is left. Nothing is
20
+ * changed when too many would go, as for a header listing.
21
+ */
22
+ export const proveByLocation = async (store, accountId, { folders, windowFrom, present, now }, refuse) => {
23
+ const candidates = new Set();
24
+ const lost = new Map();
25
+ const lostUids = new Map();
26
+ for (const [folder, { searched, rows }] of folders) {
27
+ const inWindow = new Set(searched.uids.map(Number));
28
+ for (const row of rows) {
29
+ if (row.receivedAt === null || row.receivedAt < windowFrom)
30
+ continue;
31
+ candidates.add(row.externalId);
32
+ if (inWindow.has(row.uid))
33
+ continue;
34
+ const entry = lost.get(row.externalId) ?? { row, locations: 0, folders: new Set() };
35
+ entry.locations++;
36
+ entry.folders.add(folder);
37
+ lost.set(row.externalId, entry);
38
+ const uids = lostUids.get(folder) ?? [];
39
+ uids.push(row.uid);
40
+ lostUids.set(folder, uids);
41
+ }
42
+ }
43
+ const gone = [...lost]
44
+ .filter(([id, entry]) => !present.has(id) && entry.locations === entry.row.locations && entry.folders.size >= entry.row.folders)
45
+ .map(([id]) => id);
46
+ const refused = refuse(gone.length, candidates.size);
47
+ if (refused)
48
+ return { deleted: 0, skipped: refused };
49
+ // Marked first: an interruption leaves locations that prove it again, never an email with no way to be found.
50
+ const deleted = gone.length ? await store.mail.markDeleted(accountId, gone, now) : 0;
51
+ for (const [folder, uids] of lostUids)
52
+ await store.mail.forgetLocations(accountId, folder, folders.get(folder).searched.validity, uids);
53
+ return { deleted };
54
+ };
@@ -20,6 +20,8 @@ export interface QuickPlan {
20
20
  asked: number;
21
21
  }
22
22
  export interface QuickRead {
23
+ /** Every message of the window the server listed, done or not: its flags refresh the stored mail. */
24
+ listing: Quick[];
23
25
  /** Messages read and identified, with their raw bytes so the save does not read them again. */
24
26
  read: {
25
27
  item: Listed;
@@ -36,4 +38,4 @@ export interface QuickRead {
36
38
  * One round of a folder whose server gives no cheap message id: UID, flags, date and size of what is not done, then
37
39
  * the raw bytes of the newest of it, whose Message-ID gives the id the full listing would have given.
38
40
  */
39
- export declare const readQuick: (source: MailSource & Required<Pick<MailSource, "quick" | "identify">>, since: Date, folder: string, searched: Searched, done: Ranges, max: number, plan: QuickPlan | undefined) => Promise<QuickRead>;
41
+ export declare const readQuick: (source: MailSource & Required<Pick<MailSource, "quick" | "identify">>, since: Date, folder: string, searched: Searched, done: Ranges, max: number, plan: QuickPlan | undefined, located: ReadonlySet<string>) => Promise<QuickRead>;
@@ -6,16 +6,18 @@ export const keyOf = (folder, uid) => `${folder}\0${uid}`;
6
6
  * One round of a folder whose server gives no cheap message id: UID, flags, date and size of what is not done, then
7
7
  * the raw bytes of the newest of it, whose Message-ID gives the id the full listing would have given.
8
8
  */
9
- export const readQuick = async (source, since, folder, searched, done, max, plan) => {
10
- const answer = { read: [], unreadable: [], capped: false };
9
+ export const readQuick = async (source, since, folder, searched, done, max, plan, located) => {
10
+ const answer = { listing: [], read: [], unreadable: [], capped: false };
11
11
  let items = plan?.quick.get(folder);
12
12
  if (items === undefined) {
13
- const asked = searched.uids.filter((uid) => !within(done, Number(uid)) && (plan?.only === undefined || plan.only.has(keyOf(folder, uid))));
13
+ // Mail whose location is known is listed for its flags only; it is never read again.
14
+ const asked = searched.uids.filter((uid) => plan?.only === undefined || plan.only.has(keyOf(folder, uid)));
15
+ const wanted = asked.filter((uid) => !within(done, Number(uid)) && !located.has(uid));
14
16
  if (asked.length > 0 && plan) {
15
17
  const stop = plan.budget.take();
16
18
  if (stop) {
17
19
  plan.stopped = stop;
18
- plan.remaining += asked.length;
20
+ plan.remaining += wanted.length;
19
21
  return { ...answer, capped: true };
20
22
  }
21
23
  }
@@ -23,15 +25,16 @@ export const readQuick = async (source, since, folder, searched, done, max, plan
23
25
  if (plan !== undefined) {
24
26
  plan.quick.set(folder, items);
25
27
  const got = new Set(items.map(({ uid }) => uid));
26
- for (const uid of asked)
28
+ for (const uid of wanted)
27
29
  if (!got.has(uid)) {
28
30
  plan.failed.add(keyOf(folder, uid));
29
31
  plan.unlisted.add(keyOf(folder, uid));
30
32
  }
31
33
  }
32
34
  }
35
+ answer.listing = items;
33
36
  const open = items
34
- .filter(({ uid }) => !within(done, Number(uid)) && !plan?.tried.has(keyOf(folder, uid)))
37
+ .filter(({ uid }) => !within(done, Number(uid)) && !located.has(uid) && !plan?.tried.has(keyOf(folder, uid)))
35
38
  .sort((a, b) => Number(b.uid) - Number(a.uid));
36
39
  const slice = open.slice(0, plan === undefined ? max : (source.fetchBatch ?? FETCH_SLICE));
37
40
  if (plan !== undefined)
@@ -30,6 +30,12 @@ export interface ResumePoint {
30
30
  total: number;
31
31
  at: string;
32
32
  };
33
+ /**
34
+ * From this date on, every stored email of the folder has its UID in `email_locations`: a listing that gives UIDs
35
+ * and flags is enough to refresh flags and prove deletions. Absent after an engine that did not know locations
36
+ * wrote the point, and then one header listing records them.
37
+ */
38
+ located?: string;
33
39
  /** What the latest run listed, whatever its window. */
34
40
  last?: {
35
41
  since: string;
@@ -0,0 +1,23 @@
1
+ import type { LoadEngine } from "../readers/types.js";
2
+ import type { MessageStore } from "../store/index.js";
3
+ import type { MailSource } from "./source.js";
4
+ import type { MailAccount, MailSyncOptions, MailSyncResult } from "./sync.js";
5
+ /** Failures a newer install mends: the reader was missing, not the file at fault, so reading again can succeed. */
6
+ export declare const RETRYABLE_ATTACHMENTS: string[];
7
+ /**
8
+ * Reads again, by Gmail id, only the stored emails with an attachment a missing reader left unread: memo keeps no
9
+ * attachment file, so the email comes from the source again. Nothing is listed, so no deletion is proved and no
10
+ * resume point moves; the email keeps its stored mailboxes, flags and rule.
11
+ */
12
+ export declare const retryUnread: ({ store, source, account, since, max, maxBodyChars, maxAttachmentChars, now, loadEngine, index, }: {
13
+ store: MessageStore;
14
+ source: MailSource;
15
+ account: MailAccount;
16
+ since: Date;
17
+ max: number;
18
+ maxBodyChars: number;
19
+ maxAttachmentChars: number;
20
+ now: () => number;
21
+ loadEngine: LoadEngine;
22
+ index: MailSyncOptions["index"];
23
+ }) => Promise<MailSyncResult>;