@wirecat/cli-core 0.419.0 → 0.420.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 (100) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/domain/providers.d.ts +20 -2
  3. package/dist/domain/providers.js +23 -3
  4. package/dist/domain/tag-consolidation.d.ts +30 -1
  5. package/dist/entities/index.d.ts +4 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/meetings/calendar-text.d.ts +13 -0
  9. package/dist/meetings/calendar-text.js +57 -0
  10. package/dist/meetings/event-store.d.ts +202 -0
  11. package/dist/meetings/event-store.js +1 -0
  12. package/dist/meetings/events.js +5 -1
  13. package/dist/meetings/index.d.ts +3 -0
  14. package/dist/meetings/index.js +2 -0
  15. package/dist/meetings/join-key.d.ts +7 -0
  16. package/dist/meetings/join-key.js +38 -0
  17. package/dist/meetings/model.d.ts +8 -2
  18. package/dist/meetings/read-answers.d.ts +6 -2
  19. package/dist/meetings/read-answers.js +9 -2
  20. package/dist/meetings/store.d.ts +2 -0
  21. package/dist/meetings/testing/event-store-contract.d.ts +10 -0
  22. package/dist/meetings/testing/event-store-contract.js +161 -0
  23. package/dist/meetings/testing/index.d.ts +1 -0
  24. package/dist/meetings/testing/index.js +1 -0
  25. package/dist/search/account-list.js +39 -7
  26. package/dist/services/agenda.js +5 -5
  27. package/dist/services/document-source.d.ts +7 -3
  28. package/dist/services/document-source.js +24 -12
  29. package/dist/services/documents/ingestion.d.ts +6 -1
  30. package/dist/services/documents/ingestion.js +4 -0
  31. package/dist/services/notes-search.js +3 -2
  32. package/dist/services/search-all.js +1 -0
  33. package/dist/services/task-tag-filters.d.ts +3 -0
  34. package/dist/services/task-tag-filters.js +8 -0
  35. package/dist/services/tasks.js +5 -9
  36. package/dist/store/document-transfer/index.js +12 -2
  37. package/dist/store/index.d.ts +2 -1
  38. package/dist/store/sqlite/accounts.js +1 -0
  39. package/dist/store/sqlite/calendar.d.ts +3 -0
  40. package/dist/store/sqlite/calendar.js +593 -0
  41. package/dist/store/sqlite/catalog.js +16 -5
  42. package/dist/store/sqlite/chat-rekey.js +1 -1
  43. package/dist/store/sqlite/chats.js +2 -2
  44. package/dist/store/sqlite/context-document-source.js +8 -4
  45. package/dist/store/sqlite/document-transfer/apply.js +24 -2
  46. package/dist/store/sqlite/document-transfer/collection.d.ts +19 -0
  47. package/dist/store/sqlite/document-transfer/collection.js +66 -0
  48. package/dist/store/sqlite/document-transfer/plan.js +15 -8
  49. package/dist/store/sqlite/document-transfer/snapshot.js +12 -7
  50. package/dist/store/sqlite/entity-types.d.ts +5 -1
  51. package/dist/store/sqlite/entity-types.js +5 -1
  52. package/dist/store/sqlite/events.d.ts +8 -2
  53. package/dist/store/sqlite/events.js +36 -12
  54. package/dist/store/sqlite/files.d.ts +93 -0
  55. package/dist/store/sqlite/files.js +293 -0
  56. package/dist/store/sqlite/folded.d.ts +3 -0
  57. package/dist/store/sqlite/folded.js +1 -0
  58. package/dist/store/sqlite/imported-documents.js +1 -1
  59. package/dist/store/sqlite/join-keys.d.ts +8 -0
  60. package/dist/store/sqlite/join-keys.js +27 -0
  61. package/dist/store/sqlite/knowledge.js +6 -6
  62. package/dist/store/sqlite/link-reviews.d.ts +9 -0
  63. package/dist/store/sqlite/link-reviews.js +12 -0
  64. package/dist/store/sqlite/lucene-fields.js +8 -6
  65. package/dist/store/sqlite/manifest.js +11 -0
  66. package/dist/store/sqlite/meeting-selection.js +1 -1
  67. package/dist/store/sqlite/meetings.js +6 -1
  68. package/dist/store/sqlite/migrations.generated.js +143 -0
  69. package/dist/store/sqlite/note-index.d.ts +8 -1
  70. package/dist/store/sqlite/note-index.js +14 -0
  71. package/dist/store/sqlite/note-search.js +6 -5
  72. package/dist/store/sqlite/notes.d.ts +18 -1
  73. package/dist/store/sqlite/notes.js +237 -40
  74. package/dist/store/sqlite/open-store.js +4 -0
  75. package/dist/store/sqlite/person-links.js +7 -2
  76. package/dist/store/sqlite/schema/canonical-tags.d.ts +87 -0
  77. package/dist/store/sqlite/schema/canonical-tags.js +8 -0
  78. package/dist/store/sqlite/schema/tag-consolidations.d.ts +270 -0
  79. package/dist/store/sqlite/schema/tag-consolidations.js +34 -0
  80. package/dist/store/sqlite/schema.d.ts +3589 -1634
  81. package/dist/store/sqlite/schema.js +191 -13
  82. package/dist/store/sqlite/tag-consolidation-apply.d.ts +3 -0
  83. package/dist/store/sqlite/tag-consolidation-apply.js +88 -0
  84. package/dist/store/sqlite/tag-consolidation-input.d.ts +16 -0
  85. package/dist/store/sqlite/tag-consolidation-input.js +52 -0
  86. package/dist/store/sqlite/tag-consolidation.js +51 -66
  87. package/dist/store/sqlite/tag-creation.js +17 -8
  88. package/dist/store/sqlite/tag-resolution.d.ts +5 -0
  89. package/dist/store/sqlite/tag-resolution.js +33 -0
  90. package/dist/store/sqlite/tags.d.ts +3 -1
  91. package/dist/store/sqlite/tags.js +70 -50
  92. package/dist/store/sqlite/tasks.d.ts +2 -0
  93. package/dist/store/sqlite/tasks.js +6 -1
  94. package/dist/store/sqlite/things.js +2 -1
  95. package/dist/store/sqlite/vocabulary.d.ts +1 -0
  96. package/dist/store/sqlite/vocabulary.js +30 -8
  97. package/dist/store/store.d.ts +6 -0
  98. package/dist/store/store.js +10 -0
  99. package/dist/store/tags.js +2 -2
  100. package/package.json +2 -1
@@ -191,8 +191,12 @@ export const event = v.strictObject({
191
191
  starts_at: v.nullable(isoTime),
192
192
  ends_at: v.nullable(isoTime),
193
193
  timezone: v.nullable(text),
194
- /** `auto`: made from a meeting; `owner`: made by hand. */
195
- origin: v.picklist(["auto", "owner"]),
194
+ /** `auto`: made from a meeting; `owner`: made by hand; `calendar`: from a calendar's entries. */
195
+ origin: v.picklist(["auto", "owner", "calendar"]),
196
+ status: v.nullable(text),
197
+ all_day: v.boolean(),
198
+ /** When its calendar deleted it: shown as "deleted in Google", kept as history. */
199
+ source_deleted_at: v.nullable(isoTime),
196
200
  deleted_at: v.nullable(isoTime),
197
201
  created_at: isoTime,
198
202
  updated_at: isoTime,
@@ -207,6 +211,9 @@ export const eventItem = (one) => ({
207
211
  ends_at: iso(one.endsAt),
208
212
  timezone: one.timezone,
209
213
  origin: one.origin,
214
+ status: one.status ?? null,
215
+ all_day: one.allDay === true,
216
+ source_deleted_at: iso(one.sourceDeletedAt ?? null),
210
217
  deleted_at: iso(one.deletedAt),
211
218
  created_at: new Date(one.createdAt).toISOString(),
212
219
  updated_at: new Date(one.updatedAt).toISOString(),
@@ -120,6 +120,8 @@ export interface MeetingStore {
120
120
  setEventSeries(meetingSeriesId: number, eventSeriesId: number, now: number): Promise<void>;
121
121
  /** Automatic linking must not replace an existing link. */
122
122
  linkMeeting(id: number, eventId: number, now: number, mode?: "auto" | "owner"): Promise<void>;
123
+ /** Two or more events fit a meeting equally: no link, and the pair goes to the owner's review list. */
124
+ reviewMeetingLink?(meetingId: number, eventIds: number[], now: number): Promise<void>;
123
125
  cursor(accountId: number): Promise<string | null>;
124
126
  setCursor(accountId: number, value: string, now: number): Promise<void>;
125
127
  }
@@ -0,0 +1,10 @@
1
+ import type { EventStore } from "../event-store.js";
2
+ import type { ContractCase } from "../store.js";
3
+ /** A fresh, empty store and one calendar account in it. */
4
+ export interface EventStoreFixture {
5
+ store: EventStore;
6
+ accountId: number;
7
+ }
8
+ /** "Now" in every case: entries before it have happened. */
9
+ export declare const CONTRACT_NOW: number;
10
+ export declare const eventStoreContract: (make: () => EventStoreFixture | Promise<EventStoreFixture>) => ContractCase[];
@@ -0,0 +1,161 @@
1
+ import assert from "node:assert/strict";
2
+ const HOUR = 3_600_000;
3
+ /** "Now" in every case: entries before it have happened. */
4
+ export const CONTRACT_NOW = 1_000 * HOUR;
5
+ const single = (externalId, at, extra = {}) => ({
6
+ externalId,
7
+ kind: "single",
8
+ status: "confirmed",
9
+ title: "Example review",
10
+ startsAt: at,
11
+ endsAt: at + HOUR,
12
+ ...extra,
13
+ });
14
+ export const eventStoreContract = (make) => {
15
+ const test = (name, check) => ({
16
+ name,
17
+ async run() {
18
+ const fixture = await make();
19
+ const [calendar] = await fixture.store.saveCalendars(fixture.accountId, [{ externalId: "primary", title: "Work", timezone: "UTC", accessRole: "owner", primary: true }], 1);
20
+ await check({ ...fixture, calendar: calendar.id });
21
+ },
22
+ });
23
+ const second = async ({ store, accountId }, externalId = "personal") => (await store.saveCalendars(accountId, [{ externalId, title: externalId, timezone: "UTC", accessRole: "owner", primary: false }], 1))[0].id;
24
+ const later = CONTRACT_NOW + 24 * HOUR;
25
+ const earlier = CONTRACT_NOW - 24 * HOUR;
26
+ return [
27
+ test("a calendar the owner owns syncs; one shared with them is listed and does not", async ({ store, accountId, }) => {
28
+ await store.saveCalendars(accountId, [{ externalId: "team", title: "Team", timezone: null, accessRole: "reader", primary: false }], 2);
29
+ assert.deepEqual((await store.calendars(accountId)).map(({ externalId, synced }) => [externalId, synced]), [
30
+ ["primary", true],
31
+ ["team", false],
32
+ ]);
33
+ }),
34
+ test("rule 1: the same calendar and id is the same entry; a change keeps the old values", async ({ store, calendar, }) => {
35
+ const [first] = await store.saveEntries(calendar, [single("e1", later)], 2);
36
+ const [again] = await store.saveEntries(calendar, [single("e1", later + HOUR, { title: "Moved review" })], 3);
37
+ assert.equal(again?.id, first?.id);
38
+ assert.equal(again?.eventId, first?.eventId);
39
+ const details = await store.event(first?.eventId);
40
+ assert.equal(details?.event.title, "Moved review");
41
+ assert.equal(details?.event.startsAt, later + HOUR);
42
+ assert.equal(details?.event.origin, "calendar");
43
+ assert.deepEqual(details?.revisions.map(({ title, startsAt }) => [title, startsAt]), [["Example review", later]]);
44
+ }),
45
+ test("rule 2: a plain occurrence and the exception of its slot are one entry, the series in any order", async ({ store, calendar, }) => {
46
+ const slot = later;
47
+ const occurrence = (externalId, extra = {}) => single(externalId, slot, { kind: "occurrence", seriesExternalId: "s1", originalStartsAt: slot, ...extra });
48
+ const [plain] = await store.saveEntries(calendar, [occurrence("s1_slot")], 2);
49
+ await store.saveEntries(calendar, [{ ...single("s1", earlier), kind: "series", recurrence: ["RRULE:FREQ=WEEKLY"], recurrenceEndsAt: null }], 3);
50
+ const [exception] = await store.saveEntries(calendar, [occurrence("s1_moved", { exception: true, startsAt: slot + HOUR, endsAt: slot + 2 * HOUR })], 4);
51
+ assert.equal(exception?.id, plain?.id);
52
+ assert.notEqual(exception?.seriesEntryId, null);
53
+ const details = await store.event(exception?.eventId);
54
+ assert.equal(details?.event.startsAt, slot + HOUR);
55
+ assert.notEqual(details?.event.eventSeriesId, null);
56
+ }),
57
+ test("rule 3: another calendar's copy with the same iCalUID is the same event", async (fixture) => {
58
+ const { store, calendar } = fixture;
59
+ const other = await second(fixture);
60
+ const [work] = await store.saveEntries(calendar, [single("w1", later, { icalUid: "uid-1@example.com" })], 2);
61
+ const [home] = await store.saveEntries(other, [single("p1", later, { icalUid: "uid-1@example.com" })], 3);
62
+ assert.equal(home?.eventId, work?.eventId);
63
+ assert.equal((await store.event(work?.eventId))?.entries.length, 2);
64
+ }),
65
+ test("rule 5: otherwise a new event of origin calendar; a slot cancelled before it was stored is none", async ({ store, calendar, }) => {
66
+ const [one, two, gone] = await store.saveEntries(calendar, [single("a", later), single("b", later), single("c", later, { status: "cancelled" })], 2);
67
+ assert.notEqual(one?.eventId, two?.eventId);
68
+ assert.equal(gone?.eventId, null);
69
+ assert.equal((await store.events({ from: later, to: later + HOUR })).length, 2);
70
+ }),
71
+ test("two candidates: no link and one review entry, kept through a second sync and an owner's decision", async (fixture) => {
72
+ const { store, calendar } = fixture;
73
+ const other = await second(fixture);
74
+ const third = await second(fixture, "third");
75
+ const [a] = await store.saveEntries(calendar, [single("a", later, { icalUid: "first" })], 2);
76
+ const [b] = await store.saveEntries(other, [single("b", later, { icalUid: "second" })], 2);
77
+ await store.saveEntries(calendar, [single("a", later, { icalUid: "shared" })], 3);
78
+ await store.saveEntries(other, [single("b", later, { icalUid: "shared" })], 3);
79
+ const [c] = await store.saveEntries(third, [single("c", later, { icalUid: "shared" })], 4);
80
+ assert.equal(c?.eventId, null);
81
+ await store.saveEntries(third, [single("c", later, { icalUid: "shared" })], 5);
82
+ const open = await store.reviews({ status: "open" });
83
+ assert.equal(open.length, 1);
84
+ assert.deepEqual(open[0]?.candidates, [a?.eventId, b?.eventId].sort((x, y) => x - y));
85
+ await store.resolveReview(open[0]?.id, { choose: b?.eventId }, 6);
86
+ await store.saveEntries(third, [single("c", later, { icalUid: "shared" })], 7);
87
+ const [after] = await store.saveEntries(third, [single("c", later, { icalUid: "shared" })], 8);
88
+ assert.equal(after?.eventId, b?.eventId);
89
+ assert.deepEqual((await store.reviews()).map(({ status }) => status), ["linked"]);
90
+ }),
91
+ test("the owner's unlink holds: no rule links the entry again", async ({ store, calendar }) => {
92
+ const [entry] = await store.saveEntries(calendar, [single("a", later)], 2);
93
+ await store.unlinkEntry(entry?.id, 3);
94
+ const [again] = await store.saveEntries(calendar, [single("a", later, { title: "Renamed" })], 4);
95
+ assert.equal(again?.eventId, null);
96
+ await store.linkEntry(entry?.id, entry?.eventId, 5);
97
+ const [linked] = await store.saveEntries(calendar, [single("a", later)], 6);
98
+ assert.equal(linked?.eventId, entry?.eventId);
99
+ }),
100
+ test("a deleted single event: past stays as history, future is cancelled", async ({ store, calendar }) => {
101
+ const [past, future] = await store.saveEntries(calendar, [single("p", earlier), single("f", later)], 2);
102
+ await store.saveEntries(calendar, [single("p", earlier, { status: "cancelled" }), single("f", later, { status: "cancelled" })], CONTRACT_NOW);
103
+ const happened = (await store.event(past?.eventId))?.event;
104
+ assert.equal(happened?.sourceDeletedAt, CONTRACT_NOW);
105
+ assert.notEqual(happened?.status, "cancelled");
106
+ assert.equal(happened?.title, "Example review");
107
+ const cancelled = (await store.event(future?.eventId))?.event;
108
+ assert.equal(cancelled?.status, "cancelled");
109
+ assert.equal(cancelled?.sourceDeletedAt, null);
110
+ }),
111
+ test("a whole series deleted: its entries cancel, past occurrences stay, future ones cancel", async ({ store, calendar, }) => {
112
+ const series = {
113
+ ...single("s", earlier - HOUR),
114
+ kind: "series",
115
+ icalUid: "series@example.com",
116
+ recurrence: ["RRULE:FREQ=DAILY"],
117
+ };
118
+ const occurrence = (at) => single(`s_${at}`, at, { kind: "occurrence", seriesExternalId: "s", originalStartsAt: at });
119
+ const [, past, future] = await store.saveEntries(calendar, [series, occurrence(earlier), occurrence(later)], 2);
120
+ await store.saveEntries(calendar, [{ ...series, status: "cancelled" }], CONTRACT_NOW);
121
+ const before = await store.event(past?.eventId);
122
+ assert.equal(before?.event.sourceDeletedAt, CONTRACT_NOW);
123
+ assert.equal(before?.entries[0]?.status, "cancelled");
124
+ const after = await store.event(future?.eventId);
125
+ assert.equal(after?.event.status, "cancelled");
126
+ }),
127
+ test("a full list marks the entries it did not return, plain occurrences only inside the window", async ({ store, calendar, }) => {
128
+ const outside = later + 30 * 24 * HOUR;
129
+ const [kept, gone, far] = await store.saveEntries(calendar, [
130
+ single("kept", later),
131
+ single("gone", later),
132
+ single("far", outside, { kind: "occurrence", seriesExternalId: "x", originalStartsAt: outside }),
133
+ ], 2);
134
+ await store.saveEntries(calendar, [single("kept", later)], 10);
135
+ assert.equal(await store.markUnseen(calendar, 10, { from: earlier, to: later + 24 * HOUR }, 11), 1);
136
+ const entries = new Map((await store.event(gone?.eventId))?.entries.map((entry) => [entry.externalId, entry]));
137
+ assert.equal(entries.get("gone")?.deletedAt, 11);
138
+ assert.notEqual(await store.event(gone?.eventId), null);
139
+ assert.equal((await store.event(kept?.eventId))?.entries[0]?.deletedAt, null);
140
+ assert.equal((await store.event(far?.eventId))?.entries[0]?.deletedAt, null);
141
+ }),
142
+ test("participants are kept by address; a room is listed and is no contact", async ({ store, calendar }) => {
143
+ const [entry] = await store.saveEntries(calendar, [
144
+ single("a", later, {
145
+ participants: [
146
+ { email: "Alice@Example.com", name: "Alice Example", response: "accepted" },
147
+ { email: "room-1@resource.example.com", name: "Room 1", resource: true },
148
+ ],
149
+ }),
150
+ ], 2);
151
+ const participants = (await store.event(entry?.eventId))?.participants ?? [];
152
+ assert.deepEqual(participants.map(({ email, resource, identityId }) => [email, resource, identityId === null]), [
153
+ ["alice@example.com", false, false],
154
+ ["room-1@resource.example.com", true, true],
155
+ ]);
156
+ }),
157
+ test("an entry that ends before it starts is refused", async ({ store, calendar }) => {
158
+ await assert.rejects(store.saveEntries(calendar, [single("bad", later, { endsAt: later - 1 })], 2));
159
+ }),
160
+ ];
161
+ };
@@ -1,4 +1,5 @@
1
1
  export { meetingStoreContract } from "./contract.js";
2
+ export { CONTRACT_NOW, type EventStoreFixture, eventStoreContract } from "./event-store-contract.js";
2
3
  export { type MeetingManagementContractFixture, meetingManagementContract } from "./management-contract.js";
3
4
  export { memoryMeetingStore } from "./memory.js";
4
5
  export { type RecordingSyncCheckpointFixture, recordingSyncCheckpointStoreContract, } from "./recording-sync-contract.js";
@@ -1,4 +1,5 @@
1
1
  export { meetingStoreContract } from "./contract.js";
2
+ export { CONTRACT_NOW, eventStoreContract } from "./event-store-contract.js";
2
3
  export { meetingManagementContract } from "./management-contract.js";
3
4
  export { memoryMeetingStore } from "./memory.js";
4
5
  export { recordingSyncCheckpointStoreContract, } from "./recording-sync-contract.js";
@@ -1,5 +1,5 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
- import { FOLDER_PROVIDER, INTERNAL_NOTES, isKind, kindIn, kindOf, providerNamed, providers as registered, UNKNOWN_PROVIDER, } from "../domain/providers.js";
2
+ import { INTERNAL_NOTES, isDocumentAccountProvider, isFolderProvider, isKind, kindIn, kindOf, providerNamed, providers as registered, UNKNOWN_PROVIDER, } from "../domain/providers.js";
3
3
  import { isBotProvider } from "./query.js";
4
4
  /** Store-wide: every profile, tg, MAX and memo reads the same groups. */
5
5
  export const ACCOUNT_GROUPS = "accountGroups";
@@ -29,13 +29,29 @@ const describe = (accounts) => accounts.map(({ provider, account, name }) => `${
29
29
  const kindHere = (provider, running) => provider === running.provider ? kindOf(provider) : kindIn(provider);
30
30
  const heldOf = (stored, running) => [
31
31
  ...stored
32
- .filter(({ provider }) => kindIn(provider) !== "notes")
32
+ .filter(({ provider }) => kindIn(provider) !== "notes" && !isDocumentAccountProvider(provider))
33
33
  .map(({ provider, account, name }) => ({ provider, account, name, kind: kindHere(provider, running) })),
34
34
  { provider: "notes", account: INTERNAL_NOTES, name: null, kind: "notes" },
35
35
  ...stored
36
- .filter(({ provider }) => provider === FOLDER_PROVIDER)
37
- .map(({ id, name }) => ({ provider: "notes", account: String(id), name, kind: "notes" })),
36
+ .filter(({ provider }) => isFolderProvider(provider))
37
+ .map(({ id, name, provider }) => ({
38
+ provider: "notes",
39
+ account: String(id),
40
+ name,
41
+ kind: "notes",
42
+ folder: provider,
43
+ })),
44
+ ...stored
45
+ .filter(({ provider }) => isDocumentAccountProvider(provider))
46
+ .map(({ id, provider, account, name }) => ({
47
+ provider: "notes",
48
+ account: String(id),
49
+ name,
50
+ kind: "notes",
51
+ stored: { provider, account },
52
+ })),
38
53
  ];
54
+ const storedAs = (held, key) => held.filter(({ stored }) => stored?.provider === key.provider && (key.account === "" || stored.account === key.account));
39
55
  const registry = () => registered()
40
56
  .map(({ name, aliases }) => (aliases.length ? `${name} (${aliases.join(", ")})` : name))
41
57
  .join(", ");
@@ -44,7 +60,7 @@ const providerOf = (prefix, held, { running, profiles }) => {
44
60
  if (wanted === running.command?.toLowerCase())
45
61
  return running.provider;
46
62
  const providers = [...new Set(held.map(({ provider }) => provider))];
47
- if (providers.includes(wanted))
63
+ if (providers.includes(wanted) || isDocumentAccountProvider(wanted))
48
64
  return wanted;
49
65
  const named = providerNamed(wanted);
50
66
  if (named)
@@ -57,6 +73,11 @@ const providerOf = (prefix, held, { running, profiles }) => {
57
73
  const profilesOf = (provider, profiles) => Object.values(profiles).flatMap((named) => Object.entries(named).filter(([, key]) => key.provider === provider));
58
74
  const accountOf = (prefix, ref, held, context) => {
59
75
  const provider = providerOf(prefix, held, context);
76
+ if (isDocumentAccountProvider(provider) && !context.profiles[prefix.toLowerCase()]?.[ref]) {
77
+ const stored = storedAs(held, { provider, account: ref })[0];
78
+ if (stored)
79
+ return stored;
80
+ }
60
81
  const ofProvider = held.filter((one) => one.provider === provider);
61
82
  const byId = ofProvider.find(({ account }) => account === ref || account === ref.toLowerCase());
62
83
  if (byId)
@@ -67,12 +88,18 @@ const accountOf = (prefix, ref, held, context) => {
67
88
  kind: kindHere(key.provider, context.running),
68
89
  };
69
90
  const recorded = context.profiles[prefix.toLowerCase()]?.[ref];
70
- if (recorded?.provider === FOLDER_PROVIDER) {
91
+ if (recorded && isFolderProvider(recorded.provider)) {
71
92
  const folder = held.find((one) => one.provider === "notes" && one.name === recorded.account);
72
93
  if (folder)
73
94
  return folder;
74
95
  return fail(`no stored folder for ${prefix}:${ref}; sync that profile first`);
75
96
  }
97
+ if (recorded && isDocumentAccountProvider(recorded.provider)) {
98
+ const account = storedAs(held, recorded)[0];
99
+ if (account)
100
+ return account;
101
+ return fail(`no stored documents for ${prefix}:${ref}; sync that profile first`);
102
+ }
76
103
  if (recorded)
77
104
  return keyed(recorded);
78
105
  const byProfile = [
@@ -101,9 +128,14 @@ const accountsOfWord = (word, held, context) => {
101
128
  if (isKind(wanted))
102
129
  return held.filter(({ kind }) => kind === wanted);
103
130
  const recorded = context.profiles[wanted];
104
- if (recorded && Object.values(recorded).every((key) => key.provider === FOLDER_PROVIDER))
131
+ if (recorded &&
132
+ Object.values(recorded).every((key) => isFolderProvider(key.provider) || isDocumentAccountProvider(key.provider)))
105
133
  return Object.keys(recorded).map((profile) => accountOf(word, profile, held, context));
106
134
  const provider = providerOf(word, held, context);
135
+ if (isDocumentAccountProvider(provider))
136
+ return storedAs(held, { provider, account: "" });
137
+ if (isFolderProvider(provider))
138
+ return held.filter(({ folder }) => folder === provider);
107
139
  return held.filter((one) => one.provider === provider);
108
140
  };
109
141
  const keyOf = ({ provider, account }) => `${provider}\u0000${account}`;
@@ -5,6 +5,7 @@ import { agenda } from "../entities/agenda.js";
5
5
  import { AGENDA_COLUMNS, inboxKey, queryFilter, } from "../store/store.js";
6
6
  import { CHAT_KINDS } from "./chats.js";
7
7
  import { taskAccount } from "./task-rules.js";
8
+ import { taskTagFilters } from "./task-tag-filters.js";
8
9
  const DAY = 86_400_000;
9
10
  const CLAIMS = ["none", "mine", "others", "any"];
10
11
  const TASK_FIELDS = new Set(["tag", "category", "claimed"]);
@@ -46,8 +47,9 @@ export const agendaService = (deps, now = Date.now) => ({
46
47
  throw error;
47
48
  });
48
49
  const open = await find();
50
+ const tags = await taskTagFilters(store, parts.tasks);
49
51
  const listed = parts.tasks
50
- ? await find(queryFilter(parts.tasks, taskColumns(holder, at), { command: "tasks agenda" }))
52
+ ? await find(queryFilter(parts.tasks, taskColumns(holder, at, tags), { command: "tasks agenda" }))
51
53
  : open;
52
54
  const tasks = listed
53
55
  .map((task) => ({ task, reasons: reasonsOf(task, at, dueWithin) }))
@@ -148,11 +150,9 @@ const split = (query) => {
148
150
  };
149
151
  return { tasks: of("tasks"), chats: of("chats") };
150
152
  };
151
- const TAGGED = "EXISTS (SELECT 1 FROM taggings g JOIN tags n ON n.id = g.tag_id " +
152
- "WHERE g.taggable_type = 'task' AND g.taggable_id = t.id AND n.name = ?)";
153
153
  /** A claim that lapsed holds nothing, as `tasks claim` reads it. */
154
- const taskColumns = (holder, at) => ({
155
- tag: { where: (node) => ({ sql: TAGGED, params: [node.value.trim().toLowerCase()] }) },
154
+ const taskColumns = (holder, at, tags) => ({
155
+ tag: { where: (node) => tags.get(node.value) ?? { sql: "0", params: [] } },
156
156
  category: { type: "kw", sql: "t.category" },
157
157
  claimed: {
158
158
  where: (node) => {
@@ -1,7 +1,11 @@
1
1
  import type { Profiles } from "../search/account-list.js";
2
- import type { Note, NoteFolder } from "../store/index.js";
3
- /** Imported sources keep their remote edit time; indexing time still belongs to the local row. */
4
- export declare const documentSource: (note: Note, folders: NoteFolder[], profiles: Profiles) => {
2
+ import type { Note, StoredAccount, StoredFile } from "../store/index.js";
3
+ /**
4
+ * Where a hit came from. The document's preferred file says it: its account's provider, its link and its edit time.
5
+ * A Drive document under a notes folder (before version 31 moves it) is still known by its front matter.
6
+ * Indexing time belongs to the local row.
7
+ */
8
+ export declare const documentSource: (note: Note, accounts: readonly StoredAccount[], profiles: Profiles, file?: StoredFile) => {
5
9
  kind: "document" | "note";
6
10
  provider: string;
7
11
  account: string;
@@ -1,18 +1,30 @@
1
- /** Imported sources keep their remote edit time; indexing time still belongs to the local row. */
2
- export const documentSource = (note, folders, profiles) => {
1
+ import { isFolderProvider, kindIn } from "../domain/providers.js";
2
+ /**
3
+ * Where a hit came from. The document's preferred file says it: its account's provider, its link and its edit time.
4
+ * A Drive document under a notes folder (before version 31 moves it) is still known by its front matter.
5
+ * Indexing time belongs to the local row.
6
+ */
7
+ export const documentSource = (note, accounts, profiles, file) => {
3
8
  const metadata = note.frontMatter;
4
- const folder = folders.find(({ id }) => id === note.folderId);
5
- const remote = note.source === "file" && metadata?.source === "google-drive";
6
- const profile = remote && folder
7
- ? Object.entries(profiles.drive ?? {}).find(([, key]) => key.provider === "folder" && key.account === folder.name)?.[0]
9
+ const account = accounts.find(({ id }) => String(id) === note.folderId);
10
+ const remote = note.source === "file" &&
11
+ ((account !== undefined && kindIn(account.provider) === "documents") || metadata?.source === "google-drive");
12
+ const profile = remote && account
13
+ ? Object.entries(profiles)
14
+ .flatMap(([command, named]) => Object.entries(named).map(([name, key]) => ({ command, name, key })))
15
+ .find(({ key }) => (key.provider === account.provider && key.account === account.account) ||
16
+ (isFolderProvider(key.provider) && key.account === account.name))
8
17
  : undefined;
18
+ const url = file?.url ?? (remote && typeof metadata?.sourceUrl === "string" ? metadata.sourceUrl : undefined);
19
+ const modified = file?.externalUpdatedAt ??
20
+ (remote && typeof metadata?.modifiedAt === "string" && Number.isFinite(Date.parse(metadata.modifiedAt))
21
+ ? metadata.modifiedAt
22
+ : undefined);
9
23
  return {
10
24
  kind: remote ? "document" : "note",
11
- provider: profile ? "drive" : "notes",
12
- account: profile ?? note.folderId ?? "internal",
13
- ...(remote && typeof metadata?.sourceUrl === "string" ? { url: metadata.sourceUrl } : {}),
14
- updated_at: remote && typeof metadata?.modifiedAt === "string" && Number.isFinite(Date.parse(metadata.modifiedAt))
15
- ? metadata.modifiedAt
16
- : note.updatedAt,
25
+ provider: profile ? profile.command : "notes",
26
+ account: profile?.name ?? note.folderId ?? "internal",
27
+ ...(remote && url !== undefined ? { url } : {}),
28
+ updated_at: remote && modified !== undefined ? modified : note.updatedAt,
17
29
  };
18
30
  };
@@ -1,7 +1,12 @@
1
1
  import type { SourceContent, SourceFile } from "../../documents/model.js";
2
2
  import type { CatalogInput, CatalogQuery, MessageStore } from "../../store/index.js";
3
3
  export interface IngestionOptions<M extends SourceFile> {
4
- collectionName(principal: string): string;
4
+ /**
5
+ * The documents account's provider (`google-drive`): each principal's documents live under
6
+ * `{ provider, account: principal }`. Without it they live in a notes folder named `collectionName(principal)`.
7
+ */
8
+ provider?: string;
9
+ collectionName?(principal: string): string;
5
10
  externalId(id: string): string;
6
11
  catalogInput(file: M): CatalogInput;
7
12
  frontMatter(content: SourceContent<M>): Record<string, unknown>;
@@ -2,6 +2,10 @@ import { CliError } from "@wirecat/cli-base";
2
2
  import { walkQuery } from "@wirecat/cli-base/query";
3
3
  export const documentIngestion = (store, options) => {
4
4
  const folder = async (principal, create) => {
5
+ if (options.provider)
6
+ return store.notes.documentsAccount({ provider: options.provider, account: principal }, create);
7
+ if (!options.collectionName)
8
+ throw new CliError("configuration_error", "document ingestion needs a provider or a collection name");
5
9
  const name = options.collectionName(principal);
6
10
  const found = (await store.notes.folders()).find((item) => item.name === name);
7
11
  return found ?? (create ? await store.notes.addFolder({ name, format: "markdown" }) : undefined);
@@ -295,7 +295,7 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
295
295
  const hasMore = slice ? matched.hasMore : ordered.length > window || matched.hasMore || titled.hasMore;
296
296
  const hits = [];
297
297
  const counts = new Map();
298
- const folders = await store.notes.folders();
298
+ const accounts = await store.accounts.storedAccounts();
299
299
  const profiles = await store.accounts.profiles();
300
300
  for (const [ref, { note, foundBy }] of page) {
301
301
  const links = (await store.notes.links({ from: ref })).map(({ to, targetText, kind, anchor }) => ({
@@ -310,7 +310,7 @@ export const searchNotes = async (store, query, { limit = 20, offset = 0, tag, f
310
310
  folder_id: note.folderId,
311
311
  path: note.path,
312
312
  title: note.title,
313
- ...documentSource(note, folders, profiles),
313
+ ...documentSource(note, accounts, profiles, note.source === "file" ? (await store.files.ofDocument(note.ref))[0] : undefined),
314
314
  excerpt: firstLine(note.text, words),
315
315
  links,
316
316
  found_by: foundBy,
@@ -374,6 +374,7 @@ export const searchNotesOf = async (store, account, text, { accounts: list, app,
374
374
  const held = [
375
375
  { provider: "notes", account: INTERNAL_NOTES },
376
376
  ...(await store.notes.folders()).map(({ id }) => ({ provider: "notes", account: id })),
377
+ ...(await store.notes.documentsAccounts()).map(({ id }) => ({ provider: "notes", account: id })),
377
378
  ];
378
379
  const named = list === undefined
379
380
  ? { accounts: held, skipped: [] }
@@ -109,6 +109,7 @@ export const searchAll = async (store, account, request, messenger = {}, searchM
109
109
  const read = chosen?.map(({ account }) => account) ?? [
110
110
  INTERNAL_NOTES,
111
111
  ...(await store.notes.folders()).map(({ id }) => id),
112
+ ...(await store.notes.documentsAccounts()).map(({ id }) => id),
112
113
  ];
113
114
  accounts.push(...read.map((account) => ({ resource, provider: "notes", account })));
114
115
  }
@@ -0,0 +1,3 @@
1
+ import { type QueryAst } from "@wirecat/cli-base/query";
2
+ import type { MessageStore, Where } from "../store/store.js";
3
+ export declare const taskTagFilters: (store: MessageStore, query: QueryAst | undefined) => Promise<Map<string, Where>>;
@@ -0,0 +1,8 @@
1
+ import { walkQuery } from "@wirecat/cli-base/query";
2
+ export const taskTagFilters = async (store, query) => {
3
+ const tags = new Map();
4
+ for (const node of query ? walkQuery(query.root) : [])
5
+ if (node.field === "tag" && !tags.has(node.value))
6
+ tags.set(node.value, await store.tasks.tagWhere(node.value));
7
+ return tags;
8
+ };
@@ -9,6 +9,7 @@ import { createTaskService, TASK_KINDS, TASK_STATES, } from "../tasks/index.js";
9
9
  import { storedChatId } from "./message-resolution.js";
10
10
  import { categorize, taskCategoryOf } from "./task-categories.js";
11
11
  import { taskAccount } from "./task-rules.js";
12
+ import { taskTagFilters } from "./task-tag-filters.js";
12
13
  export { TASK_KINDS, TASK_STATES };
13
14
  export const TASK_QUERY = {
14
15
  entity: "task",
@@ -68,17 +69,11 @@ export const TASK_PAIR_QUERY = {
68
69
  };
69
70
  const TIME = (sql) => ({ type: "time", sql });
70
71
  /** `chat:` names the chat as the messenger does; `groups` holds each name already resolved to its stored id. */
71
- const taskColumns = (groups) => ({
72
+ const taskColumns = (groups, tags) => ({
72
73
  id: { type: "kw", sql: "t.package_id" },
73
74
  key: { type: "kw", sql: "t.key" },
74
75
  category: { type: "kw", sql: "t.category" },
75
- tag: {
76
- where: (node) => ({
77
- sql: "EXISTS (SELECT 1 FROM taggings g JOIN tags n ON n.id = g.tag_id " +
78
- "WHERE g.taggable_type = 'task' AND g.taggable_id = t.id AND n.name = ?)",
79
- params: [node.value.trim().toLowerCase()],
80
- }),
81
- },
76
+ tag: { where: (node) => tags.get(node.value) ?? { sql: "0", params: [] } },
82
77
  title: { type: "kw", sql: "t.title_folded", folded: true },
83
78
  kind: { type: "enum", sql: "t.type" },
84
79
  state: { type: "enum", sql: "t.status" },
@@ -213,7 +208,8 @@ export const tasksService = (deps, categories) => {
213
208
  for (const node of query ? walkQuery(query.root) : [])
214
209
  if (node.field === "chat" && node.operator !== "wildcard" && !groups.has(node.value))
215
210
  groups.set(node.value, await storedChatId(deps.messenger, node.value, store, account, { includeLeft: true }));
216
- const where = query ? queryFilter(query, taskColumns(groups), { command: "tasks list" }) : undefined;
211
+ const tags = await taskTagFilters(store, query);
212
+ const where = query ? queryFilter(query, taskColumns(groups, tags), { command: "tasks list" }) : undefined;
217
213
  const rows = await store.tasks.query(taskAccount(account), where, { limit: limit + 1, offset });
218
214
  const shown = rows.slice(0, limit);
219
215
  return {
@@ -7,6 +7,7 @@ import { planTransfer } from "../sqlite/document-transfer/plan.js";
7
7
  import { transferSnapshot } from "../sqlite/document-transfer/snapshot.js";
8
8
  import { openSqlite } from "../sqlite/open.js";
9
9
  import { openStoreContext } from "../sqlite/open-store.js";
10
+ import { assertReadCompatible } from "../sqlite/read-compatibility.js";
10
11
  import { digest } from "./digest.js";
11
12
  const locations = (options) => {
12
13
  for (const value of [options.source, options.destination, options.collection])
@@ -51,8 +52,17 @@ const openSource = async (path) => {
51
52
  }
52
53
  };
53
54
  const openPreviewTarget = async (path) => {
54
- if (statSync(path, { throwIfNoEntry: false }))
55
- return openSource(path);
55
+ if (statSync(path, { throwIfNoEntry: false })) {
56
+ const opened = await openSource(path);
57
+ try {
58
+ assertReadCompatible(opened.database);
59
+ return opened;
60
+ }
61
+ catch (error) {
62
+ opened.database.close();
63
+ throw error;
64
+ }
65
+ }
56
66
  const target = await openSqlite(":memory:");
57
67
  try {
58
68
  migrate(target.database);
@@ -1,6 +1,6 @@
1
1
  export type { ContextEvidenceItem, ContextEvidencePage, ContextEvidenceReader, ContextEvidenceRequest, ContextEvidenceSender, ContextSource, } from "../context/evidence.js";
2
2
  export { FACT_STATUSES, FACT_TYPES, MEMORY_KINDS, MEMORY_SCOPES, MEMORY_STATUSES, TASK_TYPES, WORK_TASK_TYPES, } from "../domain/knowledge.js";
3
- export type { TagConsolidationOptions, TagConsolidationPreview, TagConsolidationStore, } from "../domain/tag-consolidation.js";
3
+ export type { TagConsolidationApplyOptions, TagConsolidationOptions, TagConsolidationPreview, TagConsolidationReceipt, TagConsolidationStore, } from "../domain/tag-consolidation.js";
4
4
  export type { TagRedirectIndex, TagRedirectResolution, TagRedirectSnapshot } from "../domain/tag-redirects.js";
5
5
  export { createTagRedirectIndex } from "../domain/tag-redirects.js";
6
6
  export { note } from "../entities/note.js";
@@ -31,6 +31,7 @@ export { CATALOG_QUERY } from "./sqlite/catalog.js";
31
31
  export type { ChatMetadata } from "./sqlite/chats.js";
32
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";
33
33
  export type { Fact, FactInput, FactsStore } from "./sqlite/facts.js";
34
+ export type { FileListQuery, FilesStore, StoredFile } from "./sqlite/files.js";
34
35
  export { IMPORTED_DOCUMENT_QUERY, type ImportedDocuments } from "./sqlite/imported-documents.js";
35
36
  export type { Involvement, InvolvementStore } from "./sqlite/involvements.js";
36
37
  export type { Annotation, KnowledgeRelation, KnowledgeStore, KnowledgeTarget, LabelledType, Organization, Project, Reminder, } from "./sqlite/knowledge.js";
@@ -111,6 +111,7 @@ export const purgeAccount = ({ orm, database }, accountKey) => {
111
111
  orm.delete(accountIdentities).where(eq(accountIdentities.accountId, accountKey)).run();
112
112
  // The inbox project and its tasks are the owner's; only the tie to the account goes.
113
113
  orm.update(projects).set({ accountId: null }).where(eq(projects.accountId, accountKey)).run();
114
+ orm.run(sql `DELETE FROM files WHERE account_id = ${accountKey}`);
114
115
  orm.delete(accounts).where(eq(accounts.id, accountKey)).run();
115
116
  dropOrphanPeople(database, unseenBefore);
116
117
  orm.run(sql `DELETE FROM embeddings WHERE content_hash IN (SELECT value FROM json_each(${JSON.stringify(hashes)}))
@@ -0,0 +1,3 @@
1
+ import { type EventStore } from "../../meetings/index.js";
2
+ import type { StoreContext } from "./open.js";
3
+ export declare const eventStoreOver: (context: StoreContext) => EventStore;