pagedex 0.0.0-stage → 0.1.2

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,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';