tabtree-mcp 1.2.1 → 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 +1 -1
- package/tabtree-mcp.mjs +499 -29
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tabtree-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"mcpName": "app.tabtree/tabtree",
|
|
5
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": {
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -157,7 +160,11 @@ const ART_IDS_MCP = [
|
|
|
157
160
|
"house", "pin", "plane", "coffee", "plant", "sun",
|
|
158
161
|
"camera", "music", "lock", "key", "health", "gift",
|
|
159
162
|
"manager", "client", "expert", "assistant", "coach", "learner",
|
|
160
|
-
"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"
|
|
161
168
|
];
|
|
162
169
|
const ART = Object.fromEntries(ART_IDS_MCP.map(id=>[id, true]));
|
|
163
170
|
const EMO_PIC_PAT = "\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?)*";
|
|
@@ -185,6 +192,276 @@ function tplPicAssign(n){
|
|
|
185
192
|
})(n);
|
|
186
193
|
}
|
|
187
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
|
+
|
|
188
465
|
// ---------------------------------------------------------------------------
|
|
189
466
|
// Désignation d'un nœud (partagé mot pour mot avec l'app — voir tabtree-mcp.test.mjs)
|
|
190
467
|
//
|
|
@@ -226,14 +503,31 @@ function propFind(root, spec){
|
|
|
226
503
|
// ---------------------------------------------------------------------------
|
|
227
504
|
// Arbre → markdown (lecture d'une carte)
|
|
228
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"); }
|
|
229
512
|
function nodeToMarkdown(n, depth){
|
|
230
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);
|
|
231
516
|
(function walk(node, d){
|
|
232
517
|
let bullet;
|
|
233
518
|
if(d===0) bullet = "# ";
|
|
234
519
|
else bullet = " ".repeat(d-1) + (node.check!=null ? (node.check ? "- [x] " : "- [ ] ") : "- ");
|
|
235
520
|
const body = node.link ? `[${node.text}](${node.link})` : node.text;
|
|
236
|
-
|
|
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(" ") : ""));
|
|
237
531
|
(node.children||[]).forEach(c=>walk(c, d+1));
|
|
238
532
|
})(n, depth||0);
|
|
239
533
|
return lines.join("\n");
|
|
@@ -454,8 +748,17 @@ function readManifest(){
|
|
|
454
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; }
|
|
455
749
|
return out.length ? out.join(" / ") : null;
|
|
456
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);
|
|
457
756
|
const byFile = new Map();
|
|
458
|
-
(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
|
+
});
|
|
459
762
|
return byFile;
|
|
460
763
|
}catch(e){ return new Map(); }
|
|
461
764
|
}
|
|
@@ -486,6 +789,7 @@ function scanLibrary(){
|
|
|
486
789
|
file: rel,
|
|
487
790
|
name: meta.name || ent.name.replace(/\.(tabtree|ygmind|json)$/i, "").replace(/__map[A-Za-z0-9_]+$/, ""),
|
|
488
791
|
folder: meta.folder || folder || null,
|
|
792
|
+
tabOf: meta.tabOf || null, // le classeur dont ce document est un onglet, sinon null
|
|
489
793
|
kind: doc.board ? "board" : "map",
|
|
490
794
|
nodes: countNodes(doc.root),
|
|
491
795
|
elements: doc.board ? ((doc.shapes||[]).length + (doc.stickies||[]).length + (doc.arrows||[]).length + (doc.draws||[]).length) : undefined,
|
|
@@ -575,17 +879,50 @@ async function findMap(file){
|
|
|
575
879
|
// ---------------------------------------------------------------------------
|
|
576
880
|
async function writeDoc(name, doc, folder){
|
|
577
881
|
if(CLOUD){
|
|
578
|
-
// Le dossier est un NOM de dossier de bibliothèque : on le résout en clé s'il
|
|
579
|
-
// sinon la carte arrive à la racine et le texte de réponse le dit
|
|
580
|
-
|
|
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;
|
|
581
894
|
if(folder){
|
|
582
895
|
const j = await cloudCall("list", {});
|
|
583
|
-
const
|
|
584
|
-
|
|
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
|
+
}
|
|
585
911
|
}
|
|
586
912
|
const kind = doc.board ? "board" : (doc.kanban ? "kanban" : "map");
|
|
587
913
|
const r = await cloudCall("create", { name: String(name||"").slice(0,200), kind, doc, folder_key });
|
|
588
|
-
|
|
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;
|
|
589
926
|
}
|
|
590
927
|
dirOk();
|
|
591
928
|
let target = DIR;
|
|
@@ -616,7 +953,9 @@ function countForest(roots){ return roots.reduce((n,r)=>n + countNodes(r), 0); }
|
|
|
616
953
|
// connecteur la refuserait à l'écriture, ou l'app la jetterait à la lecture — dans les deux
|
|
617
954
|
// cas Claude croirait avoir proposé quelque chose qui n'arrive jamais chez l'utilisateur.
|
|
618
955
|
// C'est le défaut du losange, rejoué. Un test compare les deux listes en ensembles.
|
|
619
|
-
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"];
|
|
620
959
|
|
|
621
960
|
// Tout est vérifié ICI, contre la carte réelle, et un défaut fait ÉCHOUER l'appel. C'est
|
|
622
961
|
// délibéré : une cible introuvable remonte à Claude, qui peut relire la carte et corriger,
|
|
@@ -641,11 +980,18 @@ function buildProposalOps(mapDoc, changes){
|
|
|
641
980
|
// profondeur — « une proposition à moitié valide arrive en lignes barrées » — ne
|
|
642
981
|
// s'applique pas : une op persona est ATOMIQUE, elle rebâtit la toile ou ne fait rien.
|
|
643
982
|
const P = c.persona;
|
|
644
|
-
|
|
645
|
-
|
|
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
|
+
}
|
|
646
993
|
const n = P.modules.reduce((a, m)=>a + ((m && Array.isArray(m.procs)) ? m.procs.length : 0), 0);
|
|
647
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.`);
|
|
648
|
-
if(JSON.stringify(P).length > 60000) throw new Error(`${where}: the persona is too large (60000 characters max).`);
|
|
649
995
|
return { op, persona: P, procs: n };
|
|
650
996
|
}
|
|
651
997
|
const target = String(c.target == null ? "" : c.target);
|
|
@@ -654,6 +1000,14 @@ function buildProposalOps(mapDoc, changes){
|
|
|
654
1000
|
if(r.error === "missing") throw new Error(`${where}: no node "${target}" in this map. Run read_map first and copy the wording exactly.`);
|
|
655
1001
|
if(r.error === "ambiguous") throw new Error(`${where}: "${target}" matches ${r.count} nodes. Name an ancestor too, e.g. "Parent > ${parts_last(target)}".`);
|
|
656
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
|
+
}
|
|
657
1011
|
if(op === "add"){
|
|
658
1012
|
if(typeof c.markdown !== "string" || !c.markdown.trim()) throw new Error(`${where}: \`markdown\` is required for an "add".`);
|
|
659
1013
|
if(c.markdown.length > 20000) throw new Error(`${where}: \`markdown\` is too long (20000 characters max).`);
|
|
@@ -661,6 +1015,22 @@ function buildProposalOps(mapDoc, changes){
|
|
|
661
1015
|
if(!forest.length) throw new Error(`${where}: \`markdown\` has no usable structure (a bullet list, indented by 2 spaces).`);
|
|
662
1016
|
out.markdown = c.markdown;
|
|
663
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
|
+
}
|
|
664
1034
|
} else if(op === "rename"){
|
|
665
1035
|
if(typeof c.text !== "string" || !c.text.trim()) throw new Error(`${where}: \`text\` is required for a "rename".`);
|
|
666
1036
|
out.text = c.text.trim().slice(0, 2000);
|
|
@@ -683,6 +1053,15 @@ function buildProposalOps(mapDoc, changes){
|
|
|
683
1053
|
+ `or "a:" followed by one of: ${ART_IDS_MCP.join(", ")} — or "" to remove it.`);
|
|
684
1054
|
}
|
|
685
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
|
+
}
|
|
686
1065
|
} else if(op === "move"){
|
|
687
1066
|
// ⚠️ Dans le FICHIER les colonnes sont `{cols:[…]}` ; en mémoire, côté app, c'est un
|
|
688
1067
|
// tableau nu. Confondre les deux donne un contrôle qui ne regarde rien.
|
|
@@ -696,17 +1075,85 @@ function buildProposalOps(mapDoc, changes){
|
|
|
696
1075
|
throw new Error(`${where}: no column "${col}" on this board. Use one of: ${noms} — or "" to take the card off the board.`);
|
|
697
1076
|
}
|
|
698
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;
|
|
699
1117
|
}
|
|
700
1118
|
return out;
|
|
701
1119
|
});
|
|
702
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})$/;
|
|
703
1143
|
function parts_last(spec){ const p = String(spec||"").split(">"); return p[p.length-1].trim(); }
|
|
704
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;
|
|
705
1149
|
async function writeProposal(map, note, ops){
|
|
706
1150
|
const payload = (id)=>({
|
|
707
1151
|
v: 1, kind: "tabtree-proposal", mapId: id, mapFile: CLOUD ? map.file : basename(map.file), mapName: map.name,
|
|
708
1152
|
createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops
|
|
709
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.");
|
|
710
1157
|
if(CLOUD){
|
|
711
1158
|
// La boîte aux lettres cloud : la table `proposals`, que l'app relit au sondage suivant.
|
|
712
1159
|
// Même charge utile que le fichier — c'est le même `parseProposal` qui la lit côté app.
|
|
@@ -763,11 +1210,16 @@ const TOOLS = [
|
|
|
763
1210
|
},
|
|
764
1211
|
{
|
|
765
1212
|
name: "create_mindmap",
|
|
766
|
-
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.",
|
|
767
1214
|
inputSchema: { type:"object", properties:{
|
|
768
1215
|
name:{ type:"string", description:"Name of the map" },
|
|
769
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." },
|
|
770
|
-
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 } },
|
|
771
1223
|
style:{ type:"object", description:"Optional look of the document. Omit it for the default.", properties:{
|
|
772
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)." },
|
|
773
1225
|
shape:{ type:"string", enum:["","line","plain"], description:"Node shape: cards (default), line = underlined, plain = plain text." },
|
|
@@ -799,7 +1251,7 @@ const TOOLS = [
|
|
|
799
1251
|
connections:{ type:"array", description:"Arrows anchored between elements (by id)", items:{ type:"object", properties:{
|
|
800
1252
|
from:{ type:"string" }, to:{ type:"string" }
|
|
801
1253
|
}, required:["from","to"], additionalProperties:false } },
|
|
802
|
-
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." },
|
|
803
1255
|
style:{ type:"object", description:"Optional look of the document. Omit it for the default.", properties:{
|
|
804
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)." },
|
|
805
1257
|
shape:{ type:"string", enum:["","line","plain"], description:"Node shape: cards (default), line = underlined, plain = plain text." },
|
|
@@ -813,20 +1265,31 @@ const TOOLS = [
|
|
|
813
1265
|
},
|
|
814
1266
|
{
|
|
815
1267
|
name: "propose_changes",
|
|
816
|
-
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.",
|
|
817
1269
|
inputSchema: { type:"object", properties:{
|
|
818
1270
|
file:{ type:"string", description:"The map to change — a file name (or map name) from list_maps" },
|
|
819
1271
|
note:{ type:"string", description:"One line telling the user what this proposal does and why. Shown above the changes." },
|
|
820
1272
|
changes:{ type:"array", description:"The proposed changes, applied in order", items:{ type:"object", properties:{
|
|
821
|
-
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)" },
|
|
822
1274
|
target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\" if ambiguous (not used by persona)" },
|
|
823
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." },
|
|
824
1276
|
markdown:{ type:"string", description:"add: the new branch as a bullet list, 2 spaces per level" },
|
|
825
|
-
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)" },
|
|
826
1278
|
note:{ type:"string", description:"note: the note body (empty string clears it)" },
|
|
827
1279
|
value:{ type:"boolean", description:"check: true to tick, false to untick" },
|
|
828
|
-
col:{ type:"string", description:"move: the kanban column id (read_map shows them), or \"\" to take the card off the board" },
|
|
829
|
-
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" }
|
|
830
1293
|
}, required:["op"], additionalProperties:false } }
|
|
831
1294
|
}, required:["file","changes"], additionalProperties:false }
|
|
832
1295
|
}
|
|
@@ -837,7 +1300,7 @@ const HANDLERS = {
|
|
|
837
1300
|
const maps = await loadLibrary();
|
|
838
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 + ").";
|
|
839
1302
|
return maps.map(m=>
|
|
840
|
-
`• ${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}`
|
|
841
1304
|
).join("\n");
|
|
842
1305
|
},
|
|
843
1306
|
async read_map(args){
|
|
@@ -846,7 +1309,7 @@ const HANDLERS = {
|
|
|
846
1309
|
if(m.kind === "board") return head + boardToText(m.doc);
|
|
847
1310
|
// Le rappel sur propose_changes vit ici et pas dans la description de l'outil : c'est au
|
|
848
1311
|
// moment où le plan est sous les yeux qu'il est utile, et il porte la règle de désignation.
|
|
849
|
-
return head + nodeToMarkdown(m.doc.root, 0)
|
|
1312
|
+
return head + nodeToMarkdown(m.doc.root, 0) + teamToText(m.doc)
|
|
850
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.)";
|
|
851
1314
|
},
|
|
852
1315
|
async search_maps(args){
|
|
@@ -879,11 +1342,15 @@ const HANDLERS = {
|
|
|
879
1342
|
// comparé caractère par caractère avec celui de l'app, et lui ajouter une syntaxe le ferait
|
|
880
1343
|
// diverger en silence. Même partage des rôles que côté modèles.
|
|
881
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);
|
|
882
1348
|
const doc = { v:1, root, images:[], stickies:[] };
|
|
883
1349
|
const st = okStyleMcp(args.style);
|
|
884
1350
|
if(st) doc.style = st;
|
|
885
1351
|
const file = await writeDoc(args.name || root.text, doc, args.folder);
|
|
886
|
-
|
|
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()}`;
|
|
887
1354
|
},
|
|
888
1355
|
async create_board(args){
|
|
889
1356
|
const { doc, warnings } = buildBoardDoc(args.name || "Board", args.elements, args.connections, args.style);
|
|
@@ -906,11 +1373,11 @@ const HANDLERS = {
|
|
|
906
1373
|
if(m.kind === "board" && !allPersona)
|
|
907
1374
|
throw new Error("Proposals only apply to mind maps. A board has free positions and drawings, which a change list cannot describe.");
|
|
908
1375
|
if(allPersona && m.kind !== "board")
|
|
909
|
-
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 —”).");
|
|
910
1377
|
const ops = buildProposalOps(m.doc, args.changes);
|
|
911
1378
|
const file = await writeProposal(m, args.note, ops);
|
|
912
1379
|
if(allPersona){
|
|
913
|
-
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`
|
|
914
1381
|
+ "NOTHING has been changed yet. The canvas file is untouched.\n"
|
|
915
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?”. "
|
|
916
1383
|
+ "One click rebuilds the canvas AND its companion sheet from these hours; Cmd+Z undoes it.\n"
|
|
@@ -918,10 +1385,13 @@ const HANDLERS = {
|
|
|
918
1385
|
}
|
|
919
1386
|
const adds = ops.reduce((n,o)=>n + (o.adds||0), 0);
|
|
920
1387
|
const drops = ops.reduce((n,o)=>n + (o.drops||0), 0);
|
|
1388
|
+
const shots = ops.filter(o=>o.op === "img" && o.img);
|
|
921
1389
|
return `📮 Proposal filed for “${m.name}” — ${ops.length} change(s)`
|
|
922
1390
|
+ (adds ? `, ${adds} node(s) to add` : "")
|
|
923
1391
|
+ (drops ? `, ${drops} node(s) to remove` : "")
|
|
1392
|
+
+ (shots.length ? `, ${shots.length} screenshot(s)` : "")
|
|
924
1393
|
+ `: ${file}\n`
|
|
1394
|
+
+ (shots.length ? shots.map(o=>" 📷 “" + String(o.was || o.target).slice(0, 60) + "” ← " + o.shot).join("\n") + "\n" : "")
|
|
925
1395
|
+ "NOTHING has been changed yet. The map file is untouched.\n"
|
|
926
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). ")
|
|
927
1397
|
+ "The user ticks the changes they want, applies them, and Cmd+Z undoes the lot.\n"
|
|
@@ -947,7 +1417,7 @@ function handle(msg){
|
|
|
947
1417
|
// déjà dérivé (1.0.0 ici, 1.1.0 dans le manifeste) sans que rien ne le
|
|
948
1418
|
// signale : un client affiche l'une, le registre publie l'autre. Un
|
|
949
1419
|
// autotest les compare désormais toutes les quatre.
|
|
950
|
-
serverInfo: { name: "tabtree", version: "1.
|
|
1420
|
+
serverInfo: { name: "tabtree", version: "1.4.0" }
|
|
951
1421
|
});
|
|
952
1422
|
} else if(method === "notifications/initialized" || (method||"").startsWith("notifications/")){
|
|
953
1423
|
// notification : pas de réponse
|