@wirecat/cli-core 0.418.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 (101) hide show
  1. package/CHANGELOG.md +42 -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/parity/bin.js +3 -1
  26. package/dist/search/account-list.js +39 -7
  27. package/dist/services/agenda.js +5 -5
  28. package/dist/services/document-source.d.ts +7 -3
  29. package/dist/services/document-source.js +24 -12
  30. package/dist/services/documents/ingestion.d.ts +6 -1
  31. package/dist/services/documents/ingestion.js +4 -0
  32. package/dist/services/notes-search.js +3 -2
  33. package/dist/services/search-all.js +1 -0
  34. package/dist/services/task-tag-filters.d.ts +3 -0
  35. package/dist/services/task-tag-filters.js +8 -0
  36. package/dist/services/tasks.js +5 -9
  37. package/dist/store/document-transfer/index.js +12 -2
  38. package/dist/store/index.d.ts +2 -1
  39. package/dist/store/sqlite/accounts.js +1 -0
  40. package/dist/store/sqlite/calendar.d.ts +3 -0
  41. package/dist/store/sqlite/calendar.js +593 -0
  42. package/dist/store/sqlite/catalog.js +16 -5
  43. package/dist/store/sqlite/chat-rekey.js +1 -1
  44. package/dist/store/sqlite/chats.js +2 -2
  45. package/dist/store/sqlite/context-document-source.js +8 -4
  46. package/dist/store/sqlite/document-transfer/apply.js +24 -2
  47. package/dist/store/sqlite/document-transfer/collection.d.ts +19 -0
  48. package/dist/store/sqlite/document-transfer/collection.js +66 -0
  49. package/dist/store/sqlite/document-transfer/plan.js +15 -8
  50. package/dist/store/sqlite/document-transfer/snapshot.js +12 -7
  51. package/dist/store/sqlite/entity-types.d.ts +5 -1
  52. package/dist/store/sqlite/entity-types.js +5 -1
  53. package/dist/store/sqlite/events.d.ts +8 -2
  54. package/dist/store/sqlite/events.js +36 -12
  55. package/dist/store/sqlite/files.d.ts +93 -0
  56. package/dist/store/sqlite/files.js +293 -0
  57. package/dist/store/sqlite/folded.d.ts +3 -0
  58. package/dist/store/sqlite/folded.js +1 -0
  59. package/dist/store/sqlite/imported-documents.js +1 -1
  60. package/dist/store/sqlite/join-keys.d.ts +8 -0
  61. package/dist/store/sqlite/join-keys.js +27 -0
  62. package/dist/store/sqlite/knowledge.js +6 -6
  63. package/dist/store/sqlite/link-reviews.d.ts +9 -0
  64. package/dist/store/sqlite/link-reviews.js +12 -0
  65. package/dist/store/sqlite/lucene-fields.js +8 -6
  66. package/dist/store/sqlite/manifest.js +11 -0
  67. package/dist/store/sqlite/meeting-selection.js +1 -1
  68. package/dist/store/sqlite/meetings.js +6 -1
  69. package/dist/store/sqlite/migrations.generated.js +143 -0
  70. package/dist/store/sqlite/note-index.d.ts +8 -1
  71. package/dist/store/sqlite/note-index.js +14 -0
  72. package/dist/store/sqlite/note-search.js +6 -5
  73. package/dist/store/sqlite/notes.d.ts +18 -1
  74. package/dist/store/sqlite/notes.js +237 -40
  75. package/dist/store/sqlite/open-store.js +4 -0
  76. package/dist/store/sqlite/person-links.js +7 -2
  77. package/dist/store/sqlite/schema/canonical-tags.d.ts +87 -0
  78. package/dist/store/sqlite/schema/canonical-tags.js +8 -0
  79. package/dist/store/sqlite/schema/tag-consolidations.d.ts +270 -0
  80. package/dist/store/sqlite/schema/tag-consolidations.js +34 -0
  81. package/dist/store/sqlite/schema.d.ts +3589 -1634
  82. package/dist/store/sqlite/schema.js +191 -13
  83. package/dist/store/sqlite/tag-consolidation-apply.d.ts +3 -0
  84. package/dist/store/sqlite/tag-consolidation-apply.js +88 -0
  85. package/dist/store/sqlite/tag-consolidation-input.d.ts +16 -0
  86. package/dist/store/sqlite/tag-consolidation-input.js +52 -0
  87. package/dist/store/sqlite/tag-consolidation.js +51 -66
  88. package/dist/store/sqlite/tag-creation.js +17 -8
  89. package/dist/store/sqlite/tag-resolution.d.ts +5 -0
  90. package/dist/store/sqlite/tag-resolution.js +33 -0
  91. package/dist/store/sqlite/tags.d.ts +3 -1
  92. package/dist/store/sqlite/tags.js +70 -50
  93. package/dist/store/sqlite/tasks.d.ts +2 -0
  94. package/dist/store/sqlite/tasks.js +6 -1
  95. package/dist/store/sqlite/things.js +2 -1
  96. package/dist/store/sqlite/vocabulary.d.ts +1 -0
  97. package/dist/store/sqlite/vocabulary.js +30 -8
  98. package/dist/store/store.d.ts +6 -0
  99. package/dist/store/store.js +10 -0
  100. package/dist/store/tags.js +2 -2
  101. package/package.json +2 -1
@@ -425,7 +425,7 @@ export const replaceAutoTags = (context, key, chatId, algorithm, matches) => {
425
425
  const pk = metadataChatPk(context, key, chatId);
426
426
  const db = context.database;
427
427
  db.prepare("DELETE FROM taggings WHERE taggable_type='chat' AND taggable_id=? AND source='auto'").run(pk);
428
- db.prepare("DELETE FROM auto_tag_claims WHERE chat_id=?").run(pk);
428
+ db.prepare("DELETE FROM canonical_auto_tag_claims WHERE chat_id=?").run(pk);
429
429
  const claims = new Map();
430
430
  for (const match of matches) {
431
431
  const tagId = ensureTag(context, match.tag).id;
@@ -436,7 +436,7 @@ export const replaceAutoTags = (context, key, chatId, algorithm, matches) => {
436
436
  });
437
437
  }
438
438
  for (const [tagId, match] of claims) {
439
- db.prepare("INSERT INTO auto_tag_claims (chat_id, tag_id, algorithm, score, fields, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)").run(pk, tagId, algorithm, match.score, JSON.stringify(match.fields), context.now(), context.now());
439
+ db.prepare("INSERT INTO canonical_auto_tag_claims (chat_id, tag_id, algorithm, score, fields, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)").run(pk, tagId, algorithm, match.score, JSON.stringify(match.fields), context.now(), context.now());
440
440
  db.prepare("INSERT INTO taggings (tag_id, taggable_type, taggable_id, source, created_at, updated_at) VALUES (?, 'chat', ?, 'auto', ?, ?) ON CONFLICT DO NOTHING").run(tagId, pk, context.now(), context.now());
441
441
  }
442
442
  };
@@ -1,9 +1,13 @@
1
- // Existing Drive file-note imports keep this provenance in front matter; never hydrate the whole blob.
1
+ import { PREFERRED_FILE } from "./files.js";
2
+ // A document's preferred file holds its link and edit time; rows without one keep them in front matter.
2
3
  const drive = "CASE WHEN json_valid(n.front_matter) THEN json_extract(n.front_matter,'$.source')='google-drive' ELSE 0 END";
3
4
  const field = (name) => `CASE WHEN (${drive}) THEN
4
5
  CASE WHEN json_type(n.front_matter,'$.${name}')='text' THEN json_extract(n.front_matter,'$.${name}') END END`;
6
+ const preferred = (column) => `(SELECT f.${column} FROM files f JOIN documents d ON d.id = f.document_id WHERE f.document_id = n.id
7
+ ORDER BY ${PREFERRED_FILE} LIMIT 1)`;
5
8
  const modified = `round(unixepoch(${field("modifiedAt")},'subsec')*1000)`;
6
- export const contextDocumentTime = `coalesce(n.external_updated_at,${modified},n.updated_at)`;
7
- export const contextDocumentTimeKind = `CASE WHEN n.external_updated_at IS NOT NULL OR (${modified}) IS NOT NULL
9
+ const remoteTime = `coalesce(${preferred("external_updated_at")},n.external_updated_at,${modified})`;
10
+ export const contextDocumentTime = `coalesce(${remoteTime},n.updated_at)`;
11
+ export const contextDocumentTimeKind = `CASE WHEN (${remoteTime}) IS NOT NULL
8
12
  THEN 'source-modified' ELSE 'stored-update' END`;
9
- export const contextDocumentUrl = `coalesce(n.url,${field("sourceUrl")})`;
13
+ export const contextDocumentUrl = `coalesce(${preferred("url")},n.url,${field("sourceUrl")})`;
@@ -1,11 +1,27 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { folderProviderOf } from "../../../domain/providers.js";
2
3
  import { botNamed, ownerPerson } from "../actors.js";
4
+ import { syncFile } from "../files.js";
3
5
  import { canonicalTagNamed } from "../tags.js";
4
6
  import { referenceOfThing } from "../things.js";
5
7
  import { write } from "../transaction.js";
6
8
  import { copyPeople } from "./people.js";
7
9
  import { planTransfer } from "./plan.js";
8
10
  import { TRANSFER_TABLES } from "./snapshot.js";
11
+ /** A file exported before store version 32 names its folder `folder`; here it gets the provider its format says. */
12
+ const providerHere = ({ provider, name, settings }) => {
13
+ if (provider !== "folder" || String(name ?? "").startsWith("google-drive:"))
14
+ return provider ?? null;
15
+ return folderProviderOf(formatOf(settings) === "markdown" ? "markdown" : "obsidian");
16
+ };
17
+ const formatOf = (settings) => {
18
+ try {
19
+ return JSON.parse(String(settings))?.format;
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ };
9
25
  const COLUMNS = {
10
26
  document: "account_id external_id kind title location file_name extension url storage local_path mime size content_hash front_matter body normalized_text extraction extraction_error language revision export_path external_created_at external_updated_at metadata created_at updated_at deleted_at",
11
27
  note: "notable_type notable_id title body author_type author_id revision created_at updated_at deleted_at",
@@ -54,7 +70,11 @@ export const applyTransfer = (database, snapshot, sourceKey, bindings, expectedF
54
70
  const organization = snapshot.collection.organization_id == null
55
71
  ? undefined
56
72
  : mapped(mappings, "organization", snapshot.collection.organization_id);
57
- collectionId = insert(database, "accounts", "provider external_id name created_at settings updated_at scope organization_id", { ...snapshot.collection, organization_id: organization?.id ?? null });
73
+ collectionId = insert(database, "accounts", "provider external_id name created_at settings updated_at scope organization_id", {
74
+ ...snapshot.collection,
75
+ provider: providerHere(snapshot.collection),
76
+ organization_id: organization?.id ?? null,
77
+ });
58
78
  }
59
79
  mappings.set(`account:${String(snapshot.collection.id)}`, { type: "account", id: collectionId });
60
80
  const inserted = new Set();
@@ -79,6 +99,8 @@ export const applyTransfer = (database, snapshot, sourceKey, bindings, expectedF
79
99
  values.project_id = null;
80
100
  }
81
101
  const id = insert(database, TRANSFER_TABLES[entity.kind], COLUMNS[entity.kind], values);
102
+ if (entity.kind === "document")
103
+ syncFile(database, now(), id, { seen: false });
82
104
  mappings.set(entity.key, { type: entity.kind, id });
83
105
  inserted.add(entity.key);
84
106
  }
@@ -105,7 +127,7 @@ export const applyTransfer = (database, snapshot, sourceKey, bindings, expectedF
105
127
  const tag = canonicalTagNamed(database, String(tagging.tag_name));
106
128
  const tagId = tag
107
129
  ? Number(tag.id)
108
- : insert(database, "tags", "name kind created_at updated_at", {
130
+ : insert(database, "canonical_tags", "name kind created_at updated_at", {
109
131
  name: tagging.tag_name ?? null,
110
132
  kind: tagging.tag_kind ?? null,
111
133
  created_at: tagging.created_at ?? null,
@@ -0,0 +1,19 @@
1
+ import type { CacheDatabase } from "../../driver.js";
2
+ type Row = Record<string, unknown>;
3
+ /**
4
+ * The name a transfer gives a collection. A Drive collection is `google-drive:<principal>` before version 31 (the
5
+ * stand-in folder's name) and after it (the account's key), so transfers between stores on either side still match.
6
+ */
7
+ export declare const collectionNameOf: (row: Row) => string;
8
+ /** At most two rows: the account a collection name stands for, and a second one only when that is ambiguous. */
9
+ export declare const collectionAccounts: (database: CacheDatabase, collection: string) => Row[];
10
+ /** The stand-in folder version 31 moved an account's documents from, as its `settings.movedFrom` records it. */
11
+ export declare const movedFromOf: (row: Row | undefined) => {
12
+ external_id?: unknown;
13
+ } | undefined;
14
+ /**
15
+ * A source store from before version 31 holds Drive in a `folder` named `google-drive:<principal>`; the copy gets the
16
+ * shape version 31 gives it, so it lands in (or creates) the `google-drive` account, never a new stand-in folder.
17
+ */
18
+ export declare const currentCollection: (row: Row) => Row;
19
+ export {};
@@ -0,0 +1,66 @@
1
+ import { folderProviderSql, isDocumentAccountProvider } from "../../../domain/providers.js";
2
+ /** `google-drive:<principal>` names a documents account; anything else is a notes folder's name. */
3
+ const documentsKeyOf = (collection) => {
4
+ const colon = collection.indexOf(":");
5
+ const provider = collection.slice(0, colon);
6
+ return colon > 0 && isDocumentAccountProvider(provider)
7
+ ? { provider, account: collection.slice(colon + 1) }
8
+ : undefined;
9
+ };
10
+ /**
11
+ * The name a transfer gives a collection. A Drive collection is `google-drive:<principal>` before version 31 (the
12
+ * stand-in folder's name) and after it (the account's key), so transfers between stores on either side still match.
13
+ */
14
+ export const collectionNameOf = (row) => isDocumentAccountProvider(String(row.provider))
15
+ ? `${String(row.provider)}:${String(row.external_id)}`
16
+ : String(row.name);
17
+ /** At most two rows: the account a collection name stands for, and a second one only when that is ambiguous. */
18
+ export const collectionAccounts = (database, collection) => {
19
+ const key = documentsKeyOf(collection);
20
+ return key
21
+ ? database
22
+ .prepare(`SELECT * FROM accounts WHERE (provider=? AND external_id=?) OR (${folderProviderSql()} AND name=?) ORDER BY id LIMIT 2`)
23
+ .all(key.provider, key.account, collection)
24
+ : database
25
+ .prepare(`SELECT * FROM accounts WHERE ${folderProviderSql()} AND name=? ORDER BY id LIMIT 2`)
26
+ .all(collection);
27
+ };
28
+ /** The stand-in folder version 31 moved an account's documents from, as its `settings.movedFrom` records it. */
29
+ export const movedFromOf = (row) => {
30
+ if (typeof row?.settings !== "string")
31
+ return undefined;
32
+ try {
33
+ const moved = JSON.parse(row.settings).movedFrom;
34
+ return typeof moved === "object" && moved !== null ? moved : undefined;
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ };
40
+ /**
41
+ * A source store from before version 31 holds Drive in a `folder` named `google-drive:<principal>`; the copy gets the
42
+ * shape version 31 gives it, so it lands in (or creates) the `google-drive` account, never a new stand-in folder.
43
+ */
44
+ export const currentCollection = (row) => {
45
+ const key = isDocumentAccountProvider(String(row.provider)) ? undefined : documentsKeyOf(String(row.name));
46
+ if (!key)
47
+ return row;
48
+ const settings = (() => {
49
+ try {
50
+ return typeof row.settings === "string" ? JSON.parse(row.settings) : {};
51
+ }
52
+ catch {
53
+ return {};
54
+ }
55
+ })();
56
+ return {
57
+ ...row,
58
+ provider: key.provider,
59
+ external_id: key.account,
60
+ name: "Google Drive",
61
+ settings: JSON.stringify({
62
+ ...settings,
63
+ movedFrom: { id: row.id, external_id: row.external_id, name: row.name },
64
+ }),
65
+ };
66
+ };
@@ -1,8 +1,11 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { folderProviderSql } from "../../../domain/providers.js";
2
3
  import { digest } from "../../document-transfer/digest.js";
3
4
  import { ownerPerson } from "../actors.js";
5
+ import { tagMemberIds } from "../tag-resolution.js";
4
6
  import { canonicalTagNamed } from "../tags.js";
5
7
  import { stateOfThing, thingOf } from "../things.js";
8
+ import { collectionAccounts, collectionNameOf, movedFromOf } from "./collection.js";
6
9
  import { planPeople } from "./people.js";
7
10
  export const planTransfer = (database, snapshot, sourceKey, bindings) => {
8
11
  const mappings = new Map();
@@ -10,21 +13,20 @@ export const planTransfer = (database, snapshot, sourceKey, bindings) => {
10
13
  const snapshotHash = digest(snapshot);
11
14
  const bindingsHash = digest(Object.entries(bindings).sort(([a], [b]) => a.localeCompare(b)));
12
15
  const collection = String(snapshot.collection.external_id);
13
- const targets = database
14
- .prepare("SELECT * FROM accounts WHERE provider='folder' AND name=? ORDER BY id LIMIT 2")
15
- .all(String(snapshot.collection.name));
16
+ const targets = collectionAccounts(database, collectionNameOf(snapshot.collection));
16
17
  if (targets.length > 1)
17
18
  conflicts.add("the destination has duplicate document collection identities");
18
19
  const target = targets[0];
19
- if (!target && database.prepare("SELECT id FROM accounts WHERE provider='folder' AND external_id=?").get(collection))
20
+ if (!target &&
21
+ database.prepare(`SELECT id FROM accounts WHERE ${folderProviderSql()} AND external_id=?`).get(collection))
20
22
  conflicts.add("source collection identity is held by another destination collection");
21
23
  const hasReceipts = database
22
24
  .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='document_annotation_transfers'")
23
25
  .get();
24
26
  const receipt = hasReceipts &&
25
27
  database
26
- .prepare("SELECT * FROM document_annotation_transfers WHERE source_key=? AND collection=?")
27
- .get(sourceKey, collection);
28
+ .prepare("SELECT * FROM document_annotation_transfers WHERE source_key=? AND collection IN (?,?,?) LIMIT 1")
29
+ .get(sourceKey, collection, String(movedFromOf(snapshot.collection)?.external_id ?? collection), String(movedFromOf(target)?.external_id ?? collection));
28
30
  if (receipt && (receipt.snapshot_hash !== snapshotHash || receipt.bindings_hash !== bindingsHash))
29
31
  conflicts.add("the source or binding decisions changed after a completed transfer; keep the original store selected");
30
32
  if (target && target.scope !== snapshot.collection.scope)
@@ -82,9 +84,10 @@ export const planTransfer = (database, snapshot, sourceKey, bindings) => {
82
84
  continue;
83
85
  }
84
86
  else if (external.collection && external.external_id) {
87
+ const holders = JSON.stringify(collectionAccounts(database, external.collection).map(({ id }) => Number(id)));
85
88
  const row = database
86
- .prepare("SELECT d.id FROM documents d JOIN accounts a ON a.id=d.account_id WHERE a.provider='folder' AND a.name=? AND d.external_id=? AND d.deleted_at IS NULL")
87
- .get(external.collection, external.external_id);
89
+ .prepare("SELECT id FROM documents WHERE account_id IN (SELECT value FROM json_each(?)) AND external_id=? AND deleted_at IS NULL ORDER BY id LIMIT 1")
90
+ .get(holders, external.external_id);
88
91
  if (row)
89
92
  found = { type: "document", id: Number(row.id) };
90
93
  }
@@ -106,7 +109,11 @@ export const planTransfer = (database, snapshot, sourceKey, bindings) => {
106
109
  if (target && target.organization_id !== organization)
107
110
  conflicts.add("the destination collection has a different organization binding");
108
111
  for (const tagging of snapshot.taggings) {
112
+ if (Number(tagging.tag_group_size ?? 1) > 1)
113
+ conflicts.add(`source tag ${String(tagging.tag_id)} belongs to a consolidated group; annotation transfer cannot preserve that group yet`);
109
114
  const tag = canonicalTagNamed(database, String(tagging.tag_name));
115
+ if (tag && tagMemberIds(database, tag.id).length > 1)
116
+ conflicts.add(`destination tag ${String(tag.id)} belongs to a consolidated group; annotation transfer cannot preserve independent contributions yet`);
110
117
  if (tag && tag.name !== tagging.tag_name)
111
118
  conflicts.add(`destination vocabulary resolves source tag ${String(tagging.tag_id)} to another canonical label`);
112
119
  if (tag && tag.kind !== tagging.tag_kind)
@@ -1,7 +1,9 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
+ import { filesAccountSql } from "../../../domain/providers.js";
2
3
  import { digest } from "../../document-transfer/digest.js";
3
4
  import { referenceOfThing, storedThing } from "../things.js";
4
5
  import { read } from "../transaction.js";
6
+ import { collectionAccounts, collectionNameOf, currentCollection } from "./collection.js";
5
7
  export const TRANSFER_TABLES = { document: "documents", note: "notes", memory: "memories", fact: "facts" };
6
8
  const MAX_ENTITIES = 1000;
7
9
  const MAX_AUXILIARY = 10000;
@@ -14,9 +16,7 @@ const valuesOf = (row) => Object.fromEntries(Object.entries(row).map(([key, valu
14
16
  return [key, value];
15
17
  }));
16
18
  export const transferSnapshot = (database, collection) => read(database, () => {
17
- const accounts = database
18
- .prepare("SELECT * FROM accounts WHERE provider='folder' AND name=? ORDER BY id LIMIT 2")
19
- .all(collection);
19
+ const accounts = collectionAccounts(database, collection);
20
20
  if (accounts.length > 1)
21
21
  throw new CliError("configuration_error", "the source has duplicate document collection identities; select a repaired copy before transferring");
22
22
  const account = accounts[0];
@@ -35,6 +35,11 @@ export const transferSnapshot = (database, collection) => read(database, () => {
35
35
  const entities = new Map();
36
36
  const links = new Map();
37
37
  const taggings = new Map();
38
+ const canonicalTags = Boolean(database.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='canonical_tags'").get());
39
+ const tagTable = canonicalTags ? "canonical_tags" : "tags";
40
+ const resolution = canonicalTags
41
+ ? "r.canonical_id AS tag_canonical_id,(SELECT count(*) FROM resolved_tags members WHERE members.canonical_id=r.canonical_id) AS tag_group_size"
42
+ : "t.id AS tag_canonical_id,1 AS tag_group_size";
38
43
  const dependencies = new Map();
39
44
  const depend = (type, id) => {
40
45
  if (type != null && id != null)
@@ -84,7 +89,7 @@ export const transferSnapshot = (database, collection) => read(database, () => {
84
89
  }
85
90
  }
86
91
  for (const row of database
87
- .prepare("SELECT g.*,t.name AS tag_name,t.kind AS tag_kind FROM taggings g JOIN tags t ON t.id=g.tag_id WHERE g.taggable_type=? AND g.taggable_id=? ORDER BY g.id LIMIT 10001")
92
+ .prepare(`SELECT g.*,t.name AS tag_name,t.kind AS tag_kind,${resolution} FROM taggings g JOIN ${tagTable} t ON t.id=g.tag_id ${canonicalTags ? "JOIN resolved_tags r ON r.id=t.id" : ""} WHERE g.taggable_type=? AND g.taggable_id=? ORDER BY g.id LIMIT 10001`)
88
93
  .all(kind, Number(values.id))) {
89
94
  if (taggings.size >= MAX_AUXILIARY)
90
95
  throw new CliError("validation_error", "annotation transfer exceeds 10000 tag assignments");
@@ -160,10 +165,10 @@ export const transferSnapshot = (database, collection) => read(database, () => {
160
165
  }
161
166
  if (thing.type === "document") {
162
167
  const doc = database
163
- .prepare("SELECT a.name AS collection,d.external_id,d.body,d.content_hash,d.deleted_at FROM documents d JOIN accounts a ON a.id=d.account_id WHERE d.id=? AND a.provider='folder'")
168
+ .prepare(`SELECT a.provider,a.external_id AS account,a.name,d.external_id,d.body,d.content_hash,d.deleted_at FROM documents d JOIN accounts a ON a.id=d.account_id WHERE d.id=? AND ${filesAccountSql("a.provider")}`)
164
169
  .get(thing.id);
165
170
  if (doc) {
166
- item.collection = String(doc.collection);
171
+ item.collection = collectionNameOf({ provider: doc.provider, external_id: doc.account, name: doc.name });
167
172
  item.external_id = String(doc.external_id);
168
173
  const content = bounded({ body: doc.body, content_hash: doc.content_hash, deleted_at: doc.deleted_at });
169
174
  item.document_fingerprint = digest(content);
@@ -172,7 +177,7 @@ export const transferSnapshot = (database, collection) => read(database, () => {
172
177
  external.push(item);
173
178
  }
174
179
  return {
175
- collection: bounded(account),
180
+ collection: bounded(currentCollection(account)),
176
181
  entities: [...entities.values()].sort((a, b) => a.key.localeCompare(b.key)),
177
182
  links: [...links.values()].sort((a, b) => Number(a.id) - Number(b.id)),
178
183
  taggings: [...taggings.values()].sort((a, b) => Number(a.id) - Number(b.id)),
@@ -7,13 +7,17 @@ export declare const ENTITY_TABLES: {
7
7
  readonly account: "accounts";
8
8
  readonly attachment: "attachments";
9
9
  readonly bot: "bots";
10
+ readonly calendar_entry: "calendar_entries";
10
11
  readonly chat: "chats";
11
12
  readonly conversation: "conversations";
12
13
  readonly document: "documents";
13
14
  readonly document_picture: "documents";
14
15
  readonly email: "emails";
15
16
  readonly email_thread: "email_threads";
17
+ readonly event: "events";
18
+ readonly event_series: "event_series";
16
19
  readonly fact: "facts";
20
+ readonly file: "files";
17
21
  readonly identity: "identities";
18
22
  readonly image: "attachments";
19
23
  readonly meeting: "meetings";
@@ -26,7 +30,7 @@ export declare const ENTITY_TABLES: {
26
30
  readonly person: "persons";
27
31
  readonly place: "places";
28
32
  readonly project: "projects";
29
- readonly tag: "tags";
33
+ readonly tag: "canonical_tags";
30
34
  readonly task: "tasks";
31
35
  };
32
36
  export type EntityType = keyof typeof ENTITY_TABLES;
@@ -6,13 +6,17 @@ export const ENTITY_TABLES = {
6
6
  account: "accounts",
7
7
  attachment: "attachments",
8
8
  bot: "bots",
9
+ calendar_entry: "calendar_entries",
9
10
  chat: "chats",
10
11
  conversation: "conversations",
11
12
  document: "documents",
12
13
  document_picture: "documents",
13
14
  email: "emails",
14
15
  email_thread: "email_threads",
16
+ event: "events",
17
+ event_series: "event_series",
15
18
  fact: "facts",
19
+ file: "files",
16
20
  identity: "identities",
17
21
  image: "attachments",
18
22
  meeting: "meetings",
@@ -25,7 +29,7 @@ export const ENTITY_TABLES = {
25
29
  person: "persons",
26
30
  place: "places",
27
31
  project: "projects",
28
- tag: "tags",
32
+ tag: "canonical_tags",
29
33
  task: "tasks",
30
34
  };
31
35
  export const isEntityType = (type) => Object.hasOwn(ENTITY_TABLES, type);
@@ -1,5 +1,5 @@
1
1
  import type { QueryAst } from "@wirecat/cli-base/query";
2
- import type { Event, EventCandidateFilter, EventSeries, NewRecord } from "../../meetings/index.js";
2
+ import { type Event, type EventCandidateFilter, type EventSeries, type NewRecord } from "../../meetings/index.js";
3
3
  import type { CacheDatabase } from "../driver.js";
4
4
  /** JSON columns keep a missing value as SQL NULL, never the text `null`, so a read gives back what was saved. */
5
5
  export declare const toJson: (value: unknown) => string | null;
@@ -13,8 +13,14 @@ export declare const page: (filter: {
13
13
  limit?: number;
14
14
  offset?: number;
15
15
  }) => [number, number];
16
+ export declare const eventOf: (row: Record<string, unknown>) => Event;
16
17
  export declare const events: (database: CacheDatabase, query?: QueryAst) => Event[];
17
- /** An event without an end occupies its start instant. */
18
+ /**
19
+ * The events a meeting may belong to: overlapping in time, with exclusive ends so back-to-back meetings do not both
20
+ * match, and sharing its series or its room (`join_key`, never the link with its passcode). The room is matched on
21
+ * other meetings and on calendar entries, so an event with no meeting yet is found. An event without an end, and a
22
+ * meeting with none, occupy their start instant.
23
+ */
18
24
  export declare const eventCandidates: (database: CacheDatabase, filter: EventCandidateFilter) => Event[];
19
25
  export declare const createEvent: (database: CacheDatabase, input: NewRecord<Event>, now: number) => Event;
20
26
  export declare const createEventSeries: (database: CacheDatabase, input: NewRecord<EventSeries>, now: number) => EventSeries;
@@ -1,4 +1,6 @@
1
+ import { joinKeyOf, } from "../../meetings/index.js";
1
2
  import { queryFilter } from "./query-filter.js";
3
+ import { write } from "./transaction.js";
2
4
  /** JSON columns keep a missing value as SQL NULL, never the text `null`, so a read gives back what was saved. */
3
5
  export const toJson = (value) => value === null || value === undefined ? null : JSON.stringify(value);
4
6
  export const fromJson = (value) => (value === null || value === undefined ? null : JSON.parse(String(value)));
@@ -14,7 +16,7 @@ export const page = (filter) => {
14
16
  throw new Error("Invalid offset");
15
17
  return [filter.limit ?? -1, filter.offset ?? 0];
16
18
  };
17
- const eventOf = (row) => ({
19
+ export const eventOf = (row) => ({
18
20
  id: Number(row.id),
19
21
  eventSeriesId: int(row.event_series_id),
20
22
  title: str(row.title),
@@ -25,6 +27,9 @@ const eventOf = (row) => ({
25
27
  timezone: str(row.timezone),
26
28
  origin: String(row.origin),
27
29
  deletedAt: int(row.deleted_at),
30
+ status: str(row.status),
31
+ allDay: bool(row.all_day),
32
+ sourceDeletedAt: int(row.source_deleted_at),
28
33
  createdAt: Number(row.created_at),
29
34
  updatedAt: Number(row.updated_at),
30
35
  });
@@ -52,17 +57,28 @@ export const events = (database, query) => {
52
57
  .all(...(where?.params ?? []))
53
58
  .map(eventOf);
54
59
  };
55
- /** An event without an end occupies its start instant. */
56
- export const eventCandidates = (database, filter) => database
57
- .prepare(`SELECT e.* FROM events e
60
+ /**
61
+ * The events a meeting may belong to: overlapping in time, with exclusive ends so back-to-back meetings do not both
62
+ * match, and sharing its series or its room (`join_key`, never the link with its passcode). The room is matched on
63
+ * other meetings and on calendar entries, so an event with no meeting yet is found. An event without an end, and a
64
+ * meeting with none, occupy their start instant.
65
+ */
66
+ export const eventCandidates = (database, filter) => {
67
+ const key = joinKeyOf(filter.joinUrl) ?? "";
68
+ const end = Math.max(filter.endsAt, filter.startsAt + 1);
69
+ return database
70
+ .prepare(`SELECT e.* FROM events e
58
71
  WHERE e.deleted_at IS NULL AND e.starts_at IS NOT NULL
59
- AND e.starts_at <= ? AND coalesce(e.ends_at, e.starts_at) >= ?
72
+ AND e.starts_at < ? AND coalesce(e.ends_at, e.starts_at + 1) > ?
60
73
  AND (e.event_series_id IN (SELECT event_series_id FROM meeting_series WHERE id = ?)
61
- OR (? IS NOT NULL AND EXISTS (SELECT 1 FROM meetings m
62
- WHERE m.event_id = e.id AND m.deleted_at IS NULL AND m.join_url = ?)))
74
+ OR (? <> '' AND (EXISTS (SELECT 1 FROM meetings m
75
+ WHERE m.event_id = e.id AND m.deleted_at IS NULL AND m.join_key = ?)
76
+ OR EXISTS (SELECT 1 FROM calendar_entries ce WHERE ce.event_id = e.id AND ce.deleted_at IS NULL
77
+ AND ce.status <> 'cancelled' AND ce.join_key = ?))))
63
78
  ORDER BY e.id`)
64
- .all(filter.endsAt, filter.startsAt, filter.meetingSeriesId, filter.joinUrl, filter.joinUrl)
65
- .map(eventOf);
79
+ .all(end, filter.startsAt, filter.meetingSeriesId, key, key, key)
80
+ .map(eventOf);
81
+ };
66
82
  export const createEvent = (database, input, now) => {
67
83
  if (input.eventSeriesId !== null && !exists(database, "event_series", input.eventSeriesId))
68
84
  throw new Error("Event series not found");
@@ -88,7 +104,15 @@ export const linkMeeting = (database, id, eventId, now, mode = "auto") => {
88
104
  throw new Error("Meeting not found");
89
105
  if (!exists(database, "events", eventId))
90
106
  throw new Error("Event not found");
91
- database
92
- .prepare(`UPDATE meetings SET event_id = ?, updated_at = ? WHERE id = ?${mode === "owner" ? "" : " AND event_id IS NULL"}`)
93
- .run(eventId, now, id);
107
+ write(database, () => {
108
+ const from = database.prepare("SELECT event_id FROM meetings WHERE id = ?").get(id)?.event_id;
109
+ const changed = database
110
+ .prepare(`UPDATE meetings SET event_id = ?, updated_at = ? WHERE id = ?${mode === "owner" ? "" : " AND event_id IS NULL"}`)
111
+ .run(eventId, now, id).changes;
112
+ if (changed && int(from) !== eventId)
113
+ database
114
+ .prepare("INSERT INTO entity_link_events (linkable_type, linkable_id, entity_type, from_entity_id, to_entity_id, method, " +
115
+ "confidence, author, created_at) VALUES ('meeting', ?, 'event', ?, ?, ?, NULL, ?, ?)")
116
+ .run(id, int(from), eventId, mode === "owner" ? "owner" : "rules", mode === "owner" ? "owner" : "rules", now);
117
+ });
94
118
  };
@@ -0,0 +1,93 @@
1
+ import type { CacheDatabase } from "../driver.js";
2
+ type Row = Record<string, unknown>;
3
+ /** A local file of a document, where it is and its own facts. */
4
+ export interface LocalPlace {
5
+ accountId: number;
6
+ path: string;
7
+ contentHash: string | null;
8
+ frontMatter: unknown;
9
+ }
10
+ interface Placed {
11
+ id: number;
12
+ path: string;
13
+ }
14
+ /** A file's document is the one its writer saved it as (`import`); a change of it is logged as every link is. */
15
+ export declare const logFileLink: (database: CacheDatabase, now: number, fileId: number, from: unknown, to: number | null, method?: string, author?: string) => {
16
+ changes: number;
17
+ };
18
+ /**
19
+ * Writes the `files` row of one document from the document as it is now: the document's source record. A Drive
20
+ * document (catalog metadata or Drive front matter) is keyed by its Drive file id and placed under its first parent;
21
+ * a local one by its path, under folder rows made from that path. `seen` marks a sync or import that returned it.
22
+ * `place` names a local file of the document other than the one its own columns name: a copy in another folder.
23
+ */
24
+ export declare const syncFile: (database: CacheDatabase, now: number, documentId: number, { seen, depth, place, method, }?: {
25
+ seen?: boolean;
26
+ depth?: number;
27
+ place?: LocalPlace;
28
+ method?: string;
29
+ }) => Placed | null;
30
+ export declare const FILES_BACKFILL = "files_backfill_after";
31
+ /**
32
+ * Writes the `files` row of every document stored before version 32, from the document after the cursor the
33
+ * migration left, in short writes: another process waits for one batch at most, and a stop resumes where it was.
34
+ */
35
+ export declare const fillFiles: (database: CacheDatabase, now: () => number, { batch }?: {
36
+ batch?: number | undefined;
37
+ }) => number;
38
+ /** A source record of a document: one item in one place (`files` row). Times are ISO 8601. */
39
+ export interface StoredFile {
40
+ id: string;
41
+ accountId: string;
42
+ externalId: string;
43
+ kind: "file" | "folder" | "page";
44
+ name: string;
45
+ path: string;
46
+ parentId: string | null;
47
+ /** `document:<id>`, or `null` for a local folder. */
48
+ document: string | null;
49
+ mime: string | null;
50
+ size: number | null;
51
+ contentHash: string | null;
52
+ url: string | null;
53
+ externalCreatedAt: string | null;
54
+ externalUpdatedAt: string | null;
55
+ metadata: Record<string, unknown> | null;
56
+ seenAt: string | null;
57
+ deletedAt: string | null;
58
+ }
59
+ export interface FileListQuery {
60
+ /** Everything at and below this folder path, from the account root. */
61
+ under?: string;
62
+ /** Paths holding this text, in any case and accent. */
63
+ pathContains?: string;
64
+ kind?: StoredFile["kind"];
65
+ includeDeleted?: boolean;
66
+ limit?: number;
67
+ offset?: number;
68
+ }
69
+ export interface FilesStore {
70
+ /** The files of one account, by path. The account is always named: there is no default place. */
71
+ list(accountId: string, query?: FileListQuery): Promise<{
72
+ items: StoredFile[];
73
+ hasMore: boolean;
74
+ }>;
75
+ byPath(accountId: string, path: string): Promise<StoredFile | undefined>;
76
+ /** Every place a document is kept, the one it shows first: the owner's pin, else the most recently changed. */
77
+ ofDocument(reference: string): Promise<StoredFile[]>;
78
+ /** The owner's choice of the file a document shows (`document:<id>`, a file id of that document). */
79
+ pin(document: string, fileId: string): Promise<void>;
80
+ /** The document shows its most recently changed file again. */
81
+ unpin(document: string): Promise<void>;
82
+ }
83
+ export declare const fileOf: (row: Row) => StoredFile;
84
+ /**
85
+ * The file a document shows (requirements 3.3): live ones first, then the owner's pin, then the most recently changed.
86
+ * Read with the document's row joined as `d`.
87
+ */
88
+ export declare const PREFERRED_FILE = "f.deleted_at IS NOT NULL, f.id IS NOT d.pinned_file_id, coalesce(f.external_updated_at, f.updated_at) DESC, f.id DESC";
89
+ export declare const filesStoreOver: ({ database, now }: {
90
+ database: CacheDatabase;
91
+ now: () => number;
92
+ }) => FilesStore;
93
+ export {};