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,254 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import {
4
+ MIME_TYPES,
5
+ VAULT_ROOT,
6
+ deleteNote,
7
+ editNote,
8
+ getCachedVault,
9
+ isProtectedPath,
10
+ renameDir,
11
+ renameNote,
12
+ resolveSafe,
13
+ sortTree,
14
+ writeBinaryFile,
15
+ writeNote
16
+ } from '../vault.js';
17
+ import {
18
+ queryFiles,
19
+ removeFileFromIndex,
20
+ searchFts,
21
+ syncIndex,
22
+ upsertFileInIndex
23
+ } from '../search-index.js';
24
+
25
+ export function registerFileRoutes(app) {
26
+ app.get('/api/v1/health', (c) => c.json({ ok: true, root: VAULT_ROOT }));
27
+
28
+ app.get('/api/v1/tree', (c) => {
29
+ const rawLimit = Number(c.req.query('limit') || 10000);
30
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 10000) : 10000;
31
+ const sort = c.req.query('sort'); // 'mtime' | 'name' | undefined
32
+ const { tree, flatFiles, fileStats, sortedStats, backlinks } = getCachedVault();
33
+ const outTree = sort === 'mtime' ? sortTree(tree, 'mtime') : tree;
34
+ const outStats =
35
+ sort === 'name'
36
+ ? [...fileStats].sort((a, b) => a.path.localeCompare(b.path)).slice(0, limit)
37
+ : sortedStats.slice(0, limit);
38
+ return c.json({
39
+ tree: outTree,
40
+ flatFiles,
41
+ fileStats: outStats,
42
+ totalFiles: sortedStats.length,
43
+ truncated: sortedStats.length > limit,
44
+ backlinks
45
+ });
46
+ });
47
+
48
+ app.get('/api/v1/files', (c) => {
49
+ const sort = c.req.query('sort') === 'name' ? 'name' : 'mtime';
50
+ const offset = Number(c.req.query('offset') || 0);
51
+ const limit = Number(c.req.query('limit') || 100);
52
+ const q = c.req.query('q') || '';
53
+ return c.json(queryFiles({ sort, offset, limit, q }));
54
+ });
55
+
56
+ app.get('/api/v1/search', (c) => {
57
+ const q = c.req.query('q') || '';
58
+ const limit = Number(c.req.query('limit') || 50);
59
+ return c.json({ query: q, results: searchFts(q, limit) });
60
+ });
61
+
62
+ function buildRequestOrigins(c) {
63
+ const url = new URL(c.req.url);
64
+ const proto = (c.req.header('x-forwarded-proto') || url.protocol.replace(':', '')).split(',')[0].trim();
65
+ const host = (c.req.header('x-forwarded-host') || c.req.header('host') || url.host).split(',')[0].trim();
66
+ const origins = new Set([`${proto}://${host}`]);
67
+ const referer = c.req.header('referer');
68
+ if (referer) {
69
+ try {
70
+ origins.add(new URL(referer).origin);
71
+ } catch {
72
+ // ignore invalid referer
73
+ }
74
+ }
75
+ return Array.from(origins).join(' ');
76
+ }
77
+
78
+ function serveFileResponse(c, full, contentType, { isDownload = false, extraHeaders = {} } = {}) {
79
+ const stat = fs.statSync(full);
80
+ const size = stat.size;
81
+ const headers = {
82
+ 'Content-Type': contentType,
83
+ 'Accept-Ranges': 'bytes',
84
+ ...extraHeaders
85
+ };
86
+ if (isDownload) {
87
+ const fileName = path.basename(full).replace(/"/g, '');
88
+ headers['Content-Disposition'] = `attachment; filename="${fileName}"`;
89
+ }
90
+
91
+ const rangeHeader = !isDownload ? c.req.header('range') : null;
92
+ if (rangeHeader && rangeHeader.startsWith('bytes=')) {
93
+ const [rawStart, rawEnd] = rangeHeader.slice(6).split('-');
94
+ let start = rawStart === '' ? 0 : Number(rawStart);
95
+ let end = rawEnd === '' ? size - 1 : Number(rawEnd);
96
+ if (rawStart === '' && rawEnd !== '') {
97
+ start = Math.max(0, size - Number(rawEnd));
98
+ end = size - 1;
99
+ }
100
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end >= size || start > end) {
101
+ return new Response(null, {
102
+ status: 416,
103
+ headers: { 'Content-Range': `bytes */${size}` }
104
+ });
105
+ }
106
+ const chunkLen = end - start + 1;
107
+ const fd = fs.openSync(full, 'r');
108
+ const slice = Buffer.alloc(chunkLen);
109
+ try {
110
+ fs.readSync(fd, slice, 0, chunkLen, start);
111
+ } finally {
112
+ fs.closeSync(fd);
113
+ }
114
+ return new Response(slice, {
115
+ status: 206,
116
+ headers: {
117
+ ...headers,
118
+ 'Content-Range': `bytes ${start}-${end}/${size}`,
119
+ 'Content-Length': String(chunkLen)
120
+ }
121
+ });
122
+ }
123
+
124
+ const buf = fs.readFileSync(full);
125
+ return new Response(buf, { status: 200, headers });
126
+ }
127
+
128
+ app.get('/api/v1/file', (c) => {
129
+ const rel = c.req.query('path') || '';
130
+ const full = resolveSafe(rel);
131
+ if (!full || !fs.existsSync(full) || !fs.statSync(full).isFile()) {
132
+ return c.text('Not found', 404);
133
+ }
134
+ const ext = path.extname(full).toLowerCase();
135
+ const isDownload = c.req.query('download') === '1';
136
+ const contentType = MIME_TYPES[ext] || (isDownload ? 'application/octet-stream' : 'text/plain; charset=utf-8');
137
+ return serveFileResponse(c, full, contentType, { isDownload });
138
+ });
139
+
140
+ app.get('/raw/*', (c) => {
141
+ let rel = '';
142
+ try {
143
+ rel = decodeURIComponent(c.req.path.replace(/^\/raw\/?/, ''));
144
+ } catch {
145
+ return c.text('Bad path', 400);
146
+ }
147
+ if (!rel || isProtectedPath(rel) || path.basename(rel).startsWith('.env')) {
148
+ return c.text('Not found', 404);
149
+ }
150
+ const full = resolveSafe(rel);
151
+ if (!full || !fs.existsSync(full) || !fs.statSync(full).isFile()) {
152
+ return c.text('Not found', 404);
153
+ }
154
+ const ext = path.extname(full).toLowerCase();
155
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
156
+ const extraHeaders = {
157
+ 'Access-Control-Allow-Origin': '*'
158
+ };
159
+ if (ext === '.html' || ext === '.htm') {
160
+ const origins = buildRequestOrigins(c);
161
+ extraHeaders['Content-Security-Policy'] =
162
+ `default-src ${origins} 'unsafe-inline' 'unsafe-eval' data: blob:`;
163
+ }
164
+ return serveFileResponse(c, full, contentType, { extraHeaders });
165
+ });
166
+
167
+ async function handleWriteOrEdit(c) {
168
+ const body = await c.req.json().catch(() => null);
169
+ if (!body || typeof body.path !== 'string') {
170
+ return c.json({ error: 'Expected JSON with path' }, 400);
171
+ }
172
+ if (typeof body.target === 'string' && typeof body.replacement === 'string') {
173
+ const result = editNote(body.path, body.target, body.replacement, {
174
+ replaceAll: Boolean(body.replaceAll)
175
+ });
176
+ if (result.error) return c.json(result, 400);
177
+ upsertFileInIndex(result.path);
178
+ return c.json(result);
179
+ }
180
+ if (typeof body.content === 'string') {
181
+ const result = writeNote(body.path, body.content, { append: Boolean(body.append) });
182
+ if (!result) return c.json({ error: 'Invalid or protected path' }, 400);
183
+ upsertFileInIndex(result.path);
184
+ return c.json(result);
185
+ }
186
+ return c.json({ error: 'Provide either { content } or { target, replacement }' }, 400);
187
+ }
188
+
189
+ app.put('/api/v1/file', handleWriteOrEdit);
190
+ app.post('/api/v1/file', handleWriteOrEdit);
191
+ app.patch('/api/v1/file', handleWriteOrEdit);
192
+
193
+ app.delete('/api/v1/file', (c) => {
194
+ const rel = c.req.query('path') || '';
195
+ const result = deleteNote(rel);
196
+ if (!result) return c.json({ error: 'Not found or protected path' }, 404);
197
+ removeFileFromIndex(result.path);
198
+ return c.json(result);
199
+ });
200
+
201
+ app.post('/api/v1/file/rename', async (c) => {
202
+ const body = await c.req.json().catch(() => null);
203
+ if (!body || typeof body.oldPath !== 'string' || typeof body.newPath !== 'string') {
204
+ return c.json({ error: 'Expected JSON { oldPath, newPath }' }, 400);
205
+ }
206
+ const result = renameNote(body.oldPath, body.newPath);
207
+ if (result.error) return c.json(result, 400);
208
+ removeFileFromIndex(result.oldPath);
209
+ upsertFileInIndex(result.newPath);
210
+ return c.json(result);
211
+ });
212
+
213
+ app.post('/api/v1/dir/rename', async (c) => {
214
+ const body = await c.req.json().catch(() => null);
215
+ if (!body || typeof body.oldPath !== 'string' || typeof body.newPath !== 'string') {
216
+ return c.json({ error: 'Expected JSON { oldPath, newPath }' }, 400);
217
+ }
218
+ const result = renameDir(body.oldPath, body.newPath);
219
+ if (result.error) return c.json(result, 400);
220
+ syncIndex();
221
+ return c.json(result);
222
+ });
223
+
224
+ app.post('/api/v1/file/upload', async (c) => {
225
+ const body = await c.req.parseBody({ all: true }).catch(() => null);
226
+ if (!body) {
227
+ return c.json({ error: 'Expected multipart/form-data' }, 400);
228
+ }
229
+ const dir = typeof body.dir === 'string' ? body.dir.trim().replace(/^\/+|\/+$/g, '') : '';
230
+ const rawFiles = body.files ?? body.file;
231
+ const list = (Array.isArray(rawFiles) ? rawFiles : rawFiles ? [rawFiles] : []).filter(
232
+ (item) => item && typeof item === 'object' && typeof item.arrayBuffer === 'function' && item.name
233
+ );
234
+ if (list.length === 0) {
235
+ return c.json({ error: 'No files provided' }, 400);
236
+ }
237
+ const uploaded = [];
238
+ for (const file of list) {
239
+ const safeName = path.basename(String(file.name));
240
+ if (!safeName || safeName === '.' || safeName === '..') {
241
+ return c.json({ error: 'Invalid filename' }, 400);
242
+ }
243
+ const relPath = dir ? `${dir}/${safeName}` : safeName;
244
+ const buf = Buffer.from(await file.arrayBuffer());
245
+ const result = writeBinaryFile(relPath, buf);
246
+ if (!result) {
247
+ return c.json({ error: `Invalid or protected path: ${relPath}` }, 400);
248
+ }
249
+ upsertFileInIndex(result.path);
250
+ uploaded.push(result);
251
+ }
252
+ return c.json({ ok: true, uploaded });
253
+ });
254
+ }
@@ -0,0 +1,40 @@
1
+ import { discardGitChange, getGitChanges, getGitDiff, gitCommitAndPush } from '../vault.js';
2
+ import { upsertFileInIndex } from '../search-index.js';
3
+
4
+ export function registerGitRoutes(app) {
5
+ app.get('/api/v1/changes', (c) => {
6
+ return c.json(getGitChanges());
7
+ });
8
+
9
+ app.get('/api/v1/git/diff', (c) => {
10
+ const rel = c.req.query('path') || '';
11
+ const result = getGitDiff(rel);
12
+ if (result.error) return c.json(result, 400);
13
+ return c.json(result);
14
+ });
15
+
16
+ app.post('/api/v1/git/discard', async (c) => {
17
+ const body = await c.req.json().catch(() => null);
18
+ if (!body || typeof body.path !== 'string') {
19
+ return c.json({ error: 'Expected JSON { path, hunkIndex?, lineIndex? }' }, 400);
20
+ }
21
+ const result = discardGitChange(body);
22
+ if (result.error) return c.json(result, 400);
23
+ upsertFileInIndex(body.path);
24
+ return c.json(result);
25
+ });
26
+
27
+ app.post('/api/v1/git/commit', async (c) => {
28
+ const body = await c.req.json().catch(() => null);
29
+ if (!body || typeof body.message !== 'string' || !body.message.trim()) {
30
+ return c.json({ error: 'Commit message is required' }, 400);
31
+ }
32
+ const result = gitCommitAndPush({
33
+ message: body.message,
34
+ files: body.files,
35
+ push: Boolean(body.push)
36
+ });
37
+ if (result.error) return c.json(result, 400);
38
+ return c.json(result);
39
+ });
40
+ }
@@ -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
+ }