tabtree-mcp 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -2
- package/package.json +2 -2
- package/tabtree-mcp.mjs +517 -30
package/README.md
CHANGED
|
@@ -168,14 +168,27 @@ bullet lists both work, two spaces per level, `[x]` for a checked item and
|
|
|
168
168
|
`[title](url)` for a link. The first top-level line becomes the centre of the
|
|
169
169
|
map.
|
|
170
170
|
|
|
171
|
+
**Screenshots on nodes.** Ask Claude to build a map *with screenshots* — of an
|
|
172
|
+
app, a website, a slide deck. Claude takes the screenshot, saves it to disk, and
|
|
173
|
+
passes its path in `images`: `{ target: "Login page", file: "/path/to/shot.png" }`.
|
|
174
|
+
The picture becomes the node's cover, drawn on the map, in the kanban and in
|
|
175
|
+
every exported image. It is re-encoded on your machine before it is written
|
|
176
|
+
(about 840 px, a few dozen KB), so a 4 MB Retina capture never bloats the map;
|
|
177
|
+
nothing is uploaded anywhere. On macOS the connector uses the built-in `sips`;
|
|
178
|
+
elsewhere it uses ImageMagick if installed, or its own PNG encoder. The same
|
|
179
|
+
works on a map you already have: `propose_changes` with `op: "img"`.
|
|
180
|
+
|
|
171
181
|
`create_board` takes a list of elements (`rect`, `ellipse`, `text`, `sticky`)
|
|
172
182
|
and connections between them. Leave out the x/y coordinates and it lays the
|
|
173
183
|
board out automatically — the connections define the hierarchy, left to right.
|
|
174
184
|
Both accept an optional `folder`, which becomes a folder in your library.
|
|
175
185
|
|
|
176
186
|
`propose_changes` is the one tool that points at a map you already have. It can
|
|
177
|
-
add a branch, rename a node, write a note, tick a task,
|
|
178
|
-
|
|
187
|
+
add a branch, rename a node, write a note, tick a task, remove a branch, put a
|
|
188
|
+
screenshot on a node — and, on a kanban, move a card to a column, set its colour
|
|
189
|
+
labels, assign it to someone, give it a priority, a due date, an estimate or a
|
|
190
|
+
`Name: value` attribute, or leave a comment on it (signed "Claude" when applied).
|
|
191
|
+
It does none of those things by itself. It writes a small file into a
|
|
179
192
|
`Propositions` subfolder of your backup folder; TabTree checks that folder while
|
|
180
193
|
it is open and raises a banner on the map concerned. You review the changes one
|
|
181
194
|
by one, tick the ones you want, and press **Apply**. ⌘Z undoes the lot.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tabtree-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"mcpName": "app.tabtree/tabtree",
|
|
5
|
-
"description": "Let Claude read your TabTree library and turn conversations into mind maps and whiteboards
|
|
5
|
+
"description": "Let Claude read your TabTree library and turn conversations into mind maps and whiteboards — in your TabTree account, or from local files.",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/ai4xedu/tabtree-mcp.git"
|
package/tabtree-mcp.mjs
CHANGED
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
// Protocole : JSON-RPC 2.0, un message JSON par ligne sur stdin/stdout.
|
|
19
19
|
// ============================================================================
|
|
20
20
|
|
|
21
|
-
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync } from "node:fs";
|
|
22
|
-
import { join, basename } from "node:path";
|
|
21
|
+
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
22
|
+
import { join, basename, resolve } from "node:path";
|
|
23
|
+
import { execFileSync } from "node:child_process";
|
|
24
|
+
import { inflateSync, deflateSync } from "node:zlib";
|
|
25
|
+
import { tmpdir, platform } from "node:os";
|
|
23
26
|
|
|
24
27
|
const DIR = process.env.TABTREE_DIR || process.env.YGMIND_DIR || "";
|
|
25
28
|
// ---------------------------------------------------------------------------
|
|
@@ -29,10 +32,27 @@ const DIR = process.env.TABTREE_DIR || process.env.YGMIND_DIR || "";
|
|
|
29
32
|
// outils ne changent pas d'un mot de contrat, seule la tuyauterie de lecture/écriture change.
|
|
30
33
|
// `TABTREE_API_URL` n'est là que pour une préversion — la valeur par défaut est la production.
|
|
31
34
|
// ---------------------------------------------------------------------------
|
|
32
|
-
|
|
35
|
+
let API_KEY = String(process.env.TABTREE_API_KEY || "").trim();
|
|
36
|
+
// ⚠️ UN CHAMP NON RENSEIGNÉ N'EST PAS UNE CLÉ FAUSSE (2026-09-09). Claude Desktop passe le
|
|
37
|
+
// gabarit `${user_config.api_key}` TEL QUEL quand le champ est vide — mesuré le jour où la
|
|
38
|
+
// réinstallation du .mcpb a vidé la configuration (la clé est `sensitive`, donc rangée dans le
|
|
39
|
+
// Trousseau et liée à l'installation précédente). `CLOUD = !!API_KEY` prenait ce littéral pour
|
|
40
|
+
// une clé, l'envoyait, et le serveur répondait « Unknown or revoked connector key » : un
|
|
41
|
+
// message FAUX, qui envoie chercher une clé révoquée là où il n'y a simplement rien de saisi.
|
|
42
|
+
if(/^\$\{.*\}$/.test(API_KEY)) API_KEY = "";
|
|
33
43
|
const API_URL = String(process.env.TABTREE_API_URL || "https://aynmiptyvisxoslzkzxn.supabase.co/functions/v1/mcp").trim();
|
|
34
44
|
const CLOUD = !!API_KEY;
|
|
45
|
+
// La FORME d'une clé se vérifie ici, avant tout aller-retour : l'app en fabrique une en
|
|
46
|
+
// `tt_live_` + 43 caractères base64url (`connKeyMaterial`). Une valeur qui n'y ressemble pas est
|
|
47
|
+
// une faute de copie ou un champ mal rempli — le dire tout de suite nomme la bonne cause, et
|
|
48
|
+
// évite d'aller faire dire au serveur quelque chose qu'il ne sait pas.
|
|
49
|
+
const API_KEY_OK = /^tt_live_[A-Za-z0-9_-]{20,}$/.test(API_KEY);
|
|
35
50
|
async function cloudCall(action, body){
|
|
51
|
+
if(!API_KEY_OK) throw new Error(
|
|
52
|
+
"TABTREE_API_KEY does not look like a connector key — it must start with \"tt_live_\". "
|
|
53
|
+
+ "If you just reinstalled the connector, its settings were reset: open TabTree → \u2699\ufe0f Settings "
|
|
54
|
+
+ "\u2192 Account \u2192 Claude connector, create a key, paste it into the connector's settings, "
|
|
55
|
+
+ "then restart the connector.");
|
|
36
56
|
let r;
|
|
37
57
|
try{
|
|
38
58
|
r = await fetch(API_URL, { method:"POST",
|
|
@@ -140,7 +160,11 @@ const ART_IDS_MCP = [
|
|
|
140
160
|
"house", "pin", "plane", "coffee", "plant", "sun",
|
|
141
161
|
"camera", "music", "lock", "key", "health", "gift",
|
|
142
162
|
"manager", "client", "expert", "assistant", "coach", "learner",
|
|
143
|
-
"developer", "seller", "support", "speaker", "freelancer", "group"
|
|
163
|
+
"developer", "seller", "support", "speaker", "freelancer", "group",
|
|
164
|
+
"uilogin", "uifeed", "uichat", "uidash", "uichart", "uishop",
|
|
165
|
+
"uimap", "uivideo", "uislide", "uiprofile", "uiboard", "uitable",
|
|
166
|
+
"atom", "dna", "flask", "microscope", "brain", "planet",
|
|
167
|
+
"pencil", "gradcap", "calc", "palette", "ruler", "backpack"
|
|
144
168
|
];
|
|
145
169
|
const ART = Object.fromEntries(ART_IDS_MCP.map(id=>[id, true]));
|
|
146
170
|
const EMO_PIC_PAT = "\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?)*";
|
|
@@ -168,6 +192,276 @@ function tplPicAssign(n){
|
|
|
168
192
|
})(n);
|
|
169
193
|
}
|
|
170
194
|
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Une CAPTURE D'ÉCRAN sur un nœud (`node.img`, 2026-09-15)
|
|
197
|
+
//
|
|
198
|
+
// Demande de l'utilisateur : « générer une mindmap avec des screenshots tirés d'une app ».
|
|
199
|
+
// Le champ existait de bout en bout — `node.img` est la COUVERTURE d'un nœud, tenue dans les
|
|
200
|
+
// cinq places, peinte sur le canevas, dans l'export et sur le recto kanban. Ce qui manquait,
|
|
201
|
+
// c'est la PORTE : le connecteur ne savait poser qu'une désignation (`pic`), jamais des pixels.
|
|
202
|
+
//
|
|
203
|
+
// Deux contraintes décident de tout ce qui suit :
|
|
204
|
+
// 1. `okCover` (index.html) n'accepte qu'un `data:image/(png|jpeg|webp|gif)` de moins de
|
|
205
|
+
// COVER_MAX_BYTES caractères. Une capture d'écran Retina fait 2 à 5 Mo : embarquée telle
|
|
206
|
+
// quelle, l'app la REFUSE à l'ouverture et le nœud arrive nu — sans une erreur. Il faut donc
|
|
207
|
+
// ré-encoder ICI, avant d'écrire. La constante et la fonction sont portées MOT POUR MOT et le
|
|
208
|
+
// test les compare à l'app : si elles divergent, le connecteur écrit une image que l'app
|
|
209
|
+
// efface, et personne ne sait pourquoi.
|
|
210
|
+
// 2. Le connecteur est ZÉRO dépendance et Node n'a aucun codec d'image. Trois encodeurs, dans
|
|
211
|
+
// l'ordre : `sips` (livré avec macOS), `magick`/`convert` (ImageMagick, s'il est là), et un
|
|
212
|
+
// chemin PUR JS — décodeur PNG (zlib est natif), réduction par moyenne de zone, encodeur PNG.
|
|
213
|
+
// Le chemin JS ne lit que du PNG, ce qui est le format de TOUTES les captures que Claude
|
|
214
|
+
// produit (navigateur, computer-use, simulateur). Quand rien ne tient dans le budget, on
|
|
215
|
+
// REFUSE en nommant la limite — jamais une image tronquée, jamais un nœud silencieusement nu.
|
|
216
|
+
//
|
|
217
|
+
// Le côté long est ré-encodé à IMG_LONG = 840 px, soit 2 × COVER_MAX_DIM : l'app affiche une
|
|
218
|
+
// couverture à 420 px au plus, et 840 la garde nette sur un écran Retina. Au-delà on stockerait
|
|
219
|
+
// des pixels que personne ne voit. Le budget par image (IMG_BUDGET) est bien plus bas que
|
|
220
|
+
// COVER_MAX_BYTES, et c'est délibéré : vingt captures à 900 Ko feraient un document de 18 Mo, lu
|
|
221
|
+
// SYNCHRONIQUEMENT depuis le miroir localStorage de l'app. À 840 px en JPEG, une capture pèse
|
|
222
|
+
// 80 à 200 Ko ; en PNG (chemin JS) 150 à 400 Ko — d'où les trois paliers de repli.
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
const COVER_MAX_BYTES = 900000; // ~900 Ko de data URL : au-delà, on refuse
|
|
225
|
+
function okCover(v){
|
|
226
|
+
return (typeof v === "string" && v.length <= COVER_MAX_BYTES
|
|
227
|
+
&& /^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/.test(v)) ? v : "";
|
|
228
|
+
}
|
|
229
|
+
const IMG_LONG = 840; // côté long visé (2 × COVER_MAX_DIM de l'app)
|
|
230
|
+
const IMG_STEPS = [840, 600, 420]; // paliers de repli quand le budget ne tient pas
|
|
231
|
+
const IMG_BUDGET = 320000; // data URL par image — bien sous COVER_MAX_BYTES, voir ci-dessus
|
|
232
|
+
const IMG_FILE_MAX = 40 * 1024 * 1024; // un fichier plus gros n'est pas une capture d'écran
|
|
233
|
+
const IMG_JPEG_Q = 82;
|
|
234
|
+
const IMG_MIMES = { png:"image/png", jpeg:"image/jpeg", webp:"image/webp", gif:"image/gif" };
|
|
235
|
+
|
|
236
|
+
// Sonde les octets, jamais l'extension : un « .png » qui contient du JPEG s'ouvre partout, et
|
|
237
|
+
// c'est l'octet qui décide de ce que l'app saura décoder.
|
|
238
|
+
function imageInfo(buf){
|
|
239
|
+
if(!buf || buf.length < 12) return null;
|
|
240
|
+
if(buf[0] === 0x89 && buf.toString("latin1", 1, 4) === "PNG" && buf.length >= 24)
|
|
241
|
+
return { kind:"png", w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
|
|
242
|
+
if(buf[0] === 0xFF && buf[1] === 0xD8){
|
|
243
|
+
let i = 2;
|
|
244
|
+
while(i + 9 < buf.length){
|
|
245
|
+
if(buf[i] !== 0xFF){ i++; continue; }
|
|
246
|
+
const m = buf[i + 1];
|
|
247
|
+
if(m === 0xFF){ i++; continue; }
|
|
248
|
+
if(m >= 0xC0 && m <= 0xCF && m !== 0xC4 && m !== 0xC8 && m !== 0xCC)
|
|
249
|
+
return { kind:"jpeg", h: buf.readUInt16BE(i + 5), w: buf.readUInt16BE(i + 7) };
|
|
250
|
+
if(m === 0xD8 || (m >= 0xD0 && m <= 0xD7) || m === 0x01){ i += 2; continue; }
|
|
251
|
+
i += 2 + buf.readUInt16BE(i + 2);
|
|
252
|
+
}
|
|
253
|
+
return { kind:"jpeg", w:0, h:0 };
|
|
254
|
+
}
|
|
255
|
+
if(buf.toString("latin1", 0, 4) === "GIF8")
|
|
256
|
+
return { kind:"gif", w: buf.readUInt16LE(6), h: buf.readUInt16LE(8) };
|
|
257
|
+
if(buf.toString("latin1", 0, 4) === "RIFF" && buf.toString("latin1", 8, 12) === "WEBP" && buf.length >= 30){
|
|
258
|
+
const c = buf.toString("latin1", 12, 16);
|
|
259
|
+
if(c === "VP8 ") return { kind:"webp", w: buf.readUInt16LE(26) & 0x3fff, h: buf.readUInt16LE(28) & 0x3fff };
|
|
260
|
+
if(c === "VP8L"){ const b = buf.subarray(21, 25); return { kind:"webp", w: 1 + (((b[1] & 0x3F) << 8) | b[0]), h: 1 + (((b[3] & 0xF) << 10) | (b[2] << 2) | ((b[1] & 0xC0) >> 6)) }; }
|
|
261
|
+
if(c === "VP8X") return { kind:"webp", w: 1 + buf.readUIntLE(24, 3), h: 1 + buf.readUIntLE(27, 3) };
|
|
262
|
+
return { kind:"webp", w:0, h:0 };
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
function dataUrlOf(buf, kind){ return "data:" + IMG_MIMES[kind] + ";base64," + buf.toString("base64"); }
|
|
267
|
+
|
|
268
|
+
// --- Le chemin PUR JS : PNG → RGB, réduction, → PNG ------------------------------------
|
|
269
|
+
// 8 bits, non entrelacé, types 0/2/3/4/6 : c'est ce qu'écrivent Chrome, macOS et Windows pour
|
|
270
|
+
// une capture. Le reste (16 bits, entrelacé) est refusé en le disant. L'alpha est composité
|
|
271
|
+
// sur du BLANC — même règle qu'`avatarFromFile` dans l'app : une capture transparente sortirait
|
|
272
|
+
// noire dans un JPEG, et grise dans l'export.
|
|
273
|
+
function pngDecode(buf){
|
|
274
|
+
let i = 8, w = 0, h = 0, depth = 0, ct = 0, inter = 0, plte = null;
|
|
275
|
+
const idat = [];
|
|
276
|
+
while(i + 8 <= buf.length){
|
|
277
|
+
const len = buf.readUInt32BE(i), type = buf.toString("latin1", i + 4, i + 8);
|
|
278
|
+
const data = buf.subarray(i + 8, i + 8 + len);
|
|
279
|
+
if(type === "IHDR"){ w = data.readUInt32BE(0); h = data.readUInt32BE(4); depth = data[8]; ct = data[9]; inter = data[12]; }
|
|
280
|
+
else if(type === "PLTE") plte = data;
|
|
281
|
+
else if(type === "IDAT") idat.push(data);
|
|
282
|
+
else if(type === "IEND") break;
|
|
283
|
+
i += 12 + len;
|
|
284
|
+
}
|
|
285
|
+
if(!w || !h) throw new Error("the PNG has no IHDR");
|
|
286
|
+
if(depth !== 8) throw new Error("a " + depth + "-bit PNG — only 8-bit PNGs can be re-encoded here");
|
|
287
|
+
if(inter) throw new Error("an interlaced PNG cannot be re-encoded here");
|
|
288
|
+
const ch = { 0:1, 2:3, 3:1, 4:2, 6:4 }[ct];
|
|
289
|
+
if(!ch || (ct === 3 && !plte)) throw new Error("unsupported PNG colour type " + ct);
|
|
290
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
291
|
+
const stride = w * ch, rgb = new Uint8Array(w * h * 3);
|
|
292
|
+
let prev = new Uint8Array(stride), cur = new Uint8Array(stride), p = 0;
|
|
293
|
+
for(let y = 0; y < h; y++){
|
|
294
|
+
const f = raw[p++];
|
|
295
|
+
for(let x = 0; x < stride; x++){
|
|
296
|
+
const a = x >= ch ? cur[x - ch] : 0, b = prev[x], c = x >= ch ? prev[x - ch] : 0;
|
|
297
|
+
let v = raw[p++];
|
|
298
|
+
if(f === 1) v += a; else if(f === 2) v += b; else if(f === 3) v += (a + b) >> 1;
|
|
299
|
+
else if(f === 4){ const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c); v += (pa <= pb && pa <= pc) ? a : (pb <= pc ? b : c); }
|
|
300
|
+
cur[x] = v & 255;
|
|
301
|
+
}
|
|
302
|
+
for(let x = 0; x < w; x++){
|
|
303
|
+
const q = x * ch; let r, g, bl, al = 255;
|
|
304
|
+
if(ct === 0){ r = g = bl = cur[q]; }
|
|
305
|
+
else if(ct === 4){ r = g = bl = cur[q]; al = cur[q + 1]; }
|
|
306
|
+
else if(ct === 2){ r = cur[q]; g = cur[q + 1]; bl = cur[q + 2]; }
|
|
307
|
+
else if(ct === 6){ r = cur[q]; g = cur[q + 1]; bl = cur[q + 2]; al = cur[q + 3]; }
|
|
308
|
+
else { const k = cur[q] * 3; r = plte[k]; g = plte[k + 1]; bl = plte[k + 2]; }
|
|
309
|
+
if(al < 255){ r = (r * al + 255 * (255 - al)) / 255 | 0; g = (g * al + 255 * (255 - al)) / 255 | 0; bl = (bl * al + 255 * (255 - al)) / 255 | 0; }
|
|
310
|
+
const o = (y * w + x) * 3; rgb[o] = r; rgb[o + 1] = g; rgb[o + 2] = bl;
|
|
311
|
+
}
|
|
312
|
+
const t = prev; prev = cur; cur = t;
|
|
313
|
+
}
|
|
314
|
+
return { w, h, rgb };
|
|
315
|
+
}
|
|
316
|
+
// Moyenne de zone : chaque pixel de sortie est la moyenne du rectangle source qu'il couvre.
|
|
317
|
+
// C'est ce qui garde lisible le texte d'une capture réduite d'un facteur 3 — un simple
|
|
318
|
+
// sous-échantillonnage saute des lignes de texte entières.
|
|
319
|
+
function rgbScale(src, W, H){
|
|
320
|
+
const { w, h, rgb } = src, out = new Uint8Array(W * H * 3);
|
|
321
|
+
for(let Y = 0; Y < H; Y++){
|
|
322
|
+
const y0 = Math.floor(Y * h / H), y1 = Math.max(y0 + 1, Math.floor((Y + 1) * h / H));
|
|
323
|
+
for(let X = 0; X < W; X++){
|
|
324
|
+
const x0 = Math.floor(X * w / W), x1 = Math.max(x0 + 1, Math.floor((X + 1) * w / W));
|
|
325
|
+
let r = 0, g = 0, b = 0, n = 0;
|
|
326
|
+
for(let y = y0; y < y1; y++){ let o = (y * w + x0) * 3; for(let x = x0; x < x1; x++){ r += rgb[o]; g += rgb[o + 1]; b += rgb[o + 2]; o += 3; n++; } }
|
|
327
|
+
const q = (Y * W + X) * 3; out[q] = r / n | 0; out[q + 1] = g / n | 0; out[q + 2] = b / n | 0;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return { w: W, h: H, rgb: out };
|
|
331
|
+
}
|
|
332
|
+
const CRC_T = (()=>{ const t = new Int32Array(256); for(let n = 0; n < 256; n++){ let c = n; for(let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); t[n] = c; } return t; })();
|
|
333
|
+
function crc32(buf){ let c = -1; for(let i = 0; i < buf.length; i++) c = CRC_T[(c ^ buf[i]) & 255] ^ (c >>> 8); return (c ^ -1) >>> 0; }
|
|
334
|
+
function pngEncode(img){
|
|
335
|
+
const { w, h, rgb } = img;
|
|
336
|
+
const raw = Buffer.alloc((w * 3 + 1) * h);
|
|
337
|
+
for(let y = 0; y < h; y++){ raw[y * (w * 3 + 1)] = 0; raw.set(rgb.subarray(y * w * 3, (y + 1) * w * 3), y * (w * 3 + 1) + 1); }
|
|
338
|
+
const chunk = (type, data)=>{
|
|
339
|
+
const out = Buffer.alloc(12 + data.length);
|
|
340
|
+
out.writeUInt32BE(data.length, 0); out.write(type, 4, "latin1"); data.copy(out, 8);
|
|
341
|
+
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
|
|
342
|
+
return out;
|
|
343
|
+
};
|
|
344
|
+
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
|
|
345
|
+
return Buffer.concat([Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw, { level: 9 })), chunk("IEND", Buffer.alloc(0))]);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// --- Les encodeurs, et l'ordre dans lequel on les essaie ---------------------------------
|
|
349
|
+
// `TABTREE_IMG_ENCODER` force un chemin (js | sips | magick) — c'est ce qui permet au test
|
|
350
|
+
// d'exercer le chemin pur JS sur une machine qui a `sips`, et à qui veut un rendu identique
|
|
351
|
+
// sur toutes ses machines de le demander.
|
|
352
|
+
function encoders(){
|
|
353
|
+
const want = String(process.env.TABTREE_IMG_ENCODER || "").trim();
|
|
354
|
+
if(want) return [want];
|
|
355
|
+
return platform() === "darwin" ? ["sips", "magick", "js"] : ["magick", "js"];
|
|
356
|
+
}
|
|
357
|
+
function withTmpFiles(fn){
|
|
358
|
+
const dir = mkdtempSync(join(tmpdir(), "tabtree-img-"));
|
|
359
|
+
try{ return fn(dir); }
|
|
360
|
+
finally{ try{ rmSync(dir, { recursive:true, force:true }); }catch(e){} }
|
|
361
|
+
}
|
|
362
|
+
// Rend { buf, kind, w, h } ou lève. `long` = côté long visé.
|
|
363
|
+
function encodeWith(name, buf, info, long){
|
|
364
|
+
if(name === "js"){
|
|
365
|
+
if(info.kind !== "png") throw new Error("the built-in encoder only reads PNG");
|
|
366
|
+
const src = pngDecode(buf);
|
|
367
|
+
const k = Math.min(1, long / Math.max(src.w, src.h));
|
|
368
|
+
const W = Math.max(1, Math.round(src.w * k)), H = Math.max(1, Math.round(src.h * k));
|
|
369
|
+
const out = pngEncode(k < 1 ? rgbScale(src, W, H) : src);
|
|
370
|
+
return { buf: out, kind:"png", w: W, h: H };
|
|
371
|
+
}
|
|
372
|
+
return withTmpFiles(dir=>{
|
|
373
|
+
const inp = join(dir, "in." + (info.kind === "jpeg" ? "jpg" : info.kind)), outp = join(dir, "out.jpg");
|
|
374
|
+
writeFileSync(inp, buf);
|
|
375
|
+
if(name === "sips"){
|
|
376
|
+
execFileSync("sips", ["-Z", String(long), "-s", "format", "jpeg", "-s", "formatOptions", String(IMG_JPEG_Q), inp, "--out", outp], { stdio:"ignore", timeout: 20000 });
|
|
377
|
+
} else if(name === "magick"){
|
|
378
|
+
const bin = ["magick", "convert"];
|
|
379
|
+
let ok = false, last = null;
|
|
380
|
+
for(const b of bin){
|
|
381
|
+
try{ execFileSync(b, [inp, "-resize", long + "x" + long + ">", "-quality", String(IMG_JPEG_Q), outp], { stdio:"ignore", timeout: 20000 }); ok = true; break; }
|
|
382
|
+
catch(e){ last = e; }
|
|
383
|
+
}
|
|
384
|
+
if(!ok) throw last || new Error("ImageMagick is not installed");
|
|
385
|
+
} else throw new Error("unknown encoder " + name);
|
|
386
|
+
const out = readFileSync(outp), oi = imageInfo(out);
|
|
387
|
+
if(!oi || oi.kind !== "jpeg") throw new Error(name + " produced no JPEG");
|
|
388
|
+
return { buf: out, kind:"jpeg", w: oi.w, h: oi.h };
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// --- La porte : un fichier ou une data URL → une couverture que l'app acceptera ---------
|
|
393
|
+
// Rend { img, note } — `note` dit ce qui a été fait, pour la réponse : une image ré-encodée
|
|
394
|
+
// à l'insu de Claude serait une capture qu'il croit envoyer en pleine résolution.
|
|
395
|
+
function readImageSpec(spec, where){
|
|
396
|
+
if(!spec || typeof spec !== "object") throw new Error(`${where}: give \`file\` (a path on this machine) or \`data\` (a data:image/… URL).`);
|
|
397
|
+
if(typeof spec.data === "string" && spec.data){
|
|
398
|
+
const m = /^data:image\/(png|jpeg|jpg|webp|gif);base64,([A-Za-z0-9+/=\s]+)$/.exec(spec.data.trim());
|
|
399
|
+
if(!m) throw new Error(`${where}: \`data\` must be a data:image/(png|jpeg|webp|gif);base64,… URL.`);
|
|
400
|
+
return { buf: Buffer.from(m[2].replace(/\s+/g, ""), "base64"), label: "inline image" };
|
|
401
|
+
}
|
|
402
|
+
if(typeof spec.file === "string" && spec.file.trim()){
|
|
403
|
+
const p = resolve(spec.file.trim());
|
|
404
|
+
if(!existsSync(p)) throw new Error(`${where}: no file at ${p}. Save the screenshot to disk first (a PNG), then pass its full path.`);
|
|
405
|
+
const st = statSync(p);
|
|
406
|
+
if(!st.isFile()) throw new Error(`${where}: ${p} is not a file.`);
|
|
407
|
+
if(st.size > IMG_FILE_MAX) throw new Error(`${where}: ${basename(p)} is ${(st.size / 1048576).toFixed(0)} MB — too large for a screenshot (40 MB max).`);
|
|
408
|
+
return { buf: readFileSync(p), label: basename(p) };
|
|
409
|
+
}
|
|
410
|
+
throw new Error(`${where}: give \`file\` (a path on this machine) or \`data\` (a data:image/… URL).`);
|
|
411
|
+
}
|
|
412
|
+
function coverFrom(spec, where){
|
|
413
|
+
const { buf, label } = readImageSpec(spec, where);
|
|
414
|
+
const info = imageInfo(buf);
|
|
415
|
+
if(!info) throw new Error(`${where}: ${label} is not a PNG, JPEG, WebP or GIF image.`);
|
|
416
|
+
const long = Math.max(info.w, info.h);
|
|
417
|
+
const src = `${info.w}×${info.h} ${info.kind.toUpperCase()}`;
|
|
418
|
+
// Tel quel quand ça tient DÉJÀ — le budget et la taille : ré-encoder une image qui va bien
|
|
419
|
+
// ne ferait que la dégrader.
|
|
420
|
+
if(long && long <= IMG_LONG){
|
|
421
|
+
const d = dataUrlOf(buf, info.kind);
|
|
422
|
+
if(d.length <= IMG_BUDGET && okCover(d)) return { img: d, note: `${label} (${src}, ${Math.round(d.length / 1024)} KB, kept as is)` };
|
|
423
|
+
}
|
|
424
|
+
const tried = [];
|
|
425
|
+
for(const step of IMG_STEPS){
|
|
426
|
+
if(long && step > long && step !== IMG_STEPS[IMG_STEPS.length - 1]) continue; // ne pas « réduire » vers plus grand
|
|
427
|
+
for(const enc of encoders()){
|
|
428
|
+
let out;
|
|
429
|
+
try{ out = encodeWith(enc, buf, info, step); }
|
|
430
|
+
catch(e){ tried.push(enc + "@" + step + ": " + (e && e.message || e).split("\n")[0]); continue; }
|
|
431
|
+
const d = dataUrlOf(out.buf, out.kind);
|
|
432
|
+
if(d.length <= IMG_BUDGET && okCover(d))
|
|
433
|
+
return { img: d, note: `${label} (${src} → ${out.w}×${out.h} ${out.kind.toUpperCase()}, ${Math.round(d.length / 1024)} KB, via ${enc})` };
|
|
434
|
+
tried.push(enc + "@" + step + ": " + Math.round(d.length / 1024) + " KB, over the " + Math.round(IMG_BUDGET / 1024) + " KB budget");
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
throw new Error(`${where}: ${label} (${src}) could not be brought under ${Math.round(IMG_BUDGET / 1024)} KB. `
|
|
438
|
+
+ (info.kind === "png" ? "Crop it to the part that matters, or " : "Save it as a PNG (the built-in encoder only reads PNG), or ")
|
|
439
|
+
+ "install ImageMagick. Tried: " + tried.join("; "));
|
|
440
|
+
}
|
|
441
|
+
// Pose les captures d'une carte NEUVE : chaque entrée désigne un nœud par son texte (ou un
|
|
442
|
+
// chemin « Parent > Nœud »), exactement comme une proposition — une désignation qui touche deux
|
|
443
|
+
// nœuds est refusée, jamais devinée. Une capture REMPLACE une illustration `pic` : deux images
|
|
444
|
+
// sur un nœud seraient deux vérités pour une question, et c'est `deserialize` qui trancherait.
|
|
445
|
+
function applyImages(root, images){
|
|
446
|
+
if(images == null) return [];
|
|
447
|
+
if(!Array.isArray(images)) throw new Error("`images` must be a list of { target, file } (or { target, data }).");
|
|
448
|
+
if(images.length > 60) throw new Error("Too many screenshots in one map (60 max).");
|
|
449
|
+
const notes = [];
|
|
450
|
+
images.forEach((im, i)=>{
|
|
451
|
+
const where = "images[" + i + "]";
|
|
452
|
+
const target = String((im && im.target) == null ? "" : im.target);
|
|
453
|
+
const r = propFind(root, target);
|
|
454
|
+
if(r.error === "empty") throw new Error(`${where}: \`target\` is required — the exact text of the node the screenshot goes on.`);
|
|
455
|
+
if(r.error === "missing") throw new Error(`${where}: no node "${target}" in this outline. Use the exact wording of a line (without its bullet, checkbox or @-marker).`);
|
|
456
|
+
if(r.error === "ambiguous") throw new Error(`${where}: "${target}" matches ${r.count} nodes. Name an ancestor too, e.g. "Parent > ${parts_last(target)}".`);
|
|
457
|
+
const { img, note } = coverFrom(im, where);
|
|
458
|
+
r.node.img = img;
|
|
459
|
+
if(r.node.pic) delete r.node.pic;
|
|
460
|
+
notes.push("“" + String(r.node.text || "").slice(0, 60) + "” ← " + note);
|
|
461
|
+
});
|
|
462
|
+
return notes;
|
|
463
|
+
}
|
|
464
|
+
|
|
171
465
|
// ---------------------------------------------------------------------------
|
|
172
466
|
// Désignation d'un nœud (partagé mot pour mot avec l'app — voir tabtree-mcp.test.mjs)
|
|
173
467
|
//
|
|
@@ -209,14 +503,31 @@ function propFind(root, spec){
|
|
|
209
503
|
// ---------------------------------------------------------------------------
|
|
210
504
|
// Arbre → markdown (lecture d'une carte)
|
|
211
505
|
// ---------------------------------------------------------------------------
|
|
506
|
+
// Les marqueurs ⟪…⟫ disent ce qu'un plan ne montre pas (2026-10-12) : les dépendances (par le
|
|
507
|
+
// TEXTE du nœud attendu, résolu ici — Claude ne lit pas des ids), la récurrence, le nombre de
|
|
508
|
+
// commentaires et l'estimation. Sans eux, Claude proposerait une date avant celle du
|
|
509
|
+
// prédécesseur ou cocherait une tâche qui repart aussitôt, sans rien savoir de ce qu'il fait.
|
|
510
|
+
const RECUR_LABEL = { d:"daily", wd:"weekdays", w:"weekly", "2w":"every 2 weeks", m:"monthly", q:"quarterly", y:"yearly" };
|
|
511
|
+
function durText(sec){ const m = Math.round(sec / 60), h = Math.floor(m / 60); return h ? (h + "h" + (m % 60 ? String(m % 60).padStart(2, "0") : "")) : (m + "m"); }
|
|
212
512
|
function nodeToMarkdown(n, depth){
|
|
213
513
|
const lines = [];
|
|
514
|
+
const byId = new Map();
|
|
515
|
+
(function idx(node){ if(node && node.id) byId.set(node.id, node); (node.children||[]).forEach(idx); })(n);
|
|
214
516
|
(function walk(node, d){
|
|
215
517
|
let bullet;
|
|
216
518
|
if(d===0) bullet = "# ";
|
|
217
519
|
else bullet = " ".repeat(d-1) + (node.check!=null ? (node.check ? "- [x] " : "- [ ] ") : "- ");
|
|
218
520
|
const body = node.link ? `[${node.text}](${node.link})` : node.text;
|
|
219
|
-
|
|
521
|
+
const marks = [];
|
|
522
|
+
if(node.note && node.note.trim()) marks.push("⟪note⟫");
|
|
523
|
+
if(Array.isArray(node.deps) && node.deps.length){
|
|
524
|
+
const names = node.deps.map(id => byId.get(id)).filter(Boolean).map(p => String(p.text || "").trim()).filter(Boolean);
|
|
525
|
+
if(names.length) marks.push("⟪waits on: " + names.join(", ") + "⟫");
|
|
526
|
+
}
|
|
527
|
+
if(node.recur && RECUR_LABEL[node.recur]) marks.push("⟪repeats " + RECUR_LABEL[node.recur] + "⟫");
|
|
528
|
+
if(Array.isArray(node.cmts) && node.cmts.length) marks.push("⟪" + node.cmts.length + " comment" + (node.cmts.length > 1 ? "s" : "") + "⟫");
|
|
529
|
+
if(typeof node.est === "number" && node.est > 0) marks.push("⟪est " + durText(node.est) + "⟫");
|
|
530
|
+
lines.push(bullet + body + (marks.length ? " " + marks.join(" ") : ""));
|
|
220
531
|
(node.children||[]).forEach(c=>walk(c, d+1));
|
|
221
532
|
})(n, depth||0);
|
|
222
533
|
return lines.join("\n");
|
|
@@ -437,8 +748,17 @@ function readManifest(){
|
|
|
437
748
|
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; }
|
|
438
749
|
return out.length ? out.join(" / ") : null;
|
|
439
750
|
};
|
|
751
|
+
// Un ONGLET (2026-09-15) est un document CACHÉ de la bibliothèque : `sub` porte l'id de son
|
|
752
|
+
// classeur. Sans le lire, Claude verrait « Sheet 2 » comme une carte ordinaire, sans savoir
|
|
753
|
+
// de quel classeur elle est la seconde page — et proposerait de la ranger, ou de la lier.
|
|
754
|
+
const byId = new Map((m.maps||[]).filter(x=>x && x.id).map(x=>[x.id, x]));
|
|
755
|
+
const okId = v => typeof v === "string" && /^[A-Za-z0-9_]{1,64}$/.test(v);
|
|
440
756
|
const byFile = new Map();
|
|
441
|
-
(m.maps||[]).forEach(x=>{
|
|
757
|
+
(m.maps||[]).forEach(x=>{
|
|
758
|
+
if(!x || !x.file) return;
|
|
759
|
+
const book = okId(x.sub) && x.sub !== x.id ? byId.get(x.sub) : null;
|
|
760
|
+
byFile.set(x.file, { name:x.name, folder: pathOf(x.folderId), tabOf: book ? (book.name || book.id) : null });
|
|
761
|
+
});
|
|
442
762
|
return byFile;
|
|
443
763
|
}catch(e){ return new Map(); }
|
|
444
764
|
}
|
|
@@ -469,6 +789,7 @@ function scanLibrary(){
|
|
|
469
789
|
file: rel,
|
|
470
790
|
name: meta.name || ent.name.replace(/\.(tabtree|ygmind|json)$/i, "").replace(/__map[A-Za-z0-9_]+$/, ""),
|
|
471
791
|
folder: meta.folder || folder || null,
|
|
792
|
+
tabOf: meta.tabOf || null, // le classeur dont ce document est un onglet, sinon null
|
|
472
793
|
kind: doc.board ? "board" : "map",
|
|
473
794
|
nodes: countNodes(doc.root),
|
|
474
795
|
elements: doc.board ? ((doc.shapes||[]).length + (doc.stickies||[]).length + (doc.arrows||[]).length + (doc.draws||[]).length) : undefined,
|
|
@@ -558,17 +879,50 @@ async function findMap(file){
|
|
|
558
879
|
// ---------------------------------------------------------------------------
|
|
559
880
|
async function writeDoc(name, doc, folder){
|
|
560
881
|
if(CLOUD){
|
|
561
|
-
// Le dossier est un NOM de dossier de bibliothèque : on le résout en clé s'il
|
|
562
|
-
// sinon la carte arrive à la racine et le texte de réponse le dit
|
|
563
|
-
|
|
882
|
+
// Le dossier est un NOM ou un CHEMIN de dossier de bibliothèque : on le résout en clé s'il
|
|
883
|
+
// existe déjà, sinon la carte arrive à la racine et le texte de réponse le dit
|
|
884
|
+
// (« no silent caps »).
|
|
885
|
+
//
|
|
886
|
+
// ⚠️ 2026-09-23 — LE NOM SEUL NE SUFFIT PAS. Cette résolution comparait `f.name` et prenait
|
|
887
|
+
// le PREMIER trouvé. Un compte qui a « Ai4X » à la racine ET un « Ai4X » imbriqué dedans
|
|
888
|
+
// (ce qui arrive dès qu'on range un dossier dans un autre) faisait alors tirer au sort :
|
|
889
|
+
// les cartes atterrissaient dans « Ai4X / Ai4X » pendant que leur auteur les cherchait dans
|
|
890
|
+
// « Ai4X », et concluait que le connecteur n'avait rien écrit. Deux règles maintenant :
|
|
891
|
+
// un CHEMIN complet (« Ai4X / Ai4X ») gagne toujours, et un nom seul prend le dossier le
|
|
892
|
+
// MOINS PROFOND — celui qu'on a en tête quand on écrit « Ai4X ». L'ambiguïté est dite.
|
|
893
|
+
let folder_key = null, folder_path = null, folder_twins = 0;
|
|
564
894
|
if(folder){
|
|
565
895
|
const j = await cloudCall("list", {});
|
|
566
|
-
const
|
|
567
|
-
|
|
896
|
+
const all = (j.folders||[]).filter(f=>f && f.folder_key);
|
|
897
|
+
const byKey = new Map(all.map(f=>[f.folder_key, f]));
|
|
898
|
+
const chain = (k)=>{ const out=[]; const seen=new Set(); let f=byKey.get(k);
|
|
899
|
+
while(f && !seen.has(f.folder_key) && out.length<=16){ seen.add(f.folder_key); out.unshift(f.name); f = f.parent_key ? byKey.get(f.parent_key) : null; }
|
|
900
|
+
return out; };
|
|
901
|
+
const norm = (x)=>String(x||"").split("/").map(t=>t.trim()).filter(Boolean).join(" / ").toLowerCase();
|
|
902
|
+
const want = norm(folder);
|
|
903
|
+
let hits = all.filter(f=>norm(chain(f.folder_key).join(" / ")) === want); // chemin exact
|
|
904
|
+
if(!hits.length) hits = all.filter(f=>norm(f.name) === want) // nom de feuille
|
|
905
|
+
.sort((a,b)=>chain(a.folder_key).length - chain(b.folder_key).length);
|
|
906
|
+
if(hits.length){
|
|
907
|
+
folder_key = hits[0].folder_key;
|
|
908
|
+
folder_path = chain(folder_key).join(" / ");
|
|
909
|
+
folder_twins = hits.length;
|
|
910
|
+
}
|
|
568
911
|
}
|
|
569
912
|
const kind = doc.board ? "board" : (doc.kanban ? "kanban" : "map");
|
|
570
913
|
const r = await cloudCall("create", { name: String(name||"").slice(0,200), kind, doc, folder_key });
|
|
571
|
-
|
|
914
|
+
// On DIT toujours où la carte a atterri : le chemin complet, pas le nom demandé. C'est ce
|
|
915
|
+
// qui permet de la retrouver du premier coup au lieu de fouiller la bibliothèque.
|
|
916
|
+
let where = "";
|
|
917
|
+
if(folder_key){
|
|
918
|
+
where = " (in “" + folder_path + "”)";
|
|
919
|
+
if(folder_twins > 1) where += " ⚠ " + folder_twins + " folders are named “" + folder
|
|
920
|
+
+ "” in this account; the shallowest was used. Pass the full path (e.g. “" + folder + " / " + folder
|
|
921
|
+
+ "”) to choose another one.";
|
|
922
|
+
} else if(folder){
|
|
923
|
+
where = " (folder “" + folder + "” does not exist in this account — the map is at the root)";
|
|
924
|
+
}
|
|
925
|
+
return "cloud:" + r.map_key + where;
|
|
572
926
|
}
|
|
573
927
|
dirOk();
|
|
574
928
|
let target = DIR;
|
|
@@ -599,7 +953,9 @@ function countForest(roots){ return roots.reduce((n,r)=>n + countNodes(r), 0); }
|
|
|
599
953
|
// connecteur la refuserait à l'écriture, ou l'app la jetterait à la lecture — dans les deux
|
|
600
954
|
// cas Claude croirait avoir proposé quelque chose qui n'arrive jamais chez l'utilisateur.
|
|
601
955
|
// C'est le défaut du losange, rejoué. Un test compare les deux listes en ensembles.
|
|
602
|
-
const PROP_OPS = ["add","rename","note","check","delete","move","pic","persona"];
|
|
956
|
+
const PROP_OPS = ["add","rename","note","check","delete","move","pic","img","persona","tags","assign","priority","due","attr","comment","est"];
|
|
957
|
+
// Les dix étiquettes de couleur du kanban — ≡ `KB_TAGS` de index.html (le test compare les deux).
|
|
958
|
+
const KB_TAG_KEYS = ["green","yellow","orange","red","purple","blue","sky","lime","pink","gray"];
|
|
603
959
|
|
|
604
960
|
// Tout est vérifié ICI, contre la carte réelle, et un défaut fait ÉCHOUER l'appel. C'est
|
|
605
961
|
// délibéré : une cible introuvable remonte à Claude, qui peut relire la carte et corriger,
|
|
@@ -624,11 +980,18 @@ function buildProposalOps(mapDoc, changes){
|
|
|
624
980
|
// profondeur — « une proposition à moitié valide arrive en lignes barrées » — ne
|
|
625
981
|
// s'applique pas : une op persona est ATOMIQUE, elle rebâtit la toile ou ne fait rien.
|
|
626
982
|
const P = c.persona;
|
|
627
|
-
|
|
628
|
-
|
|
983
|
+
// DEUX personas (2026-09-11) : celui d'une WORK MAP porte des `modules`, celui d'un SECOND
|
|
984
|
+
// CERVEAU des `domains`. Même contrôle superficiel — `okBrain` juge à l'application.
|
|
985
|
+
const isBrain = !!(P && typeof P === "object" && Array.isArray(P.domains) && P.domains.length);
|
|
986
|
+
if(!P || typeof P !== "object" || (!isBrain && (!Array.isArray(P.modules) || !P.modules.length)))
|
|
987
|
+
throw new Error(`${where}: \`persona\` must be the interview object — a work map { who, role, mission, tools, meetings, opener, modules:[{ name, procs:[{ name, h, after, freq, rep, data, stakes, lever, step, why }] }], trajectory, signs, parking }, or a second-brain blueprint { who, role, mission, tools, opener, domains:[{ name, kind, share, holds }], losses, outputs, sources, nevermix, cadence, review, ailevel, signs }.`);
|
|
988
|
+
if(JSON.stringify(P).length > 60000) throw new Error(`${where}: the persona is too large (60000 characters max).`);
|
|
989
|
+
if(isBrain){
|
|
990
|
+
if(P.domains.length > 5) throw new Error(`${where}: the blueprint has ${P.domains.length} domains — five at most.`);
|
|
991
|
+
return { op, persona: P, procs: 0, domains: P.domains.length };
|
|
992
|
+
}
|
|
629
993
|
const n = P.modules.reduce((a, m)=>a + ((m && Array.isArray(m.procs)) ? m.procs.length : 0), 0);
|
|
630
994
|
if(n < 3) throw new Error(`${where}: the persona has ${n} process(es) — a work map needs at least three. Finish the interview first.`);
|
|
631
|
-
if(JSON.stringify(P).length > 60000) throw new Error(`${where}: the persona is too large (60000 characters max).`);
|
|
632
995
|
return { op, persona: P, procs: n };
|
|
633
996
|
}
|
|
634
997
|
const target = String(c.target == null ? "" : c.target);
|
|
@@ -637,6 +1000,14 @@ function buildProposalOps(mapDoc, changes){
|
|
|
637
1000
|
if(r.error === "missing") throw new Error(`${where}: no node "${target}" in this map. Run read_map first and copy the wording exactly.`);
|
|
638
1001
|
if(r.error === "ambiguous") throw new Error(`${where}: "${target}" matches ${r.count} nodes. Name an ancestor too, e.g. "Parent > ${parts_last(target)}".`);
|
|
639
1002
|
const out = { op, target, was: String(r.node.text||"") };
|
|
1003
|
+
// `why` (2026-10-12) : la RAISON d'un changement, en une ligne — l'app la montre sous la ligne
|
|
1004
|
+
// à approuver. ≡ la règle de `parseProposal` côté app (une ligne, 200 caractères) : une raison
|
|
1005
|
+
// trop longue est rognée, jamais refusée ; une raison qui n'est pas du texte, si.
|
|
1006
|
+
if(c.why != null){
|
|
1007
|
+
if(typeof c.why !== "string") throw new Error(`${where}: \`why\` must be text — one short line saying why this change.`);
|
|
1008
|
+
const w = c.why.replace(/[\x00-\x1f\x7f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 200);
|
|
1009
|
+
if(w) out.why = w;
|
|
1010
|
+
}
|
|
640
1011
|
if(op === "add"){
|
|
641
1012
|
if(typeof c.markdown !== "string" || !c.markdown.trim()) throw new Error(`${where}: \`markdown\` is required for an "add".`);
|
|
642
1013
|
if(c.markdown.length > 20000) throw new Error(`${where}: \`markdown\` is too long (20000 characters max).`);
|
|
@@ -644,6 +1015,22 @@ function buildProposalOps(mapDoc, changes){
|
|
|
644
1015
|
if(!forest.length) throw new Error(`${where}: \`markdown\` has no usable structure (a bullet list, indented by 2 spaces).`);
|
|
645
1016
|
out.markdown = c.markdown;
|
|
646
1017
|
out.adds = countForest(forest);
|
|
1018
|
+
// `col` facultatif (2026-10-12) : les cartes NEUVES vont dans cette colonne du kanban.
|
|
1019
|
+
// Validé comme pour `move` — une colonne inventée est refusée ICI, en nommant les vraies.
|
|
1020
|
+
if(c.col != null && c.col !== ""){
|
|
1021
|
+
const cols = (mapDoc.kanban && Array.isArray(mapDoc.kanban.cols)) ? mapDoc.kanban.cols : null;
|
|
1022
|
+
const col = String(c.col);
|
|
1023
|
+
if(!cols || !cols.some(k => k.id === col))
|
|
1024
|
+
throw new Error(`${where}: no column "${col}" on this board${cols ? ". Use one of: " + cols.map(k => `"${k.name}" (${k.id})`).join(", ") : " — it has no kanban yet"}.`);
|
|
1025
|
+
out.col = col;
|
|
1026
|
+
}
|
|
1027
|
+
// `assign` facultatif (2026-10-12, 👥) : le RESPONSABLE des cartes neuves. Mêmes bornes que l'op
|
|
1028
|
+
// `assign` ; refusé ICI en le disant, plutôt que de laisser l'app le retirer en silence.
|
|
1029
|
+
if(c.assign != null && c.assign !== ""){
|
|
1030
|
+
if(typeof c.assign !== "string" || c.assign.length > 120 || /[\x00-\x1f\x7f]/.test(c.assign))
|
|
1031
|
+
throw new Error(`${where}: \`assign\` must be a name or e-mail (120 characters max) — the owner of the new cards.`);
|
|
1032
|
+
if(c.assign.trim()) out.assign = c.assign.trim();
|
|
1033
|
+
}
|
|
647
1034
|
} else if(op === "rename"){
|
|
648
1035
|
if(typeof c.text !== "string" || !c.text.trim()) throw new Error(`${where}: \`text\` is required for a "rename".`);
|
|
649
1036
|
out.text = c.text.trim().slice(0, 2000);
|
|
@@ -666,6 +1053,15 @@ function buildProposalOps(mapDoc, changes){
|
|
|
666
1053
|
+ `or "a:" followed by one of: ${ART_IDS_MCP.join(", ")} — or "" to remove it.`);
|
|
667
1054
|
}
|
|
668
1055
|
out.pic = pic;
|
|
1056
|
+
} else if(op === "img"){
|
|
1057
|
+
// Des PIXELS, cette fois — ré-encodés ICI pour passer `okCover` chez l'utilisateur. Une
|
|
1058
|
+
// capture trop lourde est refusée à l'écriture, en nommant la limite, plutôt que d'arriver
|
|
1059
|
+
// dans le panneau comme une ligne grisée. `img: ""` retire la capture.
|
|
1060
|
+
if(c.img === "" || c.img === null){ out.img = ""; }
|
|
1061
|
+
else {
|
|
1062
|
+
const { img, note } = coverFrom(c, where);
|
|
1063
|
+
out.img = img; out.shot = note;
|
|
1064
|
+
}
|
|
669
1065
|
} else if(op === "move"){
|
|
670
1066
|
// ⚠️ Dans le FICHIER les colonnes sont `{cols:[…]}` ; en mémoire, côté app, c'est un
|
|
671
1067
|
// tableau nu. Confondre les deux donne un contrôle qui ne regarde rien.
|
|
@@ -679,17 +1075,85 @@ function buildProposalOps(mapDoc, changes){
|
|
|
679
1075
|
throw new Error(`${where}: no column "${col}" on this board. Use one of: ${noms} — or "" to take the card off the board.`);
|
|
680
1076
|
}
|
|
681
1077
|
out.col = col;
|
|
1078
|
+
} else if(op === "tags"){
|
|
1079
|
+
// Les dix couleurs, liste FERMÉE — ≡ `okTags` de l'app. Une clé inventée est refusée ICI,
|
|
1080
|
+
// en nommant la liste, plutôt que de tomber en silence à la lecture. [] retire tout.
|
|
1081
|
+
if(!Array.isArray(c.tags)) throw new Error(`${where}: \`tags\` must be a list of label colours for a "tags" (an empty list clears them).`);
|
|
1082
|
+
const bad = c.tags.filter(t => !KB_TAG_KEYS.includes(t));
|
|
1083
|
+
if(bad.length) throw new Error(`${where}: unknown label colour "${bad[0]}". Use any of: ${KB_TAG_KEYS.join(", ")} (read_map shows the names the board gave them).`);
|
|
1084
|
+
out.tags = KB_TAG_KEYS.filter(k => c.tags.includes(k));
|
|
1085
|
+
} else if(op === "assign"){
|
|
1086
|
+
if(typeof c.assign !== "string") throw new Error(`${where}: \`assign\` is required for an "assign" — a name or e-mail (an empty string clears it).`);
|
|
1087
|
+
if(c.assign.length > 120 || /[\x00-\x1f\x7f]/.test(c.assign)) throw new Error(`${where}: \`assign\` is too long or contains control characters (120 characters max).`);
|
|
1088
|
+
out.assign = c.assign.trim();
|
|
1089
|
+
} else if(op === "priority"){
|
|
1090
|
+
const q = (c.priority == null) ? 0 : c.priority;
|
|
1091
|
+
if(!Number.isInteger(q) || q < 0 || q > 6) throw new Error(`${where}: \`priority\` must be a whole number from 1 (highest) to 6, or 0 to remove it.`);
|
|
1092
|
+
out.priority = q;
|
|
1093
|
+
} else if(op === "due"){
|
|
1094
|
+
const q = (c.due == null) ? "" : String(c.due);
|
|
1095
|
+
if(q && !/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(q)) throw new Error(`${where}: \`due\` must be a day written YYYY-MM-DD (or "" to remove the due date).`);
|
|
1096
|
+
out.due = q;
|
|
1097
|
+
} else if(op === "attr"){
|
|
1098
|
+
if(typeof c.name !== "string" || !c.name.trim() || c.name.length > 60 || /[:\x00-\x1f]/.test(c.name))
|
|
1099
|
+
throw new Error(`${where}: \`name\` is required for an "attr" — the attribute's name, without a colon (60 characters max).`);
|
|
1100
|
+
if(typeof c.value !== "string" || c.value.length > 400 || /[\x00-\x1f]/.test(c.value))
|
|
1101
|
+
throw new Error(`${where}: \`value\` is required for an "attr" — its value as text (400 characters max; "" removes the attribute).`);
|
|
1102
|
+
out.name = c.name.trim(); out.value = c.value.trim();
|
|
1103
|
+
} else if(op === "comment"){
|
|
1104
|
+
// Un commentaire est AJOUTÉ au fil de la carte, jamais substitué : la seule op qui ne
|
|
1105
|
+
// réécrit rien. Signé « Claude » par l'app (le `by` de la proposition), borné comme
|
|
1106
|
+
// `CMT_TEXT_MAX` côté app — un fil est lu par des gens, pas par un tableur.
|
|
1107
|
+
if(typeof c.text !== "string" || !c.text.trim()) throw new Error(`${where}: \`text\` is required for a "comment" — what you want to say about this card.`);
|
|
1108
|
+
if(c.text.length > 2000) throw new Error(`${where}: the comment is too long (2000 characters max).`);
|
|
1109
|
+
out.text = c.text.trim();
|
|
1110
|
+
} else if(op === "est"){
|
|
1111
|
+
// Une DURÉE, dans les formes que `parseDur` lit côté app (« 2h », « 1h30 », « 45 » = minutes,
|
|
1112
|
+
// « 2:15 »), jamais une phrase : « soon » est refusé ICI, en nommant les formes, plutôt que
|
|
1113
|
+
// de tomber en silence à la lecture. Vide = retirer l'estimation.
|
|
1114
|
+
const q = (c.est == null) ? "" : String(c.est).trim().toLowerCase().replace(/\s+/g, "");
|
|
1115
|
+
if(q && !DUR_RE.test(q)) throw new Error(`${where}: \`est\` must be a duration — "2h", "1h30", "45" (minutes) or "2:15" — or "" to remove the estimate.`);
|
|
1116
|
+
out.est = q;
|
|
682
1117
|
}
|
|
683
1118
|
return out;
|
|
684
1119
|
});
|
|
685
1120
|
}
|
|
1121
|
+
// 👥 L'ÉQUIPE du tableau (`kanban.team`, 2026-10-12) — qui fait quoi. Montrée par read_map pour la
|
|
1122
|
+
// même raison que dans l'app : c'est elle qui permet de proposer un RESPONSABLE (assign) au lieu de
|
|
1123
|
+
// deviner à partir d'un prénom écrit dans un titre. Lecture seule, bornée comme `okKbTeam` côté app
|
|
1124
|
+
// (30 personnes, textes rognés) : le fichier vient du disque, c'est-à-dire de n'importe qui.
|
|
1125
|
+
function teamToText(doc){
|
|
1126
|
+
const t = doc && doc.kanban && doc.kanban.team;
|
|
1127
|
+
if(!t || typeof t !== "object" || !Array.isArray(t.people)) return "";
|
|
1128
|
+
const line = (v, max) => String(typeof v === "string" ? v : "").replace(/[\x00-\x1f\x7f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, max);
|
|
1129
|
+
const people = t.people.filter(p => p && typeof p.who === "string" && line(p.who, 120)).slice(0, 30);
|
|
1130
|
+
if(!people.length) return "";
|
|
1131
|
+
let out = "\n\n👥 Team — who does what (pick owners from here; `assign` takes the exact name):";
|
|
1132
|
+
const goal = line(t.goal, 800);
|
|
1133
|
+
if(goal) out += "\n Team objectives: " + goal;
|
|
1134
|
+
for(const p of people){
|
|
1135
|
+
out += "\n• " + line(p.who, 120) + (line(p.role, 80) ? " — " + line(p.role, 80) : "");
|
|
1136
|
+
if(line(p.does, 800)) out += "\n owns: " + line(p.does, 800);
|
|
1137
|
+
if(line(p.goals, 800)) out += "\n objectives: " + line(p.goals, 800);
|
|
1138
|
+
}
|
|
1139
|
+
return out;
|
|
1140
|
+
}
|
|
1141
|
+
// ≡ les formes que `parseDur` accepte dans index.html (hh:mm, Nh, NhMM, Nm, Ns, N = minutes).
|
|
1142
|
+
const DUR_RE = /^(\d{1,5}:[0-5]\d|\d{1,5}h([0-5]?\d)?m?|\d{1,7}m(in)?|\d{1,9}s(ec)?|\d{1,7})$/;
|
|
686
1143
|
function parts_last(spec){ const p = String(spec||"").split(">"); return p[p.length-1].trim(); }
|
|
687
1144
|
|
|
1145
|
+
// L'app ignore un fichier de proposition au-delà de PROP_FILE_MAX octets (`propScan`) — sans un
|
|
1146
|
+
// mot, puisqu'elle ne peut pas savoir ce qu'il contenait. Avec des captures d'écran dedans, la
|
|
1147
|
+
// borne se rencontre : on refuse ICI en nommant le geste, jamais un dépôt qui n'arrive pas.
|
|
1148
|
+
const PROP_FILE_MAX = 1400000;
|
|
688
1149
|
async function writeProposal(map, note, ops){
|
|
689
1150
|
const payload = (id)=>({
|
|
690
1151
|
v: 1, kind: "tabtree-proposal", mapId: id, mapFile: CLOUD ? map.file : basename(map.file), mapName: map.name,
|
|
691
1152
|
createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops
|
|
692
1153
|
});
|
|
1154
|
+
const shots = ops.filter(o=>o.op === "img" && o.img).length;
|
|
1155
|
+
if(JSON.stringify(payload("x")).length > PROP_FILE_MAX)
|
|
1156
|
+
throw new Error("This proposal is too large for TabTree's mailbox (" + Math.round(PROP_FILE_MAX / 1024) + " KB max) — it carries " + shots + " screenshot(s). Send it as several proposals with fewer screenshots each.");
|
|
693
1157
|
if(CLOUD){
|
|
694
1158
|
// La boîte aux lettres cloud : la table `proposals`, que l'app relit au sondage suivant.
|
|
695
1159
|
// Même charge utile que le fichier — c'est le même `parseProposal` qui la lit côté app.
|
|
@@ -746,11 +1210,16 @@ const TOOLS = [
|
|
|
746
1210
|
},
|
|
747
1211
|
{
|
|
748
1212
|
name: "create_mindmap",
|
|
749
|
-
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.",
|
|
1213
|
+
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. SCREENSHOTS: pass `images` — a list of { target, file } where `file` is the path of a PNG/JPEG on this machine (a screenshot you saved) and `target` the exact text of the node it goes on; the picture is re-encoded to fit and drawn as the node's cover. Writes a .tabtree file into the backup folder; the user pulls it into the app from the 🛟 menu.",
|
|
750
1214
|
inputSchema: { type:"object", properties:{
|
|
751
1215
|
name:{ type:"string", description:"Name of the map" },
|
|
752
1216
|
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." },
|
|
753
|
-
folder:{ type:"string", description:"Optional TabTree
|
|
1217
|
+
folder:{ type:"string", description:"Optional TabTree folder. A NAME (\"From Claude\") or a full PATH (\"Ai4X / Programmes\"). When several folders share a name, the shallowest is used and the answer says which — pass the full path to choose another." },
|
|
1218
|
+
images:{ type:"array", description:"Optional screenshots, one per node: { target, file } or { target, data }. `target` = the exact text of a line of the outline (or \"Ancestor > Node\" when the wording repeats); `file` = full path of a PNG/JPEG/WebP/GIF on this machine (take the screenshot, save it, pass the path); `data` = a data:image/… URL instead of a file. The image is re-encoded to about 840 px and becomes the node's cover picture (it replaces any @a:/@e: illustration on that node). 60 max.", items:{ type:"object", properties:{
|
|
1219
|
+
target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\"" },
|
|
1220
|
+
file:{ type:"string", description:"Path of the image file on this machine" },
|
|
1221
|
+
data:{ type:"string", description:"A data:image/(png|jpeg|webp|gif);base64,… URL — instead of `file`" }
|
|
1222
|
+
}, required:["target"], additionalProperties:false } },
|
|
754
1223
|
style:{ type:"object", description:"Optional look of the document. Omit it for the default.", properties:{
|
|
755
1224
|
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)." },
|
|
756
1225
|
shape:{ type:"string", enum:["","line","plain"], description:"Node shape: cards (default), line = underlined, plain = plain text." },
|
|
@@ -782,7 +1251,7 @@ const TOOLS = [
|
|
|
782
1251
|
connections:{ type:"array", description:"Arrows anchored between elements (by id)", items:{ type:"object", properties:{
|
|
783
1252
|
from:{ type:"string" }, to:{ type:"string" }
|
|
784
1253
|
}, required:["from","to"], additionalProperties:false } },
|
|
785
|
-
folder:{ type:"string", description:"Optional TabTree
|
|
1254
|
+
folder:{ type:"string", description:"Optional TabTree folder. A NAME or a full PATH (\"Ai4X / Programmes\"). When several folders share a name, the shallowest is used and the answer says which." },
|
|
786
1255
|
style:{ type:"object", description:"Optional look of the document. Omit it for the default.", properties:{
|
|
787
1256
|
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)." },
|
|
788
1257
|
shape:{ type:"string", enum:["","line","plain"], description:"Node shape: cards (default), line = underlined, plain = plain text." },
|
|
@@ -796,20 +1265,31 @@ const TOOLS = [
|
|
|
796
1265
|
},
|
|
797
1266
|
{
|
|
798
1267
|
name: "propose_changes",
|
|
799
|
-
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 — or, with op \"persona\", re-cast a WORK MAP canvas from a finished interview (the JSON block of the « ma cartographie du travail » skill). 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.",
|
|
1268
|
+
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, put a screenshot on a node (op \"img\" with `file`) — or, with op \"persona\", re-cast a WORK MAP canvas from a finished interview (the JSON block of the « ma cartographie du travail » skill). 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.",
|
|
800
1269
|
inputSchema: { type:"object", properties:{
|
|
801
1270
|
file:{ type:"string", description:"The map to change — a file name (or map name) from list_maps" },
|
|
802
1271
|
note:{ type:"string", description:"One line telling the user what this proposal does and why. Shown above the changes." },
|
|
803
1272
|
changes:{ type:"array", description:"The proposed changes, applied in order", items:{ type:"object", properties:{
|
|
804
|
-
op:{ type:"string", enum:["add","rename","note","check","delete","move","pic","persona"], 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; persona = re-cast a work map canvas (a board whose name starts with “Work map —” / “Cartographie —”) from an interview — no target" },
|
|
1273
|
+
op:{ type:"string", enum:["add","rename","note","check","delete","move","pic","img","persona","tags","assign","priority","due","attr","comment","est"], 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; img = put a SCREENSHOT on it (give `file`, the path of the image on this machine — or `data`; `img: \"\"` removes it); persona = re-cast a work map canvas (a board whose name starts with “Work map —” / “Cartographie —”) or a Second Brain Blueprint (“Second brain —”) from an interview — no target; tags = set the card's colour labels; assign = who the task is for; priority = 1 (highest) to 6; due = the due day; attr = set a “Name: value” attribute on the node (an estimate, a budget…); comment = add a signed comment to the card's thread (give `text`); est = set the time ESTIMATE of the task (give `est`, e.g. \"2h\", \"45\" = 45 minutes, \"\" removes it)" },
|
|
805
1274
|
target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\" if ambiguous (not used by persona)" },
|
|
806
1275
|
persona:{ type:"object", description:"persona: the interview object exactly as the skill returns it — who, role, mission, tools, meetings, opener, modules[{name, procs[{name,h,after,freq,rep,data,stakes,lever,step,why}]}], trajectory, signs, parking. TabTree rebuilds the canvas AND its companion sheet from it." },
|
|
807
1276
|
markdown:{ type:"string", description:"add: the new branch as a bullet list, 2 spaces per level" },
|
|
808
|
-
text:{ type:"string", description:"rename: the new text" },
|
|
1277
|
+
text:{ type:"string", description:"rename: the new text. comment: the comment body (plain text; http/mailto links become clickable)" },
|
|
809
1278
|
note:{ type:"string", description:"note: the note body (empty string clears it)" },
|
|
810
1279
|
value:{ type:"boolean", description:"check: true to tick, false to untick" },
|
|
811
|
-
col:{ type:"string", description:"move: the kanban column id (read_map shows them), or \"\" to take the card off the board" },
|
|
812
|
-
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." }
|
|
1280
|
+
col:{ type:"string", description:"move: the kanban column id (read_map shows them), or \"\" to take the card off the board. add: optional — the column the NEW cards go to" },
|
|
1281
|
+
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." },
|
|
1282
|
+
file:{ type:"string", description:"img: full path of the screenshot (PNG/JPEG/WebP/GIF) on this machine. It is re-encoded to about 840 px and becomes the node's cover picture." },
|
|
1283
|
+
data:{ type:"string", description:"img: a data:image/…;base64 URL instead of `file`" },
|
|
1284
|
+
img:{ type:"string", description:"img: pass \"\" to REMOVE the screenshot from that node (otherwise give `file` or `data`)" },
|
|
1285
|
+
tags:{ type:"array", items:{ type:"string" }, description:"tags: the colour labels to set, from: green, yellow, orange, red, purple, blue, sky, lime, pink, gray — read_map shows the name the board gave each colour. [] clears them." },
|
|
1286
|
+
assign:{ type:"string", description:"assign: a name or e-mail (\"\" clears it). add: optional — the owner of the NEW cards. When read_map shows a 👥 Team, pick from it, by role, what each person owns and their objectives" },
|
|
1287
|
+
why:{ type:"string", description:"Any change: optional — one short line saying WHY (e.g. \"Owns the website\"). Shown to the user next to the change, so they can judge it" },
|
|
1288
|
+
priority:{ type:"integer", description:"priority: 1 (highest) to 6, or 0 to remove it" },
|
|
1289
|
+
due:{ type:"string", description:"due: the due day as YYYY-MM-DD (\"\" removes it)" },
|
|
1290
|
+
name:{ type:"string", description:"attr: the attribute's name, e.g. \"Estimate\"" },
|
|
1291
|
+
value:{ type:"string", description:"attr: its value, e.g. \"2h\" (\"\" removes the attribute)" },
|
|
1292
|
+
est:{ type:"string", description:"est: the estimate as a duration — \"2h\", \"1h30\", \"45\" (minutes), \"2:15\" — or \"\" to remove it" }
|
|
813
1293
|
}, required:["op"], additionalProperties:false } }
|
|
814
1294
|
}, required:["file","changes"], additionalProperties:false }
|
|
815
1295
|
}
|
|
@@ -820,7 +1300,7 @@ const HANDLERS = {
|
|
|
820
1300
|
const maps = await loadLibrary();
|
|
821
1301
|
if(!maps.length) return CLOUD ? "This TabTree account has no maps yet." : "The library is empty (or TABTREE_DIR points at the wrong folder: " + DIR + ").";
|
|
822
1302
|
return maps.map(m=>
|
|
823
|
-
`• ${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}`
|
|
1303
|
+
`• ${m.name} [${m.kind}]${m.folder ? " 📁 " + m.folder : ""}${m.tabOf ? " ↳ tab of “" + m.tabOf + "”" : ""}\n file: ${m.file}\n ${m.kind==="board" ? m.elements + " elements" : m.nodes + " nodes"} · modified ${m.mtime}`
|
|
824
1304
|
).join("\n");
|
|
825
1305
|
},
|
|
826
1306
|
async read_map(args){
|
|
@@ -829,7 +1309,7 @@ const HANDLERS = {
|
|
|
829
1309
|
if(m.kind === "board") return head + boardToText(m.doc);
|
|
830
1310
|
// Le rappel sur propose_changes vit ici et pas dans la description de l'outil : c'est au
|
|
831
1311
|
// moment où le plan est sous les yeux qu'il est utile, et il porte la règle de désignation.
|
|
832
|
-
return head + nodeToMarkdown(m.doc.root, 0)
|
|
1312
|
+
return head + nodeToMarkdown(m.doc.root, 0) + teamToText(m.doc)
|
|
833
1313
|
+ "\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.)";
|
|
834
1314
|
},
|
|
835
1315
|
async search_maps(args){
|
|
@@ -862,11 +1342,15 @@ const HANDLERS = {
|
|
|
862
1342
|
// comparé caractère par caractère avec celui de l'app, et lui ajouter une syntaxe le ferait
|
|
863
1343
|
// diverger en silence. Même partage des rôles que côté modèles.
|
|
864
1344
|
tplPicAssign(root);
|
|
1345
|
+
// Les captures d'écran APRÈS les marqueurs : une désignation se retire du texte avant que le
|
|
1346
|
+
// texte serve de cible, et une capture remplace l'illustration du même nœud.
|
|
1347
|
+
const shots = applyImages(root, args.images);
|
|
865
1348
|
const doc = { v:1, root, images:[], stickies:[] };
|
|
866
1349
|
const st = okStyleMcp(args.style);
|
|
867
1350
|
if(st) doc.style = st;
|
|
868
1351
|
const file = await writeDoc(args.name || root.text, doc, args.folder);
|
|
869
|
-
|
|
1352
|
+
const shotLines = shots.length ? `\n📷 ${shots.length} screenshot(s) on the map:\n` + shots.map(x=>" • " + x).join("\n") + "\n" : "";
|
|
1353
|
+
return `✅ Map created (${countNodes(root)} nodes): ${file}\n${shotLines}${importHint()}`;
|
|
870
1354
|
},
|
|
871
1355
|
async create_board(args){
|
|
872
1356
|
const { doc, warnings } = buildBoardDoc(args.name || "Board", args.elements, args.connections, args.style);
|
|
@@ -889,11 +1373,11 @@ const HANDLERS = {
|
|
|
889
1373
|
if(m.kind === "board" && !allPersona)
|
|
890
1374
|
throw new Error("Proposals only apply to mind maps. A board has free positions and drawings, which a change list cannot describe.");
|
|
891
1375
|
if(allPersona && m.kind !== "board")
|
|
892
|
-
throw new Error("A persona re-casts a WORK MAP canvas, which is a board. “" + m.name + "” is a mind map — pick the person's
|
|
1376
|
+
throw new Error("A persona re-casts a WORK MAP or a SECOND BRAIN BLUEPRINT canvas, which is a board. “" + m.name + "” is a mind map — pick the person's canvas from list_maps (its name starts with “Work map —”, “Cartographie —” or “Second brain —”).");
|
|
893
1377
|
const ops = buildProposalOps(m.doc, args.changes);
|
|
894
1378
|
const file = await writeProposal(m, args.note, ops);
|
|
895
1379
|
if(allPersona){
|
|
896
|
-
return `📮 Interview filed for “${m.name}” — ${ops[0].procs
|
|
1380
|
+
return `📮 Interview filed for “${m.name}” — ${ops[0].domains ? ops[0].domains + " domains" : ops[0].procs + " processes"}: ${file}\n`
|
|
897
1381
|
+ "NOTHING has been changed yet. The canvas file is untouched.\n"
|
|
898
1382
|
+ (CLOUD ? "TabTree shows a banner on that work map within a minute (app open, signed in): " : "TabTree shows a banner on that work map within a few seconds (backup folder connected, app open): ") + "“Your Claude interview is ready — re-cast this work map?”. "
|
|
899
1383
|
+ "One click rebuilds the canvas AND its companion sheet from these hours; Cmd+Z undoes it.\n"
|
|
@@ -901,10 +1385,13 @@ const HANDLERS = {
|
|
|
901
1385
|
}
|
|
902
1386
|
const adds = ops.reduce((n,o)=>n + (o.adds||0), 0);
|
|
903
1387
|
const drops = ops.reduce((n,o)=>n + (o.drops||0), 0);
|
|
1388
|
+
const shots = ops.filter(o=>o.op === "img" && o.img);
|
|
904
1389
|
return `📮 Proposal filed for “${m.name}” — ${ops.length} change(s)`
|
|
905
1390
|
+ (adds ? `, ${adds} node(s) to add` : "")
|
|
906
1391
|
+ (drops ? `, ${drops} node(s) to remove` : "")
|
|
1392
|
+
+ (shots.length ? `, ${shots.length} screenshot(s)` : "")
|
|
907
1393
|
+ `: ${file}\n`
|
|
1394
|
+
+ (shots.length ? shots.map(o=>" 📷 “" + String(o.was || o.target).slice(0, 60) + "” ← " + o.shot).join("\n") + "\n" : "")
|
|
908
1395
|
+ "NOTHING has been changed yet. The map file is untouched.\n"
|
|
909
1396
|
+ (CLOUD ? "TabTree shows a banner on that map within a minute (the app open and signed in). " : "TabTree shows a banner on that map within a few seconds (the backup folder must be connected, and the app open). ")
|
|
910
1397
|
+ "The user ticks the changes they want, applies them, and Cmd+Z undoes the lot.\n"
|
|
@@ -930,7 +1417,7 @@ function handle(msg){
|
|
|
930
1417
|
// déjà dérivé (1.0.0 ici, 1.1.0 dans le manifeste) sans que rien ne le
|
|
931
1418
|
// signale : un client affiche l'une, le registre publie l'autre. Un
|
|
932
1419
|
// autotest les compare désormais toutes les quatre.
|
|
933
|
-
serverInfo: { name: "tabtree", version: "1.
|
|
1420
|
+
serverInfo: { name: "tabtree", version: "1.4.0" }
|
|
934
1421
|
});
|
|
935
1422
|
} else if(method === "notifications/initialized" || (method||"").startsWith("notifications/")){
|
|
936
1423
|
// notification : pas de réponse
|