@openparachute/vault 0.3.3 → 0.4.3
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/.parachute/module.json +15 -0
- package/README.md +133 -0
- package/core/src/core.test.ts +2990 -92
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +413 -68
- package/core/src/notes.ts +693 -42
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +331 -0
- package/core/src/schema.ts +467 -11
- package/core/src/store.ts +262 -8
- package/core/src/tag-hierarchy.ts +171 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +96 -7
- package/core/src/vault-projection.ts +309 -0
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +360 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +173 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +322 -57
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +307 -0
- package/src/hub-jwt.ts +88 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +33 -29
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +318 -19
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +6 -5
- package/src/routes.ts +796 -61
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +106 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +727 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +1626 -183
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- package/scripts/migrate-audio-to-opus.ts +0 -499
package/core/src/notes.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { Database } from "bun:sqlite";
|
|
1
|
+
import { Database, type SQLQueryBindings } from "bun:sqlite";
|
|
2
2
|
import type { Note, NoteIndex, QueryOpts, VaultStats } from "./types.js";
|
|
3
3
|
import { normalizePath } from "./paths.js";
|
|
4
4
|
import {
|
|
5
5
|
buildOperatorClause,
|
|
6
6
|
isOperatorObject,
|
|
7
|
+
QueryError,
|
|
7
8
|
requireIndexedField,
|
|
8
9
|
} from "./query-operators.js";
|
|
9
10
|
|
|
@@ -35,15 +36,32 @@ export function createNote(
|
|
|
35
36
|
const metadata = opts?.metadata ? JSON.stringify(opts.metadata) : "{}";
|
|
36
37
|
const path = normalizePath(opts?.path);
|
|
37
38
|
|
|
39
|
+
// Empty-note invariant (#213): reject `content+path both absent`. Three
|
|
40
|
+
// legit shapes — content-only, path-only, both — only the empty+empty
|
|
41
|
+
// combo is the runaway-client signature that flooded a deployment with
|
|
42
|
+
// 7,453 pathless empty notes in one MCP burst. `content` only is a
|
|
43
|
+
// legitimate un-pathed jot; `path` only is a wikilink placeholder or
|
|
44
|
+
// `_schemas/*` config note.
|
|
45
|
+
if (!content.trim() && path === null) {
|
|
46
|
+
throw new EmptyNoteError();
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
// `updated_at` is set to `created_at` on insert so a client whose optimistic
|
|
39
50
|
// concurrency check falls back to `createdAt` on a never-updated note
|
|
40
51
|
// (the common shape: `note.updatedAt ?? note.createdAt`) matches the stored
|
|
41
52
|
// value. Hook-style writes with `skipUpdatedAt` preserve this; real user
|
|
42
53
|
// edits bump it strictly upward, so `updated_at > created_at` still means
|
|
43
54
|
// "user-touched since creation."
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
55
|
+
try {
|
|
56
|
+
db.prepare(
|
|
57
|
+
`INSERT INTO notes (id, content, path, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
|
58
|
+
).run(id, content, path, metadata, createdAt, createdAt);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (path !== null && isPathUniqueError(err)) {
|
|
61
|
+
throw new PathConflictError(path);
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
47
65
|
|
|
48
66
|
if (opts?.tags && opts.tags.length > 0) {
|
|
49
67
|
tagNote(db, id, opts.tags);
|
|
@@ -108,11 +126,98 @@ export class ConflictError extends Error {
|
|
|
108
126
|
}
|
|
109
127
|
}
|
|
110
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Thrown by `createNote` / `updateNote` when the requested path is already
|
|
131
|
+
* taken by another note. Surfaces as 409 at the HTTP layer so clients can
|
|
132
|
+
* distinguish "path taken — pick another" from a generic 500.
|
|
133
|
+
*
|
|
134
|
+
* Detected by catching SQLite's UNIQUE-constraint error on the
|
|
135
|
+
* `idx_notes_path_unique` partial index (schema v5+). Matches the tag
|
|
136
|
+
* "UNIQUE constraint failed: notes.path" rather than a numeric code so
|
|
137
|
+
* we keep working if bun:sqlite changes its error class hierarchy.
|
|
138
|
+
*/
|
|
139
|
+
export class PathConflictError extends Error {
|
|
140
|
+
code = "PATH_CONFLICT" as const;
|
|
141
|
+
path: string;
|
|
142
|
+
|
|
143
|
+
constructor(path: string) {
|
|
144
|
+
super(`path_conflict: another note already uses path "${path}"`);
|
|
145
|
+
this.name = "PathConflictError";
|
|
146
|
+
this.path = path;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Per-call item cap on `createNote`/`updateNote` batch entry points
|
|
152
|
+
* (MCP `create-note` / `update-note` and HTTP `POST /api/notes`).
|
|
153
|
+
* Single source of truth — both transports import from here so the cap
|
|
154
|
+
* can never silently drift between them. See #213 for the runaway-client
|
|
155
|
+
* incident that motivated the cap (7,453 empty notes in one MCP burst).
|
|
156
|
+
*/
|
|
157
|
+
export const MAX_BATCH_SIZE = 500;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Thrown by `createNote` / `updateNote` when the proposed note state has
|
|
161
|
+
* neither content nor path. The vault accepts un-pathed jots (content only)
|
|
162
|
+
* and path-only placeholders (wikilink stubs, `_schemas/*`), but a note
|
|
163
|
+
* with neither is the runaway-client signature flagged in #213 — one MCP
|
|
164
|
+
* burst flooded a deployment with 7,453 empty pathless rows. Surfaces as
|
|
165
|
+
* 400 at the HTTP layer.
|
|
166
|
+
*/
|
|
167
|
+
export class EmptyNoteError extends Error {
|
|
168
|
+
code = "EMPTY_NOTE" as const;
|
|
169
|
+
note_id: string | null;
|
|
170
|
+
/**
|
|
171
|
+
* Zero-based position in a batch call when the empty entry is rejected via
|
|
172
|
+
* the transport-layer pre-validation pass (HTTP `POST /api/notes` or MCP
|
|
173
|
+
* `create-note` with `notes: [...]`). `null` for single-update rejections
|
|
174
|
+
* and for Store-level throws that don't know their batch context.
|
|
175
|
+
*/
|
|
176
|
+
item_index: number | null;
|
|
177
|
+
|
|
178
|
+
constructor(noteId: string | null = null, itemIndex: number | null = null) {
|
|
179
|
+
super(
|
|
180
|
+
noteId
|
|
181
|
+
? `empty_note: update would leave note "${noteId}" with neither content nor path`
|
|
182
|
+
: itemIndex !== null
|
|
183
|
+
? `empty_note: a note must have either content or a path (item index ${itemIndex})`
|
|
184
|
+
: `empty_note: a note must have either content or a path`,
|
|
185
|
+
);
|
|
186
|
+
this.name = "EmptyNoteError";
|
|
187
|
+
this.note_id = noteId;
|
|
188
|
+
this.item_index = itemIndex;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Match bun:sqlite's UNIQUE-constraint error on the notes.path index. The
|
|
194
|
+
* error class is `SQLiteError` but matching on the message is sufficient
|
|
195
|
+
* here — the index name and column are stable parts of the schema, and
|
|
196
|
+
* bun:sqlite has carried this exact message text since 1.0.
|
|
197
|
+
*/
|
|
198
|
+
function isPathUniqueError(err: unknown): boolean {
|
|
199
|
+
if (!(err instanceof Error)) return false;
|
|
200
|
+
return err.message.includes("UNIQUE constraint failed: notes.path");
|
|
201
|
+
}
|
|
202
|
+
|
|
111
203
|
export function updateNote(
|
|
112
204
|
db: Database,
|
|
113
205
|
id: string,
|
|
114
206
|
updates: {
|
|
115
207
|
content?: string;
|
|
208
|
+
/**
|
|
209
|
+
* Atomic content append. Computed via SQL string concatenation
|
|
210
|
+
* (`content = content || ?`), so two concurrent appends never
|
|
211
|
+
* overwrite each other — the second simply lands after the first.
|
|
212
|
+
* Mutually exclusive with `content`.
|
|
213
|
+
*/
|
|
214
|
+
append?: string;
|
|
215
|
+
/**
|
|
216
|
+
* Atomic content prepend. Same SQL-level guarantee as `append`.
|
|
217
|
+
* Mutually exclusive with `content`. May be combined with `append`
|
|
218
|
+
* in a single call (both contributions land).
|
|
219
|
+
*/
|
|
220
|
+
prepend?: string;
|
|
116
221
|
path?: string;
|
|
117
222
|
metadata?: Record<string, unknown>;
|
|
118
223
|
created_at?: string;
|
|
@@ -125,8 +230,45 @@ export function updateNote(
|
|
|
125
230
|
if_updated_at?: string;
|
|
126
231
|
},
|
|
127
232
|
): Note {
|
|
233
|
+
if (updates.content !== undefined && (updates.append !== undefined || updates.prepend !== undefined)) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
"update-note: `content` is mutually exclusive with `append`/`prepend`. Pick full-replace or additive — not both in the same call.",
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Empty-note invariant (#213): when this update touches content or path,
|
|
240
|
+
// reject if the post-state would be empty content + null path. We only
|
|
241
|
+
// enforce on transitions that actually touch the relevant fields, so
|
|
242
|
+
// metadata-only or tag-only updates against legacy empty rows still pass.
|
|
243
|
+
// Hook-style writes (skipUpdatedAt) are exempted — they're machine-level
|
|
244
|
+
// marker writes that legitimately may run against any shape of row.
|
|
245
|
+
const touchesContent = updates.content !== undefined
|
|
246
|
+
|| updates.append !== undefined
|
|
247
|
+
|| updates.prepend !== undefined;
|
|
248
|
+
const touchesPath = updates.path !== undefined;
|
|
249
|
+
if ((touchesContent || touchesPath) && !updates.skipUpdatedAt) {
|
|
250
|
+
const current = getNote(db, id);
|
|
251
|
+
if (current) {
|
|
252
|
+
let finalContent: string;
|
|
253
|
+
if (updates.content !== undefined) {
|
|
254
|
+
finalContent = updates.content;
|
|
255
|
+
} else if (touchesContent) {
|
|
256
|
+
finalContent = (updates.prepend ?? "") + current.content + (updates.append ?? "");
|
|
257
|
+
} else {
|
|
258
|
+
finalContent = current.content;
|
|
259
|
+
}
|
|
260
|
+
const finalPath = touchesPath ? normalizePath(updates.path) : (current.path ?? null);
|
|
261
|
+
if (!finalContent.trim() && !finalPath) {
|
|
262
|
+
throw new EmptyNoteError(id);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// If `current` is null we fall through — existing code paths handle the
|
|
266
|
+
// missing-row case downstream (the conditional UPDATE returns 0 rows;
|
|
267
|
+
// OC throws ConflictError; non-OC returns silently).
|
|
268
|
+
}
|
|
269
|
+
|
|
128
270
|
const sets: string[] = [];
|
|
129
|
-
const values:
|
|
271
|
+
const values: (string | null)[] = [];
|
|
130
272
|
|
|
131
273
|
// Hooks and other machine-level writers pass `skipUpdatedAt: true` so
|
|
132
274
|
// their metadata markers don't look like user activity. See issue #44.
|
|
@@ -150,6 +292,34 @@ export function updateNote(
|
|
|
150
292
|
sets.push("content = ?");
|
|
151
293
|
values.push(updates.content);
|
|
152
294
|
}
|
|
295
|
+
if (updates.append !== undefined || updates.prepend !== undefined) {
|
|
296
|
+
// Atomic concat at the SQL layer. SQLite's `||` operator on the
|
|
297
|
+
// existing `content` column means a concurrent reader-then-writer
|
|
298
|
+
// race window is impossible: each `UPDATE` evaluates `content`
|
|
299
|
+
// under the write lock, so two simultaneous appends both land
|
|
300
|
+
// (in some order) instead of one clobbering the other.
|
|
301
|
+
//
|
|
302
|
+
// Frontmatter-aware prepend (#203): if the note opens with a YAML
|
|
303
|
+
// frontmatter block (`---\n...\n---\n`), the prepend is injected
|
|
304
|
+
// *after* the closing `---\n` so parsers that expect frontmatter
|
|
305
|
+
// at byte 0 still find it. Detection uses `instr(content, '\n---\n')`
|
|
306
|
+
// — the closing fence is whatever `\n---\n` appears after the
|
|
307
|
+
// opening one. If no frontmatter is detected, prepend goes at
|
|
308
|
+
// byte 0 as before. Atomicity is preserved: the entire transform
|
|
309
|
+
// is one UPDATE expression evaluated under the write lock.
|
|
310
|
+
sets.push(
|
|
311
|
+
"content = CASE "
|
|
312
|
+
+ "WHEN substr(content, 1, 4) = '---' || char(10) "
|
|
313
|
+
+ "AND instr(content, char(10) || '---' || char(10)) > 0 "
|
|
314
|
+
+ "THEN substr(content, 1, instr(content, char(10) || '---' || char(10)) + 4) || ? "
|
|
315
|
+
+ "|| substr(content, instr(content, char(10) || '---' || char(10)) + 5) || ? "
|
|
316
|
+
+ "ELSE ? || content || ? "
|
|
317
|
+
+ "END",
|
|
318
|
+
);
|
|
319
|
+
const prependVal = updates.prepend ?? "";
|
|
320
|
+
const appendVal = updates.append ?? "";
|
|
321
|
+
values.push(prependVal, appendVal, prependVal, appendVal);
|
|
322
|
+
}
|
|
153
323
|
if (updates.path !== undefined) {
|
|
154
324
|
sets.push("path = ?");
|
|
155
325
|
values.push(normalizePath(updates.path));
|
|
@@ -167,13 +337,15 @@ export function updateNote(
|
|
|
167
337
|
// need to validate the precondition; a conditional UPDATE that sets
|
|
168
338
|
// updated_at to itself does exactly that atomically — even a no-net-
|
|
169
339
|
// change UPDATE takes the write lock in WAL mode, so it still serializes
|
|
170
|
-
// with other writers
|
|
340
|
+
// with other writers. `RETURNING id` reports the row only when WHERE
|
|
341
|
+
// matched — `.changes` is unreliable inside multi-statement transactions
|
|
342
|
+
// (vault#261).
|
|
171
343
|
if (sets.length === 0) {
|
|
172
344
|
if (updates.if_updated_at !== undefined) {
|
|
173
345
|
const probe = db.prepare(
|
|
174
|
-
"UPDATE notes SET updated_at = updated_at WHERE id = ? AND updated_at IS ?",
|
|
175
|
-
).
|
|
176
|
-
if (probe
|
|
346
|
+
"UPDATE notes SET updated_at = updated_at WHERE id = ? AND updated_at IS ? RETURNING id",
|
|
347
|
+
).get(id, updates.if_updated_at) as { id: string } | null;
|
|
348
|
+
if (probe === null) {
|
|
177
349
|
throwConflictOrMissing(db, id, updates.if_updated_at);
|
|
178
350
|
}
|
|
179
351
|
}
|
|
@@ -187,9 +359,23 @@ export function updateNote(
|
|
|
187
359
|
values.push(updates.if_updated_at);
|
|
188
360
|
}
|
|
189
361
|
|
|
190
|
-
|
|
362
|
+
let matched: { id: string } | null = null;
|
|
363
|
+
try {
|
|
364
|
+
if (updates.if_updated_at !== undefined) {
|
|
365
|
+
matched = db.prepare(`${sql} RETURNING id`).get(...values) as
|
|
366
|
+
| { id: string }
|
|
367
|
+
| null;
|
|
368
|
+
} else {
|
|
369
|
+
db.prepare(sql).run(...values);
|
|
370
|
+
}
|
|
371
|
+
} catch (err) {
|
|
372
|
+
if (updates.path !== undefined && isPathUniqueError(err)) {
|
|
373
|
+
throw new PathConflictError(normalizePath(updates.path) ?? updates.path);
|
|
374
|
+
}
|
|
375
|
+
throw err;
|
|
376
|
+
}
|
|
191
377
|
|
|
192
|
-
if (updates.if_updated_at !== undefined &&
|
|
378
|
+
if (updates.if_updated_at !== undefined && matched === null) {
|
|
193
379
|
throwConflictOrMissing(db, id, updates.if_updated_at);
|
|
194
380
|
}
|
|
195
381
|
|
|
@@ -212,21 +398,38 @@ export function deleteNote(db: Database, id: string): void {
|
|
|
212
398
|
|
|
213
399
|
export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
214
400
|
const conditions: string[] = [];
|
|
215
|
-
const params:
|
|
401
|
+
const params: SQLQueryBindings[] = [];
|
|
216
402
|
const joins: string[] = [];
|
|
217
403
|
|
|
218
|
-
// Include tags — "all" (default): must have ALL tags; "any": must have ANY tag
|
|
404
|
+
// Include tags — "all" (default): must have ALL tags; "any": must have ANY tag.
|
|
405
|
+
// The `_tagsExpanded` internal field carries per-input-tag descendant sets
|
|
406
|
+
// when the tag-hierarchy resolver (see core/src/tag-hierarchy.ts) has
|
|
407
|
+
// expanded the input — `tags: ["manual"]` becomes the set
|
|
408
|
+
// `{manual, voice, text, ...}` per declared `_tags/*` config notes. Falls
|
|
409
|
+
// back to `[opts.tags[i]]` (single-element set) when no expansion is set,
|
|
410
|
+
// preserving the original semantics.
|
|
219
411
|
if (opts.tags && opts.tags.length > 0) {
|
|
412
|
+
const tagSets: string[][] = (opts as QueryOpts & { _tagsExpanded?: string[][] })._tagsExpanded
|
|
413
|
+
?? opts.tags.map((t) => [t]);
|
|
220
414
|
const match = opts.tagMatch ?? "all";
|
|
221
415
|
if (match === "any") {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
416
|
+
// Flatten all expanded sets and dedupe — a note tagged with any one
|
|
417
|
+
// matches the input.
|
|
418
|
+
const flat = Array.from(new Set(tagSets.flat()));
|
|
419
|
+
if (flat.length > 0) {
|
|
420
|
+
const placeholders = flat.map(() => "?").join(", ");
|
|
421
|
+
joins.push(`JOIN note_tags nt_or ON nt_or.note_id = n.id AND nt_or.tag_name IN (${placeholders})`);
|
|
422
|
+
params.push(...flat);
|
|
423
|
+
}
|
|
225
424
|
} else {
|
|
226
|
-
|
|
425
|
+
// "all": one JOIN per input tag, each accepting the input or any descendant.
|
|
426
|
+
for (let i = 0; i < tagSets.length; i++) {
|
|
427
|
+
const set = tagSets[i] ?? [];
|
|
428
|
+
if (set.length === 0) continue;
|
|
227
429
|
const alias = `nt${i}`;
|
|
228
|
-
|
|
229
|
-
|
|
430
|
+
const placeholders = set.map(() => "?").join(", ");
|
|
431
|
+
joins.push(`JOIN note_tags ${alias} ON ${alias}.note_id = n.id AND ${alias}.tag_name IN (${placeholders})`);
|
|
432
|
+
params.push(...set);
|
|
230
433
|
}
|
|
231
434
|
}
|
|
232
435
|
}
|
|
@@ -259,6 +462,20 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
259
462
|
);
|
|
260
463
|
}
|
|
261
464
|
|
|
465
|
+
// ID set filter — used by `near` to push neighborhood scoping into SQL so
|
|
466
|
+
// that LIMIT applies to the neighborhood, not the whole notes table.
|
|
467
|
+
if (opts.ids !== undefined) {
|
|
468
|
+
if (opts.ids.length === 0) {
|
|
469
|
+
// Caller asked for "in this empty set" — no rows match. Short-circuit
|
|
470
|
+
// with an always-false condition; building `IN ()` would be a SQL error.
|
|
471
|
+
conditions.push("0 = 1");
|
|
472
|
+
} else {
|
|
473
|
+
const placeholders = opts.ids.map(() => "?").join(", ");
|
|
474
|
+
conditions.push(`n.id IN (${placeholders})`);
|
|
475
|
+
params.push(...opts.ids);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
262
479
|
// Exact path match (case-insensitive)
|
|
263
480
|
if (opts.path) {
|
|
264
481
|
conditions.push("n.path = ? COLLATE NOCASE");
|
|
@@ -291,14 +508,61 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
|
|
|
291
508
|
}
|
|
292
509
|
}
|
|
293
510
|
|
|
294
|
-
// Date range
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
511
|
+
// Date range. Two accepted shapes:
|
|
512
|
+
// - Legacy `dateFrom` / `dateTo` — always filters on `n.created_at`
|
|
513
|
+
// (vault ingestion time).
|
|
514
|
+
// - Generalized `dateFilter: { field, from, to }` — filters on the
|
|
515
|
+
// named field. `created_at` (default) and `updated_at` map to the
|
|
516
|
+
// real columns on `notes`; any other field must be declared
|
|
517
|
+
// `indexed: true` so the SQL targets a real B-tree index. The two
|
|
518
|
+
// shapes are mutually exclusive — the combination would silently
|
|
519
|
+
// AND, which would be surprising.
|
|
520
|
+
//
|
|
521
|
+
// `updated_at` enables incremental-rebuild flows (vault#285 1.5): an
|
|
522
|
+
// SSG or syncer asks "what changed since my last build" via
|
|
523
|
+
// `dateFilter: { field: "updated_at", from: lastBuildISO }`. There's
|
|
524
|
+
// no B-tree on `updated_at` today; a sequential scan is acceptable up
|
|
525
|
+
// to ~tens of thousands of notes. Add an index if the scan ever shows
|
|
526
|
+
// up in a real workload.
|
|
527
|
+
const hasLegacyDate = opts.dateFrom !== undefined || opts.dateTo !== undefined;
|
|
528
|
+
const hasDateFilter = opts.dateFilter !== undefined;
|
|
529
|
+
if (hasLegacyDate && hasDateFilter) {
|
|
530
|
+
throw new QueryError(
|
|
531
|
+
`cannot combine top-level date_from/date_to with date_filter — pass one or the other`,
|
|
532
|
+
"INVALID_QUERY",
|
|
533
|
+
);
|
|
298
534
|
}
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
|
|
535
|
+
if (hasDateFilter) {
|
|
536
|
+
const filter = opts.dateFilter!;
|
|
537
|
+
const field = filter.field ?? "created_at";
|
|
538
|
+
let column: string;
|
|
539
|
+
if (field === "created_at") {
|
|
540
|
+
column = "n.created_at";
|
|
541
|
+
} else if (field === "updated_at") {
|
|
542
|
+
column = "n.updated_at";
|
|
543
|
+
} else {
|
|
544
|
+
// Re-uses the same indexed-field gate as `metadata` operator queries
|
|
545
|
+
// and `orderBy` so the error message and contract are consistent.
|
|
546
|
+
requireIndexedField(db, field);
|
|
547
|
+
column = `"meta_${field}"`;
|
|
548
|
+
}
|
|
549
|
+
if (filter.from !== undefined) {
|
|
550
|
+
conditions.push(`${column} >= ?`);
|
|
551
|
+
params.push(filter.from);
|
|
552
|
+
}
|
|
553
|
+
if (filter.to !== undefined) {
|
|
554
|
+
conditions.push(`${column} < ?`);
|
|
555
|
+
params.push(filter.to);
|
|
556
|
+
}
|
|
557
|
+
} else if (hasLegacyDate) {
|
|
558
|
+
if (opts.dateFrom) {
|
|
559
|
+
conditions.push("n.created_at >= ?");
|
|
560
|
+
params.push(opts.dateFrom);
|
|
561
|
+
}
|
|
562
|
+
if (opts.dateTo) {
|
|
563
|
+
conditions.push("n.created_at < ?");
|
|
564
|
+
params.push(opts.dateTo);
|
|
565
|
+
}
|
|
302
566
|
}
|
|
303
567
|
|
|
304
568
|
const direction = opts.sort === "desc" ? "DESC" : "ASC";
|
|
@@ -434,40 +698,422 @@ export function deleteTag(db: Database, name: string): { deleted: boolean; notes
|
|
|
434
698
|
// The UNIQUE PRIMARY KEY on tags.name means rename-to-existing is ambiguous:
|
|
435
699
|
// do you drop the source, or retag-and-drop? Callers must pick — rename errors
|
|
436
700
|
// out; mergeTags explicitly retags.
|
|
701
|
+
//
|
|
702
|
+
// Vault#240 + #247: rename is a transactional cascade across every surface
|
|
703
|
+
// where the old name is referenced. The shape `tag` → `tag/sub` paths
|
|
704
|
+
// recursively (sub-tags follow their root). Counts are returned per-surface
|
|
705
|
+
// so REST/MCP responses can report what changed without a re-scan.
|
|
706
|
+
export interface RenameTagSuccess {
|
|
707
|
+
/** note_tags rows repointed (cumulative across self + every sub-tag). */
|
|
708
|
+
renamed: number;
|
|
709
|
+
/** Sub-tag rows renamed alongside the root (excludes the root itself). */
|
|
710
|
+
sub_tags_renamed: number;
|
|
711
|
+
/** OTHER tags whose `parent_names` JSON array referenced any old name. */
|
|
712
|
+
parent_refs_updated: number;
|
|
713
|
+
/** Tokens whose `scoped_tags` JSON array referenced any old name. */
|
|
714
|
+
tokens_updated: number;
|
|
715
|
+
/** indexed_fields rows whose `declarer_tags` JSON array referenced any old name. */
|
|
716
|
+
indexed_field_declarers_updated: number;
|
|
717
|
+
/** Notes whose `content` had `#oldname[/...]` references rewritten. */
|
|
718
|
+
notes_rewritten: number;
|
|
719
|
+
/** `_tags/<oldname>...` notes whose `path` was rewritten for hygiene. */
|
|
720
|
+
paths_renamed: number;
|
|
721
|
+
}
|
|
722
|
+
|
|
437
723
|
export type RenameTagResult =
|
|
438
|
-
|
|
|
724
|
+
| RenameTagSuccess
|
|
439
725
|
| { error: "not_found" }
|
|
440
|
-
| { error: "target_exists" };
|
|
726
|
+
| { error: "target_exists"; conflicting: string[] };
|
|
441
727
|
|
|
728
|
+
/**
|
|
729
|
+
* Cascading tag rename — closes vault#240 (full cascade) and vault#247
|
|
730
|
+
* (parent_names piece). When `task` becomes `todo`, the rename touches:
|
|
731
|
+
*
|
|
732
|
+
* 1. `tags` PK row (and sub-tag rows `task/...` → `todo/...`).
|
|
733
|
+
* 2. `note_tags.tag_name` FK references for every renamed name.
|
|
734
|
+
* 3. `tags.parent_names` JSON arrays in OTHER tag rows.
|
|
735
|
+
* 4. `tokens.scoped_tags` JSON arrays.
|
|
736
|
+
* 5. `indexed_fields.declarer_tags` JSON arrays.
|
|
737
|
+
* 6. Note body `content`: `#oldname` and `#oldname/...` references
|
|
738
|
+
* become `#newname` / `#newname/...`. `[[_tags/oldname]]`
|
|
739
|
+
* wikilinks rewrite to `[[_tags/newname]]`.
|
|
740
|
+
* 7. `_tags/<oldname>...` config-note paths (post-v14 these are inert
|
|
741
|
+
* historical breadcrumbs, but renaming for hygiene keeps the
|
|
742
|
+
* vault internally consistent).
|
|
743
|
+
*
|
|
744
|
+
* Atomicity: a single `BEGIN IMMEDIATE` transaction. Any failure rolls
|
|
745
|
+
* back the entire cascade — no half-applied state. Pre-flight collision
|
|
746
|
+
* check covers both the root rename and every sub-tag rename so a
|
|
747
|
+
* partway-through abort can't happen on a UNIQUE-constraint violation.
|
|
748
|
+
*
|
|
749
|
+
* Cache invalidation: parent_names and tag-set both change, so callers
|
|
750
|
+
* (the store wrapper) bust both `_tagHierarchy` and `_schemaConfig`
|
|
751
|
+
* after the cascade returns.
|
|
752
|
+
*/
|
|
442
753
|
export function renameTag(db: Database, oldName: string, newName: string): RenameTagResult {
|
|
443
754
|
if (oldName === newName) {
|
|
444
755
|
const exists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(oldName);
|
|
445
|
-
return exists
|
|
756
|
+
return exists
|
|
757
|
+
? emptyCascadeResult()
|
|
758
|
+
: { error: "not_found" };
|
|
446
759
|
}
|
|
447
760
|
|
|
448
761
|
const oldExists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(oldName);
|
|
449
762
|
if (!oldExists) return { error: "not_found" };
|
|
450
763
|
|
|
451
|
-
|
|
452
|
-
|
|
764
|
+
// Discover the full set of names being renamed: the root plus every
|
|
765
|
+
// sub-tag whose name starts with `<oldName>/`. Each maps to a parallel
|
|
766
|
+
// entry under `<newName>/`. Sorted by length DESC so we update the
|
|
767
|
+
// deepest path first if any later step needs deterministic ordering
|
|
768
|
+
// (the SQL we run is order-independent, but it costs nothing here).
|
|
769
|
+
//
|
|
770
|
+
// `escapeLikePattern` neutralizes `%` and `_` inside the operator-
|
|
771
|
+
// supplied tag name so a tag literally named `task_` doesn't pull
|
|
772
|
+
// `taskX/sub` into the rename transaction (that would be a write the
|
|
773
|
+
// caller never asked for — far worse than a downstream false-positive
|
|
774
|
+
// candidate). `ESCAPE '\\'` is required for the escape to take effect.
|
|
775
|
+
const subRows = db
|
|
776
|
+
.prepare("SELECT name FROM tags WHERE name LIKE ? ESCAPE '\\' ORDER BY length(name) DESC")
|
|
777
|
+
.all(`${escapeLikePattern(oldName)}/%`) as { name: string }[];
|
|
778
|
+
const renames: { from: string; to: string }[] = [
|
|
779
|
+
{ from: oldName, to: newName },
|
|
780
|
+
...subRows.map((r) => ({ from: r.name, to: `${newName}${r.name.slice(oldName.length)}` })),
|
|
781
|
+
];
|
|
782
|
+
|
|
783
|
+
// Pre-flight: if any new name already exists as a tag (and isn't itself
|
|
784
|
+
// about to be renamed away), abort with structured error. No rows
|
|
785
|
+
// mutated. The renamed-away set covers both `oldName` itself (which
|
|
786
|
+
// becomes `newName` — fine) and any sub-tag whose new path happens to
|
|
787
|
+
// collide with an existing sub-tag (uncommon but possible if the
|
|
788
|
+
// operator picks an awkward target).
|
|
789
|
+
const renamedAway = new Set(renames.map((r) => r.from));
|
|
790
|
+
const conflicting: string[] = [];
|
|
791
|
+
const existsStmt = db.prepare("SELECT 1 FROM tags WHERE name = ?");
|
|
792
|
+
for (const { to } of renames) {
|
|
793
|
+
if (renamedAway.has(to)) continue;
|
|
794
|
+
if (existsStmt.get(to)) conflicting.push(to);
|
|
795
|
+
}
|
|
796
|
+
if (conflicting.length > 0) {
|
|
797
|
+
return { error: "target_exists", conflicting };
|
|
798
|
+
}
|
|
453
799
|
|
|
454
|
-
db.exec("BEGIN");
|
|
800
|
+
db.exec("BEGIN IMMEDIATE");
|
|
455
801
|
try {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
802
|
+
let renamedNoteTags = 0;
|
|
803
|
+
let pathsRenamed = 0;
|
|
804
|
+
|
|
805
|
+
// ---- Tag-row rename pass.
|
|
806
|
+
//
|
|
807
|
+
// Order: insert new row (carrying identity), repoint note_tags, drop
|
|
808
|
+
// old row. Per-rename, mirroring the pre-cascade behavior. The
|
|
809
|
+
// note_tags FK on `tag_name` has no ON DELETE, so the delete must
|
|
810
|
+
// come AFTER the repoint.
|
|
811
|
+
const now = new Date().toISOString();
|
|
812
|
+
const readStmt = db.prepare(
|
|
813
|
+
"SELECT description, fields, relationships, parent_names, created_at FROM tags WHERE name = ?",
|
|
814
|
+
);
|
|
815
|
+
const insertStmt = db.prepare(
|
|
816
|
+
`INSERT INTO tags (name, description, fields, relationships, parent_names, created_at, updated_at)
|
|
817
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
818
|
+
);
|
|
819
|
+
const repointStmt = db.prepare(
|
|
820
|
+
"UPDATE note_tags SET tag_name = ? WHERE tag_name = ? RETURNING note_id",
|
|
821
|
+
);
|
|
822
|
+
const dropStmt = db.prepare("DELETE FROM tags WHERE name = ?");
|
|
823
|
+
for (const { from, to } of renames) {
|
|
824
|
+
const old = readStmt.get(from) as
|
|
825
|
+
| { description: string | null; fields: string | null; relationships: string | null; parent_names: string | null; created_at: string | null }
|
|
826
|
+
| undefined;
|
|
827
|
+
insertStmt.run(
|
|
828
|
+
to,
|
|
829
|
+
old?.description ?? null,
|
|
830
|
+
old?.fields ?? null,
|
|
831
|
+
old?.relationships ?? null,
|
|
832
|
+
old?.parent_names ?? null,
|
|
833
|
+
old?.created_at ?? now,
|
|
834
|
+
now,
|
|
835
|
+
);
|
|
836
|
+
const repointed = repointStmt.all(to, from) as { note_id: string }[];
|
|
837
|
+
renamedNoteTags += repointed.length;
|
|
838
|
+
dropStmt.run(from);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ---- JSON-array cascade across parent_names / scoped_tags /
|
|
842
|
+
// declarer_tags. Same shape three times: cheap LIKE pre-filter, then
|
|
843
|
+
// per-row JSON.parse → array.map → JSON.stringify. Replacing on the
|
|
844
|
+
// parsed array (not the encoded string) is robust against escaping
|
|
845
|
+
// edge cases. The pre-filter narrows the per-row work to just the
|
|
846
|
+
// candidates that mention any of the renamed names.
|
|
847
|
+
//
|
|
848
|
+
// Each call site supplies its own column name for the filter — SQL
|
|
849
|
+
// doesn't expand `column LIKE (a OR b)` into a disjunction. We also
|
|
850
|
+
// escape LIKE wildcards (`%`, `_`) inside tag names and append
|
|
851
|
+
// `ESCAPE '\\'` to every clause so a tag literally named `task_`
|
|
852
|
+
// doesn't match `taskX` as a false-positive candidate.
|
|
853
|
+
const renameMap = new Map(renames.map((r) => [r.from, r.to]));
|
|
854
|
+
const remap = (s: string): string => renameMap.get(s) ?? s;
|
|
855
|
+
const likeClauseFor = (column: string): string =>
|
|
856
|
+
renames
|
|
857
|
+
.map((r) => `${column} LIKE '%"${escapeJsonLike(r.from)}"%' ESCAPE '\\'`)
|
|
858
|
+
.join(" OR ");
|
|
859
|
+
|
|
860
|
+
let parentRefsUpdated = 0;
|
|
861
|
+
{
|
|
862
|
+
const rows = db
|
|
863
|
+
.prepare(`SELECT name, parent_names FROM tags WHERE parent_names IS NOT NULL AND (${likeClauseFor("parent_names")})`)
|
|
864
|
+
.all() as { name: string; parent_names: string }[];
|
|
865
|
+
// We just renamed every old name; the rows we're updating are now
|
|
866
|
+
// keyed by the new name where applicable. The candidate clause
|
|
867
|
+
// matched `parent_names` containing any old name — those references
|
|
868
|
+
// are stale and need rewriting.
|
|
869
|
+
const updateStmt = db.prepare(
|
|
870
|
+
"UPDATE tags SET parent_names = ?, updated_at = ? WHERE name = ?",
|
|
871
|
+
);
|
|
872
|
+
for (const row of rows) {
|
|
873
|
+
const next = remapJsonArray(row.parent_names, remap);
|
|
874
|
+
if (next === null) continue;
|
|
875
|
+
updateStmt.run(next, now, row.name);
|
|
876
|
+
parentRefsUpdated++;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
let tokensUpdated = 0;
|
|
881
|
+
if (hasTable(db, "tokens")) {
|
|
882
|
+
const rows = db
|
|
883
|
+
.prepare(`SELECT token_hash, scoped_tags FROM tokens WHERE scoped_tags IS NOT NULL AND (${likeClauseFor("scoped_tags")})`)
|
|
884
|
+
.all() as { token_hash: string; scoped_tags: string }[];
|
|
885
|
+
const updateStmt = db.prepare("UPDATE tokens SET scoped_tags = ? WHERE token_hash = ?");
|
|
886
|
+
for (const row of rows) {
|
|
887
|
+
const next = remapJsonArray(row.scoped_tags, remap);
|
|
888
|
+
if (next === null) continue;
|
|
889
|
+
updateStmt.run(next, row.token_hash);
|
|
890
|
+
tokensUpdated++;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
let declarersUpdated = 0;
|
|
895
|
+
if (hasTable(db, "indexed_fields")) {
|
|
896
|
+
const rows = db
|
|
897
|
+
.prepare(`SELECT field, declarer_tags FROM indexed_fields WHERE declarer_tags IS NOT NULL AND (${likeClauseFor("declarer_tags")})`)
|
|
898
|
+
.all() as { field: string; declarer_tags: string }[];
|
|
899
|
+
const updateStmt = db.prepare("UPDATE indexed_fields SET declarer_tags = ? WHERE field = ?");
|
|
900
|
+
for (const row of rows) {
|
|
901
|
+
const next = remapJsonArray(row.declarer_tags, remap);
|
|
902
|
+
if (next === null) continue;
|
|
903
|
+
updateStmt.run(next, row.field);
|
|
904
|
+
declarersUpdated++;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// ---- Note body content: rewrite `#<oldname>` and `#<oldname>/...`
|
|
909
|
+
// references. Sub-tag rewrites cascade naturally — `task/work`
|
|
910
|
+
// appears in `renames` so a body that says `#task/work` rewrites
|
|
911
|
+
// directly to `#todo/work` without splitting into prefix-replace.
|
|
912
|
+
//
|
|
913
|
+
// ALSO `[[_tags/<oldname>...]]` wikilinks (post-v14 these are
|
|
914
|
+
// historical, but if any vault still carries them, keep them
|
|
915
|
+
// pointing at the right path).
|
|
916
|
+
let notesRewritten = 0;
|
|
917
|
+
{
|
|
918
|
+
// Each pair of LIKE clauses uses ESCAPE '\\' so the bound pattern
|
|
919
|
+
// can carry a literal `%` or `_` from a tag name without the LIKE
|
|
920
|
+
// engine treating them as wildcards. The middle of the bound
|
|
921
|
+
// string is `escapeLikePattern(from)`; the leading/trailing `%` we
|
|
922
|
+
// wrap in are still our actual wildcards.
|
|
923
|
+
const orClauses = renames
|
|
924
|
+
.map(() => "(content LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')")
|
|
925
|
+
.join(" OR ");
|
|
926
|
+
const params: string[] = [];
|
|
927
|
+
for (const { from } of renames) {
|
|
928
|
+
const safe = escapeLikePattern(from);
|
|
929
|
+
params.push(`%#${safe}%`, `%[[_tags/${safe}%`);
|
|
930
|
+
}
|
|
931
|
+
const candidates = db
|
|
932
|
+
.prepare(`SELECT id, content FROM notes WHERE content IS NOT NULL AND content != '' AND (${orClauses})`)
|
|
933
|
+
.all(...params) as { id: string; content: string }[];
|
|
934
|
+
const updateStmt = db.prepare("UPDATE notes SET content = ? WHERE id = ?");
|
|
935
|
+
for (const row of candidates) {
|
|
936
|
+
const next = rewriteNoteBody(row.content, renames);
|
|
937
|
+
if (next === row.content) continue;
|
|
938
|
+
updateStmt.run(next, row.id);
|
|
939
|
+
notesRewritten++;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ---- `_tags/<oldname>...` config-note paths. Post-v14 these are
|
|
944
|
+
// inert (the resolver reads `tags.parent_names`, not the notes).
|
|
945
|
+
// Renaming the path keeps the vault internally consistent for any
|
|
946
|
+
// operator who still inspects them by hand.
|
|
947
|
+
{
|
|
948
|
+
const orClauses = renames.map(() => "path LIKE ? ESCAPE '\\'").join(" OR ");
|
|
949
|
+
const params = renames.map((r) => `_tags/${escapeLikePattern(r.from)}%`);
|
|
950
|
+
const candidates = db
|
|
951
|
+
.prepare(`SELECT id, path FROM notes WHERE path IS NOT NULL AND (${orClauses})`)
|
|
952
|
+
.all(...params) as { id: string; path: string }[];
|
|
953
|
+
const updateStmt = db.prepare("UPDATE notes SET path = ? WHERE id = ?");
|
|
954
|
+
for (const row of candidates) {
|
|
955
|
+
const next = rewriteTagConfigPath(row.path, renames);
|
|
956
|
+
if (next === row.path) continue;
|
|
957
|
+
updateStmt.run(next, row.id);
|
|
958
|
+
pathsRenamed++;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
463
962
|
db.exec("COMMIT");
|
|
464
|
-
|
|
963
|
+
|
|
964
|
+
const result: RenameTagSuccess = {
|
|
965
|
+
renamed: renamedNoteTags,
|
|
966
|
+
sub_tags_renamed: renames.length - 1,
|
|
967
|
+
parent_refs_updated: parentRefsUpdated,
|
|
968
|
+
tokens_updated: tokensUpdated,
|
|
969
|
+
indexed_field_declarers_updated: declarersUpdated,
|
|
970
|
+
notes_rewritten: notesRewritten,
|
|
971
|
+
paths_renamed: pathsRenamed,
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
// Audit log: single line so operators searching `[vault] tag rename`
|
|
975
|
+
// can correlate cascades after the fact. Includes the stats and the
|
|
976
|
+
// mapping for non-trivial sub-tag cases.
|
|
977
|
+
console.error(
|
|
978
|
+
`[vault] tag rename cascade: ${oldName} → ${newName}` +
|
|
979
|
+
(renames.length > 1 ? ` (+${renames.length - 1} sub-tags)` : "") +
|
|
980
|
+
` — note_tags:${result.renamed} parent_refs:${result.parent_refs_updated} tokens:${result.tokens_updated} indexed:${result.indexed_field_declarers_updated} notes:${result.notes_rewritten} paths:${result.paths_renamed}`,
|
|
981
|
+
);
|
|
982
|
+
|
|
983
|
+
return result;
|
|
465
984
|
} catch (err) {
|
|
466
985
|
db.exec("ROLLBACK");
|
|
467
986
|
throw err;
|
|
468
987
|
}
|
|
469
988
|
}
|
|
470
989
|
|
|
990
|
+
function emptyCascadeResult(): RenameTagSuccess {
|
|
991
|
+
return {
|
|
992
|
+
renamed: 0,
|
|
993
|
+
sub_tags_renamed: 0,
|
|
994
|
+
parent_refs_updated: 0,
|
|
995
|
+
tokens_updated: 0,
|
|
996
|
+
indexed_field_declarers_updated: 0,
|
|
997
|
+
notes_rewritten: 0,
|
|
998
|
+
paths_renamed: 0,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Re-encode a JSON-array column after applying `remap` to every entry,
|
|
1004
|
+
* dropping duplicates after remap. Returns the new JSON string, or null
|
|
1005
|
+
* if parsing failed / the array became empty (the caller decides whether
|
|
1006
|
+
* empty means "leave column" vs "set to NULL"; current callers leave the
|
|
1007
|
+
* column as the new array since the existing schema accepts empty JSON).
|
|
1008
|
+
*/
|
|
1009
|
+
function remapJsonArray(raw: string, remap: (s: string) => string): string | null {
|
|
1010
|
+
let parsed: unknown;
|
|
1011
|
+
try { parsed = JSON.parse(raw); } catch { return null; }
|
|
1012
|
+
if (!Array.isArray(parsed)) return null;
|
|
1013
|
+
const seen = new Set<string>();
|
|
1014
|
+
const next: string[] = [];
|
|
1015
|
+
for (const v of parsed) {
|
|
1016
|
+
if (typeof v !== "string") continue;
|
|
1017
|
+
const mapped = remap(v);
|
|
1018
|
+
if (seen.has(mapped)) continue;
|
|
1019
|
+
seen.add(mapped);
|
|
1020
|
+
next.push(mapped);
|
|
1021
|
+
}
|
|
1022
|
+
return JSON.stringify(next);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Apply every rename to a note's body content. Walks the rename list
|
|
1027
|
+
* longest-first so `#task/work` rewrites cleanly before `#task` would
|
|
1028
|
+
* grab the same prefix. Word-boundary semantics: a tag reference is
|
|
1029
|
+
* `#name` followed by either end-of-string, whitespace, punctuation, or
|
|
1030
|
+
* `/`. We ignore matches inside fenced code blocks — those are typically
|
|
1031
|
+
* escaped examples and rewriting them silently changes documented
|
|
1032
|
+
* behavior. (We DO touch inline code spans; the trade-off is too noisy
|
|
1033
|
+
* to track precisely and the operator can audit via the rewrite count.)
|
|
1034
|
+
*/
|
|
1035
|
+
function rewriteNoteBody(content: string, renames: { from: string; to: string }[]): string {
|
|
1036
|
+
// Sort longest-first so `task/work` is matched before `task`.
|
|
1037
|
+
const sorted = [...renames].sort((a, b) => b.from.length - a.from.length);
|
|
1038
|
+
let out = content;
|
|
1039
|
+
for (const { from, to } of sorted) {
|
|
1040
|
+
// `#tag` / `#tag/...` references. `(?<=^|[\s\p{P}])` would be ideal
|
|
1041
|
+
// but we use a simpler form: match at start of string, or after
|
|
1042
|
+
// whitespace, or after a character that isn't part of a tag run.
|
|
1043
|
+
// Tag references end at a whitespace, end-of-string, or any non-tag
|
|
1044
|
+
// character (we approximate with `[^a-zA-Z0-9/_-]`).
|
|
1045
|
+
const tagRe = new RegExp(
|
|
1046
|
+
`(^|[^a-zA-Z0-9/_#-])#${escapeRegex(from)}(?=$|[^a-zA-Z0-9/_-])`,
|
|
1047
|
+
"g",
|
|
1048
|
+
);
|
|
1049
|
+
out = out.replace(tagRe, `$1#${to}`);
|
|
1050
|
+
// `[[_tags/oldname]]` and `[[_tags/oldname#...]]` wikilink targets.
|
|
1051
|
+
const wikiRe = new RegExp(
|
|
1052
|
+
`\\[\\[_tags/${escapeRegex(from)}(?=[\\]|#])`,
|
|
1053
|
+
"g",
|
|
1054
|
+
);
|
|
1055
|
+
out = out.replace(wikiRe, `[[_tags/${to}`);
|
|
1056
|
+
}
|
|
1057
|
+
return out;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Apply every rename to a `_tags/<oldname>...` path.
|
|
1062
|
+
*/
|
|
1063
|
+
function rewriteTagConfigPath(path: string, renames: { from: string; to: string }[]): string {
|
|
1064
|
+
// Longest-first so `_tags/task/work` matches before `_tags/task`.
|
|
1065
|
+
const sorted = [...renames].sort((a, b) => b.from.length - a.from.length);
|
|
1066
|
+
for (const { from, to } of sorted) {
|
|
1067
|
+
if (path === `_tags/${from}`) return `_tags/${to}`;
|
|
1068
|
+
if (path.startsWith(`_tags/${from}/`)) {
|
|
1069
|
+
return `_tags/${to}${path.slice(`_tags/${from}`.length)}`;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return path;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
function hasTable(db: Database, name: string): boolean {
|
|
1076
|
+
const row = db
|
|
1077
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
|
|
1078
|
+
.get(name);
|
|
1079
|
+
return !!row;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Escape a tag name for inline interpolation into a SQL LIKE pattern.
|
|
1084
|
+
* Doubles `'` for SQL-string safety AND backslash-prefixes the LIKE
|
|
1085
|
+
* wildcards (`%`, `_`) so a tag literally named `task_` doesn't match
|
|
1086
|
+
* `taskX` as a false-positive candidate. The escape character `\` is
|
|
1087
|
+
* declared at each call site via `ESCAPE '\\'`.
|
|
1088
|
+
*
|
|
1089
|
+
* Order matters: escape `\` first so a tag containing a backslash gets
|
|
1090
|
+
* its backslash doubled before we add our own escape prefixes for the
|
|
1091
|
+
* wildcards.
|
|
1092
|
+
*/
|
|
1093
|
+
function escapeJsonLike(s: string): string {
|
|
1094
|
+
return s
|
|
1095
|
+
.replace(/\\/g, "\\\\")
|
|
1096
|
+
.replace(/'/g, "''")
|
|
1097
|
+
.replace(/%/g, "\\%")
|
|
1098
|
+
.replace(/_/g, "\\_");
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* Escape a tag name destined for a parameterized LIKE binding. No SQL
|
|
1103
|
+
* quote escape (param-binding handles that); just the wildcard
|
|
1104
|
+
* neutralization. Pair with `LIKE ? ESCAPE '\\'`.
|
|
1105
|
+
*/
|
|
1106
|
+
function escapeLikePattern(s: string): string {
|
|
1107
|
+
return s
|
|
1108
|
+
.replace(/\\/g, "\\\\")
|
|
1109
|
+
.replace(/%/g, "\\%")
|
|
1110
|
+
.replace(/_/g, "\\_");
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function escapeRegex(s: string): string {
|
|
1114
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1115
|
+
}
|
|
1116
|
+
|
|
471
1117
|
export function mergeTags(
|
|
472
1118
|
db: Database,
|
|
473
1119
|
sources: string[],
|
|
@@ -500,8 +1146,9 @@ export function mergeTags(
|
|
|
500
1146
|
const before = (countStmt.get(source) as { c: number }).c;
|
|
501
1147
|
retagStmt.run(target, source);
|
|
502
1148
|
deleteNoteTagsStmt.run(source);
|
|
503
|
-
//
|
|
504
|
-
//
|
|
1149
|
+
// Dropping the tag row drops its identity (description, fields,
|
|
1150
|
+
// relationships, parent_names) along with it — which is what we want
|
|
1151
|
+
// for a merge: the source's identity is consumed by the target.
|
|
505
1152
|
deleteTagStmt.run(source);
|
|
506
1153
|
merged[source] = before;
|
|
507
1154
|
}
|
|
@@ -615,6 +1262,9 @@ export function getVaultStats(
|
|
|
615
1262
|
const tagCountRow = db.prepare("SELECT COUNT(DISTINCT tag_name) as c FROM note_tags").get() as { c: number };
|
|
616
1263
|
const tagCount = tagCountRow.c;
|
|
617
1264
|
|
|
1265
|
+
const attachmentCountRow = db.prepare("SELECT COUNT(*) as c FROM attachments").get() as { c: number };
|
|
1266
|
+
const attachmentCount = attachmentCountRow.c;
|
|
1267
|
+
|
|
618
1268
|
const linkCountRow = db.prepare("SELECT COUNT(*) as c FROM links").get() as { c: number };
|
|
619
1269
|
const linkCount = linkCountRow.c;
|
|
620
1270
|
|
|
@@ -629,6 +1279,7 @@ export function getVaultStats(
|
|
|
629
1279
|
notesByMonth: monthRows,
|
|
630
1280
|
topTags: topTagRows,
|
|
631
1281
|
tagCount,
|
|
1282
|
+
attachmentCount,
|
|
632
1283
|
linkCount,
|
|
633
1284
|
};
|
|
634
1285
|
}
|