vaultmd 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/LICENSE +21 -0
- package/README.md +254 -0
- package/dist/index.d.ts +253 -0
- package/dist/index.js +1787 -0
- package/package.json +63 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1787 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var MdVaultError = class extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message, options) {
|
|
5
|
+
super(message, { cause: options?.cause });
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "MdVaultError";
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// src/frontmatter/edit.ts
|
|
12
|
+
import { Document, parseDocument } from "yaml";
|
|
13
|
+
|
|
14
|
+
// src/frontmatter/parse.ts
|
|
15
|
+
import { parse } from "yaml";
|
|
16
|
+
|
|
17
|
+
// src/frontmatter/tags.ts
|
|
18
|
+
function toTagTokens(value) {
|
|
19
|
+
if (value === null || value === void 0) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
return value.flatMap(toTagTokens);
|
|
24
|
+
}
|
|
25
|
+
if (typeof value === "string") {
|
|
26
|
+
return value.split(/[\s,]+/).map((t) => t.trim()).filter(Boolean);
|
|
27
|
+
}
|
|
28
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
29
|
+
return [String(value)];
|
|
30
|
+
}
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
function deriveTags(frontmatter) {
|
|
34
|
+
const source = frontmatter.tags !== void 0 ? frontmatter.tags : frontmatter.tag;
|
|
35
|
+
const seen = /* @__PURE__ */ new Set();
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const token of toTagTokens(source)) {
|
|
38
|
+
const stripped = token.replace(/^#+/, "");
|
|
39
|
+
if (stripped && !seen.has(stripped)) {
|
|
40
|
+
seen.add(stripped);
|
|
41
|
+
out.push(stripped);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/frontmatter/validate.ts
|
|
48
|
+
function isScalar(value) {
|
|
49
|
+
return value === null || value instanceof Date || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
50
|
+
}
|
|
51
|
+
function isScalarOrArrayOfScalar(value) {
|
|
52
|
+
if (Array.isArray(value)) {
|
|
53
|
+
return value.every(isScalar);
|
|
54
|
+
}
|
|
55
|
+
return isScalar(value);
|
|
56
|
+
}
|
|
57
|
+
function isFlatFrontmatter(fm) {
|
|
58
|
+
for (const value of Object.values(fm)) {
|
|
59
|
+
if (!isScalarOrArrayOfScalar(value)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// src/frontmatter/parse.ts
|
|
67
|
+
function extractBlock(content) {
|
|
68
|
+
const firstNl = content.indexOf("\n");
|
|
69
|
+
if (firstNl === -1) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
if (content.slice(0, firstNl).replace(/\r$/, "") !== "---") {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const lines = content.slice(firstNl + 1).split("\n");
|
|
76
|
+
for (let i = 0; i < lines.length; i++) {
|
|
77
|
+
if (lines[i].replace(/\r$/, "") === "---") {
|
|
78
|
+
const yaml = lines.slice(0, i).join("\n");
|
|
79
|
+
const body = lines.slice(i + 1).join("\n");
|
|
80
|
+
return { yaml, body };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
function parseFrontmatter(content) {
|
|
86
|
+
const block = extractBlock(content);
|
|
87
|
+
if (!block) {
|
|
88
|
+
return { frontmatter: {}, tags: [], body: content, valid: "none" };
|
|
89
|
+
}
|
|
90
|
+
const { yaml: yamlText, body } = block;
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = parse(yamlText, { uniqueKeys: false });
|
|
94
|
+
} catch {
|
|
95
|
+
return { frontmatter: {}, tags: [], body, valid: "present-but-invalid" };
|
|
96
|
+
}
|
|
97
|
+
if (parsed === null || parsed === void 0) {
|
|
98
|
+
return { frontmatter: {}, tags: [], body, valid: "flat" };
|
|
99
|
+
}
|
|
100
|
+
if (typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
101
|
+
return { frontmatter: {}, tags: [], body, valid: "present-but-invalid" };
|
|
102
|
+
}
|
|
103
|
+
const frontmatter = parsed;
|
|
104
|
+
const valid = isFlatFrontmatter(frontmatter) ? "flat" : "present-but-invalid";
|
|
105
|
+
return { frontmatter, tags: deriveTags(frontmatter), body, valid };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// src/frontmatter/edit.ts
|
|
109
|
+
function editFrontmatter(content, mutate) {
|
|
110
|
+
const parsed = parseFrontmatter(content);
|
|
111
|
+
if (parsed.valid === "present-but-invalid") {
|
|
112
|
+
return { content, outcome: "unverifiable" };
|
|
113
|
+
}
|
|
114
|
+
if (parsed.valid === "none") {
|
|
115
|
+
const view2 = {};
|
|
116
|
+
mutate(view2);
|
|
117
|
+
if (!isFlatFrontmatter(view2)) {
|
|
118
|
+
return { content, outcome: "unverifiable" };
|
|
119
|
+
}
|
|
120
|
+
if (Object.keys(view2).length === 0) {
|
|
121
|
+
return { content, outcome: "unchanged" };
|
|
122
|
+
}
|
|
123
|
+
const block2 = String(new Document(view2)).replace(/\n$/, "");
|
|
124
|
+
return { content: `---
|
|
125
|
+
${block2}
|
|
126
|
+
---
|
|
127
|
+
${content}`, outcome: "edited" };
|
|
128
|
+
}
|
|
129
|
+
const ext = extractBlock(content);
|
|
130
|
+
if (!ext) {
|
|
131
|
+
return { content, outcome: "unverifiable" };
|
|
132
|
+
}
|
|
133
|
+
const doc = parseDocument(ext.yaml, { uniqueKeys: false });
|
|
134
|
+
const before = doc.toJS() ?? {};
|
|
135
|
+
const view = structuredClone(before);
|
|
136
|
+
mutate(view);
|
|
137
|
+
if (!isFlatFrontmatter(view)) {
|
|
138
|
+
return { content, outcome: "unverifiable" };
|
|
139
|
+
}
|
|
140
|
+
let changed = false;
|
|
141
|
+
for (const key of Object.keys(before)) {
|
|
142
|
+
if (!(key in view)) {
|
|
143
|
+
doc.delete(key);
|
|
144
|
+
changed = true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const key of Object.keys(view)) {
|
|
148
|
+
if (!(key in before) || JSON.stringify(before[key]) !== JSON.stringify(view[key])) {
|
|
149
|
+
doc.set(key, view[key]);
|
|
150
|
+
changed = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!changed) {
|
|
154
|
+
return { content, outcome: "unchanged" };
|
|
155
|
+
}
|
|
156
|
+
const serialized = String(doc);
|
|
157
|
+
const block = serialized.endsWith("\n") ? serialized.slice(0, -1) : serialized;
|
|
158
|
+
return { content: `---
|
|
159
|
+
${block}
|
|
160
|
+
---
|
|
161
|
+
${ext.body}`, outcome: "edited" };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// src/links/extract.ts
|
|
165
|
+
function stripFencedCode(content) {
|
|
166
|
+
const lines = content.split("\n");
|
|
167
|
+
const out = [];
|
|
168
|
+
let inFence = false;
|
|
169
|
+
for (const line of lines) {
|
|
170
|
+
if (/^[ \t]*(```|~~~)/.test(line)) {
|
|
171
|
+
inFence = !inFence;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (!inFence) {
|
|
175
|
+
out.push(line);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out.join("\n");
|
|
179
|
+
}
|
|
180
|
+
function mdLinkUrl(raw) {
|
|
181
|
+
const t = raw.trim();
|
|
182
|
+
if (t.startsWith("<")) {
|
|
183
|
+
const end = t.indexOf(">");
|
|
184
|
+
return (end >= 0 ? t.slice(1, end) : t.slice(1)).trim();
|
|
185
|
+
}
|
|
186
|
+
return t.split(/\s+/)[0];
|
|
187
|
+
}
|
|
188
|
+
function extractLinks(content) {
|
|
189
|
+
const src = stripFencedCode(content);
|
|
190
|
+
const wikilinks = [];
|
|
191
|
+
const embeds = [];
|
|
192
|
+
const mdLinks = [];
|
|
193
|
+
for (const m of src.matchAll(/(!?)\[\[([^\]\n]+)\]\]/g)) {
|
|
194
|
+
const raw = m[2].trim();
|
|
195
|
+
if (!raw) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (m[1] === "!") {
|
|
199
|
+
embeds.push(raw);
|
|
200
|
+
} else {
|
|
201
|
+
wikilinks.push(raw);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
for (const m of src.matchAll(/(?<!!)\[[^\]]*\]\(([^)]+)\)/g)) {
|
|
205
|
+
const url = mdLinkUrl(m[1]);
|
|
206
|
+
if (!url) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
mdLinks.push(url);
|
|
210
|
+
}
|
|
211
|
+
return { wikilinks, embeds, mdLinks };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/links/resolve.ts
|
|
215
|
+
import { posix } from "path";
|
|
216
|
+
function normalizeWikiTarget(raw) {
|
|
217
|
+
let t = raw;
|
|
218
|
+
const pipe = t.indexOf("|");
|
|
219
|
+
if (pipe >= 0) {
|
|
220
|
+
t = t.slice(0, pipe);
|
|
221
|
+
}
|
|
222
|
+
const hash = t.indexOf("#");
|
|
223
|
+
if (hash >= 0) {
|
|
224
|
+
t = t.slice(0, hash);
|
|
225
|
+
}
|
|
226
|
+
t = t.trim().replace(/\\/g, "/").normalize("NFC");
|
|
227
|
+
if (t.startsWith("./")) {
|
|
228
|
+
t = t.slice(2);
|
|
229
|
+
}
|
|
230
|
+
t = t.replace(/\.md$/i, "");
|
|
231
|
+
return t;
|
|
232
|
+
}
|
|
233
|
+
function resolveRelativeTarget(raw, srcDir) {
|
|
234
|
+
let t = raw.trim();
|
|
235
|
+
if (!t) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(t)) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
if (t.startsWith("#")) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
t = t.split("#")[0];
|
|
245
|
+
if (!t) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
if (t.startsWith("/")) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
let resolved = posix.normalize(posix.join(srcDir, t)).normalize("NFC");
|
|
252
|
+
if (resolved.startsWith("../") || resolved === "..") {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
if (resolved.startsWith("./")) {
|
|
256
|
+
resolved = resolved.slice(2);
|
|
257
|
+
}
|
|
258
|
+
if (!resolved) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
if (!/\.md$/i.test(resolved)) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
return resolved;
|
|
265
|
+
}
|
|
266
|
+
function storedLinksFor(content, srcRel, mode) {
|
|
267
|
+
const links = extractLinks(content);
|
|
268
|
+
const out = [];
|
|
269
|
+
if (mode === "wikilink") {
|
|
270
|
+
const push = (raw, kind) => {
|
|
271
|
+
const target = normalizeWikiTarget(raw);
|
|
272
|
+
if (!target) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const base = (target.split("/").pop() ?? target).toLowerCase();
|
|
276
|
+
out.push({ target, base, kind });
|
|
277
|
+
};
|
|
278
|
+
for (const w of links.wikilinks) {
|
|
279
|
+
push(w, "wikilink");
|
|
280
|
+
}
|
|
281
|
+
for (const e of links.embeds) {
|
|
282
|
+
push(e, "embed");
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
const srcDir = posix.dirname(
|
|
287
|
+
srcRel.trim().replace(/\\/g, "/").normalize("NFC")
|
|
288
|
+
);
|
|
289
|
+
for (const raw of links.mdLinks) {
|
|
290
|
+
const target = resolveRelativeTarget(raw, srcDir);
|
|
291
|
+
if (!target) {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
out.push({ target, base: null, kind: "mdlink" });
|
|
295
|
+
}
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// src/fs-atomic/atomic-write.ts
|
|
300
|
+
import { randomBytes } from "crypto";
|
|
301
|
+
import { link, mkdir, rename, stat as stat2, unlink, writeFile } from "fs/promises";
|
|
302
|
+
import * as path from "path";
|
|
303
|
+
|
|
304
|
+
// src/fs-atomic/sig.ts
|
|
305
|
+
import { stat } from "fs/promises";
|
|
306
|
+
function makeSig(st) {
|
|
307
|
+
return { mtimeMs: Math.trunc(st.mtimeMs), size: st.size };
|
|
308
|
+
}
|
|
309
|
+
function sigsEqual(a, b) {
|
|
310
|
+
return a.mtimeMs === b.mtimeMs && a.size === b.size;
|
|
311
|
+
}
|
|
312
|
+
async function statSig(fullPath) {
|
|
313
|
+
try {
|
|
314
|
+
return makeSig(await stat(fullPath));
|
|
315
|
+
} catch (err) {
|
|
316
|
+
if (err.code === "ENOENT") {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
throw err;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// src/fs-atomic/atomic-write.ts
|
|
324
|
+
function tempPath(fullPath) {
|
|
325
|
+
const dir = path.dirname(fullPath);
|
|
326
|
+
const base = path.basename(fullPath);
|
|
327
|
+
return path.join(
|
|
328
|
+
dir,
|
|
329
|
+
`.${base}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
async function atomicWrite(fullPath, content) {
|
|
333
|
+
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
334
|
+
const tmp = tempPath(fullPath);
|
|
335
|
+
await writeFile(tmp, content, "utf8");
|
|
336
|
+
try {
|
|
337
|
+
await rename(tmp, fullPath);
|
|
338
|
+
} catch (err) {
|
|
339
|
+
await unlink(tmp).catch(() => {
|
|
340
|
+
});
|
|
341
|
+
throw err;
|
|
342
|
+
}
|
|
343
|
+
return makeSig(await stat2(fullPath));
|
|
344
|
+
}
|
|
345
|
+
async function atomicWriteIfUnchanged(fullPath, content, expected) {
|
|
346
|
+
const tmp = tempPath(fullPath);
|
|
347
|
+
await writeFile(tmp, content, "utf8");
|
|
348
|
+
try {
|
|
349
|
+
const current = await statSig(fullPath);
|
|
350
|
+
if (!current || !sigsEqual(current, expected)) {
|
|
351
|
+
throw new MdVaultError(
|
|
352
|
+
"MTIME_CONFLICT",
|
|
353
|
+
`file changed under write: ${fullPath}`
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
await rename(tmp, fullPath);
|
|
357
|
+
} catch (err) {
|
|
358
|
+
await unlink(tmp).catch(() => {
|
|
359
|
+
});
|
|
360
|
+
throw err;
|
|
361
|
+
}
|
|
362
|
+
return makeSig(await stat2(fullPath));
|
|
363
|
+
}
|
|
364
|
+
async function exclusiveCreate(fullPath, content) {
|
|
365
|
+
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
366
|
+
const tmp = tempPath(fullPath);
|
|
367
|
+
await writeFile(tmp, content, "utf8");
|
|
368
|
+
try {
|
|
369
|
+
await link(tmp, fullPath);
|
|
370
|
+
} catch (err) {
|
|
371
|
+
await unlink(tmp).catch(() => {
|
|
372
|
+
});
|
|
373
|
+
if (err.code === "EEXIST") {
|
|
374
|
+
throw new MdVaultError("ALREADY_EXISTS", `already exists: ${fullPath}`, {
|
|
375
|
+
cause: err
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
throw err;
|
|
379
|
+
}
|
|
380
|
+
await unlink(tmp).catch(() => {
|
|
381
|
+
});
|
|
382
|
+
return makeSig(await stat2(fullPath));
|
|
383
|
+
}
|
|
384
|
+
async function unlinkIfUnchanged(fullPath, expected) {
|
|
385
|
+
const current = await statSig(fullPath);
|
|
386
|
+
if (!current) {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
if (!sigsEqual(current, expected)) {
|
|
390
|
+
throw new MdVaultError(
|
|
391
|
+
"MTIME_CONFLICT",
|
|
392
|
+
`file changed before delete: ${fullPath}`
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
await unlink(fullPath);
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/fs-atomic/read-consistent.ts
|
|
400
|
+
import { readFile } from "fs/promises";
|
|
401
|
+
async function readConsistent(fullPath) {
|
|
402
|
+
for (; ; ) {
|
|
403
|
+
const sig1 = await statSig(fullPath);
|
|
404
|
+
if (sig1 === null) {
|
|
405
|
+
return { content: null, sig: null };
|
|
406
|
+
}
|
|
407
|
+
let content;
|
|
408
|
+
try {
|
|
409
|
+
content = await readFile(fullPath, "utf8");
|
|
410
|
+
} catch (err) {
|
|
411
|
+
if (err.code === "ENOENT") {
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
throw err;
|
|
415
|
+
}
|
|
416
|
+
const sig2 = await statSig(fullPath);
|
|
417
|
+
if (sig2 !== null && sigsEqual(sig1, sig2)) {
|
|
418
|
+
return { content, sig: sig2 };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// src/locks/cross-process.ts
|
|
424
|
+
import { createHash } from "crypto";
|
|
425
|
+
import { mkdir as mkdir2, readFile as readFile2, unlink as unlink2, writeFile as writeFile2 } from "fs/promises";
|
|
426
|
+
import { hostname } from "os";
|
|
427
|
+
import * as path2 from "path";
|
|
428
|
+
function delay(ms) {
|
|
429
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
430
|
+
}
|
|
431
|
+
async function tryReclaim(lockfile, payload) {
|
|
432
|
+
let holder;
|
|
433
|
+
try {
|
|
434
|
+
holder = JSON.parse(await readFile2(lockfile, "utf8"));
|
|
435
|
+
} catch {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
if (holder.host !== hostname() || typeof holder.pid !== "number") {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
process.kill(holder.pid, 0);
|
|
443
|
+
return false;
|
|
444
|
+
} catch (err) {
|
|
445
|
+
if (err.code !== "ESRCH") {
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
await unlink2(lockfile).catch(() => {
|
|
450
|
+
});
|
|
451
|
+
try {
|
|
452
|
+
await writeFile2(lockfile, payload, { flag: "wx" });
|
|
453
|
+
return true;
|
|
454
|
+
} catch {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
async function withCrossProcessLock(lockDir, key, busyTimeoutMs, fn) {
|
|
459
|
+
await mkdir2(lockDir, { recursive: true });
|
|
460
|
+
const lockfile = path2.join(
|
|
461
|
+
lockDir,
|
|
462
|
+
`${createHash("sha256").update(key).digest("hex")}.lock`
|
|
463
|
+
);
|
|
464
|
+
const payload = JSON.stringify({
|
|
465
|
+
pid: process.pid,
|
|
466
|
+
host: hostname(),
|
|
467
|
+
createdAt: Date.now()
|
|
468
|
+
});
|
|
469
|
+
const deadline = Date.now() + busyTimeoutMs;
|
|
470
|
+
for (; ; ) {
|
|
471
|
+
try {
|
|
472
|
+
await writeFile2(lockfile, payload, { flag: "wx" });
|
|
473
|
+
break;
|
|
474
|
+
} catch (err) {
|
|
475
|
+
if (err.code !== "EEXIST") {
|
|
476
|
+
throw err;
|
|
477
|
+
}
|
|
478
|
+
if (await tryReclaim(lockfile, payload)) {
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
if (Date.now() >= deadline) {
|
|
482
|
+
throw new MdVaultError(
|
|
483
|
+
"MTIME_CONFLICT",
|
|
484
|
+
`cross-process lock busy: ${lockfile}`
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
await delay(50);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
try {
|
|
491
|
+
return await fn();
|
|
492
|
+
} finally {
|
|
493
|
+
await unlink2(lockfile).catch(() => {
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// src/locks/in-process.ts
|
|
499
|
+
var fileLocks = /* @__PURE__ */ new Map();
|
|
500
|
+
async function withFileLock(key, fn) {
|
|
501
|
+
const prev = fileLocks.get(key) ?? Promise.resolve();
|
|
502
|
+
let release;
|
|
503
|
+
const gate = new Promise((resolve) => {
|
|
504
|
+
release = resolve;
|
|
505
|
+
});
|
|
506
|
+
const mine = prev.then(() => gate);
|
|
507
|
+
fileLocks.set(key, mine);
|
|
508
|
+
await prev;
|
|
509
|
+
try {
|
|
510
|
+
return await fn();
|
|
511
|
+
} finally {
|
|
512
|
+
release();
|
|
513
|
+
if (fileLocks.get(key) === mine) {
|
|
514
|
+
fileLocks.delete(key);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// src/locked-file/commit.ts
|
|
520
|
+
async function emitCommit(onCommit, event) {
|
|
521
|
+
if (!onCommit) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
try {
|
|
525
|
+
await onCommit(event);
|
|
526
|
+
} catch (cause) {
|
|
527
|
+
throw new MdVaultError(
|
|
528
|
+
"COMMIT_FAILED",
|
|
529
|
+
`onCommit failed for ${event.path}`,
|
|
530
|
+
{ cause }
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// src/locked-file/delete.ts
|
|
536
|
+
function withFileDelete(fullPath, lockKey, relForCommit, opts = {}) {
|
|
537
|
+
const { onCommit, cross = false } = opts;
|
|
538
|
+
const run = async () => {
|
|
539
|
+
const sig = await statSig(fullPath);
|
|
540
|
+
if (sig === null) {
|
|
541
|
+
return { deleted: false };
|
|
542
|
+
}
|
|
543
|
+
const removed = await unlinkIfUnchanged(fullPath, sig);
|
|
544
|
+
if (!removed) {
|
|
545
|
+
return { deleted: false };
|
|
546
|
+
}
|
|
547
|
+
await emitCommit(onCommit, { op: "delete", path: relForCommit });
|
|
548
|
+
return { deleted: true };
|
|
549
|
+
};
|
|
550
|
+
const locked = () => withFileLock(lockKey, run);
|
|
551
|
+
if (cross) {
|
|
552
|
+
return withCrossProcessLock(
|
|
553
|
+
cross.lockDir,
|
|
554
|
+
lockKey,
|
|
555
|
+
cross.busyTimeoutMs,
|
|
556
|
+
locked
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
return locked();
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// src/locked-file/transform.ts
|
|
563
|
+
function withFileTransform(fullPath, lockKey, relForCommit, transform, opts = {}) {
|
|
564
|
+
const { allowCreate = false, onCommit, maxRetries = 3, cross = false } = opts;
|
|
565
|
+
const run = async () => {
|
|
566
|
+
let attempt = 0;
|
|
567
|
+
for (; ; ) {
|
|
568
|
+
const read = await readConsistent(fullPath);
|
|
569
|
+
const next = transform(read.content);
|
|
570
|
+
if (next === null) {
|
|
571
|
+
return { content: read.content, outcome: "unchanged" };
|
|
572
|
+
}
|
|
573
|
+
if (read.content === null) {
|
|
574
|
+
if (!allowCreate) {
|
|
575
|
+
throw new MdVaultError(
|
|
576
|
+
"REFUSE_CREATE",
|
|
577
|
+
`refusing to create missing file: ${relForCommit}`
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
await atomicWrite(fullPath, next);
|
|
581
|
+
await emitCommit(onCommit, {
|
|
582
|
+
op: "create",
|
|
583
|
+
path: relForCommit,
|
|
584
|
+
content: next
|
|
585
|
+
});
|
|
586
|
+
return { content: next, outcome: "created" };
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
await atomicWriteIfUnchanged(fullPath, next, read.sig);
|
|
590
|
+
} catch (err) {
|
|
591
|
+
if (err instanceof MdVaultError && err.code === "MTIME_CONFLICT" && attempt < maxRetries) {
|
|
592
|
+
await Bun.sleep(50 * (attempt + 1));
|
|
593
|
+
attempt++;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
throw err;
|
|
597
|
+
}
|
|
598
|
+
await emitCommit(onCommit, {
|
|
599
|
+
op: "update",
|
|
600
|
+
path: relForCommit,
|
|
601
|
+
content: next
|
|
602
|
+
});
|
|
603
|
+
return { content: next, outcome: "updated" };
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
const locked = () => withFileLock(lockKey, run);
|
|
607
|
+
if (cross) {
|
|
608
|
+
return withCrossProcessLock(
|
|
609
|
+
cross.lockDir,
|
|
610
|
+
lockKey,
|
|
611
|
+
cross.busyTimeoutMs,
|
|
612
|
+
locked
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
return locked();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/vault/create-vault.ts
|
|
619
|
+
import { dirname as dirname3 } from "path";
|
|
620
|
+
|
|
621
|
+
// src/note-index/project.ts
|
|
622
|
+
import { basename as basename2 } from "path";
|
|
623
|
+
function deriveTitle(frontmatter, body, rel) {
|
|
624
|
+
const fmTitle = frontmatter.title;
|
|
625
|
+
if (typeof fmTitle === "string" && fmTitle.trim() !== "") {
|
|
626
|
+
return fmTitle;
|
|
627
|
+
}
|
|
628
|
+
for (const line of body.split("\n")) {
|
|
629
|
+
const match = /^#\s+(.+?)\s*$/.exec(line);
|
|
630
|
+
if (match) {
|
|
631
|
+
return match[1];
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
return basename2(rel).replace(/\.md$/i, "");
|
|
635
|
+
}
|
|
636
|
+
function projectRow(content, rel, vaultIo, cfg) {
|
|
637
|
+
const path3 = vaultIo.toVaultRelative(rel);
|
|
638
|
+
const pathKey = vaultIo.toKey(rel);
|
|
639
|
+
const parsed = parseFrontmatter(content);
|
|
640
|
+
const tags = deriveTags(parsed.frontmatter);
|
|
641
|
+
const title = deriveTitle(parsed.frontmatter, parsed.body, path3);
|
|
642
|
+
const links = storedLinksFor(content, path3, cfg.linkResolution);
|
|
643
|
+
const frontmatterJson = JSON.stringify(parsed.frontmatter);
|
|
644
|
+
return { path: path3, pathKey, title, frontmatterJson, tags, links };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// src/note-index/index-note.ts
|
|
648
|
+
function indexNote(db, vaultIo, cfg, rel, content, sig) {
|
|
649
|
+
const row = projectRow(content, rel, vaultIo, cfg);
|
|
650
|
+
const body = parseFrontmatter(content).body;
|
|
651
|
+
const tx = db.transaction(() => {
|
|
652
|
+
const existing = db.query("SELECT id FROM notes WHERE path_key = ?").get(row.pathKey);
|
|
653
|
+
let id;
|
|
654
|
+
if (existing) {
|
|
655
|
+
id = existing.id;
|
|
656
|
+
db.query(
|
|
657
|
+
"UPDATE notes SET path = ?, mtime_ms = ?, size = ?, title = ?, frontmatter = ? WHERE id = ?"
|
|
658
|
+
).run(
|
|
659
|
+
row.path,
|
|
660
|
+
sig.mtimeMs,
|
|
661
|
+
sig.size,
|
|
662
|
+
row.title,
|
|
663
|
+
row.frontmatterJson,
|
|
664
|
+
id
|
|
665
|
+
);
|
|
666
|
+
db.query("DELETE FROM notes_fts WHERE rowid = ?").run(id);
|
|
667
|
+
} else {
|
|
668
|
+
const res = db.query(
|
|
669
|
+
"INSERT INTO notes(path, path_key, mtime_ms, size, title, frontmatter) VALUES (?, ?, ?, ?, ?, ?)"
|
|
670
|
+
).run(
|
|
671
|
+
row.path,
|
|
672
|
+
row.pathKey,
|
|
673
|
+
sig.mtimeMs,
|
|
674
|
+
sig.size,
|
|
675
|
+
row.title,
|
|
676
|
+
row.frontmatterJson
|
|
677
|
+
);
|
|
678
|
+
id = Number(res.lastInsertRowid);
|
|
679
|
+
}
|
|
680
|
+
db.query("INSERT INTO notes_fts(rowid, body) VALUES (?, ?)").run(id, body);
|
|
681
|
+
db.query("DELETE FROM note_tags WHERE path_key = ?").run(row.pathKey);
|
|
682
|
+
const insertTag = db.query(
|
|
683
|
+
"INSERT OR IGNORE INTO note_tags(path_key, tag) VALUES (?, ?)"
|
|
684
|
+
);
|
|
685
|
+
for (const tag of row.tags) {
|
|
686
|
+
insertTag.run(row.pathKey, tag);
|
|
687
|
+
}
|
|
688
|
+
db.query("DELETE FROM note_links WHERE src_key = ?").run(row.pathKey);
|
|
689
|
+
const insertLink = db.query(
|
|
690
|
+
"INSERT OR IGNORE INTO note_links(src_key, target, base, kind) VALUES (?, ?, ?, ?)"
|
|
691
|
+
);
|
|
692
|
+
for (const link2 of row.links) {
|
|
693
|
+
insertLink.run(row.pathKey, link2.target, link2.base, link2.kind);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
tx();
|
|
697
|
+
}
|
|
698
|
+
function dropNote(db, pathKey) {
|
|
699
|
+
const tx = db.transaction(() => {
|
|
700
|
+
const existing = db.query("SELECT id FROM notes WHERE path_key = ?").get(pathKey);
|
|
701
|
+
if (existing) {
|
|
702
|
+
db.query("DELETE FROM notes_fts WHERE rowid = ?").run(existing.id);
|
|
703
|
+
}
|
|
704
|
+
db.query("DELETE FROM notes WHERE path_key = ?").run(pathKey);
|
|
705
|
+
db.query("DELETE FROM note_tags WHERE path_key = ?").run(pathKey);
|
|
706
|
+
db.query("DELETE FROM note_links WHERE src_key = ?").run(pathKey);
|
|
707
|
+
});
|
|
708
|
+
tx();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// src/note-index/open.ts
|
|
712
|
+
import { Database } from "bun:sqlite";
|
|
713
|
+
import { createHash as createHash2 } from "crypto";
|
|
714
|
+
|
|
715
|
+
// src/note-index/schema.ts
|
|
716
|
+
var SCHEMA_VERSION = 1;
|
|
717
|
+
function applySchema(db) {
|
|
718
|
+
db.run(`
|
|
719
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
720
|
+
id INTEGER PRIMARY KEY,
|
|
721
|
+
path TEXT NOT NULL,
|
|
722
|
+
path_key TEXT NOT NULL UNIQUE,
|
|
723
|
+
mtime_ms INTEGER NOT NULL,
|
|
724
|
+
size INTEGER NOT NULL,
|
|
725
|
+
title TEXT NOT NULL,
|
|
726
|
+
frontmatter TEXT NOT NULL
|
|
727
|
+
)
|
|
728
|
+
`);
|
|
729
|
+
db.run(`
|
|
730
|
+
CREATE TABLE IF NOT EXISTS note_tags (
|
|
731
|
+
path_key TEXT NOT NULL,
|
|
732
|
+
tag TEXT NOT NULL,
|
|
733
|
+
PRIMARY KEY (path_key, tag)
|
|
734
|
+
)
|
|
735
|
+
`);
|
|
736
|
+
db.run(`
|
|
737
|
+
CREATE TABLE IF NOT EXISTS note_links (
|
|
738
|
+
src_key TEXT NOT NULL,
|
|
739
|
+
target TEXT NOT NULL,
|
|
740
|
+
base TEXT,
|
|
741
|
+
kind TEXT NOT NULL,
|
|
742
|
+
PRIMARY KEY (src_key, target, kind)
|
|
743
|
+
)
|
|
744
|
+
`);
|
|
745
|
+
db.run("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(body)");
|
|
746
|
+
db.run("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)");
|
|
747
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_note_tags_tag ON note_tags(tag)");
|
|
748
|
+
db.run(
|
|
749
|
+
"CREATE INDEX IF NOT EXISTS idx_note_links_target ON note_links(target)"
|
|
750
|
+
);
|
|
751
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_note_links_base ON note_links(base)");
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// src/note-index/open.ts
|
|
755
|
+
function openIndexDb(indexPath, opts) {
|
|
756
|
+
const db = new Database(indexPath);
|
|
757
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
758
|
+
db.run(`PRAGMA busy_timeout = ${Math.trunc(opts.sqliteBusyTimeoutMs)}`);
|
|
759
|
+
return db;
|
|
760
|
+
}
|
|
761
|
+
function probeCapabilities(db) {
|
|
762
|
+
try {
|
|
763
|
+
db.run("DROP TABLE IF EXISTS temp.__probe");
|
|
764
|
+
db.run("CREATE VIRTUAL TABLE temp.__probe USING fts5(x)");
|
|
765
|
+
} catch (cause) {
|
|
766
|
+
throw new MdVaultError(
|
|
767
|
+
"INDEX_UNAVAILABLE",
|
|
768
|
+
"SQLite FTS5 extension is unavailable in this Bun build",
|
|
769
|
+
{ cause }
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
try {
|
|
773
|
+
db.query("SELECT json_extract(?, ?) AS v").get("{}", "$.x");
|
|
774
|
+
} catch (cause) {
|
|
775
|
+
throw new MdVaultError(
|
|
776
|
+
"INDEX_UNAVAILABLE",
|
|
777
|
+
"SQLite JSON1 extension is unavailable in this Bun build",
|
|
778
|
+
{ cause }
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
function configFingerprint(cfg) {
|
|
783
|
+
const canonical = JSON.stringify({
|
|
784
|
+
linkResolution: cfg.linkResolution,
|
|
785
|
+
caseSensitive: cfg.caseSensitive,
|
|
786
|
+
ignore: [...cfg.ignore].sort(),
|
|
787
|
+
schema: SCHEMA_VERSION
|
|
788
|
+
});
|
|
789
|
+
return createHash2("sha256").update(canonical).digest("hex");
|
|
790
|
+
}
|
|
791
|
+
function readMeta(db, key) {
|
|
792
|
+
const row = db.query("SELECT value FROM meta WHERE key = ?").get(key);
|
|
793
|
+
return row ? row.value : null;
|
|
794
|
+
}
|
|
795
|
+
function writeMeta(db, key, value) {
|
|
796
|
+
db.query(
|
|
797
|
+
"INSERT INTO meta(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
|
798
|
+
).run(key, value);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// src/note-index/reconcile.ts
|
|
802
|
+
function createReconciler(db, vaultIo, cfg) {
|
|
803
|
+
function storedSigs() {
|
|
804
|
+
const rows = db.query("SELECT path_key, path, mtime_ms, size FROM notes").all();
|
|
805
|
+
const stored = /* @__PURE__ */ new Map();
|
|
806
|
+
for (const row of rows) {
|
|
807
|
+
if (!vaultIo.can(row.path, "read")) {
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
stored.set(row.path_key, { mtimeMs: row.mtime_ms, size: row.size });
|
|
811
|
+
}
|
|
812
|
+
return stored;
|
|
813
|
+
}
|
|
814
|
+
async function reconcile() {
|
|
815
|
+
const rels = await vaultIo.listMarkdown();
|
|
816
|
+
const stored = storedSigs();
|
|
817
|
+
const onDisk = await Promise.all(
|
|
818
|
+
rels.map(async (rel) => {
|
|
819
|
+
const full = vaultIo.resolveVaultPath(rel, "read");
|
|
820
|
+
const sig = await statSig(full);
|
|
821
|
+
return { rel, key: vaultIo.toKey(rel), full, sig };
|
|
822
|
+
})
|
|
823
|
+
);
|
|
824
|
+
const seen = /* @__PURE__ */ new Set();
|
|
825
|
+
for (const entry of onDisk) {
|
|
826
|
+
if (entry.sig === null) {
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
seen.add(entry.key);
|
|
830
|
+
const prev = stored.get(entry.key);
|
|
831
|
+
if (prev && prev.mtimeMs === entry.sig.mtimeMs && prev.size === entry.sig.size) {
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
const read = await readConsistent(entry.full);
|
|
835
|
+
if (read.content === null || read.sig === null) {
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
indexNote(db, vaultIo, cfg, entry.rel, read.content, read.sig);
|
|
839
|
+
}
|
|
840
|
+
for (const key of stored.keys()) {
|
|
841
|
+
if (!seen.has(key)) {
|
|
842
|
+
dropNote(db, key);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
async function reconcilePaths(rels) {
|
|
847
|
+
for (const rel of rels) {
|
|
848
|
+
if (!vaultIo.can(rel, "read")) {
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
const key = vaultIo.toKey(rel);
|
|
852
|
+
let full;
|
|
853
|
+
try {
|
|
854
|
+
full = vaultIo.resolveVaultPath(rel, "read");
|
|
855
|
+
} catch {
|
|
856
|
+
dropNote(db, key);
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
const read = await readConsistent(full);
|
|
860
|
+
if (read.content === null || read.sig === null) {
|
|
861
|
+
dropNote(db, key);
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
indexNote(db, vaultIo, cfg, rel, read.content, read.sig);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
async function rebuild() {
|
|
868
|
+
const rels = await vaultIo.listMarkdown();
|
|
869
|
+
const items = (await Promise.all(
|
|
870
|
+
rels.map(async (rel) => {
|
|
871
|
+
const full = vaultIo.resolveVaultPath(rel, "read");
|
|
872
|
+
const read = await readConsistent(full);
|
|
873
|
+
if (read.content === null || read.sig === null) {
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
876
|
+
return { rel, content: read.content, sig: read.sig };
|
|
877
|
+
})
|
|
878
|
+
)).filter(
|
|
879
|
+
(item) => item !== null
|
|
880
|
+
);
|
|
881
|
+
const swap = db.transaction(() => {
|
|
882
|
+
const rows = db.query("SELECT id, path_key, path FROM notes").all();
|
|
883
|
+
const delNote = db.query("DELETE FROM notes WHERE id = ?");
|
|
884
|
+
const delFts = db.query("DELETE FROM notes_fts WHERE rowid = ?");
|
|
885
|
+
const delTags = db.query("DELETE FROM note_tags WHERE path_key = ?");
|
|
886
|
+
const delLinks = db.query("DELETE FROM note_links WHERE src_key = ?");
|
|
887
|
+
for (const row of rows) {
|
|
888
|
+
if (!vaultIo.can(row.path, "read")) {
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
delFts.run(row.id);
|
|
892
|
+
delNote.run(row.id);
|
|
893
|
+
delTags.run(row.path_key);
|
|
894
|
+
delLinks.run(row.path_key);
|
|
895
|
+
}
|
|
896
|
+
for (const item of items) {
|
|
897
|
+
indexNote(db, vaultIo, cfg, item.rel, item.content, item.sig);
|
|
898
|
+
}
|
|
899
|
+
writeMeta(db, "config_fingerprint", configFingerprint(cfg));
|
|
900
|
+
writeMeta(db, "schema_version", String(SCHEMA_VERSION));
|
|
901
|
+
});
|
|
902
|
+
swap();
|
|
903
|
+
}
|
|
904
|
+
return { reconcile, reconcilePaths, rebuild };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// src/notes/notes.ts
|
|
908
|
+
function countOccurrences(haystack, needle) {
|
|
909
|
+
if (needle.length === 0) {
|
|
910
|
+
return 0;
|
|
911
|
+
}
|
|
912
|
+
let count = 0;
|
|
913
|
+
let idx = haystack.indexOf(needle);
|
|
914
|
+
while (idx !== -1) {
|
|
915
|
+
count++;
|
|
916
|
+
idx = haystack.indexOf(needle, idx + needle.length);
|
|
917
|
+
}
|
|
918
|
+
return count;
|
|
919
|
+
}
|
|
920
|
+
function createNotes(deps) {
|
|
921
|
+
const { db, vaultIo, cfg, query, onCommit, cross = false } = deps;
|
|
922
|
+
async function readNote(path3, opts) {
|
|
923
|
+
const read = await vaultIo.readVaultFile(path3);
|
|
924
|
+
if (!read) {
|
|
925
|
+
throw new MdVaultError("NOT_FOUND", `note not found: ${path3}`);
|
|
926
|
+
}
|
|
927
|
+
const parsed = parseFrontmatter(read.content);
|
|
928
|
+
const result = {
|
|
929
|
+
frontmatter: parsed.frontmatter,
|
|
930
|
+
tags: parsed.tags,
|
|
931
|
+
body: parsed.body,
|
|
932
|
+
valid: parsed.valid
|
|
933
|
+
};
|
|
934
|
+
if (opts?.withLinks) {
|
|
935
|
+
result.outbound = query.outboundLinks(path3);
|
|
936
|
+
result.backlinks = query.backlinks(path3);
|
|
937
|
+
}
|
|
938
|
+
return result;
|
|
939
|
+
}
|
|
940
|
+
function runLocked(key, fn) {
|
|
941
|
+
const locked = () => withFileLock(key, fn);
|
|
942
|
+
if (cross) {
|
|
943
|
+
return withCrossProcessLock(
|
|
944
|
+
cross.lockDir,
|
|
945
|
+
key,
|
|
946
|
+
cross.busyTimeoutMs,
|
|
947
|
+
locked
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
return locked();
|
|
951
|
+
}
|
|
952
|
+
function buildContent(input) {
|
|
953
|
+
const fm = input.frontmatter;
|
|
954
|
+
if (!fm || Object.keys(fm).length === 0) {
|
|
955
|
+
return input.body;
|
|
956
|
+
}
|
|
957
|
+
const res = editFrontmatter(input.body, (view) => {
|
|
958
|
+
for (const [k, v] of Object.entries(fm)) {
|
|
959
|
+
view[k] = v;
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
if (res.outcome === "unverifiable") {
|
|
963
|
+
throw new MdVaultError(
|
|
964
|
+
"FRONTMATTER_INVALID",
|
|
965
|
+
`frontmatter is not flat: ${Object.keys(fm).join(", ")}`
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
return res.content;
|
|
969
|
+
}
|
|
970
|
+
async function createNote(path3, input) {
|
|
971
|
+
const content = buildContent(input);
|
|
972
|
+
const full = vaultIo.resolveVaultPath(path3, "write");
|
|
973
|
+
const key = vaultIo.toKey(path3);
|
|
974
|
+
const display = vaultIo.toVaultRelative(path3);
|
|
975
|
+
await runLocked(key, async () => {
|
|
976
|
+
const sig = await exclusiveCreate(full, content);
|
|
977
|
+
indexNote(db, vaultIo, cfg, path3, content, sig);
|
|
978
|
+
if (onCommit) {
|
|
979
|
+
try {
|
|
980
|
+
await onCommit({ op: "create", path: display, content });
|
|
981
|
+
} catch (cause) {
|
|
982
|
+
throw new MdVaultError(
|
|
983
|
+
"COMMIT_FAILED",
|
|
984
|
+
`onCommit failed for ${display}`,
|
|
985
|
+
{ cause }
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
const indexCommit = async (e) => {
|
|
992
|
+
if (e.op === "delete") {
|
|
993
|
+
dropNote(db, vaultIo.toKey(e.path));
|
|
994
|
+
} else {
|
|
995
|
+
const sig = await statSig(vaultIo.resolveVaultPath(e.path, "write"));
|
|
996
|
+
if (sig) {
|
|
997
|
+
indexNote(db, vaultIo, cfg, e.path, e.content, sig);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (onCommit) {
|
|
1001
|
+
await onCommit(e);
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
async function updateNote(path3, op) {
|
|
1005
|
+
const full = vaultIo.resolveVaultPath(path3, "write");
|
|
1006
|
+
const key = vaultIo.toKey(path3);
|
|
1007
|
+
const display = vaultIo.toVaultRelative(path3);
|
|
1008
|
+
const transform = (current) => {
|
|
1009
|
+
if ("append" in op) {
|
|
1010
|
+
const baseText = current ?? "";
|
|
1011
|
+
const needsNl = baseText.length > 0 && !baseText.endsWith("\n");
|
|
1012
|
+
return `${baseText}${needsNl ? "\n" : ""}${op.append}`;
|
|
1013
|
+
}
|
|
1014
|
+
const { old, new: replacement } = op.editByMatch;
|
|
1015
|
+
if (current === null) {
|
|
1016
|
+
throw new MdVaultError(
|
|
1017
|
+
"NO_MATCH",
|
|
1018
|
+
`no match in missing file: ${display}`
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
const count = countOccurrences(current, old);
|
|
1022
|
+
if (count === 0) {
|
|
1023
|
+
throw new MdVaultError(
|
|
1024
|
+
"NO_MATCH",
|
|
1025
|
+
`no match for replacement in ${display}`
|
|
1026
|
+
);
|
|
1027
|
+
}
|
|
1028
|
+
if (count > 1) {
|
|
1029
|
+
throw new MdVaultError(
|
|
1030
|
+
"AMBIGUOUS_MATCH",
|
|
1031
|
+
`ambiguous match (${count}) in ${display}`
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
const at = current.indexOf(old);
|
|
1035
|
+
return current.slice(0, at) + replacement + current.slice(at + old.length);
|
|
1036
|
+
};
|
|
1037
|
+
await withFileTransform(full, key, display, transform, {
|
|
1038
|
+
allowCreate: "append" in op,
|
|
1039
|
+
onCommit: indexCommit,
|
|
1040
|
+
cross
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
async function editFrontmatter2(path3, mutate) {
|
|
1044
|
+
const full = vaultIo.resolveVaultPath(path3, "write");
|
|
1045
|
+
const key = vaultIo.toKey(path3);
|
|
1046
|
+
const display = vaultIo.toVaultRelative(path3);
|
|
1047
|
+
let outcome = "unchanged";
|
|
1048
|
+
const transform = (current) => {
|
|
1049
|
+
if (current === null) {
|
|
1050
|
+
outcome = "unchanged";
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
const res = editFrontmatter(current, mutate);
|
|
1054
|
+
outcome = res.outcome;
|
|
1055
|
+
if (res.outcome === "edited") {
|
|
1056
|
+
return res.content;
|
|
1057
|
+
}
|
|
1058
|
+
return null;
|
|
1059
|
+
};
|
|
1060
|
+
await withFileTransform(full, key, display, transform, {
|
|
1061
|
+
allowCreate: false,
|
|
1062
|
+
onCommit: indexCommit,
|
|
1063
|
+
cross
|
|
1064
|
+
});
|
|
1065
|
+
return outcome;
|
|
1066
|
+
}
|
|
1067
|
+
async function deleteNote(path3) {
|
|
1068
|
+
const full = vaultIo.resolveVaultPath(path3, "write");
|
|
1069
|
+
const key = vaultIo.toKey(path3);
|
|
1070
|
+
const display = vaultIo.toVaultRelative(path3);
|
|
1071
|
+
const { deleted } = await withFileDelete(full, key, display, {
|
|
1072
|
+
onCommit: indexCommit,
|
|
1073
|
+
cross
|
|
1074
|
+
});
|
|
1075
|
+
return deleted;
|
|
1076
|
+
}
|
|
1077
|
+
return { readNote, createNote, updateNote, editFrontmatter: editFrontmatter2, deleteNote };
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// src/query/query.ts
|
|
1081
|
+
var ORDER_FIELDS = /* @__PURE__ */ new Set(["mtime_ms", "path", "title"]);
|
|
1082
|
+
var WHERE_KEY_RE = /^[A-Za-z0-9_.-]+$/;
|
|
1083
|
+
var DEFAULT_LIMIT = 100;
|
|
1084
|
+
var HARD_MAX = 1e3;
|
|
1085
|
+
function validatePagination(limit, offset) {
|
|
1086
|
+
const lim = limit ?? DEFAULT_LIMIT;
|
|
1087
|
+
const off = offset ?? 0;
|
|
1088
|
+
if (!Number.isInteger(lim) || lim < 0) {
|
|
1089
|
+
throw new MdVaultError(
|
|
1090
|
+
"VALIDATION_ERROR",
|
|
1091
|
+
`limit must be a non-negative integer, got: ${limit}`
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
if (!Number.isInteger(off) || off < 0) {
|
|
1095
|
+
throw new MdVaultError(
|
|
1096
|
+
"VALIDATION_ERROR",
|
|
1097
|
+
`offset must be a non-negative integer, got: ${offset}`
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
return { lim: Math.min(lim, HARD_MAX), off };
|
|
1101
|
+
}
|
|
1102
|
+
function sanitizeFts(q) {
|
|
1103
|
+
const tokens = q.trim().split(/\s+/).filter((t) => t.length > 0);
|
|
1104
|
+
if (tokens.length === 0) {
|
|
1105
|
+
return null;
|
|
1106
|
+
}
|
|
1107
|
+
return tokens.map((t) => `"${t.replace(/"/g, '""')}"`).join(" ");
|
|
1108
|
+
}
|
|
1109
|
+
function pathBaseLower(p) {
|
|
1110
|
+
return (p.split("/").at(-1) ?? p).replace(/\.md$/i, "").toLowerCase();
|
|
1111
|
+
}
|
|
1112
|
+
function pathFolder(p) {
|
|
1113
|
+
const i = p.lastIndexOf("/");
|
|
1114
|
+
return i < 0 ? "" : p.slice(0, i);
|
|
1115
|
+
}
|
|
1116
|
+
function tieBreakWinner(candidates, srcFolder) {
|
|
1117
|
+
const sorted = [...candidates].sort((a, b) => {
|
|
1118
|
+
const af = pathFolder(a.path);
|
|
1119
|
+
const bf = pathFolder(b.path);
|
|
1120
|
+
const as_ = af === srcFolder ? 0 : 1;
|
|
1121
|
+
const bs_ = bf === srcFolder ? 0 : 1;
|
|
1122
|
+
if (as_ !== bs_) {
|
|
1123
|
+
return as_ - bs_;
|
|
1124
|
+
}
|
|
1125
|
+
if (a.path.length !== b.path.length) {
|
|
1126
|
+
return a.path.length - b.path.length;
|
|
1127
|
+
}
|
|
1128
|
+
return a.path.localeCompare(b.path);
|
|
1129
|
+
});
|
|
1130
|
+
return sorted[0]?.path;
|
|
1131
|
+
}
|
|
1132
|
+
function createQuery(db, vaultIo, cfg) {
|
|
1133
|
+
function inScope(path3) {
|
|
1134
|
+
return vaultIo.can(path3, "read");
|
|
1135
|
+
}
|
|
1136
|
+
function tagsFor(pathKey) {
|
|
1137
|
+
return db.query("SELECT tag FROM note_tags WHERE path_key = ?").all(pathKey).map((r) => r.tag);
|
|
1138
|
+
}
|
|
1139
|
+
function queryNotes(opts = {}) {
|
|
1140
|
+
const { tag, where = {}, folder, orderBy, limit, offset } = opts;
|
|
1141
|
+
const { lim, off } = validatePagination(limit, offset);
|
|
1142
|
+
const order = orderBy ?? { field: "mtime_ms", dir: "desc" };
|
|
1143
|
+
if (!ORDER_FIELDS.has(order.field)) {
|
|
1144
|
+
throw new MdVaultError(
|
|
1145
|
+
"VALIDATION_ERROR",
|
|
1146
|
+
`orderBy.field must be one of ${[...ORDER_FIELDS].join(", ")}, got: ${order.field}`
|
|
1147
|
+
);
|
|
1148
|
+
}
|
|
1149
|
+
const dir = order.dir === "asc" ? "ASC" : "DESC";
|
|
1150
|
+
const parts = [];
|
|
1151
|
+
const params = [];
|
|
1152
|
+
if (tag !== void 0) {
|
|
1153
|
+
parts.push(
|
|
1154
|
+
"EXISTS (SELECT 1 FROM note_tags nt WHERE nt.path_key = n.path_key AND nt.tag = ?)"
|
|
1155
|
+
);
|
|
1156
|
+
params.push(tag);
|
|
1157
|
+
}
|
|
1158
|
+
for (const key of Object.keys(where)) {
|
|
1159
|
+
if (!WHERE_KEY_RE.test(key)) {
|
|
1160
|
+
throw new MdVaultError(
|
|
1161
|
+
"VALIDATION_ERROR",
|
|
1162
|
+
`where key contains invalid characters: ${key}`
|
|
1163
|
+
);
|
|
1164
|
+
}
|
|
1165
|
+
parts.push(`json_extract(n.frontmatter, '$."${key}"') = ?`);
|
|
1166
|
+
params.push(where[key]);
|
|
1167
|
+
}
|
|
1168
|
+
if (folder !== void 0) {
|
|
1169
|
+
parts.push("(n.path = ? OR n.path LIKE ?)");
|
|
1170
|
+
params.push(folder, `${folder}/%`);
|
|
1171
|
+
}
|
|
1172
|
+
const clause = parts.length > 0 ? `WHERE ${parts.join(" AND ")}` : "";
|
|
1173
|
+
const sql = `SELECT n.path, n.path_key, n.title, n.frontmatter FROM notes n ${clause} ORDER BY n.${order.field} ${dir}, n.path ASC`;
|
|
1174
|
+
const rows = db.query(sql).all(...params);
|
|
1175
|
+
const scoped = [];
|
|
1176
|
+
for (const row of rows) {
|
|
1177
|
+
if (!inScope(row.path)) {
|
|
1178
|
+
continue;
|
|
1179
|
+
}
|
|
1180
|
+
scoped.push({
|
|
1181
|
+
path: row.path,
|
|
1182
|
+
title: row.title,
|
|
1183
|
+
frontmatter: JSON.parse(row.frontmatter),
|
|
1184
|
+
tags: tagsFor(row.path_key)
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
return scoped.slice(off, off + lim);
|
|
1188
|
+
}
|
|
1189
|
+
function backlinks(path3, opts = {}) {
|
|
1190
|
+
if (!inScope(path3)) {
|
|
1191
|
+
return [];
|
|
1192
|
+
}
|
|
1193
|
+
const { lim, off } = validatePagination(opts.limit, opts.offset);
|
|
1194
|
+
const display = vaultIo.toVaultRelative(path3);
|
|
1195
|
+
const targetKey = vaultIo.toKey(path3);
|
|
1196
|
+
const base = pathBaseLower(display);
|
|
1197
|
+
const sources = [];
|
|
1198
|
+
if (cfg.linkResolution === "relative") {
|
|
1199
|
+
const rows = db.query(
|
|
1200
|
+
`SELECT n.path AS from_path
|
|
1201
|
+
FROM note_links nl
|
|
1202
|
+
JOIN notes n ON n.path_key = nl.src_key
|
|
1203
|
+
JOIN notes tn ON tn.path_key = nl.target
|
|
1204
|
+
WHERE nl.target = ?`
|
|
1205
|
+
).all(targetKey);
|
|
1206
|
+
for (const r of rows) {
|
|
1207
|
+
if (inScope(r.from_path)) {
|
|
1208
|
+
sources.push(r.from_path);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
} else {
|
|
1212
|
+
const pqRows = db.query(
|
|
1213
|
+
`SELECT n.path AS from_path, nl.target
|
|
1214
|
+
FROM note_links nl
|
|
1215
|
+
JOIN notes n ON n.path_key = nl.src_key
|
|
1216
|
+
WHERE nl.target LIKE '%/%'`
|
|
1217
|
+
).all();
|
|
1218
|
+
for (const r of pqRows) {
|
|
1219
|
+
if (!inScope(r.from_path)) {
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
if (vaultIo.toKey(`${r.target}.md`) === targetKey) {
|
|
1223
|
+
sources.push(r.from_path);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
const bareRows = db.query(
|
|
1227
|
+
`SELECT n.path AS from_path
|
|
1228
|
+
FROM note_links nl
|
|
1229
|
+
JOIN notes n ON n.path_key = nl.src_key
|
|
1230
|
+
WHERE nl.base = ?`
|
|
1231
|
+
).all(base);
|
|
1232
|
+
const rawCandidates = db.query(
|
|
1233
|
+
`SELECT path FROM notes WHERE LOWER(path_key) = ? OR LOWER(path_key) LIKE ?`
|
|
1234
|
+
).all(`${base}.md`, `%/${base}.md`);
|
|
1235
|
+
const candidates = rawCandidates.filter(
|
|
1236
|
+
(c) => pathBaseLower(c.path) === base && inScope(c.path)
|
|
1237
|
+
);
|
|
1238
|
+
for (const r of bareRows) {
|
|
1239
|
+
if (!inScope(r.from_path)) {
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
const winner = tieBreakWinner(candidates, pathFolder(r.from_path));
|
|
1243
|
+
if (winner === display) {
|
|
1244
|
+
sources.push(r.from_path);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1249
|
+
const deduped = [];
|
|
1250
|
+
for (const s of sources) {
|
|
1251
|
+
if (!seen.has(s)) {
|
|
1252
|
+
seen.add(s);
|
|
1253
|
+
deduped.push({ from: s });
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
return deduped.slice(off, off + lim);
|
|
1257
|
+
}
|
|
1258
|
+
function outboundLinks(path3, opts = {}) {
|
|
1259
|
+
if (!inScope(path3)) {
|
|
1260
|
+
return [];
|
|
1261
|
+
}
|
|
1262
|
+
const { lim, off } = validatePagination(opts.limit, opts.offset);
|
|
1263
|
+
const srcKey = vaultIo.toKey(path3);
|
|
1264
|
+
const display = vaultIo.toVaultRelative(path3);
|
|
1265
|
+
const rows = db.query(
|
|
1266
|
+
`SELECT target, base FROM note_links WHERE src_key = ?`
|
|
1267
|
+
).all(srcKey).slice(off, off + lim);
|
|
1268
|
+
const results = [];
|
|
1269
|
+
for (const row of rows) {
|
|
1270
|
+
let resolved = null;
|
|
1271
|
+
if (cfg.linkResolution === "relative") {
|
|
1272
|
+
const hit = db.query("SELECT path FROM notes WHERE path_key = ?").get(row.target);
|
|
1273
|
+
if (hit && inScope(hit.path)) {
|
|
1274
|
+
resolved = hit.path;
|
|
1275
|
+
}
|
|
1276
|
+
} else if (row.target.includes("/")) {
|
|
1277
|
+
const tKey = vaultIo.toKey(`${row.target}.md`);
|
|
1278
|
+
const hit = db.query("SELECT path FROM notes WHERE path_key = ?").get(tKey);
|
|
1279
|
+
if (hit && inScope(hit.path)) {
|
|
1280
|
+
resolved = hit.path;
|
|
1281
|
+
}
|
|
1282
|
+
} else if (row.base !== null) {
|
|
1283
|
+
const rawC = db.query(
|
|
1284
|
+
"SELECT path FROM notes WHERE LOWER(path_key) = ? OR LOWER(path_key) LIKE ?"
|
|
1285
|
+
).all(`${row.base}.md`, `%/${row.base}.md`);
|
|
1286
|
+
const cands = rawC.filter(
|
|
1287
|
+
(c) => pathBaseLower(c.path) === row.base && inScope(c.path)
|
|
1288
|
+
);
|
|
1289
|
+
const winner = tieBreakWinner(cands, pathFolder(display));
|
|
1290
|
+
if (winner !== void 0) {
|
|
1291
|
+
resolved = winner;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
results.push({ target: row.target, resolved });
|
|
1295
|
+
}
|
|
1296
|
+
return results;
|
|
1297
|
+
}
|
|
1298
|
+
function searchText(q, opts = {}) {
|
|
1299
|
+
const { tag, folder, limit, offset } = opts;
|
|
1300
|
+
const { lim, off } = validatePagination(limit, offset);
|
|
1301
|
+
const ftsQ = sanitizeFts(q);
|
|
1302
|
+
if (ftsQ === null) {
|
|
1303
|
+
return [];
|
|
1304
|
+
}
|
|
1305
|
+
const parts = [];
|
|
1306
|
+
const params = [ftsQ];
|
|
1307
|
+
if (tag !== void 0) {
|
|
1308
|
+
parts.push(
|
|
1309
|
+
"EXISTS (SELECT 1 FROM note_tags nt WHERE nt.path_key = n.path_key AND nt.tag = ?)"
|
|
1310
|
+
);
|
|
1311
|
+
params.push(tag);
|
|
1312
|
+
}
|
|
1313
|
+
if (folder !== void 0) {
|
|
1314
|
+
parts.push("(n.path = ? OR n.path LIKE ?)");
|
|
1315
|
+
params.push(folder, `${folder}/%`);
|
|
1316
|
+
}
|
|
1317
|
+
const extra = parts.length > 0 ? `AND ${parts.join(" AND ")}` : "";
|
|
1318
|
+
const sql = `
|
|
1319
|
+
SELECT n.path, n.title,
|
|
1320
|
+
snippet(notes_fts, 0, '<b>', '</b>', '\u2026', 10) AS snippet
|
|
1321
|
+
FROM notes_fts
|
|
1322
|
+
JOIN notes n ON notes_fts.rowid = n.id
|
|
1323
|
+
WHERE notes_fts MATCH ? ${extra}
|
|
1324
|
+
ORDER BY notes_fts.rank
|
|
1325
|
+
`;
|
|
1326
|
+
let rows;
|
|
1327
|
+
try {
|
|
1328
|
+
rows = db.query(sql).all(...params);
|
|
1329
|
+
} catch {
|
|
1330
|
+
return [];
|
|
1331
|
+
}
|
|
1332
|
+
const scoped = [];
|
|
1333
|
+
for (const row of rows) {
|
|
1334
|
+
if (!inScope(row.path)) {
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
scoped.push({
|
|
1338
|
+
path: row.path,
|
|
1339
|
+
title: row.title,
|
|
1340
|
+
snippet: row.snippet || void 0
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
return scoped.slice(off, off + lim);
|
|
1344
|
+
}
|
|
1345
|
+
return { queryNotes, backlinks, outboundLinks, searchText };
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// src/vault-io/create-vault-io.ts
|
|
1349
|
+
import { join as join5, resolve as resolvePath } from "path";
|
|
1350
|
+
|
|
1351
|
+
// src/vault-io/allowlist.ts
|
|
1352
|
+
function matches(x, prefixes) {
|
|
1353
|
+
for (const p of prefixes) {
|
|
1354
|
+
if (p === "") {
|
|
1355
|
+
return true;
|
|
1356
|
+
}
|
|
1357
|
+
if (x === p) {
|
|
1358
|
+
return true;
|
|
1359
|
+
}
|
|
1360
|
+
if (x.startsWith(`${p}/`)) {
|
|
1361
|
+
return true;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
return false;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// src/vault-io/case-sensitivity.ts
|
|
1368
|
+
import { statSync, unlinkSync, writeFileSync } from "fs";
|
|
1369
|
+
import { join as join3 } from "path";
|
|
1370
|
+
var caseSensitiveCache = /* @__PURE__ */ new Map();
|
|
1371
|
+
function resolveCaseSensitive(root, override) {
|
|
1372
|
+
if (override !== void 0) {
|
|
1373
|
+
return override;
|
|
1374
|
+
}
|
|
1375
|
+
const cached = caseSensitiveCache.get(root);
|
|
1376
|
+
if (cached !== void 0) {
|
|
1377
|
+
return cached;
|
|
1378
|
+
}
|
|
1379
|
+
const detected = detectCaseSensitive(root);
|
|
1380
|
+
caseSensitiveCache.set(root, detected);
|
|
1381
|
+
return detected;
|
|
1382
|
+
}
|
|
1383
|
+
function detectCaseSensitive(root) {
|
|
1384
|
+
const probe = join3(root, `.vaultmd-case-probe-${process.pid}-${Date.now()}`);
|
|
1385
|
+
try {
|
|
1386
|
+
writeFileSync(probe, "x");
|
|
1387
|
+
const flipped = probe === probe.toUpperCase() ? probe.toLowerCase() : probe.toUpperCase();
|
|
1388
|
+
try {
|
|
1389
|
+
const a = statSync(probe);
|
|
1390
|
+
const b = statSync(flipped);
|
|
1391
|
+
return !(a.ino === b.ino && a.dev === b.dev);
|
|
1392
|
+
} catch {
|
|
1393
|
+
return true;
|
|
1394
|
+
}
|
|
1395
|
+
} catch {
|
|
1396
|
+
return true;
|
|
1397
|
+
} finally {
|
|
1398
|
+
try {
|
|
1399
|
+
unlinkSync(probe);
|
|
1400
|
+
} catch {
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// src/vault-io/enumerate.ts
|
|
1406
|
+
import { readdir, stat as statEntry } from "fs/promises";
|
|
1407
|
+
import { join as join4 } from "path";
|
|
1408
|
+
|
|
1409
|
+
// src/vault-io/realpath-guard.ts
|
|
1410
|
+
import { existsSync, realpathSync } from "fs";
|
|
1411
|
+
import { dirname as dirname2, sep } from "path";
|
|
1412
|
+
function realTargetWithinRoot(full, root) {
|
|
1413
|
+
let realRoot;
|
|
1414
|
+
try {
|
|
1415
|
+
realRoot = realpathSync(root);
|
|
1416
|
+
} catch {
|
|
1417
|
+
return true;
|
|
1418
|
+
}
|
|
1419
|
+
let probe = full;
|
|
1420
|
+
while (!existsSync(probe)) {
|
|
1421
|
+
const parent = dirname2(probe);
|
|
1422
|
+
if (parent === probe) {
|
|
1423
|
+
return true;
|
|
1424
|
+
}
|
|
1425
|
+
probe = parent;
|
|
1426
|
+
}
|
|
1427
|
+
let real;
|
|
1428
|
+
try {
|
|
1429
|
+
real = realpathSync(probe);
|
|
1430
|
+
} catch {
|
|
1431
|
+
return true;
|
|
1432
|
+
}
|
|
1433
|
+
return real === realRoot || real.startsWith(realRoot + sep);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// src/vault-io/enumerate.ts
|
|
1437
|
+
async function walk(root, absDir, relDir, out, deps) {
|
|
1438
|
+
let entries;
|
|
1439
|
+
try {
|
|
1440
|
+
entries = await readdir(absDir, { withFileTypes: true });
|
|
1441
|
+
} catch {
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
for (const ent of entries) {
|
|
1445
|
+
const name = ent.name;
|
|
1446
|
+
const childRel = relDir === "" ? name : `${relDir}/${name}`;
|
|
1447
|
+
const childAbs = join4(absDir, name);
|
|
1448
|
+
let isDir = ent.isDirectory();
|
|
1449
|
+
let isFile = ent.isFile();
|
|
1450
|
+
if (ent.isSymbolicLink()) {
|
|
1451
|
+
try {
|
|
1452
|
+
const st = await statEntry(childAbs);
|
|
1453
|
+
isDir = st.isDirectory();
|
|
1454
|
+
isFile = st.isFile();
|
|
1455
|
+
} catch {
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
if (isDir) {
|
|
1460
|
+
if (name.startsWith(".")) {
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
if (deps.isIgnored(childRel)) {
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
if (!realTargetWithinRoot(childAbs, root)) {
|
|
1467
|
+
continue;
|
|
1468
|
+
}
|
|
1469
|
+
await walk(root, childAbs, childRel, out, deps);
|
|
1470
|
+
continue;
|
|
1471
|
+
}
|
|
1472
|
+
if (isFile && name.endsWith(".md")) {
|
|
1473
|
+
if (deps.isIgnored(childRel)) {
|
|
1474
|
+
continue;
|
|
1475
|
+
}
|
|
1476
|
+
try {
|
|
1477
|
+
deps.resolveVaultPath(childRel, "read");
|
|
1478
|
+
} catch {
|
|
1479
|
+
continue;
|
|
1480
|
+
}
|
|
1481
|
+
out.push(deps.toVaultRelative(childRel));
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
async function listMarkdown(root, dir, deps) {
|
|
1486
|
+
const startRel = dir === void 0 ? "" : deps.toVaultRelative(dir);
|
|
1487
|
+
const startAbs = startRel === "" ? root : join4(root, startRel);
|
|
1488
|
+
if (!realTargetWithinRoot(startAbs, root)) {
|
|
1489
|
+
return [];
|
|
1490
|
+
}
|
|
1491
|
+
const out = [];
|
|
1492
|
+
await walk(root, startAbs, startRel, out, deps);
|
|
1493
|
+
out.sort();
|
|
1494
|
+
return out;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// src/vault-io/glob.ts
|
|
1498
|
+
function globToRegExp(glob) {
|
|
1499
|
+
let re = "";
|
|
1500
|
+
let i = 0;
|
|
1501
|
+
while (i < glob.length) {
|
|
1502
|
+
const c = glob[i];
|
|
1503
|
+
if (c === "*" && glob[i + 1] === "*") {
|
|
1504
|
+
i += 2;
|
|
1505
|
+
if (glob[i] === "/") {
|
|
1506
|
+
re += "(?:.*/)?";
|
|
1507
|
+
i += 1;
|
|
1508
|
+
} else {
|
|
1509
|
+
re += ".*";
|
|
1510
|
+
}
|
|
1511
|
+
continue;
|
|
1512
|
+
}
|
|
1513
|
+
if (c === "*") {
|
|
1514
|
+
re += "[^/]*";
|
|
1515
|
+
i += 1;
|
|
1516
|
+
continue;
|
|
1517
|
+
}
|
|
1518
|
+
if (c === "?") {
|
|
1519
|
+
re += "[^/]";
|
|
1520
|
+
i += 1;
|
|
1521
|
+
continue;
|
|
1522
|
+
}
|
|
1523
|
+
re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
1524
|
+
i += 1;
|
|
1525
|
+
}
|
|
1526
|
+
return new RegExp(`^${re}$`);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// src/vault-io/paths.ts
|
|
1530
|
+
import { isAbsolute } from "path";
|
|
1531
|
+
function canonicalizeRelative(rel) {
|
|
1532
|
+
if (isAbsolute(rel)) {
|
|
1533
|
+
throw new MdVaultError(
|
|
1534
|
+
"ALLOWLIST_VIOLATION",
|
|
1535
|
+
`vault path must be relative: ${rel}`
|
|
1536
|
+
);
|
|
1537
|
+
}
|
|
1538
|
+
const nfc = rel.normalize("NFC").replaceAll("\\", "/");
|
|
1539
|
+
const out = [];
|
|
1540
|
+
for (const seg of nfc.split("/")) {
|
|
1541
|
+
if (seg === "" || seg === ".") {
|
|
1542
|
+
continue;
|
|
1543
|
+
}
|
|
1544
|
+
if (seg === "..") {
|
|
1545
|
+
if (out.length === 0) {
|
|
1546
|
+
throw new MdVaultError(
|
|
1547
|
+
"ALLOWLIST_VIOLATION",
|
|
1548
|
+
`vault path escapes root: ${rel}`
|
|
1549
|
+
);
|
|
1550
|
+
}
|
|
1551
|
+
out.pop();
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1554
|
+
out.push(seg);
|
|
1555
|
+
}
|
|
1556
|
+
return out.join("/");
|
|
1557
|
+
}
|
|
1558
|
+
function canonPrefix(p) {
|
|
1559
|
+
const nfc = p.normalize("NFC").replaceAll("\\", "/");
|
|
1560
|
+
const out = [];
|
|
1561
|
+
for (const seg of nfc.split("/")) {
|
|
1562
|
+
if (seg === "" || seg === ".") {
|
|
1563
|
+
continue;
|
|
1564
|
+
}
|
|
1565
|
+
if (seg === "..") {
|
|
1566
|
+
throw new MdVaultError(
|
|
1567
|
+
"ALLOWLIST_VIOLATION",
|
|
1568
|
+
`vault prefix may not contain '..': ${p}`
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
out.push(seg);
|
|
1572
|
+
}
|
|
1573
|
+
return out.join("/");
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// src/vault-io/create-vault-io.ts
|
|
1577
|
+
function createVaultIo(config) {
|
|
1578
|
+
const root = resolvePath(config.root);
|
|
1579
|
+
const caseSensitive = resolveCaseSensitive(root, config.caseSensitive);
|
|
1580
|
+
const canonPrefixes = {
|
|
1581
|
+
read: config.prefixes.read.map(canonPrefix),
|
|
1582
|
+
write: config.prefixes.write.map(canonPrefix)
|
|
1583
|
+
};
|
|
1584
|
+
const ignoreRes = (config.ignore ?? []).map(globToRegExp);
|
|
1585
|
+
function toVaultRelative(rel) {
|
|
1586
|
+
return canonicalizeRelative(rel);
|
|
1587
|
+
}
|
|
1588
|
+
function toKey(rel) {
|
|
1589
|
+
const canonical = canonicalizeRelative(rel);
|
|
1590
|
+
return caseSensitive ? canonical : canonical.toLowerCase();
|
|
1591
|
+
}
|
|
1592
|
+
function can(rel, access) {
|
|
1593
|
+
let x;
|
|
1594
|
+
try {
|
|
1595
|
+
x = canonicalizeRelative(rel);
|
|
1596
|
+
} catch {
|
|
1597
|
+
return false;
|
|
1598
|
+
}
|
|
1599
|
+
return matches(x, canonPrefixes[access]);
|
|
1600
|
+
}
|
|
1601
|
+
function resolveVaultPath(rel, access = "read") {
|
|
1602
|
+
const canonical = canonicalizeRelative(rel);
|
|
1603
|
+
if (!canonical.endsWith(".md")) {
|
|
1604
|
+
throw new MdVaultError("NOT_MARKDOWN", `not a markdown path: ${rel}`);
|
|
1605
|
+
}
|
|
1606
|
+
if (!matches(canonical, canonPrefixes[access])) {
|
|
1607
|
+
throw new MdVaultError(
|
|
1608
|
+
"ALLOWLIST_VIOLATION",
|
|
1609
|
+
`path outside ${access} allowlist: ${rel}`
|
|
1610
|
+
);
|
|
1611
|
+
}
|
|
1612
|
+
const full = join5(root, canonical);
|
|
1613
|
+
if (!realTargetWithinRoot(full, root)) {
|
|
1614
|
+
throw new MdVaultError(
|
|
1615
|
+
"ALLOWLIST_VIOLATION",
|
|
1616
|
+
`vault path escapes root (symlink): ${rel}`
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
return full;
|
|
1620
|
+
}
|
|
1621
|
+
async function readVaultFile(rel) {
|
|
1622
|
+
const full = resolveVaultPath(rel, "read");
|
|
1623
|
+
const result = await readConsistent(full);
|
|
1624
|
+
if (result.content === null) {
|
|
1625
|
+
return null;
|
|
1626
|
+
}
|
|
1627
|
+
return { content: result.content, sig: result.sig };
|
|
1628
|
+
}
|
|
1629
|
+
async function writeVaultFile(rel, content) {
|
|
1630
|
+
return atomicWrite(resolveVaultPath(rel, "write"), content);
|
|
1631
|
+
}
|
|
1632
|
+
async function rewriteIfUnchanged(rel, content, expected) {
|
|
1633
|
+
return atomicWriteIfUnchanged(
|
|
1634
|
+
resolveVaultPath(rel, "write"),
|
|
1635
|
+
content,
|
|
1636
|
+
expected
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
async function unlinkIfUnchanged2(rel, expected) {
|
|
1640
|
+
return unlinkIfUnchanged(resolveVaultPath(rel, "write"), expected);
|
|
1641
|
+
}
|
|
1642
|
+
async function stat3(rel) {
|
|
1643
|
+
return statSig(resolveVaultPath(rel, "read"));
|
|
1644
|
+
}
|
|
1645
|
+
function isIgnored(rel) {
|
|
1646
|
+
return ignoreRes.some((re) => re.test(rel));
|
|
1647
|
+
}
|
|
1648
|
+
function listMarkdown2(dir) {
|
|
1649
|
+
return listMarkdown(root, dir, {
|
|
1650
|
+
isIgnored,
|
|
1651
|
+
resolveVaultPath,
|
|
1652
|
+
toVaultRelative
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
return {
|
|
1656
|
+
toVaultRelative,
|
|
1657
|
+
toKey,
|
|
1658
|
+
can,
|
|
1659
|
+
resolveVaultPath,
|
|
1660
|
+
readVaultFile,
|
|
1661
|
+
writeVaultFile,
|
|
1662
|
+
rewriteIfUnchanged,
|
|
1663
|
+
unlinkIfUnchanged: unlinkIfUnchanged2,
|
|
1664
|
+
stat: stat3,
|
|
1665
|
+
listMarkdown: listMarkdown2
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// src/vault/create-vault.ts
|
|
1670
|
+
async function createVault(config) {
|
|
1671
|
+
const linkResolution = config.linkResolution ?? "wikilink";
|
|
1672
|
+
const lazyReconcile = config.lazyReconcile ?? true;
|
|
1673
|
+
const reconcileTtlMs = config.reconcileTtlMs ?? 2e3;
|
|
1674
|
+
const sqliteBusyTimeoutMs = config.sqliteBusyTimeoutMs ?? 5e3;
|
|
1675
|
+
const crossProcessWriterLock = config.crossProcessWriterLock ?? true;
|
|
1676
|
+
const io = createVaultIo({
|
|
1677
|
+
root: config.root,
|
|
1678
|
+
prefixes: config.prefixes,
|
|
1679
|
+
caseSensitive: config.caseSensitive,
|
|
1680
|
+
ignore: config.ignore
|
|
1681
|
+
});
|
|
1682
|
+
const caseSensitive = io.toKey("A.md") === io.toVaultRelative("A.md");
|
|
1683
|
+
const cfg = {
|
|
1684
|
+
linkResolution,
|
|
1685
|
+
caseSensitive,
|
|
1686
|
+
ignore: config.ignore ?? []
|
|
1687
|
+
};
|
|
1688
|
+
const db = openIndexDb(config.indexPath, { sqliteBusyTimeoutMs });
|
|
1689
|
+
probeCapabilities(db);
|
|
1690
|
+
applySchema(db);
|
|
1691
|
+
const reconciler = createReconciler(db, io, cfg);
|
|
1692
|
+
const ownsWholeIndex = config.prefixes.read.includes("");
|
|
1693
|
+
const cur = configFingerprint(cfg);
|
|
1694
|
+
const stored = readMeta(db, "config_fingerprint");
|
|
1695
|
+
const storedVer = readMeta(db, "schema_version");
|
|
1696
|
+
if (stored === null) {
|
|
1697
|
+
await reconciler.rebuild();
|
|
1698
|
+
} else if (stored !== cur || storedVer !== String(SCHEMA_VERSION)) {
|
|
1699
|
+
if (ownsWholeIndex) {
|
|
1700
|
+
await reconciler.rebuild();
|
|
1701
|
+
} else {
|
|
1702
|
+
db.close();
|
|
1703
|
+
throw new MdVaultError(
|
|
1704
|
+
"INDEX_UNAVAILABLE",
|
|
1705
|
+
"index config fingerprint mismatch on a shared index not owned by this scope"
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
} else {
|
|
1709
|
+
const row = db.query("PRAGMA integrity_check").get();
|
|
1710
|
+
if (row?.integrity_check !== "ok") {
|
|
1711
|
+
await reconciler.rebuild();
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
let lastReconcileMs = 0;
|
|
1715
|
+
let inFlight = null;
|
|
1716
|
+
function maybeReconcile() {
|
|
1717
|
+
if (!lazyReconcile || inFlight) {
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
const now = Date.now();
|
|
1721
|
+
if (now - lastReconcileMs < reconcileTtlMs) {
|
|
1722
|
+
return;
|
|
1723
|
+
}
|
|
1724
|
+
lastReconcileMs = now;
|
|
1725
|
+
inFlight = reconciler.reconcile().catch(() => {
|
|
1726
|
+
}).finally(() => {
|
|
1727
|
+
inFlight = null;
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
const rawQuery = createQuery(db, io, cfg);
|
|
1731
|
+
const query = {
|
|
1732
|
+
queryNotes(opts) {
|
|
1733
|
+
maybeReconcile();
|
|
1734
|
+
return rawQuery.queryNotes(opts);
|
|
1735
|
+
},
|
|
1736
|
+
backlinks(path3, opts) {
|
|
1737
|
+
maybeReconcile();
|
|
1738
|
+
return rawQuery.backlinks(path3, opts);
|
|
1739
|
+
},
|
|
1740
|
+
outboundLinks(path3, opts) {
|
|
1741
|
+
maybeReconcile();
|
|
1742
|
+
return rawQuery.outboundLinks(path3, opts);
|
|
1743
|
+
},
|
|
1744
|
+
searchText(q, opts) {
|
|
1745
|
+
maybeReconcile();
|
|
1746
|
+
return rawQuery.searchText(q, opts);
|
|
1747
|
+
}
|
|
1748
|
+
};
|
|
1749
|
+
const notes = createNotes({
|
|
1750
|
+
db,
|
|
1751
|
+
vaultIo: io,
|
|
1752
|
+
cfg,
|
|
1753
|
+
query,
|
|
1754
|
+
onCommit: config.onCommit,
|
|
1755
|
+
cross: crossProcessWriterLock ? {
|
|
1756
|
+
lockDir: `${dirname3(config.indexPath)}/.vaultmd-locks`,
|
|
1757
|
+
busyTimeoutMs: sqliteBusyTimeoutMs
|
|
1758
|
+
} : false
|
|
1759
|
+
});
|
|
1760
|
+
return {
|
|
1761
|
+
io,
|
|
1762
|
+
notes,
|
|
1763
|
+
query,
|
|
1764
|
+
reconcile: async () => {
|
|
1765
|
+
await reconciler.reconcile();
|
|
1766
|
+
lastReconcileMs = Date.now();
|
|
1767
|
+
},
|
|
1768
|
+
reconcilePaths: (rels) => reconciler.reconcilePaths(rels),
|
|
1769
|
+
rebuild: () => reconciler.rebuild(),
|
|
1770
|
+
close: () => {
|
|
1771
|
+
db.close();
|
|
1772
|
+
}
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
export {
|
|
1776
|
+
MdVaultError,
|
|
1777
|
+
createVault,
|
|
1778
|
+
createVaultIo,
|
|
1779
|
+
deriveTags,
|
|
1780
|
+
editFrontmatter,
|
|
1781
|
+
extractLinks,
|
|
1782
|
+
isFlatFrontmatter,
|
|
1783
|
+
parseFrontmatter,
|
|
1784
|
+
storedLinksFor,
|
|
1785
|
+
withFileDelete,
|
|
1786
|
+
withFileTransform
|
|
1787
|
+
};
|