context-vault 2.7.0 → 2.8.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/bin/cli.js +389 -179
- package/node_modules/@context-vault/core/package.json +1 -1
- package/node_modules/@context-vault/core/src/capture/import-pipeline.js +11 -16
- package/node_modules/@context-vault/core/src/capture/index.js +57 -15
- package/node_modules/@context-vault/core/src/index/index.js +206 -52
- package/node_modules/@context-vault/core/src/server/tools/ingest-url.js +29 -11
- package/node_modules/@context-vault/core/src/server/tools/save-context.js +154 -30
- package/node_modules/@context-vault/core/src/server/tools/submit-feedback.js +24 -18
- package/node_modules/@context-vault/core/src/sync/sync.js +24 -19
- package/package.json +2 -2
- package/scripts/local-server.js +305 -93
package/scripts/local-server.js
CHANGED
|
@@ -7,13 +7,26 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { createServer } from "node:http";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
createReadStream,
|
|
12
|
+
existsSync,
|
|
13
|
+
statSync,
|
|
14
|
+
unlinkSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
} from "node:fs";
|
|
11
19
|
import { join, resolve, dirname, extname } from "node:path";
|
|
12
20
|
import { fileURLToPath } from "node:url";
|
|
13
21
|
import { homedir, platform } from "node:os";
|
|
14
22
|
import { execSync } from "node:child_process";
|
|
15
23
|
import { resolveConfig } from "@context-vault/core/core/config";
|
|
16
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
initDatabase,
|
|
26
|
+
prepareStatements,
|
|
27
|
+
insertVec,
|
|
28
|
+
deleteVec,
|
|
29
|
+
} from "@context-vault/core/index/db";
|
|
17
30
|
import { embed } from "@context-vault/core/index/embed";
|
|
18
31
|
import { captureAndIndex, updateEntryFile } from "@context-vault/core/capture";
|
|
19
32
|
import { indexEntry } from "@context-vault/core/index";
|
|
@@ -24,7 +37,12 @@ import { categoryFor } from "@context-vault/core/core/categories";
|
|
|
24
37
|
import { parseFile } from "@context-vault/core/capture/importers";
|
|
25
38
|
import { importEntries } from "@context-vault/core/capture/import-pipeline";
|
|
26
39
|
import { ingestUrl } from "@context-vault/core/capture/ingest-url";
|
|
27
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
buildLocalManifest,
|
|
42
|
+
fetchRemoteManifest,
|
|
43
|
+
computeSyncPlan,
|
|
44
|
+
executeSync,
|
|
45
|
+
} from "@context-vault/core/sync";
|
|
28
46
|
|
|
29
47
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
30
48
|
const LOCAL_ROOT = resolve(__dirname, "..");
|
|
@@ -32,7 +50,9 @@ const LOCAL_ROOT = resolve(__dirname, "..");
|
|
|
32
50
|
// Try bundled path first (npm install), then workspace path (local dev)
|
|
33
51
|
const bundledDist = resolve(LOCAL_ROOT, "app-dist");
|
|
34
52
|
const workspaceDist = resolve(LOCAL_ROOT, "..", "app", "dist");
|
|
35
|
-
const APP_DIST = existsSync(join(bundledDist, "index.html"))
|
|
53
|
+
const APP_DIST = existsSync(join(bundledDist, "index.html"))
|
|
54
|
+
? bundledDist
|
|
55
|
+
: workspaceDist;
|
|
36
56
|
|
|
37
57
|
const MIME = {
|
|
38
58
|
".html": "text/html",
|
|
@@ -51,7 +71,11 @@ function formatEntry(row) {
|
|
|
51
71
|
title: row.title || null,
|
|
52
72
|
body: row.body || null,
|
|
53
73
|
tags: row.tags ? JSON.parse(row.tags) : [],
|
|
54
|
-
meta: row.meta
|
|
74
|
+
meta: row.meta
|
|
75
|
+
? typeof row.meta === "string"
|
|
76
|
+
? JSON.parse(row.meta)
|
|
77
|
+
: row.meta
|
|
78
|
+
: {},
|
|
55
79
|
source: row.source || null,
|
|
56
80
|
identity_key: row.identity_key || null,
|
|
57
81
|
expires_at: row.expires_at || null,
|
|
@@ -60,17 +84,30 @@ function formatEntry(row) {
|
|
|
60
84
|
}
|
|
61
85
|
|
|
62
86
|
function validateEntry(data, { requireKind = true, requireBody = true } = {}) {
|
|
63
|
-
if (requireKind && !data.kind)
|
|
64
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
87
|
+
if (requireKind && !data.kind)
|
|
88
|
+
return { error: "kind is required", status: 400 };
|
|
89
|
+
if (data.kind && !/^[a-z0-9-]+$/.test(data.kind))
|
|
90
|
+
return {
|
|
91
|
+
error: "kind must be lowercase alphanumeric/hyphens",
|
|
92
|
+
status: 400,
|
|
93
|
+
};
|
|
94
|
+
if (requireBody && !data.body)
|
|
95
|
+
return { error: "body is required", status: 400 };
|
|
96
|
+
if (data.body && data.body.length > 100 * 1024)
|
|
97
|
+
return { error: "body max 100KB", status: 400 };
|
|
98
|
+
if (categoryFor(data.kind) === "entity" && !data.identity_key)
|
|
99
|
+
return {
|
|
100
|
+
error: `Entity kind "${data.kind}" requires identity_key`,
|
|
101
|
+
status: 400,
|
|
102
|
+
};
|
|
68
103
|
return null;
|
|
69
104
|
}
|
|
70
105
|
|
|
71
106
|
async function main() {
|
|
72
107
|
const portArg = process.argv.find((a) => a.startsWith("--port="));
|
|
73
|
-
const portVal = portArg
|
|
108
|
+
const portVal = portArg
|
|
109
|
+
? portArg.split("=")[1]
|
|
110
|
+
: process.argv[process.argv.indexOf("--port") + 1];
|
|
74
111
|
const port = parseInt(portVal || "3141", 10);
|
|
75
112
|
|
|
76
113
|
const config = resolveConfig();
|
|
@@ -143,15 +180,18 @@ async function main() {
|
|
|
143
180
|
if (os === "darwin") {
|
|
144
181
|
selected = execSync(
|
|
145
182
|
`osascript -e 'POSIX path of (choose folder with prompt "Select vault folder")'`,
|
|
146
|
-
{ encoding: "utf-8", timeout: 30000 }
|
|
183
|
+
{ encoding: "utf-8", timeout: 30000 },
|
|
147
184
|
).trim();
|
|
148
185
|
} else if (os === "linux") {
|
|
149
186
|
selected = execSync(
|
|
150
187
|
`zenity --file-selection --directory --title="Select vault folder" 2>/dev/null`,
|
|
151
|
-
{ encoding: "utf-8", timeout: 30000 }
|
|
188
|
+
{ encoding: "utf-8", timeout: 30000 },
|
|
152
189
|
).trim();
|
|
153
190
|
} else {
|
|
154
|
-
return json(
|
|
191
|
+
return json(
|
|
192
|
+
{ error: "Folder picker not supported on this platform" },
|
|
193
|
+
501,
|
|
194
|
+
);
|
|
155
195
|
}
|
|
156
196
|
|
|
157
197
|
if (selected) {
|
|
@@ -167,14 +207,33 @@ async function main() {
|
|
|
167
207
|
// ─── API: POST /api/local/connect — switch to a local vault folder ───────
|
|
168
208
|
if (url === "/api/local/connect" && req.method === "POST") {
|
|
169
209
|
const data = await readBody();
|
|
170
|
-
if (!data?.vaultDir?.trim())
|
|
210
|
+
if (!data?.vaultDir?.trim())
|
|
211
|
+
return json(
|
|
212
|
+
{ error: "vaultDir is required", code: "INVALID_INPUT" },
|
|
213
|
+
400,
|
|
214
|
+
);
|
|
171
215
|
let vaultPath = data.vaultDir.trim().replace(/^~/, homedir());
|
|
172
216
|
vaultPath = resolve(vaultPath);
|
|
173
|
-
if (!existsSync(vaultPath))
|
|
174
|
-
|
|
217
|
+
if (!existsSync(vaultPath))
|
|
218
|
+
return json(
|
|
219
|
+
{ error: "Vault folder not found", code: "NOT_FOUND" },
|
|
220
|
+
404,
|
|
221
|
+
);
|
|
222
|
+
if (!statSync(vaultPath).isDirectory())
|
|
223
|
+
return json(
|
|
224
|
+
{ error: "Path is not a directory", code: "INVALID_INPUT" },
|
|
225
|
+
400,
|
|
226
|
+
);
|
|
175
227
|
try {
|
|
176
|
-
try {
|
|
177
|
-
|
|
228
|
+
try {
|
|
229
|
+
state.db.close();
|
|
230
|
+
} catch {}
|
|
231
|
+
const newConfig = {
|
|
232
|
+
...state.config,
|
|
233
|
+
vaultDir: vaultPath,
|
|
234
|
+
dbPath: join(vaultPath, ".context-vault.db"),
|
|
235
|
+
vaultDirExists: true,
|
|
236
|
+
};
|
|
178
237
|
state.config = newConfig;
|
|
179
238
|
state.db = await initDatabase(newConfig.dbPath);
|
|
180
239
|
state.stmts = prepareStatements(state.db);
|
|
@@ -188,7 +247,10 @@ async function main() {
|
|
|
188
247
|
});
|
|
189
248
|
} catch (e) {
|
|
190
249
|
console.error(`[local-server] Connect error: ${e.message}`);
|
|
191
|
-
return json(
|
|
250
|
+
return json(
|
|
251
|
+
{ error: `Failed to connect: ${e.message}`, code: "CONNECT_FAILED" },
|
|
252
|
+
500,
|
|
253
|
+
);
|
|
192
254
|
}
|
|
193
255
|
}
|
|
194
256
|
|
|
@@ -207,10 +269,16 @@ async function main() {
|
|
|
207
269
|
if (url === "/api/billing/usage" && req.method === "GET") {
|
|
208
270
|
const status = gatherVaultStatus(ctx, {});
|
|
209
271
|
const total = status.kindCounts.reduce((s, k) => s + k.c, 0);
|
|
210
|
-
const storageMb =
|
|
272
|
+
const storageMb =
|
|
273
|
+
Math.round((status.dbSizeBytes / (1024 * 1024)) * 100) / 100;
|
|
211
274
|
return json({
|
|
212
275
|
tier: "free",
|
|
213
|
-
limits: {
|
|
276
|
+
limits: {
|
|
277
|
+
maxEntries: "unlimited",
|
|
278
|
+
requestsPerDay: "unlimited",
|
|
279
|
+
storageMb: 1024,
|
|
280
|
+
exportEnabled: true,
|
|
281
|
+
},
|
|
214
282
|
usage: { requestsToday: 0, entriesUsed: total, storageMb },
|
|
215
283
|
});
|
|
216
284
|
}
|
|
@@ -226,14 +294,24 @@ async function main() {
|
|
|
226
294
|
return json({
|
|
227
295
|
entries: {
|
|
228
296
|
total: status.kindCounts.reduce((s, k) => s + k.c, 0),
|
|
229
|
-
by_kind: Object.fromEntries(
|
|
230
|
-
|
|
297
|
+
by_kind: Object.fromEntries(
|
|
298
|
+
status.kindCounts.map((k) => [k.kind, k.c]),
|
|
299
|
+
),
|
|
300
|
+
by_category: Object.fromEntries(
|
|
301
|
+
status.categoryCounts.map((k) => [k.category, k.c]),
|
|
302
|
+
),
|
|
231
303
|
},
|
|
232
304
|
files: { total: status.fileCount, directories: status.subdirs },
|
|
233
|
-
database: {
|
|
305
|
+
database: {
|
|
306
|
+
size: status.dbSize,
|
|
307
|
+
size_bytes: status.dbSizeBytes,
|
|
308
|
+
stale_paths: status.staleCount,
|
|
309
|
+
expired: status.expiredCount,
|
|
310
|
+
},
|
|
234
311
|
embeddings: status.embeddingStatus,
|
|
235
312
|
embed_model_available: status.embedModelAvailable,
|
|
236
|
-
health:
|
|
313
|
+
health:
|
|
314
|
+
status.errors.length === 0 && !status.stalePaths ? "ok" : "degraded",
|
|
237
315
|
errors: status.errors,
|
|
238
316
|
});
|
|
239
317
|
}
|
|
@@ -243,13 +321,17 @@ async function main() {
|
|
|
243
321
|
const idMatch = url.match(/\/api\/vault\/entries\/([^/]+)$/);
|
|
244
322
|
if (idMatch) {
|
|
245
323
|
const entry = stmts.getEntryById.get(idMatch[1]);
|
|
246
|
-
if (!entry)
|
|
324
|
+
if (!entry)
|
|
325
|
+
return json({ error: "Entry not found", code: "NOT_FOUND" }, 404);
|
|
247
326
|
return json(formatEntry(entry));
|
|
248
327
|
}
|
|
249
328
|
const u = new URL(req.url || "", "http://localhost");
|
|
250
329
|
const kind = u.searchParams.get("kind") || null;
|
|
251
330
|
const category = u.searchParams.get("category") || null;
|
|
252
|
-
const limit = Math.min(
|
|
331
|
+
const limit = Math.min(
|
|
332
|
+
parseInt(u.searchParams.get("limit") || "20", 10) || 20,
|
|
333
|
+
100,
|
|
334
|
+
);
|
|
253
335
|
const offset = parseInt(u.searchParams.get("offset") || "0", 10) || 0;
|
|
254
336
|
const clauses = ["(expires_at IS NULL OR expires_at > datetime('now'))"];
|
|
255
337
|
const params = [];
|
|
@@ -262,37 +344,44 @@ async function main() {
|
|
|
262
344
|
params.push(category);
|
|
263
345
|
}
|
|
264
346
|
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
265
|
-
const total = ctx.db
|
|
266
|
-
|
|
347
|
+
const total = ctx.db
|
|
348
|
+
.prepare(`SELECT COUNT(*) as c FROM vault ${where}`)
|
|
349
|
+
.get(...params).c;
|
|
350
|
+
const rows = ctx.db
|
|
351
|
+
.prepare(
|
|
352
|
+
`SELECT * FROM vault ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
|
353
|
+
)
|
|
354
|
+
.all(...params, limit, offset);
|
|
267
355
|
return json({ entries: rows.map(formatEntry), total, limit, offset });
|
|
268
356
|
}
|
|
269
357
|
|
|
270
358
|
// ─── API: POST /api/vault/entries ────────────────────────────────────────
|
|
271
359
|
if (url === "/api/vault/entries" && req.method === "POST") {
|
|
272
360
|
const data = await readBody();
|
|
273
|
-
if (!data)
|
|
361
|
+
if (!data)
|
|
362
|
+
return json({ error: "Invalid JSON body", code: "INVALID_INPUT" }, 400);
|
|
274
363
|
const err = validateEntry(data);
|
|
275
|
-
if (err)
|
|
364
|
+
if (err)
|
|
365
|
+
return json({ error: err.error, code: "INVALID_INPUT" }, err.status);
|
|
276
366
|
try {
|
|
277
|
-
const entry = await captureAndIndex(
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
userId: null,
|
|
289
|
-
},
|
|
290
|
-
indexEntry
|
|
291
|
-
);
|
|
367
|
+
const entry = await captureAndIndex(ctx, {
|
|
368
|
+
kind: data.kind,
|
|
369
|
+
title: data.title,
|
|
370
|
+
body: data.body,
|
|
371
|
+
meta: data.meta,
|
|
372
|
+
tags: data.tags,
|
|
373
|
+
source: data.source || "rest-api",
|
|
374
|
+
identity_key: data.identity_key,
|
|
375
|
+
expires_at: data.expires_at,
|
|
376
|
+
userId: null,
|
|
377
|
+
});
|
|
292
378
|
return json(formatEntry(stmts.getEntryById.get(entry.id)), 201);
|
|
293
379
|
} catch (e) {
|
|
294
380
|
console.error(`[local-server] Create error: ${e.message}`);
|
|
295
|
-
return json(
|
|
381
|
+
return json(
|
|
382
|
+
{ error: "Failed to create entry", code: "CREATE_FAILED" },
|
|
383
|
+
500,
|
|
384
|
+
);
|
|
296
385
|
}
|
|
297
386
|
}
|
|
298
387
|
|
|
@@ -300,11 +389,17 @@ async function main() {
|
|
|
300
389
|
if (url.match(/^\/api\/vault\/entries\/[^/]+$/) && req.method === "PUT") {
|
|
301
390
|
const id = url.split("/").pop();
|
|
302
391
|
const data = await readBody();
|
|
303
|
-
if (!data)
|
|
304
|
-
|
|
305
|
-
|
|
392
|
+
if (!data)
|
|
393
|
+
return json({ error: "Invalid JSON body", code: "INVALID_INPUT" }, 400);
|
|
394
|
+
const err = validateEntry(data, {
|
|
395
|
+
requireKind: false,
|
|
396
|
+
requireBody: false,
|
|
397
|
+
});
|
|
398
|
+
if (err)
|
|
399
|
+
return json({ error: err.error, code: "INVALID_INPUT" }, err.status);
|
|
306
400
|
const existing = stmts.getEntryById.get(id);
|
|
307
|
-
if (!existing)
|
|
401
|
+
if (!existing)
|
|
402
|
+
return json({ error: "Entry not found", code: "NOT_FOUND" }, 404);
|
|
308
403
|
try {
|
|
309
404
|
const entry = updateEntryFile(ctx, existing, {
|
|
310
405
|
title: data.title,
|
|
@@ -318,15 +413,22 @@ async function main() {
|
|
|
318
413
|
return json(formatEntry(stmts.getEntryById.get(id)));
|
|
319
414
|
} catch (e) {
|
|
320
415
|
console.error(`[local-server] Update error: ${e.message}`);
|
|
321
|
-
return json(
|
|
416
|
+
return json(
|
|
417
|
+
{ error: "Failed to update entry", code: "UPDATE_FAILED" },
|
|
418
|
+
500,
|
|
419
|
+
);
|
|
322
420
|
}
|
|
323
421
|
}
|
|
324
422
|
|
|
325
423
|
// ─── API: DELETE /api/vault/entries/:id ──────────────────────────────────
|
|
326
|
-
if (
|
|
424
|
+
if (
|
|
425
|
+
url.match(/^\/api\/vault\/entries\/[^/]+$/) &&
|
|
426
|
+
req.method === "DELETE"
|
|
427
|
+
) {
|
|
327
428
|
const id = url.split("/").pop();
|
|
328
429
|
const entry = stmts.getEntryById.get(id);
|
|
329
|
-
if (!entry)
|
|
430
|
+
if (!entry)
|
|
431
|
+
return json({ error: "Entry not found", code: "NOT_FOUND" }, 404);
|
|
330
432
|
if (entry.file_path) {
|
|
331
433
|
try {
|
|
332
434
|
unlinkSync(entry.file_path);
|
|
@@ -339,13 +441,19 @@ async function main() {
|
|
|
339
441
|
} catch {}
|
|
340
442
|
}
|
|
341
443
|
stmts.deleteEntry.run(id);
|
|
342
|
-
return json({
|
|
444
|
+
return json({
|
|
445
|
+
deleted: true,
|
|
446
|
+
id,
|
|
447
|
+
kind: entry.kind,
|
|
448
|
+
title: entry.title || null,
|
|
449
|
+
});
|
|
343
450
|
}
|
|
344
451
|
|
|
345
452
|
// ─── API: POST /api/vault/search ─────────────────────────────────────────
|
|
346
453
|
if (url === "/api/vault/search" && req.method === "POST") {
|
|
347
454
|
const data = await readBody();
|
|
348
|
-
if (!data || !data.query?.trim())
|
|
455
|
+
if (!data || !data.query?.trim())
|
|
456
|
+
return json({ error: "query is required", code: "INVALID_INPUT" }, 400);
|
|
349
457
|
const limit = Math.min(parseInt(data.limit || 20, 10) || 20, 100);
|
|
350
458
|
const offset = parseInt(data.offset || 0, 10) || 0;
|
|
351
459
|
try {
|
|
@@ -357,8 +465,15 @@ async function main() {
|
|
|
357
465
|
decayDays: ctx.config.eventDecayDays || 30,
|
|
358
466
|
userIdFilter: undefined,
|
|
359
467
|
});
|
|
360
|
-
const formatted = results.map((row) => ({
|
|
361
|
-
|
|
468
|
+
const formatted = results.map((row) => ({
|
|
469
|
+
...formatEntry(row),
|
|
470
|
+
score: Math.round(row.score * 1000) / 1000,
|
|
471
|
+
}));
|
|
472
|
+
return json({
|
|
473
|
+
results: formatted,
|
|
474
|
+
count: formatted.length,
|
|
475
|
+
query: data.query,
|
|
476
|
+
});
|
|
362
477
|
} catch (e) {
|
|
363
478
|
console.error(`[local-server] Search error: ${e.message}`);
|
|
364
479
|
return json({ error: "Search failed", code: "SEARCH_FAILED" }, 500);
|
|
@@ -369,28 +484,60 @@ async function main() {
|
|
|
369
484
|
if (url === "/api/vault/import/bulk" && req.method === "POST") {
|
|
370
485
|
const data = await readBody();
|
|
371
486
|
if (!data || !Array.isArray(data.entries)) {
|
|
372
|
-
return json(
|
|
487
|
+
return json(
|
|
488
|
+
{
|
|
489
|
+
error: "Invalid body — expected { entries: [...] }",
|
|
490
|
+
code: "INVALID_INPUT",
|
|
491
|
+
},
|
|
492
|
+
400,
|
|
493
|
+
);
|
|
373
494
|
}
|
|
374
495
|
if (data.entries.length > 500) {
|
|
375
|
-
return json(
|
|
496
|
+
return json(
|
|
497
|
+
{ error: "Maximum 500 entries per request", code: "LIMIT_EXCEEDED" },
|
|
498
|
+
400,
|
|
499
|
+
);
|
|
376
500
|
}
|
|
377
501
|
|
|
378
|
-
const result = await importEntries(ctx, data.entries, {
|
|
379
|
-
|
|
502
|
+
const result = await importEntries(ctx, data.entries, {
|
|
503
|
+
source: "bulk-import",
|
|
504
|
+
});
|
|
505
|
+
return json({
|
|
506
|
+
imported: result.imported,
|
|
507
|
+
failed: result.failed,
|
|
508
|
+
errors: result.errors.slice(0, 10).map((e) => e.error),
|
|
509
|
+
});
|
|
380
510
|
}
|
|
381
511
|
|
|
382
512
|
// ─── API: POST /api/vault/import/file — Import from file content ────────
|
|
383
513
|
if (url === "/api/vault/import/file" && req.method === "POST") {
|
|
384
514
|
const data = await readBody();
|
|
385
515
|
if (!data?.filename || !data?.content) {
|
|
386
|
-
return json(
|
|
516
|
+
return json(
|
|
517
|
+
{ error: "filename and content are required", code: "INVALID_INPUT" },
|
|
518
|
+
400,
|
|
519
|
+
);
|
|
387
520
|
}
|
|
388
521
|
|
|
389
|
-
const entries = parseFile(data.filename, data.content, {
|
|
390
|
-
|
|
522
|
+
const entries = parseFile(data.filename, data.content, {
|
|
523
|
+
kind: data.kind,
|
|
524
|
+
source: data.source || "file-import",
|
|
525
|
+
});
|
|
526
|
+
if (!entries.length)
|
|
527
|
+
return json({
|
|
528
|
+
imported: 0,
|
|
529
|
+
failed: 0,
|
|
530
|
+
errors: ["No entries parsed from file"],
|
|
531
|
+
});
|
|
391
532
|
|
|
392
|
-
const result = await importEntries(ctx, entries, {
|
|
393
|
-
|
|
533
|
+
const result = await importEntries(ctx, entries, {
|
|
534
|
+
source: data.source || "file-import",
|
|
535
|
+
});
|
|
536
|
+
return json({
|
|
537
|
+
imported: result.imported,
|
|
538
|
+
failed: result.failed,
|
|
539
|
+
errors: result.errors.slice(0, 10).map((e) => e.error),
|
|
540
|
+
});
|
|
394
541
|
}
|
|
395
542
|
|
|
396
543
|
// ─── API: GET /api/vault/export — Export all entries ─────────────────────
|
|
@@ -398,14 +545,27 @@ async function main() {
|
|
|
398
545
|
const u = new URL(req.url || "", "http://localhost");
|
|
399
546
|
const format = u.searchParams.get("format") || "json";
|
|
400
547
|
|
|
401
|
-
const rows = ctx.db
|
|
402
|
-
|
|
403
|
-
|
|
548
|
+
const rows = ctx.db
|
|
549
|
+
.prepare(
|
|
550
|
+
"SELECT * FROM vault WHERE (expires_at IS NULL OR expires_at > datetime('now')) ORDER BY created_at DESC",
|
|
551
|
+
)
|
|
552
|
+
.all();
|
|
404
553
|
|
|
405
554
|
const entries = rows.map(formatEntry);
|
|
406
555
|
|
|
407
556
|
if (format === "csv") {
|
|
408
|
-
const headers = [
|
|
557
|
+
const headers = [
|
|
558
|
+
"id",
|
|
559
|
+
"kind",
|
|
560
|
+
"category",
|
|
561
|
+
"title",
|
|
562
|
+
"body",
|
|
563
|
+
"tags",
|
|
564
|
+
"source",
|
|
565
|
+
"identity_key",
|
|
566
|
+
"expires_at",
|
|
567
|
+
"created_at",
|
|
568
|
+
];
|
|
409
569
|
const csvLines = [headers.join(",")];
|
|
410
570
|
for (const e of entries) {
|
|
411
571
|
const row = headers.map((h) => {
|
|
@@ -420,34 +580,57 @@ async function main() {
|
|
|
420
580
|
});
|
|
421
581
|
csvLines.push(row.join(","));
|
|
422
582
|
}
|
|
423
|
-
res.writeHead(200, {
|
|
583
|
+
res.writeHead(200, {
|
|
584
|
+
"Content-Type": "text/csv",
|
|
585
|
+
"Access-Control-Allow-Origin": "*",
|
|
586
|
+
});
|
|
424
587
|
res.end(csvLines.join("\n"));
|
|
425
588
|
return;
|
|
426
589
|
}
|
|
427
590
|
|
|
428
|
-
return json({
|
|
591
|
+
return json({
|
|
592
|
+
entries,
|
|
593
|
+
total: entries.length,
|
|
594
|
+
exported_at: new Date().toISOString(),
|
|
595
|
+
});
|
|
429
596
|
}
|
|
430
597
|
|
|
431
598
|
// ─── API: POST /api/vault/ingest — Fetch URL and save as entry ──────────
|
|
432
599
|
if (url === "/api/vault/ingest" && req.method === "POST") {
|
|
433
600
|
const data = await readBody();
|
|
434
|
-
if (!data?.url)
|
|
601
|
+
if (!data?.url)
|
|
602
|
+
return json({ error: "url is required", code: "INVALID_INPUT" }, 400);
|
|
435
603
|
|
|
436
604
|
try {
|
|
437
|
-
const entry = await ingestUrl(data.url, {
|
|
438
|
-
|
|
605
|
+
const entry = await ingestUrl(data.url, {
|
|
606
|
+
kind: data.kind,
|
|
607
|
+
tags: data.tags,
|
|
608
|
+
});
|
|
609
|
+
const result = await captureAndIndex(ctx, entry);
|
|
439
610
|
return json(formatEntry(ctx.stmts.getEntryById.get(result.id)), 201);
|
|
440
611
|
} catch (e) {
|
|
441
|
-
return json(
|
|
612
|
+
return json(
|
|
613
|
+
{ error: `Ingestion failed: ${e.message}`, code: "INGEST_FAILED" },
|
|
614
|
+
500,
|
|
615
|
+
);
|
|
442
616
|
}
|
|
443
617
|
}
|
|
444
618
|
|
|
445
619
|
// ─── API: GET /api/vault/manifest — Lightweight entry list for sync ─────
|
|
446
620
|
if (url === "/api/vault/manifest" && req.method === "GET") {
|
|
447
|
-
const rows = ctx.db
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
621
|
+
const rows = ctx.db
|
|
622
|
+
.prepare(
|
|
623
|
+
"SELECT id, kind, title, created_at FROM vault WHERE (expires_at IS NULL OR expires_at > datetime('now')) ORDER BY created_at DESC",
|
|
624
|
+
)
|
|
625
|
+
.all();
|
|
626
|
+
return json({
|
|
627
|
+
entries: rows.map((r) => ({
|
|
628
|
+
id: r.id,
|
|
629
|
+
kind: r.kind,
|
|
630
|
+
title: r.title || null,
|
|
631
|
+
created_at: r.created_at,
|
|
632
|
+
})),
|
|
633
|
+
});
|
|
451
634
|
}
|
|
452
635
|
|
|
453
636
|
// ─── API: GET /api/local/link — Get link status ─────────────────────────
|
|
@@ -456,7 +639,9 @@ async function main() {
|
|
|
456
639
|
const configPath = join(dataDir, "config.json");
|
|
457
640
|
let storedConfig = {};
|
|
458
641
|
if (existsSync(configPath)) {
|
|
459
|
-
try {
|
|
642
|
+
try {
|
|
643
|
+
storedConfig = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
644
|
+
} catch {}
|
|
460
645
|
}
|
|
461
646
|
return json({
|
|
462
647
|
linked: !!storedConfig.apiKey,
|
|
@@ -475,7 +660,9 @@ async function main() {
|
|
|
475
660
|
|
|
476
661
|
let storedConfig = {};
|
|
477
662
|
if (existsSync(configPath)) {
|
|
478
|
-
try {
|
|
663
|
+
try {
|
|
664
|
+
storedConfig = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
665
|
+
} catch {}
|
|
479
666
|
}
|
|
480
667
|
|
|
481
668
|
if (!data?.apiKey) {
|
|
@@ -508,9 +695,16 @@ async function main() {
|
|
|
508
695
|
mkdirSync(dataDir, { recursive: true });
|
|
509
696
|
writeFileSync(configPath, JSON.stringify(storedConfig, null, 2) + "\n");
|
|
510
697
|
|
|
511
|
-
return json({
|
|
698
|
+
return json({
|
|
699
|
+
linked: true,
|
|
700
|
+
email: user.email,
|
|
701
|
+
tier: user.tier || "free",
|
|
702
|
+
});
|
|
512
703
|
} catch (e) {
|
|
513
|
-
return json(
|
|
704
|
+
return json(
|
|
705
|
+
{ error: `Verification failed: ${e.message}`, code: "AUTH_FAILED" },
|
|
706
|
+
401,
|
|
707
|
+
);
|
|
514
708
|
}
|
|
515
709
|
}
|
|
516
710
|
|
|
@@ -520,16 +714,24 @@ async function main() {
|
|
|
520
714
|
const configPath = join(dataDir, "config.json");
|
|
521
715
|
let storedConfig = {};
|
|
522
716
|
if (existsSync(configPath)) {
|
|
523
|
-
try {
|
|
717
|
+
try {
|
|
718
|
+
storedConfig = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
719
|
+
} catch {}
|
|
524
720
|
}
|
|
525
721
|
|
|
526
722
|
if (!storedConfig.apiKey) {
|
|
527
|
-
return json(
|
|
723
|
+
return json(
|
|
724
|
+
{ error: "Not linked. Use link endpoint first.", code: "NOT_LINKED" },
|
|
725
|
+
400,
|
|
726
|
+
);
|
|
528
727
|
}
|
|
529
728
|
|
|
530
729
|
try {
|
|
531
730
|
const local = buildLocalManifest(ctx);
|
|
532
|
-
const remote = await fetchRemoteManifest(
|
|
731
|
+
const remote = await fetchRemoteManifest(
|
|
732
|
+
storedConfig.hostedUrl,
|
|
733
|
+
storedConfig.apiKey,
|
|
734
|
+
);
|
|
533
735
|
const plan = computeSyncPlan(local, remote);
|
|
534
736
|
const result = await executeSync(ctx, {
|
|
535
737
|
hostedUrl: storedConfig.hostedUrl,
|
|
@@ -538,12 +740,18 @@ async function main() {
|
|
|
538
740
|
});
|
|
539
741
|
return json(result);
|
|
540
742
|
} catch (e) {
|
|
541
|
-
return json(
|
|
743
|
+
return json(
|
|
744
|
+
{ error: `Sync failed: ${e.message}`, code: "SYNC_FAILED" },
|
|
745
|
+
500,
|
|
746
|
+
);
|
|
542
747
|
}
|
|
543
748
|
}
|
|
544
749
|
|
|
545
750
|
// ─── Static files ───────────────────────────────────────────────────────
|
|
546
|
-
const filePath = join(
|
|
751
|
+
const filePath = join(
|
|
752
|
+
APP_DIST,
|
|
753
|
+
url === "/" ? "index.html" : url.replace(/^\//, ""),
|
|
754
|
+
);
|
|
547
755
|
if (!filePath.startsWith(APP_DIST)) {
|
|
548
756
|
res.writeHead(403);
|
|
549
757
|
res.end();
|
|
@@ -572,11 +780,15 @@ async function main() {
|
|
|
572
780
|
});
|
|
573
781
|
|
|
574
782
|
process.on("SIGINT", () => {
|
|
575
|
-
try {
|
|
783
|
+
try {
|
|
784
|
+
state.db.close();
|
|
785
|
+
} catch {}
|
|
576
786
|
process.exit(0);
|
|
577
787
|
});
|
|
578
788
|
process.on("SIGTERM", () => {
|
|
579
|
-
try {
|
|
789
|
+
try {
|
|
790
|
+
state.db.close();
|
|
791
|
+
} catch {}
|
|
580
792
|
process.exit(0);
|
|
581
793
|
});
|
|
582
794
|
}
|