@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
package/src/routes.ts
ADDED
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST API route handlers for the multi-vault server.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the 9 MCP tools:
|
|
5
|
+
* /api/notes — query-notes, create-note, update-note, delete-note
|
|
6
|
+
* /api/tags — list-tags, update-tag, delete-tag
|
|
7
|
+
* /api/find-path — find-path
|
|
8
|
+
* /api/vault — vault-info
|
|
9
|
+
*
|
|
10
|
+
* Each handler receives a Store instance (already resolved for the vault)
|
|
11
|
+
* and the Request, and returns a Response.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Store, Note } from "../core/src/types.ts";
|
|
15
|
+
import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
|
|
16
|
+
import { toNoteIndex, filterMetadata } from "../core/src/notes.ts";
|
|
17
|
+
import * as linkOps from "../core/src/links.ts";
|
|
18
|
+
import * as tagSchemaOps from "../core/src/tag-schemas.ts";
|
|
19
|
+
import { join, extname, normalize } from "path";
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
21
|
+
import { vaultDir } from "./config.ts";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Helpers
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
function json(data: unknown, status = 200): Response {
|
|
28
|
+
return Response.json(data, { status });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseBool(val: string | null, defaultVal: boolean): boolean {
|
|
32
|
+
if (val === null) return defaultVal;
|
|
33
|
+
return val === "true" || val === "1";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseQuery(url: URL, key: string): string | null {
|
|
37
|
+
return url.searchParams.get(key);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseQueryList(url: URL, key: string): string[] | undefined {
|
|
41
|
+
const val = url.searchParams.get(key);
|
|
42
|
+
return val ? val.split(",") : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseInt10(val: string | null): number | undefined {
|
|
46
|
+
if (!val) return undefined;
|
|
47
|
+
const n = parseInt(val, 10);
|
|
48
|
+
return isNaN(n) ? undefined : n;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse include_metadata query param.
|
|
53
|
+
* - absent/null → undefined (all metadata, default)
|
|
54
|
+
* - "true"/"1" → true (all metadata)
|
|
55
|
+
* - "false"/"0" → false (no metadata)
|
|
56
|
+
* - "summary,status" → ["summary", "status"] (field filter)
|
|
57
|
+
*/
|
|
58
|
+
function parseIncludeMetadata(url: URL): boolean | string[] | undefined {
|
|
59
|
+
const val = url.searchParams.get("include_metadata");
|
|
60
|
+
if (val === null) return undefined;
|
|
61
|
+
if (val === "true" || val === "1") return true;
|
|
62
|
+
if (val === "false" || val === "0") return false;
|
|
63
|
+
const fields = val.split(",").map((s) => s.trim()).filter(Boolean);
|
|
64
|
+
if (fields.length === 0) return undefined; // empty string → treat as default (all)
|
|
65
|
+
return fields;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve a note by ID or path. Tries ID first, then case-insensitive path.
|
|
71
|
+
*/
|
|
72
|
+
function resolveNote(store: Store, idOrPath: string): Note | null {
|
|
73
|
+
const byId = store.getNote(idOrPath);
|
|
74
|
+
if (byId) return byId;
|
|
75
|
+
return store.getNoteByPath(idOrPath);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function requireNote(store: Store, idOrPath: string): Note {
|
|
79
|
+
const note = resolveNote(store, idOrPath);
|
|
80
|
+
if (!note) throw new NotFoundError(`Note not found: "${idOrPath}"`);
|
|
81
|
+
return note;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
class NotFoundError extends Error {
|
|
85
|
+
constructor(message: string) {
|
|
86
|
+
super(message);
|
|
87
|
+
this.name = "NotFoundError";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Notes — GET/POST/PATCH/DELETE /api/notes[/:idOrPath]
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
export async function handleNotes(
|
|
96
|
+
req: Request,
|
|
97
|
+
store: Store,
|
|
98
|
+
subpath: string,
|
|
99
|
+
): Promise<Response> {
|
|
100
|
+
const url = new URL(req.url);
|
|
101
|
+
const method = req.method;
|
|
102
|
+
const db = (store as any).db;
|
|
103
|
+
|
|
104
|
+
// ---- Collection routes (no ID in path) ----
|
|
105
|
+
if (subpath === "") {
|
|
106
|
+
// GET /notes — query (all filters as query params)
|
|
107
|
+
if (method === "GET") {
|
|
108
|
+
const id = parseQuery(url, "id");
|
|
109
|
+
const search = parseQuery(url, "search");
|
|
110
|
+
|
|
111
|
+
// Single note by id/path
|
|
112
|
+
if (id) {
|
|
113
|
+
const note = resolveNote(store, id);
|
|
114
|
+
if (!note) return json({ error: "Note not found", id }, 404);
|
|
115
|
+
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
116
|
+
let result: any = includeContent ? { ...note } : toNoteIndex(note);
|
|
117
|
+
result = filterMetadata(result, parseIncludeMetadata(url));
|
|
118
|
+
if (parseBool(parseQuery(url, "include_links"), false)) {
|
|
119
|
+
result.links = linkOps.getLinksHydrated(db, note.id);
|
|
120
|
+
}
|
|
121
|
+
if (parseBool(parseQuery(url, "include_attachments"), false)) {
|
|
122
|
+
result.attachments = store.getAttachments(note.id);
|
|
123
|
+
}
|
|
124
|
+
return json(result);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Full-text search
|
|
128
|
+
if (search) {
|
|
129
|
+
const searchTags = parseQueryList(url, "tag");
|
|
130
|
+
const limit = parseInt10(parseQuery(url, "limit")) ?? 50;
|
|
131
|
+
const results = store.searchNotes(search, { tags: searchTags, limit });
|
|
132
|
+
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
133
|
+
const inclMeta = parseIncludeMetadata(url);
|
|
134
|
+
let output = includeContent ? results : results.map(toNoteIndex);
|
|
135
|
+
if (inclMeta !== undefined && inclMeta !== true) {
|
|
136
|
+
output = output.map((n: any) => filterMetadata(n, inclMeta));
|
|
137
|
+
}
|
|
138
|
+
return json(output);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Structured query
|
|
142
|
+
const tags = parseQueryList(url, "tag");
|
|
143
|
+
let results: Note[] = store.queryNotes({
|
|
144
|
+
tags,
|
|
145
|
+
tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
|
|
146
|
+
excludeTags: parseQueryList(url, "exclude_tag"),
|
|
147
|
+
path: parseQuery(url, "path") ?? undefined,
|
|
148
|
+
pathPrefix: parseQuery(url, "path_prefix") ?? undefined,
|
|
149
|
+
metadata: undefined, // metadata filter not practical in query params
|
|
150
|
+
dateFrom: parseQuery(url, "date_from") ?? undefined,
|
|
151
|
+
dateTo: parseQuery(url, "date_to") ?? undefined,
|
|
152
|
+
sort: (parseQuery(url, "sort") as "asc" | "desc") ?? undefined,
|
|
153
|
+
limit: parseInt10(parseQuery(url, "limit")) ?? 50,
|
|
154
|
+
offset: parseInt10(parseQuery(url, "offset")),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Near-scope filter (graph neighborhood)
|
|
158
|
+
const nearNoteId = parseQuery(url, "near[note_id]");
|
|
159
|
+
if (nearNoteId) {
|
|
160
|
+
const anchor = resolveNote(store, nearNoteId);
|
|
161
|
+
if (!anchor) return json({ error: "Anchor note not found", note_id: nearNoteId }, 404);
|
|
162
|
+
const depth = Math.min(parseInt10(parseQuery(url, "near[depth]")) ?? 2, 5);
|
|
163
|
+
const relationship = parseQuery(url, "near[relationship]") ?? undefined;
|
|
164
|
+
const traversed = linkOps.traverseLinks(db, anchor.id, { max_depth: depth, relationship });
|
|
165
|
+
const nearScope = new Set([anchor.id, ...traversed.map((t) => t.noteId)]);
|
|
166
|
+
results = results.filter((n) => nearScope.has(n.id));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const includeContent = parseBool(parseQuery(url, "include_content"), false);
|
|
170
|
+
const includeLinks = parseBool(parseQuery(url, "include_links"), false);
|
|
171
|
+
const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
|
|
172
|
+
const inclMeta = parseIncludeMetadata(url);
|
|
173
|
+
let output: any[] = includeContent ? results : results.map(toNoteIndex);
|
|
174
|
+
if (inclMeta !== undefined && inclMeta !== true) {
|
|
175
|
+
output = output.map((n: any) => filterMetadata(n, inclMeta));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Graph format — reshape into { nodes, edges }
|
|
179
|
+
if (parseQuery(url, "format") === "graph") {
|
|
180
|
+
const resultIds = new Set(results.map((n) => n.id));
|
|
181
|
+
const nodes = output.map((n: any) => ({ id: n.id, path: n.path ?? null, tags: n.tags ?? [] }));
|
|
182
|
+
const edges: { source: string; target: string; relationship: string }[] = [];
|
|
183
|
+
if (includeLinks) {
|
|
184
|
+
for (const n of results) {
|
|
185
|
+
for (const link of linkOps.getLinksHydrated(db, n.id)) {
|
|
186
|
+
// Only include edges where source is this note and target is in the result set
|
|
187
|
+
if (link.sourceId === n.id && resultIds.has(link.targetId)) {
|
|
188
|
+
edges.push({ source: link.sourceId, target: link.targetId, relationship: link.relationship });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return json({ nodes, edges });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (includeLinks || includeAttachments) {
|
|
197
|
+
return json(output.map((n: any) => {
|
|
198
|
+
const enriched = { ...n };
|
|
199
|
+
if (includeLinks) enriched.links = linkOps.getLinksHydrated(db, n.id);
|
|
200
|
+
if (includeAttachments) enriched.attachments = store.getAttachments(n.id);
|
|
201
|
+
return enriched;
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return json(output);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// POST /notes — create (single or batch)
|
|
209
|
+
if (method === "POST") {
|
|
210
|
+
const body = await req.json() as any;
|
|
211
|
+
const items: any[] = body.notes ?? [body];
|
|
212
|
+
|
|
213
|
+
const created: Note[] = [];
|
|
214
|
+
for (const item of items) {
|
|
215
|
+
const note = store.createNote(item.content ?? "", {
|
|
216
|
+
id: item.id,
|
|
217
|
+
path: item.path,
|
|
218
|
+
tags: item.tags,
|
|
219
|
+
metadata: item.metadata,
|
|
220
|
+
created_at: item.createdAt ?? item.created_at,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Create explicit links
|
|
224
|
+
if (item.links) {
|
|
225
|
+
for (const link of item.links as { target: string; relationship: string }[]) {
|
|
226
|
+
const target = resolveNote(store, link.target);
|
|
227
|
+
if (target) store.createLink(note.id, target.id, link.relationship);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
created.push(store.getNote(note.id) ?? note);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Apply tag schema defaults
|
|
235
|
+
for (const note of created) {
|
|
236
|
+
if (note.tags?.length) {
|
|
237
|
+
applySchemaDefaults(store, db, [note.id], note.tags);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return json(body.notes ? created : created[0], 201);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return json({ error: "Method not allowed" }, 405);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---- Note-level routes (/notes/:idOrPath[/attachments]) ----
|
|
248
|
+
const idMatch = subpath.match(/^\/([^/]+)(\/.*)?$/);
|
|
249
|
+
if (!idMatch) return json({ error: "Not found" }, 404);
|
|
250
|
+
|
|
251
|
+
const idOrPath = decodeURIComponent(idMatch[1]);
|
|
252
|
+
const sub = idMatch[2] ?? "";
|
|
253
|
+
|
|
254
|
+
// Attachments sub-routes (keep as-is — Daily needs them)
|
|
255
|
+
if (sub === "/attachments") {
|
|
256
|
+
if (method === "POST") {
|
|
257
|
+
const note = resolveNote(store, idOrPath);
|
|
258
|
+
if (!note) return json({ error: "Not found" }, 404);
|
|
259
|
+
const body = await req.json() as { path: string; mimeType: string };
|
|
260
|
+
if (!body.path || !body.mimeType) return json({ error: "path and mimeType are required" }, 400);
|
|
261
|
+
return json(store.addAttachment(note.id, body.path, body.mimeType), 201);
|
|
262
|
+
}
|
|
263
|
+
if (method === "GET") {
|
|
264
|
+
const note = resolveNote(store, idOrPath);
|
|
265
|
+
if (!note) return json({ error: "Not found" }, 404);
|
|
266
|
+
return json(store.getAttachments(note.id));
|
|
267
|
+
}
|
|
268
|
+
return json({ error: "Method not allowed" }, 405);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (sub !== "") return json({ error: "Not found" }, 404);
|
|
272
|
+
|
|
273
|
+
// GET /notes/:idOrPath — single note
|
|
274
|
+
if (method === "GET") {
|
|
275
|
+
const note = resolveNote(store, idOrPath);
|
|
276
|
+
if (!note) return json({ error: "Not found" }, 404);
|
|
277
|
+
const includeContent = parseBool(parseQuery(url, "include_content"), true);
|
|
278
|
+
let result: any = includeContent ? { ...note } : toNoteIndex(note);
|
|
279
|
+
result = filterMetadata(result, parseIncludeMetadata(url));
|
|
280
|
+
if (parseBool(parseQuery(url, "include_links"), false)) {
|
|
281
|
+
result.links = linkOps.getLinksHydrated(db, note.id);
|
|
282
|
+
}
|
|
283
|
+
if (parseBool(parseQuery(url, "include_attachments"), false)) {
|
|
284
|
+
result.attachments = store.getAttachments(note.id);
|
|
285
|
+
}
|
|
286
|
+
return json(result);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// PATCH /notes/:idOrPath — update (content, path, metadata, tags, links)
|
|
290
|
+
if (method === "PATCH") {
|
|
291
|
+
try {
|
|
292
|
+
const note = resolveNote(store, idOrPath);
|
|
293
|
+
if (!note) throw new NotFoundError(`Note not found: "${idOrPath}"`);
|
|
294
|
+
const body = await req.json() as any;
|
|
295
|
+
|
|
296
|
+
// Remove links first (before content update for bracket removal)
|
|
297
|
+
const linksRemove = body.links?.remove as { target: string; relationship: string }[] | undefined;
|
|
298
|
+
let contentOverride = body.content as string | undefined;
|
|
299
|
+
if (linksRemove) {
|
|
300
|
+
for (const link of linksRemove) {
|
|
301
|
+
const target = resolveNote(store, link.target);
|
|
302
|
+
if (target) {
|
|
303
|
+
store.deleteLink(note.id, target.id, link.relationship);
|
|
304
|
+
if (link.relationship === "wikilink" && target.path) {
|
|
305
|
+
const current = contentOverride ?? note.content;
|
|
306
|
+
const cleaned = removeWikilinkBrackets(current, target.path);
|
|
307
|
+
if (cleaned !== current) contentOverride = cleaned;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Core update
|
|
314
|
+
const updates: any = {};
|
|
315
|
+
if (contentOverride !== undefined) updates.content = contentOverride;
|
|
316
|
+
if (body.path !== undefined) updates.path = body.path;
|
|
317
|
+
if (body.metadata !== undefined) {
|
|
318
|
+
const existing = (note.metadata as Record<string, unknown>) ?? {};
|
|
319
|
+
updates.metadata = { ...existing, ...body.metadata };
|
|
320
|
+
}
|
|
321
|
+
if (body.created_at !== undefined || body.createdAt !== undefined) {
|
|
322
|
+
updates.created_at = body.created_at ?? body.createdAt;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (Object.keys(updates).length > 0) {
|
|
326
|
+
store.updateNote(note.id, updates);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Tags
|
|
330
|
+
if (body.tags?.add?.length) {
|
|
331
|
+
store.tagNote(note.id, body.tags.add);
|
|
332
|
+
applySchemaDefaults(store, db, [note.id], body.tags.add);
|
|
333
|
+
}
|
|
334
|
+
if (body.tags?.remove?.length) {
|
|
335
|
+
store.untagNote(note.id, body.tags.remove);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Add links
|
|
339
|
+
if (body.links?.add) {
|
|
340
|
+
for (const link of body.links.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[]) {
|
|
341
|
+
const target = resolveNote(store, link.target);
|
|
342
|
+
if (target) store.createLink(note.id, target.id, link.relationship, link.metadata);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return json(store.getNote(note.id));
|
|
347
|
+
} catch (e: any) {
|
|
348
|
+
if (e instanceof NotFoundError) return json({ error: e.message }, 404);
|
|
349
|
+
throw e;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// DELETE /notes/:idOrPath — admin only (enforced at server level)
|
|
354
|
+
if (method === "DELETE") {
|
|
355
|
+
const note = resolveNote(store, idOrPath);
|
|
356
|
+
if (!note) return json({ error: "Not found" }, 404);
|
|
357
|
+
store.deleteNote(note.id);
|
|
358
|
+
return json({ deleted: true, id: note.id });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return json({ error: "Method not allowed" }, 405);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// Tags — GET/PUT/DELETE /api/tags[/:name]
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
export async function handleTags(req: Request, store: Store, subpath = ""): Promise<Response> {
|
|
369
|
+
const url = new URL(req.url);
|
|
370
|
+
const db = (store as any).db;
|
|
371
|
+
|
|
372
|
+
// GET /tags — list all, or get single tag detail
|
|
373
|
+
if (req.method === "GET" && subpath === "") {
|
|
374
|
+
const singleTag = parseQuery(url, "tag");
|
|
375
|
+
|
|
376
|
+
if (singleTag) {
|
|
377
|
+
const allTags = store.listTags();
|
|
378
|
+
const found = allTags.find((t) => t.name === singleTag);
|
|
379
|
+
const schema = store.getTagSchema(singleTag);
|
|
380
|
+
return json({
|
|
381
|
+
name: singleTag,
|
|
382
|
+
count: found?.count ?? 0,
|
|
383
|
+
description: schema?.description ?? null,
|
|
384
|
+
fields: schema?.fields ?? null,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const tags = store.listTags();
|
|
389
|
+
if (parseBool(parseQuery(url, "include_schema"), false)) {
|
|
390
|
+
const schemas = store.getTagSchemaMap();
|
|
391
|
+
return json(tags.map((t) => ({
|
|
392
|
+
...t,
|
|
393
|
+
description: schemas[t.name]?.description ?? null,
|
|
394
|
+
fields: schemas[t.name]?.fields ?? null,
|
|
395
|
+
})));
|
|
396
|
+
}
|
|
397
|
+
return json(tags);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Routes with tag name
|
|
401
|
+
const nameMatch = subpath.match(/^\/([^/]+)$/);
|
|
402
|
+
if (!nameMatch) return json({ error: "Not found" }, 404);
|
|
403
|
+
const tagName = decodeURIComponent(nameMatch[1]);
|
|
404
|
+
|
|
405
|
+
// GET /tags/:name — single tag detail
|
|
406
|
+
if (req.method === "GET") {
|
|
407
|
+
const allTags = store.listTags();
|
|
408
|
+
const found = allTags.find((t) => t.name === tagName);
|
|
409
|
+
const schema = store.getTagSchema(tagName);
|
|
410
|
+
return json({
|
|
411
|
+
name: tagName,
|
|
412
|
+
count: found?.count ?? 0,
|
|
413
|
+
description: schema?.description ?? null,
|
|
414
|
+
fields: schema?.fields ?? null,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// PUT /tags/:name — upsert tag schema (description + fields)
|
|
419
|
+
if (req.method === "PUT") {
|
|
420
|
+
const body = await req.json() as { description?: string; fields?: Record<string, unknown> };
|
|
421
|
+
const existing = store.getTagSchema(tagName);
|
|
422
|
+
const mergedFields = { ...existing?.fields, ...(body.fields as any) };
|
|
423
|
+
const schema = store.upsertTagSchema(tagName, {
|
|
424
|
+
description: body.description ?? existing?.description,
|
|
425
|
+
fields: Object.keys(mergedFields).length > 0 ? mergedFields : undefined,
|
|
426
|
+
});
|
|
427
|
+
return json(schema);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// DELETE /tags/:name — delete tag + schema from all notes
|
|
431
|
+
if (req.method === "DELETE") {
|
|
432
|
+
store.deleteTagSchema(tagName);
|
|
433
|
+
return json(store.deleteTag(tagName));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return json({ error: "Method not allowed" }, 405);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// Find-path — GET /api/find-path?source=...&target=...
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
export function handleFindPath(req: Request, store: Store): Response {
|
|
444
|
+
if (req.method !== "GET") return json({ error: "Method not allowed" }, 405);
|
|
445
|
+
|
|
446
|
+
const url = new URL(req.url);
|
|
447
|
+
const source = parseQuery(url, "source");
|
|
448
|
+
const target = parseQuery(url, "target");
|
|
449
|
+
if (!source || !target) return json({ error: "source and target parameters are required" }, 400);
|
|
450
|
+
|
|
451
|
+
const db = (store as any).db;
|
|
452
|
+
try {
|
|
453
|
+
const sourceNote = resolveNote(store, source);
|
|
454
|
+
if (!sourceNote) return json({ error: `Note not found: "${source}"` }, 404);
|
|
455
|
+
const targetNote = resolveNote(store, target);
|
|
456
|
+
if (!targetNote) return json({ error: `Note not found: "${target}"` }, 404);
|
|
457
|
+
const maxDepth = Math.min(parseInt10(parseQuery(url, "max_depth")) ?? 5, 10);
|
|
458
|
+
|
|
459
|
+
const result = linkOps.findPath(db, sourceNote.id, targetNote.id, { max_depth: maxDepth });
|
|
460
|
+
return json(result);
|
|
461
|
+
} catch (e: any) {
|
|
462
|
+
if (e instanceof NotFoundError) return json({ error: e.message }, 404);
|
|
463
|
+
throw e;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
// Vault info — GET/PATCH /api/vault
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
export async function handleVault(
|
|
472
|
+
req: Request,
|
|
473
|
+
store: Store,
|
|
474
|
+
vaultConfig: { name: string; description?: string },
|
|
475
|
+
updateDescription?: (description: string) => void,
|
|
476
|
+
): Promise<Response> {
|
|
477
|
+
const url = new URL(req.url);
|
|
478
|
+
|
|
479
|
+
if (req.method === "GET") {
|
|
480
|
+
const result: any = {
|
|
481
|
+
name: vaultConfig.name,
|
|
482
|
+
description: vaultConfig.description ?? null,
|
|
483
|
+
};
|
|
484
|
+
if (parseBool(parseQuery(url, "include_stats"), false)) {
|
|
485
|
+
result.stats = store.getVaultStats();
|
|
486
|
+
}
|
|
487
|
+
return json(result);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (req.method === "PATCH") {
|
|
491
|
+
const body = await req.json() as { description?: string };
|
|
492
|
+
if (body.description !== undefined && updateDescription) {
|
|
493
|
+
updateDescription(body.description);
|
|
494
|
+
}
|
|
495
|
+
return json({
|
|
496
|
+
name: vaultConfig.name,
|
|
497
|
+
description: body.description ?? vaultConfig.description ?? null,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return json({ error: "Method not allowed" }, 405);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
// Unresolved wikilinks — REST-only (admin/maintenance)
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
|
|
508
|
+
export function handleUnresolvedWikilinks(req: Request, store: Store): Response {
|
|
509
|
+
const url = new URL(req.url);
|
|
510
|
+
const limitStr = url.searchParams.get("limit");
|
|
511
|
+
const limit = limitStr ? parseInt(limitStr, 10) : 50;
|
|
512
|
+
const db = (store as any).db;
|
|
513
|
+
return Response.json(listUnresolvedWikilinks(db, limit));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
// Published notes — public, no-auth HTML rendering
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
function escapeHtml(s: string): string {
|
|
521
|
+
return s
|
|
522
|
+
.replace(/&/g, "&")
|
|
523
|
+
.replace(/</g, "<")
|
|
524
|
+
.replace(/>/g, ">")
|
|
525
|
+
.replace(/"/g, """);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function renderMarkdown(md: string): string {
|
|
529
|
+
const lines = md.split("\n");
|
|
530
|
+
const out: string[] = [];
|
|
531
|
+
let inCodeBlock = false;
|
|
532
|
+
|
|
533
|
+
for (let i = 0; i < lines.length; i++) {
|
|
534
|
+
const line = lines[i];
|
|
535
|
+
|
|
536
|
+
if (line.trimStart().startsWith("```")) {
|
|
537
|
+
if (inCodeBlock) {
|
|
538
|
+
out.push("</code></pre>");
|
|
539
|
+
inCodeBlock = false;
|
|
540
|
+
} else {
|
|
541
|
+
out.push("<pre><code>");
|
|
542
|
+
inCodeBlock = true;
|
|
543
|
+
}
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (inCodeBlock) {
|
|
547
|
+
out.push(escapeHtml(line));
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const trimmed = line.trim();
|
|
552
|
+
|
|
553
|
+
if (!trimmed) {
|
|
554
|
+
out.push("");
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const headerMatch = trimmed.match(/^(#{1,6})\s+(.+)/);
|
|
559
|
+
if (headerMatch) {
|
|
560
|
+
const level = headerMatch[1].length;
|
|
561
|
+
out.push(`<h${level}>${inlineMarkdown(escapeHtml(headerMatch[2]))}</h${level}>`);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (trimmed.startsWith("- ") || trimmed.startsWith("* ")) {
|
|
566
|
+
const items: string[] = [trimmed.slice(2)];
|
|
567
|
+
while (i + 1 < lines.length) {
|
|
568
|
+
const next = lines[i + 1].trim();
|
|
569
|
+
if (next.startsWith("- ") || next.startsWith("* ")) {
|
|
570
|
+
items.push(next.slice(2));
|
|
571
|
+
i++;
|
|
572
|
+
} else break;
|
|
573
|
+
}
|
|
574
|
+
out.push("<ul>");
|
|
575
|
+
for (const item of items) {
|
|
576
|
+
out.push(`<li>${inlineMarkdown(escapeHtml(item))}</li>`);
|
|
577
|
+
}
|
|
578
|
+
out.push("</ul>");
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
out.push(`<p>${inlineMarkdown(escapeHtml(trimmed))}</p>`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (inCodeBlock) out.push("</code></pre>");
|
|
586
|
+
return out.join("\n");
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function inlineMarkdown(html: string): string {
|
|
590
|
+
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
|
591
|
+
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
|
|
592
|
+
html = html.replace(/`(.+?)`/g, "<code>$1</code>");
|
|
593
|
+
html = html.replace(/\[(.+?)\]\((.+?)\)/g, (_match, text, url) => {
|
|
594
|
+
const decoded = url.replace(/&/g, "&");
|
|
595
|
+
if (/^(https?:|mailto:|#|\/)/i.test(decoded)) {
|
|
596
|
+
return `<a href="${url}">${text}</a>`;
|
|
597
|
+
}
|
|
598
|
+
return text;
|
|
599
|
+
});
|
|
600
|
+
return html;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function isNotePublished(note: { tags?: string[]; metadata?: unknown }, publishedTag: string = "publish"): boolean {
|
|
604
|
+
if (note.tags?.includes(publishedTag)) return true;
|
|
605
|
+
const meta = note.metadata as Record<string, unknown> | undefined;
|
|
606
|
+
if (meta?.published === true) return true;
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* GET /view/:idOrPath — serve a note as clean HTML.
|
|
612
|
+
* Supports ID or path resolution.
|
|
613
|
+
*/
|
|
614
|
+
export function handleViewNote(
|
|
615
|
+
store: Store,
|
|
616
|
+
idOrPath: string,
|
|
617
|
+
options: { authenticated?: boolean; publishedTag?: string } = {},
|
|
618
|
+
): Response {
|
|
619
|
+
const { authenticated = false, publishedTag = "publish" } = options;
|
|
620
|
+
const note = resolveNote(store, idOrPath);
|
|
621
|
+
if (!note) {
|
|
622
|
+
return new Response("Not Found", { status: 404, headers: { "Content-Type": "text/plain" } });
|
|
623
|
+
}
|
|
624
|
+
if (!authenticated && !isNotePublished(note, publishedTag)) {
|
|
625
|
+
return new Response("Not Found", { status: 404, headers: { "Content-Type": "text/plain" } });
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const title = note.path?.split("/").pop()?.replace(/\.[^.]+$/, "") ?? note.id;
|
|
629
|
+
const rendered = renderMarkdown(note.content);
|
|
630
|
+
const html = `<!DOCTYPE html>
|
|
631
|
+
<html lang="en">
|
|
632
|
+
<head>
|
|
633
|
+
<meta charset="utf-8">
|
|
634
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
635
|
+
<title>${escapeHtml(title)}</title>
|
|
636
|
+
<style>
|
|
637
|
+
body {
|
|
638
|
+
max-width: 42rem;
|
|
639
|
+
margin: 2rem auto;
|
|
640
|
+
padding: 0 1rem;
|
|
641
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
642
|
+
line-height: 1.6;
|
|
643
|
+
color: #1a1a1a;
|
|
644
|
+
}
|
|
645
|
+
pre {
|
|
646
|
+
background: #f5f5f5;
|
|
647
|
+
padding: 1rem;
|
|
648
|
+
border-radius: 4px;
|
|
649
|
+
overflow-x: auto;
|
|
650
|
+
}
|
|
651
|
+
code {
|
|
652
|
+
font-size: 0.9em;
|
|
653
|
+
background: #f5f5f5;
|
|
654
|
+
padding: 0.15em 0.3em;
|
|
655
|
+
border-radius: 3px;
|
|
656
|
+
}
|
|
657
|
+
pre code {
|
|
658
|
+
background: none;
|
|
659
|
+
padding: 0;
|
|
660
|
+
}
|
|
661
|
+
a { color: #0066cc; }
|
|
662
|
+
h1, h2, h3, h4, h5, h6 { margin-top: 1.5em; margin-bottom: 0.5em; }
|
|
663
|
+
ul { padding-left: 1.5em; }
|
|
664
|
+
@media (prefers-color-scheme: dark) {
|
|
665
|
+
body { background: #1a1a1a; color: #e0e0e0; }
|
|
666
|
+
pre, code { background: #2a2a2a; }
|
|
667
|
+
a { color: #66b3ff; }
|
|
668
|
+
}
|
|
669
|
+
</style>
|
|
670
|
+
</head>
|
|
671
|
+
<body>
|
|
672
|
+
${rendered}
|
|
673
|
+
</body>
|
|
674
|
+
</html>`;
|
|
675
|
+
|
|
676
|
+
return new Response(html, {
|
|
677
|
+
status: 200,
|
|
678
|
+
headers: {
|
|
679
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
680
|
+
"Content-Security-Policy": "default-src 'self'; script-src 'none'; style-src 'unsafe-inline'",
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
// Storage (file upload/serve) — kept as-is, Daily needs it
|
|
687
|
+
// ---------------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
export function assetsDir(vault: string): string {
|
|
690
|
+
return process.env.ASSETS_DIR ?? join(vaultDir(vault), "assets");
|
|
691
|
+
}
|
|
692
|
+
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
|
693
|
+
|
|
694
|
+
const ALLOWED_EXTENSIONS = new Set([
|
|
695
|
+
".wav", ".mp3", ".m4a", ".ogg", ".webm",
|
|
696
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp",
|
|
697
|
+
]);
|
|
698
|
+
|
|
699
|
+
const MIME_TYPES: Record<string, string> = {
|
|
700
|
+
".wav": "audio/wav",
|
|
701
|
+
".mp3": "audio/mpeg",
|
|
702
|
+
".m4a": "audio/mp4",
|
|
703
|
+
".ogg": "audio/ogg",
|
|
704
|
+
".webm": "audio/webm",
|
|
705
|
+
".png": "image/png",
|
|
706
|
+
".jpg": "image/jpeg",
|
|
707
|
+
".jpeg": "image/jpeg",
|
|
708
|
+
".gif": "image/gif",
|
|
709
|
+
".webp": "image/webp",
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
export async function handleStorage(req: Request, path: string, vault: string): Promise<Response> {
|
|
713
|
+
const assets = assetsDir(vault);
|
|
714
|
+
|
|
715
|
+
if (req.method === "POST" && path === "/upload") {
|
|
716
|
+
const form = await req.formData();
|
|
717
|
+
const file = form.get("file");
|
|
718
|
+
if (!(file instanceof File)) {
|
|
719
|
+
return json({ error: "file is required" }, 400);
|
|
720
|
+
}
|
|
721
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
722
|
+
return json({ error: `File too large (${Math.round(file.size / 1024 / 1024)}MB). Max: 100MB` }, 413);
|
|
723
|
+
}
|
|
724
|
+
const ext = extname(file.name).toLowerCase();
|
|
725
|
+
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
|
726
|
+
return json({ error: `File type ${ext} not allowed` }, 400);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const date = new Date().toISOString().split("T")[0];
|
|
730
|
+
const dir = join(assets, date);
|
|
731
|
+
mkdirSync(dir, { recursive: true });
|
|
732
|
+
|
|
733
|
+
const filename = `${Date.now()}-${crypto.randomUUID()}${ext}`;
|
|
734
|
+
const filePath = join(dir, filename);
|
|
735
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
736
|
+
writeFileSync(filePath, buffer);
|
|
737
|
+
|
|
738
|
+
const relativePath = `${date}/${filename}`;
|
|
739
|
+
const mimeType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
740
|
+
|
|
741
|
+
return json({ path: relativePath, size: buffer.length, mimeType }, 201);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const fileMatch = path.match(/^\/([^/]+)\/(.+)$/);
|
|
745
|
+
if (req.method === "GET" && fileMatch) {
|
|
746
|
+
const reqPath = `${fileMatch[1]}/${fileMatch[2]}`;
|
|
747
|
+
const filePath = normalize(join(assets, reqPath));
|
|
748
|
+
|
|
749
|
+
if (!filePath.startsWith(normalize(assets))) {
|
|
750
|
+
return json({ error: "Invalid path" }, 403);
|
|
751
|
+
}
|
|
752
|
+
if (!existsSync(filePath)) {
|
|
753
|
+
return json({ error: "Not found" }, 404);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const stat = statSync(filePath);
|
|
757
|
+
const ext = extname(filePath).toLowerCase();
|
|
758
|
+
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
759
|
+
const fileBuffer = readFileSync(filePath);
|
|
760
|
+
|
|
761
|
+
return new Response(fileBuffer, {
|
|
762
|
+
headers: {
|
|
763
|
+
"Content-Type": contentType,
|
|
764
|
+
"Content-Length": String(stat.size),
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return json({ error: "Not found" }, 404);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// ---------------------------------------------------------------------------
|
|
773
|
+
// Tag schema defaults — same logic as core/src/mcp.ts applySchemaDefaults
|
|
774
|
+
// ---------------------------------------------------------------------------
|
|
775
|
+
|
|
776
|
+
function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: string[]): void {
|
|
777
|
+
const schemas = tagSchemaOps.getTagSchemaMap(db);
|
|
778
|
+
if (Object.keys(schemas).length === 0) return;
|
|
779
|
+
|
|
780
|
+
const defaults: Record<string, unknown> = {};
|
|
781
|
+
for (const tag of tags) {
|
|
782
|
+
const schema = schemas[tag];
|
|
783
|
+
if (!schema?.fields) continue;
|
|
784
|
+
for (const [field, fieldSchema] of Object.entries(schema.fields)) {
|
|
785
|
+
if (!(field in defaults)) {
|
|
786
|
+
defaults[field] = defaultForField(fieldSchema);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (Object.keys(defaults).length === 0) return;
|
|
791
|
+
|
|
792
|
+
for (const noteId of noteIds) {
|
|
793
|
+
const note = store.getNote(noteId);
|
|
794
|
+
if (!note) continue;
|
|
795
|
+
const existing = (note.metadata as Record<string, unknown>) ?? {};
|
|
796
|
+
const missing: Record<string, unknown> = {};
|
|
797
|
+
for (const [field, value] of Object.entries(defaults)) {
|
|
798
|
+
if (!(field in existing)) missing[field] = value;
|
|
799
|
+
}
|
|
800
|
+
if (Object.keys(missing).length === 0) continue;
|
|
801
|
+
store.updateNote(noteId, {
|
|
802
|
+
metadata: { ...existing, ...missing },
|
|
803
|
+
skipUpdatedAt: true,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function defaultForField(field: { type: string; enum?: string[] }): unknown {
|
|
809
|
+
if (field.enum && field.enum.length > 0) return field.enum[0];
|
|
810
|
+
switch (field.type) {
|
|
811
|
+
case "boolean": return false;
|
|
812
|
+
case "integer": return 0;
|
|
813
|
+
default: return "";
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function removeWikilinkBrackets(content: string, targetPath: string): string {
|
|
818
|
+
const escaped = targetPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
819
|
+
content = content.replace(new RegExp(`\\[\\[${escaped}\\|([^\\]]+)\\]\\]`, "gi"), "$1");
|
|
820
|
+
content = content.replace(new RegExp(`\\[\\[${escaped}(#[^\\]]+)?\\]\\]`, "gi"), `${targetPath}$1`);
|
|
821
|
+
return content;
|
|
822
|
+
}
|