@wirecat/cli-core 0.419.0 → 0.421.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 (126) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/cli/bot/context.d.ts +3 -2
  3. package/dist/cli/bot/context.js +15 -0
  4. package/dist/cli/config-command.js +1 -2
  5. package/dist/cli/index.d.ts +0 -1
  6. package/dist/cli/index.js +0 -1
  7. package/dist/cli/messenger/context.js +3 -6
  8. package/dist/cli/permission-keys.d.ts +2 -0
  9. package/dist/cli/permission-keys.js +10 -3
  10. package/dist/cli/preview.d.ts +2 -2
  11. package/dist/cli/preview.js +4 -3
  12. package/dist/cli/program.js +4 -8
  13. package/dist/cli/settings.d.ts +0 -2
  14. package/dist/cli/settings.js +1 -5
  15. package/dist/domain/providers.d.ts +20 -2
  16. package/dist/domain/providers.js +23 -3
  17. package/dist/domain/tag-consolidation.d.ts +30 -1
  18. package/dist/entities/index.d.ts +4 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/mcp/surface.js +0 -4
  22. package/dist/mcp/tool.d.ts +1 -3
  23. package/dist/mcp/tool.js +1 -2
  24. package/dist/meetings/calendar-text.d.ts +13 -0
  25. package/dist/meetings/calendar-text.js +57 -0
  26. package/dist/meetings/event-store.d.ts +202 -0
  27. package/dist/meetings/event-store.js +1 -0
  28. package/dist/meetings/events.js +5 -1
  29. package/dist/meetings/index.d.ts +3 -0
  30. package/dist/meetings/index.js +2 -0
  31. package/dist/meetings/join-key.d.ts +7 -0
  32. package/dist/meetings/join-key.js +38 -0
  33. package/dist/meetings/mcp/index.d.ts +15 -15
  34. package/dist/meetings/model.d.ts +8 -2
  35. package/dist/meetings/read-answers.d.ts +6 -2
  36. package/dist/meetings/read-answers.js +9 -2
  37. package/dist/meetings/store.d.ts +2 -0
  38. package/dist/meetings/testing/event-store-contract.d.ts +10 -0
  39. package/dist/meetings/testing/event-store-contract.js +161 -0
  40. package/dist/meetings/testing/index.d.ts +1 -0
  41. package/dist/meetings/testing/index.js +1 -0
  42. package/dist/parity/manifest.js +0 -4
  43. package/dist/search/account-list.js +39 -7
  44. package/dist/sends/index.d.ts +1 -1
  45. package/dist/sends/index.js +1 -1
  46. package/dist/sends/permissions.d.ts +0 -13
  47. package/dist/sends/permissions.js +15 -142
  48. package/dist/services/agenda.js +5 -5
  49. package/dist/services/document-source.d.ts +7 -3
  50. package/dist/services/document-source.js +24 -12
  51. package/dist/services/documents/ingestion.d.ts +6 -1
  52. package/dist/services/documents/ingestion.js +4 -0
  53. package/dist/services/notes-search.js +3 -2
  54. package/dist/services/search-all.js +1 -0
  55. package/dist/services/task-tag-filters.d.ts +3 -0
  56. package/dist/services/task-tag-filters.js +8 -0
  57. package/dist/services/tasks.js +5 -9
  58. package/dist/store/document-transfer/index.js +12 -2
  59. package/dist/store/index.d.ts +2 -1
  60. package/dist/store/sqlite/accounts.js +1 -0
  61. package/dist/store/sqlite/calendar.d.ts +3 -0
  62. package/dist/store/sqlite/calendar.js +593 -0
  63. package/dist/store/sqlite/catalog.js +16 -5
  64. package/dist/store/sqlite/chat-rekey.js +1 -1
  65. package/dist/store/sqlite/chats.js +2 -2
  66. package/dist/store/sqlite/context-document-source.js +8 -4
  67. package/dist/store/sqlite/document-transfer/apply.js +24 -2
  68. package/dist/store/sqlite/document-transfer/collection.d.ts +19 -0
  69. package/dist/store/sqlite/document-transfer/collection.js +66 -0
  70. package/dist/store/sqlite/document-transfer/plan.js +15 -8
  71. package/dist/store/sqlite/document-transfer/snapshot.js +12 -7
  72. package/dist/store/sqlite/entity-types.d.ts +5 -1
  73. package/dist/store/sqlite/entity-types.js +5 -1
  74. package/dist/store/sqlite/events.d.ts +8 -2
  75. package/dist/store/sqlite/events.js +36 -12
  76. package/dist/store/sqlite/files.d.ts +93 -0
  77. package/dist/store/sqlite/files.js +293 -0
  78. package/dist/store/sqlite/folded.d.ts +3 -0
  79. package/dist/store/sqlite/folded.js +1 -0
  80. package/dist/store/sqlite/imported-documents.js +1 -1
  81. package/dist/store/sqlite/join-keys.d.ts +8 -0
  82. package/dist/store/sqlite/join-keys.js +27 -0
  83. package/dist/store/sqlite/knowledge.js +6 -6
  84. package/dist/store/sqlite/link-reviews.d.ts +9 -0
  85. package/dist/store/sqlite/link-reviews.js +12 -0
  86. package/dist/store/sqlite/lucene-fields.js +8 -6
  87. package/dist/store/sqlite/manifest.js +11 -0
  88. package/dist/store/sqlite/meeting-selection.js +1 -1
  89. package/dist/store/sqlite/meetings.js +6 -1
  90. package/dist/store/sqlite/migrations.generated.js +143 -0
  91. package/dist/store/sqlite/note-index.d.ts +8 -1
  92. package/dist/store/sqlite/note-index.js +14 -0
  93. package/dist/store/sqlite/note-search.js +6 -5
  94. package/dist/store/sqlite/notes.d.ts +18 -1
  95. package/dist/store/sqlite/notes.js +237 -40
  96. package/dist/store/sqlite/open-store.js +4 -0
  97. package/dist/store/sqlite/person-links.js +7 -2
  98. package/dist/store/sqlite/schema/canonical-tags.d.ts +87 -0
  99. package/dist/store/sqlite/schema/canonical-tags.js +8 -0
  100. package/dist/store/sqlite/schema/tag-consolidations.d.ts +270 -0
  101. package/dist/store/sqlite/schema/tag-consolidations.js +34 -0
  102. package/dist/store/sqlite/schema.d.ts +3589 -1634
  103. package/dist/store/sqlite/schema.js +191 -13
  104. package/dist/store/sqlite/tag-consolidation-apply.d.ts +3 -0
  105. package/dist/store/sqlite/tag-consolidation-apply.js +88 -0
  106. package/dist/store/sqlite/tag-consolidation-input.d.ts +16 -0
  107. package/dist/store/sqlite/tag-consolidation-input.js +52 -0
  108. package/dist/store/sqlite/tag-consolidation.js +51 -66
  109. package/dist/store/sqlite/tag-creation.js +17 -8
  110. package/dist/store/sqlite/tag-resolution.d.ts +5 -0
  111. package/dist/store/sqlite/tag-resolution.js +33 -0
  112. package/dist/store/sqlite/tags.d.ts +3 -1
  113. package/dist/store/sqlite/tags.js +70 -50
  114. package/dist/store/sqlite/tasks.d.ts +2 -0
  115. package/dist/store/sqlite/tasks.js +6 -1
  116. package/dist/store/sqlite/things.js +2 -1
  117. package/dist/store/sqlite/vocabulary.d.ts +1 -0
  118. package/dist/store/sqlite/vocabulary.js +30 -8
  119. package/dist/store/store.d.ts +6 -0
  120. package/dist/store/store.js +10 -0
  121. package/dist/store/tags.js +2 -2
  122. package/package.json +2 -1
  123. package/dist/cli/moved.d.ts +0 -18
  124. package/dist/cli/moved.js +0 -72
  125. package/dist/sends/permission-aliases.d.ts +0 -5
  126. package/dist/sends/permission-aliases.js +0 -50
@@ -1,14 +1,17 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { MAX_TEXT_CHARS } from "../../attachments/limits.js";
3
3
  import { googleDocumentLinks } from "../../domain/google-documents.js";
4
+ import { DOCUMENT_ACCOUNT_PROVIDERS, filesAccountSql, folderProviderOf, folderProviderSql, isDocumentAccountProvider, } from "../../domain/providers.js";
4
5
  import { parseReference } from "../../domain/references.js";
5
6
  import { normalizeTag } from "../../domain/tags.js";
6
7
  import { fold } from "../normalize.js";
7
8
  import { ulid } from "../ulid.js";
8
9
  import { ownerPerson } from "./actors.js";
9
10
  import { encodeDocumentMetadata, languageOf } from "./document-metadata.js";
11
+ import { PREFERRED_FILE, syncFile } from "./files.js";
10
12
  import { importedDocumentWhere } from "./imported-documents.js";
11
13
  import { LINK_KINDS, linkKind } from "./link-kinds.js";
14
+ import { reviewLink } from "./link-reviews.js";
12
15
  import { addTags, ensureTag, labelsOf } from "./tags.js";
13
16
  import { canonicalThingReference, referenceOfThing, storedThing, thingOf } from "./things.js";
14
17
  import { write } from "./transaction.js";
@@ -177,13 +180,33 @@ export const insertLink = (context, input) => {
177
180
  /** A folder's `accounts` row; its format lives in `settings`. */
178
181
  const folderOf = (row) => {
179
182
  const settings = row.settings == null ? {} : JSON.parse(String(row.settings));
183
+ const folder = !isDocumentAccountProvider(String(row.provider));
180
184
  return {
181
185
  id: String(row.id),
182
- name: String(row.name),
183
- format: (settings.format ?? "obsidian"),
186
+ name: folder ? String(row.name) : `${String(row.provider)}:${String(row.external_id)}`,
187
+ provider: String(row.provider),
188
+ format: (settings.format ?? (folder ? "obsidian" : "markdown")),
184
189
  createdAt: iso(row.created_at),
190
+ ...(folder ? {} : { account: { provider: String(row.provider), account: String(row.external_id) } }),
185
191
  };
186
192
  };
193
+ const frontMatterObject = (text) => {
194
+ try {
195
+ const value = JSON.parse(String(text));
196
+ return value !== null && typeof value === "object" ? value : undefined;
197
+ }
198
+ catch {
199
+ return undefined;
200
+ }
201
+ };
202
+ const frontMatterOf = (metadata) => {
203
+ try {
204
+ return JSON.parse(String(metadata))?.front_matter ?? null;
205
+ }
206
+ catch {
207
+ return null;
208
+ }
209
+ };
187
210
  const pathParts = (path) => {
188
211
  const slash = path.lastIndexOf("/");
189
212
  const fileName = slash < 0 ? path : path.slice(slash + 1);
@@ -198,7 +221,7 @@ export const notesStoreOver = (context) => {
198
221
  const { database, now } = context;
199
222
  const folderRow = (id) => {
200
223
  const row = /^\d+$/.test(id)
201
- ? database.prepare("SELECT * FROM accounts WHERE id = ? AND provider = 'folder'").get(Number(id))
224
+ ? database.prepare(`SELECT * FROM accounts WHERE id = ? AND ${filesAccountSql()}`).get(Number(id))
202
225
  : undefined;
203
226
  if (!row)
204
227
  throw new CliError("not_found", `no notes folder ${id} in the local store`);
@@ -228,15 +251,101 @@ export const notesStoreOver = (context) => {
228
251
  return { limit, offset };
229
252
  };
230
253
  const documentTags = (id) => labelsOf(database, { type: "document", id });
254
+ /** A file row of a folder by its path; a local folder's own rows are not notes. */
255
+ const fileAt = (folder, path, liveOnly = false) => database
256
+ .prepare(`SELECT * FROM files WHERE account_id = ? AND external_id = ? AND kind <> 'folder'${liveOnly ? " AND deleted_at IS NULL" : ""}`)
257
+ .get(folder, path);
258
+ const isLocal = (accountId) => database
259
+ .prepare(`SELECT 1 FROM accounts WHERE id = ? AND provider IN ('local', 'obsidian')`)
260
+ .get(Number(accountId)) !== undefined;
261
+ /** Drive's documents are found by its folder and `<id>.md`, whichever account that folder is now. */
262
+ const remoteDocument = (row) => {
263
+ const front = frontMatterObject(row.front_matter);
264
+ return front?.source === "google-drive" || frontMatterObject(row.metadata)?.catalog !== undefined;
265
+ };
266
+ /** Drive's own saves keep their documents by folder and `<id>.md`; only files on disk join by content. */
267
+ const localSave = (folder, frontMatter) => isLocal(folder) && frontMatter?.source !== "google-drive";
268
+ /**
269
+ * The documents whose shown file has this content: live ones, else gone ones no file holds any more, so a file
270
+ * moved to another folder is the same document whichever folder syncs first. Empty text matches nothing.
271
+ */
272
+ const sameContent = (hash, text) => {
273
+ if (!hash || !text.trim())
274
+ return [];
275
+ const live = database
276
+ .prepare("SELECT id FROM documents WHERE content_hash = ? AND deleted_at IS NULL ORDER BY id")
277
+ .all(hash);
278
+ if (live.length)
279
+ return live.map(({ id }) => Number(id));
280
+ return database
281
+ .prepare("SELECT d.id FROM documents d WHERE d.content_hash = ? AND d.deleted_at IS NOT NULL AND NOT EXISTS " +
282
+ "(SELECT 1 FROM files f WHERE f.document_id = d.id AND f.deleted_at IS NULL) ORDER BY d.id")
283
+ .all(hash)
284
+ .map(({ id }) => Number(id));
285
+ };
286
+ /**
287
+ * A local document's own place columns follow the file it shows (the owner's pin, else the latest changed), so
288
+ * readers that still go by `documents.account_id` find it where it is. Drive's documents keep theirs.
289
+ */
290
+ const followPreferred = (id, at) => {
291
+ const document = documentRow(String(id));
292
+ if (!isLocal(document.account_id) || remoteDocument(document))
293
+ return;
294
+ const preferred = database
295
+ .prepare(`SELECT f.account_id, f.external_id FROM files f JOIN documents d ON d.id = f.document_id
296
+ JOIN accounts a ON a.id = f.account_id WHERE f.document_id = ? AND f.deleted_at IS NULL AND f.kind <> 'folder'
297
+ AND a.provider IN ('local', 'obsidian') ORDER BY ${PREFERRED_FILE} LIMIT 1`)
298
+ .get(id);
299
+ if (!preferred)
300
+ return;
301
+ const [account, path] = [Number(preferred.account_id), String(preferred.external_id)];
302
+ if (Number(document.account_id) === account && String(document.external_id) === path)
303
+ return;
304
+ const blocking = database
305
+ .prepare("SELECT id, deleted_at FROM documents WHERE account_id = ? AND external_id = ? AND id <> ?")
306
+ .get(account, path, id);
307
+ if (blocking?.deleted_at == null && blocking !== undefined)
308
+ return;
309
+ if (blocking)
310
+ database.prepare("DELETE FROM documents WHERE id = ?").run(Number(blocking.id));
311
+ const { location, fileName, extension } = pathParts(path);
312
+ database
313
+ .prepare("UPDATE documents SET account_id = ?, external_id = ?, location = ?, file_name = ?, extension = ?, updated_at = ? WHERE id = ?")
314
+ .run(account, path, location, fileName, extension, at, id);
315
+ };
316
+ /** The document as the folder that holds this file sees it: its folder and path are the file's. */
317
+ const noteAt = (id, folder, path) => ({
318
+ ...documentOf(documentRow(String(id))),
319
+ folderId: String(folder),
320
+ path,
321
+ });
231
322
  return {
232
323
  addFolder: async ({ name, format = "obsidian" }) => {
233
324
  const at = now();
325
+ const checked = oneOf(format, NOTE_FORMATS, "a folder's format");
234
326
  const row = database
235
- .prepare("INSERT INTO accounts (provider, external_id, name, settings, created_at, updated_at) VALUES ('folder', ?, ?, ?, ?, ?) RETURNING id")
236
- .get(ulid(at), textOf(name, "a folder's name", 200), JSON.stringify({ format: oneOf(format, NOTE_FORMATS, "a folder's format") }), at, at);
327
+ .prepare("INSERT INTO accounts (provider, external_id, name, settings, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) RETURNING id")
328
+ .get(folderProviderOf(checked), ulid(at), textOf(name, "a folder's name", 200), JSON.stringify({ format: checked }), at, at);
237
329
  return folderOf(folderRow(String(row?.id)));
238
330
  },
239
- folders: async () => database.prepare("SELECT * FROM accounts WHERE provider = 'folder' ORDER BY name, id").all().map(folderOf),
331
+ folders: async () => database.prepare(`SELECT * FROM accounts WHERE ${folderProviderSql()} ORDER BY name, id`).all().map(folderOf),
332
+ documentsAccount: async ({ provider, account }, create = false) => {
333
+ if (!isDocumentAccountProvider(provider))
334
+ throw new CliError("validation_error", `${provider} is not a provider whose accounts hold documents`);
335
+ const find = () => database.prepare("SELECT * FROM accounts WHERE provider = ? AND external_id = ?").get(provider, account);
336
+ const found = find();
337
+ if (found || !create)
338
+ return found ? folderOf(found) : undefined;
339
+ const at = now();
340
+ database
341
+ .prepare("INSERT OR IGNORE INTO accounts (provider, external_id, name, settings, created_at, updated_at) VALUES (?, ?, NULL, ?, ?, ?)")
342
+ .run(provider, textOf(account, "an account's id", 200), JSON.stringify({ format: "markdown" }), at, at);
343
+ return folderOf(find());
344
+ },
345
+ documentsAccounts: async () => database
346
+ .prepare(`SELECT * FROM accounts WHERE provider IN (${DOCUMENT_ACCOUNT_PROVIDERS.map(() => "?").join(", ")}) ORDER BY provider, external_id`)
347
+ .all(...DOCUMENT_ACCOUNT_PROVIDERS)
348
+ .map(folderOf),
240
349
  saveFileNote: async (input) => write(database, () => {
241
350
  const folder = Number(folderRow(input.folderId).id);
242
351
  const path = textOf(input.path, "a note's path", 4096);
@@ -246,9 +355,25 @@ export const notesStoreOver = (context) => {
246
355
  const language = languageOf(input.metadata);
247
356
  const title = typeof input.metadata?.title === "string" && input.metadata.title ? input.metadata.title : input.title;
248
357
  const at = now();
249
- const found = database
250
- .prepare("SELECT * FROM documents WHERE account_id = ? AND external_id = ?")
251
- .get(folder, path);
358
+ const hash = input.contentHash ?? null;
359
+ const here = { accountId: folder, path, contentHash: hash, frontMatter: input.frontMatter ?? null };
360
+ const place = fileAt(folder, path);
361
+ const found = place?.document_id != null
362
+ ? documentRow(String(place.document_id))
363
+ : database.prepare("SELECT * FROM documents WHERE account_id = ? AND external_id = ?").get(folder, path);
364
+ // Rule 2 (requirements 3.3): a new local file with the content of exactly one document is a copy of it.
365
+ const candidates = !found && !place && localSave(folder, input.frontMatter) ? sameContent(hash, text) : [];
366
+ if (candidates.length === 1) {
367
+ const id = candidates[0];
368
+ const joined = documentRow(String(id));
369
+ if (joined.deleted_at != null)
370
+ database
371
+ .prepare("UPDATE documents SET deleted_at = NULL, body = ?, updated_at = ? WHERE id = ?")
372
+ .run(text, at, id);
373
+ syncFile(database, at, id, { place: here, method: "content-hash" });
374
+ followPreferred(id, at);
375
+ return { note: noteAt(id, folder, path), changed: true };
376
+ }
252
377
  if (found?.metadata != null) {
253
378
  const stored = JSON.parse(String(found.metadata));
254
379
  if (stored.catalog !== undefined)
@@ -260,52 +385,117 @@ export const notesStoreOver = (context) => {
260
385
  .prepare("INSERT INTO documents (account_id, external_id, kind, title, location, file_name, extension, storage, content_hash, " +
261
386
  "front_matter, body, extraction, metadata, language, revision, created_at, updated_at) " +
262
387
  "VALUES (?, ?, 'file', ?, ?, ?, ?, 'local', ?, ?, ?, 'text', ?, ?, 1, ?, ?) RETURNING id")
263
- .get(folder, path, title, location, fileName, extension, input.contentHash ?? null, frontMatter, text, metadata, language, at, at);
264
- return { note: documentOf(documentRow(String(row?.id))), changed: true };
388
+ .get(folder, path, title, location, fileName, extension, hash, frontMatter, text, metadata, language, at, at);
389
+ const id = Number(row?.id);
390
+ const file = syncFile(database, at, id);
391
+ if (candidates.length > 1 && file)
392
+ reviewLink(database, { type: "file", id: file.id }, "document", candidates, "content-hash", at);
393
+ return { note: noteAt(id, folder, path), changed: true };
265
394
  }
395
+ const id = Number(found.id);
396
+ const own = Number(found.account_id) === folder && String(found.external_id) === path;
397
+ // A copy elsewhere changes the document's text; its own front matter stays on its file row.
398
+ const keptFrontMatter = own ? frontMatter : found.front_matter == null ? null : String(found.front_matter);
399
+ const keptMetadata = own ? metadata : found.metadata == null ? null : String(found.metadata);
266
400
  const same = (found.body ?? "") === text &&
267
401
  (found.title ?? null) === title &&
268
- (found.front_matter ?? null) === frontMatter &&
269
- (found.metadata ?? null) === metadata &&
270
- (found.content_hash ?? null) === (input.contentHash ?? null) &&
402
+ (found.front_matter ?? null) === keptFrontMatter &&
403
+ (found.metadata ?? null) === keptMetadata &&
404
+ (found.content_hash ?? null) === hash &&
271
405
  found.deleted_at == null;
272
- if (same)
273
- return { note: documentOf(found), changed: false };
274
- if ((found.body ?? "") !== text)
406
+ if (!same) {
407
+ if ((found.body ?? "") !== text)
408
+ database
409
+ .prepare("INSERT INTO document_revisions (document_id, body, revision, created_at) VALUES (?, ?, ?, ?)")
410
+ .run(id, String(found.body ?? ""), Number(found.revision), at);
275
411
  database
276
- .prepare("INSERT INTO document_revisions (document_id, body, revision, created_at) VALUES (?, ?, ?, ?)")
277
- .run(Number(found.id), String(found.body ?? ""), Number(found.revision), at);
278
- database
279
- .prepare("UPDATE documents SET title = ?, body = ?, front_matter = ?, content_hash = ?, metadata = ?, language = ?, " +
280
- "revision = revision + ?, updated_at = ?, deleted_at = NULL WHERE id = ?")
281
- .run(title, text, frontMatter, input.contentHash ?? null, metadata, language, (found.body ?? "") === text ? 0 : 1, at, Number(found.id));
282
- return { note: documentOf(documentRow(String(found.id))), changed: true };
412
+ .prepare("UPDATE documents SET title = ?, body = ?, front_matter = ?, content_hash = ?, metadata = ?, language = ?, " +
413
+ "revision = revision + ?, updated_at = ?, deleted_at = NULL WHERE id = ?")
414
+ .run(title, text, keptFrontMatter, hash, keptMetadata, language, (found.body ?? "") === text ? 0 : 1, at, id);
415
+ }
416
+ syncFile(database, at, id, own ? {} : { place: here });
417
+ if (!same)
418
+ followPreferred(id, at);
419
+ return { note: noteAt(id, folder, path), changed: !same };
283
420
  }),
284
421
  renameFileNote: async (folderId, from, to) => write(database, () => {
285
422
  const folder = Number(folderRow(folderId).id);
286
423
  const target = textOf(to, "a note's path", 4096);
287
- const found = database
424
+ const at = now();
425
+ const place = fileAt(folder, from, true);
426
+ const legacy = database
288
427
  .prepare("SELECT id FROM documents WHERE account_id = ? AND external_id = ? AND deleted_at IS NULL")
289
428
  .get(folder, from);
290
- if (!found)
429
+ const id = place?.document_id != null ? Number(place.document_id) : legacy ? Number(legacy.id) : undefined;
430
+ if (id === undefined)
291
431
  throw new CliError("not_found", `no live note at ${from} in folder ${folderId}`);
432
+ const takenFile = fileAt(folder, target);
433
+ if (takenFile?.deleted_at == null && takenFile !== undefined)
434
+ throw new CliError("validation_error", `a live note is already at ${target} in folder ${folderId}`);
292
435
  const taken = database
293
- .prepare("SELECT id, deleted_at FROM documents WHERE account_id = ? AND external_id = ?")
294
- .get(folder, target);
436
+ .prepare("SELECT id, deleted_at FROM documents WHERE account_id = ? AND external_id = ? AND id <> ?")
437
+ .get(folder, target, id);
295
438
  if (taken && taken.deleted_at == null)
296
439
  throw new CliError("validation_error", `a live note is already at ${target} in folder ${folderId}`);
440
+ // The gone note at the target goes, as it always did; its file row goes with it or on its own.
297
441
  if (taken)
298
442
  database.prepare("DELETE FROM documents WHERE id = ?").run(Number(taken.id));
299
- const { location, fileName, extension } = pathParts(target);
300
- database
301
- .prepare("UPDATE documents SET external_id = ?, location = ?, file_name = ?, extension = ?, updated_at = ? WHERE id = ?")
302
- .run(target, location, fileName, extension, now(), Number(found.id));
303
- return documentOf(documentRow(String(found.id)));
443
+ if (takenFile)
444
+ database.prepare("DELETE FROM files WHERE id = ?").run(Number(takenFile.id));
445
+ if (place)
446
+ database.prepare("UPDATE files SET external_id = ? WHERE id = ?").run(target, Number(place.id));
447
+ const document = documentRow(String(id));
448
+ const own = Number(document.account_id) === folder && String(document.external_id) === from;
449
+ if (own) {
450
+ const { location, fileName, extension } = pathParts(target);
451
+ database
452
+ .prepare("UPDATE documents SET external_id = ?, location = ?, file_name = ?, extension = ?, updated_at = ? WHERE id = ?")
453
+ .run(target, location, fileName, extension, at, id);
454
+ syncFile(database, at, id);
455
+ }
456
+ else
457
+ syncFile(database, at, id, {
458
+ place: {
459
+ accountId: folder,
460
+ path: target,
461
+ contentHash: place?.content_hash == null ? null : String(place.content_hash),
462
+ frontMatter: frontMatterOf(place?.metadata),
463
+ },
464
+ });
465
+ return noteAt(id, folder, target);
304
466
  }),
305
467
  deleteFileNotes: async (folderId, paths) => write(database, () => {
306
468
  const folder = Number(folderRow(folderId).id);
307
- const mark = database.prepare("UPDATE documents SET deleted_at = ?, body = '' WHERE account_id = ? AND external_id = ? AND deleted_at IS NULL");
308
- return paths.reduce((sum, path) => sum + mark.run(now(), folder, path).changes, 0);
469
+ let deleted = 0;
470
+ for (const path of paths) {
471
+ const at = now();
472
+ const place = fileAt(folder, path, true);
473
+ if (!place) {
474
+ const marked = database
475
+ .prepare("UPDATE documents SET deleted_at = ?, body = '' WHERE account_id = ? AND external_id = ? AND deleted_at IS NULL RETURNING id")
476
+ .all(at, folder, path);
477
+ for (const row of marked)
478
+ syncFile(database, at, Number(row.id), { seen: false });
479
+ deleted += marked.length;
480
+ continue;
481
+ }
482
+ database.prepare("UPDATE files SET deleted_at = ?, updated_at = ? WHERE id = ?").run(at, at, Number(place.id));
483
+ deleted++;
484
+ if (place.document_id == null)
485
+ continue;
486
+ const id = Number(place.document_id);
487
+ // A document stays while any copy of it is live (requirements 3.1.7).
488
+ const elsewhere = database
489
+ .prepare("SELECT 1 FROM files WHERE document_id = ? AND deleted_at IS NULL AND kind <> 'folder' LIMIT 1")
490
+ .get(id);
491
+ if (elsewhere)
492
+ followPreferred(id, at);
493
+ else
494
+ database
495
+ .prepare("UPDATE documents SET deleted_at = ?, body = '' WHERE id = ? AND deleted_at IS NULL")
496
+ .run(at, id);
497
+ }
498
+ return deleted;
309
499
  }),
310
500
  addNote: async ({ text, title, about = [] }) => write(database, () => {
311
501
  const at = now();
@@ -341,7 +531,15 @@ export const notesStoreOver = (context) => {
341
531
  const files = options.source === "internal"
342
532
  ? []
343
533
  : database
344
- .prepare("SELECT d.* FROM documents d WHERE d.deleted_at IS NULL AND coalesce(json_extract(d.metadata, '$.catalog.active'), 1)=1 AND (? IS NULL OR d.account_id = ?) " +
534
+ .prepare(
535
+ // In a folder, a document is wherever one of its live files is, under that file's path.
536
+ (folder === null
537
+ ? "SELECT d.* FROM documents d WHERE d.deleted_at IS NULL AND ? IS NULL AND ? IS NULL "
538
+ : "SELECT d.*, f.account_id AS account_id, CASE WHEN a.provider IN ('local', 'obsidian') " +
539
+ "THEN f.external_id ELSE d.external_id END AS external_id FROM files f " +
540
+ "JOIN documents d ON d.id = f.document_id JOIN accounts a ON a.id = f.account_id " +
541
+ "WHERE f.deleted_at IS NULL AND d.deleted_at IS NULL AND f.account_id = ? AND ? IS NOT NULL ") +
542
+ "AND coalesce(json_extract(d.metadata, '$.catalog.active'), 1)=1 " +
345
543
  `AND (? IS NULL OR EXISTS (SELECT 1 FROM links l WHERE l.from_type = 'document' AND l.from_id = d.id AND l.kind = ${linkKind("about")} AND l.to_type = ? AND l.to_id = ?)) ` +
346
544
  "AND (? IS NULL OR instr(lower(coalesce(d.body, '')), lower(?)) > 0) ORDER BY d.created_at DESC, d.id DESC LIMIT ?")
347
545
  .all(folder, folder, aboutType, aboutType, aboutId, search, search, limit + offset + 1)
@@ -465,12 +663,11 @@ export const notesStoreOver = (context) => {
465
663
  replaceFileTags: async (documentId, tags) => write(database, () => {
466
664
  const id = Number(documentRow(documentId).id);
467
665
  const stated = [...new Set(tags.map(normalizeTag))];
666
+ const selected = [...new Set(stated.map((name) => ensureTag(context, name).id))];
468
667
  database
469
668
  .prepare("DELETE FROM taggings WHERE taggable_type = 'document' AND taggable_id = ? AND source = 'file' " +
470
- "AND tag_id NOT IN (SELECT t.id FROM tags t JOIN json_each(?) j ON j.value = t.name)")
471
- .run(id, JSON.stringify(stated));
472
- for (const tag of stated)
473
- ensureTag(context, tag);
669
+ "AND tag_id NOT IN (SELECT t.id FROM resolved_tags t JOIN json_each(?) j ON j.value=t.canonical_id)")
670
+ .run(id, JSON.stringify(selected));
474
671
  addTags(context, { type: "document", id }, stated, "file");
475
672
  return documentTags(id);
476
673
  }),
@@ -6,7 +6,9 @@ import { storeCapable } from "../open.js";
6
6
  import { storePath } from "../path.js";
7
7
  import { sharedConfigPath } from "../shared-config.js";
8
8
  import { backfillNormalized, pendingNormalization } from "./backfill.js";
9
+ import { fillFiles } from "./files.js";
9
10
  import { fillFolded } from "./folded.js";
11
+ import { fillJoinKeys } from "./join-keys.js";
10
12
  import { logPruner } from "./log-pruning.js";
11
13
  import { openSqlite } from "./open.js";
12
14
  import { loadProviders } from "./providers.js";
@@ -48,7 +50,9 @@ export const openStoreContext = async ({ path, env, now = Date.now, command, ver
48
50
  const notice = migrate(database, { now, command, ...(version ? { version } : {}) });
49
51
  if (notice)
50
52
  warn(notice);
53
+ fillFiles(database, now);
51
54
  fillFolded(database);
55
+ fillJoinKeys(database);
52
56
  const pending = pendingNormalization(database);
53
57
  if (pending > 0 && pending <= BACKFILL_ON_OPEN)
54
58
  backfillNormalized(database);
@@ -154,9 +154,14 @@ export const joinByAddress = (context, ref, address, by) => {
154
154
  if (!/^[^\s@]+@[^\s@]+$/.test(key) || ref.provider === "email")
155
155
  return;
156
156
  const found = required(context, ref);
157
- if (!identityRow(context, { provider: "email", id: key }))
157
+ context.database.prepare("UPDATE identities SET email = ? WHERE id = ? AND email IS NOT ?").run(key, found.pk, key);
158
+ // The mail contact of the address, found by the address column every contact with one carries (store version 32).
159
+ const mail = context.database
160
+ .prepare("SELECT external_id FROM identities WHERE provider = 'email' AND email = ? LIMIT 1")
161
+ .get(key);
162
+ if (!mail)
158
163
  identityOf(context, "email", key, null);
159
- const contact = required(context, { provider: "email", id: key });
164
+ const contact = required(context, { provider: "email", id: mail ? String(mail.external_id) : key });
160
165
  if (contact.personPk === found.personPk || separatedByOwner(context, found.pk, contact.pk))
161
166
  return;
162
167
  mergePerson(context, found.personPk, contact.personPk, { method: "same-email", by });
@@ -0,0 +1,87 @@
1
+ export declare const canonicalTags: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
2
+ name: "canonical_tags";
3
+ schema: undefined;
4
+ columns: {
5
+ id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
6
+ name: string;
7
+ tableName: "canonical_tags";
8
+ dataType: "number int53";
9
+ data: number;
10
+ driverParam: number;
11
+ notNull: true;
12
+ hasDefault: true;
13
+ isPrimaryKey: true;
14
+ isAutoincrement: false;
15
+ hasRuntimeDefault: false;
16
+ enumValues: undefined;
17
+ baseColumn: never;
18
+ identity: undefined;
19
+ generated: undefined;
20
+ }, {}>;
21
+ name: import("drizzle-orm/sqlite-core").SQLiteColumn<{
22
+ name: string;
23
+ tableName: "canonical_tags";
24
+ dataType: "string";
25
+ data: string;
26
+ driverParam: string;
27
+ notNull: true;
28
+ hasDefault: false;
29
+ isPrimaryKey: false;
30
+ isAutoincrement: false;
31
+ hasRuntimeDefault: false;
32
+ enumValues: [string, ...string[]];
33
+ baseColumn: never;
34
+ identity: undefined;
35
+ generated: undefined;
36
+ }, {}>;
37
+ kind: import("drizzle-orm/sqlite-core").SQLiteColumn<{
38
+ name: string;
39
+ tableName: "canonical_tags";
40
+ dataType: "string";
41
+ data: string;
42
+ driverParam: string;
43
+ notNull: true;
44
+ hasDefault: true;
45
+ isPrimaryKey: false;
46
+ isAutoincrement: false;
47
+ hasRuntimeDefault: false;
48
+ enumValues: [string, ...string[]];
49
+ baseColumn: never;
50
+ identity: undefined;
51
+ generated: undefined;
52
+ }, {}>;
53
+ createdAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
54
+ name: string;
55
+ tableName: "canonical_tags";
56
+ dataType: "number int53";
57
+ data: number;
58
+ driverParam: number;
59
+ notNull: true;
60
+ hasDefault: false;
61
+ isPrimaryKey: false;
62
+ isAutoincrement: false;
63
+ hasRuntimeDefault: false;
64
+ enumValues: undefined;
65
+ baseColumn: never;
66
+ identity: undefined;
67
+ generated: undefined;
68
+ }, {}>;
69
+ updatedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
70
+ name: string;
71
+ tableName: "canonical_tags";
72
+ dataType: "number int53";
73
+ data: number;
74
+ driverParam: number;
75
+ notNull: true;
76
+ hasDefault: false;
77
+ isPrimaryKey: false;
78
+ isAutoincrement: false;
79
+ hasRuntimeDefault: false;
80
+ enumValues: undefined;
81
+ baseColumn: never;
82
+ identity: undefined;
83
+ generated: undefined;
84
+ }, {}>;
85
+ };
86
+ dialect: 'sqlite';
87
+ }>;
@@ -0,0 +1,8 @@
1
+ import { integer, sqliteTable, text } from "../drizzle/core.js";
2
+ export const canonicalTags = sqliteTable("canonical_tags", {
3
+ id: integer("id").primaryKey(),
4
+ name: text("name").notNull().unique(),
5
+ kind: text("kind").notNull().default("tag"),
6
+ createdAt: integer("created_at").notNull(),
7
+ updatedAt: integer("updated_at").notNull(),
8
+ });