@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.
- package/CHANGELOG.md +48 -0
- package/dist/cli/bot/context.d.ts +3 -2
- package/dist/cli/bot/context.js +15 -0
- package/dist/cli/config-command.js +1 -2
- package/dist/cli/index.d.ts +0 -1
- package/dist/cli/index.js +0 -1
- package/dist/cli/messenger/context.js +3 -6
- package/dist/cli/permission-keys.d.ts +2 -0
- package/dist/cli/permission-keys.js +10 -3
- package/dist/cli/preview.d.ts +2 -2
- package/dist/cli/preview.js +4 -3
- package/dist/cli/program.js +4 -8
- package/dist/cli/settings.d.ts +0 -2
- package/dist/cli/settings.js +1 -5
- package/dist/domain/providers.d.ts +20 -2
- package/dist/domain/providers.js +23 -3
- package/dist/domain/tag-consolidation.d.ts +30 -1
- package/dist/entities/index.d.ts +4 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mcp/surface.js +0 -4
- package/dist/mcp/tool.d.ts +1 -3
- package/dist/mcp/tool.js +1 -2
- package/dist/meetings/calendar-text.d.ts +13 -0
- package/dist/meetings/calendar-text.js +57 -0
- package/dist/meetings/event-store.d.ts +202 -0
- package/dist/meetings/event-store.js +1 -0
- package/dist/meetings/events.js +5 -1
- package/dist/meetings/index.d.ts +3 -0
- package/dist/meetings/index.js +2 -0
- package/dist/meetings/join-key.d.ts +7 -0
- package/dist/meetings/join-key.js +38 -0
- package/dist/meetings/mcp/index.d.ts +15 -15
- package/dist/meetings/model.d.ts +8 -2
- package/dist/meetings/read-answers.d.ts +6 -2
- package/dist/meetings/read-answers.js +9 -2
- package/dist/meetings/store.d.ts +2 -0
- package/dist/meetings/testing/event-store-contract.d.ts +10 -0
- package/dist/meetings/testing/event-store-contract.js +161 -0
- package/dist/meetings/testing/index.d.ts +1 -0
- package/dist/meetings/testing/index.js +1 -0
- package/dist/parity/manifest.js +0 -4
- package/dist/search/account-list.js +39 -7
- package/dist/sends/index.d.ts +1 -1
- package/dist/sends/index.js +1 -1
- package/dist/sends/permissions.d.ts +0 -13
- package/dist/sends/permissions.js +15 -142
- package/dist/services/agenda.js +5 -5
- package/dist/services/document-source.d.ts +7 -3
- package/dist/services/document-source.js +24 -12
- package/dist/services/documents/ingestion.d.ts +6 -1
- package/dist/services/documents/ingestion.js +4 -0
- package/dist/services/notes-search.js +3 -2
- package/dist/services/search-all.js +1 -0
- package/dist/services/task-tag-filters.d.ts +3 -0
- package/dist/services/task-tag-filters.js +8 -0
- package/dist/services/tasks.js +5 -9
- package/dist/store/document-transfer/index.js +12 -2
- package/dist/store/index.d.ts +2 -1
- package/dist/store/sqlite/accounts.js +1 -0
- package/dist/store/sqlite/calendar.d.ts +3 -0
- package/dist/store/sqlite/calendar.js +593 -0
- package/dist/store/sqlite/catalog.js +16 -5
- package/dist/store/sqlite/chat-rekey.js +1 -1
- package/dist/store/sqlite/chats.js +2 -2
- package/dist/store/sqlite/context-document-source.js +8 -4
- package/dist/store/sqlite/document-transfer/apply.js +24 -2
- package/dist/store/sqlite/document-transfer/collection.d.ts +19 -0
- package/dist/store/sqlite/document-transfer/collection.js +66 -0
- package/dist/store/sqlite/document-transfer/plan.js +15 -8
- package/dist/store/sqlite/document-transfer/snapshot.js +12 -7
- package/dist/store/sqlite/entity-types.d.ts +5 -1
- package/dist/store/sqlite/entity-types.js +5 -1
- package/dist/store/sqlite/events.d.ts +8 -2
- package/dist/store/sqlite/events.js +36 -12
- package/dist/store/sqlite/files.d.ts +93 -0
- package/dist/store/sqlite/files.js +293 -0
- package/dist/store/sqlite/folded.d.ts +3 -0
- package/dist/store/sqlite/folded.js +1 -0
- package/dist/store/sqlite/imported-documents.js +1 -1
- package/dist/store/sqlite/join-keys.d.ts +8 -0
- package/dist/store/sqlite/join-keys.js +27 -0
- package/dist/store/sqlite/knowledge.js +6 -6
- package/dist/store/sqlite/link-reviews.d.ts +9 -0
- package/dist/store/sqlite/link-reviews.js +12 -0
- package/dist/store/sqlite/lucene-fields.js +8 -6
- package/dist/store/sqlite/manifest.js +11 -0
- package/dist/store/sqlite/meeting-selection.js +1 -1
- package/dist/store/sqlite/meetings.js +6 -1
- package/dist/store/sqlite/migrations.generated.js +143 -0
- package/dist/store/sqlite/note-index.d.ts +8 -1
- package/dist/store/sqlite/note-index.js +14 -0
- package/dist/store/sqlite/note-search.js +6 -5
- package/dist/store/sqlite/notes.d.ts +18 -1
- package/dist/store/sqlite/notes.js +237 -40
- package/dist/store/sqlite/open-store.js +4 -0
- package/dist/store/sqlite/person-links.js +7 -2
- package/dist/store/sqlite/schema/canonical-tags.d.ts +87 -0
- package/dist/store/sqlite/schema/canonical-tags.js +8 -0
- package/dist/store/sqlite/schema/tag-consolidations.d.ts +270 -0
- package/dist/store/sqlite/schema/tag-consolidations.js +34 -0
- package/dist/store/sqlite/schema.d.ts +3589 -1634
- package/dist/store/sqlite/schema.js +191 -13
- package/dist/store/sqlite/tag-consolidation-apply.d.ts +3 -0
- package/dist/store/sqlite/tag-consolidation-apply.js +88 -0
- package/dist/store/sqlite/tag-consolidation-input.d.ts +16 -0
- package/dist/store/sqlite/tag-consolidation-input.js +52 -0
- package/dist/store/sqlite/tag-consolidation.js +51 -66
- package/dist/store/sqlite/tag-creation.js +17 -8
- package/dist/store/sqlite/tag-resolution.d.ts +5 -0
- package/dist/store/sqlite/tag-resolution.js +33 -0
- package/dist/store/sqlite/tags.d.ts +3 -1
- package/dist/store/sqlite/tags.js +70 -50
- package/dist/store/sqlite/tasks.d.ts +2 -0
- package/dist/store/sqlite/tasks.js +6 -1
- package/dist/store/sqlite/things.js +2 -1
- package/dist/store/sqlite/vocabulary.d.ts +1 -0
- package/dist/store/sqlite/vocabulary.js +30 -8
- package/dist/store/store.d.ts +6 -0
- package/dist/store/store.js +10 -0
- package/dist/store/tags.js +2 -2
- package/package.json +2 -1
- package/dist/cli/moved.d.ts +0 -18
- package/dist/cli/moved.js +0 -72
- package/dist/sends/permission-aliases.d.ts +0 -5
- 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
|
-
|
|
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(
|
|
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 (
|
|
236
|
-
.get(ulid(at), textOf(name, "a folder's name", 200), JSON.stringify({ format:
|
|
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(
|
|
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
|
|
250
|
-
|
|
251
|
-
|
|
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,
|
|
264
|
-
|
|
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) ===
|
|
269
|
-
(found.metadata ?? null) ===
|
|
270
|
-
(found.content_hash ?? 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
|
-
|
|
274
|
-
|
|
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("
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
.
|
|
303
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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(
|
|
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
|
|
471
|
-
.run(id, JSON.stringify(
|
|
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
|
-
|
|
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
|
+
});
|