edisnote-mcp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +94 -2
- package/bin/edisnote-mcp.js +166 -0
- package/package.json +36 -4
- package/skill/SKILL.md +46 -0
- package/src/notes.js +246 -0
- package/src/rpc.js +101 -0
- package/src/server.js +442 -0
- package/src/vault.js +226 -0
package/src/vault.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads an Edisnote folder off disk. Read-only by design: nothing in this
|
|
3
|
+
* package opens a file for writing. The extension's rule is that writers into
|
|
4
|
+
* the notes folder must merge rather than regenerate, and the cheapest way to
|
|
5
|
+
* honour that from a second program is not to be a writer at all.
|
|
6
|
+
*
|
|
7
|
+
* Every path that leaves this module has been checked to sit inside the
|
|
8
|
+
* folder. Note bodies are user content and can name any path in an embed, so
|
|
9
|
+
* `../../secrets.png` must resolve to nothing rather than to a file.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readdir, readFile, stat, realpath } from 'node:fs/promises';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join, relative, resolve, sep, posix, dirname, isAbsolute } from 'node:path';
|
|
15
|
+
import {
|
|
16
|
+
parseFrontmatter,
|
|
17
|
+
findImageEmbeds,
|
|
18
|
+
findBoardLinks,
|
|
19
|
+
boardItemAddedAt,
|
|
20
|
+
parseCanvas,
|
|
21
|
+
viewableMime,
|
|
22
|
+
extensionOf,
|
|
23
|
+
} from './notes.js';
|
|
24
|
+
|
|
25
|
+
/** Folders that hold Edisnote's own machinery or another app's, never notes. */
|
|
26
|
+
const SKIP_DIRS = new Set(['attachments', 'collections', 'node_modules']);
|
|
27
|
+
|
|
28
|
+
export function defaultFolder() {
|
|
29
|
+
return join(homedir(), 'Documents', 'Notes');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The folder to read: `--dir` beats `EDISNOTE_DIR` beats the default the
|
|
34
|
+
* extension's README suggests. A leading `~` is expanded because a JSON config
|
|
35
|
+
* file won't do it for you.
|
|
36
|
+
*/
|
|
37
|
+
export function chooseFolder(argv = process.argv.slice(2), env = process.env) {
|
|
38
|
+
const i = argv.indexOf('--dir');
|
|
39
|
+
const picked = (i >= 0 && argv[i + 1]) || env.EDISNOTE_DIR || defaultFolder();
|
|
40
|
+
return resolve(picked.replace(/^~(?=$|[\\/])/, homedir()));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True when `abs` is `root` or somewhere beneath it. */
|
|
44
|
+
export function isInside(root, abs) {
|
|
45
|
+
const rel = relative(root, abs);
|
|
46
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toPosix(rel) {
|
|
50
|
+
return rel.split(sep).join(posix.sep);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function exists(abs) {
|
|
54
|
+
try {
|
|
55
|
+
return await stat(abs);
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A symlink inside the folder can still point outside it, so the lexical check
|
|
63
|
+
* is followed by a real-path one before any bytes are read.
|
|
64
|
+
*/
|
|
65
|
+
async function insideForReal(root, abs) {
|
|
66
|
+
if (!isInside(root, abs)) return false;
|
|
67
|
+
try {
|
|
68
|
+
const [realRoot, realAbs] = await Promise.all([realpath(root), realpath(abs)]);
|
|
69
|
+
return isInside(realRoot, realAbs);
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function listMarkdown(root, dir = root, out = []) {
|
|
76
|
+
let entries;
|
|
77
|
+
try {
|
|
78
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
79
|
+
} catch {
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (entry.name.startsWith('.')) continue; // .sidenote, .obsidian, .trash, .git
|
|
84
|
+
const abs = join(dir, entry.name);
|
|
85
|
+
if (entry.isDirectory()) {
|
|
86
|
+
if (dir === root && SKIP_DIRS.has(entry.name)) continue;
|
|
87
|
+
await listMarkdown(root, abs, out);
|
|
88
|
+
} else if (entry.isFile() && /\.md$/i.test(entry.name)) {
|
|
89
|
+
out.push(abs);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Obsidian resolves a bare `![[photo.jpg]]` by searching the vault, and the
|
|
97
|
+
* extension writes paths relative to the folder root. Try the places a target
|
|
98
|
+
* can honestly mean, in that order, and accept only what stays inside.
|
|
99
|
+
*/
|
|
100
|
+
async function locate(root, noteAbs, target) {
|
|
101
|
+
const tries = [resolve(dirname(noteAbs), target), resolve(root, target)];
|
|
102
|
+
if (!/[\\/]/.test(target)) tries.push(resolve(root, 'attachments', target));
|
|
103
|
+
for (const abs of tries) {
|
|
104
|
+
if (!isInside(root, abs)) continue;
|
|
105
|
+
const info = await exists(abs);
|
|
106
|
+
if (info?.isFile() && (await insideForReal(root, abs))) return { abs, info };
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Loads one note's text. Images are not touched here — listing sixty notes
|
|
113
|
+
* should not mean stat-ing three hundred pictures. `imagesOf()` does that on
|
|
114
|
+
* demand.
|
|
115
|
+
*/
|
|
116
|
+
async function loadNote(root, abs) {
|
|
117
|
+
const [text, info] = await Promise.all([readFile(abs, 'utf8'), stat(abs)]);
|
|
118
|
+
const { data, body } = parseFrontmatter(text);
|
|
119
|
+
const id = toPosix(relative(root, abs)).replace(/\.md$/i, '');
|
|
120
|
+
const heading = /^#\s+(.+)$/m.exec(body)?.[1]?.trim();
|
|
121
|
+
const sources = Array.isArray(data.sources) ? data.sources : data.sources ? [data.sources] : [];
|
|
122
|
+
const embeds = findImageEmbeds(body);
|
|
123
|
+
return {
|
|
124
|
+
id,
|
|
125
|
+
abs,
|
|
126
|
+
title: (typeof data.title === 'string' && data.title.trim()) || heading || id,
|
|
127
|
+
created: typeof data.created === 'string' ? data.created : null,
|
|
128
|
+
updated: typeof data.updated === 'string' ? data.updated : null,
|
|
129
|
+
mtime: info.mtimeMs,
|
|
130
|
+
sources,
|
|
131
|
+
body,
|
|
132
|
+
embeds,
|
|
133
|
+
boards: findBoardLinks(body),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Every note in the folder, most recently changed first. The extension only
|
|
139
|
+
* rewrites a .md whose bytes changed, so mtime is a fair "last touched".
|
|
140
|
+
*
|
|
141
|
+
* @returns {Promise<{root: string, found: boolean, notes: Awaited<ReturnType<typeof loadNote>>[]}>}
|
|
142
|
+
*/
|
|
143
|
+
export async function loadVault(root) {
|
|
144
|
+
const info = await exists(root);
|
|
145
|
+
if (!info?.isDirectory()) return { root, found: false, notes: [] };
|
|
146
|
+
const files = await listMarkdown(root);
|
|
147
|
+
const notes = [];
|
|
148
|
+
for (const abs of files) {
|
|
149
|
+
try {
|
|
150
|
+
notes.push(await loadNote(root, abs));
|
|
151
|
+
} catch {
|
|
152
|
+
// A file deleted between readdir and readFile — the next call sees it gone.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
notes.sort((a, b) => b.mtime - a.mtime);
|
|
156
|
+
return { root, found: true, notes };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The note's images, numbered in reading order: embeds in the body first, then
|
|
161
|
+
* any board image the note's gallery didn't show (the gallery is a preview and
|
|
162
|
+
* the board can hold more). Numbers are what the tools take, so they must be
|
|
163
|
+
* stable for a given state of the file — they are, being pure reading order.
|
|
164
|
+
*
|
|
165
|
+
* `addedAt` is the best evidence of when each image arrived: the timestamp in
|
|
166
|
+
* a board item's id when there is one, otherwise the file's mtime. Body images
|
|
167
|
+
* are named by position (`slug-03.jpg`), so inserting one mid-note renames the
|
|
168
|
+
* ones after it and refreshes their mtimes — "newest" is therefore a strong
|
|
169
|
+
* hint, not a promise, for body images.
|
|
170
|
+
*/
|
|
171
|
+
export async function imagesOf(root, note) {
|
|
172
|
+
const images = [];
|
|
173
|
+
const seen = new Set();
|
|
174
|
+
|
|
175
|
+
const add = async (target, board) => {
|
|
176
|
+
const hit = await locate(root, note.abs, target);
|
|
177
|
+
const key = hit ? hit.abs : `missing:${target}`;
|
|
178
|
+
if (seen.has(key)) return;
|
|
179
|
+
seen.add(key);
|
|
180
|
+
const rel = hit ? toPosix(relative(root, hit.abs)) : target;
|
|
181
|
+
images.push({
|
|
182
|
+
n: images.length + 1,
|
|
183
|
+
target,
|
|
184
|
+
rel,
|
|
185
|
+
abs: hit?.abs ?? null,
|
|
186
|
+
bytes: hit?.info.size ?? 0,
|
|
187
|
+
ext: extensionOf(rel),
|
|
188
|
+
mime: hit ? viewableMime(rel) : null,
|
|
189
|
+
board,
|
|
190
|
+
addedAt: boardItemAddedAt(rel) ?? hit?.info.mtimeMs ?? null,
|
|
191
|
+
});
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const boards = [];
|
|
195
|
+
const boardTitles = new Map();
|
|
196
|
+
for (const path of note.boards) {
|
|
197
|
+
const hit = await locate(root, note.abs, path);
|
|
198
|
+
if (!hit) continue;
|
|
199
|
+
const canvas = parseCanvas(await readFile(hit.abs, 'utf8'));
|
|
200
|
+
const title = boardTitle(note.body, path);
|
|
201
|
+
boards.push({ path: toPosix(relative(root, hit.abs)), title, ...canvas });
|
|
202
|
+
for (const file of canvas.files) boardTitles.set(file, title);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
for (const embed of note.embeds) {
|
|
206
|
+
if (embed.remote) continue;
|
|
207
|
+
await add(embed.target, boardTitles.get(embed.target) ?? null);
|
|
208
|
+
}
|
|
209
|
+
for (const board of boards) {
|
|
210
|
+
for (const file of board.files) await add(file, board.title);
|
|
211
|
+
}
|
|
212
|
+
return { images, boards };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The bold label the extension writes before a board link, else its filename. */
|
|
216
|
+
function boardTitle(body, path) {
|
|
217
|
+
const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
218
|
+
const label = new RegExp(`\\*\\*([^*\\n]+)\\*\\*\\s*·\\s*\\[\\[${escaped}`).exec(body)?.[1];
|
|
219
|
+
return label ?? posix.basename(path, '.canvas');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Reads an image that imagesOf() located. Refuses anything it didn't. */
|
|
223
|
+
export async function readImage(root, image) {
|
|
224
|
+
if (!image.abs || !(await insideForReal(root, image.abs))) return null;
|
|
225
|
+
return readFile(image.abs);
|
|
226
|
+
}
|