@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.
- package/.claude/settings.local.json +31 -0
- package/.dockerignore +8 -0
- package/.env.example +9 -0
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
- package/CLAUDE.md +115 -0
- package/Caddyfile +3 -0
- package/Dockerfile +22 -0
- package/LICENSE +661 -0
- package/README.md +356 -0
- package/bun.lock +219 -0
- package/bunfig.toml +2 -0
- package/core/package.json +7 -0
- package/core/src/core.test.ts +940 -0
- package/core/src/hooks.test.ts +361 -0
- package/core/src/hooks.ts +234 -0
- package/core/src/links.ts +352 -0
- package/core/src/mcp.ts +672 -0
- package/core/src/notes.ts +520 -0
- package/core/src/obsidian.test.ts +380 -0
- package/core/src/obsidian.ts +322 -0
- package/core/src/paths.test.ts +197 -0
- package/core/src/paths.ts +53 -0
- package/core/src/schema.ts +331 -0
- package/core/src/store.ts +303 -0
- package/core/src/tag-schemas.ts +104 -0
- package/core/src/test-preload.ts +8 -0
- package/core/src/types.ts +140 -0
- package/core/src/wikilinks.test.ts +277 -0
- package/core/src/wikilinks.ts +402 -0
- package/deploy/parachute-vault.service +20 -0
- package/docker-compose.yml +50 -0
- package/docs/HTTP_API.md +328 -0
- package/fly.toml +24 -0
- package/package.json +32 -0
- package/railway.json +14 -0
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/scripts/migrate-audio-to-opus.test.ts +237 -0
- package/scripts/migrate-audio-to-opus.ts +499 -0
- package/src/auth.ts +170 -0
- package/src/cli.ts +1131 -0
- package/src/config-triggers.test.ts +83 -0
- package/src/config.test.ts +125 -0
- package/src/config.ts +716 -0
- package/src/db.ts +14 -0
- package/src/launchd.ts +109 -0
- package/src/mcp-http.ts +113 -0
- package/src/mcp-tools.ts +155 -0
- package/src/oauth.test.ts +1242 -0
- package/src/oauth.ts +729 -0
- package/src/owner-auth.ts +159 -0
- package/src/prompt.ts +141 -0
- package/src/published.test.ts +214 -0
- package/src/qrcode-terminal.d.ts +9 -0
- package/src/routes.ts +822 -0
- package/src/server.ts +450 -0
- package/src/systemd.ts +84 -0
- package/src/token-store.test.ts +174 -0
- package/src/token-store.ts +241 -0
- package/src/triggers.test.ts +397 -0
- package/src/triggers.ts +412 -0
- package/src/two-factor.test.ts +246 -0
- package/src/two-factor.ts +222 -0
- package/src/vault-store.ts +47 -0
- package/src/vault.test.ts +1309 -0
- package/tsconfig.json +29 -0
- package/web/README.md +73 -0
- package/web/bun.lock +827 -0
- package/web/eslint.config.js +23 -0
- package/web/index.html +15 -0
- package/web/package.json +36 -0
- package/web/public/favicon.svg +1 -0
- package/web/public/icons.svg +24 -0
- package/web/src/App.tsx +149 -0
- package/web/src/Graph.tsx +200 -0
- package/web/src/NoteView.tsx +155 -0
- package/web/src/Sidebar.tsx +186 -0
- package/web/src/api.ts +21 -0
- package/web/src/index.css +50 -0
- package/web/src/main.tsx +10 -0
- package/web/src/types.ts +37 -0
- package/web/src/utils.ts +107 -0
- package/web/tsconfig.app.json +25 -0
- package/web/tsconfig.json +7 -0
- package/web/tsconfig.node.json +24 -0
- 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
|
+
}
|