@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
|
@@ -1 +1,65 @@
|
|
|
1
|
-
|
|
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
|
+
};
|
package/dist/meetings/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/meetings/index.js
CHANGED
|
@@ -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,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { eventChange } from "../event-store.js";
|
|
2
3
|
const HOUR = 3_600_000;
|
|
3
4
|
/** "Now" in every case: entries before it have happened. */
|
|
4
5
|
export const CONTRACT_NOW = 1_000 * HOUR;
|
|
@@ -218,6 +219,119 @@ export const eventStoreContract = (make) => {
|
|
|
218
219
|
assert.equal((await store.event(kept?.eventId))?.entries[0]?.deletedAt, null);
|
|
219
220
|
assert.equal((await store.event(far?.eventId))?.entries[0]?.deletedAt, null);
|
|
220
221
|
}),
|
|
222
|
+
test("a full list marks nothing outside its window: a single, a moved slot, a series that ended", async ({ store, calendar, }) => {
|
|
223
|
+
const month = 30 * 24 * HOUR;
|
|
224
|
+
const until = new Date(earlier - month).toISOString().replace(/[-:]|\.\d{3}/gu, "");
|
|
225
|
+
await store.saveEntries(calendar, [
|
|
226
|
+
single("old", earlier - month),
|
|
227
|
+
single("next", later + month),
|
|
228
|
+
single("inside", later),
|
|
229
|
+
single("ended", earlier - 2 * month, { kind: "series", recurrence: [`RRULE:FREQ=WEEKLY;UNTIL=${until}`] }),
|
|
230
|
+
single("endless", earlier - 2 * month, { kind: "series", recurrence: ["RRULE:FREQ=WEEKLY"] }),
|
|
231
|
+
single("moved", earlier - month, {
|
|
232
|
+
kind: "occurrence",
|
|
233
|
+
exception: true,
|
|
234
|
+
seriesExternalId: "endless",
|
|
235
|
+
originalStartsAt: earlier - month,
|
|
236
|
+
}),
|
|
237
|
+
], 2);
|
|
238
|
+
assert.equal(await store.markUnseen(calendar, 10, { from: earlier, to: later + 24 * HOUR }, 11), 2);
|
|
239
|
+
const deleted = async (id) => (await store.entry(calendar, id))?.deletedAt;
|
|
240
|
+
assert.deepEqual(await Promise.all(["old", "next", "ended", "moved", "inside", "endless"].map(deleted)), [
|
|
241
|
+
null,
|
|
242
|
+
null,
|
|
243
|
+
null,
|
|
244
|
+
null,
|
|
245
|
+
11,
|
|
246
|
+
11,
|
|
247
|
+
]);
|
|
248
|
+
}),
|
|
249
|
+
test("entries marked outside the window of the list that marked them come back, with their events", async ({ store, calendar, }) => {
|
|
250
|
+
const month = 30 * 24 * HOUR;
|
|
251
|
+
const [old, next, inside] = await store.saveEntries(calendar, [single("old", earlier - month), single("next", later + month), single("inside", later)], 2);
|
|
252
|
+
assert.equal(await store.markUnseen(calendar, 10, { from: 0, to: later + 2 * month }, 11), 3);
|
|
253
|
+
assert.equal((await store.event(next?.eventId))?.event.status, "cancelled");
|
|
254
|
+
assert.equal(await store.restoreUnseen(calendar, () => ({ from: earlier, to: later + 24 * HOUR }), 12), 2);
|
|
255
|
+
const oldEvent = (await store.event(old?.eventId))?.event;
|
|
256
|
+
const nextEvent = (await store.event(next?.eventId))?.event;
|
|
257
|
+
assert.equal(oldEvent?.sourceDeletedAt, null);
|
|
258
|
+
assert.equal(nextEvent?.status, "confirmed");
|
|
259
|
+
assert.equal((await store.entry(calendar, "old"))?.deletedAt, null);
|
|
260
|
+
assert.equal((await store.entry(calendar, "inside"))?.deletedAt, 11);
|
|
261
|
+
assert.equal(await store.restoreUnseen(calendar, () => ({ from: earlier, to: later + 24 * HOUR }), 13), 0);
|
|
262
|
+
assert.equal((await store.event(inside?.eventId))?.event.status, "cancelled");
|
|
263
|
+
}),
|
|
264
|
+
test("a list by time finds a long event that started well before it, timed or all-day", async ({ store, calendar, }) => {
|
|
265
|
+
const day = 24 * HOUR;
|
|
266
|
+
await store.saveEntries(calendar, [
|
|
267
|
+
single("trip", later - 30 * day, { endsAt: later + 30 * day }),
|
|
268
|
+
single("leave", later - 30 * day, {
|
|
269
|
+
allDay: true,
|
|
270
|
+
startDate: new Date(later - 30 * day).toISOString().slice(0, 10),
|
|
271
|
+
endDate: new Date(later + 30 * day).toISOString().slice(0, 10),
|
|
272
|
+
endsAt: later + 30 * day,
|
|
273
|
+
}),
|
|
274
|
+
single("today", later),
|
|
275
|
+
], 2);
|
|
276
|
+
const day0 = new Date(later).toISOString().slice(0, 10);
|
|
277
|
+
const day1 = new Date(later + day).toISOString().slice(0, 10);
|
|
278
|
+
for (const filter of [
|
|
279
|
+
{ from: later, to: later + day },
|
|
280
|
+
{ from: later, to: later + day, fromDate: day0, toDate: day1 },
|
|
281
|
+
])
|
|
282
|
+
assert.deepEqual((await store.events(filter)).map(({ title }) => title).length, 3, JSON.stringify(filter));
|
|
283
|
+
}),
|
|
284
|
+
test("a list of changes since an instant finds events new, moved or cancelled since, and says which", async ({ store, calendar, }) => {
|
|
285
|
+
await store.saveEntries(calendar, ["same", "moved", "back", "cancelled"].map((id) => single(id, later)), 2);
|
|
286
|
+
await store.saveEntries(calendar, [
|
|
287
|
+
single("moved", later + HOUR),
|
|
288
|
+
single("back", later + HOUR),
|
|
289
|
+
single("cancelled", later, { status: "cancelled" }),
|
|
290
|
+
single("new", later),
|
|
291
|
+
], 10);
|
|
292
|
+
await store.saveEntries(calendar, [single("back", later)], 11);
|
|
293
|
+
const changed = async (since) => {
|
|
294
|
+
const events = await store.events({ changedSince: since });
|
|
295
|
+
const { entries, revisions } = await store.historyOf(events.map(({ id }) => id), [calendar], since);
|
|
296
|
+
return Object.fromEntries(events.map((event) => {
|
|
297
|
+
const [copy] = entries.filter(({ eventId }) => eventId === event.id);
|
|
298
|
+
return [
|
|
299
|
+
copy?.externalId,
|
|
300
|
+
eventChange(event, entries.filter(({ eventId }) => eventId === event.id), revisions, since),
|
|
301
|
+
];
|
|
302
|
+
}));
|
|
303
|
+
};
|
|
304
|
+
assert.deepEqual(await changed(5), {
|
|
305
|
+
moved: { change: "moved", at: 10, was: { startsAt: later, endsAt: later + HOUR } },
|
|
306
|
+
cancelled: { change: "cancelled", at: 10 },
|
|
307
|
+
new: { change: "new", at: 10 },
|
|
308
|
+
});
|
|
309
|
+
assert.deepEqual(Object.keys(await changed(12)), []);
|
|
310
|
+
assert.equal((await changed(1)).same?.change, "new");
|
|
311
|
+
}),
|
|
312
|
+
test("a list of overlapping events holds timed events that clash and take the owner's time", async ({ store, calendar, }) => {
|
|
313
|
+
const at = later;
|
|
314
|
+
await store.saveEntries(calendar, [
|
|
315
|
+
single("first", at),
|
|
316
|
+
single("second", at + HOUR / 2),
|
|
317
|
+
single("alone", at + 3 * HOUR),
|
|
318
|
+
single("declined", at + 3 * HOUR + HOUR / 2, { ownResponse: "declined" }),
|
|
319
|
+
single("busy", at + 5 * HOUR),
|
|
320
|
+
single("free", at + 5 * HOUR, { transparency: "transparent" }),
|
|
321
|
+
single("cancelled", at + 3 * HOUR, { status: "cancelled" }),
|
|
322
|
+
single("all-day", at, {
|
|
323
|
+
allDay: true,
|
|
324
|
+
startDate: new Date(at).toISOString().slice(0, 10),
|
|
325
|
+
endDate: new Date(at + 24 * HOUR).toISOString().slice(0, 10),
|
|
326
|
+
endsAt: at + 24 * HOUR,
|
|
327
|
+
}),
|
|
328
|
+
], 2);
|
|
329
|
+
const found = await store.events({ overlapping: true, from: at - HOUR, to: at + 8 * HOUR });
|
|
330
|
+
assert.deepEqual(found.map(({ title }) => title).length, 2);
|
|
331
|
+
const ids = new Set(found.map(({ id }) => id));
|
|
332
|
+
for (const externalId of ["first", "second"])
|
|
333
|
+
assert.ok(ids.has((await store.entry(calendar, externalId))?.eventId), externalId);
|
|
334
|
+
}),
|
|
221
335
|
test("participants are kept by address; a room is listed and is no contact", async ({ store, calendar }) => {
|
|
222
336
|
const [entry] = await store.saveEntries(calendar, [
|
|
223
337
|
single("a", later, {
|
|
@@ -9,4 +9,12 @@ export declare const openWithNodeSqlite: (path: string) => Promise<CacheDatabase
|
|
|
9
9
|
export declare const nodeClient: (path: string, { readOnly }?: {
|
|
10
10
|
readOnly?: boolean;
|
|
11
11
|
}) => Promise<DatabaseSync>;
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Preparing is the expensive part of a call: the sync write path builds the same SQL text thousands of times, so each
|
|
14
|
+
* connection keeps its statements, least recently used out first. Node's own `createTagStore` would do it, but it needs
|
|
15
|
+
* Node 24.9 and template tags, not SQL text. An evicted statement is not finalized, because a caller may still hold
|
|
16
|
+
* it; `close()` finalizes everything left. The seam must never offer `iterate`: callers share a cached statement, and
|
|
17
|
+
* reusing a statement invalidates its open iterator.
|
|
18
|
+
*/
|
|
19
|
+
export declare const STATEMENT_CACHE_SIZE = 1000;
|
|
20
|
+
export declare const cacheOverNodeSqlite: (database: DatabaseSync, cacheSize?: number) => CacheDatabase;
|
|
@@ -19,17 +19,39 @@ export const nodeClient = async (path, { readOnly = false } = {}) => {
|
|
|
19
19
|
throw error;
|
|
20
20
|
}
|
|
21
21
|
};
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Preparing is the expensive part of a call: the sync write path builds the same SQL text thousands of times, so each
|
|
24
|
+
* connection keeps its statements, least recently used out first. Node's own `createTagStore` would do it, but it needs
|
|
25
|
+
* Node 24.9 and template tags, not SQL text. An evicted statement is not finalized, because a caller may still hold
|
|
26
|
+
* it; `close()` finalizes everything left. The seam must never offer `iterate`: callers share a cached statement, and
|
|
27
|
+
* reusing a statement invalidates its open iterator.
|
|
28
|
+
*/
|
|
29
|
+
export const STATEMENT_CACHE_SIZE = 1000;
|
|
30
|
+
export const cacheOverNodeSqlite = (database, cacheSize = STATEMENT_CACHE_SIZE) => {
|
|
31
|
+
const statements = new Map();
|
|
32
|
+
const prepare = (sql) => {
|
|
33
|
+
const statement = database.prepare(sql);
|
|
34
|
+
return {
|
|
35
|
+
run: (...parameters) => ({ changes: Number(statement.run(...parameters).changes) }),
|
|
36
|
+
get: (...parameters) => statement.get(...parameters),
|
|
37
|
+
all: (...parameters) => statement.all(...parameters),
|
|
38
|
+
};
|
|
39
|
+
};
|
|
23
40
|
return {
|
|
24
41
|
exec: (sql) => database.exec(sql),
|
|
25
42
|
prepare: (sql) => {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
const cached = statements.get(sql);
|
|
44
|
+
if (cached)
|
|
45
|
+
statements.delete(sql);
|
|
46
|
+
const statement = cached ?? prepare(sql);
|
|
47
|
+
statements.set(sql, statement);
|
|
48
|
+
if (statements.size > cacheSize)
|
|
49
|
+
statements.delete(statements.keys().next().value);
|
|
50
|
+
return statement;
|
|
51
|
+
},
|
|
52
|
+
close: () => {
|
|
53
|
+
statements.clear();
|
|
54
|
+
database.close();
|
|
32
55
|
},
|
|
33
|
-
close: () => database.close(),
|
|
34
56
|
};
|
|
35
57
|
};
|
package/dist/store/index.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export type { BotUpdate, BotUpdateStore } from "./sqlite/bot-updates.js";
|
|
|
29
29
|
export type { CatalogActor, CatalogEntry, CatalogInput, CatalogQuery, CatalogStore } from "./sqlite/catalog.js";
|
|
30
30
|
export { CATALOG_QUERY } from "./sqlite/catalog.js";
|
|
31
31
|
export type { ChatMetadata } from "./sqlite/chats.js";
|
|
32
|
-
export type { Email, EmailAddress, EmailAttachment, EmailFacts, EmailFilter, EmailInput, EmailInThread, EmailRecipient, EmailRef, EmailThread, EmailVectorHit, Mailbox, MailboxInput, MailFilter, MailStore, SenderScore, ThreadDetails, ThreadSave, } from "./sqlite/emails.js";
|
|
32
|
+
export type { Email, EmailAddress, EmailAttachment, EmailFacts, EmailFilter, EmailInput, EmailInThread, EmailLocationInput, EmailRecipient, EmailRef, EmailThread, EmailVectorHit, FolderEmail, Mailbox, MailboxInput, MailFilter, MailStore, SenderScore, ThreadDetails, ThreadSave, } from "./sqlite/emails.js";
|
|
33
33
|
export { EVENT_FIELDS, type EventChunkToEmbed, type EventHit, type EventQuery, type EventSearch, type NearestEvent, } from "./sqlite/event-search.js";
|
|
34
34
|
export type { Fact, FactInput, FactsStore } from "./sqlite/facts.js";
|
|
35
35
|
export type { FileListQuery, FilesStore, StoredFile } from "./sqlite/files.js";
|
|
@@ -92,6 +92,7 @@ export const purgeAccount = ({ orm, database }, accountKey) => {
|
|
|
92
92
|
orm.delete(messages).where(eq(messages.accountId, accountKey)).run();
|
|
93
93
|
orm.delete(chats).where(eq(chats.accountId, accountKey)).run();
|
|
94
94
|
orm.delete(syncCursors).where(eq(syncCursors.accountId, accountKey)).run();
|
|
95
|
+
orm.run(sql `DELETE FROM email_locations WHERE mailbox_id IN (SELECT id FROM mailboxes WHERE account_id = ${accountKey})`);
|
|
95
96
|
// Deleting an email clears its recipients, mailboxes, words, attachments and tags (trigger email_bd).
|
|
96
97
|
orm.run(sql `DELETE FROM emails WHERE account_id = ${accountKey}`);
|
|
97
98
|
orm.run(sql `DELETE FROM taggings WHERE taggable_type = 'email_thread' AND taggable_id IN (SELECT id FROM email_threads WHERE account_id = ${accountKey})`);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Event, EventFilter } from "../../meetings/index.js";
|
|
2
|
+
import type { CacheDatabase } from "../driver.js";
|
|
3
|
+
/** The events of a filter, earliest first (`EventStore.events`). */
|
|
4
|
+
export declare const listEvents: (database: CacheDatabase, filter: EventFilter) => Event[];
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { CliError } from "@wirecat/cli-base";
|
|
2
|
+
import { eventOf } from "./events.js";
|
|
3
|
+
const DAY = 86_400_000;
|
|
4
|
+
/** The events of a filter, earliest first (`EventStore.events`). */
|
|
5
|
+
export const listEvents = (database, filter) => {
|
|
6
|
+
const clauses = ["e.deleted_at IS NULL"];
|
|
7
|
+
const params = [];
|
|
8
|
+
const entries = (extra) => `EXISTS (SELECT 1 FROM calendar_entries ce JOIN calendars c ON c.id = ce.calendar_id WHERE ce.event_id = e.id AND ${extra})`;
|
|
9
|
+
const dated = (column, date, instant, at) => {
|
|
10
|
+
if (at === undefined)
|
|
11
|
+
return;
|
|
12
|
+
if (date === undefined) {
|
|
13
|
+
clauses.push(instant);
|
|
14
|
+
params.push(at);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
clauses.push(`(CASE WHEN e.all_day = 1 THEN EXISTS (SELECT 1 FROM calendar_entries ce WHERE ce.event_id = e.id AND ce.${column}) ELSE ${instant} END)`);
|
|
18
|
+
params.push(date, at);
|
|
19
|
+
};
|
|
20
|
+
dated("end_date > ?", filter.fromDate, "coalesce(e.ends_at, e.starts_at + 1) > ?", filter.from);
|
|
21
|
+
dated("start_date < ?", filter.toDate, "e.starts_at < ?", filter.to);
|
|
22
|
+
// Bounds on starts_at alone let SQLite seek its index; the CASE above cannot. They are wider than the CASE: by the
|
|
23
|
+
// longest stored event back, and by a day each way for an all-day event's zone.
|
|
24
|
+
const longest = () => Number(database.prepare("SELECT coalesce(max(ends_at - starts_at), 0) AS ms FROM events WHERE deleted_at IS NULL").get()
|
|
25
|
+
?.ms ?? 0);
|
|
26
|
+
if (filter.from !== undefined) {
|
|
27
|
+
clauses.push("e.starts_at >= ?");
|
|
28
|
+
params.push(filter.from - longest() - DAY);
|
|
29
|
+
}
|
|
30
|
+
if (filter.to !== undefined && filter.toDate !== undefined) {
|
|
31
|
+
clauses.push("e.starts_at < ?");
|
|
32
|
+
params.push(filter.to + DAY);
|
|
33
|
+
}
|
|
34
|
+
if (filter.calendarId !== undefined) {
|
|
35
|
+
clauses.push(entries("ce.calendar_id = ?"));
|
|
36
|
+
params.push(filter.calendarId);
|
|
37
|
+
}
|
|
38
|
+
if (filter.accountId !== undefined) {
|
|
39
|
+
clauses.push(entries("c.account_id = ?"));
|
|
40
|
+
params.push(filter.accountId);
|
|
41
|
+
}
|
|
42
|
+
if (filter.personId !== undefined) {
|
|
43
|
+
clauses.push(entries("EXISTS (SELECT 1 FROM calendar_entry_participants p JOIN identity_links il ON il.identity_id = p.identity_id " +
|
|
44
|
+
"WHERE p.entry_id = ce.id AND il.person_id = ?)"));
|
|
45
|
+
params.push(filter.personId);
|
|
46
|
+
}
|
|
47
|
+
const account = (alias) => {
|
|
48
|
+
if (filter.accountId === undefined)
|
|
49
|
+
return "";
|
|
50
|
+
params.push(filter.accountId);
|
|
51
|
+
return ` AND ${alias}.account_id = ?`;
|
|
52
|
+
};
|
|
53
|
+
if (filter.changedSince !== undefined) {
|
|
54
|
+
// The SQL form of eventChange: first stored since, cancelled since, or a live copy's time changed since.
|
|
55
|
+
const since = filter.changedSince;
|
|
56
|
+
const own = account("c");
|
|
57
|
+
params.push(since);
|
|
58
|
+
const older = account("oc");
|
|
59
|
+
params.push(since, since, since, since);
|
|
60
|
+
clauses.push(`EXISTS (SELECT 1 FROM calendar_entries ce JOIN calendars c ON c.id = ce.calendar_id WHERE ce.event_id = e.id${own}
|
|
61
|
+
AND ((ce.created_at >= ? AND NOT EXISTS (SELECT 1 FROM calendar_entries o JOIN calendars oc
|
|
62
|
+
ON oc.id = o.calendar_id WHERE o.event_id = e.id${older} AND o.created_at < ?))
|
|
63
|
+
OR ((e.status = 'cancelled' OR e.source_deleted_at IS NOT NULL) AND (ce.deleted_at >= ?
|
|
64
|
+
OR (ce.status = 'cancelled' AND EXISTS (SELECT 1 FROM calendar_entry_revisions r WHERE r.entry_id = ce.id
|
|
65
|
+
AND r.created_at >= ? AND r.status IS NOT 'cancelled'))))
|
|
66
|
+
OR (ce.deleted_at IS NULL AND ce.status <> 'cancelled' AND (SELECT r.starts_at IS NOT ce.starts_at
|
|
67
|
+
OR r.ends_at IS NOT ce.ends_at FROM calendar_entry_revisions r WHERE r.entry_id = ce.id AND r.created_at >= ?
|
|
68
|
+
ORDER BY r.created_at, r.id LIMIT 1) = 1)))`);
|
|
69
|
+
}
|
|
70
|
+
if (filter.overlapping) {
|
|
71
|
+
const busy = (event) => `${event}.deleted_at IS NULL AND ${event}.status <> 'cancelled' AND ${event}.all_day = 0
|
|
72
|
+
AND ${event}.starts_at IS NOT NULL AND ${event}.ends_at IS NOT NULL AND EXISTS (SELECT 1 FROM calendar_entries
|
|
73
|
+
bce JOIN calendars bc ON bc.id = bce.calendar_id WHERE bce.event_id = ${event}.id${account("bc")}
|
|
74
|
+
AND bce.deleted_at IS NULL AND bce.status <> 'cancelled' AND coalesce(bce.own_response, '') <> 'declined'
|
|
75
|
+
AND coalesce(bce.transparency, '') <> 'transparent')`;
|
|
76
|
+
const own = busy("e");
|
|
77
|
+
params.push(longest());
|
|
78
|
+
clauses.push(`${own} AND EXISTS (SELECT 1 FROM events e2 WHERE e2.id <> e.id AND e2.starts_at < e.ends_at
|
|
79
|
+
AND e2.ends_at > e.starts_at AND e2.starts_at >= e.starts_at - ? AND ${busy("e2")})`);
|
|
80
|
+
}
|
|
81
|
+
const limit = filter.limit ?? 100;
|
|
82
|
+
const offset = filter.offset ?? 0;
|
|
83
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000 || !Number.isInteger(offset) || offset < 0)
|
|
84
|
+
throw new CliError("validation_error", "limit takes 1–1000 and offset 0 or more");
|
|
85
|
+
return database
|
|
86
|
+
.prepare(`SELECT e.* FROM events e WHERE ${clauses.join(" AND ")} ORDER BY ${filter.newest ? "e.starts_at DESC, e.id DESC" : "e.starts_at, e.id"} LIMIT ? OFFSET ?`)
|
|
87
|
+
.all(...params, limit, offset)
|
|
88
|
+
.map(eventOf);
|
|
89
|
+
};
|