pagedex 0.0.0-stage → 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.
@@ -0,0 +1,338 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { DatabaseSync } from 'node:sqlite';
6
+ import { IGNORED, VAULT_ROOT, isImagePath, isProtectedPath, resolveSafe } from './vault.js';
7
+
8
+ const BINARY_EXT_RE = /\.(png|jpe?g|gif|webp|svg|ico|epub|pdf|zip|tar|gz|tgz|7z|rar|docx?|xlsx?|pptx?|odt|ods|odp|mp3|wav|m4a|mp4|mov|webm|mkv|avi|dmg|exe|bin|whl|sqlite|db)$/i;
9
+ const MAX_INDEX_BYTES = 2 * 1024 * 1024;
10
+
11
+ let db = null;
12
+ let syncedOnce = false;
13
+
14
+ function isTextIndexable(relPath, size) {
15
+ if (isImagePath(relPath) || BINARY_EXT_RE.test(relPath)) return false;
16
+ return size <= MAX_INDEX_BYTES;
17
+ }
18
+
19
+ function resolveDbPath() {
20
+ if (process.env.PAGEDEX_INDEX_PATH) return path.resolve(process.env.PAGEDEX_INDEX_PATH);
21
+ const cacheDir = process.env.PAGEDEX_INDEX_DIR || path.join(os.homedir(), '.cache', 'pagedex');
22
+ fs.mkdirSync(cacheDir, { recursive: true });
23
+ const hash = createHash('sha256').update(VAULT_ROOT).digest('hex').slice(0, 16);
24
+ return path.join(cacheDir, `${hash}.db`);
25
+ }
26
+
27
+ function getDb() {
28
+ if (db) return db;
29
+ const dbPath = resolveDbPath();
30
+ db = new DatabaseSync(dbPath);
31
+ db.exec(`
32
+ PRAGMA journal_mode = WAL;
33
+ PRAGMA synchronous = NORMAL;
34
+ CREATE TABLE IF NOT EXISTS files (
35
+ path TEXT PRIMARY KEY,
36
+ name TEXT NOT NULL,
37
+ mtime_ms INTEGER NOT NULL,
38
+ size INTEGER NOT NULL,
39
+ is_text INTEGER NOT NULL
40
+ );
41
+ CREATE INDEX IF NOT EXISTS idx_files_mtime ON files(mtime_ms DESC, path ASC);
42
+ CREATE INDEX IF NOT EXISTS idx_files_name ON files(name COLLATE NOCASE ASC, path ASC);
43
+ CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(
44
+ path,
45
+ content,
46
+ tokenize='trigram'
47
+ );
48
+ `);
49
+ return db;
50
+ }
51
+
52
+ function walkDiskFiles(dir = VAULT_ROOT, relBase = '', out = []) {
53
+ let entries;
54
+ try {
55
+ entries = fs.readdirSync(dir, { withFileTypes: true });
56
+ } catch {
57
+ return out;
58
+ }
59
+ for (const entry of entries) {
60
+ if (IGNORED.has(entry.name)) continue;
61
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
62
+ const full = path.join(dir, entry.name);
63
+ if (entry.isDirectory()) {
64
+ walkDiskFiles(full, rel, out);
65
+ } else if (entry.isFile()) {
66
+ try {
67
+ const st = fs.statSync(full);
68
+ out.push({
69
+ path: rel,
70
+ name: entry.name,
71
+ mtimeMs: Math.round(st.mtimeMs),
72
+ size: st.size,
73
+ full
74
+ });
75
+ } catch {
76
+ // Ignore unreadable transient files
77
+ }
78
+ }
79
+ }
80
+ return out;
81
+ }
82
+
83
+ export function syncIndex() {
84
+ const conn = getDb();
85
+ const diskFiles = walkDiskFiles();
86
+ const diskMap = new Map(diskFiles.map((f) => [f.path, f]));
87
+
88
+ const existingRows = conn.prepare('SELECT path, mtime_ms, size FROM files').all();
89
+ const existingMap = new Map(existingRows.map((r) => [r.path, r]));
90
+
91
+ const delFile = conn.prepare('DELETE FROM files WHERE path = ?');
92
+ const delFts = conn.prepare('DELETE FROM docs_fts WHERE path = ?');
93
+ const upsertFile = conn.prepare(`
94
+ INSERT INTO files (path, name, mtime_ms, size, is_text)
95
+ VALUES (?, ?, ?, ?, ?)
96
+ ON CONFLICT(path) DO UPDATE SET
97
+ name = excluded.name,
98
+ mtime_ms = excluded.mtime_ms,
99
+ size = excluded.size,
100
+ is_text = excluded.is_text
101
+ `);
102
+ const insertFts = conn.prepare('INSERT INTO docs_fts (path, content) VALUES (?, ?)');
103
+
104
+ conn.exec('BEGIN');
105
+ try {
106
+ for (const row of existingRows) {
107
+ if (!diskMap.has(row.path)) {
108
+ delFile.run(row.path);
109
+ delFts.run(row.path);
110
+ }
111
+ }
112
+ for (const f of diskFiles) {
113
+ const prev = existingMap.get(f.path);
114
+ if (prev && prev.mtime_ms === f.mtimeMs && prev.size === f.size) continue;
115
+ const textable = isTextIndexable(f.path, f.size);
116
+ let content = '';
117
+ if (textable) {
118
+ try {
119
+ content = fs.readFileSync(f.full, 'utf8');
120
+ } catch {
121
+ content = '';
122
+ }
123
+ }
124
+ upsertFile.run(f.path, f.name, f.mtimeMs, f.size, textable ? 1 : 0);
125
+ delFts.run(f.path);
126
+ if (textable) {
127
+ insertFts.run(f.path, content);
128
+ }
129
+ }
130
+ conn.exec('COMMIT');
131
+ } catch (err) {
132
+ conn.exec('ROLLBACK');
133
+ throw err;
134
+ }
135
+ syncedOnce = true;
136
+ return { total: diskFiles.length };
137
+ }
138
+
139
+ function ensureSynced() {
140
+ if (!syncedOnce) syncIndex();
141
+ }
142
+
143
+ export function upsertFileInIndex(relPath) {
144
+ if (!relPath || isProtectedPath(relPath)) return;
145
+ const clean = relPath.replace(/^\/+/, '');
146
+ const full = resolveSafe(clean);
147
+ if (!full) return;
148
+
149
+ const conn = getDb();
150
+ if (!fs.existsSync(full)) {
151
+ removeFileFromIndex(clean);
152
+ return;
153
+ }
154
+ let st;
155
+ try {
156
+ st = fs.statSync(full);
157
+ } catch {
158
+ return;
159
+ }
160
+ if (st.isDirectory()) {
161
+ syncIndex();
162
+ return;
163
+ }
164
+ if (!st.isFile()) return;
165
+
166
+ const mtimeMs = Math.round(st.mtimeMs);
167
+ const textable = isTextIndexable(clean, st.size);
168
+ let content = '';
169
+ if (textable) {
170
+ try {
171
+ content = fs.readFileSync(full, 'utf8');
172
+ } catch {
173
+ content = '';
174
+ }
175
+ }
176
+
177
+ conn.exec('BEGIN');
178
+ try {
179
+ conn
180
+ .prepare(
181
+ `INSERT INTO files (path, name, mtime_ms, size, is_text)
182
+ VALUES (?, ?, ?, ?, ?)
183
+ ON CONFLICT(path) DO UPDATE SET
184
+ name = excluded.name,
185
+ mtime_ms = excluded.mtime_ms,
186
+ size = excluded.size,
187
+ is_text = excluded.is_text`
188
+ )
189
+ .run(clean, path.basename(clean), mtimeMs, st.size, textable ? 1 : 0);
190
+ conn.prepare('DELETE FROM docs_fts WHERE path = ?').run(clean);
191
+ if (textable) {
192
+ conn.prepare('INSERT INTO docs_fts (path, content) VALUES (?, ?)').run(clean, content);
193
+ }
194
+ conn.exec('COMMIT');
195
+ } catch (err) {
196
+ conn.exec('ROLLBACK');
197
+ throw err;
198
+ }
199
+ }
200
+
201
+ export function removeFileFromIndex(relPath) {
202
+ if (!relPath) return;
203
+ const clean = relPath.replace(/^\/+/, '');
204
+ const conn = getDb();
205
+ conn.prepare('DELETE FROM files WHERE path = ?').run(clean);
206
+ conn.prepare('DELETE FROM docs_fts WHERE path = ?').run(clean);
207
+ }
208
+
209
+ export function queryFiles({ sort = 'mtime', offset = 0, limit = 100, q = '' } = {}) {
210
+ ensureSynced();
211
+ const conn = getDb();
212
+ const safeOffset = Math.max(0, Number(offset) || 0);
213
+ const safeLimit = Math.min(1000, Math.max(1, Number(limit) || 100));
214
+ const orderBy = sort === 'name' ? 'name COLLATE NOCASE ASC, path ASC' : 'mtime_ms DESC, path ASC';
215
+ const cleanQ = String(q || '').trim().toLowerCase();
216
+
217
+ let total;
218
+ let rows;
219
+ if (cleanQ) {
220
+ const pattern = `%${cleanQ.replace(/[%_\\]/g, '\\$&')}%`;
221
+ total = conn
222
+ .prepare("SELECT COUNT(*) AS cnt FROM files WHERE lower(path) LIKE ? ESCAPE '\\'")
223
+ .get(pattern).cnt;
224
+ rows = conn
225
+ .prepare(
226
+ `SELECT path, mtime_ms AS mtimeMs FROM files
227
+ WHERE lower(path) LIKE ? ESCAPE '\\'
228
+ ORDER BY ${orderBy}
229
+ LIMIT ? OFFSET ?`
230
+ )
231
+ .all(pattern, safeLimit, safeOffset);
232
+ } else {
233
+ total = conn.prepare('SELECT COUNT(*) AS cnt FROM files').get().cnt;
234
+ rows = conn
235
+ .prepare(
236
+ `SELECT path, mtime_ms AS mtimeMs FROM files
237
+ ORDER BY ${orderBy}
238
+ LIMIT ? OFFSET ?`
239
+ )
240
+ .all(safeLimit, safeOffset);
241
+ }
242
+
243
+ return {
244
+ items: rows,
245
+ total,
246
+ offset: safeOffset,
247
+ limit: safeLimit,
248
+ hasMore: safeOffset + rows.length < total
249
+ };
250
+ }
251
+
252
+ export function searchFts(query, limit = 50) {
253
+ ensureSynced();
254
+ const q = String(query || '').trim();
255
+ if (!q) return [];
256
+ const conn = getDb();
257
+ const safeLimit = Math.min(200, Math.max(1, Number(limit) || 50));
258
+
259
+ if (q.length >= 3) {
260
+ // Trigram FTS5 expects double-quoted literal; escape internal quotes
261
+ const ftsLiteral = `"${q.replace(/"/g, '""')}"`;
262
+ const rows = conn
263
+ .prepare(
264
+ `SELECT docs_fts.path AS path,
265
+ COALESCE(files.mtime_ms, 0) AS mtimeMs,
266
+ snippet(docs_fts, 1, '', '', '...', 16) AS snippet
267
+ FROM docs_fts
268
+ LEFT JOIN files ON files.path = docs_fts.path
269
+ WHERE docs_fts MATCH ?
270
+ ORDER BY files.mtime_ms DESC, docs_fts.path ASC
271
+ LIMIT ?`
272
+ )
273
+ .all(ftsLiteral, safeLimit);
274
+
275
+ // Also include non-text files whose path matches q (since non-text files aren't in docs_fts)
276
+ if (rows.length < safeLimit) {
277
+ const seen = new Set(rows.map((r) => r.path));
278
+ const pattern = `%${q.toLowerCase().replace(/[%_\\]/g, '\\$&')}%`;
279
+ const pathOnly = conn
280
+ .prepare(
281
+ `SELECT path, mtime_ms AS mtimeMs FROM files
282
+ WHERE is_text = 0 AND lower(path) LIKE ? ESCAPE '\\'
283
+ ORDER BY mtime_ms DESC
284
+ LIMIT ?`
285
+ )
286
+ .all(pattern, safeLimit - rows.length);
287
+ for (const r of pathOnly) {
288
+ if (!seen.has(r.path)) rows.push({ path: r.path, mtimeMs: r.mtimeMs, snippet: '' });
289
+ }
290
+ }
291
+ return rows;
292
+ }
293
+
294
+ // Short queries (1-2 chars): trigram requires >=3 chars, so match path + scan recent text files
295
+ const lowerQ = q.toLowerCase();
296
+ const pattern = `%${lowerQ.replace(/[%_\\]/g, '\\$&')}%`;
297
+ const results = [];
298
+ const seen = new Set();
299
+
300
+ const pathRows = conn
301
+ .prepare(
302
+ `SELECT path, mtime_ms AS mtimeMs FROM files
303
+ WHERE lower(path) LIKE ? ESCAPE '\\'
304
+ ORDER BY mtime_ms DESC
305
+ LIMIT ?`
306
+ )
307
+ .all(pattern, safeLimit);
308
+
309
+ for (const r of pathRows) {
310
+ seen.add(r.path);
311
+ results.push({ path: r.path, mtimeMs: r.mtimeMs, snippet: '' });
312
+ }
313
+
314
+ if (results.length < safeLimit) {
315
+ const recentText = conn
316
+ .prepare('SELECT path, mtime_ms AS mtimeMs FROM files WHERE is_text = 1 ORDER BY mtime_ms DESC LIMIT 500')
317
+ .all();
318
+ for (const r of recentText) {
319
+ if (seen.has(r.path)) continue;
320
+ const full = path.join(VAULT_ROOT, r.path);
321
+ try {
322
+ const text = fs.readFileSync(full, 'utf8');
323
+ const idx = text.toLowerCase().indexOf(lowerQ);
324
+ if (idx !== -1) {
325
+ const snippet = text
326
+ .slice(Math.max(0, idx - 60), Math.min(text.length, idx + q.length + 100))
327
+ .replace(/\s+/g, ' ')
328
+ .trim();
329
+ results.push({ path: r.path, mtimeMs: r.mtimeMs, snippet });
330
+ if (results.length >= safeLimit) break;
331
+ }
332
+ } catch {
333
+ // Ignore transient read error
334
+ }
335
+ }
336
+ }
337
+ return results;
338
+ }
@@ -0,0 +1,269 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { IGNORED, VAULT_ROOT, isImagePath, isProtectedPath, resolveSafe } from './paths.js';
4
+
5
+ let vaultCache = null;
6
+
7
+ export function invalidateVaultCache() {
8
+ vaultCache = null;
9
+ }
10
+
11
+ function sortTreeNodes(nodes) {
12
+ nodes.sort((a, b) => {
13
+ if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
14
+ return a.name.localeCompare(b.name);
15
+ });
16
+ }
17
+
18
+ function insertTreeFile(nodes, relPath, mtimeMs = 0) {
19
+ const parts = relPath.split('/');
20
+ let list = nodes;
21
+ for (let i = 0; i < parts.length - 1; i++) {
22
+ const dirName = parts[i];
23
+ const dirPath = parts.slice(0, i + 1).join('/');
24
+ let dirNode = list.find((n) => n.type === 'dir' && n.name === dirName);
25
+ if (!dirNode) {
26
+ dirNode = { type: 'dir', name: dirName, path: dirPath, mtimeMs, children: [] };
27
+ list.push(dirNode);
28
+ sortTreeNodes(list);
29
+ } else if (mtimeMs > (dirNode.mtimeMs || 0)) {
30
+ dirNode.mtimeMs = mtimeMs;
31
+ }
32
+ list = dirNode.children;
33
+ }
34
+ const fileName = parts[parts.length - 1];
35
+ const existing = list.find((n) => n.type === 'file' && n.path === relPath);
36
+ if (!existing) {
37
+ list.push({ type: 'file', name: fileName, path: relPath, mtimeMs });
38
+ sortTreeNodes(list);
39
+ } else {
40
+ existing.mtimeMs = mtimeMs;
41
+ }
42
+ }
43
+
44
+ function removeTreeFile(nodes, relPath) {
45
+ const parts = relPath.split('/');
46
+ if (parts.length === 1) {
47
+ const idx = nodes.findIndex((n) => n.type === 'file' && n.name === parts[0]);
48
+ if (idx !== -1) nodes.splice(idx, 1);
49
+ return;
50
+ }
51
+ const dirNode = nodes.find((n) => n.type === 'dir' && n.name === parts[0]);
52
+ if (!dirNode) return;
53
+ removeTreeFile(dirNode.children, parts.slice(1).join('/'));
54
+ dirNode.mtimeMs = dirNode.children.reduce((max, n) => Math.max(max, n.mtimeMs || 0), 0);
55
+ if (dirNode.children.length === 0 && !fs.existsSync(path.join(VAULT_ROOT, dirNode.path))) {
56
+ const idx = nodes.indexOf(dirNode);
57
+ if (idx !== -1) nodes.splice(idx, 1);
58
+ }
59
+ }
60
+
61
+ function createWikiResolver(flatFiles) {
62
+ const mdFiles = flatFiles.filter((p) => p.endsWith('.md'));
63
+ const byName = new Map();
64
+ const byNoExt = new Map();
65
+
66
+ for (const p of mdFiles) {
67
+ const noExt = p.slice(0, -3);
68
+ const base = path.basename(noExt).toLowerCase();
69
+ byNoExt.set(noExt.toLowerCase(), p);
70
+ if (!byName.has(base)) byName.set(base, p);
71
+ }
72
+
73
+ return function resolveWiki(rawTarget) {
74
+ const clean = rawTarget.split('|')[0].split('#')[0].trim().replace(/\.md$/i, '').replace(/^\/+/, '');
75
+ if (!clean) return null;
76
+ const lower = clean.toLowerCase();
77
+ if (byNoExt.has(lower)) return byNoExt.get(lower);
78
+ const base = path.basename(lower);
79
+ return byName.get(base) || null;
80
+ };
81
+ }
82
+
83
+ function extractWikiTargets(text, resolveWiki) {
84
+ const stripped = text.replace(/```[\s\S]*?```/g, '');
85
+ const wikiRe = /\[\[([^\]]+)\]\]/g;
86
+ const targets = new Set();
87
+ let match;
88
+ while ((match = wikiRe.exec(stripped)) !== null) {
89
+ const target = resolveWiki(match[1]);
90
+ if (target) targets.add(target);
91
+ }
92
+ return targets;
93
+ }
94
+
95
+ export function removeVaultCacheFile(relPath) {
96
+ if (!vaultCache || !relPath) return;
97
+ const clean = relPath.replace(/^\/+/, '');
98
+ const fIdx = vaultCache.flatFiles.indexOf(clean);
99
+ if (fIdx !== -1) vaultCache.flatFiles.splice(fIdx, 1);
100
+ vaultCache.fileStats = vaultCache.fileStats.filter((f) => f.path !== clean);
101
+ vaultCache.sortedStats = vaultCache.sortedStats.filter((f) => f.path !== clean);
102
+ removeTreeFile(vaultCache.tree, clean);
103
+ delete vaultCache.backlinks[clean];
104
+ for (const list of Object.values(vaultCache.backlinks)) {
105
+ const i = list.indexOf(clean);
106
+ if (i !== -1) list.splice(i, 1);
107
+ }
108
+ }
109
+
110
+ export function updateVaultCacheFile(relPath) {
111
+ if (!vaultCache || !relPath) return;
112
+ const clean = relPath.replace(/^\/+/, '');
113
+ if (isProtectedPath(clean)) return;
114
+ const full = resolveSafe(clean);
115
+ if (!full || !fs.existsSync(full)) {
116
+ removeVaultCacheFile(clean);
117
+ return;
118
+ }
119
+ let st;
120
+ try {
121
+ st = fs.statSync(full);
122
+ } catch {
123
+ return;
124
+ }
125
+ if (st.isDirectory()) {
126
+ invalidateVaultCache();
127
+ return;
128
+ }
129
+ if (!st.isFile()) return;
130
+
131
+ const mtimeMs = Math.round(st.mtimeMs);
132
+ if (!vaultCache.flatFiles.includes(clean)) {
133
+ vaultCache.flatFiles.push(clean);
134
+ }
135
+ insertTreeFile(vaultCache.tree, clean, mtimeMs);
136
+
137
+ const statEntry = vaultCache.fileStats.find((f) => f.path === clean);
138
+ if (statEntry) statEntry.mtimeMs = mtimeMs;
139
+ else vaultCache.fileStats.push({ path: clean, mtimeMs });
140
+
141
+ vaultCache.sortedStats = vaultCache.sortedStats.filter((f) => f.path !== clean);
142
+ vaultCache.sortedStats.unshift({ path: clean, mtimeMs });
143
+ vaultCache.sortedStats.sort((a, b) => b.mtimeMs - a.mtimeMs);
144
+
145
+ if (clean.endsWith('.md')) {
146
+ if (!vaultCache.backlinks[clean]) vaultCache.backlinks[clean] = [];
147
+ for (const list of Object.values(vaultCache.backlinks)) {
148
+ const i = list.indexOf(clean);
149
+ if (i !== -1) list.splice(i, 1);
150
+ }
151
+ try {
152
+ const text = fs.readFileSync(full, 'utf8');
153
+ const resolveWiki = createWikiResolver(vaultCache.flatFiles);
154
+ for (const target of extractWikiTargets(text, resolveWiki)) {
155
+ if (target !== clean) {
156
+ if (!vaultCache.backlinks[target]) vaultCache.backlinks[target] = [];
157
+ vaultCache.backlinks[target].push(clean);
158
+ }
159
+ }
160
+ } catch {
161
+ // Ignore transient read error
162
+ }
163
+ }
164
+ }
165
+
166
+ export function sortTree(nodes, sort = 'name') {
167
+ if (sort !== 'mtime') return nodes;
168
+ return [...nodes]
169
+ .map((n) => (n.type === 'dir' ? { ...n, children: sortTree(n.children, sort) } : n))
170
+ .sort((a, b) => (b.mtimeMs || 0) - (a.mtimeMs || 0) || a.name.localeCompare(b.name));
171
+ }
172
+
173
+ export function scanVault(dir = VAULT_ROOT, relBase = '') {
174
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
175
+ const dirs = [];
176
+ const files = [];
177
+ const flatFiles = [];
178
+ const fileStats = [];
179
+
180
+ for (const entry of entries) {
181
+ if (IGNORED.has(entry.name)) continue;
182
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
183
+ const full = path.join(dir, entry.name);
184
+
185
+ if (entry.isDirectory()) {
186
+ const sub = scanVault(full, rel);
187
+ const dirMtime = sub.tree.reduce((max, n) => Math.max(max, n.mtimeMs || 0), 0);
188
+ dirs.push({ type: 'dir', name: entry.name, path: rel, mtimeMs: dirMtime, children: sub.tree });
189
+ flatFiles.push(...sub.flatFiles);
190
+ fileStats.push(...sub.fileStats);
191
+ } else if (entry.isFile()) {
192
+ let mtimeMs = 0;
193
+ try {
194
+ mtimeMs = Math.round(fs.statSync(full).mtimeMs);
195
+ } catch {
196
+ mtimeMs = 0;
197
+ }
198
+ files.push({ type: 'file', name: entry.name, path: rel, mtimeMs });
199
+ flatFiles.push(rel);
200
+ fileStats.push({ path: rel, mtimeMs });
201
+ }
202
+ }
203
+
204
+ dirs.sort((a, b) => a.name.localeCompare(b.name));
205
+ files.sort((a, b) => a.name.localeCompare(b.name));
206
+ return { tree: [...dirs, ...files], flatFiles, fileStats };
207
+ }
208
+
209
+ export function buildLinkIndex(flatFiles) {
210
+ const mdFiles = flatFiles.filter((p) => p.endsWith('.md'));
211
+ const resolveWiki = createWikiResolver(flatFiles);
212
+ const backlinks = Object.fromEntries(mdFiles.map((p) => [p, []]));
213
+
214
+ for (const src of mdFiles) {
215
+ const full = path.join(VAULT_ROOT, src);
216
+ let text = '';
217
+ try {
218
+ text = fs.readFileSync(full, 'utf8');
219
+ } catch {
220
+ continue;
221
+ }
222
+ for (const target of extractWikiTargets(text, resolveWiki)) {
223
+ if (target !== src) {
224
+ backlinks[target]?.push(src);
225
+ }
226
+ }
227
+ }
228
+
229
+ return { backlinks, mdFiles };
230
+ }
231
+
232
+ export function getCachedVault() {
233
+ if (vaultCache) return vaultCache;
234
+ const { tree, flatFiles, fileStats } = scanVault();
235
+ const { backlinks } = buildLinkIndex(flatFiles);
236
+ const sortedStats = [...fileStats].sort((a, b) => b.mtimeMs - a.mtimeMs);
237
+ vaultCache = { tree, flatFiles, fileStats, sortedStats, backlinks };
238
+ return vaultCache;
239
+ }
240
+
241
+ export function searchVault(query, limit = 50) {
242
+ const q = String(query || '').trim().toLowerCase();
243
+ if (!q) return [];
244
+ const { fileStats } = scanVault();
245
+ const sorted = [...fileStats].sort((a, b) => b.mtimeMs - a.mtimeMs);
246
+ const results = [];
247
+
248
+ for (const { path: rel, mtimeMs } of sorted) {
249
+ if (isImagePath(rel)) continue;
250
+ const full = path.join(VAULT_ROOT, rel);
251
+ let text = '';
252
+ try {
253
+ text = fs.readFileSync(full, 'utf8');
254
+ } catch {
255
+ continue;
256
+ }
257
+ const pathMatch = rel.toLowerCase().includes(q);
258
+ const idx = text.toLowerCase().indexOf(q);
259
+ if (pathMatch || idx !== -1) {
260
+ const snippet =
261
+ idx !== -1
262
+ ? text.slice(Math.max(0, idx - 60), Math.min(text.length, idx + q.length + 100)).replace(/\s+/g, ' ').trim()
263
+ : '';
264
+ results.push({ path: rel, mtimeMs, snippet });
265
+ if (results.length >= limit) break;
266
+ }
267
+ }
268
+ return results;
269
+ }
@@ -0,0 +1,4 @@
1
+ export * from './paths.js';
2
+ export * from './vault-cache.js';
3
+ export * from './fs-ops.js';
4
+ export * from './git.js';