tabtree-mcp 1.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.md +33 -0
- package/README.md +261 -0
- package/package.json +44 -0
- package/tabtree-mcp.mjs +856 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# TabTree Connector — Licence
|
|
2
|
+
|
|
3
|
+
This licence covers **the connector only** — the `tabtree-mcp` package and the
|
|
4
|
+
`tabtree-mcp.mjs` server file it contains. It does not cover the TabTree
|
|
5
|
+
application itself.
|
|
6
|
+
|
|
7
|
+
## The connector
|
|
8
|
+
|
|
9
|
+
Free to use, copy, redistribute and modify, for any purpose, personal or
|
|
10
|
+
commercial, with or without a TabTree licence. No attribution required, no fee,
|
|
11
|
+
no registration.
|
|
12
|
+
|
|
13
|
+
It is given away on purpose. The connector reads and writes files in a folder
|
|
14
|
+
you choose; it is worth something to you only if you also have TabTree to open
|
|
15
|
+
those files with, so there is nothing to gain by locking it up and a great deal
|
|
16
|
+
to gain by letting it travel.
|
|
17
|
+
|
|
18
|
+
Provided **as is**, without warranty of any kind. The authors are not liable for
|
|
19
|
+
any claim or damage arising from its use.
|
|
20
|
+
|
|
21
|
+
## The TabTree application
|
|
22
|
+
|
|
23
|
+
`TabTree.html` — the app you bought — is **separately licensed**, and that
|
|
24
|
+
licence is the one shipped with it (`LICENCE.txt`). In short: personal, for life,
|
|
25
|
+
all your own devices, and **not** to be resold, published for download or passed
|
|
26
|
+
to people who have not bought a licence. Team use needs the Teams plan.
|
|
27
|
+
|
|
28
|
+
Copying this file, or the `tabtree-mcp` package, grants no right whatsoever over
|
|
29
|
+
the application.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
TabTree — https://tabtree.app/ — info@ai4x.academy
|
package/README.md
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
# TabTree Connector for Claude
|
|
2
|
+
|
|
3
|
+
Let Claude read your TabTree library and build new maps for you.
|
|
4
|
+
|
|
5
|
+
> "Turn these meeting notes into a mind map."
|
|
6
|
+
> "Map the structure of this codebase."
|
|
7
|
+
> "Draw our database schema as a board."
|
|
8
|
+
> "Add the risks we just discussed to my Q3 plan."
|
|
9
|
+
|
|
10
|
+
The map appears as a file in your backup folder. You pull it into TabTree from
|
|
11
|
+
*My maps* → **🛟** → **"Bring maps back from this folder…"**.
|
|
12
|
+
|
|
13
|
+
The connector **only ever creates new files.** It cannot overwrite a map, cannot
|
|
14
|
+
delete one, and never touches your browser storage. There is no account, no
|
|
15
|
+
server, no upload: it runs on your machine and reads one folder you choose.
|
|
16
|
+
|
|
17
|
+
**You need TabTree itself.** This package is the bridge, not the app: it reads
|
|
18
|
+
and writes map files in one folder, and TabTree is what opens them. TabTree is a
|
|
19
|
+
single local HTML file you buy once and keep for life — no subscription, no
|
|
20
|
+
account, works offline. See [tabtree.app](https://tabtree.app/).
|
|
21
|
+
|
|
22
|
+
Changes to a map you already have are no exception. Claude never writes to it:
|
|
23
|
+
it files a **proposal**, TabTree shows it as a banner on that map, and you tick
|
|
24
|
+
the changes you want. ⌘Z undoes them afterwards, like anything else you do.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Before you start
|
|
29
|
+
|
|
30
|
+
**1. TabTree needs a backup folder.** Open TabTree → *My maps* → 🛟 and pick a
|
|
31
|
+
folder. That folder is what the connector reads and writes. If you have never
|
|
32
|
+
set one up, do that first — the connector has nothing to talk to otherwise.
|
|
33
|
+
|
|
34
|
+
**2. Node.js 18+ — for the npm and Claude Code routes.** Claude Desktop ships
|
|
35
|
+
its own Node, so the `.mcpb` install below needs nothing. For the other routes,
|
|
36
|
+
check with `node --version` and install the LTS build from
|
|
37
|
+
[nodejs.org](https://nodejs.org) if it errors. The app itself never needs Node.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Install — npm (Claude Code, Cursor, any MCP client)
|
|
42
|
+
|
|
43
|
+
Nothing to clone, nothing to build. Point `TABTREE_DIR` at your backup folder:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
claude mcp add tabtree --scope user -e TABTREE_DIR="/path/to/your/backup/folder" -- npx -y tabtree-mcp
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
For any client that reads a JSON config, the same thing:
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"mcpServers": {
|
|
54
|
+
"tabtree": {
|
|
55
|
+
"command": "npx",
|
|
56
|
+
"args": ["-y", "tabtree-mcp"],
|
|
57
|
+
"env": { "TABTREE_DIR": "/path/to/your/backup/folder" }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The path must be absolute — `~` and relative paths will not resolve.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Install — Claude Desktop (double-click)
|
|
68
|
+
|
|
69
|
+
**Double-click `TabTree-Connector.mcpb`.** Claude Desktop opens its install
|
|
70
|
+
screen, asks which folder to use — pick your backup folder — and that is the
|
|
71
|
+
whole procedure. No terminal, no config file, no Node.
|
|
72
|
+
|
|
73
|
+
To change the folder later, or to remove the connector, use Claude Desktop's own
|
|
74
|
+
extension settings. Nothing was written outside the app.
|
|
75
|
+
|
|
76
|
+
## Install — Claude Code (one command)
|
|
77
|
+
|
|
78
|
+
`.mcpb` is a Claude Desktop format; Claude Code reads a config file instead. The
|
|
79
|
+
installer writes it for you, backing up any existing one first:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
node mcp/install.mjs --dir "/path/to/your/backup/folder"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
It prints the `claude mcp add` command to paste for user-wide access.
|
|
86
|
+
|
|
87
|
+
To see what it *would* do without writing anything:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
node mcp/install.mjs --dir "/path/to/your/backup/folder" --dry-run
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
To check an install that already exists:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
node mcp/install.mjs --check
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Manual install — Claude Code
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
claude mcp add tabtree --scope user -e TABTREE_DIR="/path/to/your/backup/folder" -- node /absolute/path/to/mcp/tabtree-mcp.mjs
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`--scope user` makes the connector available in every project. Drop it and the
|
|
106
|
+
connector only exists in the directory you ran the command from.
|
|
107
|
+
|
|
108
|
+
If you would rather not use the CLI, put this in a `.mcp.json` file at the root
|
|
109
|
+
of a project — same result, scoped to that project:
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"mcpServers": {
|
|
114
|
+
"tabtree": {
|
|
115
|
+
"command": "node",
|
|
116
|
+
"args": ["/absolute/path/to/mcp/tabtree-mcp.mjs"],
|
|
117
|
+
"env": { "TABTREE_DIR": "/path/to/your/backup/folder" }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Manual install — Claude Desktop (fallback)
|
|
124
|
+
|
|
125
|
+
You should not need this: the `.mcpb` above does it for you. Use it only if your
|
|
126
|
+
Claude Desktop is too old to install extensions. Open the config file for your
|
|
127
|
+
system:
|
|
128
|
+
|
|
129
|
+
| System | File |
|
|
130
|
+
| --- | --- |
|
|
131
|
+
| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |
|
|
132
|
+
| Windows | `%APPDATA%\Claude\claude_desktop_config.json` |
|
|
133
|
+
| Linux | `~/.config/Claude/claude_desktop_config.json` |
|
|
134
|
+
|
|
135
|
+
Add the `tabtree` block shown above inside `mcpServers`, keeping any servers
|
|
136
|
+
already listed there. Save, then **fully quit and reopen Claude Desktop** —
|
|
137
|
+
reloading the window is not enough.
|
|
138
|
+
|
|
139
|
+
Paths must be absolute in both clients. `~` and relative paths will not resolve.
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## What Claude can do
|
|
144
|
+
|
|
145
|
+
| Tool | What it does |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| `list_maps` | Lists every map and board — name, type, folder, size, last modified. |
|
|
148
|
+
| `read_map` | Reads one map. A mind map comes back as a markdown outline, a board as elements and connections. |
|
|
149
|
+
| `search_maps` | Searches the whole library — map names, nodes, notes, shapes, stickies. Case-insensitive. |
|
|
150
|
+
| `create_mindmap` | Creates a new mind map from a markdown outline. |
|
|
151
|
+
| `create_board` | Creates a new whiteboard — shapes and arrows that stay anchored in the app. |
|
|
152
|
+
| `propose_changes` | Proposes changes to a map you already have. Nothing is written to it — TabTree asks you first. |
|
|
153
|
+
|
|
154
|
+
`create_mindmap` takes an indented markdown outline. Headings (`#`, `##`) and
|
|
155
|
+
bullet lists both work, two spaces per level, `[x]` for a checked item and
|
|
156
|
+
`[title](url)` for a link. The first top-level line becomes the centre of the
|
|
157
|
+
map.
|
|
158
|
+
|
|
159
|
+
`create_board` takes a list of elements (`rect`, `ellipse`, `text`, `sticky`)
|
|
160
|
+
and connections between them. Leave out the x/y coordinates and it lays the
|
|
161
|
+
board out automatically — the connections define the hierarchy, left to right.
|
|
162
|
+
Both accept an optional `folder`, which becomes a folder in your library.
|
|
163
|
+
|
|
164
|
+
`propose_changes` is the one tool that points at a map you already have. It can
|
|
165
|
+
add a branch, rename a node, write a note, tick a task, or remove a branch — and
|
|
166
|
+
it does none of those things by itself. It writes a small file into a
|
|
167
|
+
`Propositions` subfolder of your backup folder; TabTree checks that folder while
|
|
168
|
+
it is open and raises a banner on the map concerned. You review the changes one
|
|
169
|
+
by one, tick the ones you want, and press **Apply**. ⌘Z undoes the lot.
|
|
170
|
+
|
|
171
|
+
Claude names each node by its exact text, so ask it to read the map first. When
|
|
172
|
+
the same wording appears twice, it must write a path — `"Q4 > Hire"` — and if it
|
|
173
|
+
cannot tell them apart, the proposal is refused rather than applied to the wrong
|
|
174
|
+
one. A proposal you ignore is moved to `Propositions/Reviewed`, never deleted.
|
|
175
|
+
|
|
176
|
+
If you close TabTree, or the backup folder is not connected, nothing is lost:
|
|
177
|
+
the proposal waits in the folder and the banner appears the next time you open
|
|
178
|
+
the app with that folder connected.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Getting the maps into TabTree
|
|
183
|
+
|
|
184
|
+
New files land in your backup folder but the app does not watch that folder, so
|
|
185
|
+
nothing appears on its own. Open *My maps*, click the **🛟** button in the
|
|
186
|
+
toolbar, and choose **"Bring maps back from this folder…"**. Anything already in
|
|
187
|
+
your library is skipped, so you can press it as often as you like. The same
|
|
188
|
+
command is in ⌘K under "Bring my maps back from a folder…".
|
|
189
|
+
|
|
190
|
+
There is no ♻️ button in the toolbar: it only appears when your library is
|
|
191
|
+
empty. With maps already in it, the 🛟 menu is the way in.
|
|
192
|
+
|
|
193
|
+
**The folder must match.** The connector writes to the folder you configured;
|
|
194
|
+
TabTree reads the folder named next to its 🛟 button. If those are two different
|
|
195
|
+
folders, maps are created correctly and never appear. Every creation message
|
|
196
|
+
tells you which folder was written — compare it with what the app shows.
|
|
197
|
+
|
|
198
|
+
Tell Claude to use a `folder` — `"Depuis Claude"`, `"Claude"`, whatever you
|
|
199
|
+
prefer — and everything it makes stays grouped in one place in your library.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## When it does not work
|
|
204
|
+
|
|
205
|
+
**Claude does not offer the tools.** Restart the client — Claude Desktop needs a
|
|
206
|
+
full quit, not a window reload. Then check the config file is valid JSON; a
|
|
207
|
+
trailing comma is enough to make the whole file be ignored silently.
|
|
208
|
+
|
|
209
|
+
**"TABTREE_DIR is not set."** The `env` block is missing or the path is wrong.
|
|
210
|
+
Run `node mcp/install.mjs --check` to see the path the connector resolves.
|
|
211
|
+
|
|
212
|
+
**"The TABTREE_DIR folder does not exist."** The path has a typo, or the folder
|
|
213
|
+
is in iCloud Drive / OneDrive and has been evicted to the cloud. Open it in
|
|
214
|
+
Finder or Explorer once to bring it back.
|
|
215
|
+
|
|
216
|
+
**New maps do not show up in the app.** They are files on disk until you press
|
|
217
|
+
brought them in from the 🛟 menu. Check the folder in Finder first — if the file
|
|
218
|
+
is there, the connector did its job, and the question is only which folder the
|
|
219
|
+
app is reading.
|
|
220
|
+
|
|
221
|
+
**A map comes back empty.** The connector reads `.tabtree` and `.ygmind` files
|
|
222
|
+
one folder deep. A map filed two levels down is out of reach; move it up.
|
|
223
|
+
|
|
224
|
+
**Claude filed a proposal and no banner appeared.** Three things must all be
|
|
225
|
+
true: TabTree is open, its 🛟 folder is the folder the connector writes to, and
|
|
226
|
+
the map the proposal targets is one this browser actually has. That last one
|
|
227
|
+
catches the common case — a proposal for a map that only exists on your other
|
|
228
|
+
machine stays silent here, on purpose. Type `__ygProps()` in the browser console
|
|
229
|
+
to see what the app found.
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## 🇫🇷 En français
|
|
234
|
+
|
|
235
|
+
Le connecteur permet à Claude de lire votre bibliothèque TabTree et de créer des
|
|
236
|
+
cartes et des boards. Il ne crée que de **nouveaux fichiers** : il ne peut rien
|
|
237
|
+
écraser ni supprimer, et ne touche jamais au stockage du navigateur. Pour
|
|
238
|
+
modifier une carte existante, il dépose une **proposition** que TabTree vous
|
|
239
|
+
présente ligne par ligne : rien n'est appliqué sans votre clic, et ⌘Z l'annule.
|
|
240
|
+
|
|
241
|
+
Prérequis : un dossier de sauvegarde configuré dans TabTree (🛟 dans « Mes
|
|
242
|
+
cartes »).
|
|
243
|
+
|
|
244
|
+
**Claude Desktop** — double-cliquez `TabTree-Connector.mcpb`. Claude demande le
|
|
245
|
+
dossier à utiliser, et c'est terminé : ni terminal, ni fichier de config, ni
|
|
246
|
+
Node à installer.
|
|
247
|
+
|
|
248
|
+
**Claude Code** — `.mcpb` ne s'y applique pas, il lit un fichier de config :
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
node mcp/install.mjs --dir "/chemin/vers/votre/dossier"
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Cette voie-là demande Node.js 18 ou plus. Ensuite, demandez par exemple
|
|
255
|
+
« transforme ces notes de réunion en carte mentale » : la carte est écrite dans
|
|
256
|
+
le dossier ; récupérez-la dans l'app par *Mes cartes* → **🛟** → **« Bring maps
|
|
257
|
+
back from this folder… »**. Le dossier visé par le connecteur doit être celui
|
|
258
|
+
que TabTree affiche à côté de son bouton 🛟 : s'ils diffèrent, les cartes sont
|
|
259
|
+
créées et n'apparaissent jamais.
|
|
260
|
+
|
|
261
|
+
Support : info@ai4x.academy
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tabtree-mcp",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"mcpName": "app.tabtree/tabtree",
|
|
5
|
+
"description": "Let Claude read your TabTree library and turn conversations into mind maps and whiteboards. Local files, no account, nothing uploaded.",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/ai4xedu/tabtree-mcp.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"bin": {
|
|
12
|
+
"tabtree-mcp": "tabtree-mcp.mjs"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"tabtree-mcp.mjs",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mcp",
|
|
24
|
+
"model-context-protocol",
|
|
25
|
+
"modelcontextprotocol",
|
|
26
|
+
"mind-map",
|
|
27
|
+
"mindmap",
|
|
28
|
+
"whiteboard",
|
|
29
|
+
"claude",
|
|
30
|
+
"claude-desktop",
|
|
31
|
+
"brainstorming",
|
|
32
|
+
"diagram",
|
|
33
|
+
"outline",
|
|
34
|
+
"kanban",
|
|
35
|
+
"local-first",
|
|
36
|
+
"offline"
|
|
37
|
+
],
|
|
38
|
+
"homepage": "https://tabtree.app/",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"email": "info@ai4x.academy"
|
|
41
|
+
},
|
|
42
|
+
"author": "Ai4x <info@ai4x.academy> (https://tabtree.app/)",
|
|
43
|
+
"license": "SEE LICENSE IN LICENSE.md"
|
|
44
|
+
}
|
package/tabtree-mcp.mjs
ADDED
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ============================================================================
|
|
3
|
+
// TabTree MCP — serveur local, zéro dépendance (Model Context Protocol, stdio)
|
|
4
|
+
//
|
|
5
|
+
// Rôle : permettre à Claude (Claude Code, Claude Desktop…) de LIRE la
|
|
6
|
+
// bibliothèque TabTree et de CRÉER des cartes mentales et des boards,
|
|
7
|
+
// sans passer par l'interface — génération depuis une conversation,
|
|
8
|
+
// un document, un plan…
|
|
9
|
+
//
|
|
10
|
+
// Où : le serveur travaille sur le DOSSIER DE SAUVEGARDE AUTO de TabTree
|
|
11
|
+
// (celui choisi via 🛟 dans « Mes cartes ») : 1 fichier .ygmind par carte
|
|
12
|
+
// + bibliotheque.json (manifeste, propriété de l'app — jamais modifié ici).
|
|
13
|
+
// Le MCP ne crée que de NOUVEAUX fichiers ; l'app les avale par le menu 🛟
|
|
14
|
+
// (« Bring maps back from this folder… »), avec dédoublonnage et sans écrasement.
|
|
15
|
+
//
|
|
16
|
+
// Config : TABTREE_DIR = chemin du dossier. YGMIND_DIR (ancien nom) reste accepté,
|
|
17
|
+
// pour qu'un .mcp.json écrit avant le renommage continue de marcher sans retouche.
|
|
18
|
+
// Protocole : JSON-RPC 2.0, un message JSON par ligne sur stdin/stdout.
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync } from "node:fs";
|
|
22
|
+
import { join, basename } from "node:path";
|
|
23
|
+
|
|
24
|
+
const DIR = process.env.TABTREE_DIR || process.env.YGMIND_DIR || "";
|
|
25
|
+
const MANIFEST = "bibliotheque.json";
|
|
26
|
+
// Boîte aux lettres des propositions. Le connecteur reste EN AJOUT SEUL : il ne modifie
|
|
27
|
+
// jamais un .tabtree, il dépose ici un fichier que l'app lit, montre, et n'applique que si
|
|
28
|
+
// l'utilisateur clique. C'est la seule raison pour laquelle un outil qui « modifie une
|
|
29
|
+
// carte » peut exister sans mettre en danger le travail de qui que ce soit.
|
|
30
|
+
const PROP_DIR = "Propositions";
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Utilitaires
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
function dirOk(){
|
|
36
|
+
if(!DIR) throw new Error("TABTREE_DIR is not set. Point it at your TabTree backup folder — the one you picked with 🛟 on the My maps screen. (The older name YGMIND_DIR still works.)");
|
|
37
|
+
if(!existsSync(DIR)) throw new Error("The TABTREE_DIR folder does not exist: " + DIR);
|
|
38
|
+
}
|
|
39
|
+
// Même règle de nommage que l'app (bkSafeName) : la restauration retire le
|
|
40
|
+
// suffixe __map… pour retrouver le nom de la carte.
|
|
41
|
+
function safeName(s){
|
|
42
|
+
return String(s||"").replace(/[\/\\:*?"<>|\n\r\t]+/g, "_").replace(/\s+/g, " ").trim().slice(0,60) || "map";
|
|
43
|
+
}
|
|
44
|
+
function newFileName(name){ return safeName(name) + "__map_mcp" + Date.now().toString(36) + ".tabtree"; }
|
|
45
|
+
function checkRelPath(f){
|
|
46
|
+
if(typeof f !== "string" || !f || f.includes("..") || f.startsWith("/")) throw new Error("Invalid file path: " + f);
|
|
47
|
+
return f;
|
|
48
|
+
}
|
|
49
|
+
function makeNode(text){
|
|
50
|
+
return { text: String(text||""), color: "#ffffff", link: "", note: "", check: null, priority: null, collapsed: false, children: [] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Markdown → arbre (portage de parseOutline/buildForest de l'app)
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
function parseOutline(txt){
|
|
57
|
+
const items = [];
|
|
58
|
+
let headingBase = -1;
|
|
59
|
+
String(txt).replace(/\r/g,"").split("\n").forEach(raw=>{
|
|
60
|
+
if(!raw.trim()) return;
|
|
61
|
+
const hm = raw.match(/^\s*(#{1,6})\s+(.*)$/);
|
|
62
|
+
let level, text;
|
|
63
|
+
if(hm){ level = hm[1].length - 1; headingBase = level; text = hm[2]; }
|
|
64
|
+
else {
|
|
65
|
+
const m = raw.match(/^(\s*)(?:[-*+]\s+|\d+[.)]\s+)?(.*)$/);
|
|
66
|
+
const indent = m[1].replace(/\t/g," ");
|
|
67
|
+
level = (headingBase>=0 ? headingBase+1 : 0) + Math.floor(indent.length/2);
|
|
68
|
+
text = m[2];
|
|
69
|
+
}
|
|
70
|
+
let check = null;
|
|
71
|
+
const cm = text.match(/^\[([ xX])\]\s+(.*)$/);
|
|
72
|
+
if(cm){ check = /x/i.test(cm[1]); text = cm[2]; }
|
|
73
|
+
let link = "";
|
|
74
|
+
const lm = text.trim().match(/^\[(.+?)\]\((\S+?)\)$/);
|
|
75
|
+
if(lm){ text = lm[1]; link = lm[2]; }
|
|
76
|
+
items.push({ level, text: text.trim(), check, link });
|
|
77
|
+
});
|
|
78
|
+
return items;
|
|
79
|
+
}
|
|
80
|
+
function buildForest(items){
|
|
81
|
+
if(!items.length) return [];
|
|
82
|
+
const minLevel = Math.min(...items.map(i=>i.level));
|
|
83
|
+
const roots = [], stack = [];
|
|
84
|
+
for(const it of items){
|
|
85
|
+
const lvl = it.level - minLevel;
|
|
86
|
+
const node = makeNode(it.text);
|
|
87
|
+
if(it.check!=null) node.check = it.check;
|
|
88
|
+
if(it.link) node.link = it.link;
|
|
89
|
+
let parent = null;
|
|
90
|
+
for(let k=lvl-1;k>=0;k--){ if(stack[k]){ parent = stack[k]; break; } }
|
|
91
|
+
if(parent) parent.children.push(node); else roots.push(node);
|
|
92
|
+
stack[lvl] = node; stack.length = lvl+1;
|
|
93
|
+
}
|
|
94
|
+
return roots;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Une ILLUSTRATION sur un nœud (`node.pic`, 2026-08-19)
|
|
99
|
+
//
|
|
100
|
+
// Claude ne dessine pas — l'API rend du texte, et 🖼 Generate image de l'app parle à Google
|
|
101
|
+
// avec une seconde clé. Mais `node.pic` ne porte pas de pixels : il porte une DÉSIGNATION,
|
|
102
|
+
// `a:<id>` parmi les illustrations livrées avec l'app, ou `e:<emoji>`. Une vingtaine
|
|
103
|
+
// d'octets. C'est donc exactement ce qu'un producteur de TEXTE sait produire, et c'est ce qui
|
|
104
|
+
// permet au connecteur de livrer une carte ILLUSTRÉE au lieu d'un arbre nu.
|
|
105
|
+
//
|
|
106
|
+
// ⚠️ Portage MOT POUR MOT d'`okPic` et de `tplPicAssign`, et le test les compare caractère
|
|
107
|
+
// par caractère — comme `propFind`, et pour la même raison en pire : `okPic` est une
|
|
108
|
+
// FRONTIÈRE DE CONFIANCE. Si les deux divergent, le connecteur écrit une désignation que
|
|
109
|
+
// l'app refuse à l'ouverture, et l'illustration disparaît sans une erreur. Les ids, eux,
|
|
110
|
+
// sont comparés en ENSEMBLE à `ART_GROUPS` de l'app — même dispositif que les formes et
|
|
111
|
+
// que les listes d'habillage.
|
|
112
|
+
const PIC_MAX = 40; // un emoji ou un id, jamais une charge utile
|
|
113
|
+
const ART_IDS_MCP = [
|
|
114
|
+
"person", "team", "chat", "idea", "target", "trophy",
|
|
115
|
+
"star", "heart", "warning", "done", "flag", "clock",
|
|
116
|
+
"laptop", "phone", "mail", "folder", "book", "chart",
|
|
117
|
+
"money", "calendar", "building", "car", "globe", "rocket",
|
|
118
|
+
"house", "pin", "plane", "coffee", "plant", "sun",
|
|
119
|
+
"camera", "music", "lock", "key", "health", "gift"
|
|
120
|
+
];
|
|
121
|
+
const ART = Object.fromEntries(ART_IDS_MCP.map(id=>[id, true]));
|
|
122
|
+
const EMO_PIC_PAT = "\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?)*";
|
|
123
|
+
const PIC_EMO_RE = (()=>{
|
|
124
|
+
try{ return new RegExp("^(?:" + EMO_PIC_PAT + ")$", "u"); }
|
|
125
|
+
// Repli du côté SÛR : un navigateur sans échappement de propriété refuse l'emoji plutôt
|
|
126
|
+
// que d'accepter n'importe quoi. On perd un ornement, jamais la carte.
|
|
127
|
+
catch(e){ return /^(?:[‼-㊙\uD800-\uDBFF][\uDC00-\uDFFF]?[️︎]?[]?)+$/; }
|
|
128
|
+
})();
|
|
129
|
+
function okPic(v){
|
|
130
|
+
if(typeof v !== "string" || !v || v.length > PIC_MAX) return "";
|
|
131
|
+
if(v.startsWith("a:")) return ART[v.slice(2)] ? v : "";
|
|
132
|
+
if(v.startsWith("e:")){
|
|
133
|
+
const ch = v.slice(2);
|
|
134
|
+
return (ch && PIC_EMO_RE.test(ch)) ? "e:" + ch : "";
|
|
135
|
+
}
|
|
136
|
+
return "";
|
|
137
|
+
}
|
|
138
|
+
function tplPicAssign(n){
|
|
139
|
+
(function walk(x){
|
|
140
|
+
const m = /^(.*\S)\s+@([ae]:\S+)$/.exec(x.text || "");
|
|
141
|
+
const v = m ? okPic(m[2]) : "";
|
|
142
|
+
if(v){ x.text = m[1]; x.pic = v; }
|
|
143
|
+
x.children.forEach(walk);
|
|
144
|
+
})(n);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Désignation d'un nœud (partagé mot pour mot avec l'app — voir tabtree-mcp.test.mjs)
|
|
149
|
+
//
|
|
150
|
+
// Une proposition doit nommer le nœud qu'elle vise, et ce nom est écrit par Claude puis
|
|
151
|
+
// relu par l'app : les deux DOIVENT désigner le même nœud, sinon le panneau montre une
|
|
152
|
+
// modification et une autre est appliquée. D'où deux fonctions dupliquées et gelées par
|
|
153
|
+
// un test caractère par caractère, comme safeName.
|
|
154
|
+
//
|
|
155
|
+
// La syntaxe est celle qu'un humain écrirait : « Parent > Enfant ». Seule la DERNIÈRE
|
|
156
|
+
// partie doit être le nœud lui-même ; les précédentes sont des ancêtres, dans l'ordre mais
|
|
157
|
+
// pas forcément contigus — on peut donc désigner « Q3 > Recruter » sans réciter tout le
|
|
158
|
+
// chemin. Une désignation qui touche deux nœuds est refusée, jamais devinée : appliquer la
|
|
159
|
+
// modification au mauvais jumeau serait une perte de données silencieuse.
|
|
160
|
+
function propPathMatch(path, parts){
|
|
161
|
+
let i = path.length - 1, j = parts.length - 1;
|
|
162
|
+
if(path[i] !== parts[j]) return false;
|
|
163
|
+
j--;
|
|
164
|
+
while(j >= 0){
|
|
165
|
+
i--;
|
|
166
|
+
while(i >= 0 && path[i] !== parts[j]) i--;
|
|
167
|
+
if(i < 0) return false;
|
|
168
|
+
j--;
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
function propFind(root, spec){
|
|
173
|
+
const parts = String(spec == null ? "" : spec).split(">").map(s=>s.trim()).filter(Boolean);
|
|
174
|
+
if(!parts.length) return { error:"empty", count:0 };
|
|
175
|
+
const hits = [];
|
|
176
|
+
(function walk(n, path){
|
|
177
|
+
const p = path.concat([String(n.text == null ? "" : n.text).trim()]);
|
|
178
|
+
if(propPathMatch(p, parts)) hits.push(n);
|
|
179
|
+
(n.children||[]).forEach(c=>walk(c, p));
|
|
180
|
+
})(root, []);
|
|
181
|
+
if(hits.length === 1) return { node: hits[0], count:1 };
|
|
182
|
+
return { error: hits.length ? "ambiguous" : "missing", count: hits.length };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// Arbre → markdown (lecture d'une carte)
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
function nodeToMarkdown(n, depth){
|
|
189
|
+
const lines = [];
|
|
190
|
+
(function walk(node, d){
|
|
191
|
+
let bullet;
|
|
192
|
+
if(d===0) bullet = "# ";
|
|
193
|
+
else bullet = " ".repeat(d-1) + (node.check!=null ? (node.check ? "- [x] " : "- [ ] ") : "- ");
|
|
194
|
+
const body = node.link ? `[${node.text}](${node.link})` : node.text;
|
|
195
|
+
lines.push(bullet + body + (node.note && node.note.trim() ? " ⟪note⟫" : ""));
|
|
196
|
+
(node.children||[]).forEach(c=>walk(c, d+1));
|
|
197
|
+
})(n, depth||0);
|
|
198
|
+
return lines.join("\n");
|
|
199
|
+
}
|
|
200
|
+
function countNodes(n){ let c = 1; (n.children||[]).forEach(k=>c += countNodes(k)); return c; }
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Board : construction avec layout automatique par niveaux
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// Doit rester ALIGNÉ sur la liste blanche de `deserialize()` dans index.html
|
|
206
|
+
// (`rect` / `ellipse` / `diamond` / `trap` / `chev` / `arc` / `text`, plus `sticky` qui
|
|
207
|
+
// est un post-it et non une forme). Le losange a été ajouté le 2026-08-14 en même temps
|
|
208
|
+
// que dans l'app : sans ça, le Copilote intégré savait dessiner un nœud de décision et le
|
|
209
|
+
// connecteur non — exactement la dérive app ↔ connecteur que `safeName` a déjà payée.
|
|
210
|
+
// `trap` porte `tk` (rapport du petit côté, signe = quel côté est en haut) et `arc` porte
|
|
211
|
+
// a0/a1/ir — le connecteur les laisse passer tels quels, c'est `deserialize()` qui borne.
|
|
212
|
+
const KINDS = ["rect","ellipse","diamond","trap","chev","arc","text","sticky"];
|
|
213
|
+
// ---- Le texte tient-il dans sa boîte ? ---------------------------------------------
|
|
214
|
+
// L'app MESURE le texte (canvas measureText) et réduit le corps jusqu'à ce qu'il tienne :
|
|
215
|
+
// depuis le 2026-08-16, rien ne peut donc plus sortir d'une forme. Mais un texte ramené à
|
|
216
|
+
// 9 px « tient » et reste illisible — et une toile BMC dont les neuf blocs sont en 9 px est
|
|
217
|
+
// exactement le résultat qui a motivé tout ceci. On estime donc ICI, on pose le corps qui
|
|
218
|
+
// convient, et surtout on le DIT dans la réponse : c'est le seul moment où Claude peut encore
|
|
219
|
+
// raccourcir son texte.
|
|
220
|
+
//
|
|
221
|
+
// 7,2 px par caractère à 15 px : moyenne MESURÉE dans le navigateur sur six phrases réelles
|
|
222
|
+
// de toile (6,77 à 7,34 selon la phrase). C'est une ESTIMATION assumée, pas un miroir de
|
|
223
|
+
// wrap() — l'app reste seule juge à l'écran, et c'est pour ça que cette fonction n'a pas à
|
|
224
|
+
// être figée caractère par caractère comme parseOutline ou propFind.
|
|
225
|
+
const CHAR_W = 7.2, LINE_H = 20, FIT_FLOOR = 11;
|
|
226
|
+
function fitSize(text, w, h, kind, want){
|
|
227
|
+
if(kind === "text") return { size: want, shrunk: false, readable: true }; // grandit avec son contenu
|
|
228
|
+
const sticky = kind === "sticky";
|
|
229
|
+
const pad = sticky ? 10 : kind === "diamond" ? Math.max(14, Math.round(w*0.22)) : 12;
|
|
230
|
+
const avail = Math.max(30, w - pad*2);
|
|
231
|
+
const linesAt = (size)=>{ // wrap glouton, comme celui de l'app
|
|
232
|
+
const cw = CHAR_W * size/15;
|
|
233
|
+
let n = 0;
|
|
234
|
+
for(const para of String(text).split("\n")){
|
|
235
|
+
const words = para.split(/\s+/).filter(Boolean);
|
|
236
|
+
if(!words.length){ n++; continue; }
|
|
237
|
+
let cur = 0;
|
|
238
|
+
for(const word of words){
|
|
239
|
+
const wl = word.length*cw, sp = cur ? cw : 0;
|
|
240
|
+
if(cur && cur + sp + wl > avail){ n++; cur = wl; } else cur += sp + wl;
|
|
241
|
+
}
|
|
242
|
+
if(cur) n++;
|
|
243
|
+
}
|
|
244
|
+
return n;
|
|
245
|
+
};
|
|
246
|
+
const fits = (size)=>{
|
|
247
|
+
const lh = Math.round(size*LINE_H/15);
|
|
248
|
+
return sticky ? linesAt(size) <= Math.floor((h - 14)/lh) // un post-it COUPE au-delà
|
|
249
|
+
: linesAt(size)*lh + 10 <= h; // une forme centre et débordait
|
|
250
|
+
};
|
|
251
|
+
for(let size = want; size >= 9; size--)
|
|
252
|
+
if(fits(size)) return { size, shrunk: size < want, readable: size >= FIT_FLOOR };
|
|
253
|
+
return { size: 9, shrunk: true, readable: false };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function buildBoardDoc(name, elements, connections, style){
|
|
257
|
+
if(!Array.isArray(elements) || !elements.length) throw new Error("`elements` must be a non-empty list.");
|
|
258
|
+
const els = elements.map((e,i)=>{
|
|
259
|
+
const kind = KINDS.includes(e.kind) ? e.kind : "rect";
|
|
260
|
+
return { lid: String(e.id != null ? e.id : i), kind, text: String(e.text||""),
|
|
261
|
+
x: Number.isFinite(+e.x) && e.x!=null ? Math.round(+e.x) : null,
|
|
262
|
+
y: Number.isFinite(+e.y) && e.y!=null ? Math.round(+e.y) : null,
|
|
263
|
+
w: (+e.w>=20) ? Math.round(+e.w) : null, h: (+e.h>=20) ? Math.round(+e.h) : null,
|
|
264
|
+
color: (typeof e.color==="string" && /^#[0-9a-fA-F]{3,8}$/.test(e.color)) ? e.color : null,
|
|
265
|
+
// Géométrie des formes « diagramme » : transmise telle quelle, deserialize() borne.
|
|
266
|
+
tk: (e.kind==="trap" && Number.isFinite(+e.tk)) ? +e.tk : null,
|
|
267
|
+
a0: (e.kind==="arc" && Number.isFinite(+e.a0)) ? +e.a0 : null,
|
|
268
|
+
a1: (e.kind==="arc" && Number.isFinite(+e.a1)) ? +e.a1 : null,
|
|
269
|
+
ir: (e.kind==="arc" && Number.isFinite(+e.ir)) ? +e.ir : null,
|
|
270
|
+
size: (+e.size>=9 && +e.size<=72) ? Math.round(+e.size) : null };
|
|
271
|
+
});
|
|
272
|
+
const byId = new Map();
|
|
273
|
+
for(const e of els){
|
|
274
|
+
if(byId.has(e.lid)) throw new Error("Duplicate element id: " + e.lid);
|
|
275
|
+
byId.set(e.lid, e);
|
|
276
|
+
}
|
|
277
|
+
const conns = (Array.isArray(connections) ? connections : []).map(c=>{
|
|
278
|
+
const from = String(c.from), to = String(c.to);
|
|
279
|
+
if(!byId.has(from) || !byId.has(to))
|
|
280
|
+
throw new Error(`connexion ${from}→${to} : id inconnu (ids valides : ${[...byId.keys()].join(", ")})`);
|
|
281
|
+
return { from, to };
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Niveaux : racines = jamais cibles ; enfants = BFS. Sert au placement auto.
|
|
285
|
+
const targets = new Set(conns.map(c=>c.to));
|
|
286
|
+
const kids = new Map();
|
|
287
|
+
conns.forEach(c=>{ if(!kids.has(c.from)) kids.set(c.from, []); kids.get(c.from).push(c.to); });
|
|
288
|
+
const level = new Map();
|
|
289
|
+
const queue = els.filter(e=>!targets.has(e.lid)).map(e=>e.lid);
|
|
290
|
+
queue.forEach(id=>level.set(id, 0));
|
|
291
|
+
while(queue.length){
|
|
292
|
+
const id = queue.shift();
|
|
293
|
+
for(const k of (kids.get(id)||[])){
|
|
294
|
+
if(!level.has(k)){ level.set(k, level.get(id)+1); queue.push(k); }
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
els.forEach(e=>{ if(!level.has(e.lid)) level.set(e.lid, 0); }); // cycles / isolés
|
|
298
|
+
|
|
299
|
+
// Placement : une colonne par niveau, empilement vertical.
|
|
300
|
+
const perLevel = new Map();
|
|
301
|
+
for(const e of els){
|
|
302
|
+
const defW = e.kind==="sticky" ? 190 : e.kind==="text" ? 220 : 180;
|
|
303
|
+
const defH = e.kind==="sticky" ? 130 : e.kind==="text" ? 28 : 90;
|
|
304
|
+
e.w = e.w || defW; e.h = e.h || defH;
|
|
305
|
+
if(e.x==null || e.y==null){
|
|
306
|
+
const lv = level.get(e.lid);
|
|
307
|
+
const idx = perLevel.get(lv) || 0; perLevel.set(lv, idx+1);
|
|
308
|
+
e.x = 60 + lv*320;
|
|
309
|
+
e.y = 60 + idx*170;
|
|
310
|
+
}
|
|
311
|
+
if(!e.color) e.color = e.kind==="sticky" ? "#fff59d" : (level.get(e.lid)===0 && conns.length ? "#dbeafe" : "#ffffff");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Ajustement du texte à sa boîte : on pose le corps qui tient, et on signale ce qui a dû
|
|
315
|
+
// être réduit. Sans ce retour, un texte trois fois trop long part quand même et ne se
|
|
316
|
+
// découvre qu'à l'écran, une fois la carte importée.
|
|
317
|
+
// On signale TOUT rétrécissement, pas seulement l'illisible : sur une toile, un bloc à
|
|
318
|
+
// 15 px à côté d'un bloc à 11 px se voit immédiatement, alors que les deux « tiennent ».
|
|
319
|
+
// C'est l'uniformité qui fait qu'une toile remplie a l'air composée.
|
|
320
|
+
const warnings = [];
|
|
321
|
+
for(const e of els){
|
|
322
|
+
const f = fitSize(e.text, e.w, e.h, e.kind, e.size || 15);
|
|
323
|
+
if(!f.shrunk) continue;
|
|
324
|
+
e.size = f.size;
|
|
325
|
+
const budget = Math.round(Math.max(40, Math.floor((e.w - 24)/CHAR_W) * Math.max(1, Math.floor((e.h - 10)/LINE_H)) * 0.7));
|
|
326
|
+
warnings.push(`“${e.text.slice(0, 38).replace(/\s+/g, " ")}…” — ${e.text.length} chars in a ${e.w}×${e.h} box, `
|
|
327
|
+
+ `shrunk to ${f.size}px${f.readable ? "" : " (too small to read)"}. Aim for ~${budget} chars.`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Sortie au format de l'app : shapes / stickies + refs "sh:i" / "st:i"
|
|
331
|
+
const shapes = [], stickies = [], refOf = new Map();
|
|
332
|
+
for(const e of els){
|
|
333
|
+
if(e.kind==="sticky"){
|
|
334
|
+
refOf.set(e.lid, "st:" + stickies.length);
|
|
335
|
+
stickies.push({ text:e.text, x:e.x, y:e.y, w:e.w, h:e.h, color:e.color, size:e.size||undefined });
|
|
336
|
+
} else {
|
|
337
|
+
refOf.set(e.lid, "sh:" + shapes.length);
|
|
338
|
+
shapes.push({ kind:e.kind, x:e.x, y:e.y, w:e.w, h:e.h, color:e.kind==="text" ? "#ffffff" : e.color, text:e.text, size:e.size||undefined,
|
|
339
|
+
tk: e.tk != null ? e.tk : undefined,
|
|
340
|
+
a0: e.a0 != null ? e.a0 : undefined,
|
|
341
|
+
a1: e.a1 != null ? e.a1 : undefined,
|
|
342
|
+
ir: e.ir != null ? e.ir : undefined });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const arrows = conns.map(c=>{
|
|
346
|
+
const a = byId.get(c.from), b = byId.get(c.to);
|
|
347
|
+
return { x1: a.x + Math.round(a.w/2), y1: a.y + Math.round(a.h/2),
|
|
348
|
+
x2: b.x + Math.round(b.w/2), y2: b.y + Math.round(b.h/2),
|
|
349
|
+
from: refOf.get(c.from), to: refOf.get(c.to) };
|
|
350
|
+
});
|
|
351
|
+
const root = makeNode(name); root.color = "#dbeafe";
|
|
352
|
+
const doc = { v:1, root, images:[], stickies, board:true, shapes, arrows, draws:[] };
|
|
353
|
+
// `style` n'est émis que s'il est posé : l'app elle-même l'omet quand il vaut le défaut
|
|
354
|
+
// (styleParDefaut), et le JSON d'un board ordinaire ne doit pas gagner un octet.
|
|
355
|
+
const st = okStyleMcp(style);
|
|
356
|
+
if(st) doc.style = st;
|
|
357
|
+
return { doc, warnings };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Habillage du document : familles de caractères, forme des nœuds, fond de page. Liste
|
|
361
|
+
// blanche recopiée de l'app (FONTS / NODE_SHAPES / BACKGROUNDS) — même nécessité que KINDS :
|
|
362
|
+
// une valeur inconnue serait silencieusement ramenée au défaut par okStyle() à l'ouverture.
|
|
363
|
+
const FONTS_MCP = ["", "grotesk", "humanist", "condensed", "rounded", "serif",
|
|
364
|
+
"garamond", "didone", "slab", "typewriter", "mono", "hand"];
|
|
365
|
+
const SHAPES_MCP = ["", "line", "plain"];
|
|
366
|
+
const BACKGROUNDS_MCP = ["", "plain", "grid", "graph", "lines", "iso", "paper",
|
|
367
|
+
"cream", "kraft", "aged", "mist", "sage", "dawn", "blue", "slate", "chalk"];
|
|
368
|
+
// `palette` ne parle qu'aux couleurs de BRANCHE : accepté sur une carte, sans effet sur un
|
|
369
|
+
// board (les formes n'ont pas de branche) — même règle que la rangée du menu ✒️ Style.
|
|
370
|
+
const PALETTES_MCP = ["", "ocean", "sunset", "forest", "berry", "nordic", "earth",
|
|
371
|
+
"vintage", "candy", "muted"];
|
|
372
|
+
// `skin` est l'axe STRUCTUREL (bordures, ombres, coins — 2026-08-17) : recopié de SKINS
|
|
373
|
+
// dans l'app, et comparé en ensemble par le test anti-dérive comme les trois autres listes.
|
|
374
|
+
const SKINS_MCP = ["", "soft", "flat", "paper", "ink", "draft", "sticker", "brutal", "arcade"];
|
|
375
|
+
// `ico` est l'axe des PICTOGRAMMES (2026-08-17) : « Plain » les dessine au trait comme depuis
|
|
376
|
+
// toujours, « Chip » et « Large » les posent sur une pastille teintée de la couleur de branche.
|
|
377
|
+
// Sans effet sur un board (aucune forme ne porte de pictogramme), accepté quand même — même
|
|
378
|
+
// règle que `palette`. Recopié d'ICO_STYLES et comparé en ensemble par le test anti-dérive.
|
|
379
|
+
const ICOS_MCP = ["", "chip", "big"];
|
|
380
|
+
function okStyleMcp(s){
|
|
381
|
+
if(!s || typeof s !== "object") return null;
|
|
382
|
+
const out = {};
|
|
383
|
+
if(FONTS_MCP.includes(s.font)) out.font = s.font;
|
|
384
|
+
if(SHAPES_MCP.includes(s.shape)) out.shape = s.shape;
|
|
385
|
+
if(BACKGROUNDS_MCP.includes(s.background)) out.bg = s.background;
|
|
386
|
+
if(PALETTES_MCP.includes(s.palette)) out.palette = s.palette;
|
|
387
|
+
if(SKINS_MCP.includes(s.skin)) out.skin = s.skin;
|
|
388
|
+
if(ICOS_MCP.includes(s.icons)) out.ico = s.icons;
|
|
389
|
+
return (out.font || out.shape || out.bg || out.palette || out.skin || out.ico)
|
|
390
|
+
? { font:out.font||"", shape:out.shape||"", bg:out.bg||"", palette:out.palette||"",
|
|
391
|
+
skin:out.skin||"", ico:out.ico||"" } : null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ---------------------------------------------------------------------------
|
|
395
|
+
// Lecture de la bibliothèque
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
function readManifest(){
|
|
398
|
+
try{
|
|
399
|
+
const m = JSON.parse(readFileSync(join(DIR, MANIFEST), "utf8"));
|
|
400
|
+
// Les dossiers de la bibliothèque sont ARBORESCENTS (parentId) : on rend le chemin
|
|
401
|
+
// complet, sinon deux « Archive » rangés sous deux parents sont indiscernables ici.
|
|
402
|
+
// Un manifeste d'avant les sous-dossiers n'a pas de parentId → chemin à une part,
|
|
403
|
+
// c'est-à-dire l'ancien comportement.
|
|
404
|
+
const folders = new Map((m.folders||[]).filter(f=>f && f.id).map(f=>[f.id, f]));
|
|
405
|
+
const pathOf = (id)=>{
|
|
406
|
+
const out = [], seen = new Set();
|
|
407
|
+
let f = folders.get(id);
|
|
408
|
+
while(f && !seen.has(f.id) && out.length <= 16){ seen.add(f.id); out.unshift(f.name); f = f.parentId ? folders.get(f.parentId) : null; }
|
|
409
|
+
return out.length ? out.join(" / ") : null;
|
|
410
|
+
};
|
|
411
|
+
const byFile = new Map();
|
|
412
|
+
(m.maps||[]).forEach(x=>{ if(x && x.file) byFile.set(x.file, { name:x.name, folder: pathOf(x.folderId) }); });
|
|
413
|
+
return byFile;
|
|
414
|
+
}catch(e){ return new Map(); }
|
|
415
|
+
}
|
|
416
|
+
function scanLibrary(){
|
|
417
|
+
dirOk();
|
|
418
|
+
const man = readManifest();
|
|
419
|
+
const out = [];
|
|
420
|
+
const scanDir = (path, folder)=>{
|
|
421
|
+
for(const ent of readdirSync(path, { withFileTypes:true })){
|
|
422
|
+
if(ent.isDirectory()){
|
|
423
|
+
// Corbeille = cartes supprimées, Propositions = boîte aux lettres : ni l'une ni
|
|
424
|
+
// l'autre ne contient de carte vivante. Sans cette exclusion, une proposition
|
|
425
|
+
// déposée ici se relirait comme une carte de la bibliothèque au tour suivant.
|
|
426
|
+
if(folder===null && ent.name !== "Corbeille" && ent.name !== PROP_DIR) scanDir(join(path, ent.name), ent.name); // 1 niveau, comme bkCollect
|
|
427
|
+
// ⚠️ « comme l'app » est vrai de ♻️ Restaurer (bkCollect) et FAUX de la synchro
|
|
428
|
+
// automatique : syncReadDisk ne lit QUE la racine. Une carte écrite ici dans un
|
|
429
|
+
// sous-dossier est donc invisible au scan, syncDecide rend « push » et l'app en
|
|
430
|
+
// recrée une copie à la racine. Dette nommée, pas un oubli.
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
if(!/\.(tabtree|ygmind|json)$/i.test(ent.name) || ent.name === MANIFEST) continue;
|
|
434
|
+
const rel = folder ? folder + "/" + ent.name : ent.name;
|
|
435
|
+
const meta = man.get(ent.name) || {};
|
|
436
|
+
let doc = null;
|
|
437
|
+
try{ doc = JSON.parse(readFileSync(join(path, ent.name), "utf8")); }catch(e){}
|
|
438
|
+
if(!doc || !doc.root) continue;
|
|
439
|
+
out.push({
|
|
440
|
+
file: rel,
|
|
441
|
+
name: meta.name || ent.name.replace(/\.(tabtree|ygmind|json)$/i, "").replace(/__map[A-Za-z0-9_]+$/, ""),
|
|
442
|
+
folder: meta.folder || folder || null,
|
|
443
|
+
kind: doc.board ? "board" : "map",
|
|
444
|
+
nodes: countNodes(doc.root),
|
|
445
|
+
elements: doc.board ? ((doc.shapes||[]).length + (doc.stickies||[]).length + (doc.arrows||[]).length + (doc.draws||[]).length) : undefined,
|
|
446
|
+
mtime: statSync(join(path, ent.name)).mtime.toISOString().slice(0,16).replace("T"," "),
|
|
447
|
+
doc
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
scanDir(DIR, null);
|
|
452
|
+
out.sort((a,b)=>b.mtime.localeCompare(a.mtime));
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
function collectTexts(doc){
|
|
456
|
+
const texts = [];
|
|
457
|
+
(function walk(n){ if(n.text) texts.push(n.text); if(n.note) texts.push(n.note); (n.children||[]).forEach(walk); })(doc.root);
|
|
458
|
+
(doc.shapes||[]).forEach(s=>{ if(s.text) texts.push(s.text); });
|
|
459
|
+
(doc.stickies||[]).forEach(s=>{ if(s.text) texts.push(s.text); });
|
|
460
|
+
return texts;
|
|
461
|
+
}
|
|
462
|
+
function boardToText(doc){
|
|
463
|
+
const lines = [];
|
|
464
|
+
const label = (arr, i, tag)=>{
|
|
465
|
+
const o = arr[i];
|
|
466
|
+
return o ? `${tag}${i} ${JSON.stringify((o.text||"(no text)").slice(0,60))}` : tag+i+" ?";
|
|
467
|
+
};
|
|
468
|
+
lines.push(`Shapes (${(doc.shapes||[]).length}):`);
|
|
469
|
+
(doc.shapes||[]).forEach((s,i)=>lines.push(` sh:${i} [${s.kind}] ${JSON.stringify((s.text||"").slice(0,80))} @(${s.x},${s.y}) ${s.w}×${s.h}`));
|
|
470
|
+
if((doc.stickies||[]).length){
|
|
471
|
+
lines.push(`Sticky notes (${doc.stickies.length}):`);
|
|
472
|
+
doc.stickies.forEach((s,i)=>lines.push(` st:${i} ${JSON.stringify((s.text||"").slice(0,80))} @(${s.x},${s.y})`));
|
|
473
|
+
}
|
|
474
|
+
const named = ref=>{
|
|
475
|
+
if(typeof ref !== "string") return null;
|
|
476
|
+
const m = /^(sh|st):(\d+)$/.exec(ref); if(!m) return null;
|
|
477
|
+
return label(m[1]==="sh" ? (doc.shapes||[]) : (doc.stickies||[]), +m[2], m[1]+":");
|
|
478
|
+
};
|
|
479
|
+
if((doc.arrows||[]).length){
|
|
480
|
+
lines.push(`Arrows (${doc.arrows.length}):`);
|
|
481
|
+
doc.arrows.forEach(a=>{
|
|
482
|
+
const f = named(a.from), t = named(a.to);
|
|
483
|
+
lines.push(` ${f || `(${a.x1},${a.y1})`} → ${t || `(${a.x2},${a.y2})`}`);
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
if((doc.draws||[]).length) lines.push(`Pencil strokes: ${doc.draws.length}`);
|
|
487
|
+
return lines.join("\n");
|
|
488
|
+
}
|
|
489
|
+
function findMap(file){
|
|
490
|
+
checkRelPath(file);
|
|
491
|
+
const all = scanLibrary();
|
|
492
|
+
const hit = all.find(m=>m.file===file) || all.find(m=>m.file.endsWith(file)) || all.find(m=>m.name===file);
|
|
493
|
+
if(!hit) throw new Error("No such map: " + file + ". Use list_maps to see what is available.");
|
|
494
|
+
return hit;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
// Écriture (toujours un NOUVEAU fichier — jamais de modification en place)
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
function writeDoc(name, doc, folder){
|
|
501
|
+
dirOk();
|
|
502
|
+
let target = DIR;
|
|
503
|
+
if(folder){
|
|
504
|
+
const f = safeName(folder);
|
|
505
|
+
target = join(DIR, f);
|
|
506
|
+
if(!existsSync(target)) mkdirSync(target);
|
|
507
|
+
}
|
|
508
|
+
const file = newFileName(name);
|
|
509
|
+
writeFileSync(join(target, file), JSON.stringify(doc, null, 2), "utf8");
|
|
510
|
+
return (folder ? safeName(folder) + "/" : "") + file;
|
|
511
|
+
}
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
// Propositions de modification (dépôt seul — l'app décide, l'utilisateur clique)
|
|
514
|
+
// ---------------------------------------------------------------------------
|
|
515
|
+
// L'id de carte est le suffixe « __<id> » du nom de fichier : c'est lui qui apparie une
|
|
516
|
+
// proposition à la carte ouverte dans le navigateur. Même règle que syncIdOf côté app.
|
|
517
|
+
function mapIdOf(file){
|
|
518
|
+
const base = basename(String(file||"")).replace(/\.(tabtree|ygmind|json)$/i, "");
|
|
519
|
+
const i = base.lastIndexOf("__");
|
|
520
|
+
if(i < 0) return null;
|
|
521
|
+
const id = base.slice(i + 2);
|
|
522
|
+
return /^[A-Za-z0-9_]+$/.test(id) ? id : null;
|
|
523
|
+
}
|
|
524
|
+
function countForest(roots){ return roots.reduce((n,r)=>n + countNodes(r), 0); }
|
|
525
|
+
|
|
526
|
+
// ≡ la liste de `index.html`. Une op connue d'un seul côté ne lève AUCUNE erreur : le
|
|
527
|
+
// connecteur la refuserait à l'écriture, ou l'app la jetterait à la lecture — dans les deux
|
|
528
|
+
// cas Claude croirait avoir proposé quelque chose qui n'arrive jamais chez l'utilisateur.
|
|
529
|
+
// C'est le défaut du losange, rejoué. Un test compare les deux listes en ensembles.
|
|
530
|
+
const PROP_OPS = ["add","rename","note","check","delete","move","pic"];
|
|
531
|
+
|
|
532
|
+
// Tout est vérifié ICI, contre la carte réelle, et un défaut fait ÉCHOUER l'appel. C'est
|
|
533
|
+
// délibéré : une cible introuvable remonte à Claude, qui peut relire la carte et corriger,
|
|
534
|
+
// alors qu'une proposition à moitié valide arriverait chez l'utilisateur sous forme de
|
|
535
|
+
// lignes barrées qu'il ne peut ni comprendre ni réparer. La boîte aux lettres ne reçoit
|
|
536
|
+
// que des propositions applicables.
|
|
537
|
+
function buildProposalOps(mapDoc, changes){
|
|
538
|
+
if(!Array.isArray(changes) || !changes.length) throw new Error("`changes` must be a non-empty list.");
|
|
539
|
+
if(changes.length > 100) throw new Error("Too many changes in one proposal (max 100). Send several smaller ones — the user reviews them one by one.");
|
|
540
|
+
return changes.map((c, i)=>{
|
|
541
|
+
const where = "change #" + (i+1);
|
|
542
|
+
const op = String((c && c.op) || "").trim();
|
|
543
|
+
if(!PROP_OPS.includes(op)) throw new Error(`${where}: unknown op "${op}". Use one of: ${PROP_OPS.join(", ")}.`);
|
|
544
|
+
const target = String(c.target == null ? "" : c.target);
|
|
545
|
+
const r = propFind(mapDoc.root, target);
|
|
546
|
+
if(r.error === "empty") throw new Error(`${where}: \`target\` is required — the node the change applies to.`);
|
|
547
|
+
if(r.error === "missing") throw new Error(`${where}: no node "${target}" in this map. Run read_map first and copy the wording exactly.`);
|
|
548
|
+
if(r.error === "ambiguous") throw new Error(`${where}: "${target}" matches ${r.count} nodes. Name an ancestor too, e.g. "Parent > ${parts_last(target)}".`);
|
|
549
|
+
const out = { op, target, was: String(r.node.text||"") };
|
|
550
|
+
if(op === "add"){
|
|
551
|
+
if(typeof c.markdown !== "string" || !c.markdown.trim()) throw new Error(`${where}: \`markdown\` is required for an "add".`);
|
|
552
|
+
if(c.markdown.length > 20000) throw new Error(`${where}: \`markdown\` is too long (20000 characters max).`);
|
|
553
|
+
const forest = buildForest(parseOutline(c.markdown));
|
|
554
|
+
if(!forest.length) throw new Error(`${where}: \`markdown\` has no usable structure (a bullet list, indented by 2 spaces).`);
|
|
555
|
+
out.markdown = c.markdown;
|
|
556
|
+
out.adds = countForest(forest);
|
|
557
|
+
} else if(op === "rename"){
|
|
558
|
+
if(typeof c.text !== "string" || !c.text.trim()) throw new Error(`${where}: \`text\` is required for a "rename".`);
|
|
559
|
+
out.text = c.text.trim().slice(0, 2000);
|
|
560
|
+
} else if(op === "note"){
|
|
561
|
+
if(typeof c.note !== "string") throw new Error(`${where}: \`note\` is required for a "note" (an empty string clears it).`);
|
|
562
|
+
out.note = c.note.slice(0, 8000);
|
|
563
|
+
} else if(op === "check"){
|
|
564
|
+
if(typeof c.value !== "boolean") throw new Error(`${where}: \`value\` must be true or false for a "check".`);
|
|
565
|
+
out.value = c.value;
|
|
566
|
+
} else if(op === "delete"){
|
|
567
|
+
if(r.node === mapDoc.root) throw new Error(`${where}: the centre of the map cannot be deleted.`);
|
|
568
|
+
out.drops = countNodes(r.node);
|
|
569
|
+
} else if(op === "pic"){
|
|
570
|
+
// Une désignation, jamais des pixels. La liste est FERMÉE : un id inventé est refusé ICI,
|
|
571
|
+
// contre la carte réelle, plutôt que d'arriver chez l'utilisateur sous forme d'une ligne
|
|
572
|
+
// barrée qu'il ne peut ni comprendre ni réparer. Vide = retirer l'illustration.
|
|
573
|
+
const pic = String(c.pic == null ? "" : c.pic);
|
|
574
|
+
if(pic && !okPic(pic)){
|
|
575
|
+
throw new Error(`${where}: "${pic}" is not a picture. Use "e:" followed by one emoji, `
|
|
576
|
+
+ `or "a:" followed by one of: ${ART_IDS_MCP.join(", ")} — or "" to remove it.`);
|
|
577
|
+
}
|
|
578
|
+
out.pic = pic;
|
|
579
|
+
} else if(op === "move"){
|
|
580
|
+
// ⚠️ Dans le FICHIER les colonnes sont `{cols:[…]}` ; en mémoire, côté app, c'est un
|
|
581
|
+
// tableau nu. Confondre les deux donne un contrôle qui ne regarde rien.
|
|
582
|
+
const cols = (mapDoc.kanban && Array.isArray(mapDoc.kanban.cols)) ? mapDoc.kanban.cols : null;
|
|
583
|
+
if(!cols || !cols.length) throw new Error(`${where}: this map has no kanban board yet. Open it in TabTree and press 🃏 once, then try again.`);
|
|
584
|
+
const col = String(c.col == null ? "" : c.col);
|
|
585
|
+
// Vide = retirer du tableau, comme le ✕ d'une carte. Toute autre valeur doit désigner une
|
|
586
|
+
// colonne RÉELLE : une carte envoyée ailleurs disparaîtrait de l'écran sans un message.
|
|
587
|
+
if(col && !cols.some(k => k.id === col)){
|
|
588
|
+
const noms = cols.map(k => `"${k.name}" (${k.id})`).join(", ");
|
|
589
|
+
throw new Error(`${where}: no column "${col}" on this board. Use one of: ${noms} — or "" to take the card off the board.`);
|
|
590
|
+
}
|
|
591
|
+
out.col = col;
|
|
592
|
+
}
|
|
593
|
+
return out;
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
function parts_last(spec){ const p = String(spec||"").split(">"); return p[p.length-1].trim(); }
|
|
597
|
+
|
|
598
|
+
function writeProposal(map, note, ops){
|
|
599
|
+
dirOk();
|
|
600
|
+
const target = join(DIR, PROP_DIR);
|
|
601
|
+
if(!existsSync(target)) mkdirSync(target);
|
|
602
|
+
const id = mapIdOf(map.file);
|
|
603
|
+
if(!id) throw new Error("This map's file has no id suffix (“…__map123.tabtree”), so the app cannot match a proposal to it. Open it once in TabTree with the backup folder turned on, then try again.");
|
|
604
|
+
const file = "prop_" + id + "__" + Date.now().toString(36) + ".json";
|
|
605
|
+
writeFileSync(join(target, file), JSON.stringify({
|
|
606
|
+
v: 1, kind: "tabtree-proposal", mapId: id, mapFile: basename(map.file), mapName: map.name,
|
|
607
|
+
createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops
|
|
608
|
+
}, null, 2), "utf8");
|
|
609
|
+
return PROP_DIR + "/" + file;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Ce texte est lu par CHAQUE acheteur, à chaque création. Il a déjà été faux une
|
|
613
|
+
// fois : il disait « My maps → ♻️ Restore », or ce bouton est masqué tant que la
|
|
614
|
+
// bibliothèque n'est pas vide. L'utilisateur cherchait un bouton inexistant
|
|
615
|
+
// pendant que son fichier attendait sur le disque.
|
|
616
|
+
//
|
|
617
|
+
// Il nomme aussi le DOSSIER écrit, et c'est délibéré : la panne la plus vicieuse
|
|
618
|
+
// du connecteur est de viser une autre bibliothèque que celle ouverte dans l'app.
|
|
619
|
+
// Tout répond « ✅ » et rien n'apparaît. Donner le nom du dossier rend l'écart
|
|
620
|
+
// visible en une seconde, sans que personne ait à soupçonner quoi que ce soit.
|
|
621
|
+
function importHint(){
|
|
622
|
+
return "Written to the folder “" + basename(DIR) + "”.\n"
|
|
623
|
+
+ "To see it in TabTree: My maps → 🛟 → “Bring maps back from this folder…”. "
|
|
624
|
+
+ "(Or press ⌘K and type “bring my maps back”.) Nothing is overwritten.\n"
|
|
625
|
+
+ "If “" + basename(DIR) + "” is not the folder TabTree names next to its 🛟 button, "
|
|
626
|
+
+ "the connector is pointed at a different library — that is why a map can be created and never appear.";
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ---------------------------------------------------------------------------
|
|
630
|
+
// Définition des outils
|
|
631
|
+
// ---------------------------------------------------------------------------
|
|
632
|
+
const TOOLS = [
|
|
633
|
+
{
|
|
634
|
+
name: "list_maps",
|
|
635
|
+
description: "Lists every mind map and board in the TabTree library (name, type, folder, size, last modified).",
|
|
636
|
+
inputSchema: { type:"object", properties:{}, additionalProperties:false }
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
name: "read_map",
|
|
640
|
+
description: "Reads one TabTree map. A mind map comes back as a markdown outline; a board as a list of elements and connections. `file` is a file name (or a map name) returned by list_maps.",
|
|
641
|
+
inputSchema: { type:"object", properties:{ file:{ type:"string", description:"The .tabtree file name (or the map name)" } }, required:["file"], additionalProperties:false }
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
name: "search_maps",
|
|
645
|
+
description: "Searches text across the whole TabTree library — map names, nodes, notes, shapes, sticky notes. Case-insensitive.",
|
|
646
|
+
inputSchema: { type:"object", properties:{ query:{ type:"string", description:"Text to search for" } }, required:["query"], additionalProperties:false }
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
name: "create_mindmap",
|
|
650
|
+
description: "Creates a NEW TabTree mind map from a markdown outline (#/## headings, bullet lists indented by 2 spaces, [x]/[ ] checkboxes, [title](url) links, and \" @a:<id>\" / \" @e:<emoji>\" at the end of a line for an illustration). The first top-level line becomes the centre. Writes a .tabtree file into the backup folder; the user pulls it into the app from the 🛟 menu.",
|
|
651
|
+
inputSchema: { type:"object", properties:{
|
|
652
|
+
name:{ type:"string", description:"Name of the map" },
|
|
653
|
+
markdown:{ type:"string", description:"Markdown outline (headings and/or bullet list). End any line with \" @a:<id>\" to give that node a built-in illustration, or \" @e:<emoji>\" for one large emoji — e.g. \"Launch @a:rocket\". Ids: person, team, chat, idea, target, trophy, star, heart, warning, done, flag, clock, laptop, phone, mail, folder, book, chart, money, calendar, building, car, globe, rocket, house, pin, plane, coffee, plant, sun, camera, music, lock, key, health, gift. An unknown id is left as plain text rather than dropped." },
|
|
654
|
+
folder:{ type:"string", description:"Optional TabTree subfolder (e.g. \"From Claude\")" },
|
|
655
|
+
style:{ type:"object", description:"Optional look of the document. Omit it for the default.", properties:{
|
|
656
|
+
font:{ type:"string", enum:["","grotesk","humanist","condensed","rounded","serif","garamond","didone","slab","typewriter","mono","hand"], description:"Type family: grotesk (corporate), humanist (warm), condensed (dense), rounded (friendly), serif/garamond (editorial), didone (elegant), slab (sturdy), typewriter, mono (technical), hand (handwritten)." },
|
|
657
|
+
shape:{ type:"string", enum:["","line","plain"], description:"Node shape: cards (default), line = underlined, plain = plain text." },
|
|
658
|
+
background:{ type:"string", enum:["","plain","grid","graph","lines","iso","paper","cream","kraft","aged","mist","sage","dawn","blue","slate","chalk"], description:"Page background: dots (default), plain, grid, graph paper, ruled lines, isometric, paper, cream, kraft, aged, mist, sage, dawn, blueprint, slate (dark), chalkboard (dark)." },
|
|
659
|
+
palette:{ type:"string", enum:["","ocean","sunset","forest","berry","nordic","earth","vintage","candy","muted"], description:"Branch colour palette (mind maps only): classic (default), ocean (cool blues), sunset (warm), forest (greens), berry (purples), nordic (calm cool), earth (terracotta and olive), vintage (faded warm), candy (bright and playful), muted (subdued, client-ready)." },
|
|
660
|
+
skin:{ type:"string", enum:["","soft","flat","paper","ink","draft","sticker","brutal","arcade"], description:"Structural skin — borders, shadows, corners: classic (default), soft (borderless, rounded, cushioned), flat (no shadows at all — for print and projection), paper (journal, soft warm shadows), ink (fine ink outline, no shadow), draft (square corners, dashed links — technical sketch), sticker (white cut-out ring, drop shadow — made to be posted), brutal (thick ink borders, hard offset shadows — the poster look), arcade (square corners, hard short shadows). Travels with the map and into exported images." },
|
|
661
|
+
icons:{ type:"string", enum:["","chip","big"], description:"How node icons are drawn: plain (default — a 20px line drawing), chip (the same drawing on a pill tinted with the branch colour), big (a 30px drawing on a larger pill). Only affects nodes that carry an icon. Travels with the map and into exported images." }
|
|
662
|
+
}, additionalProperties:false }
|
|
663
|
+
}, required:["name","markdown"], additionalProperties:false }
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
name: "create_board",
|
|
667
|
+
description: "Creates a NEW TabTree board (whiteboard): shapes (rect, ellipse, diamond, text, sticky) and arrow connections that stay anchored to the shapes in the app. With no x/y coordinates, layout is automatic by level (the connections define the hierarchy, left to right). Good for a diagram, a flow, a brainstorm. In a flowchart use ellipse for start/end, rect for a step, and diamond for a decision — its text should read as a closed question with exactly two ways out.\n\nFIT THE TEXT TO THE BOX. A box shows roughly (w−24)/7 characters per line and (h−10)/20 lines, and every blank line costs a full line. Write captions, not paragraphs: when filling a canvas template (Business Model Canvas, Lean Canvas, SWOT…), the shipped templates use 70–110 characters per block, and that is what looks composed. Longer text is shrunk to fit — nothing ever spills out — but past ~11px it stops being readable and the response will warn you.",
|
|
668
|
+
inputSchema: { type:"object", properties:{
|
|
669
|
+
name:{ type:"string", description:"Name of the board" },
|
|
670
|
+
elements:{ type:"array", description:"Board elements", items:{ type:"object", properties:{
|
|
671
|
+
id:{ type:"string", description:"Logical id used by connections (defaults to the index)" },
|
|
672
|
+
kind:{ type:"string", enum:["rect","ellipse","diamond","trap","chev","arc","text","sticky"], description:"Type (defaults to rect). diamond = a decision in a flowchart, trap = a pyramid/funnel tier (tk = small-side ratio, negative when the base is the narrow side), chev = a process step pointing forward, arc = a pie slice (a0/a1 in degrees from 12 o'clock, ir = inner-radius fraction for a donut)." },
|
|
673
|
+
text:{ type:"string" },
|
|
674
|
+
x:{ type:"number" }, y:{ type:"number" }, w:{ type:"number" }, h:{ type:"number" },
|
|
675
|
+
color:{ type:"string", description:"Hex fill colour (shapes) or sticky-note colour" },
|
|
676
|
+
size:{ type:"number", description:"Text size 9-72 (defaults to 15)" },
|
|
677
|
+
tk:{ type:"number", description:"trap only — small-side/large-side ratio, 0..0.95 (0 = triangle); NEGATIVE puts the narrow side at the bottom (funnel tier)" },
|
|
678
|
+
a0:{ type:"number", description:"arc only — start angle in degrees, 0 at 12 o'clock, clockwise" },
|
|
679
|
+
a1:{ type:"number", description:"arc only — end angle in degrees (a1 > a0; a full circle is a0=0, a1=360)" },
|
|
680
|
+
ir:{ type:"number", description:"arc only — inner-radius fraction 0..0.9 (0 = pie slice, 0.55 = donut)" }
|
|
681
|
+
}, required:["text"], additionalProperties:false } },
|
|
682
|
+
connections:{ type:"array", description:"Arrows anchored between elements (by id)", items:{ type:"object", properties:{
|
|
683
|
+
from:{ type:"string" }, to:{ type:"string" }
|
|
684
|
+
}, required:["from","to"], additionalProperties:false } },
|
|
685
|
+
folder:{ type:"string", description:"Optional TabTree subfolder" },
|
|
686
|
+
style:{ type:"object", description:"Optional look of the document. Omit it for the default.", properties:{
|
|
687
|
+
font:{ type:"string", enum:["","grotesk","humanist","condensed","rounded","serif","garamond","didone","slab","typewriter","mono","hand"], description:"Type family: grotesk (corporate), humanist (warm), condensed (dense), rounded (friendly), serif/garamond (editorial), didone (elegant), slab (sturdy), typewriter, mono (technical), hand (handwritten)." },
|
|
688
|
+
shape:{ type:"string", enum:["","line","plain"], description:"Node shape: cards (default), line = underlined, plain = plain text." },
|
|
689
|
+
background:{ type:"string", enum:["","plain","grid","graph","lines","iso","paper","cream","kraft","aged","mist","sage","dawn","blue","slate","chalk"], description:"Page background: dots (default), plain, grid, graph paper, ruled lines, isometric, paper, cream, kraft, aged, mist, sage, dawn, blueprint, slate (dark), chalkboard (dark)." },
|
|
690
|
+
skin:{ type:"string", enum:["","soft","flat","paper","ink","draft","sticker","brutal","arcade"], description:"Structural skin — borders, shadows, corners: classic (default), soft (borderless, rounded), flat (no shadows — for print), paper (soft warm shadows), ink (fine ink outline), draft (square corners, technical), sticker (white cut-out ring), brutal (thick ink borders, hard shadows), arcade (square, hard short shadows). Travels with the board and into exported images." }
|
|
691
|
+
// Pas d'`icons` ici, et c'est délibéré : un board n'a pas de nœuds, donc aucune forme
|
|
692
|
+
// ne porte de pictogramme. Le déclarer promettrait un réglage sans effet — même règle
|
|
693
|
+
// que la rangée du menu ✒️ Style, gardée par `!boardMode`.
|
|
694
|
+
}, additionalProperties:false }
|
|
695
|
+
}, required:["name","elements"], additionalProperties:false }
|
|
696
|
+
},
|
|
697
|
+
{
|
|
698
|
+
name: "propose_changes",
|
|
699
|
+
description: "Proposes changes to an EXISTING mind map: add branches, rename a node, write a note, tick a task, remove a branch, illustrate a node. Nothing is applied — the proposal appears in TabTree as a banner on that map, the user reviews it change by change and picks what to keep (and can undo with Cmd+Z afterwards). Name each target node by its exact text, or by a path \"Ancestor > Node\" when the same wording appears twice. Read the map first so the wording matches.",
|
|
700
|
+
inputSchema: { type:"object", properties:{
|
|
701
|
+
file:{ type:"string", description:"The map to change — a file name (or map name) from list_maps" },
|
|
702
|
+
note:{ type:"string", description:"One line telling the user what this proposal does and why. Shown above the changes." },
|
|
703
|
+
changes:{ type:"array", description:"The proposed changes, applied in order", items:{ type:"object", properties:{
|
|
704
|
+
op:{ type:"string", enum:["add","rename","note","check","delete","move","pic"], description:"add = new children under `target`; rename = change its text; note = set its note; check = tick/untick the task; delete = remove it and its children; move = send the card to a kanban column; pic = put an illustration on it" },
|
|
705
|
+
target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\" if ambiguous" },
|
|
706
|
+
markdown:{ type:"string", description:"add: the new branch as a bullet list, 2 spaces per level" },
|
|
707
|
+
text:{ type:"string", description:"rename: the new text" },
|
|
708
|
+
note:{ type:"string", description:"note: the note body (empty string clears it)" },
|
|
709
|
+
value:{ type:"boolean", description:"check: true to tick, false to untick" },
|
|
710
|
+
col:{ type:"string", description:"move: the kanban column id (read_map shows them), or \"\" to take the card off the board" },
|
|
711
|
+
pic:{ type:"string", description:"pic: \"a:<id>\" for a built-in illustration, or \"e:<emoji>\" for one large emoji, or \"\" to remove it. Ids: person, team, chat, idea, target, trophy, star, heart, warning, done, flag, clock, laptop, phone, mail, folder, book, chart, money, calendar, building, car, globe, rocket, house, pin, plane, coffee, plant, sun, camera, music, lock, key, health, gift." }
|
|
712
|
+
}, required:["op","target"], additionalProperties:false } }
|
|
713
|
+
}, required:["file","changes"], additionalProperties:false }
|
|
714
|
+
}
|
|
715
|
+
];
|
|
716
|
+
|
|
717
|
+
const HANDLERS = {
|
|
718
|
+
list_maps(){
|
|
719
|
+
const maps = scanLibrary();
|
|
720
|
+
if(!maps.length) return "The library is empty (or TABTREE_DIR points at the wrong folder: " + DIR + ").";
|
|
721
|
+
return maps.map(m=>
|
|
722
|
+
`• ${m.name} [${m.kind}]${m.folder ? " 📁 " + m.folder : ""}\n file: ${m.file}\n ${m.kind==="board" ? m.elements + " elements" : m.nodes + " nodes"} · modified ${m.mtime}`
|
|
723
|
+
).join("\n");
|
|
724
|
+
},
|
|
725
|
+
read_map(args){
|
|
726
|
+
const m = findMap(args.file);
|
|
727
|
+
const head = `${m.name} [${m.kind}] — file: ${m.file}\n`;
|
|
728
|
+
if(m.kind === "board") return head + boardToText(m.doc);
|
|
729
|
+
// Le rappel sur propose_changes vit ici et pas dans la description de l'outil : c'est au
|
|
730
|
+
// moment où le plan est sous les yeux qu'il est utile, et il porte la règle de désignation.
|
|
731
|
+
return head + nodeToMarkdown(m.doc.root, 0)
|
|
732
|
+
+ "\n\n(To change this map, use propose_changes with `file: \"" + m.file + "\"`. Name a node by the exact text above, or \"Ancestor > Node\" when it appears twice. Nothing is applied until the user approves it in TabTree.)";
|
|
733
|
+
},
|
|
734
|
+
search_maps(args){
|
|
735
|
+
const q = String(args.query||"").toLowerCase();
|
|
736
|
+
if(!q) throw new Error("`query` is empty.");
|
|
737
|
+
const hits = [];
|
|
738
|
+
for(const m of scanLibrary()){
|
|
739
|
+
const found = [];
|
|
740
|
+
if(m.name.toLowerCase().includes(q)) found.push("(map name)");
|
|
741
|
+
for(const t of collectTexts(m.doc)){
|
|
742
|
+
if(t.toLowerCase().includes(q)) found.push(t.slice(0,100));
|
|
743
|
+
if(found.length >= 5) break;
|
|
744
|
+
}
|
|
745
|
+
if(found.length) hits.push(`• ${m.name} [${m.kind}] — file: ${m.file}\n ${found.join("\n ")}`);
|
|
746
|
+
}
|
|
747
|
+
return hits.length ? hits.join("\n") : "Nothing found for \"" + args.query + "\".";
|
|
748
|
+
},
|
|
749
|
+
create_mindmap(args){
|
|
750
|
+
// Sans cette garde, un appel où « markdown » manque donnait String(undefined) =
|
|
751
|
+
// la chaîne "undefined" : une carte à un seul nœud, écrite sur le disque sans erreur.
|
|
752
|
+
if(typeof args.markdown !== "string" || !args.markdown.trim())
|
|
753
|
+
throw new Error("The `markdown` parameter is required (an indented outline, or # headings).");
|
|
754
|
+
const roots = buildForest(parseOutline(args.markdown));
|
|
755
|
+
if(!roots.length) throw new Error("`markdown` is empty or has no usable structure (# headings or a bullet list).");
|
|
756
|
+
let root;
|
|
757
|
+
if(roots.length === 1){ root = roots[0]; }
|
|
758
|
+
else { root = makeNode(args.name || "Map"); root.children = roots; }
|
|
759
|
+
root.color = "#dbeafe";
|
|
760
|
+
// Le marqueur d'illustration, retiré ICI et jamais dans parseOutline() — ce parseur est
|
|
761
|
+
// comparé caractère par caractère avec celui de l'app, et lui ajouter une syntaxe le ferait
|
|
762
|
+
// diverger en silence. Même partage des rôles que côté modèles.
|
|
763
|
+
tplPicAssign(root);
|
|
764
|
+
const doc = { v:1, root, images:[], stickies:[] };
|
|
765
|
+
const st = okStyleMcp(args.style);
|
|
766
|
+
if(st) doc.style = st;
|
|
767
|
+
const file = writeDoc(args.name || root.text, doc, args.folder);
|
|
768
|
+
return `✅ Map created (${countNodes(root)} nodes): ${file}\n${importHint()}`;
|
|
769
|
+
},
|
|
770
|
+
create_board(args){
|
|
771
|
+
const { doc, warnings } = buildBoardDoc(args.name || "Board", args.elements, args.connections, args.style);
|
|
772
|
+
const file = writeDoc(args.name || "Board", doc, args.folder);
|
|
773
|
+
const n = doc.shapes.length + doc.stickies.length;
|
|
774
|
+
// Les avertissements passent AVANT l'astuce d'import : c'est le seul moment où le texte
|
|
775
|
+
// peut encore être raccourci, et une ligne noyée en fin de réponse ne se lit pas.
|
|
776
|
+
const warn = warnings.length
|
|
777
|
+
? `\n⚠️ ${warnings.length} element(s) hold more text than their box shows at full size:\n`
|
|
778
|
+
+ warnings.map(w=>" • " + w).join("\n")
|
|
779
|
+
+ `\n Nothing spills out — the text was shrunk to fit — but blocks at different sizes look\n`
|
|
780
|
+
+ ` uneven on a canvas. Recreate the board with shorter text to keep it clean.\n`
|
|
781
|
+
: "";
|
|
782
|
+
return `✅ Board created (${n} elements, ${doc.arrows.length} connection(s)): ${file}\n${warn}${importHint()}`;
|
|
783
|
+
},
|
|
784
|
+
propose_changes(args){
|
|
785
|
+
const m = findMap(args.file);
|
|
786
|
+
if(m.kind === "board")
|
|
787
|
+
throw new Error("Proposals only apply to mind maps. A board has free positions and drawings, which a change list cannot describe.");
|
|
788
|
+
const ops = buildProposalOps(m.doc, args.changes);
|
|
789
|
+
const file = writeProposal(m, args.note, ops);
|
|
790
|
+
const adds = ops.reduce((n,o)=>n + (o.adds||0), 0);
|
|
791
|
+
const drops = ops.reduce((n,o)=>n + (o.drops||0), 0);
|
|
792
|
+
return `📮 Proposal filed for “${m.name}” — ${ops.length} change(s)`
|
|
793
|
+
+ (adds ? `, ${adds} node(s) to add` : "")
|
|
794
|
+
+ (drops ? `, ${drops} node(s) to remove` : "")
|
|
795
|
+
+ `: ${file}\n`
|
|
796
|
+
+ "NOTHING has been changed yet. The map file is untouched.\n"
|
|
797
|
+
+ "TabTree shows a banner on that map within a few seconds (the backup folder must be connected, and the app open). "
|
|
798
|
+
+ "The user ticks the changes they want, applies them, and Cmd+Z undoes the lot.\n"
|
|
799
|
+
+ "Tell the user to look at TabTree — do not claim the map has been updated.";
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
// ---------------------------------------------------------------------------
|
|
804
|
+
// Boucle JSON-RPC (stdio, un message par ligne)
|
|
805
|
+
// ---------------------------------------------------------------------------
|
|
806
|
+
function send(obj){ process.stdout.write(JSON.stringify(obj) + "\n"); }
|
|
807
|
+
function reply(id, result){ send({ jsonrpc:"2.0", id, result }); }
|
|
808
|
+
function replyErr(id, code, message){ send({ jsonrpc:"2.0", id, error:{ code, message } }); }
|
|
809
|
+
|
|
810
|
+
function handle(msg){
|
|
811
|
+
const { id, method, params } = msg;
|
|
812
|
+
if(method === "initialize"){
|
|
813
|
+
reply(id, {
|
|
814
|
+
protocolVersion: (params && params.protocolVersion) || "2024-11-05",
|
|
815
|
+
capabilities: { tools: {} },
|
|
816
|
+
// Cette version vit dans QUATRE fichiers — ici, mcpb/manifest.json,
|
|
817
|
+
// mcp/package.json (npm) et mcp/server.json (registre MCP). Elle avait
|
|
818
|
+
// déjà dérivé (1.0.0 ici, 1.1.0 dans le manifeste) sans que rien ne le
|
|
819
|
+
// signale : un client affiche l'une, le registre publie l'autre. Un
|
|
820
|
+
// autotest les compare désormais toutes les quatre.
|
|
821
|
+
serverInfo: { name: "tabtree", version: "1.1.0" }
|
|
822
|
+
});
|
|
823
|
+
} else if(method === "notifications/initialized" || (method||"").startsWith("notifications/")){
|
|
824
|
+
// notification : pas de réponse
|
|
825
|
+
} else if(method === "ping"){
|
|
826
|
+
reply(id, {});
|
|
827
|
+
} else if(method === "tools/list"){
|
|
828
|
+
reply(id, { tools: TOOLS });
|
|
829
|
+
} else if(method === "tools/call"){
|
|
830
|
+
const name = params && params.name;
|
|
831
|
+
const fn = HANDLERS[name];
|
|
832
|
+
if(!fn){ replyErr(id, -32602, "Outil inconnu : " + name); return; }
|
|
833
|
+
try{
|
|
834
|
+
const text = fn((params && params.arguments) || {});
|
|
835
|
+
reply(id, { content: [{ type:"text", text }] });
|
|
836
|
+
}catch(e){
|
|
837
|
+
reply(id, { content: [{ type:"text", text: "Erreur : " + (e && e.message || e) }], isError: true });
|
|
838
|
+
}
|
|
839
|
+
} else if(id !== undefined){
|
|
840
|
+
replyErr(id, -32601, "Méthode non supportée : " + method);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
let buf = "";
|
|
845
|
+
process.stdin.setEncoding("utf8");
|
|
846
|
+
process.stdin.on("data", chunk=>{
|
|
847
|
+
buf += chunk;
|
|
848
|
+
let i;
|
|
849
|
+
while((i = buf.indexOf("\n")) >= 0){
|
|
850
|
+
const line = buf.slice(0, i); buf = buf.slice(i+1);
|
|
851
|
+
if(!line.trim()) continue;
|
|
852
|
+
try{ handle(JSON.parse(line)); }
|
|
853
|
+
catch(e){ replyErr(null, -32700, "JSON invalide"); }
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
process.stdin.on("end", ()=>process.exit(0));
|