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.
- package/README.md +61 -2
- package/bin/pagedex.js +108 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/TiptapEditor-OFHNcjy3.js +1 -0
- package/dist/assets/index-BXGU3AiE.css +1 -0
- package/dist/assets/index-ZVjt99Mq.js +6 -0
- package/dist/assets/vendor-hljs-B8we2J8a.js +5 -0
- package/dist/assets/vendor-markdown-C94ubdL7.js +71 -0
- package/dist/assets/vendor-react-E4kvVwMA.js +49 -0
- package/dist/assets/vendor-tiptap-ChgD7Piv.js +179 -0
- package/dist/favicon-32x32.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +7 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +22 -0
- package/dist/vault-logo.svg +15 -0
- package/dist/vault-mark.svg +5 -0
- package/package.json +52 -4
- package/src/server/dev.js +14 -0
- package/src/server/fs-ops.js +120 -0
- package/src/server/git.js +217 -0
- package/src/server/index.js +53 -0
- package/src/server/mcp.js +364 -0
- package/src/server/paths.js +84 -0
- package/src/server/routes/events.js +55 -0
- package/src/server/routes/files.js +254 -0
- package/src/server/routes/git.js +40 -0
- package/src/server/search-index.js +338 -0
- package/src/server/vault-cache.js +269 -0
- package/src/server/vault.js +4 -0
|
@@ -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
|
+
}
|