pathnote 0.1.0 → 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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Pathnote
2
2
 
3
- See which notes your AI searched, read and wrote, live on your note graph.
3
+ See which files your AI searched, read and wrote, live on a map of your folders.
4
4
 
5
- Pathnote is an MCP server for a folder of markdown notes (an Obsidian vault works as is) plus a local viewer page. While Claude, Cursor or another MCP client works in your notes, the viewer draws the path: search hits, the notes it read in order, and the notes it wrote, linked to the notes they came from. Every note the AI creates or edits can be undone from the viewer.
5
+ Pathnote is an MCP server for the folders you choose (notes, an Obsidian vault, project folders) plus a local viewer page. The viewer draws the folders as a tree, with `[[wikilinks]]` between markdown notes. While Claude, Cursor or another MCP client works, it draws the path on top: the files it searched, the folders and files it opened in order, and the files it wrote, linked to the files they came from. Every file the AI creates, edits or moves can be undone from the viewer.
6
6
 
7
7
  Free, MIT licensed. Runs entirely on your computer; nothing is sent anywhere by Pathnote.
8
8
 
@@ -18,59 +18,69 @@ npx -y pathnote --sample ~/pathnote-sample
18
18
 
19
19
  ### Claude Desktop
20
20
 
21
- Settings → Developer → Edit Config, and add Pathnote to `mcpServers`:
21
+ Quit Claude Desktop first (⌘Q). It rewrites its config file while running, so edits made with it open can be lost. Then add Pathnote to `mcpServers` in `~/Library/Application Support/Claude/claude_desktop_config.json`. List as many folders as you like:
22
22
 
23
23
  ```json
24
24
  {
25
25
  "mcpServers": {
26
26
  "pathnote": {
27
27
  "command": "npx",
28
- "args": ["-y", "pathnote", "/Users/you/pathnote-sample"]
28
+ "args": ["-y", "pathnote", "/Users/you/pathnote-sample", "/Users/you/Projects"]
29
29
  }
30
30
  }
31
31
  }
32
32
  ```
33
33
 
34
- Restart Claude Desktop, open http://127.0.0.1:4747 in your browser, and ask Claude something like:
34
+ Open Claude Desktop again, open http://127.0.0.1:4747 in your browser, and ask Claude something like:
35
35
 
36
36
  > Go through the meeting notes and customer interviews and write next quarter's priorities as a new note.
37
37
 
38
38
  ### Claude Code
39
39
 
40
40
  ```bash
41
- claude mcp add pathnote -- npx -y pathnote ~/pathnote-sample
41
+ claude mcp add pathnote -- npx -y pathnote ~/pathnote-sample ~/Projects
42
42
  ```
43
43
 
44
44
  ### Cursor and other MCP clients
45
45
 
46
- Use the same command: `npx -y pathnote <your notes folder>`.
46
+ Use the same command: `npx -y pathnote <folder> [<folder> ...]`.
47
47
 
48
48
  ## The viewer
49
49
 
50
50
  - http://127.0.0.1:4747 (the next free port if 4747 is taken; the address is printed to the client's MCP log).
51
- - Several AI sessions on the same folder all report to one viewer.
52
- - To keep the viewer open while clients come and go: `npx pathnote <folder> --viewer`.
53
- - **Undo** puts a note back the way it was before the AI changed it. If you edited the note yourself afterwards, Pathnote leaves it alone and says so.
54
- - **Clear path** clears the drawing, not your notes.
51
+ - Drag to move, scroll to zoom. File names appear as you zoom in.
52
+ - Several AI sessions on the same folders all report to one viewer.
53
+ - To keep the viewer open while clients come and go: `npx pathnote <folders> --viewer`.
54
+ - **Undo** puts a file back the way it was before the AI changed it. If you edited it yourself afterwards, Pathnote leaves it alone and says so.
55
+ - **Clear path** clears the drawing, not your files.
55
56
 
56
57
  ## Tools the AI gets
57
58
 
58
59
  | Tool | What it does |
59
60
  |---|---|
60
- | `list_notes` | Every note path in the folder |
61
- | `search_vault` | Notes matching some words, with a snippet each |
62
- | `read_note` | Full text of one note |
63
- | `write_note` | Create a note or replace its text (undoable) |
64
- | `edit_note` | Replace one exact piece of text in a note (undoable) |
61
+ | `list_files` | The connected folders, or what is inside one folder |
62
+ | `search_files` | Text files and documents matching some words, with a snippet each |
63
+ | `read_file` | A text file as is (up to 2 MB); PDF, Word, PowerPoint, Excel and Hangul (.hwpx) files as their text; pictures (PNG, JPEG, GIF, WebP, HEIC) as images the AI can see (up to 50 MB) |
64
+ | `write_file` | Create a text file or replace its text (undoable). Documents and pictures are never written |
65
+ | `edit_file` | Replace one exact piece of text in a file (undoable) |
66
+ | `move_file` | Move or rename a file or folder of any type, creating missing folders; never overwrites (undoable) |
65
67
 
66
68
  Each tool takes an optional `reason`, a short sentence the viewer shows next to the step.
67
69
 
68
70
  ## What Pathnote touches
69
71
 
70
- - Only `.md` files inside the folder you give it. Paths outside it, and `.obsidian`, `.git` and `.trash`, are refused.
71
- - The text of each note before the AI changed it is kept in `~/.pathnote/` so it can be undone. Delete that folder to clear the history.
72
+ - Only files inside the folders you list. Anything outside them is refused, including through symlinks.
73
+ - Old Hangul `.hwp` files and other binary formats can't be read. Scanned PDFs without a text layer come back empty.
74
+ - Skipped: hidden files and folders, `.git`, `node_modules`, build output, and files that usually hold secrets (`.env`, `*.pem`, `*.p8`, `*.key`, SSH keys, `credentials.json`, `token.json`, `client_secret*.json`, folders named `secrets`). The AI can't read or write them, and the viewer doesn't show them.
75
+ - Whatever the AI reads, including document text and pictures, is sent to the AI service you use, like anything else you share with it.
76
+ - The AI can create and change any other text file in those folders. Connect only folders you are comfortable letting it edit.
77
+ - The text of each file before the AI changed it is kept in `~/.pathnote/` so it can be undone. Delete that folder to clear the history.
72
78
  - The viewer only listens on 127.0.0.1 and refuses requests from other websites.
73
79
 
80
+ ## macOS folder permissions
81
+
82
+ Desktop, Documents, Downloads and iCloud Drive are protected by macOS. The first time Pathnote reads one, macOS asks whether the app that runs Pathnote (Claude, Terminal, ...) may access it. Until you allow it, the viewer shows that folder as empty and says so. You can change this later in System Settings → Privacy & Security → Files and Folders.
83
+
74
84
  ## Development
75
85
 
76
86
  ```bash
package/bin/pathnote.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
- // pathnote <vault folder> [--port 4747] [--viewer]
2
+ // pathnote <folder> [<folder> ...] [--port 4747] [--viewer]
3
3
  // pathnote --sample <new folder> copies the sample notes there, to try Pathnote safely
4
4
  // Started by an MCP client: MCP over stdin/stdout, plus the viewer at http://127.0.0.1:4747
5
5
  // --viewer: only the viewer, for keeping it open while MCP clients come and go
6
6
  // stdout belongs to MCP, so every message for people goes to stderr.
7
7
  import fs from 'node:fs';
8
8
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
- import { Vault } from '../src/vault.js';
9
+ import { Workspace } from '../src/workspace.js';
10
10
  import { History } from '../src/history.js';
11
11
  import { Hub } from '../src/hub.js';
12
12
  import { createServer } from '../src/mcp.js';
@@ -15,7 +15,8 @@ const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.ur
15
15
  const args = process.argv.slice(2);
16
16
  const flag = name => args.includes(name);
17
17
  const opt = name => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
18
- const folder = args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--port');
18
+ const folders = args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--port');
19
+ const folder = folders[0];
19
20
 
20
21
  if (flag('--sample')) {
21
22
  if (!folder) { console.error('Usage: pathnote --sample <new folder>'); process.exit(1); }
@@ -25,22 +26,25 @@ if (flag('--sample')) {
25
26
  process.exit(0);
26
27
  }
27
28
  if (!folder || flag('--help')) {
28
- console.error(`Pathnote ${pkg.version}\n\nUsage: pathnote <vault folder> [--port 4747] [--viewer]\n pathnote --sample <new folder>`);
29
+ console.error(`Pathnote ${pkg.version}\n\nUsage: pathnote <folder> [<folder> ...] [--port 4747] [--viewer]\n pathnote --sample <new folder>`);
29
30
  process.exit(folder ? 0 : 1);
30
31
  }
31
- if (!fs.existsSync(folder) || !fs.statSync(folder).isDirectory()) {
32
- console.error(`Pathnote: "${folder}" is not a folder.`);
33
- process.exit(1);
32
+ for (const f of folders) {
33
+ if (!fs.existsSync(f) || !fs.statSync(f).isDirectory()) {
34
+ console.error(`Pathnote: "${f}" is not a folder.`);
35
+ process.exit(1);
36
+ }
34
37
  }
35
38
 
36
- const vault = new Vault(folder);
37
- const history = new History(vault);
38
- const hub = new Hub(vault, history, Number(opt('--port')) || 4747);
39
+ const ws = new Workspace(folders);
40
+ const history = new History(ws);
41
+ const hub = new Hub(ws, history, Number(opt('--port')) || 4747);
39
42
  const url = await hub.start();
40
- console.error(`Pathnote ${pkg.version}: vault ${vault.root}\nViewer: ${url}`);
43
+ console.error(`Pathnote ${pkg.version}: ${ws.roots.join(', ')}\nViewer: ${url}`);
44
+ ws.warm().catch(() => {});
41
45
 
42
46
  if (!flag('--viewer')) {
43
- await createServer({ vault, history, hub, version: pkg.version }).connect(new StdioServerTransport());
47
+ await createServer({ ws, history, hub, version: pkg.version }).connect(new StdioServerTransport());
44
48
  // The viewer server would keep the process alive after the MCP client quits
45
49
  process.stdin.on('close', () => process.exit(0));
46
50
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pathnote",
3
- "version": "0.1.0",
4
- "description": "See which notes your AI searched, read and wrote, live on your note graph. MCP server + local viewer for a markdown folder.",
3
+ "version": "0.2.0",
4
+ "description": "See which files your AI searched, read and wrote, live on a map of your folders. MCP server + local viewer.",
5
5
  "keywords": [
6
6
  "mcp",
7
7
  "model-context-protocol",
@@ -34,6 +34,8 @@
34
34
  "license": "MIT",
35
35
  "dependencies": {
36
36
  "@modelcontextprotocol/sdk": "^1.32.2",
37
+ "fflate": "0.8.3",
38
+ "unpdf": "1.9.1",
37
39
  "zod": "^4.6.7"
38
40
  }
39
41
  }
@@ -0,0 +1,98 @@
1
+ // Reads files that aren't plain text: text out of PDF, Word, PowerPoint, Excel and Hangul (.hwpx)
2
+ // files, and pictures as images the AI can look at. Read-only: Pathnote never writes these.
3
+ import { execFile } from 'node:child_process';
4
+ import fs from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { promisify } from 'node:util';
8
+ import { unzipSync, strFromU8 } from 'fflate';
9
+ import { getDocumentProxy, extractText } from 'unpdf';
10
+
11
+ const run = promisify(execFile);
12
+ const DOCS = new Set(['.pdf', '.docx', '.pptx', '.xlsx', '.hwpx']);
13
+ const IMAGES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.heic': 'image/heic' };
14
+ export const MAX_DOC = 50 * 1024 * 1024;
15
+ const MAX_TEXT = 200_000;
16
+ // Claude accepts images up to about 5 MB once base64-encoded
17
+ const MAX_IMAGE = 3.5 * 1024 * 1024;
18
+
19
+ export const isDocument = p => DOCS.has(path.extname(p).toLowerCase());
20
+ export const isImage = p => path.extname(p).toLowerCase() in IMAGES;
21
+
22
+ const decode = s => s
23
+ .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
24
+ .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(+d))
25
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&apos;/g, "'").replace(/&amp;/g, '&');
26
+
27
+ // XML -> text, with a line break wherever the format ends a paragraph
28
+ const xmlText = (xml, paragraph) => decode(xml
29
+ .replace(new RegExp(`</${paragraph}>`, 'g'), '\n')
30
+ .replace(/<(w:tab|a:tab|hp:tab)\b[^>]*\/>/g, '\t')
31
+ .replace(/<[^>]+>/g, ''))
32
+ .replace(/\n{3,}/g, '\n\n').trim();
33
+
34
+ // "ppt/slides/slide10.xml" sorts after "slide9.xml"
35
+ const byNumber = (a, b) => (+a.match(/(\d+)\.xml$/)?.[1] || 0) - (+b.match(/(\d+)\.xml$/)?.[1] || 0);
36
+
37
+ function unzip(buf, keep) {
38
+ const files = unzipSync(new Uint8Array(buf), { filter: f => keep(f.name) });
39
+ return Object.fromEntries(Object.entries(files).map(([k, v]) => [k, strFromU8(v)]));
40
+ }
41
+
42
+ function xlsx(buf) {
43
+ const z = unzip(buf, n => n === 'xl/sharedStrings.xml' || n === 'xl/workbook.xml' || /^xl\/worksheets\/sheet\d+\.xml$/.test(n));
44
+ const shared = [...(z['xl/sharedStrings.xml'] || '').matchAll(/<si>([\s\S]*?)<\/si>/g)].map(m => decode(m[1].replace(/<[^>]+>/g, '')));
45
+ const names = [...(z['xl/workbook.xml'] || '').matchAll(/<sheet [^>]*name="([^"]*)"/g)].map(m => decode(m[1]));
46
+ return Object.keys(z).filter(k => k.startsWith('xl/worksheets/')).sort(byNumber).map((k, i) => {
47
+ const rows = [...z[k].matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/g)].map(r =>
48
+ [...r[1].matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)].map(([, attrs, body = '']) => {
49
+ const v = body.match(/<v>([\s\S]*?)<\/v>/)?.[1];
50
+ if (/t="s"/.test(attrs)) return shared[+v] ?? '';
51
+ if (/t="inlineStr"/.test(attrs)) return decode(body.replace(/<[^>]+>/g, ''));
52
+ return v === undefined ? '' : decode(v);
53
+ }).join('\t'));
54
+ return `--- sheet: ${names[i] ?? i + 1} ---\n${rows.join('\n')}`;
55
+ }).join('\n\n');
56
+ }
57
+
58
+ // Text of a document. `buf` is the file's bytes.
59
+ export async function documentText(file, buf) {
60
+ const ext = path.extname(file).toLowerCase();
61
+ let text;
62
+ if (ext === '.pdf') {
63
+ const pdf = await getDocumentProxy(new Uint8Array(buf));
64
+ const { text: pages } = await extractText(pdf, { mergePages: false });
65
+ text = pages.map((t, i) => `--- page ${i + 1} ---\n${t.trim()}`).join('\n\n');
66
+ } else if (ext === '.docx') {
67
+ text = xmlText(unzip(buf, n => n === 'word/document.xml')['word/document.xml'] || '', 'w:p');
68
+ } else if (ext === '.pptx') {
69
+ const z = unzip(buf, n => /^ppt\/slides\/slide\d+\.xml$/.test(n));
70
+ text = Object.keys(z).sort(byNumber).map((k, i) => `--- slide ${i + 1} ---\n${xmlText(z[k], 'a:p')}`).join('\n\n');
71
+ } else if (ext === '.xlsx') {
72
+ text = xlsx(buf);
73
+ } else if (ext === '.hwpx') {
74
+ const z = unzip(buf, n => /^Contents\/section\d+\.xml$/.test(n));
75
+ text = Object.keys(z).sort(byNumber).map(k => xmlText(z[k], 'hp:p')).join('\n\n');
76
+ } else {
77
+ throw new Error(`Pathnote can't read ${ext} files.`);
78
+ }
79
+ if (!text.trim()) return '(No text found. It may be a scanned document made of images.)';
80
+ return text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) + `\n\n(cut off at ${MAX_TEXT} characters)` : text;
81
+ }
82
+
83
+ // An image the AI can look at. HEIC (iPhone photos) and large pictures go through macOS `sips`
84
+ // to become a JPEG small enough to send.
85
+ export async function imageContent(file, buf) {
86
+ const ext = path.extname(file).toLowerCase();
87
+ if (ext !== '.heic' && buf.length <= MAX_IMAGE) return { data: buf.toString('base64'), mimeType: IMAGES[ext] };
88
+ if (process.platform !== 'darwin') throw new Error(`"${file}" is too large to send as an image (over 3.5 MB).`);
89
+ const out = path.join(await fs.mkdtemp(path.join(os.tmpdir(), 'pathnote-')), 'image.jpg');
90
+ try {
91
+ await run('sips', ['-s', 'format', 'jpeg', '-s', 'formatOptions', '80', '-Z', '1600', file, '--out', out]);
92
+ const small = await fs.readFile(out);
93
+ if (small.length > MAX_IMAGE) throw new Error(`"${file}" is still too large after shrinking.`);
94
+ return { data: small.toString('base64'), mimeType: 'image/jpeg' };
95
+ } finally {
96
+ await fs.rm(path.dirname(out), { recursive: true, force: true });
97
+ }
98
+ }
package/src/history.js CHANGED
@@ -1,14 +1,14 @@
1
1
  // Keeps the text a note had before the AI changed it, so each change can be undone.
2
- // Stored outside the vault (~/.pathnote/<vault id>/history.jsonl) to keep the vault clean.
2
+ // Stored outside the connected folders (~/.pathnote/<id>/history.jsonl) to keep them clean.
3
3
  import fs from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import os from 'node:os';
6
6
  import crypto from 'node:crypto';
7
7
 
8
8
  export class History {
9
- constructor(vault) {
10
- this.vault = vault;
11
- const id = crypto.createHash('sha1').update(vault.root).digest('hex').slice(0, 12);
9
+ constructor(ws) {
10
+ this.ws = ws;
11
+ const id = crypto.createHash('sha1').update(ws.id).digest('hex').slice(0, 12);
12
12
  this.file = path.join(os.homedir(), '.pathnote', id, 'history.jsonl');
13
13
  }
14
14
 
@@ -26,13 +26,19 @@ export class History {
26
26
  await fs.appendFile(this.file, JSON.stringify(entry) + '\n');
27
27
  }
28
28
 
29
- // Call before changing a note. `before` is null for a note that didn't exist.
29
+ // Call before changing a file. `before` is null for a file that didn't exist.
30
30
  async record(rel, before, after, kind) {
31
31
  const entry = { id: crypto.randomUUID(), t: Date.now(), path: rel, kind, before, after };
32
32
  await this.append(entry);
33
33
  return entry;
34
34
  }
35
35
 
36
+ async recordMove(from, to) {
37
+ const entry = { id: crypto.randomUUID(), t: Date.now(), path: to, kind: 'move', from, to };
38
+ await this.append(entry);
39
+ return entry;
40
+ }
41
+
36
42
  // Put the note back the way it was before change `id`. Refuses if someone edited it since,
37
43
  // so a user's own later edits are never thrown away.
38
44
  async undo(id) {
@@ -40,10 +46,16 @@ export class History {
40
46
  const entry = all.find(e => e.id === id);
41
47
  if (!entry) throw new Error('No such change.');
42
48
  if (all.some(e => e.undoes === id)) throw new Error('Already undone.');
43
- const now = await this.vault.readOrNull(entry.path);
49
+ if (entry.kind === 'move') {
50
+ // put it back where it was, as long as nothing has taken either place since
51
+ await this.ws.move(entry.to, entry.from);
52
+ await this.append({ id: crypto.randomUUID(), t: Date.now(), path: entry.from, kind: 'undo', undoes: id });
53
+ return entry;
54
+ }
55
+ const now = await this.ws.readOrNull(entry.path);
44
56
  if (now !== entry.after) throw new Error(`"${entry.path}" changed after the AI wrote it, so it was left as is.`);
45
- if (entry.before === null) await this.vault.remove(entry.path);
46
- else await this.vault.write(entry.path, entry.before);
57
+ if (entry.before === null) await this.ws.remove(entry.path);
58
+ else await this.ws.write(entry.path, entry.before);
47
59
  await this.append({ id: crypto.randomUUID(), t: Date.now(), path: entry.path, kind: 'undo', undoes: id });
48
60
  return entry;
49
61
  }
package/src/hub.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Local viewer server: serves the graph page and streams what the AI does to it.
2
- // Claude Desktop may start several Pathnote processes; the first one for a vault owns the
2
+ // Claude Desktop may start several Pathnote processes; the first one for a set of folders owns the
3
3
  // viewer port and the others forward their events to it.
4
4
  import http from 'node:http';
5
5
  import fs from 'node:fs/promises';
@@ -10,8 +10,8 @@ const PAGE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'view
10
10
  const KEEP = 500;
11
11
 
12
12
  export class Hub {
13
- constructor(vault, history, port) {
14
- this.vault = vault;
13
+ constructor(ws, history, port) {
14
+ this.ws = ws;
15
15
  this.history = history;
16
16
  this.port = port;
17
17
  this.events = [];
@@ -21,11 +21,11 @@ export class Hub {
21
21
  this.seq = 0;
22
22
  }
23
23
 
24
- // Own a port for this vault, or find the process that already does. Returns the viewer URL.
24
+ // Own a port for these folders, or find the process that already does. Returns the viewer URL.
25
25
  async start() {
26
26
  for (let port = this.port; port < this.port + 10; port++) {
27
27
  const other = await this.info(port);
28
- if (other?.vault === this.vault.root) { this.forwardTo = port; return `http://127.0.0.1:${port}`; }
28
+ if (other?.id === this.ws.id) { this.forwardTo = port; return `http://127.0.0.1:${port}`; }
29
29
  if (other) continue;
30
30
  try {
31
31
  await this.listen(port);
@@ -98,8 +98,8 @@ export class Hub {
98
98
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
99
99
  return res.end(await fs.readFile(PAGE));
100
100
  }
101
- if (req.method === 'GET' && url.pathname === '/api/info') return send(res, 200, { app: 'pathnote', vault: this.vault.root });
102
- if (req.method === 'GET' && url.pathname === '/api/graph') return send(res, 200, await this.vault.graph());
101
+ if (req.method === 'GET' && url.pathname === '/api/info') return send(res, 200, { app: 'pathnote', id: this.ws.id, roots: this.ws.roots });
102
+ if (req.method === 'GET' && url.pathname === '/api/graph') return send(res, 200, await this.ws.graph());
103
103
  if (req.method === 'GET' && url.pathname === '/api/events') {
104
104
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
105
105
  for (const e of this.events) res.write(`data: ${JSON.stringify(e)}\n\n`);
@@ -115,7 +115,7 @@ export class Hub {
115
115
  const { id } = await body(req);
116
116
  try {
117
117
  const entry = await this.history.undo(id);
118
- this.broadcast({ t: Date.now(), type: 'undo', path: entry.path, changeId: id, created: entry.before === null });
118
+ this.broadcast({ t: Date.now(), type: 'undo', path: entry.kind === 'move' ? entry.to : entry.path, changeId: id, created: entry.before === null || entry.kind === 'move', back: entry.kind === 'move' ? entry.from : undefined });
119
119
  return send(res, 200, { ok: true });
120
120
  } catch (e) {
121
121
  return send(res, 409, { error: e.message });
package/src/mcp.js CHANGED
@@ -6,71 +6,93 @@ const reason = z.string().optional().describe('One short sentence on why you are
6
6
  const text = s => ({ content: [{ type: 'text', text: s }] });
7
7
  const fail = e => ({ content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true });
8
8
 
9
- export function createServer({ vault, history, hub, version }) {
9
+ export function createServer({ ws, history, hub, version }) {
10
10
  const server = new McpServer({ name: 'pathnote', version });
11
+ const where = `Connected folders: ${ws.roots.join(', ')}. Use full paths.`;
11
12
 
12
- server.registerTool('list_notes', {
13
- title: 'List notes',
14
- description: 'List every markdown note in the vault, as paths relative to the vault root.',
15
- inputSchema: {}
16
- }, async () => {
17
- try { return text((await vault.list()).join('\n') || '(no notes)'); } catch (e) { return fail(e); }
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); }
18
25
  });
19
26
 
20
- server.registerTool('search_vault', {
21
- title: 'Search notes',
22
- description: 'Search the notes for words. Returns the best matching note paths with a snippet each.',
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}`,
23
30
  inputSchema: { query: z.string().describe('Words to look for'), reason }
24
31
  }, async ({ query, reason }) => {
25
32
  try {
26
- const hits = await vault.search(query);
33
+ const hits = await ws.search(query);
27
34
  await hub.emit({ type: 'search', query, hits: hits.map(h => h.path), reason });
28
35
  return text(hits.length ? hits.map(h => `${h.path}\n ${h.snippet}`).join('\n') : 'No matches.');
29
36
  } catch (e) { return fail(e); }
30
37
  });
31
38
 
32
- server.registerTool('read_note', {
33
- title: 'Read a note',
34
- description: 'Read the full text of a note. Path is relative to the vault root; ".md" is optional.',
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}`,
35
42
  inputSchema: { path: z.string(), reason }
36
43
  }, async ({ path, reason }) => {
37
44
  try {
38
- const body = await vault.read(path);
39
- await hub.emit({ type: 'read', path: vault.rel(vault.resolve(path)), reason });
40
- return text(body);
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);
41
50
  } catch (e) { return fail(e); }
42
51
  });
43
52
 
44
- server.registerTool('write_note', {
45
- title: 'Write a note',
46
- description: 'Create a note, or replace a note\'s whole text. The user can undo this from the Pathnote viewer. Link the notes you used with [[Note name]].',
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}`,
47
56
  inputSchema: { path: z.string(), content: z.string(), reason }
48
57
  }, async ({ path, content, reason }) => {
49
58
  try {
50
- const rel = vault.rel(vault.resolve(path));
51
- const before = await vault.readOrNull(rel);
52
- const change = await history.record(rel, before, content, before === null ? 'create' : 'write');
53
- await vault.write(rel, content);
54
- await hub.emit({ type: 'write', path: rel, created: before === null, changeId: change.id, reason });
55
- return text(`${before === null ? 'Created' : 'Replaced'} ${rel}.`);
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}.`);
56
65
  } catch (e) { return fail(e); }
57
66
  });
58
67
 
59
- server.registerTool('edit_note', {
60
- title: 'Edit a note',
61
- description: 'Replace one exact piece of text in a note. `find` must appear exactly once. The user can undo this from the Pathnote viewer.',
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}`,
62
71
  inputSchema: { path: z.string(), find: z.string().min(1), replace: z.string(), reason }
63
72
  }, async ({ path, find, replace, reason }) => {
64
73
  try {
65
- const rel = vault.rel(vault.resolve(path));
66
- const before = await vault.read(rel);
74
+ const abs = ws.resolve(path);
75
+ const before = await ws.read(abs);
67
76
  const count = before.split(find).length - 1;
68
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.');
69
78
  const after = before.replace(find, () => replace);
70
- const change = await history.record(rel, before, after, 'edit');
71
- await vault.write(rel, after);
72
- await hub.emit({ type: 'edit', path: rel, changeId: change.id, reason });
73
- return text(`Edited ${rel}.`);
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}.`);
74
96
  } catch (e) { return fail(e); }
75
97
  });
76
98
 
@@ -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
+ }
package/viewer/index.html CHANGED
@@ -31,6 +31,8 @@ body { background: var(--bg); color: var(--ink); font: 400 14px/1.43 var(--font)
31
31
  .graph { position: relative; min-width: 0; min-height: 0; border-right: 1px solid var(--line); }
32
32
  svg { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; }
33
33
  svg.drag { cursor: grabbing; }
34
+ .note { position: absolute; left: 12px; right: 12px; bottom: 12px; padding: 10px 14px; border-radius: 11px; background: var(--surface); color: var(--muted); font-size: 12px; }
35
+ .note b { color: var(--ink); font-weight: 600; }
34
36
  .empty { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); padding: 24px; text-align: center; pointer-events: none; }
35
37
  .log { display: flex; flex-direction: column; min-height: 0; }
36
38
  .log h2 { font-size: 14px; font-weight: 600; margin: 0; padding: 10px 16px; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; }
@@ -50,12 +52,21 @@ li .btn { grid-column: 1 / -1; justify-self: start; margin-top: 4px; }
50
52
  .btn:disabled { opacity: .4; cursor: default; }
51
53
  .hint { padding: 16px; color: var(--muted); }
52
54
 
53
- .link { stroke: var(--line); stroke-width: 1; }
55
+ /* --k = user units per screen pixel, so dots, labels and lines keep their on-screen size while zooming */
56
+ svg { --k: 1; }
57
+ line { vector-effect: non-scaling-stroke; }
58
+ .tree { stroke: var(--line); stroke-width: 1; }
59
+ .link { stroke: var(--accent); stroke-width: 1; stroke-dasharray: 3 4; opacity: .35; }
54
60
  .trail { stroke: var(--accent); stroke-width: 2.5; stroke-linecap: round; transition: stroke-dashoffset .6s ease-in-out, opacity .4s; }
55
61
  .src { stroke: var(--accent); stroke-width: 1.5; opacity: .6; }
56
- .node .dot { fill: var(--node); transition: fill .3s; }
57
- .node text { font: 400 11px var(--font); fill: var(--muted); paint-order: stroke; stroke: var(--bg); stroke-width: 4px; stroke-linejoin: round; pointer-events: none; }
58
- .node .ring { fill: none; stroke: var(--accent); stroke-width: 1.5; stroke-dasharray: 3 3; opacity: 0; }
62
+ .node .dot { fill: var(--node); transition: fill .3s; r: calc(var(--k) * 3.5px); }
63
+ .node.dir .dot { r: calc(var(--k) * 5px); }
64
+ .node.root .dot { r: calc(var(--k) * 8px); fill: var(--ink); }
65
+ .node text { font-family: var(--font); font-size: calc(var(--k) * 11px); fill: var(--muted); paint-order: stroke; stroke: var(--bg); stroke-width: calc(var(--k) * 4px); stroke-linejoin: round; pointer-events: none; display: none; }
66
+ .node.root text { font-size: calc(var(--k) * 13px); font-weight: 600; fill: var(--ink); }
67
+ .node.root text, .node.top text, .node.hit text, .node.visited text, .node.written text, svg.near .node.dir text, svg.close .node text { display: inline; }
68
+ .node .ring { fill: none; stroke: var(--accent); stroke-width: 1.5; stroke-dasharray: 3 3; opacity: 0; r: calc(var(--k) * 11px); vector-effect: non-scaling-stroke; }
69
+ .node .pulse { r: calc(var(--k) * 6px); }
59
70
  .node.hit .ring { opacity: 1; }
60
71
  .node.visited .dot, .node.written .dot { fill: var(--accent); }
61
72
  .node.visited text, .node.written text { fill: var(--ink); font-weight: 600; }
@@ -74,13 +85,14 @@ li .btn { grid-column: 1 / -1; justify-self: start; margin-top: 4px; }
74
85
  <body>
75
86
  <div class="bar">
76
87
  <strong>Pathnote</strong>
77
- <span class="vault" id="vault"></span>
88
+ <span class="vault" id="roots"></span>
78
89
  <span class="chip" id="status"><i></i><span id="statusText"></span></span>
79
90
  </div>
80
91
  <div class="main">
81
92
  <div class="graph">
82
93
  <svg id="g" role="img"></svg>
83
94
  <div class="empty" id="empty" hidden></div>
95
+ <p class="note" id="note" hidden></p>
84
96
  </div>
85
97
  <div class="log">
86
98
  <h2><span id="logTitle"></span><button class="btn" id="clear" type="button"></button></h2>
@@ -92,12 +104,16 @@ li .btn { grid-column: 1 / -1; justify-self: start; margin-top: 4px; }
92
104
  const ko = (navigator.language || '').toLowerCase().startsWith('ko');
93
105
  const L = ko ? {
94
106
  live: '연결됨', off: '연결 끊김', activity: '작업 기록', clear: '경로 지우기', undo: '되돌리기', undone: '되돌림',
95
- wait: 'AI가 노트를 검색하거나 읽으면 여기에 기록이 쌓입니다.', empty: '이 폴더에 .md 노트가 없습니다.',
96
- hits: n => `${n}개 찾음`, created: '새 노트', graph: '노트 그래프와 AI가 지나간 경로'
107
+ wait: 'AI가 파일을 찾거나 읽으면 여기에 기록이 쌓입니다.', empty: '연결된 폴더가 비어 있습니다.',
108
+ hits: n => `${n}개 찾음`, created: '새 파일', graph: '연결된 폴더 구조와 AI가 지나간 경로',
109
+ capped: n => `파일이 많아 ${n}개까지만 그렸습니다.`, folders: n => `폴더 ${n}개`,
110
+ blocked: 'macOS가 막고 있어 비어 보이는 폴더: ', allow: ' 시스템 설정 → 개인정보 보호 및 보안 → 파일 및 폴더에서 Pathnote를 실행하는 앱(Claude 등)에 허용한 뒤 새로고침하세요.'
97
111
  } : {
98
112
  live: 'Live', off: 'Disconnected', activity: 'Activity', clear: 'Clear path', undo: 'Undo', undone: 'undone',
99
- wait: 'When the AI searches or reads your notes, it shows up here.', empty: 'No .md notes in this folder.',
100
- hits: n => `${n} found`, created: 'new note', graph: 'Note graph with the path the AI took'
113
+ wait: 'When the AI looks through or reads your files, it shows up here.', empty: 'The connected folders are empty.',
114
+ hits: n => `${n} found`, created: 'new file', graph: 'Connected folders and the path the AI took',
115
+ capped: n => `Too many files; drew the first ${n}.`, folders: n => `${n} folders`,
116
+ blocked: 'macOS is blocking these folders, so they look empty: ', allow: ' Allow them in System Settings → Privacy & Security → Files and Folders for the app that runs Pathnote (e.g. Claude), then reload.'
101
117
  };
102
118
  document.documentElement.lang = ko ? 'ko' : 'en';
103
119
  document.getElementById('logTitle').textContent = L.activity;
@@ -107,81 +123,124 @@ document.getElementById('hint').textContent = L.wait;
107
123
  const NS = 'http://www.w3.org/2000/svg';
108
124
  const svg = document.getElementById('g');
109
125
  svg.setAttribute('aria-label', L.graph);
110
- const view = el('g'), gLinks = el('g'), gTrail = el('g'), gNodes = el('g');
111
- view.append(gLinks, gTrail, gNodes);
126
+ const view = el('g'), gTree = el('g'), gLinks = el('g'), gTrail = el('g'), gNodes = el('g');
127
+ view.append(gTree, gLinks, gTrail, gNodes);
112
128
  svg.append(view);
113
- const pos = new Map(), nodeEl = new Map();
114
- let current = null, sources = new Set(), vb = { x: 0, y: 0, w: 1000, h: 700 };
129
+ const pos = new Map(), nodeEl = new Map(), angleOf = new Map(), depthOf = new Map();
130
+ const RING = 160;
131
+ // connected folders sit on a wide first ring so their names don't crowd the middle
132
+ const radius = depth => (depth + 1.5) * RING;
133
+ let current = null, sources = new Set(), vb = { x: -500, y: -500, w: 1000, h: 1000 };
115
134
 
116
135
  function el(tag, attrs = {}) {
117
136
  const e = document.createElementNS(NS, tag);
118
137
  for (const k in attrs) e.setAttribute(k, attrs[k]);
119
138
  return e;
120
139
  }
121
- const name = p => p.split('/').pop().replace(/\.md$/i, '');
140
+ const base = p => p.split('/').filter(Boolean).pop() || p;
141
+ const parentOf = p => p.slice(0, p.lastIndexOf('/')) || '/';
122
142
 
123
- // Small force layout: nodes push apart, links pull together, everything drifts to the middle
124
- function layout(nodes, edges) {
125
- const n = nodes.length, P = nodes.map((_, i) => {
126
- const a = i * 2.39996, r = 30 * Math.sqrt(i + 1);
127
- return { x: r * Math.cos(a), y: r * Math.sin(a), vx: 0, vy: 0 };
128
- });
129
- const idx = new Map(nodes.map((id, i) => [id, i]));
130
- const E = edges.map(([a, b]) => [idx.get(a), idx.get(b)]);
131
- const steps = n > 600 ? 80 : 250;
132
- for (let s = 0; s < steps; s++) {
133
- const cool = 1 - s / steps;
134
- for (let i = 0; i < n; i++) for (let j = i + 1; j < n; j++) {
135
- const dx = P[i].x - P[j].x, dy = P[i].y - P[j].y, d2 = dx * dx + dy * dy + 0.01;
136
- if (d2 > 90000) continue;
137
- const f = 1800 / d2;
138
- P[i].vx += dx * f; P[i].vy += dy * f; P[j].vx -= dx * f; P[j].vy -= dy * f;
139
- }
140
- for (const [a, b] of E) {
141
- const dx = P[b].x - P[a].x, dy = P[b].y - P[a].y, d = Math.hypot(dx, dy) + 0.01, f = (d - 90) * 0.02 / d;
142
- P[a].vx += dx * f; P[a].vy += dy * f; P[b].vx -= dx * f; P[b].vy -= dy * f;
143
+ // Radial tree: each folder gets a slice of the circle sized by how many files it holds,
144
+ // and sits one ring further out than its parent
145
+ function layout(items) {
146
+ const kids = new Map([['', []]]);
147
+ for (const it of items) {
148
+ const p = it.kind === 'root' ? '' : it.parent;
149
+ if (!kids.has(p)) kids.set(p, []);
150
+ kids.get(p).push(it.id);
151
+ }
152
+ const leaves = new Map();
153
+ const count = id => {
154
+ const c = kids.get(id) || [];
155
+ const n = c.length ? c.reduce((s, k) => s + count(k), 0) : 1;
156
+ leaves.set(id, n);
157
+ return n;
158
+ };
159
+ count('');
160
+ // a connected folder with few files still gets a readable share of the circle
161
+ const total = leaves.get('');
162
+ for (const r of kids.get('')) leaves.set(r, Math.max(leaves.get(r), total * 0.1));
163
+ leaves.set('', kids.get('').reduce((s, r) => s + leaves.get(r), 0));
164
+ const place = (id, a0, a1, depth) => {
165
+ const a = (a0 + a1) / 2;
166
+ if (id) {
167
+ pos.set(id, [Math.cos(a) * radius(depth), Math.sin(a) * radius(depth)]);
168
+ angleOf.set(id, a); depthOf.set(id, depth);
143
169
  }
144
- for (const p of P) {
145
- p.vx -= p.x * 0.004; p.vy -= p.y * 0.004;
146
- p.x += Math.max(-20, Math.min(20, p.vx)) * cool; p.y += Math.max(-20, Math.min(20, p.vy)) * cool;
147
- p.vx *= 0.5; p.vy *= 0.5;
170
+ let at = a0;
171
+ const sum = (kids.get(id) || []).reduce((s, k) => s + leaves.get(k), 0);
172
+ for (const k of kids.get(id) || []) {
173
+ const span = (a1 - a0) * leaves.get(k) / sum;
174
+ place(k, at, at + span, depth + 1);
175
+ at += span;
148
176
  }
149
- }
150
- nodes.forEach((id, i) => pos.set(id, [P[i].x, P[i].y]));
177
+ };
178
+ place('', -Math.PI / 2, Math.PI * 1.5, 0);
179
+ }
180
+
181
+ function setView() {
182
+ svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
183
+ const k = vb.w / Math.max(1, svg.clientWidth);
184
+ svg.style.setProperty('--k', k);
185
+ svg.classList.toggle('near', k < 1.2);
186
+ svg.classList.toggle('close', k < 0.5);
151
187
  }
152
188
 
153
189
  function fit() {
154
190
  const pts = [...pos.values()];
155
- if (!pts.length) return;
191
+ if (!pts.length) return setView();
156
192
  const xs = pts.map(p => p[0]), ys = pts.map(p => p[1]);
157
- const pad = 60, x0 = Math.min(...xs) - pad, y0 = Math.min(...ys) - pad;
158
- vb = { x: x0, y: y0, w: Math.max(...xs) + pad - x0, h: Math.max(...ys) + pad - y0 };
159
- svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
193
+ const pad = 80, x0 = Math.min(...xs) - pad, y0 = Math.min(...ys) - pad;
194
+ const w = Math.max(...xs) + pad - x0, h = Math.max(...ys) + pad - y0;
195
+ // keep the drawing's aspect so it isn't squashed in a wide panel
196
+ const r = svg.clientWidth / Math.max(1, svg.clientHeight);
197
+ // leave room for file names, which run sideways past the outermost dots
198
+ const label = 220 * Math.max(w, h * r) / Math.max(1, svg.clientWidth);
199
+ const size = Math.max(w + 2 * label, h * r);
200
+ vb = { x: x0 + w / 2 - size / 2, y: y0 + h / 2 - size / r / 2, w: size, h: size / r };
201
+ setView();
160
202
  }
161
203
 
162
- function addNode(id) {
204
+ function addNode(id, kind, top) {
163
205
  const [x, y] = pos.get(id);
164
- const g = el('g', { class: 'node' });
165
- g.append(el('circle', { class: 'pulse', cx: x, cy: y, r: 7 }), el('circle', { class: 'ring', cx: x, cy: y, r: 13 }),
166
- el('circle', { class: 'dot', cx: x, cy: y, r: 6 }));
167
- const t = el('text', { x, y: y + 22, 'text-anchor': 'middle' });
168
- t.textContent = name(id);
206
+ const g = el('g', { class: `node ${kind}${top ? ' top' : ''}` });
207
+ g.append(el('circle', { class: 'pulse', cx: x, cy: y }), el('circle', { class: 'ring', cx: x, cy: y }), el('circle', { class: 'dot', cx: x, cy: y }));
208
+ const a = angleOf.get(id) ?? 0, right = Math.cos(a) >= 0;
209
+ const t = el('text', { x, y, dx: right ? '0.9em' : '-0.9em', dy: '0.35em', 'text-anchor': right ? 'start' : 'end' });
210
+ t.textContent = base(id);
169
211
  const title = el('title'); title.textContent = id;
170
212
  g.append(t, title);
171
213
  gNodes.append(g);
172
214
  nodeEl.set(id, g);
173
215
  }
174
216
 
217
+ function treeLine(a, b) {
218
+ const [x1, y1] = pos.get(a), [x2, y2] = pos.get(b);
219
+ gTree.append(el('line', { class: 'tree', x1, y1, x2, y2 }));
220
+ }
221
+
175
222
  async function loadGraph() {
176
- const { nodes, edges } = await (await fetch('/api/graph')).json();
177
- document.getElementById('empty').hidden = nodes.length > 0;
178
- document.getElementById('empty').textContent = L.empty;
179
- layout(nodes, edges);
180
- for (const [a, b] of edges) {
223
+ const g = await (await fetch('/api/graph')).json();
224
+ document.getElementById('roots').textContent = g.roots.map(base).join(' · ');
225
+ document.getElementById('roots').title = g.roots.join('\n');
226
+ const empty = document.getElementById('empty');
227
+ empty.hidden = g.items.some(i => i.kind !== 'root') && !g.capped;
228
+ empty.textContent = g.capped ? L.capped(g.items.length) : L.empty;
229
+ const note = document.getElementById('note');
230
+ note.hidden = !g.blocked?.length;
231
+ if (g.blocked?.length) {
232
+ note.textContent = '';
233
+ const b = document.createElement('b'); b.textContent = L.blocked + g.blocked.map(base).join(', ') + '.';
234
+ note.append(b, L.allow);
235
+ }
236
+ layout(g.items);
237
+ const isRoot = new Set(g.roots);
238
+ for (const it of g.items) if (it.parent && pos.has(it.parent)) treeLine(it.parent, it.id);
239
+ for (const it of g.items) addNode(it.id, it.kind, it.kind === 'dir' && isRoot.has(it.parent));
240
+ for (const [a, b] of g.links) {
181
241
  const [x1, y1] = pos.get(a), [x2, y2] = pos.get(b);
182
242
  gLinks.append(el('line', { class: 'link', x1, y1, x2, y2 }));
183
243
  }
184
- nodes.forEach(addNode);
185
244
  fit();
186
245
  }
187
246
 
@@ -202,17 +261,30 @@ function setCurrent(id) {
202
261
  nodeEl.get(id)?.classList.add('current');
203
262
  }
204
263
 
205
- // A note the AI created: place it next to the notes it read
206
- function ensureNode(id) {
264
+ // A file the AI created: put it one ring outside its folder
265
+ function ensureNode(id, kind = 'file') {
207
266
  if (pos.has(id)) return;
208
- const src = [...sources].map(s => pos.get(s)).filter(Boolean);
209
- const cx = src.length ? src.reduce((a, p) => a + p[0], 0) / src.length : vb.x + vb.w / 2;
210
- const cy = src.length ? src.reduce((a, p) => a + p[1], 0) / src.length : vb.y + vb.h / 2;
211
- pos.set(id, [cx + 50, cy - 40]);
212
- addNode(id);
267
+ const parent = parentOf(id);
268
+ if (pos.has(parent)) {
269
+ const a = (angleOf.get(parent) ?? 0) + (Math.random() - 0.5) * 0.08, d = (depthOf.get(parent) ?? 1) + 1;
270
+ pos.set(id, [Math.cos(a) * radius(d), Math.sin(a) * radius(d)]); angleOf.set(id, a); depthOf.set(id, d);
271
+ treeLine(parent, id);
272
+ } else {
273
+ pos.set(id, [vb.x + vb.w / 2, vb.y + vb.h / 2]);
274
+ }
275
+ addNode(id, kind);
213
276
  document.getElementById('empty').hidden = true;
214
277
  }
215
278
 
279
+ // Move along the path to a node: line from the last place the AI was
280
+ function visit(id, live) {
281
+ if (!nodeEl.has(id)) return;
282
+ if (current && current !== id) line('trail', current, id, live);
283
+ const g = nodeEl.get(id);
284
+ g.classList.remove('hit'); g.classList.add('visited');
285
+ setCurrent(id);
286
+ }
287
+
216
288
  const logEl = document.getElementById('log');
217
289
  function addLog(e, tool, text, live) {
218
290
  document.getElementById('hint').hidden = true;
@@ -222,7 +294,7 @@ function addLog(e, tool, text, live) {
222
294
  if (e.type === 'undo') li.classList.add('undo');
223
295
  const a = document.createElement('span'); a.className = 'tool'; a.textContent = tool;
224
296
  const tm = document.createElement('time'); tm.textContent = new Date(e.t).toLocaleTimeString();
225
- const b = document.createElement('span'); b.className = 'text'; b.textContent = text;
297
+ const b = document.createElement('span'); b.className = 'text'; b.textContent = text; b.title = e.path || '';
226
298
  li.append(a, tm, b);
227
299
  if (e.reason) { const w = document.createElement('span'); w.className = 'why'; w.textContent = e.reason; li.append(w); }
228
300
  if (e.changeId && e.type !== 'undo') {
@@ -242,32 +314,48 @@ function addLog(e, tool, text, live) {
242
314
  logEl.scrollTop = logEl.scrollHeight;
243
315
  }
244
316
 
317
+ // Show paths shortly: the connected folder's name, then the rest
318
+ function short(p) {
319
+ const root = rootsList.find(r => p === r || p.startsWith(r + '/'));
320
+ return root ? base(root) + p.slice(root.length) : p;
321
+ }
322
+ let rootsList = [];
323
+
245
324
  function onEvent(e, live) {
246
325
  if (e.type === 'search') {
247
326
  for (const h of e.hits) nodeEl.get(h)?.classList.add('hit');
248
- addLog(e, 'search_vault', `"${e.query}" · ${L.hits(e.hits.length)}`, live);
327
+ addLog(e, 'search_files', `"${e.query}" · ${L.hits(e.hits.length)}`, live);
328
+ } else if (e.type === 'list') {
329
+ visit(e.path, live);
330
+ addLog(e, 'list_files', short(e.path) + '/', live);
249
331
  } else if (e.type === 'read') {
250
- const g = nodeEl.get(e.path);
251
- if (g) {
252
- if (current && current !== e.path) line('trail', current, e.path, live);
253
- g.classList.remove('hit'); g.classList.add('visited');
254
- sources.add(e.path); setCurrent(e.path);
255
- }
256
- addLog(e, 'read_note', e.path, live);
332
+ visit(e.path, live);
333
+ if (nodeEl.has(e.path)) sources.add(e.path);
334
+ addLog(e, 'read_file', short(e.path), live);
257
335
  } else if (e.type === 'write' || e.type === 'edit') {
258
336
  ensureNode(e.path);
259
- for (const s of sources) if (s !== e.path) line('src', s, e.path, live);
337
+ for (const s of sources) if (s !== e.path && pos.has(s)) line('src', s, e.path, live);
260
338
  const g = nodeEl.get(e.path);
261
339
  g.classList.remove('gone'); g.classList.add('written');
262
340
  setCurrent(e.path);
263
- addLog(e, e.type === 'write' ? 'write_note' : 'edit_note', e.created ? `${e.path} (${L.created})` : e.path, live);
341
+ addLog(e, e.type === 'write' ? 'write_file' : 'edit_file', e.created ? `${short(e.path)} (${L.created})` : short(e.path), live);
342
+ } else if (e.type === 'move') {
343
+ visit(e.from, live);
344
+ ensureNode(e.path, e.dir ? 'dir' : 'file');
345
+ if (pos.has(e.from)) line('src', e.from, e.path, live);
346
+ nodeEl.get(e.from)?.classList.add('gone');
347
+ const g = nodeEl.get(e.path);
348
+ g.classList.remove('gone'); g.classList.add('written');
349
+ setCurrent(e.path);
350
+ addLog(e, 'move_file', `${short(e.from)} → ${short(e.path)}`, live);
264
351
  } else if (e.type === 'undo') {
352
+ if (e.back) nodeEl.get(e.back)?.classList.remove('gone');
265
353
  if (e.created) {
266
354
  nodeEl.get(e.path)?.classList.add('gone');
267
355
  gTrail.querySelectorAll(`[data-to="${CSS.escape(e.path)}"]`).forEach(l => l.style.opacity = 0);
268
356
  }
269
357
  logEl.querySelectorAll(`[data-change="${e.changeId}"]`).forEach(b => { b.disabled = true; b.textContent = L.undone; });
270
- addLog(e, 'undo', e.path, live);
358
+ addLog(e, 'undo', short(e.path), live);
271
359
  }
272
360
  }
273
361
 
@@ -277,14 +365,14 @@ document.getElementById('clear').onclick = () => {
277
365
  current = null; sources = new Set();
278
366
  };
279
367
 
280
- // Drag to pan, wheel to zoom
368
+ // Drag to pan, wheel or pinch to zoom
281
369
  let drag = null;
282
370
  svg.addEventListener('pointerdown', e => { drag = { x: e.clientX, y: e.clientY, vb: { ...vb } }; svg.classList.add('drag'); svg.setPointerCapture(e.pointerId); });
283
371
  svg.addEventListener('pointermove', e => {
284
372
  if (!drag) return;
285
373
  const k = vb.w / svg.clientWidth;
286
374
  vb.x = drag.vb.x - (e.clientX - drag.x) * k; vb.y = drag.vb.y - (e.clientY - drag.y) * k;
287
- svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
375
+ setView();
288
376
  });
289
377
  svg.addEventListener('pointerup', () => { drag = null; svg.classList.remove('drag'); });
290
378
  svg.addEventListener('wheel', e => {
@@ -292,14 +380,16 @@ svg.addEventListener('wheel', e => {
292
380
  const k = Math.exp(e.deltaY * 0.0015), r = svg.getBoundingClientRect();
293
381
  const mx = vb.x + (e.clientX - r.left) / r.width * vb.w, my = vb.y + (e.clientY - r.top) / r.height * vb.h;
294
382
  vb = { x: mx - (mx - vb.x) * k, y: my - (my - vb.y) * k, w: vb.w * k, h: vb.h * k };
295
- svg.setAttribute('viewBox', `${vb.x} ${vb.y} ${vb.w} ${vb.h}`);
383
+ setView();
296
384
  }, { passive: false });
385
+ addEventListener('resize', fit);
297
386
 
298
387
  (async () => {
299
388
  const info = await (await fetch('/api/info')).json();
300
- document.getElementById('vault').textContent = info.vault;
389
+ rootsList = info.roots;
301
390
  await loadGraph();
302
391
  const status = document.getElementById('status'), statusText = document.getElementById('statusText');
392
+ statusText.textContent = L.off;
303
393
  let replay = true;
304
394
  const es = new EventSource('/api/events');
305
395
  es.onopen = () => { status.classList.add('on'); statusText.textContent = L.live; setTimeout(() => { replay = false; }, 300); };
@@ -311,7 +401,6 @@ svg.addEventListener('wheel', e => {
311
401
  seen.add(e.key);
312
402
  onEvent(e, !replay);
313
403
  };
314
- statusText.textContent = L.off;
315
404
  })();
316
405
  </script>
317
406
  </body>
package/src/vault.js DELETED
@@ -1,109 +0,0 @@
1
- // Read and write markdown notes inside one folder. Every path that comes from the AI is
2
- // resolved against the vault root and refused if it points outside it or isn't a .md file.
3
- import fs from 'node:fs/promises';
4
- import path from 'node:path';
5
-
6
- const SKIP = new Set(['.git', '.obsidian', '.trash', 'node_modules']);
7
- const LINK = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g;
8
-
9
- export class Vault {
10
- constructor(root) {
11
- this.root = path.resolve(root);
12
- }
13
-
14
- // "Notes/Plan" or "Notes/Plan.md" -> absolute path, or throw if it escapes the vault
15
- resolve(rel) {
16
- let p = String(rel || '').trim().replace(/\\/g, '/');
17
- if (!p) throw new Error('Note path is empty.');
18
- if (!p.toLowerCase().endsWith('.md')) p += '.md';
19
- const abs = path.resolve(this.root, p);
20
- if (abs !== this.root && !abs.startsWith(this.root + path.sep)) throw new Error(`"${rel}" is outside the vault.`);
21
- if (abs.split(path.sep).some(part => SKIP.has(part))) throw new Error(`"${rel}" is in a folder Pathnote does not touch.`);
22
- return abs;
23
- }
24
-
25
- // Same check after following symlinks, so a link inside the vault can't lead outside it
26
- async real(rel) {
27
- const abs = this.resolve(rel);
28
- const root = await fs.realpath(this.root);
29
- let dir = abs;
30
- for (;;) {
31
- try { dir = await fs.realpath(dir); break; } catch (e) { if (e.code !== 'ENOENT') throw e; dir = path.dirname(dir); }
32
- }
33
- if (dir !== root && !dir.startsWith(root + path.sep)) throw new Error(`"${rel}" is outside the vault.`);
34
- return abs;
35
- }
36
-
37
- rel(abs) {
38
- return path.relative(this.root, abs).split(path.sep).join('/');
39
- }
40
-
41
- async list() {
42
- const out = [];
43
- const walk = async dir => {
44
- for (const e of await fs.readdir(dir, { withFileTypes: true })) {
45
- if (e.name.startsWith('.') || SKIP.has(e.name)) continue;
46
- const abs = path.join(dir, e.name);
47
- if (e.isDirectory()) await walk(abs);
48
- else if (e.name.toLowerCase().endsWith('.md')) out.push(this.rel(abs));
49
- }
50
- };
51
- await walk(this.root);
52
- return out.sort();
53
- }
54
-
55
- async read(rel) {
56
- return fs.readFile(await this.real(rel), 'utf8');
57
- }
58
-
59
- // Current text, or null when the note doesn't exist yet
60
- async readOrNull(rel) {
61
- try { return await this.read(rel); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
62
- }
63
-
64
- async write(rel, text) {
65
- const abs = await this.real(rel);
66
- await fs.mkdir(path.dirname(abs), { recursive: true });
67
- await fs.writeFile(abs, text, 'utf8');
68
- }
69
-
70
- async remove(rel) {
71
- await fs.rm(await this.real(rel), { force: true });
72
- }
73
-
74
- // Notes ranked by how often the query's words appear, title matches counted extra
75
- async search(query, limit = 10) {
76
- const words = String(query).toLowerCase().split(/\s+/).filter(Boolean);
77
- if (!words.length) return [];
78
- const hits = [];
79
- for (const rel of await this.list()) {
80
- const text = await this.read(rel);
81
- const lower = text.toLowerCase(), name = rel.toLowerCase();
82
- let score = 0;
83
- for (const w of words) {
84
- score += lower.split(w).length - 1;
85
- if (name.includes(w)) score += 5;
86
- }
87
- if (!score) continue;
88
- const at = Math.max(0, lower.indexOf(words.find(w => lower.includes(w)) ?? words[0]));
89
- const snippet = text.slice(Math.max(0, at - 60), at + 140).replace(/\s+/g, ' ').trim();
90
- hits.push({ path: rel, score, snippet });
91
- }
92
- return hits.sort((a, b) => b.score - a.score).slice(0, limit);
93
- }
94
-
95
- // Nodes and [[wikilink]] edges for the viewer. Links resolve by file name, like Obsidian.
96
- async graph() {
97
- const notes = await this.list();
98
- const byName = new Map(notes.map(n => [path.posix.basename(n, '.md').toLowerCase(), n]));
99
- const edges = [];
100
- for (const n of notes) {
101
- const text = await this.read(n);
102
- for (const m of text.matchAll(LINK)) {
103
- const target = byName.get(path.posix.basename(m[1].trim()).toLowerCase());
104
- if (target && target !== n) edges.push([n, target]);
105
- }
106
- }
107
- return { nodes: notes, edges };
108
- }
109
- }