@openparachute/vault 0.1.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 (103) hide show
  1. package/.claude/settings.local.json +31 -0
  2. package/.dockerignore +8 -0
  3. package/.env.example +9 -0
  4. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
  5. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
  6. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
  7. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
  8. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
  10. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
  11. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
  12. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
  13. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
  14. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
  15. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
  16. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
  17. package/CLAUDE.md +115 -0
  18. package/Caddyfile +3 -0
  19. package/Dockerfile +22 -0
  20. package/LICENSE +661 -0
  21. package/README.md +356 -0
  22. package/bun.lock +219 -0
  23. package/bunfig.toml +2 -0
  24. package/core/package.json +7 -0
  25. package/core/src/core.test.ts +940 -0
  26. package/core/src/hooks.test.ts +361 -0
  27. package/core/src/hooks.ts +234 -0
  28. package/core/src/links.ts +352 -0
  29. package/core/src/mcp.ts +672 -0
  30. package/core/src/notes.ts +520 -0
  31. package/core/src/obsidian.test.ts +380 -0
  32. package/core/src/obsidian.ts +322 -0
  33. package/core/src/paths.test.ts +197 -0
  34. package/core/src/paths.ts +53 -0
  35. package/core/src/schema.ts +331 -0
  36. package/core/src/store.ts +303 -0
  37. package/core/src/tag-schemas.ts +104 -0
  38. package/core/src/test-preload.ts +8 -0
  39. package/core/src/types.ts +140 -0
  40. package/core/src/wikilinks.test.ts +277 -0
  41. package/core/src/wikilinks.ts +402 -0
  42. package/deploy/parachute-vault.service +20 -0
  43. package/docker-compose.yml +50 -0
  44. package/docs/HTTP_API.md +328 -0
  45. package/fly.toml +24 -0
  46. package/package.json +32 -0
  47. package/railway.json +14 -0
  48. package/religions-abrahamic-filter.png +0 -0
  49. package/religions-buddhism-v2.png +0 -0
  50. package/religions-buddhism.png +0 -0
  51. package/religions-final.png +0 -0
  52. package/religions-v1.png +0 -0
  53. package/religions-v2.png +0 -0
  54. package/religions-zen.png +0 -0
  55. package/scripts/migrate-audio-to-opus.test.ts +237 -0
  56. package/scripts/migrate-audio-to-opus.ts +499 -0
  57. package/src/auth.ts +170 -0
  58. package/src/cli.ts +1131 -0
  59. package/src/config-triggers.test.ts +83 -0
  60. package/src/config.test.ts +125 -0
  61. package/src/config.ts +716 -0
  62. package/src/db.ts +14 -0
  63. package/src/launchd.ts +109 -0
  64. package/src/mcp-http.ts +113 -0
  65. package/src/mcp-tools.ts +155 -0
  66. package/src/oauth.test.ts +1242 -0
  67. package/src/oauth.ts +729 -0
  68. package/src/owner-auth.ts +159 -0
  69. package/src/prompt.ts +141 -0
  70. package/src/published.test.ts +214 -0
  71. package/src/qrcode-terminal.d.ts +9 -0
  72. package/src/routes.ts +822 -0
  73. package/src/server.ts +450 -0
  74. package/src/systemd.ts +84 -0
  75. package/src/token-store.test.ts +174 -0
  76. package/src/token-store.ts +241 -0
  77. package/src/triggers.test.ts +397 -0
  78. package/src/triggers.ts +412 -0
  79. package/src/two-factor.test.ts +246 -0
  80. package/src/two-factor.ts +222 -0
  81. package/src/vault-store.ts +47 -0
  82. package/src/vault.test.ts +1309 -0
  83. package/tsconfig.json +29 -0
  84. package/web/README.md +73 -0
  85. package/web/bun.lock +827 -0
  86. package/web/eslint.config.js +23 -0
  87. package/web/index.html +15 -0
  88. package/web/package.json +36 -0
  89. package/web/public/favicon.svg +1 -0
  90. package/web/public/icons.svg +24 -0
  91. package/web/src/App.tsx +149 -0
  92. package/web/src/Graph.tsx +200 -0
  93. package/web/src/NoteView.tsx +155 -0
  94. package/web/src/Sidebar.tsx +186 -0
  95. package/web/src/api.ts +21 -0
  96. package/web/src/index.css +50 -0
  97. package/web/src/main.tsx +10 -0
  98. package/web/src/types.ts +37 -0
  99. package/web/src/utils.ts +107 -0
  100. package/web/tsconfig.app.json +25 -0
  101. package/web/tsconfig.json +7 -0
  102. package/web/tsconfig.node.json +24 -0
  103. package/web/vite.config.ts +15 -0
@@ -0,0 +1,520 @@
1
+ import { Database } from "bun:sqlite";
2
+ import type { Note, NoteIndex, QueryOpts, VaultStats } from "./types.js";
3
+ import { normalizePath } from "./paths.js";
4
+
5
+ let idCounter = 0;
6
+
7
+ /** Generate a timestamp-based ID: YYYY-MM-DD-HH-MM-SS-ffffff */
8
+ export function generateId(): string {
9
+ const now = new Date();
10
+ const pad = (n: number, len = 2) => String(n).padStart(len, "0");
11
+ const micro = now.getMilliseconds() * 1000 + (idCounter++ % 1000);
12
+ return [
13
+ now.getFullYear(),
14
+ pad(now.getMonth() + 1),
15
+ pad(now.getDate()),
16
+ pad(now.getHours()),
17
+ pad(now.getMinutes()),
18
+ pad(now.getSeconds()),
19
+ pad(micro, 6),
20
+ ].join("-");
21
+ }
22
+
23
+ export function createNote(
24
+ db: Database,
25
+ content: string,
26
+ opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string },
27
+ ): Note {
28
+ const id = opts?.id ?? generateId();
29
+ const createdAt = opts?.created_at ?? new Date().toISOString();
30
+ const metadata = opts?.metadata ? JSON.stringify(opts.metadata) : "{}";
31
+ const path = normalizePath(opts?.path);
32
+
33
+ db.prepare(
34
+ `INSERT INTO notes (id, content, path, metadata, created_at) VALUES (?, ?, ?, ?, ?)`,
35
+ ).run(id, content, path, metadata, createdAt);
36
+
37
+ if (opts?.tags && opts.tags.length > 0) {
38
+ tagNote(db, id, opts.tags);
39
+ }
40
+
41
+ return getNote(db, id)!;
42
+ }
43
+
44
+ export function getNote(db: Database, id: string): Note | null {
45
+ const row = db.prepare("SELECT * FROM notes WHERE id = ?").get(id) as NoteRow | undefined;
46
+ if (!row) return null;
47
+
48
+ const note = rowToNote(row);
49
+ note.tags = getNoteTags(db, note.id);
50
+ return note;
51
+ }
52
+
53
+ export function getNoteByPath(db: Database, path: string): Note | null {
54
+ const row = db.prepare("SELECT * FROM notes WHERE path = ?").get(path) as NoteRow | undefined;
55
+ if (!row) return null;
56
+
57
+ const note = rowToNote(row);
58
+ note.tags = getNoteTags(db, note.id);
59
+ return note;
60
+ }
61
+
62
+ export function getNotes(db: Database, ids: string[]): Note[] {
63
+ if (ids.length === 0) return [];
64
+ const placeholders = ids.map(() => "?").join(", ");
65
+ const rows = db.prepare(
66
+ `SELECT * FROM notes WHERE id IN (${placeholders}) ORDER BY created_at`,
67
+ ).all(...ids) as NoteRow[];
68
+ return rows.map((row) => {
69
+ const note = rowToNote(row);
70
+ note.tags = getNoteTags(db, note.id);
71
+ return note;
72
+ });
73
+ }
74
+
75
+ export function updateNote(
76
+ db: Database,
77
+ id: string,
78
+ updates: { content?: string; path?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean },
79
+ ): Note {
80
+ const sets: string[] = [];
81
+ const values: unknown[] = [];
82
+
83
+ // Hooks and other machine-level writers pass `skipUpdatedAt: true` so
84
+ // their metadata markers don't look like user activity. See issue #44.
85
+ if (!updates.skipUpdatedAt) {
86
+ sets.push("updated_at = ?");
87
+ values.push(new Date().toISOString());
88
+ }
89
+
90
+ if (updates.content !== undefined) {
91
+ sets.push("content = ?");
92
+ values.push(updates.content);
93
+ }
94
+ if (updates.path !== undefined) {
95
+ sets.push("path = ?");
96
+ values.push(normalizePath(updates.path));
97
+ }
98
+ if (updates.metadata !== undefined) {
99
+ sets.push("metadata = ?");
100
+ values.push(JSON.stringify(updates.metadata));
101
+ }
102
+ if (updates.created_at !== undefined) {
103
+ sets.push("created_at = ?");
104
+ values.push(updates.created_at);
105
+ }
106
+
107
+ // No-op: skipUpdatedAt with no other fields. Avoid generating invalid SQL.
108
+ if (sets.length === 0) {
109
+ return getNote(db, id)!;
110
+ }
111
+
112
+ values.push(id);
113
+ db.prepare(`UPDATE notes SET ${sets.join(", ")} WHERE id = ?`).run(...values);
114
+
115
+ return getNote(db, id)!;
116
+ }
117
+
118
+ export function deleteNote(db: Database, id: string): void {
119
+ db.prepare("DELETE FROM notes WHERE id = ?").run(id);
120
+ }
121
+
122
+ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
123
+ const conditions: string[] = [];
124
+ const params: unknown[] = [];
125
+ const joins: string[] = [];
126
+
127
+ // Include tags — "all" (default): must have ALL tags; "any": must have ANY tag
128
+ if (opts.tags && opts.tags.length > 0) {
129
+ const match = opts.tagMatch ?? "all";
130
+ if (match === "any") {
131
+ const placeholders = opts.tags.map(() => "?").join(", ");
132
+ joins.push(`JOIN note_tags nt_or ON nt_or.note_id = n.id AND nt_or.tag_name IN (${placeholders})`);
133
+ params.push(...opts.tags);
134
+ } else {
135
+ for (let i = 0; i < opts.tags.length; i++) {
136
+ const alias = `nt${i}`;
137
+ joins.push(`JOIN note_tags ${alias} ON ${alias}.note_id = n.id AND ${alias}.tag_name = ?`);
138
+ params.push(opts.tags[i]);
139
+ }
140
+ }
141
+ }
142
+
143
+ // Exclude tags
144
+ if (opts.excludeTags && opts.excludeTags.length > 0) {
145
+ for (const tag of opts.excludeTags) {
146
+ conditions.push(`NOT EXISTS (SELECT 1 FROM note_tags ex WHERE ex.note_id = n.id AND ex.tag_name = ?)`);
147
+ params.push(tag);
148
+ }
149
+ }
150
+
151
+ // Exact path match (case-insensitive)
152
+ if (opts.path) {
153
+ conditions.push("n.path = ? COLLATE NOCASE");
154
+ params.push(opts.path);
155
+ }
156
+
157
+ // Path prefix
158
+ if (opts.pathPrefix) {
159
+ conditions.push("n.path LIKE ?");
160
+ params.push(opts.pathPrefix + "%");
161
+ }
162
+
163
+ // Metadata filters
164
+ if (opts.metadata) {
165
+ for (const [key, value] of Object.entries(opts.metadata)) {
166
+ conditions.push(`json_extract(n.metadata, '$.' || ?) = ?`);
167
+ params.push(key, typeof value === "string" ? value : JSON.stringify(value));
168
+ }
169
+ }
170
+
171
+ // Date range
172
+ if (opts.dateFrom) {
173
+ conditions.push("n.created_at >= ?");
174
+ params.push(opts.dateFrom);
175
+ }
176
+ if (opts.dateTo) {
177
+ conditions.push("n.created_at < ?");
178
+ params.push(opts.dateTo);
179
+ }
180
+
181
+ const orderBy = `n.created_at ${opts.sort === "desc" ? "DESC" : "ASC"}`;
182
+ const limit = typeof opts.limit === "number" ? opts.limit : 100;
183
+ const offset = typeof opts.offset === "number" ? opts.offset : 0;
184
+
185
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
186
+
187
+ const sql = `
188
+ SELECT DISTINCT n.* FROM notes n
189
+ ${joins.join("\n")}
190
+ ${whereClause}
191
+ ORDER BY ${orderBy}
192
+ LIMIT ? OFFSET ?
193
+ `;
194
+ params.push(limit, offset);
195
+
196
+ const rows = db.prepare(sql).all(...params) as NoteRow[];
197
+ return rows.map((row) => {
198
+ const note = rowToNote(row);
199
+ note.tags = getNoteTags(db, note.id);
200
+ return note;
201
+ });
202
+ }
203
+
204
+ export function searchNotes(
205
+ db: Database,
206
+ query: string,
207
+ opts?: { tags?: string[]; limit?: number },
208
+ ): Note[] {
209
+ const limit = typeof opts?.limit === "number" ? opts.limit : 50;
210
+
211
+ if (opts?.tags && opts.tags.length > 0) {
212
+ try {
213
+ const tagPlaceholders = opts.tags.map(() => "?").join(", ");
214
+ const rows = db.prepare(`
215
+ SELECT DISTINCT n.* FROM notes n
216
+ JOIN notes_fts fts ON fts.rowid = n.rowid
217
+ JOIN note_tags nt ON nt.note_id = n.id AND nt.tag_name IN (${tagPlaceholders})
218
+ WHERE notes_fts MATCH ?
219
+ ORDER BY rank
220
+ LIMIT ?
221
+ `).all(...opts.tags, query, limit) as NoteRow[];
222
+ return rows.map((row) => {
223
+ const note = rowToNote(row);
224
+ note.tags = getNoteTags(db, note.id);
225
+ return note;
226
+ });
227
+ } catch {
228
+ return [];
229
+ }
230
+ }
231
+
232
+ try {
233
+ const rows = db.prepare(`
234
+ SELECT n.* FROM notes n
235
+ JOIN notes_fts fts ON fts.rowid = n.rowid
236
+ WHERE notes_fts MATCH ?
237
+ ORDER BY rank
238
+ LIMIT ?
239
+ `).all(query, limit) as NoteRow[];
240
+ return rows.map((row) => {
241
+ const note = rowToNote(row);
242
+ note.tags = getNoteTags(db, note.id);
243
+ return note;
244
+ });
245
+ } catch {
246
+ return [];
247
+ }
248
+ }
249
+
250
+ // ---- Tag Operations ----
251
+
252
+ export function tagNote(db: Database, noteId: string, tags: string[]): void {
253
+ const insertTag = db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)");
254
+ const insertNoteTag = db.prepare("INSERT OR IGNORE INTO note_tags (note_id, tag_name) VALUES (?, ?)");
255
+
256
+ for (const tag of tags) {
257
+ insertTag.run(tag);
258
+ insertNoteTag.run(noteId, tag);
259
+ }
260
+ }
261
+
262
+ export function untagNote(db: Database, noteId: string, tags: string[]): void {
263
+ const stmt = db.prepare("DELETE FROM note_tags WHERE note_id = ? AND tag_name = ?");
264
+ for (const tag of tags) {
265
+ stmt.run(noteId, tag);
266
+ }
267
+ }
268
+
269
+ export function getNoteTags(db: Database, noteId: string): string[] {
270
+ const rows = db.prepare(
271
+ "SELECT tag_name FROM note_tags WHERE note_id = ? ORDER BY tag_name",
272
+ ).all(noteId) as { tag_name: string }[];
273
+ return rows.map((r) => r.tag_name);
274
+ }
275
+
276
+ export function listTags(db: Database): { name: string; count: number }[] {
277
+ const rows = db.prepare(`
278
+ SELECT t.name, COUNT(nt.note_id) as count
279
+ FROM tags t
280
+ LEFT JOIN note_tags nt ON nt.tag_name = t.name
281
+ GROUP BY t.name
282
+ ORDER BY t.name
283
+ `).all() as { name: string; count: number }[];
284
+ return rows;
285
+ }
286
+
287
+ export function deleteTag(db: Database, name: string): { deleted: boolean; notes_untagged: number } {
288
+ const exists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(name);
289
+ if (!exists) return { deleted: false, notes_untagged: 0 };
290
+
291
+ const countRow = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?").get(name) as { c: number };
292
+ const notesUntagged = countRow.c;
293
+
294
+ db.prepare("DELETE FROM note_tags WHERE tag_name = ?").run(name);
295
+ db.prepare("DELETE FROM tags WHERE name = ?").run(name);
296
+
297
+ return { deleted: true, notes_untagged: notesUntagged };
298
+ }
299
+
300
+ // ---- Lean note index shape ----
301
+
302
+ /** Max code points in a NoteIndex preview. */
303
+ export const NOTE_INDEX_PREVIEW_LEN = 120;
304
+
305
+ /**
306
+ * Convert a full Note into its lean index shape:
307
+ * drops `content`, adds `byteSize` and a whitespace-collapsed `preview`.
308
+ * Shared between the `query-notes` MCP tool, HTTP /notes endpoints, and /graph.
309
+ */
310
+ export function toNoteIndex(note: Note): NoteIndex {
311
+ const content = note.content ?? "";
312
+ const byteSize = Buffer.byteLength(content, "utf8");
313
+ // Collapse whitespace for a readable single-line preview
314
+ const collapsed = content.replace(/\s+/g, " ").trim();
315
+ // Iterate by Unicode code points so we don't split surrogate pairs
316
+ // (e.g. astral-plane emoji) mid-character.
317
+ const codePoints = Array.from(collapsed);
318
+ const preview = codePoints.length > NOTE_INDEX_PREVIEW_LEN
319
+ ? codePoints.slice(0, NOTE_INDEX_PREVIEW_LEN).join("")
320
+ : collapsed;
321
+ return {
322
+ id: note.id,
323
+ path: note.path,
324
+ createdAt: note.createdAt,
325
+ updatedAt: note.updatedAt,
326
+ tags: note.tags,
327
+ metadata: note.metadata,
328
+ byteSize,
329
+ preview,
330
+ };
331
+ }
332
+
333
+ // ---- Metadata field filtering ----
334
+
335
+ /**
336
+ * Filter metadata on a note/index result based on an include_metadata param.
337
+ * - true / undefined → return as-is (all metadata)
338
+ * - false → strip metadata entirely
339
+ * - string[] → return only those keys (empty array = no filtering)
340
+ */
341
+ export function filterMetadata(obj: any, includeMetadata: boolean | string[] | undefined): any {
342
+ if (includeMetadata === undefined || includeMetadata === true) return obj;
343
+ if (includeMetadata === false) {
344
+ const { metadata, ...rest } = obj;
345
+ return rest;
346
+ }
347
+ // Array of field names — empty array means no filtering (treat as "all")
348
+ const fields = includeMetadata as string[];
349
+ if (fields.length === 0 || !obj.metadata) return obj;
350
+ const filtered = Object.fromEntries(
351
+ Object.entries(obj.metadata).filter(([k]) => fields.includes(k)),
352
+ );
353
+ return { ...obj, metadata: Object.keys(filtered).length > 0 ? filtered : undefined };
354
+ }
355
+
356
+ // ---- Vault stats (aggregate situational awareness) ----
357
+
358
+ /**
359
+ * Compute aggregate vault statistics for session-start situational awareness.
360
+ *
361
+ * All computation is done via SQL aggregation — no full-table scans into memory.
362
+ * Safe to call on large vaults. Read-only.
363
+ */
364
+ export function getVaultStats(
365
+ db: Database,
366
+ opts?: { topTagsLimit?: number },
367
+ ): VaultStats {
368
+ const topTagsLimit = opts?.topTagsLimit ?? 20;
369
+
370
+ const totalRow = db.prepare("SELECT COUNT(*) as c FROM notes").get() as { c: number };
371
+ const totalNotes = totalRow.c;
372
+
373
+ const earliestRow = db.prepare(
374
+ "SELECT id, created_at FROM notes ORDER BY created_at ASC, id ASC LIMIT 1",
375
+ ).get() as { id: string; created_at: string } | undefined;
376
+
377
+ const latestRow = db.prepare(
378
+ "SELECT id, created_at FROM notes ORDER BY created_at DESC, id DESC LIMIT 1",
379
+ ).get() as { id: string; created_at: string } | undefined;
380
+
381
+ const monthRows = db.prepare(`
382
+ SELECT strftime('%Y-%m', created_at) AS month, COUNT(*) AS count
383
+ FROM notes
384
+ WHERE created_at IS NOT NULL
385
+ GROUP BY month
386
+ ORDER BY month ASC
387
+ `).all() as { month: string; count: number }[];
388
+
389
+ const topTagRows = db.prepare(`
390
+ SELECT tag_name AS tag, COUNT(*) AS count
391
+ FROM note_tags
392
+ GROUP BY tag_name
393
+ ORDER BY count DESC, tag_name ASC
394
+ LIMIT ?
395
+ `).all(topTagsLimit) as { tag: string; count: number }[];
396
+
397
+ const tagCountRow = db.prepare("SELECT COUNT(DISTINCT tag_name) as c FROM note_tags").get() as { c: number };
398
+ const tagCount = tagCountRow.c;
399
+
400
+ return {
401
+ totalNotes,
402
+ earliestNote: earliestRow
403
+ ? { id: earliestRow.id, createdAt: earliestRow.created_at }
404
+ : null,
405
+ latestNote: latestRow
406
+ ? { id: latestRow.id, createdAt: latestRow.created_at }
407
+ : null,
408
+ notesByMonth: monthRows,
409
+ topTags: topTagRows,
410
+ tagCount,
411
+ };
412
+ }
413
+
414
+ // ---- Bulk Operations ----
415
+
416
+ export interface BulkNoteInput {
417
+ content: string;
418
+ id?: string;
419
+ path?: string;
420
+ tags?: string[];
421
+ metadata?: Record<string, unknown>;
422
+ created_at?: string;
423
+ }
424
+
425
+ export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
426
+ const results: Note[] = [];
427
+
428
+ db.exec("BEGIN");
429
+ try {
430
+ for (const input of inputs) {
431
+ results.push(
432
+ createNote(db, input.content, {
433
+ id: input.id,
434
+ path: input.path,
435
+ tags: input.tags,
436
+ metadata: input.metadata,
437
+ created_at: input.created_at,
438
+ }),
439
+ );
440
+ }
441
+ db.exec("COMMIT");
442
+ } catch (err) {
443
+ db.exec("ROLLBACK");
444
+ throw err;
445
+ }
446
+
447
+ return results;
448
+ }
449
+
450
+ export function batchTag(db: Database, noteIds: string[], tags: string[]): number {
451
+ const insertTag = db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)");
452
+ const insertNoteTag = db.prepare("INSERT OR IGNORE INTO note_tags (note_id, tag_name) VALUES (?, ?)");
453
+ let count = 0;
454
+
455
+ db.exec("BEGIN");
456
+ try {
457
+ for (const tag of tags) {
458
+ insertTag.run(tag);
459
+ }
460
+ for (const noteId of noteIds) {
461
+ for (const tag of tags) {
462
+ insertNoteTag.run(noteId, tag);
463
+ count++;
464
+ }
465
+ }
466
+ db.exec("COMMIT");
467
+ } catch (err) {
468
+ db.exec("ROLLBACK");
469
+ throw err;
470
+ }
471
+
472
+ return count;
473
+ }
474
+
475
+ export function batchUntag(db: Database, noteIds: string[], tags: string[]): number {
476
+ const stmt = db.prepare("DELETE FROM note_tags WHERE note_id = ? AND tag_name = ?");
477
+ let count = 0;
478
+
479
+ db.exec("BEGIN");
480
+ try {
481
+ for (const noteId of noteIds) {
482
+ for (const tag of tags) {
483
+ stmt.run(noteId, tag);
484
+ count++;
485
+ }
486
+ }
487
+ db.exec("COMMIT");
488
+ } catch (err) {
489
+ db.exec("ROLLBACK");
490
+ throw err;
491
+ }
492
+
493
+ return count;
494
+ }
495
+
496
+ // ---- Internal ----
497
+
498
+ interface NoteRow {
499
+ id: string;
500
+ content: string;
501
+ path: string | null;
502
+ metadata: string | null;
503
+ created_at: string;
504
+ updated_at: string | null;
505
+ }
506
+
507
+ function rowToNote(row: NoteRow): Note {
508
+ let metadata: Record<string, unknown> | undefined;
509
+ if (row.metadata && row.metadata !== "{}") {
510
+ try { metadata = JSON.parse(row.metadata); } catch {}
511
+ }
512
+ return {
513
+ id: row.id,
514
+ content: row.content,
515
+ path: row.path ?? undefined,
516
+ metadata,
517
+ createdAt: row.created_at,
518
+ updatedAt: row.updated_at ?? undefined,
519
+ };
520
+ }