@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.
- package/CHANGELOG.md +108 -1
- package/dist/calendar/cli/index.js +48 -13
- package/dist/calendar/index.d.ts +1 -1
- package/dist/calendar/mcp/index.d.ts +390 -0
- package/dist/calendar/mcp/index.js +50 -12
- package/dist/calendar/reads.d.ts +9 -2
- package/dist/calendar/reads.js +60 -5
- package/dist/calendar/recurrence.d.ts +20 -0
- package/dist/calendar/recurrence.js +74 -0
- package/dist/calendar/sync.d.ts +6 -0
- package/dist/calendar/sync.js +123 -41
- package/dist/calendar/testing/memory.js +90 -7
- package/dist/calendar/testing/sync-contract.js +90 -0
- package/dist/calendar/testing/writer-contract.d.ts +2 -6
- package/dist/calendar/testing/writer-contract.js +157 -29
- package/dist/calendar/testing/writer.d.ts +3 -1
- package/dist/calendar/testing/writer.js +11 -0
- package/dist/calendar/writer.d.ts +20 -3
- package/dist/calendar/writer.js +8 -4
- package/dist/calendar/writes.d.ts +21 -12
- package/dist/calendar/writes.js +259 -20
- package/dist/errors/reasons.d.ts +1 -1
- package/dist/errors/reasons.js +5 -0
- package/dist/mail/attachments.d.ts +15 -0
- package/dist/mail/attachments.js +38 -0
- package/dist/mail/locations.d.ts +28 -0
- package/dist/mail/locations.js +54 -0
- package/dist/mail/quick.d.ts +3 -1
- package/dist/mail/quick.js +9 -6
- package/dist/mail/resume.d.ts +6 -0
- package/dist/mail/retry.d.ts +23 -0
- package/dist/mail/retry.js +110 -0
- package/dist/mail/sync.d.ts +2 -3
- package/dist/mail/sync.js +85 -152
- package/dist/mail/testing/fake.d.ts +2 -0
- package/dist/mail/testing/fake.js +1 -1
- package/dist/meetings/event-store.d.ts +80 -2
- package/dist/meetings/event-store.js +65 -1
- package/dist/meetings/index.d.ts +2 -1
- package/dist/meetings/index.js +1 -0
- package/dist/meetings/testing/event-store-contract.d.ts +1 -1
- package/dist/meetings/testing/event-store-contract.js +114 -0
- package/dist/store/drivers/node-sqlite.d.ts +9 -1
- package/dist/store/drivers/node-sqlite.js +30 -8
- package/dist/store/index.d.ts +1 -1
- package/dist/store/sqlite/accounts.js +1 -0
- package/dist/store/sqlite/calendar-events.d.ts +4 -0
- package/dist/store/sqlite/calendar-events.js +89 -0
- package/dist/store/sqlite/calendar.js +126 -94
- package/dist/store/sqlite/email-locations.d.ts +24 -0
- package/dist/store/sqlite/email-locations.js +56 -0
- package/dist/store/sqlite/email-state.d.ts +2 -0
- package/dist/store/sqlite/email-state.js +28 -0
- package/dist/store/sqlite/emails.d.ts +13 -0
- package/dist/store/sqlite/emails.js +9 -29
- package/dist/store/sqlite/manifest.js +1 -0
- package/dist/store/sqlite/migrations.generated.js +7 -0
- package/dist/store/sqlite/schema.d.ts +72 -0
- package/dist/store/sqlite/schema.js +13 -0
- package/package.json +1 -1
package/dist/calendar/writes.js
CHANGED
|
@@ -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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 ${
|
|
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
|
+
};
|
package/dist/errors/reasons.d.ts
CHANGED
|
@@ -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[];
|
package/dist/errors/reasons.js
CHANGED
|
@@ -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
|
+
};
|
package/dist/mail/quick.d.ts
CHANGED
|
@@ -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>;
|
package/dist/mail/quick.js
CHANGED
|
@@ -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
|
-
|
|
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 +=
|
|
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
|
|
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)
|
package/dist/mail/resume.d.ts
CHANGED
|
@@ -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>;
|