@staix/vault-mem 0.3.1 → 0.3.3
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/CHANGELOG.md +21 -1
- package/README.md +57 -6
- package/package.json +1 -1
- package/src/cli.ts +55 -11
- package/src/commands-table.ts +8 -3
- package/src/config.ts +28 -8
- package/src/index.ts +13 -3
- package/src/log-commands.ts +1 -1
- package/src/mcp-spec.ts +65 -0
- package/src/mcp-vault.ts +12 -4
- package/src/mcp.ts +44 -19
- package/src/org-promote.ts +1 -14
- package/src/registry.ts +63 -2
- package/src/spec-command-table.ts +15 -0
- package/src/spec-commands.ts +174 -0
- package/src/spec-freshness.ts +163 -0
- package/src/spec-git.ts +33 -0
- package/src/spec-graph.ts +128 -0
- package/src/spec-source.ts +225 -0
- package/src/spec-validate.ts +241 -0
- package/src/tidy.ts +7 -1
- package/src/vault-index.ts +186 -102
- package/src/write-commands.ts +8 -3
- package/src/write-operations.ts +46 -1
- package/src/write-validation.ts +20 -8
package/src/vault-index.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { realPath } from "./platform.ts";
|
|
1
2
|
import { Database } from "bun:sqlite";
|
|
2
3
|
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync } from "node:fs";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
4
5
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
6
|
import type { AnalysisSnapshot } from "./analysis-types.ts";
|
|
6
|
-
import type { VaultSpec } from "./config.ts";
|
|
7
|
-
import { parseFrontmatter } from "./frontmatter.ts";
|
|
7
|
+
import type { VaultSpec, SpecSourceSpec } from "./config.ts";
|
|
8
|
+
import { parseFrontmatter, type FrontmatterDocument } from "./frontmatter.ts";
|
|
8
9
|
import { normalizeTarget, noteKey, proseLines, resolveLinks, type LinkNote, type ResolvedLink } from "./links.ts";
|
|
9
10
|
|
|
10
11
|
export const MAX_NOTE_BYTES = 2 * 1024 * 1024;
|
|
@@ -41,13 +42,13 @@ function within(root: string, target: string): boolean {
|
|
|
41
42
|
const rel = relative(root, target);
|
|
42
43
|
return rel === "" || (!rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && rel !== ".." && !isAbsolute(rel));
|
|
43
44
|
}
|
|
44
|
-
function canonicalLocation(location: string): string {
|
|
45
|
-
try { return
|
|
45
|
+
function canonicalLocation(location: string, canonicalize: (path: string) => string = realpathSync): string {
|
|
46
|
+
try { return canonicalize(location); }
|
|
46
47
|
catch (error) {
|
|
47
48
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
48
49
|
const parent = dirname(location);
|
|
49
50
|
if (parent === location) throw error;
|
|
50
|
-
return join(canonicalLocation(parent), relative(parent, location));
|
|
51
|
+
return join(canonicalLocation(parent, canonicalize), relative(parent, location));
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
export function resultLimit(value = 50): number {
|
|
@@ -61,6 +62,15 @@ function aliases(value: unknown): string[] {
|
|
|
61
62
|
function metadata(stat: NonNullable<ReturnType<typeof lstatSync>>): string {
|
|
62
63
|
return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":");
|
|
63
64
|
}
|
|
65
|
+
/** Adapter file I/O gets path-specific guidance without rewriting unrelated Git or binding errors. */
|
|
66
|
+
function specFileIO<T>(path: string, action: string, operation: () => T): T {
|
|
67
|
+
try { return operation(); }
|
|
68
|
+
catch (error) {
|
|
69
|
+
const code = (error as NodeJS.ErrnoException)?.code ?? "I/O failure";
|
|
70
|
+
const advice = action === "close" ? "retry the scan and inspect local file I/O if the failure repeats" : "restore read access and a stable file, then retry";
|
|
71
|
+
throw new Error(`spec discovery ${path}: cannot ${action} Markdown (${code}); ${advice}`, { cause: error });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
64
74
|
function boundedText(value: string, bytes: number): { content: string; truncated: boolean } {
|
|
65
75
|
const buffer = Buffer.from(value);
|
|
66
76
|
if (buffer.length <= bytes) return { content: value, truncated: false };
|
|
@@ -77,7 +87,7 @@ function readPage(prefix: Buffer, maxBytes: number, offset: number, totalBytes:
|
|
|
77
87
|
const truncated = offset + end < totalBytes;
|
|
78
88
|
return { content: prefix.subarray(0, end).toString("utf8"), truncated, ...(truncated ? { nextOffset: offset + end } : {}) };
|
|
79
89
|
}
|
|
80
|
-
function h2Sections(body: string): { name: string; start: number; end: number }[] {
|
|
90
|
+
export function h2Sections(body: string): { name: string; start: number; end: number }[] {
|
|
81
91
|
const visible = proseLines(body), lines = body.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
|
82
92
|
const sections: { name: string; start: number; end: number }[] = [];
|
|
83
93
|
let offset = 0, active: number | undefined;
|
|
@@ -94,7 +104,7 @@ function h2Sections(body: string): { name: string; start: number; end: number }[
|
|
|
94
104
|
}
|
|
95
105
|
return sections;
|
|
96
106
|
}
|
|
97
|
-
function headingMetadata(sections: { name: string }[]): Pick<NoteReadResult, "headings" | "headingsTruncated"> {
|
|
107
|
+
export function headingMetadata(sections: { name: string }[]): Pick<NoteReadResult, "headings" | "headingsTruncated"> {
|
|
98
108
|
const headings: string[] = [];
|
|
99
109
|
let bytes = 2, truncated = false;
|
|
100
110
|
for (const { name } of sections) {
|
|
@@ -104,69 +114,91 @@ function headingMetadata(sections: { name: string }[]): Pick<NoteReadResult, "he
|
|
|
104
114
|
}
|
|
105
115
|
return { headings, ...(truncated ? { headingsTruncated: true } : {}) };
|
|
106
116
|
}
|
|
117
|
+
/** A source changes discovery and record selection, while sharing scan storage and BM25. */
|
|
118
|
+
export interface IndexSourceAdapter {
|
|
119
|
+
validate(): void;
|
|
120
|
+
paths(): string[];
|
|
121
|
+
validatePath(path: string): void;
|
|
122
|
+
accept(path: string, document: FrontmatterDocument, hash: string): boolean;
|
|
123
|
+
invalid(path: string): boolean | void;
|
|
124
|
+
skipFile?(path: string, reason: "oversized" | "invalid_utf8", bytes: Buffer): boolean;
|
|
125
|
+
}
|
|
126
|
+
export class IndexCacheError extends Error {}
|
|
127
|
+
export interface IndexOptions { memory?: boolean; adapter?: IndexSourceAdapter; reuseCache?: boolean }
|
|
107
128
|
/** Derived cache only. No vault or application configuration files are written. */
|
|
108
|
-
export class VaultIndex {
|
|
129
|
+
export class VaultIndex<Source extends VaultSpec | SpecSourceSpec = VaultSpec> {
|
|
109
130
|
private readonly db: Database;
|
|
110
131
|
readonly file: string;
|
|
111
132
|
private readonly root: string;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
133
|
+
private readonly adapter?: IndexSourceAdapter;
|
|
134
|
+
constructor(home: string, readonly vault: Source, vaultRoots: string[] = [vault.path], options: IndexOptions = {}) {
|
|
135
|
+
this.adapter = options.adapter;
|
|
136
|
+
this.root = this.adapter ? realPath(vault.path) : realpathSync(vault.path);
|
|
137
|
+
if (this.root !== vault.path) throw new Error(this.adapter ? "spec index: worktree path changed; resolve the source again with resolveSpecWorktree" : `vault ${vault.id}: registered path changed`);
|
|
115
138
|
const notes = join(this.root, "notes");
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
} else this.file = ":memory:";
|
|
133
|
-
this.db = new Database(this.file);
|
|
134
|
-
this.db.run("PRAGMA busy_timeout=5000; PRAGMA journal_mode=DELETE;");
|
|
135
|
-
this.db.run(`CREATE TABLE IF NOT EXISTS notes (
|
|
136
|
-
path TEXT PRIMARY KEY, hash TEXT NOT NULL, metadata TEXT NOT NULL,
|
|
137
|
-
title TEXT NOT NULL, description TEXT NOT NULL, body TEXT NOT NULL,
|
|
138
|
-
type TEXT NOT NULL, domain TEXT NOT NULL, aliases TEXT NOT NULL, source TEXT NOT NULL);
|
|
139
|
-
CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(path UNINDEXED, title, description, body, tokenize='unicode61');
|
|
140
|
-
CREATE TABLE IF NOT EXISTS analysis_notes (path TEXT PRIMARY KEY, source TEXT NOT NULL, hash TEXT NOT NULL, metadata TEXT NOT NULL);
|
|
141
|
-
CREATE TABLE IF NOT EXISTS links (source TEXT NOT NULL, ordinal INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY(source, ordinal));`);
|
|
142
|
-
// Dry runs reuse the derived cache via a readonly connection, then refresh only changed notes in memory.
|
|
143
|
-
if (options.memory) {
|
|
144
|
-
const file = join(cache, indexName(this.root));
|
|
145
|
-
let exists = false;
|
|
146
|
-
try { const stat = lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("index: expected plain database file"); exists = true; }
|
|
147
|
-
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
148
|
-
if (exists) {
|
|
149
|
-
if (realpathSync(cache) !== cache) throw new Error("index: linked cache directory");
|
|
150
|
-
const disk = new Database(file, { readonly: true });
|
|
139
|
+
if (this.adapter) this.adapter.validate();
|
|
140
|
+
else if (realpathSync(notes) !== notes || !lstatSync(notes).isDirectory()) throw new Error(`vault ${vault.id}: notes must be a plain directory`);
|
|
141
|
+
try {
|
|
142
|
+
const cacheName = this.adapter ? `spec-${indexName(this.root)}` : indexName(this.root);
|
|
143
|
+
const cache = resolve(home, "indexes");
|
|
144
|
+
const noDisk = options.memory && options.reuseCache === false;
|
|
145
|
+
const canonicalize = this.adapter ? realPath : realpathSync;
|
|
146
|
+
const canonical = noDisk ? cache : canonicalLocation(cache, canonicalize);
|
|
147
|
+
for (const root of noDisk ? [] : vaultRoots) {
|
|
148
|
+
if (within(canonicalLocation(resolve(root), canonicalize), canonical)) throw new Error(this.adapter ? "spec index: cache is inside a protected source; choose VMEM_HOME outside every repository and vault" : "index: VMEM_HOME cache must be outside every vault");
|
|
149
|
+
}
|
|
150
|
+
// Canonical home resolution is allowed, but a linked cache directory/file is not.
|
|
151
|
+
if (!options.memory) {
|
|
152
|
+
mkdirSync(cache, { recursive: true, mode: 0o700 });
|
|
153
|
+
if (lstatSync(cache).isSymbolicLink() || !lstatSync(cache).isDirectory()) throw new Error("index: expected plain cache directory");
|
|
154
|
+
this.file = join(cache, cacheName);
|
|
151
155
|
try {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
156
|
+
const fd = openSync(this.file, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
157
|
+
closeSync(fd);
|
|
158
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
159
|
+
if (!lstatSync(this.file).isFile() || lstatSync(this.file).isSymbolicLink() || (this.adapter && lstatSync(this.file).nlink !== 1)) throw new Error("index: expected plain database file");
|
|
160
|
+
} else this.file = ":memory:";
|
|
161
|
+
this.db = new Database(this.file);
|
|
162
|
+
this.db.run("PRAGMA busy_timeout=5000; PRAGMA journal_mode=DELETE;");
|
|
163
|
+
this.db.run(`CREATE TABLE IF NOT EXISTS notes (
|
|
164
|
+
path TEXT PRIMARY KEY, hash TEXT NOT NULL, metadata TEXT NOT NULL,
|
|
165
|
+
title TEXT NOT NULL, description TEXT NOT NULL, body TEXT NOT NULL,
|
|
166
|
+
type TEXT NOT NULL, domain TEXT NOT NULL, aliases TEXT NOT NULL, source TEXT NOT NULL);
|
|
167
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(path UNINDEXED, title, description, body, tokenize='unicode61');
|
|
168
|
+
CREATE TABLE IF NOT EXISTS analysis_notes (path TEXT PRIMARY KEY, source TEXT NOT NULL, hash TEXT NOT NULL, metadata TEXT NOT NULL);
|
|
169
|
+
CREATE TABLE IF NOT EXISTS links (source TEXT NOT NULL, ordinal INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY(source, ordinal));`);
|
|
170
|
+
// Dry runs reuse the derived cache via a readonly connection, then refresh only changed notes in memory.
|
|
171
|
+
if (options.memory && options.reuseCache !== false) {
|
|
172
|
+
const file = join(cache, cacheName);
|
|
173
|
+
let exists = false;
|
|
174
|
+
try { const stat = lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink() || (this.adapter && stat.nlink !== 1)) throw new Error("index: expected plain database file"); exists = true; }
|
|
175
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
176
|
+
if (exists) {
|
|
177
|
+
if (canonicalize(cache) !== cache) throw new Error("index: linked cache directory");
|
|
178
|
+
const disk = new Database(file, { readonly: true });
|
|
179
|
+
try {
|
|
180
|
+
disk.transaction(() => {
|
|
181
|
+
const size = disk.query<{count:number;bytes:number}, []>("SELECT count(*) AS count, coalesce(sum(length(CAST(source AS BLOB))),0) AS bytes FROM notes").get()!;
|
|
164
182
|
if (size.count > 10000 || size.bytes > 64 * 1024 * 1024) throw new Error("analysis: cached snapshot exceeds bound");
|
|
165
|
-
for (const row of disk.query<{
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
183
|
+
for (const row of disk.query<Omit<StoredNote,"aliases"> & {aliases:string}, []>("SELECT * FROM notes").all()) {
|
|
184
|
+
this.db.query("INSERT INTO notes VALUES (?,?,?,?,?,?,?,?,?,?)").run(row.path,row.hash,row.metadata,row.title,row.description,row.body,row.type,row.domain,row.aliases,row.source);
|
|
185
|
+
this.db.query("INSERT INTO fts VALUES (?,?,?,?)").run(row.path,row.title,row.description,row.body);
|
|
186
|
+
}
|
|
187
|
+
for (const row of disk.query<{source:string;ordinal:number;data:string}, []>("SELECT * FROM links").all()) this.db.query("INSERT INTO links VALUES (?,?,?)").run(row.source,row.ordinal,row.data);
|
|
188
|
+
this.db.run(`PRAGMA user_version = ${Number(disk.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version ?? 0)}`);
|
|
189
|
+
const exists = disk.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='analysis_notes'").get();
|
|
190
|
+
if (exists) {
|
|
191
|
+
const size = disk.query<{count:number;bytes:number}, []>("SELECT count(*) AS count, coalesce(sum(length(CAST(source AS BLOB))),0) AS bytes FROM analysis_notes").get()!;
|
|
192
|
+
if (size.count > 10000 || size.bytes > 64 * 1024 * 1024) throw new Error("analysis: cached snapshot exceeds bound");
|
|
193
|
+
for (const row of disk.query<{path:string;source:string;hash:string;metadata:string}, []>("SELECT * FROM analysis_notes").all()) this.db.query("INSERT INTO analysis_notes VALUES (?,?,?,?)").run(row.path,row.source,row.hash,row.metadata);
|
|
194
|
+
}
|
|
195
|
+
})();
|
|
196
|
+
} finally { disk.close(); }
|
|
197
|
+
}
|
|
169
198
|
}
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (this.adapter) throw new IndexCacheError(error instanceof Error ? error.message : "cache initialization failed", { cause: error });
|
|
201
|
+
throw error;
|
|
170
202
|
}
|
|
171
203
|
}
|
|
172
204
|
close(): void { this.db.close(); }
|
|
@@ -175,64 +207,111 @@ export class VaultIndex {
|
|
|
175
207
|
}
|
|
176
208
|
/** Scan metadata each call; hash only new or metadata-changed files. Atomic cache update. */
|
|
177
209
|
scan(rebuild = false, options: { skipInvalidFrontmatter?: boolean; captureInvalidFrontmatter?: boolean } = {}): { notes: number; changed: number; removed: number; skipped?: string[] } {
|
|
178
|
-
|
|
210
|
+
// Discovery/binding Git calls must never hold a SQLite writer lock.
|
|
211
|
+
let preparedPaths: string[] | undefined;
|
|
212
|
+
if (this.adapter) { this.adapter.validate(); preparedPaths = this.adapter.paths(); }
|
|
213
|
+
const result = this.db.transaction(() => {
|
|
179
214
|
const notesDir = join(this.root, "notes");
|
|
180
|
-
if (realpathSync(this.root) !== this.root || realpathSync(notesDir) !== notesDir || !lstatSync(notesDir).isDirectory()) throw new Error(`vault ${this.vault.id}: path or notes directory changed`);
|
|
181
|
-
const
|
|
215
|
+
if (!this.adapter && (realpathSync(this.root) !== this.root || realpathSync(notesDir) !== notesDir || !lstatSync(notesDir).isDirectory())) throw new Error(`vault ${this.vault.id}: path or notes directory changed`);
|
|
216
|
+
const indexed = this.adapter ? new Set(this.db.query<{path:string}, []>("SELECT path FROM notes").all().map(row => row.path)) : undefined;
|
|
217
|
+
const previous = new Map(this.db.query<{ path: string; hash: string; metadata: string }, []>(this.adapter ? "SELECT path,hash,metadata FROM analysis_notes" : options.captureInvalidFrontmatter ? "SELECT path,hash,metadata FROM notes UNION SELECT path,hash,metadata FROM analysis_notes" : "SELECT path,hash,metadata FROM notes").all().map(note => [note.path, note]));
|
|
182
218
|
if (rebuild) this.db.run("DELETE FROM fts");
|
|
183
219
|
const live = new Set<string>(), present = new Set<string>();
|
|
184
220
|
let changed = 0, analysisBytes = 0;
|
|
185
221
|
const skipped: string[] = [], touched = new Set<string>(), keys = new Set<string>();
|
|
186
222
|
// Links resolve by path only, so other notes re-resolve only when they target a touched path.
|
|
187
223
|
const touch = (path: string) => { touched.add(path); keys.add(noteKey(path)); };
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
224
|
+
const paths = preparedPaths ?? readdirSync(notesDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).filter(entry => entry.name.endsWith(".md") && entry.isFile()).map(entry => entry.name);
|
|
225
|
+
for (const path of paths) {
|
|
226
|
+
this.adapter?.validatePath(path);
|
|
227
|
+
const file = join(this.adapter ? this.root : notesDir, path);
|
|
228
|
+
const before = this.adapter ? specFileIO(path, "inspect", () => lstatSync(file)) : lstatSync(file);
|
|
229
|
+
if (!before.isFile() || before.isSymbolicLink()) throw new Error(this.adapter ? `spec discovery ${path}: selected path changed; restore a plain file and retry` : `vault ${this.vault.id}: note path changed`);
|
|
230
|
+
if (!this.adapter && before.size > MAX_NOTE_BYTES) throw new Error(`vault ${this.vault.id}, ${path}: note exceeds ${MAX_NOTE_BYTES} bytes`);
|
|
193
231
|
live.add(path); present.add(path);
|
|
194
|
-
if (options.captureInvalidFrontmatter) {
|
|
232
|
+
if (options.captureInvalidFrontmatter && !this.adapter) {
|
|
195
233
|
analysisBytes += before.size;
|
|
196
|
-
if (present.size > 10000 || analysisBytes > 64 * 1024 * 1024) throw new Error("analysis: snapshot exceeds bound");
|
|
234
|
+
if (present.size > 10000 || analysisBytes > 64 * 1024 * 1024) throw new Error(this.adapter ? `spec discovery ${path}: more than 10000 Markdown files or 64 MiB of input; ignore large or vendored Markdown via .gitignore and untrack already-tracked excluded files with git rm --cached before rebuilding` : "analysis: snapshot exceeds bound");
|
|
235
|
+
}
|
|
236
|
+
if (this.adapter) {
|
|
237
|
+
analysisBytes += Math.min(before.size, MAX_NOTE_BYTES + 1);
|
|
238
|
+
if (present.size > 10000 || analysisBytes > 64 * 1024 * 1024) throw new Error(`spec discovery ${path}: more than 10000 Markdown inputs or 64 MiB of bounded reads; ignore vendored Markdown via .gitignore and untrack excluded files with git rm --cached`);
|
|
197
239
|
}
|
|
198
240
|
const old = previous.get(path), stamp = metadata(before);
|
|
199
|
-
if (!rebuild && old?.metadata === stamp && this.db.query("SELECT 1 FROM analysis_notes WHERE path=?").get(path)) continue;
|
|
200
|
-
const
|
|
201
|
-
let
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
241
|
+
if (!this.adapter && !rebuild && old?.metadata === stamp && this.db.query("SELECT 1 FROM analysis_notes WHERE path=?").get(path)) continue;
|
|
242
|
+
const cached = this.adapter && before.size <= MAX_NOTE_BYTES && !rebuild && old?.metadata === stamp ? this.db.query<{source:string}, [string]>("SELECT source FROM analysis_notes WHERE path=?").get(path) : null;
|
|
243
|
+
let source: string, hash: string;
|
|
244
|
+
if (cached && old) { source = cached.source; hash = old.hash; }
|
|
245
|
+
else {
|
|
246
|
+
const fd = this.adapter ? specFileIO(path, "open", () => openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)) : openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
247
|
+
let buffer: Buffer;
|
|
248
|
+
try {
|
|
249
|
+
const stat = this.adapter ? specFileIO(path, "inspect", () => fstatSync(fd)) : fstatSync(fd);
|
|
250
|
+
if (!stat.isFile() || (!this.adapter && stat.size > MAX_NOTE_BYTES) || metadata(stat) !== stamp) throw new Error(this.adapter ? `spec discovery ${path}: file changed during scan; retry on a stable worktree` : `vault ${this.vault.id}, ${path}: note changed during scan`);
|
|
251
|
+
// Fixed bound even if a concurrent writer grows the file.
|
|
252
|
+
buffer = Buffer.alloc(MAX_NOTE_BYTES + 1);
|
|
253
|
+
let size = 0;
|
|
254
|
+
for (;;) {
|
|
255
|
+
const count = this.adapter ? specFileIO(path, "read", () => readSync(fd, buffer, size, buffer.length - size, null)) : readSync(fd, buffer, size, buffer.length - size, null);
|
|
256
|
+
size += count;
|
|
257
|
+
if (!count || size === buffer.length) break;
|
|
258
|
+
}
|
|
259
|
+
buffer = buffer.subarray(0, size);
|
|
260
|
+
if ((!this.adapter && size > MAX_NOTE_BYTES) || metadata(this.adapter ? specFileIO(path, "inspect", () => fstatSync(fd)) : fstatSync(fd)) !== stamp) throw new Error(this.adapter ? `spec discovery ${path}: file changed during scan; retry on a stable worktree` : `vault ${this.vault.id}, ${path}: note changed during scan`);
|
|
261
|
+
} finally { if (this.adapter) specFileIO(path, "close", () => closeSync(fd)); else closeSync(fd); }
|
|
262
|
+
this.adapter?.validatePath(path);
|
|
263
|
+
if (this.adapter && metadata(specFileIO(path, "inspect", () => lstatSync(file))) !== stamp) throw new Error(`spec discovery ${path}: file changed during read; retry on a stable worktree`);
|
|
264
|
+
if (this.adapter) {
|
|
265
|
+
const skip = (reason: "oversized" | "invalid_utf8") => {
|
|
266
|
+
if (!this.adapter?.skipFile?.(path, reason, buffer.subarray(0, MAX_NOTE_BYTES))) return false;
|
|
267
|
+
live.delete(path); skipped.push(path);
|
|
268
|
+
this.db.query("DELETE FROM analysis_notes WHERE path=?").run(path);
|
|
269
|
+
return true;
|
|
270
|
+
};
|
|
271
|
+
if (before.size > MAX_NOTE_BYTES || buffer.length > MAX_NOTE_BYTES) {
|
|
272
|
+
if (skip("oversized")) continue;
|
|
273
|
+
throw new Error(`spec discovery ${path}: Markdown exceeds ${MAX_NOTE_BYTES} bytes; split the candidate before validating`);
|
|
274
|
+
}
|
|
275
|
+
try { source = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer); }
|
|
276
|
+
catch {
|
|
277
|
+
if (skip("invalid_utf8")) continue;
|
|
278
|
+
throw new Error(`spec discovery ${path}: Markdown is not valid UTF-8; re-encode the candidate as UTF-8 before validating`);
|
|
279
|
+
}
|
|
280
|
+
} else source = buffer.toString("utf8");
|
|
281
|
+
hash = digest(buffer);
|
|
282
|
+
this.db.query("INSERT OR REPLACE INTO analysis_notes VALUES (?,?,?,?)").run(path, source, hash, stamp);
|
|
283
|
+
}
|
|
284
|
+
if (!this.adapter && !rebuild && old?.hash === hash) { this.db.query("UPDATE notes SET metadata=? WHERE path=?").run(stamp, path); continue; }
|
|
219
285
|
let document: ReturnType<typeof parseFrontmatter>;
|
|
220
|
-
try { document = parseFrontmatter(
|
|
286
|
+
try { document = parseFrontmatter(source); }
|
|
221
287
|
catch {
|
|
222
|
-
if (!options.skipInvalidFrontmatter && !options.captureInvalidFrontmatter) throw new Error(`vault ${this.vault.id}, ${path}: invalid frontmatter`);
|
|
223
|
-
|
|
288
|
+
if (!this.adapter && !options.skipInvalidFrontmatter && !options.captureInvalidFrontmatter) throw new Error(`vault ${this.vault.id}, ${path}: invalid frontmatter`);
|
|
289
|
+
const report = this.adapter?.invalid(path);
|
|
290
|
+
live.delete(path); if (report !== false) skipped.push(path); continue;
|
|
291
|
+
}
|
|
292
|
+
if (this.adapter) {
|
|
293
|
+
this.adapter.validatePath(path);
|
|
294
|
+
if (metadata(specFileIO(path, "inspect", () => lstatSync(file))) !== stamp) throw new Error(`spec discovery ${path}: file changed during scan; retry on a stable worktree`);
|
|
295
|
+
if (!this.adapter.accept(path, document, hash)) { live.delete(path); continue; }
|
|
296
|
+
const row = this.db.query<{hash:string}, [string]>("SELECT hash FROM notes WHERE path=?").get(path);
|
|
297
|
+
if (!rebuild && row?.hash === hash) { this.db.query("UPDATE notes SET metadata=? WHERE path=?").run(stamp, path); continue; }
|
|
224
298
|
}
|
|
225
299
|
const { data, body } = document;
|
|
226
300
|
touch(path);
|
|
227
301
|
this.db.query("INSERT OR REPLACE INTO notes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(path, hash, stamp,
|
|
228
|
-
text(data.title) || path.slice(0, -3), text(data.description), body, text(data.type), text(data.domain), JSON.stringify(aliases(data.aliases ?? data.alias)),
|
|
302
|
+
text(data.title) || path.slice(0, -3), text(data.description), body, text(data.type), text(data.domain), JSON.stringify(aliases(data.aliases ?? data.alias)), source);
|
|
229
303
|
this.db.query("DELETE FROM fts WHERE path=?").run(path);
|
|
230
304
|
this.db.query("INSERT INTO fts(path,title,description,body) SELECT path,title,description,body FROM notes WHERE path=?").run(path);
|
|
231
305
|
changed++;
|
|
232
306
|
}
|
|
233
307
|
for (const row of this.db.query<{path:string}, []>("SELECT path FROM analysis_notes").all()) if (!present.has(row.path)) this.db.query("DELETE FROM analysis_notes WHERE path=?").run(row.path);
|
|
234
308
|
let removed = 0;
|
|
235
|
-
for (const path of previous.keys()) if (!live.has(path)) { touch(path); this.db.query("DELETE FROM notes WHERE path=?").run(path); this.db.query("DELETE FROM fts WHERE path=?").run(path); removed++; }
|
|
309
|
+
for (const path of indexed ?? previous.keys()) if (!live.has(path)) { touch(path); this.db.query("DELETE FROM notes WHERE path=?").run(path); this.db.query("DELETE FROM fts WHERE path=?").run(path); removed++; }
|
|
310
|
+
if (this.adapter) {
|
|
311
|
+
// Spec links are resolved by frontmatter id in buildSpecGraph, never by vault paths.
|
|
312
|
+
this.db.run("DELETE FROM links");
|
|
313
|
+
return { notes: live.size, changed, removed, ...(skipped.length ? { skipped } : {}) };
|
|
314
|
+
}
|
|
236
315
|
const stale = this.db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version !== LINK_RESOLUTION;
|
|
237
316
|
if (rebuild || stale) this.db.run("DELETE FROM links");
|
|
238
317
|
else if (changed || removed) {
|
|
@@ -253,6 +332,8 @@ export class VaultIndex {
|
|
|
253
332
|
if (stale) this.db.run(`PRAGMA user_version = ${LINK_RESOLUTION}`);
|
|
254
333
|
return { notes: live.size, changed, removed, ...(skipped.length ? { skipped } : {}) };
|
|
255
334
|
}).immediate();
|
|
335
|
+
this.adapter?.validate();
|
|
336
|
+
return result;
|
|
256
337
|
}
|
|
257
338
|
/** Consume cached note sources only. Auxiliary inputs are captured separately by analysisInput. */
|
|
258
339
|
analysisSnapshot(inputs: Omit<AnalysisSnapshot, "notes">): AnalysisSnapshot {
|
|
@@ -261,15 +342,18 @@ export class VaultIndex {
|
|
|
261
342
|
const notes = this.db.query<{path: string; source: string}, []>("SELECT path,source FROM analysis_notes ORDER BY path").all();
|
|
262
343
|
return { ...inputs, notes };
|
|
263
344
|
}
|
|
264
|
-
search(query: string, options: SearchOptions = {}): SearchResult[] {
|
|
345
|
+
search(query: string, options: SearchOptions = {}, snapshot?: ReadonlyMap<string, string>, snapshotOffset = 0, allSnapshotRows = false): SearchResult[] {
|
|
265
346
|
const limit = resultLimit(options.limit);
|
|
266
347
|
if (!query.trim() || Buffer.byteLength(query) > 1024) throw new Error("search: query must contain 1..1024 bytes");
|
|
267
348
|
// Literal AND terms: users cannot inject FTS operators, columns or SQL.
|
|
268
349
|
const terms = query.trim().split(/\s+/).map(term => `"${term.replaceAll('"', '""')}"`).join(" AND ");
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
350
|
+
const hashColumn = snapshot ? ",n.hash AS sourceHash" : "";
|
|
351
|
+
return this.db.query<SearchResult & { sourceHash?: string }, [string, string | null, string | null, string | null, string | null, number]>(`SELECT n.path, n.title, substr(n.description,1,512) AS description, n.type, n.domain,
|
|
352
|
+
bm25(fts,0,5,2,1) AS score, snippet(fts,3,'','',' ... ',32) AS snippet${hashColumn} FROM fts JOIN notes n ON n.path=fts.path
|
|
353
|
+
WHERE fts MATCH ? AND (? IS NULL OR n.type=?) AND (? IS NULL OR n.domain=?) ORDER BY score,n.path LIMIT ?`).all(terms, options.type ?? null, options.type ?? null, options.domain ?? null, options.domain ?? null, snapshot ? 10000 : limit)
|
|
354
|
+
.filter(row => !snapshot || snapshot.get(row.path) === row.sourceHash)
|
|
355
|
+
.slice(snapshot ? snapshotOffset : 0, (snapshot ? snapshotOffset : 0) + (snapshot && allSnapshotRows ? 10000 : limit))
|
|
356
|
+
.map(({ sourceHash: _, ...row }) => ({ ...row, title: boundedText(row.title, 512).content, snippet: boundedText(row.snippet, 1024).content, type: boundedText(row.type, 256).content, domain: boundedText(row.domain, 256).content }));
|
|
273
357
|
}
|
|
274
358
|
private find(target: string): string {
|
|
275
359
|
if (!target || Buffer.byteLength(target) > 1024) throw new Error("note: expected target <=1024 bytes");
|
package/src/write-commands.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { COMMANDS as COMMAND_USAGE } from "./commands-table.ts";
|
|
1
2
|
import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
|
|
2
3
|
import { canonicalPath, isDenied } from "./platform.ts";
|
|
3
4
|
import { loadRegistry } from "./registry.ts";
|
|
@@ -44,7 +45,7 @@ export function runWriteCommand(command: string, args: string[], home: string, e
|
|
|
44
45
|
for (let i = 0; i < args.length; i++) {
|
|
45
46
|
const arg = args[i]!;
|
|
46
47
|
if (!arg.startsWith("--")) { positional.push(arg); continue; }
|
|
47
|
-
if (Object.hasOwn(flags, arg) || (!values.includes(arg) && !booleans.includes(arg))) throw new Error(`${command}: unknown or repeated option`);
|
|
48
|
+
if (Object.hasOwn(flags, arg) || (!values.includes(arg) && !booleans.includes(arg))) throw new Error(`${command}: unknown or repeated option: ${arg}`);
|
|
48
49
|
if (booleans.includes(arg)) flags[arg] = true;
|
|
49
50
|
else {
|
|
50
51
|
const value = args[++i];
|
|
@@ -54,8 +55,12 @@ export function runWriteCommand(command: string, args: string[], home: string, e
|
|
|
54
55
|
}
|
|
55
56
|
const required = (name: string): string => { if (typeof flags[name] !== "string") throw new Error(`${name}: required`); return flags[name] as string; };
|
|
56
57
|
const optional = (name: string): string | undefined => typeof flags[name] === "string" ? flags[name] as string : undefined;
|
|
57
|
-
const
|
|
58
|
-
|
|
58
|
+
const usage = COMMAND_USAGE.find(item => item.name === command)!.usage;
|
|
59
|
+
const targets = usage.slice(command.length).split(/\s+(?:--|\[)/, 1)[0]!.match(/<[^>]+>/g) ?? [];
|
|
60
|
+
if (positional.length !== targets.length) {
|
|
61
|
+
const expected = targets.length === 2 ? `${targets[0]} and one ${targets[1]}` : targets.join(" and ");
|
|
62
|
+
throw new Error(`${command}: takes ${expected}; got ${Math.max(0, positional.length - 1)} targets`);
|
|
63
|
+
}
|
|
59
64
|
const vault = positional[0]!, target = positional[1]!;
|
|
60
65
|
if (command === "tidy") {
|
|
61
66
|
const all = flags["--all"] === true, rules = TIDY_RULES.filter(rule => all || flags[`--${rule}`] === true);
|
package/src/write-operations.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
1
2
|
import { normalizeSource } from "./source.ts";
|
|
2
3
|
import { parseFrontmatter, emitFrontmatter } from "./frontmatter.ts";
|
|
3
4
|
import { normalizeTarget, proseLines, rewriteLinkTargets, extractLinks, resolveLinks, type LinkNote } from "./links.ts";
|
|
@@ -45,6 +46,42 @@ export function h2Sections(body: string): { name: string; start: number; end: nu
|
|
|
45
46
|
}
|
|
46
47
|
return sections;
|
|
47
48
|
}
|
|
49
|
+
/** Replace the last prose `## Topics` section (never one inside a fence) with the given topics; append one if absent. */
|
|
50
|
+
export function replaceTopicsSection(body: string, topics: string[]): string {
|
|
51
|
+
const prose = proseLines(body), raw = body.split("\n");
|
|
52
|
+
let start = -1;
|
|
53
|
+
for (let i = prose.length - 1; i >= 0; i--) if (/^ {0,3}##[ \t]+Topics[ \t]*#*[ \t]*$/i.test(prose[i]!)) { start = i; break; }
|
|
54
|
+
const section = ["## Topics", "", ...topics.map(topic => `- ${topic}`), ""];
|
|
55
|
+
if (start < 0) return `${body.replace(/\n*$/, "")}\n\n${section.join("\n")}`;
|
|
56
|
+
// The section ends at the next prose heading of level 1 or 2.
|
|
57
|
+
let end = raw.length;
|
|
58
|
+
for (let i = start + 1; i < prose.length; i++) if (/^ {0,3}#{1,2}[ \t]/.test(prose[i]!)) { end = i; break; }
|
|
59
|
+
const tail = raw.slice(end);
|
|
60
|
+
return [...raw.slice(0, start), ...section, ...(tail.length ? tail : [])].join("\n").replace(/\n*$/, "\n");
|
|
61
|
+
}
|
|
62
|
+
/** In-place refiling replaces only visible wiki-list lines, preserving all other bytes. */
|
|
63
|
+
function replaceTopicsList(body: string, topics: string[], newline: string): string {
|
|
64
|
+
const visible = proseLines(body), raw = body.match(/[^\n]*\n|[^\n]+$/g) ?? [];
|
|
65
|
+
let start = -1;
|
|
66
|
+
for (let i = visible.length - 1; i >= 0; i--) if (/^ {0,3}##[ \t]+Topics(?:[ \t]+#+)?[ \t]*$/i.test(visible[i]!)) { start = i; break; }
|
|
67
|
+
const list = topics.map(topic => `- ${topic}`).join(newline);
|
|
68
|
+
if (start < 0) return body + (body && !body.endsWith("\n") ? newline : "") + newline + `## Topics${newline}${newline}${list}${newline}`;
|
|
69
|
+
const items = new Set<number>();
|
|
70
|
+
// The Topics list is the leading run of list lines as V05 sees them: blank and fenced lines continue it (fences are kept),
|
|
71
|
+
// the first visible heading or prose line ends it.
|
|
72
|
+
for (let i = start + 1; i < raw.length; i++) {
|
|
73
|
+
const line = visible[i] ?? "";
|
|
74
|
+
if (/^[ \t]*(?:[-*+][ \t]+)?(\[\[[^\]|#]+\]\])[ \t]*$/.test(line)) items.add(i);
|
|
75
|
+
else if (line.trim()) break;
|
|
76
|
+
}
|
|
77
|
+
const first = items.values().next().value;
|
|
78
|
+
if (first === undefined) {
|
|
79
|
+
const addition = (raw[start]!.endsWith("\n") ? "" : newline) + list + (list ? newline : "");
|
|
80
|
+
return raw.slice(0, start + 1).join("") + addition + raw.slice(start + 1).join("");
|
|
81
|
+
}
|
|
82
|
+
const ending = /\r?\n$/.exec(raw[first]!)?.[0] ?? "";
|
|
83
|
+
return raw.map((line, i) => i === first ? list + (list ? ending : "") : items.has(i) ? "" : line).join("");
|
|
84
|
+
}
|
|
48
85
|
export function appendSection(source: string, section: string, markdown: string, path: string): string {
|
|
49
86
|
if (typeof section !== "string" || !section.trim() || section.length > 120 || /[\r\n]/.test(section) || section.trim().replace(/[ \t]+#+$/, "").trim().toLowerCase() === "topics") refuse("V05", "append cannot target Topics or an invalid section");
|
|
50
87
|
if (typeof markdown !== "string" || Buffer.byteLength(markdown) > 16384 || proseLines(markdown).some(line => /^ {0,3}##[ \t]+Topics(?:[ \t]+#+)?[ \t]*$/i.test(line))) refuse("V05", "append cannot introduce a Topics section or exceed 16384 bytes");
|
|
@@ -125,7 +162,15 @@ export function planOperation(operation: WriteOp, notes: Map<string, string>, re
|
|
|
125
162
|
if (!operation.set || typeof operation.set !== "object" || Array.isArray(operation.set) || (operation.unset !== undefined && (!Array.isArray(operation.unset) || operation.unset.some(key => typeof key !== "string")))) refuse("V01", "invalid frontmatter update");
|
|
126
163
|
const changes = {...operation.set};
|
|
127
164
|
for (const key of operation.unset ?? []) changes[key] = undefined;
|
|
128
|
-
try {
|
|
165
|
+
try {
|
|
166
|
+
const before = document(source, path);
|
|
167
|
+
let after = emitFrontmatter(before, normalizedSourceChanges(changes));
|
|
168
|
+
if (Object.hasOwn(operation.set, "topics") && !isDeepStrictEqual(changes.topics, before.data.topics) && Array.isArray(changes.topics) && changes.topics.every((value): value is string => typeof value === "string")) {
|
|
169
|
+
const doc = document(after, path);
|
|
170
|
+
after = (doc.frontmatter ?? "") + replaceTopicsList(doc.body, changes.topics, /\r?\n/.exec(source)?.[0] ?? "\n");
|
|
171
|
+
}
|
|
172
|
+
set(path, after);
|
|
173
|
+
} catch (error) { if (error instanceof WriteRefusal) throw error; refuse("V01", "cannot emit frontmatter", path); }
|
|
129
174
|
break;
|
|
130
175
|
}
|
|
131
176
|
case "link": {
|
package/src/write-validation.ts
CHANGED
|
@@ -181,22 +181,34 @@ function topicIssues(note: ParsedNote, index: Map<string, Set<ParsedNote>>, seve
|
|
|
181
181
|
}
|
|
182
182
|
return [];
|
|
183
183
|
}
|
|
184
|
-
|
|
185
|
-
|
|
184
|
+
/** V05 has two independent problems: placement (one Topics H2, and it is the last H2) and content (the last Topics section lists exactly the frontmatter topics). */
|
|
185
|
+
function topicProblems(note: ParsedNote): Set<"placement" | "content"> {
|
|
186
|
+
const problems = new Set<"placement" | "content">();
|
|
187
|
+
if (note.data.type === "moc" && note.data.topics === undefined) return problems;
|
|
186
188
|
const lines = proseLines(note.body), h2s = lines.flatMap((line, index) => /^ {0,3}##[ \t]+/.test(line) ? [index] : []);
|
|
187
189
|
const topics = h2s.filter(index => /^ {0,3}##[ \t]+Topics(?:[ \t]+#+)?[ \t]*$/i.test(lines[index]!));
|
|
188
|
-
|
|
190
|
+
if (topics.length !== 1 || h2s[h2s.length - 1] !== topics[0]) problems.add("placement");
|
|
191
|
+
const last = topics[topics.length - 1], end = h2s.find(index => last !== undefined && index > last) ?? lines.length;
|
|
189
192
|
const actual: string[] = [];
|
|
190
|
-
|
|
193
|
+
let content = last !== undefined;
|
|
194
|
+
if (last !== undefined) for (const line of lines.slice(last + 1, end)) {
|
|
191
195
|
if (!line.trim()) continue;
|
|
192
196
|
const wiki = /^[ \t]*(?:[-*+][ \t]+)?(\[\[[^\]|#]+\]\])[ \t]*$/.exec(line);
|
|
193
|
-
if (!wiki) {
|
|
197
|
+
if (!wiki) { content = false; break; }
|
|
194
198
|
actual.push(noteKey(wiki[1]!.slice(2, -2)));
|
|
195
199
|
}
|
|
196
200
|
const expected = Array.isArray(note.data.topics) ? note.data.topics.map(value => typeof value === "string" ? noteKey(value.slice(2, -2)) : "") : [];
|
|
197
201
|
const sortedExpected = [...expected].sort();
|
|
198
|
-
if (actual.length !== expected.length || [...actual].sort().some((value, index) => value !== sortedExpected[index]))
|
|
199
|
-
return
|
|
202
|
+
if (!content || actual.length !== expected.length || [...actual].sort().some((value, index) => value !== sortedExpected[index])) problems.add("content");
|
|
203
|
+
return problems;
|
|
204
|
+
}
|
|
205
|
+
/** An error unless every V05 problem of the new note was already present in the prior note (as V01 retains only unchanged findings). */
|
|
206
|
+
function topicPlacement(note: ParsedNote, old: ParsedNote | undefined, context: ValidationContext): WriteIssue[] {
|
|
207
|
+
const problems = topicProblems(note);
|
|
208
|
+
if (!problems.size) return [];
|
|
209
|
+
const before = context.operation.op !== "create" && old !== undefined ? topicProblems(old) : new Set<string>();
|
|
210
|
+
const retained = [...problems].every(problem => before.has(problem));
|
|
211
|
+
return [issue("V05", "Topics must be the final H2 and list exactly the frontmatter topics", retained ? "warning" : "error", note.path)];
|
|
200
212
|
}
|
|
201
213
|
|
|
202
214
|
/** Validate a complete mutation plan against the final overlaid snapshot, without filesystem writes. */
|
|
@@ -226,7 +238,7 @@ export function validateWrite(changes: PlannedChange[], context: ValidationConte
|
|
|
226
238
|
const note = newNotes.get(change.path);
|
|
227
239
|
if (!note) { issues.push(issue("V01", "invalid frontmatter", "error", change.path)); continue; }
|
|
228
240
|
const prior=context.operation.op==="move"&¬eKey(context.operation.to)===noteKey(note.path)?oldNotes.get(movedFrom(context.operation.from, context)):oldNotes.get(note.path);
|
|
229
|
-
issues.push(...schemaIssues(note,prior,context), ...topicPlacement(note));
|
|
241
|
+
issues.push(...schemaIssues(note,prior,context), ...topicPlacement(note,prior,context));
|
|
230
242
|
const source = note.data.source;
|
|
231
243
|
let sourceDenied=strings(source).some(entry=>isDenied(entry.replaceAll("\\","/"),context.config.denyPaths));
|
|
232
244
|
if(source!==undefined&&!sourceDenied)try{
|