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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ayodeji Areago
|
|
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,95 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Edisnote for AI agents
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Let Claude Code, Cursor, and other AI tools see the notes and images you save
|
|
4
|
+
with [Edisnote](https://chromewebstore.google.com/detail/edisnote/fbnlpkgckonpaekmbfohfobgabbigaan).
|
|
5
|
+
|
|
6
|
+
Collect references in Chrome, then tell your agent "look at the two images I
|
|
7
|
+
just added to my moodboard note". It sees the actual pictures, not just file
|
|
8
|
+
names.
|
|
9
|
+
|
|
10
|
+
- **`/edisnote`**, in the Claude desktop app or the terminal:
|
|
11
|
+
|
|
12
|
+
| Type | You get |
|
|
13
|
+
|---|---|
|
|
14
|
+
| `/edisnote` | your recent notes to pick from |
|
|
15
|
+
| `/edisnote moodboard` | that note: its text, source links and newest images |
|
|
16
|
+
| `/edisnote moodboard 2` | the 2 newest images in it |
|
|
17
|
+
| `/edisnote moodboard #3` | image 3 |
|
|
18
|
+
| `/edisnote recent` | what you just saved, from any note |
|
|
19
|
+
|
|
20
|
+
- **Ask in plain words.** "Check my Edisnote references for the Apex signage"
|
|
21
|
+
works. The agent finds the note and opens the images itself.
|
|
22
|
+
- **`@` your notes (terminal).** Type `@` in Claude Code in a terminal and your
|
|
23
|
+
notes show up next to your files.
|
|
24
|
+
|
|
25
|
+
It only reads. It never changes, moves, or deletes your notes, and nothing
|
|
26
|
+
leaves your computer except what your agent sends to its own AI model.
|
|
27
|
+
|
|
28
|
+
## Before you start
|
|
29
|
+
|
|
30
|
+
1. **Edisnote saves to a folder.** In the Edisnote panel, click **Sync off** in
|
|
31
|
+
the footer and pick a folder. `Documents\Notes` is the default this uses.
|
|
32
|
+
2. **Node.js 20 or newer** ([nodejs.org](https://nodejs.org)).
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npx -y edisnote-mcp install
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
That adds it to Claude Code for every project, adds the `/edisnote` command, and prints the config for other
|
|
41
|
+
apps. Start a new Claude Code session afterwards.
|
|
42
|
+
|
|
43
|
+
If your notes are somewhere other than `Documents\Notes`:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx -y edisnote-mcp install --dir "D:\My Notes"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
To check what it can see:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx -y edisnote-mcp check
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Other apps
|
|
56
|
+
|
|
57
|
+
Add this to the app's MCP settings (Cursor: `~/.cursor/mcp.json`; Claude
|
|
58
|
+
Desktop: Settings → Developer → Edit Config):
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"mcpServers": {
|
|
63
|
+
"edisnote": { "command": "npx", "args": ["-y", "edisnote-mcp"] }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Add `"--dir", "D:\\My Notes"` to `args` if your folder is elsewhere.
|
|
69
|
+
|
|
70
|
+
## What your agent gets
|
|
71
|
+
|
|
72
|
+
| | |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `list_notes` | Find a note. Searches titles, text and the links images came from. |
|
|
75
|
+
| `read_note` | A note's text, sources and boards, a numbered list of its images, and the newest ones to look at. |
|
|
76
|
+
| `view_images` | Specific images by number, or the newest few. |
|
|
77
|
+
| `recent_images` | The newest images across every note. |
|
|
78
|
+
|
|
79
|
+
Images are numbered in the order they appear in the note. For board images
|
|
80
|
+
Edisnote records exactly when each one was added; for images in the note body,
|
|
81
|
+
"newest" goes by when the file was last written, which is usually but not
|
|
82
|
+
always when you added it.
|
|
83
|
+
|
|
84
|
+
Images over about 3.7 MB, and formats AI models can't view (SVG, AVIF), are
|
|
85
|
+
listed by file path instead of sent.
|
|
86
|
+
|
|
87
|
+
## Remove it
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
claude mcp remove edisnote --scope user
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Develop
|
|
94
|
+
|
|
95
|
+
No dependencies. `npm test` runs the suite with Node's built-in test runner.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* edisnote-mcp run the MCP server on stdio (what an agent launches)
|
|
4
|
+
* edisnote-mcp install register it with Claude Code, print config for others
|
|
5
|
+
* edisnote-mcp check show what the server can see, then exit
|
|
6
|
+
*
|
|
7
|
+
* Every form takes --dir <folder>; the default is Documents\Notes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { watch, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { serveStdio } from '../src/rpc.js';
|
|
16
|
+
import { createHandlers, listFingerprint, NAME, VERSION } from '../src/server.js';
|
|
17
|
+
import { chooseFolder, loadVault, defaultFolder } from '../src/vault.js';
|
|
18
|
+
|
|
19
|
+
const SKILL_MARKER = 'edisnote-mcp skill';
|
|
20
|
+
const argv = process.argv.slice(2);
|
|
21
|
+
const command = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'serve';
|
|
22
|
+
const root = chooseFolder(argv);
|
|
23
|
+
|
|
24
|
+
if (argv.includes('--version') || command === 'version') {
|
|
25
|
+
console.log(VERSION);
|
|
26
|
+
} else if (command === 'serve') {
|
|
27
|
+
serve();
|
|
28
|
+
} else if (command === 'check') {
|
|
29
|
+
await check();
|
|
30
|
+
} else if (command === 'install') {
|
|
31
|
+
install();
|
|
32
|
+
} else {
|
|
33
|
+
console.error(`Unknown command "${command}". Try: edisnote-mcp install | check | --version`);
|
|
34
|
+
process.exitCode = 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function serve() {
|
|
38
|
+
const { notify, closed } = serveStdio(createHandlers(root));
|
|
39
|
+
|
|
40
|
+
// New notes should show up in the @ list without restarting the agent. The
|
|
41
|
+
// extension writes two seconds after every keystroke, so the folder is busy;
|
|
42
|
+
// the fingerprint means a notification goes out only when the list itself
|
|
43
|
+
// (ids, titles, image counts) actually changed.
|
|
44
|
+
let last = null;
|
|
45
|
+
let timer = null;
|
|
46
|
+
listFingerprint(root).then((f) => (last = f));
|
|
47
|
+
const recheck = () => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
timer = setTimeout(async () => {
|
|
50
|
+
const next = await listFingerprint(root).catch(() => last);
|
|
51
|
+
if (last !== null && next !== last) notify('notifications/resources/list_changed');
|
|
52
|
+
last = next;
|
|
53
|
+
}, 750);
|
|
54
|
+
};
|
|
55
|
+
let watcher = null;
|
|
56
|
+
try {
|
|
57
|
+
watcher = watch(root, { recursive: true }, recheck);
|
|
58
|
+
watcher.on('error', () => watcher?.close());
|
|
59
|
+
} catch {
|
|
60
|
+
// Folder missing or recursive watch unsupported: the list still refreshes
|
|
61
|
+
// whenever the client asks, it just isn't pushed.
|
|
62
|
+
}
|
|
63
|
+
closed.then(() => {
|
|
64
|
+
watcher?.close();
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
process.exit(0);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function check() {
|
|
71
|
+
const vault = await loadVault(root);
|
|
72
|
+
if (!vault.found) {
|
|
73
|
+
console.log(`No folder at ${root}.`);
|
|
74
|
+
console.log('Turn on folder sync in Edisnote (the "Sync off" chip), or pass --dir <folder>.');
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const images = vault.notes.reduce((sum, n) => sum + n.embeds.filter((e) => !e.remote).length, 0);
|
|
79
|
+
console.log(`Edisnote folder: ${root}`);
|
|
80
|
+
console.log(`${vault.notes.length} notes, ${images} images embedded in them.`);
|
|
81
|
+
for (const note of vault.notes.slice(0, 5)) console.log(` ${note.title} (${note.id})`);
|
|
82
|
+
if (vault.notes.length > 5) console.log(` …and ${vault.notes.length - 5} more`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Registers the server with Claude Code at user scope, so it works in every
|
|
87
|
+
* project. Run through npx, it registers `npx -y edisnote-mcp` and so always
|
|
88
|
+
* gets the published version; run from a checkout, it registers that checkout.
|
|
89
|
+
*/
|
|
90
|
+
function install() {
|
|
91
|
+
const self = fileURLToPath(import.meta.url);
|
|
92
|
+
const viaNpx = /[\\/]_npx[\\/]/.test(self);
|
|
93
|
+
// Plain `node`, not process.execPath: the full path is usually
|
|
94
|
+
// C:\Program Files\..., and a space in the command is one more way for a
|
|
95
|
+
// config file or shell to split it in two.
|
|
96
|
+
const launch = viaNpx ? ['npx', '-y', 'edisnote-mcp'] : ['node', self];
|
|
97
|
+
const dirArgs = root !== defaultFolder() ? ['--dir', root] : [];
|
|
98
|
+
const full = [...launch, ...dirArgs];
|
|
99
|
+
|
|
100
|
+
console.log(`Edisnote MCP ${VERSION} — reading ${root}\n`);
|
|
101
|
+
|
|
102
|
+
const claude = runClaude(['mcp', 'add', '--scope', 'user', NAME, '--', ...full]);
|
|
103
|
+
if (claude.status === 0) {
|
|
104
|
+
console.log('Added to Claude Code for every project. Start a new session, then:');
|
|
105
|
+
console.log(' /edisnote pick one of your recent notes');
|
|
106
|
+
console.log(' /edisnote moodboard 2 the 2 newest images in that note');
|
|
107
|
+
console.log(' /edisnote recent what you just saved, from any note');
|
|
108
|
+
console.log(' or just say "look at my Edisnote note on …"\n');
|
|
109
|
+
} else if (/already exists/i.test(`${claude.stdout}${claude.stderr}`)) {
|
|
110
|
+
console.log(`Claude Code already has a server called "${NAME}". To replace it:`);
|
|
111
|
+
console.log(` claude mcp remove ${NAME} --scope user`);
|
|
112
|
+
console.log(' then run this again.\n');
|
|
113
|
+
} else {
|
|
114
|
+
console.log('Claude Code not found (or it refused). To add it by hand:');
|
|
115
|
+
console.log(` claude mcp add --scope user ${NAME} -- ${full.map(quote).join(' ')}\n`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
installSkill();
|
|
119
|
+
|
|
120
|
+
const [cmd, ...args] = full;
|
|
121
|
+
console.log('For Cursor, Claude Desktop or any other MCP app, add this to its MCP config:');
|
|
122
|
+
console.log(JSON.stringify({ mcpServers: { [NAME]: { command: cmd, args } } }, null, 2));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The Claude desktop app's message box lists skills under `/` but not MCP
|
|
127
|
+
* prompts or resources, so without this the server is reachable there only by
|
|
128
|
+
* asking in words. The skill is the `/edisnote` command for the desktop app.
|
|
129
|
+
*
|
|
130
|
+
* A file at that path without our marker is someone's own skill, and an
|
|
131
|
+
* installer has no business replacing it.
|
|
132
|
+
*/
|
|
133
|
+
function installSkill() {
|
|
134
|
+
const source = fileURLToPath(new URL('../skill/SKILL.md', import.meta.url));
|
|
135
|
+
const dir = join(homedir(), '.claude', 'skills', 'edisnote');
|
|
136
|
+
const target = join(dir, 'SKILL.md');
|
|
137
|
+
const ours = readFileSync(source, 'utf8');
|
|
138
|
+
if (existsSync(target)) {
|
|
139
|
+
const current = readFileSync(target, 'utf8');
|
|
140
|
+
if (current === ours) return console.log('The /edisnote skill is already up to date.\n');
|
|
141
|
+
if (!current.includes(SKILL_MARKER)) {
|
|
142
|
+
console.log(`Left ${target} alone: it isn't one this installer wrote.\n`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
mkdirSync(dir, { recursive: true });
|
|
147
|
+
writeFileSync(target, ours);
|
|
148
|
+
console.log('Added the /edisnote skill. In the Claude desktop app, type /edisnote to pick a note.\n');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Runs the claude CLI without a shell first: through one, Node joins the args
|
|
153
|
+
* with spaces unescaped, and the first install registered "C:\Program" as the
|
|
154
|
+
* command. Only a Windows install that is a .cmd shim (npm's) needs the shell,
|
|
155
|
+
* and then every argument is quoted by hand.
|
|
156
|
+
*/
|
|
157
|
+
function runClaude(args) {
|
|
158
|
+
const direct = spawnSync('claude', args, { encoding: 'utf8' });
|
|
159
|
+
if (!direct.error || process.platform !== 'win32') return direct;
|
|
160
|
+
const line = ['claude', ...args].map((a) => (/[\s"&|<>^]/.test(a) ? `"${a.replace(/"/g, '""')}"` : a)).join(' ');
|
|
161
|
+
return spawnSync(line, { encoding: 'utf8', shell: true });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function quote(arg) {
|
|
165
|
+
return /\s/.test(arg) ? `"${arg}"` : arg;
|
|
166
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "edisnote-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Let Claude Code, Cursor and other AI agents see the notes and images you save with Edisnote. Read-only, local, no dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"edisnote-mcp": "bin/edisnote-mcp.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"skill",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test test/*.test.js"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mcp",
|
|
24
|
+
"edisnote",
|
|
25
|
+
"claude",
|
|
26
|
+
"moodboard",
|
|
27
|
+
"references",
|
|
28
|
+
"obsidian"
|
|
29
|
+
],
|
|
30
|
+
"author": "Ayodeji Areago",
|
|
31
|
+
"homepage": "https://github.com/ayareago/edisnote-mcp#readme",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/ayareago/edisnote-mcp.git"
|
|
35
|
+
},
|
|
36
|
+
"bugs": "https://github.com/ayareago/edisnote-mcp/issues",
|
|
37
|
+
"license": "MIT"
|
|
38
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: edisnote
|
|
3
|
+
description: Look at the user's Edisnote notes and the images they saved in Chrome. Use when the user types /edisnote, mentions Edisnote, or says "my notes", "my references", "the images I saved", "what I just added", or names one of their notes.
|
|
4
|
+
argument-hint: "[note] [N newest | #image] — or recent"
|
|
5
|
+
allowed-tools: mcp__edisnote__list_notes mcp__edisnote__read_note mcp__edisnote__view_images mcp__edisnote__recent_images
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<!-- edisnote-mcp skill: the installer may overwrite this file. -->
|
|
9
|
+
|
|
10
|
+
The user collects references with Edisnote, a Chrome side-panel notepad. Every
|
|
11
|
+
note is a Markdown file with images, and the `edisnote` MCP server reads them.
|
|
12
|
+
Use its tools; don't search the disk yourself.
|
|
13
|
+
|
|
14
|
+
What they asked for: `$ARGUMENTS`
|
|
15
|
+
|
|
16
|
+
Work out which case this is, then act. Don't explain these steps to the user.
|
|
17
|
+
|
|
18
|
+
1. **Nothing given** (the line above is empty): call `list_notes` with
|
|
19
|
+
`limit: 8`. Then ask which note with AskUserQuestion: header `Note`, the
|
|
20
|
+
**four most recently changed notes** as options, each label the note's title
|
|
21
|
+
(shortened to a few words if long), each description its image count and
|
|
22
|
+
when it changed. The automatic "Other" option lets them type any name. Then
|
|
23
|
+
read the chosen note as in case 3.
|
|
24
|
+
|
|
25
|
+
2. **`recent`, `new`, `latest`, or "what I just saved", with no note named**:
|
|
26
|
+
call `recent_images`, with `count` if they gave a number.
|
|
27
|
+
|
|
28
|
+
3. **A note name alone** (any part of the title works): call `read_note`.
|
|
29
|
+
|
|
30
|
+
4. **A note plus one bare number**, e.g. `independence party 2`, or with
|
|
31
|
+
"newest/latest/last N": that is always the **newest N**, never image number
|
|
32
|
+
N. Call `view_images` with `newest: N`.
|
|
33
|
+
|
|
34
|
+
5. **A note plus specific images**, written `#3`, `image 3`, `images 3 and 5`,
|
|
35
|
+
or a list like `3,5`: call `view_images` with `numbers`.
|
|
36
|
+
|
|
37
|
+
If a tool says the name matches several notes, ask which one with
|
|
38
|
+
AskUserQuestion, using those candidates as the options. Never guess.
|
|
39
|
+
|
|
40
|
+
After the images arrive, describe what's in each one in a line or two:
|
|
41
|
+
subject, colour, type, layout. Then stop, unless they asked for more. Keep the
|
|
42
|
+
note in mind for the rest of the conversation, so that "the second one" or
|
|
43
|
+
"what did I add after that" work without naming it again.
|
|
44
|
+
|
|
45
|
+
If the `edisnote` tools aren't available at all, tell the user to run
|
|
46
|
+
`npx -y edisnote-mcp install` in a terminal and then start a new session.
|
package/src/notes.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure parsing for an Edisnote folder: frontmatter, image embeds, board links,
|
|
3
|
+
* note lookup. No file system here — vault.js reads the bytes and hands them
|
|
4
|
+
* in, which is what lets the tests run on strings.
|
|
5
|
+
*
|
|
6
|
+
* The format being parsed is what the extension's markdown.js and vault.js
|
|
7
|
+
* write, plus whatever the user has since done to those files in Obsidian. So
|
|
8
|
+
* every parser here accepts both the Markdown form `` the extension
|
|
9
|
+
* writes and the wiki form `![[path|160]]` Obsidian writes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Formats a model can actually look at. Everything else is listed by path. */
|
|
13
|
+
const VIEWABLE = new Map([
|
|
14
|
+
['jpg', 'image/jpeg'],
|
|
15
|
+
['jpeg', 'image/jpeg'],
|
|
16
|
+
['png', 'image/png'],
|
|
17
|
+
['gif', 'image/gif'],
|
|
18
|
+
['webp', 'image/webp'],
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const IMAGE_EXTENSIONS = new Set([...VIEWABLE.keys(), 'svg', 'avif', 'bmp', 'heic', 'tif', 'tiff']);
|
|
22
|
+
|
|
23
|
+
export function extensionOf(path) {
|
|
24
|
+
const match = /\.([A-Za-z0-9]+)$/.exec(path);
|
|
25
|
+
return match ? match[1].toLowerCase() : '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isImagePath(path) {
|
|
29
|
+
return IMAGE_EXTENSIONS.has(extensionOf(path));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The MIME type to send an image inline with, or null when it can't be. */
|
|
33
|
+
export function viewableMime(path) {
|
|
34
|
+
return VIEWABLE.get(extensionOf(path)) ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Splits a leading `---` block off the note. Only the subset of YAML that the
|
|
39
|
+
* extension writes, and that Obsidian's property editor writes back, is
|
|
40
|
+
* understood: scalars, quoted scalars, block lists and inline `[a, b]` lists.
|
|
41
|
+
* Anything stranger is kept as its raw string rather than guessed at.
|
|
42
|
+
*
|
|
43
|
+
* @returns {{data: Record<string, string|string[]>, body: string}}
|
|
44
|
+
*/
|
|
45
|
+
export function parseFrontmatter(text) {
|
|
46
|
+
const normalised = text.replace(/^/, '').replace(/\r\n?/g, '\n');
|
|
47
|
+
const match = /^---\n([\s\S]*?)\n---[ \t]*(?:\n|$)/.exec(normalised);
|
|
48
|
+
if (!match) return { data: {}, body: normalised };
|
|
49
|
+
|
|
50
|
+
const data = {};
|
|
51
|
+
let listKey = null;
|
|
52
|
+
for (const line of match[1].split('\n')) {
|
|
53
|
+
const item = /^\s+-\s*(.*)$/.exec(line);
|
|
54
|
+
if (item && listKey) {
|
|
55
|
+
data[listKey].push(unquote(item[1]));
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const pair = /^([A-Za-z0-9_][\w -]*?)\s*:\s*(.*)$/.exec(line);
|
|
59
|
+
if (!pair) continue;
|
|
60
|
+
const [, key, raw] = pair;
|
|
61
|
+
if (raw === '') {
|
|
62
|
+
data[key] = [];
|
|
63
|
+
listKey = key;
|
|
64
|
+
} else if (/^\[.*\]$/.test(raw)) {
|
|
65
|
+
data[key] = raw.slice(1, -1).split(',').map((s) => unquote(s.trim())).filter(Boolean);
|
|
66
|
+
listKey = null;
|
|
67
|
+
} else {
|
|
68
|
+
data[key] = unquote(raw);
|
|
69
|
+
listKey = null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { data, body: normalised.slice(match[0].length) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function unquote(value) {
|
|
76
|
+
const v = value.trim();
|
|
77
|
+
if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(v);
|
|
80
|
+
} catch {
|
|
81
|
+
return v.slice(1, -1);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) {
|
|
85
|
+
return v.slice(1, -1).replace(/''/g, "'");
|
|
86
|
+
}
|
|
87
|
+
return v;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Images, in reading order. Group 1/2 = Markdown form, group 3 = wiki form. */
|
|
91
|
+
const EMBED = /!\[[^\]]*\]\((?:<([^>]+)>|([^)\s]+))(?:\s+"[^"]*")?\)|!\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Every image embed in the body, in the order a reader meets them. Embeds of
|
|
95
|
+
* other things (notes, canvases, PDFs) are skipped; remote URLs are kept and
|
|
96
|
+
* flagged so they can be listed without being fetched.
|
|
97
|
+
*
|
|
98
|
+
* @returns {Array<{target: string, remote: boolean, wiki: boolean, start: number, end: number}>}
|
|
99
|
+
*/
|
|
100
|
+
export function findImageEmbeds(body) {
|
|
101
|
+
const found = [];
|
|
102
|
+
for (const match of body.matchAll(EMBED)) {
|
|
103
|
+
const wiki = match[3] !== undefined;
|
|
104
|
+
let target = (match[1] ?? match[2] ?? match[3]).trim();
|
|
105
|
+
if (!wiki) target = safeDecode(target);
|
|
106
|
+
const remote = /^https?:\/\//i.test(target);
|
|
107
|
+
if (!remote && !isImagePath(target)) continue;
|
|
108
|
+
found.push({ target, remote, wiki, start: match.index, end: match.index + match[0].length });
|
|
109
|
+
}
|
|
110
|
+
return found;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function safeDecode(value) {
|
|
114
|
+
try {
|
|
115
|
+
return decodeURI(value);
|
|
116
|
+
} catch {
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The boards (collections) a note shows. The extension writes
|
|
123
|
+
* `[[collections/x.canvas|open canvas]]` above the gallery; an older version
|
|
124
|
+
* wrote `![[collections/x.canvas]]`. Both count, each board once.
|
|
125
|
+
*/
|
|
126
|
+
export function findBoardLinks(body) {
|
|
127
|
+
const boards = [];
|
|
128
|
+
for (const match of body.matchAll(/!?\[\[([^\]|#]+\.canvas)(?:\|[^\]]*)?\]\]/g)) {
|
|
129
|
+
const path = match[1].trim();
|
|
130
|
+
if (!boards.includes(path)) boards.push(path);
|
|
131
|
+
}
|
|
132
|
+
return boards;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Board images are named `col-<slug>-it_<base36 ms>_<random>.<ext>`: the item
|
|
137
|
+
* id is minted from Date.now() in collections.js, so the file name carries the
|
|
138
|
+
* exact moment the image was added. That is better evidence than any mtime.
|
|
139
|
+
*/
|
|
140
|
+
export function boardItemAddedAt(path) {
|
|
141
|
+
const match = /(?:^|\/)col-.+-it_([0-9a-z]+)_[0-9a-z]+\.[A-Za-z0-9]+$/.exec(path);
|
|
142
|
+
if (!match) return null;
|
|
143
|
+
const ms = parseInt(match[1], 36);
|
|
144
|
+
// Anything outside 2020–2100 is a coincidental match, not a timestamp.
|
|
145
|
+
return ms > 1577836800000 && ms < 4102444800000 ? ms : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Replaces each image embed with `[image N]` so the text Claude reads lines up
|
|
150
|
+
* with the numbers the image tools take, and drops board links, which are
|
|
151
|
+
* summarised separately. `numberFor` maps an embed's target to its number.
|
|
152
|
+
*/
|
|
153
|
+
export function readableBody(body, embeds, numberFor) {
|
|
154
|
+
let out = '';
|
|
155
|
+
let last = 0;
|
|
156
|
+
for (const embed of embeds) {
|
|
157
|
+
out += body.slice(last, embed.start);
|
|
158
|
+
const n = numberFor(embed.target);
|
|
159
|
+
out += embed.remote ? `[web image: ${embed.target}]` : n ? `[image ${n}]` : `[missing image: ${embed.target}]`;
|
|
160
|
+
last = embed.end;
|
|
161
|
+
}
|
|
162
|
+
out += body.slice(last);
|
|
163
|
+
return out
|
|
164
|
+
.replace(/\*\*[^*\n]+\*\*\s*·\s*\[\[[^\]]+\.canvas(?:\|[^\]]*)?\]\]/g, '')
|
|
165
|
+
.replace(/!?\[\[[^\]]+\.canvas(?:\|[^\]]*)?\]\]/g, '')
|
|
166
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
167
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
168
|
+
.trim();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Pulls what a model needs out of a JSON Canvas file: the image files in the
|
|
173
|
+
* order they sit on the board (top to bottom, then left to right) and the text
|
|
174
|
+
* cards. Edisnote's own link cards are text nodes, so links arrive here too.
|
|
175
|
+
*/
|
|
176
|
+
export function parseCanvas(json) {
|
|
177
|
+
let doc;
|
|
178
|
+
try {
|
|
179
|
+
doc = JSON.parse(json);
|
|
180
|
+
} catch {
|
|
181
|
+
return { files: [], texts: [] };
|
|
182
|
+
}
|
|
183
|
+
const nodes = Array.isArray(doc?.nodes) ? doc.nodes : [];
|
|
184
|
+
const placed = [...nodes].sort((a, b) => (a.y ?? 0) - (b.y ?? 0) || (a.x ?? 0) - (b.x ?? 0));
|
|
185
|
+
return {
|
|
186
|
+
files: placed.filter((n) => n.type === 'file' && typeof n.file === 'string' && isImagePath(n.file)).map((n) => n.file),
|
|
187
|
+
texts: placed.filter((n) => n.type === 'text' && typeof n.text === 'string' && n.text.trim()).map((n) => n.text.trim()),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function normalise(value) {
|
|
192
|
+
return String(value ?? '')
|
|
193
|
+
.toLowerCase()
|
|
194
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
195
|
+
.trim();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Finds the note a person or a model meant. Exact id or title wins outright;
|
|
200
|
+
* otherwise every word of the query has to appear in the id or title. More
|
|
201
|
+
* than one hit is returned as candidates rather than silently picking one —
|
|
202
|
+
* looking at the wrong moodboard is worse than asking.
|
|
203
|
+
*
|
|
204
|
+
* @template {{id: string, title: string, mtime: number}} N
|
|
205
|
+
* @param {N[]} notes
|
|
206
|
+
* @returns {{note?: N, candidates: N[]}}
|
|
207
|
+
*/
|
|
208
|
+
export function resolveNote(notes, query) {
|
|
209
|
+
const q = normalise(query);
|
|
210
|
+
if (!q) return { candidates: [] };
|
|
211
|
+
|
|
212
|
+
const byRecency = (list) => [...list].sort((a, b) => b.mtime - a.mtime);
|
|
213
|
+
const exact = notes.filter((n) => normalise(n.id) === q || normalise(n.title) === q);
|
|
214
|
+
if (exact.length === 1) return { note: exact[0], candidates: [] };
|
|
215
|
+
if (exact.length > 1) return { candidates: byRecency(exact) };
|
|
216
|
+
|
|
217
|
+
const words = q.split(' ');
|
|
218
|
+
const hits = notes.filter((n) => {
|
|
219
|
+
const hay = `${normalise(n.id)} ${normalise(n.title)}`;
|
|
220
|
+
return words.every((w) => hay.includes(w));
|
|
221
|
+
});
|
|
222
|
+
if (hits.length === 1) return { note: hits[0], candidates: [] };
|
|
223
|
+
return { candidates: byRecency(hits) };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Case-insensitive search over title, id, body and sources, best first. */
|
|
227
|
+
export function searchNotes(notes, query) {
|
|
228
|
+
const words = normalise(query).split(' ').filter(Boolean);
|
|
229
|
+
if (!words.length) return [];
|
|
230
|
+
const scored = [];
|
|
231
|
+
for (const note of notes) {
|
|
232
|
+
const head = `${normalise(note.title)} ${normalise(note.id)}`;
|
|
233
|
+
const rest = `${normalise(note.body)} ${normalise((note.sources ?? []).join(' '))}`;
|
|
234
|
+
let score = 0;
|
|
235
|
+
for (const w of words) {
|
|
236
|
+
if (head.includes(w)) score += 3;
|
|
237
|
+
else if (rest.includes(w)) score += 1;
|
|
238
|
+
else {
|
|
239
|
+
score = 0;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (score) scored.push({ note, score });
|
|
244
|
+
}
|
|
245
|
+
return scored.sort((a, b) => b.score - a.score || b.note.mtime - a.note.mtime).map((s) => s.note);
|
|
246
|
+
}
|