pathnote 0.0.0-stage → 0.2.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.
- package/LICENSE +21 -0
- package/README.md +89 -2
- package/bin/pathnote.js +50 -0
- package/examples/sample-vault/Ideas.md +5 -0
- package/examples/sample-vault/Meetings/Oct team meeting.md +7 -0
- package/examples/sample-vault/Meetings/Weekly review.md +3 -0
- package/examples/sample-vault/Product/Budget plan.md +3 -0
- package/examples/sample-vault/Product/Product roadmap.md +8 -0
- package/examples/sample-vault/README.md +5 -0
- package/examples/sample-vault/Research/Competitor comparison.md +5 -0
- package/examples/sample-vault/Research/Customer interviews.md +9 -0
- package/examples/sample-vault/Research/Signup metrics.md +7 -0
- package/examples/sample-vault/Research/User feedback.md +7 -0
- package/package.json +39 -4
- package/src/documents.js +98 -0
- package/src/history.js +62 -0
- package/src/hub.js +137 -0
- package/src/mcp.js +100 -0
- package/src/workspace.js +244 -0
- package/viewer/index.html +407 -0
package/src/mcp.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The MCP tools the AI uses. Each call does its work and tells the viewer what happened.
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
const reason = z.string().optional().describe('One short sentence on why you are doing this. Shown to the user in the Pathnote viewer.');
|
|
6
|
+
const text = s => ({ content: [{ type: 'text', text: s }] });
|
|
7
|
+
const fail = e => ({ content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true });
|
|
8
|
+
|
|
9
|
+
export function createServer({ ws, history, hub, version }) {
|
|
10
|
+
const server = new McpServer({ name: 'pathnote', version });
|
|
11
|
+
const where = `Connected folders: ${ws.roots.join(', ')}. Use full paths.`;
|
|
12
|
+
|
|
13
|
+
server.registerTool('list_files', {
|
|
14
|
+
title: 'List a folder',
|
|
15
|
+
description: `List what is inside a folder (folders end with "/"). Without a folder, lists the connected folders. ${where}`,
|
|
16
|
+
inputSchema: { folder: z.string().optional(), reason }
|
|
17
|
+
}, async ({ folder, reason }) => {
|
|
18
|
+
try {
|
|
19
|
+
if (!folder) return text(ws.roots.join('\n'));
|
|
20
|
+
const abs = ws.resolve(folder);
|
|
21
|
+
const items = await ws.list(abs);
|
|
22
|
+
await hub.emit({ type: 'list', path: abs, reason });
|
|
23
|
+
return text(items.join('\n') || '(empty folder)');
|
|
24
|
+
} catch (e) { return fail(e); }
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
server.registerTool('search_files', {
|
|
28
|
+
title: 'Search files',
|
|
29
|
+
description: `Search text files and documents (PDF, Word, PowerPoint, Excel, Hangul) in the connected folders for words. Returns the best matching paths with a snippet each. ${where}`,
|
|
30
|
+
inputSchema: { query: z.string().describe('Words to look for'), reason }
|
|
31
|
+
}, async ({ query, reason }) => {
|
|
32
|
+
try {
|
|
33
|
+
const hits = await ws.search(query);
|
|
34
|
+
await hub.emit({ type: 'search', query, hits: hits.map(h => h.path), reason });
|
|
35
|
+
return text(hits.length ? hits.map(h => `${h.path}\n ${h.snippet}`).join('\n') : 'No matches.');
|
|
36
|
+
} catch (e) { return fail(e); }
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
server.registerTool('read_file', {
|
|
40
|
+
title: 'Read a file',
|
|
41
|
+
description: `Read a file: text files (up to 2 MB) as they are, PDF, Word (.docx), PowerPoint (.pptx), Excel (.xlsx) and Hangul (.hwpx) as their text, and pictures (.png, .jpg, .gif, .webp, .heic) as images you can see. ${where}`,
|
|
42
|
+
inputSchema: { path: z.string(), reason }
|
|
43
|
+
}, async ({ path, reason }) => {
|
|
44
|
+
try {
|
|
45
|
+
const abs = ws.resolve(path);
|
|
46
|
+
const file = await ws.open(abs);
|
|
47
|
+
await hub.emit({ type: 'read', path: abs, reason });
|
|
48
|
+
if (file.kind === 'image') return { content: [{ type: 'image', data: file.data, mimeType: file.mimeType }] };
|
|
49
|
+
return text(file.text);
|
|
50
|
+
} catch (e) { return fail(e); }
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
server.registerTool('write_file', {
|
|
54
|
+
title: 'Write a file',
|
|
55
|
+
description: `Create a text file, or replace a text file's whole text (not documents or pictures). The user can undo this from the Pathnote viewer. In markdown notes, link the notes you used with [[Note name]]. ${where}`,
|
|
56
|
+
inputSchema: { path: z.string(), content: z.string(), reason }
|
|
57
|
+
}, async ({ path, content, reason }) => {
|
|
58
|
+
try {
|
|
59
|
+
const abs = ws.resolve(path);
|
|
60
|
+
const before = await ws.readOrNull(abs);
|
|
61
|
+
const change = await history.record(abs, before, content, before === null ? 'create' : 'write');
|
|
62
|
+
await ws.write(abs, content);
|
|
63
|
+
await hub.emit({ type: 'write', path: abs, created: before === null, changeId: change.id, reason });
|
|
64
|
+
return text(`${before === null ? 'Created' : 'Replaced'} ${abs}.`);
|
|
65
|
+
} catch (e) { return fail(e); }
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
server.registerTool('edit_file', {
|
|
69
|
+
title: 'Edit a file',
|
|
70
|
+
description: `Replace one exact piece of text in a file. \`find\` must appear exactly once. The user can undo this from the Pathnote viewer. ${where}`,
|
|
71
|
+
inputSchema: { path: z.string(), find: z.string().min(1), replace: z.string(), reason }
|
|
72
|
+
}, async ({ path, find, replace, reason }) => {
|
|
73
|
+
try {
|
|
74
|
+
const abs = ws.resolve(path);
|
|
75
|
+
const before = await ws.read(abs);
|
|
76
|
+
const count = before.split(find).length - 1;
|
|
77
|
+
if (count !== 1) throw new Error(count ? `The text appears ${count} times; include more of it so it is unique.` : 'The text was not found.');
|
|
78
|
+
const after = before.replace(find, () => replace);
|
|
79
|
+
const change = await history.record(abs, before, after, 'edit');
|
|
80
|
+
await ws.write(abs, after);
|
|
81
|
+
await hub.emit({ type: 'edit', path: abs, changeId: change.id, reason });
|
|
82
|
+
return text(`Edited ${abs}.`);
|
|
83
|
+
} catch (e) { return fail(e); }
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
server.registerTool('move_file', {
|
|
87
|
+
title: 'Move a file or folder',
|
|
88
|
+
description: `Move or rename a file or folder (any type, including pictures and videos) to a new full path. Missing parent folders are created. Never overwrites an existing path. The user can undo this from the Pathnote viewer. ${where}`,
|
|
89
|
+
inputSchema: { from: z.string(), to: z.string(), reason }
|
|
90
|
+
}, async ({ from, to, reason }) => {
|
|
91
|
+
try {
|
|
92
|
+
const moved = await ws.move(from, to);
|
|
93
|
+
const change = await history.recordMove(moved.from, moved.to);
|
|
94
|
+
await hub.emit({ type: 'move', path: moved.to, from: moved.from, dir: moved.dir, changeId: change.id, reason });
|
|
95
|
+
return text(`Moved ${moved.from} to ${moved.to}.`);
|
|
96
|
+
} catch (e) { return fail(e); }
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return server;
|
|
100
|
+
}
|
package/src/workspace.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// The folders Pathnote may touch. Every path from the AI is checked against them: it must be
|
|
2
|
+
// inside one of the folders (after following symlinks), not in a skipped folder, and not a
|
|
3
|
+
// file that usually holds secrets.
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { isDocument, isImage, documentText, imageContent, MAX_DOC } from './documents.js';
|
|
8
|
+
|
|
9
|
+
const SKIP = new Set(['.git', '.obsidian', '.trash', '.Trash', 'node_modules', '.build', 'build', 'DerivedData', 'dist', '.wrangler', '.venv', '__pycache__']);
|
|
10
|
+
// Word and LibreOffice leave these lock files next to open documents
|
|
11
|
+
const LOCK = /^(~\$|\.~lock\.)/;
|
|
12
|
+
const SECRET_DIRS = new Set(['secrets', '.ssh', '.gnupg', '.aws']);
|
|
13
|
+
const SECRET_FILE = /^(\.env(\..*)?|.*\.(pem|p8|p12|key|keystore|jks)|id_(rsa|ed25519|ecdsa)(\.pub)?|credentials\.json|token\.json|client_secret.*\.json|\.npmrc|\.netrc)$/i;
|
|
14
|
+
const TEXT = /\.(md|mdx|txt|markdown|csv|tsv|json|jsonc|ya?ml|toml|ini|cfg|conf|xml|html?|css|scss|js|mjs|cjs|jsx|ts|tsx|py|rb|go|rs|java|kt|swift|m|h|c|cc|cpp|hpp|cs|php|sh|zsh|bash|sql|r|lua|tex|bib|srt|vtt|log)$/i;
|
|
15
|
+
const LINK = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g;
|
|
16
|
+
const MAX_READ = 2 * 1024 * 1024;
|
|
17
|
+
const MAX_SEARCH = 512 * 1024;
|
|
18
|
+
export const MAX_NODES = 6000;
|
|
19
|
+
|
|
20
|
+
const BLOCKED = 'macOS is blocking access to this folder. Allow it in System Settings → Privacy & Security → Files and Folders (for the app that runs Pathnote), then try again.';
|
|
21
|
+
|
|
22
|
+
// macOS holds file calls on protected folders (Desktop, Downloads, iCloud Drive) until the user
|
|
23
|
+
// answers a permission prompt, possibly forever. Give up after `ms` instead of hanging.
|
|
24
|
+
const timeout = (promise, ms) => Promise.race([promise, new Promise((_, no) => setTimeout(() => no(Object.assign(new Error(BLOCKED), { code: 'EBLOCKED' })), ms))]);
|
|
25
|
+
|
|
26
|
+
const exists = p => fs.access(p).then(() => true, () => false);
|
|
27
|
+
|
|
28
|
+
// First file under `dir` that looks like it holds secrets, or null
|
|
29
|
+
async function findSecret(dir) {
|
|
30
|
+
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
|
|
31
|
+
const abs = path.join(dir, e.name);
|
|
32
|
+
if (SECRET_FILE.test(e.name) || (e.isDirectory() && SECRET_DIRS.has(e.name))) return abs;
|
|
33
|
+
if (e.isDirectory()) { const found = await findSecret(abs); if (found) return found; }
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const expand = p => String(p || '').trim().replace(/^~(?=$|\/)/, os.homedir());
|
|
39
|
+
|
|
40
|
+
export class Workspace {
|
|
41
|
+
constructor(folders) {
|
|
42
|
+
this.roots = [...new Set(folders.map(f => path.resolve(expand(f))))];
|
|
43
|
+
this.id = this.roots.join('\n');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Full path for something the AI named. Relative paths are allowed when there is one folder.
|
|
47
|
+
resolve(p) {
|
|
48
|
+
let s = expand(p);
|
|
49
|
+
if (!s) throw new Error('Path is empty.');
|
|
50
|
+
if (!path.isAbsolute(s)) {
|
|
51
|
+
if (this.roots.length > 1) throw new Error(`Use a full path, e.g. ${path.join(this.roots[0], s)}`);
|
|
52
|
+
s = path.join(this.roots[0], s);
|
|
53
|
+
}
|
|
54
|
+
const abs = path.resolve(s);
|
|
55
|
+
const root = this.rootOf(abs);
|
|
56
|
+
if (!root) throw new Error(`"${p}" is outside the connected folders: ${this.roots.join(', ')}`);
|
|
57
|
+
const parts = path.relative(root, abs).split(path.sep);
|
|
58
|
+
if (parts.some(x => SKIP.has(x))) throw new Error(`"${p}" is in a folder Pathnote skips.`);
|
|
59
|
+
if (parts.some(x => SECRET_DIRS.has(x)) || SECRET_FILE.test(path.basename(abs))) throw new Error(`"${p}" looks like it holds secrets, so Pathnote won't open it.`);
|
|
60
|
+
return abs;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
rootOf(abs) {
|
|
64
|
+
return this.roots.find(r => abs === r || abs.startsWith(r + path.sep));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// resolve(), then the same check after following symlinks
|
|
68
|
+
async real(p) {
|
|
69
|
+
const abs = this.resolve(p);
|
|
70
|
+
let at = abs;
|
|
71
|
+
for (;;) {
|
|
72
|
+
try { at = await timeout(fs.realpath(at), 5000); break; } catch (e) { if (e.code !== 'ENOENT') throw e; at = path.dirname(at); }
|
|
73
|
+
}
|
|
74
|
+
const roots = await Promise.all(this.roots.map(r => fs.realpath(r).catch(() => r)));
|
|
75
|
+
if (!roots.some(r => at === r || at.startsWith(r + path.sep))) throw new Error(`"${p}" leads outside the connected folders.`);
|
|
76
|
+
return abs;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Immediate contents of a folder; folders end with "/"
|
|
80
|
+
async list(dir) {
|
|
81
|
+
const abs = await this.real(dir);
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const e of await timeout(fs.readdir(abs, { withFileTypes: true }), 5000)) {
|
|
84
|
+
if (e.name.startsWith('.') || LOCK.test(e.name) || SKIP.has(e.name) || SECRET_DIRS.has(e.name) || SECRET_FILE.test(e.name)) continue;
|
|
85
|
+
out.push(e.isDirectory() ? e.name + '/' : e.name);
|
|
86
|
+
}
|
|
87
|
+
return out.sort();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async read(p) {
|
|
91
|
+
const abs = await this.real(p);
|
|
92
|
+
const st = await timeout(fs.stat(abs), 5000);
|
|
93
|
+
if (st.isDirectory()) throw new Error(`"${p}" is a folder. Use list_files.`);
|
|
94
|
+
if (st.size > MAX_READ) throw new Error(`"${p}" is ${(st.size / 1048576).toFixed(1)} MB; Pathnote reads files up to 2 MB.`);
|
|
95
|
+
const buf = await timeout(fs.readFile(abs), 10000);
|
|
96
|
+
if (buf.subarray(0, 8000).includes(0)) throw new Error(`"${p}" is not a text file.`);
|
|
97
|
+
return buf.toString('utf8');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Anything the AI can take in: text as is, documents as their text, pictures as images
|
|
101
|
+
async open(p) {
|
|
102
|
+
const abs = await this.real(p);
|
|
103
|
+
if (!isDocument(abs) && !isImage(abs)) return { kind: 'text', text: await this.read(abs) };
|
|
104
|
+
const st = await timeout(fs.stat(abs), 5000);
|
|
105
|
+
if (st.size > MAX_DOC) throw new Error(`"${p}" is ${(st.size / 1048576).toFixed(1)} MB; Pathnote opens documents and images up to 50 MB.`);
|
|
106
|
+
const buf = await timeout(fs.readFile(abs), 15000);
|
|
107
|
+
if (isImage(abs)) return { kind: 'image', ...(await timeout(imageContent(abs, buf), 20000)) };
|
|
108
|
+
return { kind: 'document', text: await timeout(documentText(abs, buf), 30000) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Document text for search, kept until the file changes
|
|
112
|
+
async docText(abs) {
|
|
113
|
+
const st = await fs.stat(abs);
|
|
114
|
+
if (st.size > MAX_DOC) return null;
|
|
115
|
+
const hit = this.docs?.get(abs);
|
|
116
|
+
if (hit?.mtime === st.mtimeMs) return hit.text;
|
|
117
|
+
const text = await timeout(documentText(abs, await fs.readFile(abs)), 15000);
|
|
118
|
+
(this.docs ??= new Map()).set(abs, { mtime: st.mtimeMs, text });
|
|
119
|
+
return text;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Read every document's text once in the background, so the first search doesn't wait for it
|
|
123
|
+
async warm() {
|
|
124
|
+
for (const it of await this.walk()) {
|
|
125
|
+
if (it.kind === 'file' && isDocument(it.id)) await this.docText(it.id).catch(() => {});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async readOrNull(p) {
|
|
130
|
+
try { return await this.read(p); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async write(p, text) {
|
|
134
|
+
const abs = await this.real(p);
|
|
135
|
+
if (isDocument(abs) || isImage(abs)) throw new Error(`Pathnote can read ${path.extname(abs)} files but not write them.`);
|
|
136
|
+
await timeout(fs.mkdir(path.dirname(abs), { recursive: true }), 5000);
|
|
137
|
+
await timeout(fs.writeFile(abs, text, 'utf8'), 10000);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Move a file or folder (any type) to a new path in the connected folders. Never overwrites,
|
|
141
|
+
// and refuses folders that hold secret files, since those would leave the protected place.
|
|
142
|
+
async move(from, to) {
|
|
143
|
+
const src = await this.real(from), dst = await this.real(to);
|
|
144
|
+
if (this.roots.includes(src)) throw new Error(`"${from}" is a connected folder itself; move what is inside it instead.`);
|
|
145
|
+
const st = await timeout(fs.stat(src), 5000);
|
|
146
|
+
if (await exists(dst)) throw new Error(`"${to}" already exists; Pathnote never overwrites.`);
|
|
147
|
+
if (dst.startsWith(src + path.sep)) throw new Error(`Can't move "${from}" into itself.`);
|
|
148
|
+
if (st.isDirectory()) {
|
|
149
|
+
const secret = await findSecret(src);
|
|
150
|
+
if (secret) throw new Error(`"${from}" holds a file that looks like a secret (${path.relative(src, secret)}), so Pathnote won't move it.`);
|
|
151
|
+
}
|
|
152
|
+
await timeout(fs.mkdir(path.dirname(dst), { recursive: true }), 5000);
|
|
153
|
+
try {
|
|
154
|
+
await fs.rename(src, dst);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
if (e.code !== 'EXDEV') throw e;
|
|
157
|
+
// different disk (e.g. iCloud Drive): copy, then remove the original
|
|
158
|
+
await fs.cp(src, dst, { recursive: true, errorOnExist: true, force: false, preserveTimestamps: true });
|
|
159
|
+
await fs.rm(src, { recursive: true });
|
|
160
|
+
}
|
|
161
|
+
return { from: src, to: dst, dir: st.isDirectory() };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async remove(p) {
|
|
165
|
+
await fs.rm(await this.real(p), { force: true });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Every folder and file under the roots, minus skipped, hidden and secret ones. Stops at MAX_NODES.
|
|
169
|
+
async walk() {
|
|
170
|
+
// All folders at once, so several blocked ones cost one timeout, not one each
|
|
171
|
+
const per = await Promise.all(this.roots.map(async r => {
|
|
172
|
+
const mine = [{ id: r, kind: 'root', parent: null }];
|
|
173
|
+
try {
|
|
174
|
+
await timeout(this.visit(r, mine, MAX_NODES), 4000);
|
|
175
|
+
return { items: mine };
|
|
176
|
+
} catch (e) {
|
|
177
|
+
if (e.code !== 'EBLOCKED') throw e;
|
|
178
|
+
return { items: mine.slice(0, 1), blocked: r };
|
|
179
|
+
}
|
|
180
|
+
}));
|
|
181
|
+
this.blocked = per.filter(x => x.blocked).map(x => x.blocked);
|
|
182
|
+
return per.flatMap(x => x.items).slice(0, MAX_NODES);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async visit(root, items, max) {
|
|
186
|
+
const visit = async (dir, depth) => {
|
|
187
|
+
let entries;
|
|
188
|
+
try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
189
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
190
|
+
for (const e of entries) {
|
|
191
|
+
if (items.length >= max) return;
|
|
192
|
+
if (e.name.startsWith('.') || LOCK.test(e.name) || SKIP.has(e.name) || SECRET_DIRS.has(e.name) || SECRET_FILE.test(e.name)) continue;
|
|
193
|
+
const abs = path.join(dir, e.name);
|
|
194
|
+
if (e.isDirectory()) { items.push({ id: abs, kind: 'dir', parent: dir }); await visit(abs, depth + 1); }
|
|
195
|
+
else if (e.isFile()) items.push({ id: abs, kind: 'file', parent: dir });
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
await visit(root, 1);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Text files and documents ranked by how often the query's words appear, file-name matches counted extra
|
|
202
|
+
async search(query, limit = 10) {
|
|
203
|
+
const words = String(query).toLowerCase().split(/\s+/).filter(Boolean);
|
|
204
|
+
if (!words.length) return [];
|
|
205
|
+
const hits = [];
|
|
206
|
+
for (const it of await this.walk()) {
|
|
207
|
+
if (it.kind !== 'file' || !(TEXT.test(it.id) || isDocument(it.id))) continue;
|
|
208
|
+
let text;
|
|
209
|
+
try {
|
|
210
|
+
if (isDocument(it.id)) text = await this.docText(it.id);
|
|
211
|
+
else {
|
|
212
|
+
const st = await fs.stat(it.id);
|
|
213
|
+
if (st.size > MAX_SEARCH) continue;
|
|
214
|
+
text = await timeout(fs.readFile(it.id, 'utf8'), 3000);
|
|
215
|
+
}
|
|
216
|
+
} catch { continue; }
|
|
217
|
+
if (!text) continue;
|
|
218
|
+
const lower = text.toLowerCase(), name = path.basename(it.id).toLowerCase();
|
|
219
|
+
let score = 0;
|
|
220
|
+
for (const w of words) { score += lower.split(w).length - 1; if (name.includes(w)) score += 5; }
|
|
221
|
+
if (!score) continue;
|
|
222
|
+
const at = Math.max(0, lower.indexOf(words.find(w => lower.includes(w)) ?? words[0]));
|
|
223
|
+
hits.push({ path: it.id, score, snippet: text.slice(Math.max(0, at - 60), at + 140).replace(/\s+/g, ' ').trim() });
|
|
224
|
+
}
|
|
225
|
+
return hits.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Folder tree for the viewer, plus [[wikilinks]] between markdown notes (resolved by name, like Obsidian)
|
|
229
|
+
async graph() {
|
|
230
|
+
const items = await this.walk();
|
|
231
|
+
const notes = items.filter(i => i.kind === 'file' && /\.md$/i.test(i.id));
|
|
232
|
+
const byName = new Map(notes.map(n => [path.basename(n.id, path.extname(n.id)).toLowerCase(), n.id]));
|
|
233
|
+
const links = [];
|
|
234
|
+
for (const n of notes) {
|
|
235
|
+
let text;
|
|
236
|
+
try { text = await fs.readFile(n.id, 'utf8'); } catch { continue; }
|
|
237
|
+
for (const m of text.matchAll(LINK)) {
|
|
238
|
+
const target = byName.get(path.basename(m[1].trim()).toLowerCase());
|
|
239
|
+
if (target && target !== n.id) links.push([n.id, target]);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { roots: this.roots, items, links, blocked: this.blocked, capped: items.length >= MAX_NODES };
|
|
243
|
+
}
|
|
244
|
+
}
|