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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shinehyeuk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,90 @@
1
- # Temporary Holding Version
1
+ # Pathnote
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ See which files your AI searched, read and wrote, live on a map of your folders.
4
+
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
+
7
+ Free, MIT licensed. Runs entirely on your computer; nothing is sent anywhere by Pathnote.
8
+
9
+ ## Try it
10
+
11
+ You need Node.js 20 or later.
12
+
13
+ Start with the sample notes so nothing of yours is touched:
14
+
15
+ ```bash
16
+ npx -y pathnote --sample ~/pathnote-sample
17
+ ```
18
+
19
+ ### Claude Desktop
20
+
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
+
23
+ ```json
24
+ {
25
+ "mcpServers": {
26
+ "pathnote": {
27
+ "command": "npx",
28
+ "args": ["-y", "pathnote", "/Users/you/pathnote-sample", "/Users/you/Projects"]
29
+ }
30
+ }
31
+ }
32
+ ```
33
+
34
+ Open Claude Desktop again, open http://127.0.0.1:4747 in your browser, and ask Claude something like:
35
+
36
+ > Go through the meeting notes and customer interviews and write next quarter's priorities as a new note.
37
+
38
+ ### Claude Code
39
+
40
+ ```bash
41
+ claude mcp add pathnote -- npx -y pathnote ~/pathnote-sample ~/Projects
42
+ ```
43
+
44
+ ### Cursor and other MCP clients
45
+
46
+ Use the same command: `npx -y pathnote <folder> [<folder> ...]`.
47
+
48
+ ## The viewer
49
+
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
+ - 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.
56
+
57
+ ## Tools the AI gets
58
+
59
+ | Tool | What it does |
60
+ |---|---|
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) |
67
+
68
+ Each tool takes an optional `reason`, a short sentence the viewer shows next to the step.
69
+
70
+ ## What Pathnote touches
71
+
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.
78
+ - The viewer only listens on 127.0.0.1 and refuses requests from other websites.
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
+
84
+ ## Development
85
+
86
+ ```bash
87
+ npm install
88
+ npm test
89
+ node bin/pathnote.js examples/sample-vault --viewer
90
+ ```
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ // pathnote <folder> [<folder> ...] [--port 4747] [--viewer]
3
+ // pathnote --sample <new folder> copies the sample notes there, to try Pathnote safely
4
+ // Started by an MCP client: MCP over stdin/stdout, plus the viewer at http://127.0.0.1:4747
5
+ // --viewer: only the viewer, for keeping it open while MCP clients come and go
6
+ // stdout belongs to MCP, so every message for people goes to stderr.
7
+ import fs from 'node:fs';
8
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
+ import { Workspace } from '../src/workspace.js';
10
+ import { History } from '../src/history.js';
11
+ import { Hub } from '../src/hub.js';
12
+ import { createServer } from '../src/mcp.js';
13
+
14
+ const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url)));
15
+ const args = process.argv.slice(2);
16
+ const flag = name => args.includes(name);
17
+ const opt = name => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
18
+ const folders = args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--port');
19
+ const folder = folders[0];
20
+
21
+ if (flag('--sample')) {
22
+ if (!folder) { console.error('Usage: pathnote --sample <new folder>'); process.exit(1); }
23
+ if (fs.existsSync(folder)) { console.error(`Pathnote: "${folder}" already exists. Pick a new folder.`); process.exit(1); }
24
+ fs.cpSync(new URL('../examples/sample-vault', import.meta.url), folder, { recursive: true });
25
+ console.error(`Sample notes copied to ${fs.realpathSync(folder)}`);
26
+ process.exit(0);
27
+ }
28
+ if (!folder || flag('--help')) {
29
+ console.error(`Pathnote ${pkg.version}\n\nUsage: pathnote <folder> [<folder> ...] [--port 4747] [--viewer]\n pathnote --sample <new folder>`);
30
+ process.exit(folder ? 0 : 1);
31
+ }
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
+ }
37
+ }
38
+
39
+ const ws = new Workspace(folders);
40
+ const history = new History(ws);
41
+ const hub = new Hub(ws, history, Number(opt('--port')) || 4747);
42
+ const url = await hub.start();
43
+ console.error(`Pathnote ${pkg.version}: ${ws.roots.join(', ')}\nViewer: ${url}`);
44
+ ws.warm().catch(() => {});
45
+
46
+ if (!flag('--viewer')) {
47
+ await createServer({ ws, history, hub, version: pkg.version }).connect(new StdioServerTransport());
48
+ // The viewer server would keep the process alive after the MCP client quits
49
+ process.stdin.on('close', () => process.exit(0));
50
+ }
@@ -0,0 +1,5 @@
1
+ # Ideas
2
+
3
+ - Keyboard-first capture
4
+ - Weekly digest email
5
+ - Templates gallery
@@ -0,0 +1,7 @@
1
+ # Oct team meeting
2
+
3
+ - Onboarding: still no decision. Several people think setup takes too long.
4
+ - Pricing: proposal to drop the monthly-only plan. Parked until we hear from customers.
5
+ - Action: read through [[Customer interviews]] before the next planning session.
6
+
7
+ Related: [[Weekly review]], [[Budget plan]]
@@ -0,0 +1,3 @@
1
+ # Weekly review
2
+
3
+ Shipped the new search. Next week: start on onboarding research with [[Oct team meeting]] notes.
@@ -0,0 +1,3 @@
1
+ # Budget plan
2
+
3
+ Q1 budget allows one extra contractor for 8 weeks. See [[Product roadmap]].
@@ -0,0 +1,8 @@
1
+ # Product roadmap
2
+
3
+ Next quarter candidates:
4
+ 1. Guided onboarding (first-run checklist, import wizard)
5
+ 2. Annual pricing
6
+ 3. Team spaces
7
+
8
+ Inputs: [[Customer interviews]], [[Signup metrics]], [[Competitor comparison]]
@@ -0,0 +1,5 @@
1
+ # Sample vault
2
+
3
+ Example notes for trying Pathnote safely. Ask your AI something like:
4
+
5
+ "Go through the meeting notes and customer interviews and write next quarter's priorities as a new note."
@@ -0,0 +1,5 @@
1
+ # Competitor comparison
2
+
3
+ Two competitors ship an import wizard. Both offer annual plans at about 20% off.
4
+
5
+ See [[Product roadmap]].
@@ -0,0 +1,9 @@
1
+ # Customer interviews
2
+
3
+ Seven interviews in September.
4
+
5
+ - 5 of 7 said first-time setup was the hardest part.
6
+ - 3 of 7 asked for an annual plan.
7
+ - 2 of 7 compared us with the tools in [[Competitor comparison]].
8
+
9
+ Raw quotes are collected in [[User feedback]].
@@ -0,0 +1,7 @@
1
+ # Signup metrics
2
+
3
+ - Visit to signup: 4.1%
4
+ - Signup to first note: 38% (this is where most people leave)
5
+ - First note to week-2 active: 61%
6
+
7
+ Linked from [[Product roadmap]].
@@ -0,0 +1,7 @@
1
+ # User feedback
2
+
3
+ > "I gave up on importing my notes the first day."
4
+ > "Once it was set up, I used it every day."
5
+ > "I'd pay yearly if it were cheaper than monthly."
6
+
7
+ Themes: setup friction, price per year. See [[Signup metrics]] for the drop-off numbers.
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "pathnote",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
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
+ "keywords": [
6
+ "mcp",
7
+ "model-context-protocol",
8
+ "obsidian",
9
+ "markdown",
10
+ "notes",
11
+ "claude",
12
+ "ai",
13
+ "knowledge-graph"
14
+ ],
15
+ "homepage": "https://pathnote.pages.dev",
16
+ "type": "module",
17
+ "bin": {
18
+ "pathnote": "bin/pathnote.js"
19
+ },
20
+ "files": [
21
+ "bin",
22
+ "src",
23
+ "viewer",
24
+ "examples",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "scripts": {
32
+ "test": "node --test test/"
33
+ },
34
+ "license": "MIT",
35
+ "dependencies": {
36
+ "@modelcontextprotocol/sdk": "^1.32.2",
37
+ "fflate": "0.8.3",
38
+ "unpdf": "1.9.1",
39
+ "zod": "^4.6.7"
40
+ }
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 ADDED
@@ -0,0 +1,62 @@
1
+ // Keeps the text a note had before the AI changed it, so each change can be undone.
2
+ // Stored outside the connected folders (~/.pathnote/<id>/history.jsonl) to keep them clean.
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import os from 'node:os';
6
+ import crypto from 'node:crypto';
7
+
8
+ export class History {
9
+ constructor(ws) {
10
+ this.ws = ws;
11
+ const id = crypto.createHash('sha1').update(ws.id).digest('hex').slice(0, 12);
12
+ this.file = path.join(os.homedir(), '.pathnote', id, 'history.jsonl');
13
+ }
14
+
15
+ async all() {
16
+ try {
17
+ return (await fs.readFile(this.file, 'utf8')).split('\n').filter(Boolean).map(l => JSON.parse(l));
18
+ } catch (e) {
19
+ if (e.code === 'ENOENT') return [];
20
+ throw e;
21
+ }
22
+ }
23
+
24
+ async append(entry) {
25
+ await fs.mkdir(path.dirname(this.file), { recursive: true });
26
+ await fs.appendFile(this.file, JSON.stringify(entry) + '\n');
27
+ }
28
+
29
+ // Call before changing a file. `before` is null for a file that didn't exist.
30
+ async record(rel, before, after, kind) {
31
+ const entry = { id: crypto.randomUUID(), t: Date.now(), path: rel, kind, before, after };
32
+ await this.append(entry);
33
+ return entry;
34
+ }
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
+
42
+ // Put the note back the way it was before change `id`. Refuses if someone edited it since,
43
+ // so a user's own later edits are never thrown away.
44
+ async undo(id) {
45
+ const all = await this.all();
46
+ const entry = all.find(e => e.id === id);
47
+ if (!entry) throw new Error('No such change.');
48
+ if (all.some(e => e.undoes === id)) throw new Error('Already undone.');
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);
56
+ if (now !== entry.after) throw new Error(`"${entry.path}" changed after the AI wrote it, so it was left as is.`);
57
+ if (entry.before === null) await this.ws.remove(entry.path);
58
+ else await this.ws.write(entry.path, entry.before);
59
+ await this.append({ id: crypto.randomUUID(), t: Date.now(), path: entry.path, kind: 'undo', undoes: id });
60
+ return entry;
61
+ }
62
+ }
package/src/hub.js ADDED
@@ -0,0 +1,137 @@
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 set of folders owns the
3
+ // viewer port and the others forward their events to it.
4
+ import http from 'node:http';
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const PAGE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'viewer', 'index.html');
10
+ const KEEP = 500;
11
+
12
+ export class Hub {
13
+ constructor(ws, history, port) {
14
+ this.ws = ws;
15
+ this.history = history;
16
+ this.port = port;
17
+ this.events = [];
18
+ this.clients = new Set();
19
+ this.forwardTo = null; // port of another process that owns the viewer
20
+ this.boot = Date.now().toString(36); // with seq, lets the page skip events it already has
21
+ this.seq = 0;
22
+ }
23
+
24
+ // Own a port for these folders, or find the process that already does. Returns the viewer URL.
25
+ async start() {
26
+ for (let port = this.port; port < this.port + 10; port++) {
27
+ const other = await this.info(port);
28
+ if (other?.id === this.ws.id) { this.forwardTo = port; return `http://127.0.0.1:${port}`; }
29
+ if (other) continue;
30
+ try {
31
+ await this.listen(port);
32
+ this.port = port;
33
+ return `http://127.0.0.1:${port}`;
34
+ } catch (e) {
35
+ if (e.code !== 'EADDRINUSE') throw e;
36
+ }
37
+ }
38
+ throw new Error(`No free port between ${this.port} and ${this.port + 9}.`);
39
+ }
40
+
41
+ async info(port) {
42
+ try {
43
+ const res = await fetch(`http://127.0.0.1:${port}/api/info`, { signal: AbortSignal.timeout(500) });
44
+ const body = await res.json();
45
+ return body.app === 'pathnote' ? body : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ listen(port) {
52
+ return new Promise((resolve, reject) => {
53
+ const server = http.createServer((req, res) => this.handle(req, res).catch(e => send(res, 500, { error: e.message })));
54
+ server.once('error', reject);
55
+ server.listen(port, '127.0.0.1', () => { this.server = server; resolve(); });
56
+ });
57
+ }
58
+
59
+ async emit(event, retried = false) {
60
+ const e = { t: Date.now(), ...event };
61
+ if (this.forwardTo) {
62
+ try {
63
+ await fetch(`http://127.0.0.1:${this.forwardTo}/api/event`, {
64
+ method: 'POST', headers: { 'Content-Type': 'application/json', Origin: `http://127.0.0.1:${this.forwardTo}` },
65
+ body: JSON.stringify(e), signal: AbortSignal.timeout(1000)
66
+ });
67
+ return;
68
+ } catch {
69
+ // The process that owned the viewer has quit: take the port over and keep showing events
70
+ this.forwardTo = null;
71
+ if (retried) return;
72
+ try { await this.start(); } catch { return; }
73
+ if (this.forwardTo) return this.emit(event, true);
74
+ }
75
+ }
76
+ this.broadcast(e);
77
+ }
78
+
79
+ broadcast(event) {
80
+ const e = { ...event, key: `${this.boot}:${++this.seq}` };
81
+ this.events.push(e);
82
+ if (this.events.length > KEEP) this.events.shift();
83
+ for (const res of this.clients) res.write(`data: ${JSON.stringify(e)}\n\n`);
84
+ }
85
+
86
+ // Only pages served from this port may call the API, so other websites can't trigger undo.
87
+ allowed(req) {
88
+ const hosts = [`127.0.0.1:${this.port}`, `localhost:${this.port}`];
89
+ if (!hosts.includes(req.headers.host)) return false;
90
+ const origin = req.headers.origin;
91
+ return req.method === 'GET' ? !origin || hosts.some(h => origin === `http://${h}`) : hosts.some(h => origin === `http://${h}`);
92
+ }
93
+
94
+ async handle(req, res) {
95
+ if (!this.allowed(req)) return send(res, 403, { error: 'forbidden' });
96
+ const url = new URL(req.url, 'http://x');
97
+ if (req.method === 'GET' && url.pathname === '/') {
98
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
99
+ return res.end(await fs.readFile(PAGE));
100
+ }
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
+ if (req.method === 'GET' && url.pathname === '/api/events') {
104
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
105
+ for (const e of this.events) res.write(`data: ${JSON.stringify(e)}\n\n`);
106
+ this.clients.add(res);
107
+ req.on('close', () => this.clients.delete(res));
108
+ return;
109
+ }
110
+ if (req.method === 'POST' && url.pathname === '/api/event') {
111
+ this.broadcast(await body(req));
112
+ return send(res, 200, { ok: true });
113
+ }
114
+ if (req.method === 'POST' && url.pathname === '/api/undo') {
115
+ const { id } = await body(req);
116
+ try {
117
+ const entry = await this.history.undo(id);
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
+ return send(res, 200, { ok: true });
120
+ } catch (e) {
121
+ return send(res, 409, { error: e.message });
122
+ }
123
+ }
124
+ send(res, 404, { error: 'not found' });
125
+ }
126
+ }
127
+
128
+ function send(res, status, data) {
129
+ res.writeHead(status, { 'Content-Type': 'application/json' });
130
+ res.end(JSON.stringify(data));
131
+ }
132
+
133
+ async function body(req) {
134
+ let s = '';
135
+ for await (const chunk of req) { s += chunk; if (s.length > 1e6) throw new Error('too large'); }
136
+ return JSON.parse(s || '{}');
137
+ }