tabtree-mcp 1.2.1 → 1.5.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 +100 -203
- package/package.json +4 -4
- package/tabtree-mcp.mjs +966 -98
package/tabtree-mcp.mjs
CHANGED
|
@@ -7,21 +7,33 @@
|
|
|
7
7
|
// sans passer par l'interface — génération depuis une conversation,
|
|
8
8
|
// un document, un plan…
|
|
9
9
|
//
|
|
10
|
-
// Où : le
|
|
11
|
-
// (
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// (« Bring maps back from this folder… »), avec dédoublonnage et sans écrasement.
|
|
10
|
+
// Où (1.5.0, 2026-10-20) : le COMPTE TabTree. TabTree est 100 % en ligne depuis le 2026-10-20
|
|
11
|
+
// (Zemind seule source, la licence Classic arrêtée) : la source normale est la clé personnelle
|
|
12
|
+
// `TABTREE_API_KEY` et l'edge function `mcp` (supabase/functions/mcp), qui voit la bibliothèque
|
|
13
|
+
// du compte ET les espaces d'équipe dont il est membre (migration 0033).
|
|
15
14
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// Le DOSSIER 🛟 (TABTREE_DIR, ancien nom YGMIND_DIR) reste lu par compatibilité : les acheteurs
|
|
16
|
+
// Classic ont des connecteurs installés qui le pointent, et la fin de Classic n'est pas encore
|
|
17
|
+
// annoncée côté ventes. Il n'est plus documenté que comme ancien mode — ne pas y ajouter de
|
|
18
|
+
// fonction : tout ce qui est nouveau (espaces, application sans clic) n'existe qu'en compte.
|
|
19
|
+
//
|
|
20
|
+
// Ce fichier vit dans le dépôt Zemind depuis la 1.5.0 (mcp/) — il était dans le moteur figé, et
|
|
21
|
+
// son test de dérive comparait au moteur, plus à ce qui est en ligne. Il compare app/index.html.
|
|
18
22
|
// Protocole : JSON-RPC 2.0, un message JSON par ligne sur stdin/stdout.
|
|
19
23
|
// ============================================================================
|
|
20
24
|
|
|
21
|
-
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync } from "node:fs";
|
|
22
|
-
import { join, basename } from "node:path";
|
|
25
|
+
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
26
|
+
import { join, basename, resolve } from "node:path";
|
|
27
|
+
import { execFileSync } from "node:child_process";
|
|
28
|
+
import { inflateSync, deflateSync } from "node:zlib";
|
|
29
|
+
import { tmpdir, platform } from "node:os";
|
|
23
30
|
|
|
24
|
-
|
|
31
|
+
// ⚠️ LE GABARIT NON RÉSOLU vaut « vide », pour le dossier comme pour la clé (2026-10-20). Claude
|
|
32
|
+
// Desktop passe `${user_config.backup_folder}` TEL QUEL quand le champ (ancien, Classic) est vide :
|
|
33
|
+
// pris pour un chemin, il produisait « The TABTREE_DIR folder does not exist: ${user_config…} » —
|
|
34
|
+
// un message sur un dossier que l'utilisateur n'a jamais choisi, qui cachait la vraie cause (la clé).
|
|
35
|
+
const DIR_RAW = process.env.TABTREE_DIR || process.env.YGMIND_DIR || "";
|
|
36
|
+
const DIR = /^\$\{.*\}$/.test(String(DIR_RAW).trim()) ? "" : DIR_RAW;
|
|
25
37
|
// ---------------------------------------------------------------------------
|
|
26
38
|
// DEUX SOURCES, UN CONNECTEUR (2026-09-08). Le dossier 🛟 sert TabTree Classic (le fichier
|
|
27
39
|
// local) ; le CLOUD sert l'abonné TabTree SaaS. La source est choisie par la présence d'une
|
|
@@ -36,7 +48,11 @@ let API_KEY = String(process.env.TABTREE_API_KEY || "").trim();
|
|
|
36
48
|
// Trousseau et liée à l'installation précédente). `CLOUD = !!API_KEY` prenait ce littéral pour
|
|
37
49
|
// une clé, l'envoyait, et le serveur répondait « Unknown or revoked connector key » : un
|
|
38
50
|
// message FAUX, qui envoie chercher une clé révoquée là où il n'y a simplement rien de saisi.
|
|
39
|
-
|
|
51
|
+
// Mesuré de nouveau le 2026-10-20 (mise à jour 1.3.0 → 1.5.0 par la page /connector) : la clé
|
|
52
|
+
// était bien enregistrée — chiffrée, liée à l'installation précédente — et c'est le gabarit qui
|
|
53
|
+
// est arrivé. On s'en souvient pour DIRE la cause au lieu de « aucune source ».
|
|
54
|
+
const KEY_UNSENT = /^\$\{.*\}$/.test(API_KEY);
|
|
55
|
+
if(KEY_UNSENT) API_KEY = "";
|
|
40
56
|
const API_URL = String(process.env.TABTREE_API_URL || "https://aynmiptyvisxoslzkzxn.supabase.co/functions/v1/mcp").trim();
|
|
41
57
|
const CLOUD = !!API_KEY;
|
|
42
58
|
// La FORME d'une clé se vérifie ici, avant tout aller-retour : l'app en fabrique une en
|
|
@@ -54,7 +70,9 @@ async function cloudCall(action, body){
|
|
|
54
70
|
try{
|
|
55
71
|
r = await fetch(API_URL, { method:"POST",
|
|
56
72
|
headers:{ "content-type":"application/json", "x-tabtree-key": API_KEY },
|
|
57
|
-
|
|
73
|
+
// `v: 2` : le contrat 1.5.0 (espaces d'équipe, propriétaire de chaque carte, pagination).
|
|
74
|
+
// Une edge function plus ancienne l'ignore ; une plus récente sert les 1.2–1.4 sans `v` à l'identique.
|
|
75
|
+
body: JSON.stringify(Object.assign({ action, v: 2 }, body || {})) });
|
|
58
76
|
}catch(e){ throw new Error("Could not reach the TabTree cloud (" + (e && e.message || e) + ")."); }
|
|
59
77
|
let j = null; try{ j = await r.json(); }catch(e){}
|
|
60
78
|
if(!r.ok || !j || j.ok === false) throw new Error((j && j.error) || ("The TabTree cloud answered HTTP " + r.status + "."));
|
|
@@ -72,6 +90,7 @@ const PROP_DIR = "Propositions";
|
|
|
72
90
|
// ---------------------------------------------------------------------------
|
|
73
91
|
function dirOk(){
|
|
74
92
|
if(CLOUD) return; // la source est le compte, pas un dossier
|
|
93
|
+
if(!DIR && KEY_UNSENT) throw new Error("Claude Desktop did not pass your TabTree connector key to the connector. This happens after the extension is updated or reinstalled: the key saved in its settings belongs to the previous install. Open Claude Desktop → Settings → Extensions → TabTree, paste your key again (create one in TabTree → Settings → Account → Claude connector if you need to), then quit Claude Desktop completely and reopen it.");
|
|
75
94
|
if(!DIR) throw new Error("The connector has no source. For TabTree (subscription), set TABTREE_API_KEY to the connector key from ⚙️ Settings → Account → Claude connector. For TabTree Classic (the local file), set TABTREE_DIR to your 🛟 backup folder. (The older name YGMIND_DIR still works.)");
|
|
76
95
|
if(!existsSync(DIR)) throw new Error("The TABTREE_DIR folder does not exist: " + DIR);
|
|
77
96
|
}
|
|
@@ -157,9 +176,17 @@ const ART_IDS_MCP = [
|
|
|
157
176
|
"house", "pin", "plane", "coffee", "plant", "sun",
|
|
158
177
|
"camera", "music", "lock", "key", "health", "gift",
|
|
159
178
|
"manager", "client", "expert", "assistant", "coach", "learner",
|
|
160
|
-
"developer", "seller", "support", "speaker", "freelancer", "group"
|
|
179
|
+
"developer", "seller", "support", "speaker", "freelancer", "group",
|
|
180
|
+
"uilogin", "uifeed", "uichat", "uidash", "uichart", "uishop",
|
|
181
|
+
"uimap", "uivideo", "uislide", "uiprofile", "uiboard", "uitable",
|
|
182
|
+
"atom", "dna", "flask", "microscope", "brain", "planet",
|
|
183
|
+
"pencil", "gradcap", "calc", "palette", "ruler", "backpack"
|
|
161
184
|
];
|
|
162
185
|
const ART = Object.fromEntries(ART_IDS_MCP.map(id=>[id, true]));
|
|
186
|
+
// ≡ `artHas` d'app/index.html : seule une propriété PROPRE compte. `ART["toString"]` est une
|
|
187
|
+
// fonction héritée, vraie au test de vérité — « a:toString » passait `okPic` ici alors que l'app
|
|
188
|
+
// le refusait déjà (dérive attrapée le 2026-10-20 en comparant enfin au fichier EN LIGNE).
|
|
189
|
+
const artHas = id => typeof id === "string" && Object.prototype.hasOwnProperty.call(ART, id);
|
|
163
190
|
const EMO_PIC_PAT = "\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?)*";
|
|
164
191
|
const PIC_EMO_RE = (()=>{
|
|
165
192
|
try{ return new RegExp("^(?:" + EMO_PIC_PAT + ")$", "u"); }
|
|
@@ -169,7 +196,7 @@ const ART = Object.fromEntries(ART_IDS_MCP.map(id=>[id, true]));
|
|
|
169
196
|
})();
|
|
170
197
|
function okPic(v){
|
|
171
198
|
if(typeof v !== "string" || !v || v.length > PIC_MAX) return "";
|
|
172
|
-
if(v.startsWith("a:")) return
|
|
199
|
+
if(v.startsWith("a:")) return artHas(v.slice(2)) ? v : "";
|
|
173
200
|
if(v.startsWith("e:")){
|
|
174
201
|
const ch = v.slice(2);
|
|
175
202
|
return (ch && PIC_EMO_RE.test(ch)) ? "e:" + ch : "";
|
|
@@ -185,6 +212,276 @@ function tplPicAssign(n){
|
|
|
185
212
|
})(n);
|
|
186
213
|
}
|
|
187
214
|
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Une CAPTURE D'ÉCRAN sur un nœud (`node.img`, 2026-09-15)
|
|
217
|
+
//
|
|
218
|
+
// Demande de l'utilisateur : « générer une mindmap avec des screenshots tirés d'une app ».
|
|
219
|
+
// Le champ existait de bout en bout — `node.img` est la COUVERTURE d'un nœud, tenue dans les
|
|
220
|
+
// cinq places, peinte sur le canevas, dans l'export et sur le recto kanban. Ce qui manquait,
|
|
221
|
+
// c'est la PORTE : le connecteur ne savait poser qu'une désignation (`pic`), jamais des pixels.
|
|
222
|
+
//
|
|
223
|
+
// Deux contraintes décident de tout ce qui suit :
|
|
224
|
+
// 1. `okCover` (index.html) n'accepte qu'un `data:image/(png|jpeg|webp|gif)` de moins de
|
|
225
|
+
// COVER_MAX_BYTES caractères. Une capture d'écran Retina fait 2 à 5 Mo : embarquée telle
|
|
226
|
+
// quelle, l'app la REFUSE à l'ouverture et le nœud arrive nu — sans une erreur. Il faut donc
|
|
227
|
+
// ré-encoder ICI, avant d'écrire. La constante et la fonction sont portées MOT POUR MOT et le
|
|
228
|
+
// test les compare à l'app : si elles divergent, le connecteur écrit une image que l'app
|
|
229
|
+
// efface, et personne ne sait pourquoi.
|
|
230
|
+
// 2. Le connecteur est ZÉRO dépendance et Node n'a aucun codec d'image. Trois encodeurs, dans
|
|
231
|
+
// l'ordre : `sips` (livré avec macOS), `magick`/`convert` (ImageMagick, s'il est là), et un
|
|
232
|
+
// chemin PUR JS — décodeur PNG (zlib est natif), réduction par moyenne de zone, encodeur PNG.
|
|
233
|
+
// Le chemin JS ne lit que du PNG, ce qui est le format de TOUTES les captures que Claude
|
|
234
|
+
// produit (navigateur, computer-use, simulateur). Quand rien ne tient dans le budget, on
|
|
235
|
+
// REFUSE en nommant la limite — jamais une image tronquée, jamais un nœud silencieusement nu.
|
|
236
|
+
//
|
|
237
|
+
// Le côté long est ré-encodé à IMG_LONG = 840 px, soit 2 × COVER_MAX_DIM : l'app affiche une
|
|
238
|
+
// couverture à 420 px au plus, et 840 la garde nette sur un écran Retina. Au-delà on stockerait
|
|
239
|
+
// des pixels que personne ne voit. Le budget par image (IMG_BUDGET) est bien plus bas que
|
|
240
|
+
// COVER_MAX_BYTES, et c'est délibéré : vingt captures à 900 Ko feraient un document de 18 Mo, lu
|
|
241
|
+
// SYNCHRONIQUEMENT depuis le miroir localStorage de l'app. À 840 px en JPEG, une capture pèse
|
|
242
|
+
// 80 à 200 Ko ; en PNG (chemin JS) 150 à 400 Ko — d'où les trois paliers de repli.
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
const COVER_MAX_BYTES = 900000; // ~900 Ko de data URL : au-delà, on refuse
|
|
245
|
+
function okCover(v){
|
|
246
|
+
return (typeof v === "string" && v.length <= COVER_MAX_BYTES
|
|
247
|
+
&& /^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/.test(v)) ? v : "";
|
|
248
|
+
}
|
|
249
|
+
const IMG_LONG = 840; // côté long visé (2 × COVER_MAX_DIM de l'app)
|
|
250
|
+
const IMG_STEPS = [840, 600, 420]; // paliers de repli quand le budget ne tient pas
|
|
251
|
+
const IMG_BUDGET = 320000; // data URL par image — bien sous COVER_MAX_BYTES, voir ci-dessus
|
|
252
|
+
const IMG_FILE_MAX = 40 * 1024 * 1024; // un fichier plus gros n'est pas une capture d'écran
|
|
253
|
+
const IMG_JPEG_Q = 82;
|
|
254
|
+
const IMG_MIMES = { png:"image/png", jpeg:"image/jpeg", webp:"image/webp", gif:"image/gif" };
|
|
255
|
+
|
|
256
|
+
// Sonde les octets, jamais l'extension : un « .png » qui contient du JPEG s'ouvre partout, et
|
|
257
|
+
// c'est l'octet qui décide de ce que l'app saura décoder.
|
|
258
|
+
function imageInfo(buf){
|
|
259
|
+
if(!buf || buf.length < 12) return null;
|
|
260
|
+
if(buf[0] === 0x89 && buf.toString("latin1", 1, 4) === "PNG" && buf.length >= 24)
|
|
261
|
+
return { kind:"png", w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
|
|
262
|
+
if(buf[0] === 0xFF && buf[1] === 0xD8){
|
|
263
|
+
let i = 2;
|
|
264
|
+
while(i + 9 < buf.length){
|
|
265
|
+
if(buf[i] !== 0xFF){ i++; continue; }
|
|
266
|
+
const m = buf[i + 1];
|
|
267
|
+
if(m === 0xFF){ i++; continue; }
|
|
268
|
+
if(m >= 0xC0 && m <= 0xCF && m !== 0xC4 && m !== 0xC8 && m !== 0xCC)
|
|
269
|
+
return { kind:"jpeg", h: buf.readUInt16BE(i + 5), w: buf.readUInt16BE(i + 7) };
|
|
270
|
+
if(m === 0xD8 || (m >= 0xD0 && m <= 0xD7) || m === 0x01){ i += 2; continue; }
|
|
271
|
+
i += 2 + buf.readUInt16BE(i + 2);
|
|
272
|
+
}
|
|
273
|
+
return { kind:"jpeg", w:0, h:0 };
|
|
274
|
+
}
|
|
275
|
+
if(buf.toString("latin1", 0, 4) === "GIF8")
|
|
276
|
+
return { kind:"gif", w: buf.readUInt16LE(6), h: buf.readUInt16LE(8) };
|
|
277
|
+
if(buf.toString("latin1", 0, 4) === "RIFF" && buf.toString("latin1", 8, 12) === "WEBP" && buf.length >= 30){
|
|
278
|
+
const c = buf.toString("latin1", 12, 16);
|
|
279
|
+
if(c === "VP8 ") return { kind:"webp", w: buf.readUInt16LE(26) & 0x3fff, h: buf.readUInt16LE(28) & 0x3fff };
|
|
280
|
+
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)) }; }
|
|
281
|
+
if(c === "VP8X") return { kind:"webp", w: 1 + buf.readUIntLE(24, 3), h: 1 + buf.readUIntLE(27, 3) };
|
|
282
|
+
return { kind:"webp", w:0, h:0 };
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
function dataUrlOf(buf, kind){ return "data:" + IMG_MIMES[kind] + ";base64," + buf.toString("base64"); }
|
|
287
|
+
|
|
288
|
+
// --- Le chemin PUR JS : PNG → RGB, réduction, → PNG ------------------------------------
|
|
289
|
+
// 8 bits, non entrelacé, types 0/2/3/4/6 : c'est ce qu'écrivent Chrome, macOS et Windows pour
|
|
290
|
+
// une capture. Le reste (16 bits, entrelacé) est refusé en le disant. L'alpha est composité
|
|
291
|
+
// sur du BLANC — même règle qu'`avatarFromFile` dans l'app : une capture transparente sortirait
|
|
292
|
+
// noire dans un JPEG, et grise dans l'export.
|
|
293
|
+
function pngDecode(buf){
|
|
294
|
+
let i = 8, w = 0, h = 0, depth = 0, ct = 0, inter = 0, plte = null;
|
|
295
|
+
const idat = [];
|
|
296
|
+
while(i + 8 <= buf.length){
|
|
297
|
+
const len = buf.readUInt32BE(i), type = buf.toString("latin1", i + 4, i + 8);
|
|
298
|
+
const data = buf.subarray(i + 8, i + 8 + len);
|
|
299
|
+
if(type === "IHDR"){ w = data.readUInt32BE(0); h = data.readUInt32BE(4); depth = data[8]; ct = data[9]; inter = data[12]; }
|
|
300
|
+
else if(type === "PLTE") plte = data;
|
|
301
|
+
else if(type === "IDAT") idat.push(data);
|
|
302
|
+
else if(type === "IEND") break;
|
|
303
|
+
i += 12 + len;
|
|
304
|
+
}
|
|
305
|
+
if(!w || !h) throw new Error("the PNG has no IHDR");
|
|
306
|
+
if(depth !== 8) throw new Error("a " + depth + "-bit PNG — only 8-bit PNGs can be re-encoded here");
|
|
307
|
+
if(inter) throw new Error("an interlaced PNG cannot be re-encoded here");
|
|
308
|
+
const ch = { 0:1, 2:3, 3:1, 4:2, 6:4 }[ct];
|
|
309
|
+
if(!ch || (ct === 3 && !plte)) throw new Error("unsupported PNG colour type " + ct);
|
|
310
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
311
|
+
const stride = w * ch, rgb = new Uint8Array(w * h * 3);
|
|
312
|
+
let prev = new Uint8Array(stride), cur = new Uint8Array(stride), p = 0;
|
|
313
|
+
for(let y = 0; y < h; y++){
|
|
314
|
+
const f = raw[p++];
|
|
315
|
+
for(let x = 0; x < stride; x++){
|
|
316
|
+
const a = x >= ch ? cur[x - ch] : 0, b = prev[x], c = x >= ch ? prev[x - ch] : 0;
|
|
317
|
+
let v = raw[p++];
|
|
318
|
+
if(f === 1) v += a; else if(f === 2) v += b; else if(f === 3) v += (a + b) >> 1;
|
|
319
|
+
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); }
|
|
320
|
+
cur[x] = v & 255;
|
|
321
|
+
}
|
|
322
|
+
for(let x = 0; x < w; x++){
|
|
323
|
+
const q = x * ch; let r, g, bl, al = 255;
|
|
324
|
+
if(ct === 0){ r = g = bl = cur[q]; }
|
|
325
|
+
else if(ct === 4){ r = g = bl = cur[q]; al = cur[q + 1]; }
|
|
326
|
+
else if(ct === 2){ r = cur[q]; g = cur[q + 1]; bl = cur[q + 2]; }
|
|
327
|
+
else if(ct === 6){ r = cur[q]; g = cur[q + 1]; bl = cur[q + 2]; al = cur[q + 3]; }
|
|
328
|
+
else { const k = cur[q] * 3; r = plte[k]; g = plte[k + 1]; bl = plte[k + 2]; }
|
|
329
|
+
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; }
|
|
330
|
+
const o = (y * w + x) * 3; rgb[o] = r; rgb[o + 1] = g; rgb[o + 2] = bl;
|
|
331
|
+
}
|
|
332
|
+
const t = prev; prev = cur; cur = t;
|
|
333
|
+
}
|
|
334
|
+
return { w, h, rgb };
|
|
335
|
+
}
|
|
336
|
+
// Moyenne de zone : chaque pixel de sortie est la moyenne du rectangle source qu'il couvre.
|
|
337
|
+
// C'est ce qui garde lisible le texte d'une capture réduite d'un facteur 3 — un simple
|
|
338
|
+
// sous-échantillonnage saute des lignes de texte entières.
|
|
339
|
+
function rgbScale(src, W, H){
|
|
340
|
+
const { w, h, rgb } = src, out = new Uint8Array(W * H * 3);
|
|
341
|
+
for(let Y = 0; Y < H; Y++){
|
|
342
|
+
const y0 = Math.floor(Y * h / H), y1 = Math.max(y0 + 1, Math.floor((Y + 1) * h / H));
|
|
343
|
+
for(let X = 0; X < W; X++){
|
|
344
|
+
const x0 = Math.floor(X * w / W), x1 = Math.max(x0 + 1, Math.floor((X + 1) * w / W));
|
|
345
|
+
let r = 0, g = 0, b = 0, n = 0;
|
|
346
|
+
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++; } }
|
|
347
|
+
const q = (Y * W + X) * 3; out[q] = r / n | 0; out[q + 1] = g / n | 0; out[q + 2] = b / n | 0;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return { w: W, h: H, rgb: out };
|
|
351
|
+
}
|
|
352
|
+
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; })();
|
|
353
|
+
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; }
|
|
354
|
+
function pngEncode(img){
|
|
355
|
+
const { w, h, rgb } = img;
|
|
356
|
+
const raw = Buffer.alloc((w * 3 + 1) * h);
|
|
357
|
+
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); }
|
|
358
|
+
const chunk = (type, data)=>{
|
|
359
|
+
const out = Buffer.alloc(12 + data.length);
|
|
360
|
+
out.writeUInt32BE(data.length, 0); out.write(type, 4, "latin1"); data.copy(out, 8);
|
|
361
|
+
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
|
|
362
|
+
return out;
|
|
363
|
+
};
|
|
364
|
+
const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
|
|
365
|
+
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))]);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// --- Les encodeurs, et l'ordre dans lequel on les essaie ---------------------------------
|
|
369
|
+
// `TABTREE_IMG_ENCODER` force un chemin (js | sips | magick) — c'est ce qui permet au test
|
|
370
|
+
// d'exercer le chemin pur JS sur une machine qui a `sips`, et à qui veut un rendu identique
|
|
371
|
+
// sur toutes ses machines de le demander.
|
|
372
|
+
function encoders(){
|
|
373
|
+
const want = String(process.env.TABTREE_IMG_ENCODER || "").trim();
|
|
374
|
+
if(want) return [want];
|
|
375
|
+
return platform() === "darwin" ? ["sips", "magick", "js"] : ["magick", "js"];
|
|
376
|
+
}
|
|
377
|
+
function withTmpFiles(fn){
|
|
378
|
+
const dir = mkdtempSync(join(tmpdir(), "tabtree-img-"));
|
|
379
|
+
try{ return fn(dir); }
|
|
380
|
+
finally{ try{ rmSync(dir, { recursive:true, force:true }); }catch(e){} }
|
|
381
|
+
}
|
|
382
|
+
// Rend { buf, kind, w, h } ou lève. `long` = côté long visé.
|
|
383
|
+
function encodeWith(name, buf, info, long){
|
|
384
|
+
if(name === "js"){
|
|
385
|
+
if(info.kind !== "png") throw new Error("the built-in encoder only reads PNG");
|
|
386
|
+
const src = pngDecode(buf);
|
|
387
|
+
const k = Math.min(1, long / Math.max(src.w, src.h));
|
|
388
|
+
const W = Math.max(1, Math.round(src.w * k)), H = Math.max(1, Math.round(src.h * k));
|
|
389
|
+
const out = pngEncode(k < 1 ? rgbScale(src, W, H) : src);
|
|
390
|
+
return { buf: out, kind:"png", w: W, h: H };
|
|
391
|
+
}
|
|
392
|
+
return withTmpFiles(dir=>{
|
|
393
|
+
const inp = join(dir, "in." + (info.kind === "jpeg" ? "jpg" : info.kind)), outp = join(dir, "out.jpg");
|
|
394
|
+
writeFileSync(inp, buf);
|
|
395
|
+
if(name === "sips"){
|
|
396
|
+
execFileSync("sips", ["-Z", String(long), "-s", "format", "jpeg", "-s", "formatOptions", String(IMG_JPEG_Q), inp, "--out", outp], { stdio:"ignore", timeout: 20000 });
|
|
397
|
+
} else if(name === "magick"){
|
|
398
|
+
const bin = ["magick", "convert"];
|
|
399
|
+
let ok = false, last = null;
|
|
400
|
+
for(const b of bin){
|
|
401
|
+
try{ execFileSync(b, [inp, "-resize", long + "x" + long + ">", "-quality", String(IMG_JPEG_Q), outp], { stdio:"ignore", timeout: 20000 }); ok = true; break; }
|
|
402
|
+
catch(e){ last = e; }
|
|
403
|
+
}
|
|
404
|
+
if(!ok) throw last || new Error("ImageMagick is not installed");
|
|
405
|
+
} else throw new Error("unknown encoder " + name);
|
|
406
|
+
const out = readFileSync(outp), oi = imageInfo(out);
|
|
407
|
+
if(!oi || oi.kind !== "jpeg") throw new Error(name + " produced no JPEG");
|
|
408
|
+
return { buf: out, kind:"jpeg", w: oi.w, h: oi.h };
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// --- La porte : un fichier ou une data URL → une couverture que l'app acceptera ---------
|
|
413
|
+
// Rend { img, note } — `note` dit ce qui a été fait, pour la réponse : une image ré-encodée
|
|
414
|
+
// à l'insu de Claude serait une capture qu'il croit envoyer en pleine résolution.
|
|
415
|
+
function readImageSpec(spec, where){
|
|
416
|
+
if(!spec || typeof spec !== "object") throw new Error(`${where}: give \`file\` (a path on this machine) or \`data\` (a data:image/… URL).`);
|
|
417
|
+
if(typeof spec.data === "string" && spec.data){
|
|
418
|
+
const m = /^data:image\/(png|jpeg|jpg|webp|gif);base64,([A-Za-z0-9+/=\s]+)$/.exec(spec.data.trim());
|
|
419
|
+
if(!m) throw new Error(`${where}: \`data\` must be a data:image/(png|jpeg|webp|gif);base64,… URL.`);
|
|
420
|
+
return { buf: Buffer.from(m[2].replace(/\s+/g, ""), "base64"), label: "inline image" };
|
|
421
|
+
}
|
|
422
|
+
if(typeof spec.file === "string" && spec.file.trim()){
|
|
423
|
+
const p = resolve(spec.file.trim());
|
|
424
|
+
if(!existsSync(p)) throw new Error(`${where}: no file at ${p}. Save the screenshot to disk first (a PNG), then pass its full path.`);
|
|
425
|
+
const st = statSync(p);
|
|
426
|
+
if(!st.isFile()) throw new Error(`${where}: ${p} is not a file.`);
|
|
427
|
+
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).`);
|
|
428
|
+
return { buf: readFileSync(p), label: basename(p) };
|
|
429
|
+
}
|
|
430
|
+
throw new Error(`${where}: give \`file\` (a path on this machine) or \`data\` (a data:image/… URL).`);
|
|
431
|
+
}
|
|
432
|
+
function coverFrom(spec, where){
|
|
433
|
+
const { buf, label } = readImageSpec(spec, where);
|
|
434
|
+
const info = imageInfo(buf);
|
|
435
|
+
if(!info) throw new Error(`${where}: ${label} is not a PNG, JPEG, WebP or GIF image.`);
|
|
436
|
+
const long = Math.max(info.w, info.h);
|
|
437
|
+
const src = `${info.w}×${info.h} ${info.kind.toUpperCase()}`;
|
|
438
|
+
// Tel quel quand ça tient DÉJÀ — le budget et la taille : ré-encoder une image qui va bien
|
|
439
|
+
// ne ferait que la dégrader.
|
|
440
|
+
if(long && long <= IMG_LONG){
|
|
441
|
+
const d = dataUrlOf(buf, info.kind);
|
|
442
|
+
if(d.length <= IMG_BUDGET && okCover(d)) return { img: d, note: `${label} (${src}, ${Math.round(d.length / 1024)} KB, kept as is)` };
|
|
443
|
+
}
|
|
444
|
+
const tried = [];
|
|
445
|
+
for(const step of IMG_STEPS){
|
|
446
|
+
if(long && step > long && step !== IMG_STEPS[IMG_STEPS.length - 1]) continue; // ne pas « réduire » vers plus grand
|
|
447
|
+
for(const enc of encoders()){
|
|
448
|
+
let out;
|
|
449
|
+
try{ out = encodeWith(enc, buf, info, step); }
|
|
450
|
+
catch(e){ tried.push(enc + "@" + step + ": " + (e && e.message || e).split("\n")[0]); continue; }
|
|
451
|
+
const d = dataUrlOf(out.buf, out.kind);
|
|
452
|
+
if(d.length <= IMG_BUDGET && okCover(d))
|
|
453
|
+
return { img: d, note: `${label} (${src} → ${out.w}×${out.h} ${out.kind.toUpperCase()}, ${Math.round(d.length / 1024)} KB, via ${enc})` };
|
|
454
|
+
tried.push(enc + "@" + step + ": " + Math.round(d.length / 1024) + " KB, over the " + Math.round(IMG_BUDGET / 1024) + " KB budget");
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
throw new Error(`${where}: ${label} (${src}) could not be brought under ${Math.round(IMG_BUDGET / 1024)} KB. `
|
|
458
|
+
+ (info.kind === "png" ? "Crop it to the part that matters, or " : "Save it as a PNG (the built-in encoder only reads PNG), or ")
|
|
459
|
+
+ "install ImageMagick. Tried: " + tried.join("; "));
|
|
460
|
+
}
|
|
461
|
+
// Pose les captures d'une carte NEUVE : chaque entrée désigne un nœud par son texte (ou un
|
|
462
|
+
// chemin « Parent > Nœud »), exactement comme une proposition — une désignation qui touche deux
|
|
463
|
+
// nœuds est refusée, jamais devinée. Une capture REMPLACE une illustration `pic` : deux images
|
|
464
|
+
// sur un nœud seraient deux vérités pour une question, et c'est `deserialize` qui trancherait.
|
|
465
|
+
function applyImages(root, images){
|
|
466
|
+
if(images == null) return [];
|
|
467
|
+
if(!Array.isArray(images)) throw new Error("`images` must be a list of { target, file } (or { target, data }).");
|
|
468
|
+
if(images.length > 60) throw new Error("Too many screenshots in one map (60 max).");
|
|
469
|
+
const notes = [];
|
|
470
|
+
images.forEach((im, i)=>{
|
|
471
|
+
const where = "images[" + i + "]";
|
|
472
|
+
const target = String((im && im.target) == null ? "" : im.target);
|
|
473
|
+
const r = propFind(root, target);
|
|
474
|
+
if(r.error === "empty") throw new Error(`${where}: \`target\` is required — the exact text of the node the screenshot goes on.`);
|
|
475
|
+
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).`);
|
|
476
|
+
if(r.error === "ambiguous") throw new Error(`${where}: "${target}" matches ${r.count} nodes. Name an ancestor too, e.g. "Parent > ${parts_last(target)}".`);
|
|
477
|
+
const { img, note } = coverFrom(im, where);
|
|
478
|
+
r.node.img = img;
|
|
479
|
+
if(r.node.pic) delete r.node.pic;
|
|
480
|
+
notes.push("“" + String(r.node.text || "").slice(0, 60) + "” ← " + note);
|
|
481
|
+
});
|
|
482
|
+
return notes;
|
|
483
|
+
}
|
|
484
|
+
|
|
188
485
|
// ---------------------------------------------------------------------------
|
|
189
486
|
// Désignation d'un nœud (partagé mot pour mot avec l'app — voir tabtree-mcp.test.mjs)
|
|
190
487
|
//
|
|
@@ -226,14 +523,48 @@ function propFind(root, spec){
|
|
|
226
523
|
// ---------------------------------------------------------------------------
|
|
227
524
|
// Arbre → markdown (lecture d'une carte)
|
|
228
525
|
// ---------------------------------------------------------------------------
|
|
229
|
-
|
|
526
|
+
// Les marqueurs ⟪…⟫ disent ce qu'un plan ne montre pas (2026-10-12) : les dépendances (par le
|
|
527
|
+
// TEXTE du nœud attendu, résolu ici — Claude ne lit pas des ids), la récurrence, le nombre de
|
|
528
|
+
// commentaires et l'estimation. Sans eux, Claude proposerait une date avant celle du
|
|
529
|
+
// prédécesseur ou cocherait une tâche qui repart aussitôt, sans rien savoir de ce qu'il fait.
|
|
530
|
+
const RECUR_LABEL = { d:"daily", wd:"weekdays", w:"weekly", "2w":"every 2 weeks", m:"monthly", q:"quarterly", y:"yearly" };
|
|
531
|
+
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"); }
|
|
532
|
+
// `ctx` (1.5.0) : ce que la carte sait du KANBAN et des autres cartes — la colonne de chaque carte,
|
|
533
|
+
// son responsable, ses dates, sa priorité, ses étiquettes, et le NOM de la carte qu'un lien `map:`
|
|
534
|
+
// ouvre (un board attaché à une carte kanban se lit « ⟪opens “Vision” [board]⟫ », pas « map:x7 »).
|
|
535
|
+
function nodeToMarkdown(n, depth, ctx){
|
|
230
536
|
const lines = [];
|
|
537
|
+
const cx = ctx || {};
|
|
538
|
+
const colName = new Map(((cx.cols) || []).map(k => [k.id, k]));
|
|
539
|
+
const doneCol = ((cx.cols) || []).find(k => k.done) || null;
|
|
540
|
+
const byId = new Map();
|
|
541
|
+
(function idx(node){ if(node && node.id) byId.set(node.id, node); (node.children||[]).forEach(idx); })(n);
|
|
231
542
|
(function walk(node, d){
|
|
232
543
|
let bullet;
|
|
233
544
|
if(d===0) bullet = "# ";
|
|
234
545
|
else bullet = " ".repeat(d-1) + (node.check!=null ? (node.check ? "- [x] " : "- [ ] ") : "- ");
|
|
235
|
-
const
|
|
236
|
-
|
|
546
|
+
const mlink = /^map:([A-Za-z0-9_]+)$/.exec(String(node.link || ""));
|
|
547
|
+
const linked = mlink && cx.maps ? cx.maps.get(mlink[1]) : null;
|
|
548
|
+
const body = node.link && !mlink ? `[${node.text}](${node.link})` : node.text;
|
|
549
|
+
const marks = [];
|
|
550
|
+
if(mlink) marks.push(linked ? "⟪opens “" + linked.name + "” [" + linked.kind + "]⟫" : "⟪opens another map (" + mlink[1] + ")⟫");
|
|
551
|
+
if(d > 0 && colName.size){
|
|
552
|
+
if(node.check === true && doneCol && node.col) marks.push("⟪column: " + doneCol.name + "⟫");
|
|
553
|
+
else if(node.col && colName.has(node.col)) marks.push("⟪column: " + colName.get(node.col).name + "⟫");
|
|
554
|
+
}
|
|
555
|
+
if(typeof node.assign === "string" && node.assign.trim()) marks.push("⟪owner: " + node.assign.trim() + "⟫");
|
|
556
|
+
if(node.start || node.due) marks.push("⟪" + (node.start && node.due ? node.start + " → " + node.due : node.due ? "due " + node.due : "starts " + node.start) + "⟫");
|
|
557
|
+
if(Number.isInteger(node.priority) && node.priority > 0) marks.push("⟪P" + node.priority + "⟫");
|
|
558
|
+
if(Array.isArray(node.tags) && node.tags.length) marks.push("⟪labels: " + node.tags.map(t => (cx.tagNames && cx.tagNames[t]) ? t + " “" + cx.tagNames[t] + "”" : t).join(", ") + "⟫");
|
|
559
|
+
if(node.note && node.note.trim()) marks.push("⟪note⟫");
|
|
560
|
+
if(Array.isArray(node.deps) && node.deps.length){
|
|
561
|
+
const names = node.deps.map(id => byId.get(id)).filter(Boolean).map(p => String(p.text || "").trim()).filter(Boolean);
|
|
562
|
+
if(names.length) marks.push("⟪waits on: " + names.join(", ") + "⟫");
|
|
563
|
+
}
|
|
564
|
+
if(node.recur && RECUR_LABEL[node.recur]) marks.push("⟪repeats " + RECUR_LABEL[node.recur] + "⟫");
|
|
565
|
+
if(Array.isArray(node.cmts) && node.cmts.length) marks.push("⟪" + node.cmts.length + " comment" + (node.cmts.length > 1 ? "s" : "") + "⟫");
|
|
566
|
+
if(typeof node.est === "number" && node.est > 0) marks.push("⟪est " + durText(node.est) + "⟫");
|
|
567
|
+
lines.push(bullet + body + (marks.length ? " " + marks.join(" ") : ""));
|
|
237
568
|
(node.children||[]).forEach(c=>walk(c, d+1));
|
|
238
569
|
})(n, depth||0);
|
|
239
570
|
return lines.join("\n");
|
|
@@ -454,8 +785,17 @@ function readManifest(){
|
|
|
454
785
|
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
786
|
return out.length ? out.join(" / ") : null;
|
|
456
787
|
};
|
|
788
|
+
// Un ONGLET (2026-09-15) est un document CACHÉ de la bibliothèque : `sub` porte l'id de son
|
|
789
|
+
// classeur. Sans le lire, Claude verrait « Sheet 2 » comme une carte ordinaire, sans savoir
|
|
790
|
+
// de quel classeur elle est la seconde page — et proposerait de la ranger, ou de la lier.
|
|
791
|
+
const byId = new Map((m.maps||[]).filter(x=>x && x.id).map(x=>[x.id, x]));
|
|
792
|
+
const okId = v => typeof v === "string" && /^[A-Za-z0-9_]{1,64}$/.test(v);
|
|
457
793
|
const byFile = new Map();
|
|
458
|
-
(m.maps||[]).forEach(x=>{
|
|
794
|
+
(m.maps||[]).forEach(x=>{
|
|
795
|
+
if(!x || !x.file) return;
|
|
796
|
+
const book = okId(x.sub) && x.sub !== x.id ? byId.get(x.sub) : null;
|
|
797
|
+
byFile.set(x.file, { name:x.name, folder: pathOf(x.folderId), tabOf: book ? (book.name || book.id) : null });
|
|
798
|
+
});
|
|
459
799
|
return byFile;
|
|
460
800
|
}catch(e){ return new Map(); }
|
|
461
801
|
}
|
|
@@ -486,6 +826,7 @@ function scanLibrary(){
|
|
|
486
826
|
file: rel,
|
|
487
827
|
name: meta.name || ent.name.replace(/\.(tabtree|ygmind|json)$/i, "").replace(/__map[A-Za-z0-9_]+$/, ""),
|
|
488
828
|
folder: meta.folder || folder || null,
|
|
829
|
+
tabOf: meta.tabOf || null, // le classeur dont ce document est un onglet, sinon null
|
|
489
830
|
kind: doc.board ? "board" : "map",
|
|
490
831
|
nodes: countNodes(doc.root),
|
|
491
832
|
elements: doc.board ? ((doc.shapes||[]).length + (doc.stickies||[]).length + (doc.arrows||[]).length + (doc.draws||[]).length) : undefined,
|
|
@@ -499,26 +840,56 @@ function scanLibrary(){
|
|
|
499
840
|
return out;
|
|
500
841
|
}
|
|
501
842
|
// La bibliothèque du COMPTE, présentée exactement comme celle du dossier : mêmes champs, donc
|
|
502
|
-
// les
|
|
503
|
-
//
|
|
843
|
+
// les outils n'ont rien à savoir de la source. `file` est l'identifiant que l'APP donne à la carte
|
|
844
|
+
// (≡ `teamLocalId`) : sa clé pour une carte à moi, « T<10 hexa du propriétaire>_<clé> » pour une
|
|
845
|
+
// carte d'équipe — c'est lui qu'une proposition doit porter, l'app le cherche dans SA bibliothèque.
|
|
846
|
+
//
|
|
847
|
+
// ⚠️ PAGINÉE (1.5.0) : la version précédente lisait 300 documents et s'arrêtait EN SILENCE — la
|
|
848
|
+
// recherche ratait des cartes sur un gros compte sans le dire. On boucle jusqu'au `total`.
|
|
849
|
+
function teamLocalId(owner, key){
|
|
850
|
+
const t = String(owner || "").replace(/-/g, "").toLowerCase().slice(0, 10);
|
|
851
|
+
if(!/^[0-9a-f]{10}$/.test(t) || !/^[A-Za-z0-9_]{1,52}$/.test(String(key || ""))) return null;
|
|
852
|
+
return "T" + t + "_" + key;
|
|
853
|
+
}
|
|
854
|
+
let cloudSpacesCache = null; // les espaces vus au dernier chargement — la création les résout par nom
|
|
504
855
|
async function cloudLibrary(){
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
856
|
+
const pages = [];
|
|
857
|
+
let total = Infinity, off = 0, j = null;
|
|
858
|
+
while(off < total && off < 5000){
|
|
859
|
+
j = await cloudCall("docs", { limit: 100, offset: off });
|
|
860
|
+
const got = j.maps || [];
|
|
861
|
+
pages.push(...got);
|
|
862
|
+
total = typeof j.total === "number" ? j.total : got.length; // edge function d'avant la 1.5.0 : une seule page
|
|
863
|
+
if(!got.length || typeof j.total !== "number") break;
|
|
864
|
+
off += got.length;
|
|
865
|
+
}
|
|
866
|
+
const me = (j && j.me) || null;
|
|
867
|
+
cloudSpacesCache = (j && j.spaces) || [];
|
|
868
|
+
const spaceName = (owner, key)=>{ const sp = cloudSpacesCache.find(x=>x.owner === owner && x.space_key === key); return sp ? sp.name : null; };
|
|
869
|
+
const folders = new Map((j && j.folders || []).filter(f=>f && f.folder_key).map(f=>[(f.owner || "") + "|" + f.folder_key, f]));
|
|
870
|
+
const pathOf = (owner, k)=>{
|
|
508
871
|
const out = [], seen = new Set();
|
|
509
|
-
let f = folders.get(k);
|
|
510
|
-
while(f && !seen.has(f.folder_key) && out.length <= 16){ seen.add(f.folder_key); out.unshift(f.name); f = f.parent_key ? folders.get(f.parent_key) : null; }
|
|
872
|
+
let f = folders.get((owner || "") + "|" + k) || folders.get("|" + k);
|
|
873
|
+
while(f && !seen.has(f.folder_key) && out.length <= 16){ seen.add(f.folder_key); out.unshift(f.name); f = f.parent_key ? (folders.get((owner || "") + "|" + f.parent_key) || folders.get("|" + f.parent_key)) : null; }
|
|
511
874
|
return out.length ? out.join(" / ") : null;
|
|
512
875
|
};
|
|
513
876
|
const out = [];
|
|
514
|
-
for(const r of
|
|
877
|
+
for(const r of pages){
|
|
515
878
|
const doc = r && r.doc;
|
|
516
879
|
if(!doc || !doc.root) continue;
|
|
880
|
+
const owner = r.owner || me;
|
|
881
|
+
const team = !!(owner && me && owner !== me);
|
|
882
|
+
const file = team ? teamLocalId(owner, r.map_key) : r.map_key;
|
|
883
|
+
if(!file) continue;
|
|
517
884
|
out.push({
|
|
518
|
-
file: r.map_key,
|
|
885
|
+
file, key: r.map_key, owner, team,
|
|
886
|
+
role: r.role || "owner",
|
|
887
|
+
space: r.space_key ? spaceName(owner, r.space_key) : null,
|
|
519
888
|
name: String(r.name || "") || String(doc.root.text || "").slice(0, 60) || r.map_key,
|
|
520
|
-
folder: pathOf(r.folder_key),
|
|
521
|
-
|
|
889
|
+
folder: pathOf(owner, r.folder_key),
|
|
890
|
+
// La SORTE de l'index (kanban, note, gantt, sheet) quand le compte la dit : c'est elle qui décide
|
|
891
|
+
// de la vue d'ouverture dans l'app. Le document seul ne distingue que board / carte.
|
|
892
|
+
kind: doc.board ? "board" : (r.kind && r.kind !== "board" ? r.kind : "map"),
|
|
522
893
|
nodes: countNodes(doc.root),
|
|
523
894
|
elements: doc.board ? ((doc.shapes||[]).length + (doc.stickies||[]).length + (doc.arrows||[]).length + (doc.draws||[]).length) : undefined,
|
|
524
895
|
mtime: String(r.updated_at || "").slice(0,16).replace("T"," "),
|
|
@@ -573,19 +944,70 @@ async function findMap(file){
|
|
|
573
944
|
// ---------------------------------------------------------------------------
|
|
574
945
|
// Écriture (toujours un NOUVEAU fichier — jamais de modification en place)
|
|
575
946
|
// ---------------------------------------------------------------------------
|
|
576
|
-
async function writeDoc(name, doc, folder){
|
|
947
|
+
async function writeDoc(name, doc, folder, opts){
|
|
948
|
+
const o = opts || {};
|
|
577
949
|
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
|
-
|
|
950
|
+
// Le dossier est un NOM ou un CHEMIN de dossier de bibliothèque : on le résout en clé s'il
|
|
951
|
+
// existe déjà, sinon la carte arrive à la racine et le texte de réponse le dit
|
|
952
|
+
// (« no silent caps »).
|
|
953
|
+
//
|
|
954
|
+
// ⚠️ 2026-09-23 — LE NOM SEUL NE SUFFIT PAS. Un compte qui a « Ai4X » à la racine ET un « Ai4X »
|
|
955
|
+
// imbriqué dedans faisait tirer au sort. Deux règles : un CHEMIN complet (« Ai4X / Ai4X ») gagne
|
|
956
|
+
// toujours, et un nom seul prend le dossier le MOINS PROFOND. L'ambiguïté est dite.
|
|
957
|
+
//
|
|
958
|
+
// 👥 1.5.0 — LES ESPACES D'ÉQUIPE. `space` (un nom d'espace) dit OÙ : un de mes espaces, ou un
|
|
959
|
+
// espace dont je suis membre — la carte vit alors chez son PROPRIÉTAIRE, comme toute carte
|
|
960
|
+
// d'équipe. Les dossiers candidats sont ceux de cet espace ; sans `space`, mes dossiers d'abord,
|
|
961
|
+
// puis ceux des espaces d'équipe (un dossier d'équipe se désigne aussi par son chemin).
|
|
962
|
+
const j = await cloudCall("list", {});
|
|
963
|
+
const me = j.me || null;
|
|
964
|
+
const spaces = (j.spaces || []).filter(x=>x && x.space_key);
|
|
965
|
+
const all = (j.folders || []).filter(f=>f && f.folder_key);
|
|
966
|
+
const byKey = new Map(all.map(f=>[(f.owner || "") + "|" + f.folder_key, f]));
|
|
967
|
+
const chain = (f0)=>{ const out=[]; const seen=new Set(); let f=f0;
|
|
968
|
+
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.owner || "") + "|" + f.parent_key) : null; }
|
|
969
|
+
return out; };
|
|
970
|
+
const norm = (x)=>String(x||"").split("/").map(t=>t.trim()).filter(Boolean).join(" / ").toLowerCase();
|
|
971
|
+
let sp = null;
|
|
972
|
+
if(o.space){
|
|
973
|
+
const want = norm(o.space);
|
|
974
|
+
const hits = spaces.filter(x=>norm(x.name) === want);
|
|
975
|
+
if(!hits.length) throw new Error("No space named “" + o.space + "” in this account" + (spaces.length ? ". Spaces you can use: " + spaces.map(x=>"“" + x.name + "”" + (x.owner !== me ? " (team, " + x.role + ")" : "")).join(", ") : " — it has no spaces") + ".");
|
|
976
|
+
sp = hits.find(x=>x.owner === me) || hits[0];
|
|
977
|
+
if(!sp.writable) throw new Error("You cannot add maps to the space “" + sp.name + "” — " + (sp.role === "viewer" ? "your role there is viewer. Ask its owner for the editor role." : "its owner's plan has ended, so it is read-only."));
|
|
978
|
+
}
|
|
979
|
+
let f0 = null, folder_twins = 0;
|
|
581
980
|
if(folder){
|
|
582
|
-
const
|
|
583
|
-
|
|
584
|
-
if(
|
|
981
|
+
const want = norm(folder);
|
|
982
|
+
let cands = all.filter(f=>!sp || (f.owner === sp.owner && f.space_key === sp.space_key));
|
|
983
|
+
if(!sp) cands = cands.slice().sort((a,b)=>(a.owner === me ? 0 : 1) - (b.owner === me ? 0 : 1));
|
|
984
|
+
let hits = cands.filter(f=>norm(chain(f).join(" / ")) === want); // chemin exact
|
|
985
|
+
if(!hits.length) hits = cands.filter(f=>norm(f.name) === want) // nom de feuille
|
|
986
|
+
.sort((a,b)=>((a.owner === me ? 0 : 1) - (b.owner === me ? 0 : 1)) || (chain(a).length - chain(b).length));
|
|
987
|
+
if(hits.length){ f0 = hits[0]; folder_twins = hits.length; }
|
|
988
|
+
}
|
|
989
|
+
const owner = f0 ? (f0.owner || me) : (sp ? sp.owner : me);
|
|
990
|
+
const kind = o.kind || (doc.board ? "board" : (doc.kanban ? "kanban" : "map"));
|
|
991
|
+
const body = { name: String(name||"").slice(0,200), kind, doc, folder_key: f0 ? f0.folder_key : null };
|
|
992
|
+
if(owner && me && owner !== me) body.owner = owner;
|
|
993
|
+
if(!f0 && sp) body.space_key = sp.space_key;
|
|
994
|
+
const r = await cloudCall("create", body);
|
|
995
|
+
// On DIT toujours où la carte a atterri : l'espace, le chemin complet — pas le nom demandé.
|
|
996
|
+
const spaceOf = (own, key)=>{ const x = spaces.find(y=>y.owner === own && y.space_key === key); return x ? x.name : null; };
|
|
997
|
+
const inSpace = f0 ? spaceOf(f0.owner, f0.space_key) : (sp ? sp.name : null);
|
|
998
|
+
let where = "";
|
|
999
|
+
if(f0){
|
|
1000
|
+
where = " (in “" + chain(f0).join(" / ") + "”" + (inSpace ? ", space “" + inSpace + "”" : "") + ")";
|
|
1001
|
+
if(folder_twins > 1) where += " ⚠ " + folder_twins + " folders are named “" + folder
|
|
1002
|
+
+ "” in this account; the shallowest was used. Pass the full path (e.g. “" + folder + " / " + folder
|
|
1003
|
+
+ "”) to choose another one.";
|
|
1004
|
+
} else if(folder){
|
|
1005
|
+
where = " (folder “" + folder + "” does not exist" + (sp ? " in the space “" + sp.name + "”" : " in this account") + " — the map is at the root" + (sp ? " of that space" : "") + ")";
|
|
1006
|
+
} else if(sp){
|
|
1007
|
+
where = " (space “" + sp.name + "”" + (owner !== me ? " — a team space: every member sees it" : "") + ")";
|
|
585
1008
|
}
|
|
586
|
-
const
|
|
587
|
-
|
|
588
|
-
return "cloud:" + r.map_key + (folder && !folder_key ? " (folder “" + folder + "” does not exist in this account — the map is at the root)" : "");
|
|
1009
|
+
const id = (owner && me && owner !== me) ? teamLocalId(owner, r.map_key) : r.map_key;
|
|
1010
|
+
return "cloud:" + (id || r.map_key) + where;
|
|
589
1011
|
}
|
|
590
1012
|
dirOk();
|
|
591
1013
|
let target = DIR;
|
|
@@ -616,20 +1038,78 @@ function countForest(roots){ return roots.reduce((n,r)=>n + countNodes(r), 0); }
|
|
|
616
1038
|
// connecteur la refuserait à l'écriture, ou l'app la jetterait à la lecture — dans les deux
|
|
617
1039
|
// cas Claude croirait avoir proposé quelque chose qui n'arrive jamais chez l'utilisateur.
|
|
618
1040
|
// 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"];
|
|
1041
|
+
const PROP_OPS = ["add","rename","note","check","delete","move","pic","img","persona","tags","assign","priority","due","attr","comment","est","start","cols","link"];
|
|
1042
|
+
// ⚡ Les ops que l'app applique D'ELLE-MÊME (1.5.0, décidé « hybride » le 2026-10-20) — ≡
|
|
1043
|
+
// `PROP_AUTO_OPS` d'app/index.html (le test compare les deux). Plus `cols` quand le tableau n'a
|
|
1044
|
+
// pas encore de colonnes (`seed`). C'est l'APP qui décide : le connecteur ne fait que demander
|
|
1045
|
+
// (`auto:true`) et découper le lot pour que le reste parte à la revue.
|
|
1046
|
+
const PROP_AUTO_OPS = ["add","move","check","tags","assign","priority","due","start","est","attr","comment","pic","img","link"];
|
|
1047
|
+
// Les ops que Claude peut ÉCRIRE et qui n'existent pas telles quelles dans l'app : `lever` devient
|
|
1048
|
+
// un attribut « Lever: <clé> » (c'est ainsi que l'app le range, `KB_LEVER_ATTR`), `columns` est le
|
|
1049
|
+
// nom lisible de `cols`.
|
|
1050
|
+
const INPUT_OPS = PROP_OPS.concat(["lever", "columns"]);
|
|
1051
|
+
// Les quatre leviers Ai4X — ≡ les clés de `KB_LEVERS` d'app/index.html (le test compare).
|
|
1052
|
+
const KB_LEVER_KEYS_MCP = ["human","think","accelerate","automate"];
|
|
1053
|
+
// Un identifiant de nœud neuf, de la forme qu'`okNodeId` accepte. Posé sur chaque `add` (`nid`) :
|
|
1054
|
+
// c'est ce qui rend l'ajout IDEMPOTENT dans l'app — rejoué par deux appareils, il ne double pas.
|
|
1055
|
+
function newNid(){ return "mcp" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
|
|
1056
|
+
function cloneDoc(d){ return JSON.parse(JSON.stringify(d)); }
|
|
1057
|
+
// Les dix étiquettes de couleur du kanban — ≡ `KB_TAGS` de index.html (le test compare les deux).
|
|
1058
|
+
const KB_TAG_KEYS = ["green","yellow","orange","red","purple","blue","sky","lime","pink","gray"];
|
|
620
1059
|
|
|
621
1060
|
// Tout est vérifié ICI, contre la carte réelle, et un défaut fait ÉCHOUER l'appel. C'est
|
|
622
1061
|
// délibéré : une cible introuvable remonte à Claude, qui peut relire la carte et corriger,
|
|
623
1062
|
// alors qu'une proposition à moitié valide arriverait chez l'utilisateur sous forme de
|
|
624
1063
|
// lignes barrées qu'il ne peut ni comprendre ni réparer. La boîte aux lettres ne reçoit
|
|
625
1064
|
// que des propositions applicables.
|
|
626
|
-
|
|
1065
|
+
// ⚠️ SÉQUENTIEL depuis la 1.5.0 : chaque op est validée contre la carte TELLE QUE LES OPS
|
|
1066
|
+
// PRÉCÉDENTES L'ONT LAISSÉE (une copie, `work`). Sans ça, « ajoute la carte X, puis mets-lui une
|
|
1067
|
+
// échéance » échouait : X n'existait pas encore dans la carte lue. L'app applique dans le même
|
|
1068
|
+
// ordre, donc la cible validée ici est celle qu'elle trouvera.
|
|
1069
|
+
function buildProposalOps(mapDoc0, changes){
|
|
627
1070
|
if(!Array.isArray(changes) || !changes.length) throw new Error("`changes` must be a non-empty list.");
|
|
628
1071
|
if(changes.length > 100) throw new Error("Too many changes in one proposal (max 100). Send several smaller ones — the user reviews them one by one.");
|
|
1072
|
+
const mapDoc = cloneDoc(mapDoc0);
|
|
1073
|
+
const colsNow = ()=>(mapDoc.kanban && Array.isArray(mapDoc.kanban.cols) && mapDoc.kanban.cols.length) ? mapDoc.kanban.cols : null;
|
|
1074
|
+
// Une colonne se désigne par son id OU par son nom (« Doing ») — Claude lit les noms.
|
|
1075
|
+
const colRef = (raw, where)=>{
|
|
1076
|
+
const cols = colsNow();
|
|
1077
|
+
const v = String(raw);
|
|
1078
|
+
if(!cols) throw new Error(`${where}: this map has no kanban board yet. Add a "columns" change first (e.g. { op: "columns", columns: [{ name: "To do" }, { name: "Doing" }, { name: "Done", done: true }] }), then refer to those column names.`);
|
|
1079
|
+
const hit = cols.find(k => k.id === v) || cols.find(k => String(k.name).trim().toLowerCase() === v.trim().toLowerCase());
|
|
1080
|
+
if(!hit) throw new Error(`${where}: no column "${v}" on this board. Use one of: ${cols.map(k => `"${k.name}" (${k.id})`).join(", ")}.`);
|
|
1081
|
+
return hit.id;
|
|
1082
|
+
};
|
|
629
1083
|
return changes.map((c, i)=>{
|
|
630
1084
|
const where = "change #" + (i+1);
|
|
631
1085
|
const op = String((c && c.op) || "").trim();
|
|
632
|
-
if(!
|
|
1086
|
+
if(!INPUT_OPS.includes(op)) throw new Error(`${where}: unknown op "${op}". Use one of: ${INPUT_OPS.filter(x=>x !== "cols").join(", ")}.`);
|
|
1087
|
+
if(op === "columns" || op === "cols"){
|
|
1088
|
+
// Les colonnes du tableau, en un bloc. Un tableau qui n'en a PAS reçoit `seed` : l'app pose ces
|
|
1089
|
+
// colonnes d'elle-même, sans écraser celles qu'un collègue aurait posées entre-temps. Un tableau
|
|
1090
|
+
// qui en a déjà : un REMPLACEMENT, qui part à la revue (les cartes d'une colonne retirée
|
|
1091
|
+
// quittent le tableau). Un id existant est GARDÉ quand le nom revient — sinon chaque carte de
|
|
1092
|
+
// « Doing » perdrait sa colonne au passage.
|
|
1093
|
+
const list = Array.isArray(c.columns) ? c.columns : (Array.isArray(c.cols) ? c.cols : null);
|
|
1094
|
+
if(!list || !list.length || list.length > 12) throw new Error(`${where}: \`columns\` must list 1 to 12 columns — { name, done?, wip? }.`);
|
|
1095
|
+
const before = colsNow() || [];
|
|
1096
|
+
const used = new Set();
|
|
1097
|
+
const cols = list.map((k, j)=>{
|
|
1098
|
+
const name = String((k && (k.name != null ? k.name : k)) || "").replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, 60);
|
|
1099
|
+
if(!name) throw new Error(`${where}: column #${j+1} has no name.`);
|
|
1100
|
+
const prev = before.find(b => String(b.name).trim().toLowerCase() === name.toLowerCase() && !used.has(b.id));
|
|
1101
|
+
let id = prev ? prev.id : (k && typeof k.id === "string" && /^[\w-]{1,24}$/.test(k.id) && !before.some(b=>b.id === k.id) ? k.id : "c" + (j+1));
|
|
1102
|
+
while(used.has(id) || (!prev && before.some(b => b.id === id))) id = "c" + Math.random().toString(36).slice(2, 7);
|
|
1103
|
+
used.add(id);
|
|
1104
|
+
const wip = parseInt(k && k.wip, 10);
|
|
1105
|
+
return Object.assign({ id, name }, k && k.done ? { done: true } : {}, (wip >= 1 && wip <= 99) ? { wip } : {});
|
|
1106
|
+
});
|
|
1107
|
+
if(cols.filter(k => k.done).length > 1) throw new Error(`${where}: only one column can be the "done" column.`);
|
|
1108
|
+
const out = { op: "cols", cols };
|
|
1109
|
+
if(!before.length) out.seed = true;
|
|
1110
|
+
mapDoc.kanban = Object.assign({}, mapDoc.kanban || {}, { cols });
|
|
1111
|
+
return out;
|
|
1112
|
+
}
|
|
633
1113
|
if(op === "persona"){
|
|
634
1114
|
// `persona` (2026-09-08) vise le DOCUMENT entier — la toile d'une work map — jamais un
|
|
635
1115
|
// nœud : donc pas de `target`. C'est le relais skill → carte sans copier-coller : le skill
|
|
@@ -641,11 +1121,18 @@ function buildProposalOps(mapDoc, changes){
|
|
|
641
1121
|
// profondeur — « une proposition à moitié valide arrive en lignes barrées » — ne
|
|
642
1122
|
// s'applique pas : une op persona est ATOMIQUE, elle rebâtit la toile ou ne fait rien.
|
|
643
1123
|
const P = c.persona;
|
|
644
|
-
|
|
645
|
-
|
|
1124
|
+
// DEUX personas (2026-09-11) : celui d'une WORK MAP porte des `modules`, celui d'un SECOND
|
|
1125
|
+
// CERVEAU des `domains`. Même contrôle superficiel — `okBrain` juge à l'application.
|
|
1126
|
+
const isBrain = !!(P && typeof P === "object" && Array.isArray(P.domains) && P.domains.length);
|
|
1127
|
+
if(!P || typeof P !== "object" || (!isBrain && (!Array.isArray(P.modules) || !P.modules.length)))
|
|
1128
|
+
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 }.`);
|
|
1129
|
+
if(JSON.stringify(P).length > 60000) throw new Error(`${where}: the persona is too large (60000 characters max).`);
|
|
1130
|
+
if(isBrain){
|
|
1131
|
+
if(P.domains.length > 5) throw new Error(`${where}: the blueprint has ${P.domains.length} domains — five at most.`);
|
|
1132
|
+
return { op, persona: P, procs: 0, domains: P.domains.length };
|
|
1133
|
+
}
|
|
646
1134
|
const n = P.modules.reduce((a, m)=>a + ((m && Array.isArray(m.procs)) ? m.procs.length : 0), 0);
|
|
647
1135
|
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
1136
|
return { op, persona: P, procs: n };
|
|
650
1137
|
}
|
|
651
1138
|
const target = String(c.target == null ? "" : c.target);
|
|
@@ -654,6 +1141,14 @@ function buildProposalOps(mapDoc, changes){
|
|
|
654
1141
|
if(r.error === "missing") throw new Error(`${where}: no node "${target}" in this map. Run read_map first and copy the wording exactly.`);
|
|
655
1142
|
if(r.error === "ambiguous") throw new Error(`${where}: "${target}" matches ${r.count} nodes. Name an ancestor too, e.g. "Parent > ${parts_last(target)}".`);
|
|
656
1143
|
const out = { op, target, was: String(r.node.text||"") };
|
|
1144
|
+
// `why` (2026-10-12) : la RAISON d'un changement, en une ligne — l'app la montre sous la ligne
|
|
1145
|
+
// à approuver. ≡ la règle de `parseProposal` côté app (une ligne, 200 caractères) : une raison
|
|
1146
|
+
// trop longue est rognée, jamais refusée ; une raison qui n'est pas du texte, si.
|
|
1147
|
+
if(c.why != null){
|
|
1148
|
+
if(typeof c.why !== "string") throw new Error(`${where}: \`why\` must be text — one short line saying why this change.`);
|
|
1149
|
+
const w = c.why.replace(/[\x00-\x1f\x7f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 200);
|
|
1150
|
+
if(w) out.why = w;
|
|
1151
|
+
}
|
|
657
1152
|
if(op === "add"){
|
|
658
1153
|
if(typeof c.markdown !== "string" || !c.markdown.trim()) throw new Error(`${where}: \`markdown\` is required for an "add".`);
|
|
659
1154
|
if(c.markdown.length > 20000) throw new Error(`${where}: \`markdown\` is too long (20000 characters max).`);
|
|
@@ -661,18 +1156,35 @@ function buildProposalOps(mapDoc, changes){
|
|
|
661
1156
|
if(!forest.length) throw new Error(`${where}: \`markdown\` has no usable structure (a bullet list, indented by 2 spaces).`);
|
|
662
1157
|
out.markdown = c.markdown;
|
|
663
1158
|
out.adds = countForest(forest);
|
|
1159
|
+
out.nid = newNid();
|
|
1160
|
+
// `col` facultatif (2026-10-12) : les cartes NEUVES vont dans cette colonne du kanban.
|
|
1161
|
+
// Validé comme pour `move` — une colonne inventée est refusée ICI, en nommant les vraies ;
|
|
1162
|
+
// un NOM de colonne est accepté et traduit en id (1.5.0).
|
|
1163
|
+
if(c.col != null && c.col !== "") out.col = colRef(c.col, where);
|
|
1164
|
+
// La copie de travail reçoit les nœuds : une op suivante du même lot peut les viser.
|
|
1165
|
+
r.node.children = (r.node.children || []).concat(forest);
|
|
1166
|
+
// `assign` facultatif (2026-10-12, 👥) : le RESPONSABLE des cartes neuves. Mêmes bornes que l'op
|
|
1167
|
+
// `assign` ; refusé ICI en le disant, plutôt que de laisser l'app le retirer en silence.
|
|
1168
|
+
if(c.assign != null && c.assign !== ""){
|
|
1169
|
+
if(typeof c.assign !== "string" || c.assign.length > 120 || /[\x00-\x1f\x7f]/.test(c.assign))
|
|
1170
|
+
throw new Error(`${where}: \`assign\` must be a name or e-mail (120 characters max) — the owner of the new cards.`);
|
|
1171
|
+
if(c.assign.trim()) out.assign = c.assign.trim();
|
|
1172
|
+
}
|
|
664
1173
|
} else if(op === "rename"){
|
|
665
1174
|
if(typeof c.text !== "string" || !c.text.trim()) throw new Error(`${where}: \`text\` is required for a "rename".`);
|
|
666
1175
|
out.text = c.text.trim().slice(0, 2000);
|
|
1176
|
+
r.node.text = out.text;
|
|
667
1177
|
} else if(op === "note"){
|
|
668
1178
|
if(typeof c.note !== "string") throw new Error(`${where}: \`note\` is required for a "note" (an empty string clears it).`);
|
|
669
1179
|
out.note = c.note.slice(0, 8000);
|
|
670
1180
|
} else if(op === "check"){
|
|
671
|
-
|
|
672
|
-
|
|
1181
|
+
const v = c.value === "true" ? true : c.value === "false" ? false : c.value;
|
|
1182
|
+
if(typeof v !== "boolean") throw new Error(`${where}: \`value\` must be true or false for a "check".`);
|
|
1183
|
+
out.value = v;
|
|
673
1184
|
} else if(op === "delete"){
|
|
674
1185
|
if(r.node === mapDoc.root) throw new Error(`${where}: the centre of the map cannot be deleted.`);
|
|
675
1186
|
out.drops = countNodes(r.node);
|
|
1187
|
+
(function cut(n){ const k = (n.children || []).indexOf(r.node); if(k >= 0){ n.children.splice(k, 1); return true; } return (n.children || []).some(cut); })(mapDoc.root);
|
|
676
1188
|
} else if(op === "pic"){
|
|
677
1189
|
// Une désignation, jamais des pixels. La liste est FERMÉE : un id inventé est refusé ICI,
|
|
678
1190
|
// contre la carte réelle, plutôt que d'arriver chez l'utilisateur sous forme d'une ligne
|
|
@@ -683,35 +1195,138 @@ function buildProposalOps(mapDoc, changes){
|
|
|
683
1195
|
+ `or "a:" followed by one of: ${ART_IDS_MCP.join(", ")} — or "" to remove it.`);
|
|
684
1196
|
}
|
|
685
1197
|
out.pic = pic;
|
|
1198
|
+
} else if(op === "img"){
|
|
1199
|
+
// Des PIXELS, cette fois — ré-encodés ICI pour passer `okCover` chez l'utilisateur. Une
|
|
1200
|
+
// capture trop lourde est refusée à l'écriture, en nommant la limite, plutôt que d'arriver
|
|
1201
|
+
// dans le panneau comme une ligne grisée. `img: ""` retire la capture.
|
|
1202
|
+
if(c.img === "" || c.img === null){ out.img = ""; }
|
|
1203
|
+
else {
|
|
1204
|
+
const { img, note } = coverFrom(c, where);
|
|
1205
|
+
out.img = img; out.shot = note;
|
|
1206
|
+
}
|
|
686
1207
|
} else if(op === "move"){
|
|
687
1208
|
// ⚠️ Dans le FICHIER les colonnes sont `{cols:[…]}` ; en mémoire, côté app, c'est un
|
|
688
1209
|
// tableau nu. Confondre les deux donne un contrôle qui ne regarde rien.
|
|
689
|
-
const cols = (mapDoc.kanban && Array.isArray(mapDoc.kanban.cols)) ? mapDoc.kanban.cols : null;
|
|
690
|
-
if(!cols || !cols.length) throw new Error(`${where}: this map has no kanban board yet. Open it in TabTree and press 🃏 once, then try again.`);
|
|
691
|
-
const col = String(c.col == null ? "" : c.col);
|
|
692
1210
|
// Vide = retirer du tableau, comme le ✕ d'une carte. Toute autre valeur doit désigner une
|
|
693
|
-
// colonne RÉELLE : une carte envoyée ailleurs disparaîtrait de l'écran sans un message.
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
1211
|
+
// colonne RÉELLE (id ou nom) : une carte envoyée ailleurs disparaîtrait de l'écran sans un message.
|
|
1212
|
+
const raw = String(c.col == null ? "" : c.col);
|
|
1213
|
+
if(!colsNow()) colRef(raw || "?", where); // lève le message « pas encore de kanban »
|
|
1214
|
+
out.col = raw ? colRef(raw, where) : "";
|
|
1215
|
+
} else if(op === "tags"){
|
|
1216
|
+
// Les dix couleurs, liste FERMÉE — ≡ `okTags` de l'app. Une clé inventée est refusée ICI,
|
|
1217
|
+
// en nommant la liste, plutôt que de tomber en silence à la lecture. [] retire tout.
|
|
1218
|
+
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).`);
|
|
1219
|
+
const bad = c.tags.filter(t => !KB_TAG_KEYS.includes(t));
|
|
1220
|
+
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).`);
|
|
1221
|
+
out.tags = KB_TAG_KEYS.filter(k => c.tags.includes(k));
|
|
1222
|
+
} else if(op === "assign"){
|
|
1223
|
+
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).`);
|
|
1224
|
+
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).`);
|
|
1225
|
+
out.assign = c.assign.trim();
|
|
1226
|
+
} else if(op === "priority"){
|
|
1227
|
+
const q = (c.priority == null) ? 0 : c.priority;
|
|
1228
|
+
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.`);
|
|
1229
|
+
out.priority = q;
|
|
1230
|
+
} else if(op === "due" || op === "start"){
|
|
1231
|
+
const q = (c[op] == null) ? "" : String(c[op]);
|
|
1232
|
+
if(q && !/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(q)) throw new Error(`${where}: \`${op}\` must be a day written YYYY-MM-DD (or "" to remove it).`);
|
|
1233
|
+
out[op] = q;
|
|
1234
|
+
} else if(op === "link"){
|
|
1235
|
+
// La liste blanche de l'app (`okOpLink`), en plus étroit : une page web, un e-mail, ou une
|
|
1236
|
+
// autre carte (`map:<id>` — l'id que list_maps donne). Jamais `javascript:` ni `data:`.
|
|
1237
|
+
const q = (c.link == null) ? "" : String(c.link).trim();
|
|
1238
|
+
if(q && !/^(https?:\/\/[^\s\x00-\x1f]+|mailto:[^\s\x00-\x1f]+|map:[A-Za-z0-9_]+)$/i.test(q))
|
|
1239
|
+
throw new Error(`${where}: \`link\` must be an https:// address, a mailto: address, or "map:<file>" to open another map from list_maps (or "" to remove it).`);
|
|
1240
|
+
out.link = q;
|
|
1241
|
+
} else if(op === "lever"){
|
|
1242
|
+
// Le levier Ai4X d'une carte. L'app le range en ATTRIBUT « Lever: <clé> » (`kbLeverSet` →
|
|
1243
|
+
// `shAttrSet`) : on écrit exactement ça, par l'op `attr`, que l'app sait appliquer.
|
|
1244
|
+
const q = String(c.lever == null ? "" : c.lever).trim().toLowerCase();
|
|
1245
|
+
if(q && !KB_LEVER_KEYS_MCP.includes(q)) throw new Error(`${where}: \`lever\` must be one of ${KB_LEVER_KEYS_MCP.join(", ")} (or "" to remove it).`);
|
|
1246
|
+
out.op = "attr"; out.name = "Lever"; out.value = q;
|
|
1247
|
+
} else if(op === "attr"){
|
|
1248
|
+
if(typeof c.name !== "string" || !c.name.trim() || c.name.length > 60 || /[:\x00-\x1f]/.test(c.name))
|
|
1249
|
+
throw new Error(`${where}: \`name\` is required for an "attr" — the attribute's name, without a colon (60 characters max).`);
|
|
1250
|
+
if(typeof c.value !== "string" || c.value.length > 400 || /[\x00-\x1f]/.test(c.value))
|
|
1251
|
+
throw new Error(`${where}: \`value\` is required for an "attr" — its value as text (400 characters max; "" removes the attribute).`);
|
|
1252
|
+
out.name = c.name.trim(); out.value = c.value.trim();
|
|
1253
|
+
} else if(op === "comment"){
|
|
1254
|
+
// Un commentaire est AJOUTÉ au fil de la carte, jamais substitué : la seule op qui ne
|
|
1255
|
+
// réécrit rien. Signé « Claude » par l'app (le `by` de la proposition), borné comme
|
|
1256
|
+
// `CMT_TEXT_MAX` côté app — un fil est lu par des gens, pas par un tableur.
|
|
1257
|
+
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.`);
|
|
1258
|
+
if(c.text.length > 2000) throw new Error(`${where}: the comment is too long (2000 characters max).`);
|
|
1259
|
+
out.text = c.text.trim();
|
|
1260
|
+
} else if(op === "est"){
|
|
1261
|
+
// Une DURÉE, dans les formes que `parseDur` lit côté app (« 2h », « 1h30 », « 45 » = minutes,
|
|
1262
|
+
// « 2:15 »), jamais une phrase : « soon » est refusé ICI, en nommant les formes, plutôt que
|
|
1263
|
+
// de tomber en silence à la lecture. Vide = retirer l'estimation.
|
|
1264
|
+
const q = (c.est == null) ? "" : String(c.est).trim().toLowerCase().replace(/\s+/g, "");
|
|
1265
|
+
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.`);
|
|
1266
|
+
out.est = q;
|
|
699
1267
|
}
|
|
700
1268
|
return out;
|
|
701
1269
|
});
|
|
702
1270
|
}
|
|
1271
|
+
// 👥 L'ÉQUIPE du tableau (`kanban.team`, 2026-10-12) — qui fait quoi. Montrée par read_map pour la
|
|
1272
|
+
// même raison que dans l'app : c'est elle qui permet de proposer un RESPONSABLE (assign) au lieu de
|
|
1273
|
+
// deviner à partir d'un prénom écrit dans un titre. Lecture seule, bornée comme `okKbTeam` côté app
|
|
1274
|
+
// (30 personnes, textes rognés) : le fichier vient du disque, c'est-à-dire de n'importe qui.
|
|
1275
|
+
function teamToText(doc){
|
|
1276
|
+
const t = doc && doc.kanban && doc.kanban.team;
|
|
1277
|
+
if(!t || typeof t !== "object" || !Array.isArray(t.people)) return "";
|
|
1278
|
+
const line = (v, max) => String(typeof v === "string" ? v : "").replace(/[\x00-\x1f\x7f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, max);
|
|
1279
|
+
const people = t.people.filter(p => p && typeof p.who === "string" && line(p.who, 120)).slice(0, 30);
|
|
1280
|
+
if(!people.length) return "";
|
|
1281
|
+
let out = "\n\n👥 Team — who does what (pick owners from here; `assign` takes the exact name):";
|
|
1282
|
+
const goal = line(t.goal, 800);
|
|
1283
|
+
if(goal) out += "\n Team objectives: " + goal;
|
|
1284
|
+
for(const p of people){
|
|
1285
|
+
out += "\n• " + line(p.who, 120) + (line(p.role, 80) ? " — " + line(p.role, 80) : "");
|
|
1286
|
+
if(line(p.does, 800)) out += "\n owns: " + line(p.does, 800);
|
|
1287
|
+
if(line(p.goals, 800)) out += "\n objectives: " + line(p.goals, 800);
|
|
1288
|
+
}
|
|
1289
|
+
return out;
|
|
1290
|
+
}
|
|
1291
|
+
// ≡ les formes que `parseDur` accepte dans index.html (hh:mm, Nh, NhMM, Nm, Ns, N = minutes).
|
|
1292
|
+
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
1293
|
function parts_last(spec){ const p = String(spec||"").split(">"); return p[p.length-1].trim(); }
|
|
704
1294
|
|
|
705
|
-
|
|
706
|
-
|
|
1295
|
+
// L'app ignore un fichier de proposition au-delà de PROP_FILE_MAX octets (`propScan`) — sans un
|
|
1296
|
+
// mot, puisqu'elle ne peut pas savoir ce qu'il contenait. Avec des captures d'écran dedans, la
|
|
1297
|
+
// borne se rencontre : on refuse ICI en nommant le geste, jamais un dépôt qui n'arrive pas.
|
|
1298
|
+
const PROP_FILE_MAX = 1400000;
|
|
1299
|
+
// ⚡ LE DÉCOUPAGE « HYBRIDE » (1.5.0). Le début du lot fait d'ops sûres part avec `auto:true` —
|
|
1300
|
+
// l'app l'applique d'elle-même ; à partir de la PREMIÈRE op qui demande une revue, tout le reste
|
|
1301
|
+
// part dans une seconde proposition, à revoir. Découper AU PREMIER refus, jamais op par op : une
|
|
1302
|
+
// op sûre qui suit un renommage vise souvent le texte renommé, et l'appliquer avant lui la ferait
|
|
1303
|
+
// échouer chez l'utilisateur.
|
|
1304
|
+
function autoOk(o){ return o.op === "cols" ? o.seed === true : PROP_AUTO_OPS.includes(o.op); }
|
|
1305
|
+
function splitAuto(ops){
|
|
1306
|
+
let k = 0;
|
|
1307
|
+
while(k < ops.length && autoOk(ops[k])) k++;
|
|
1308
|
+
return { auto: ops.slice(0, k), review: ops.slice(k) };
|
|
1309
|
+
}
|
|
1310
|
+
async function writeProposal(map, note, ops, opts){
|
|
1311
|
+
const o = opts || {};
|
|
1312
|
+
const payload = (id, list, auto)=>Object.assign({
|
|
707
1313
|
v: 1, kind: "tabtree-proposal", mapId: id, mapFile: CLOUD ? map.file : basename(map.file), mapName: map.name,
|
|
708
|
-
createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops
|
|
709
|
-
});
|
|
1314
|
+
createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops: list
|
|
1315
|
+
}, auto ? { auto: true } : {});
|
|
1316
|
+
const shots = ops.filter(x=>x.op === "img" && x.img).length;
|
|
1317
|
+
if(JSON.stringify(payload("x", ops)).length > PROP_FILE_MAX)
|
|
1318
|
+
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
1319
|
if(CLOUD){
|
|
711
1320
|
// La boîte aux lettres cloud : la table `proposals`, que l'app relit au sondage suivant.
|
|
712
1321
|
// Même charge utile que le fichier — c'est le même `parseProposal` qui la lit côté app.
|
|
713
|
-
|
|
714
|
-
|
|
1322
|
+
// Une carte d'équipe : la clé chez son propriétaire, et `mapId` = l'id que l'app lui donne.
|
|
1323
|
+
const parts = o.review ? { auto: [], review: ops } : splitAuto(ops);
|
|
1324
|
+
const base = { map_key: map.key || map.file };
|
|
1325
|
+
if(map.team && map.owner) base.owner = map.owner;
|
|
1326
|
+
const ids = {};
|
|
1327
|
+
if(parts.auto.length){ const r = await cloudCall("propose", Object.assign({ payload: payload(map.file, parts.auto, true) }, base)); ids.auto = r.id || "proposal"; }
|
|
1328
|
+
if(parts.review.length){ const r = await cloudCall("propose", Object.assign({ payload: payload(map.file, parts.review, false) }, base)); ids.review = r.id || "proposal"; }
|
|
1329
|
+
return { auto: parts.auto, review: parts.review, ids };
|
|
715
1330
|
}
|
|
716
1331
|
dirOk();
|
|
717
1332
|
const target = join(DIR, PROP_DIR);
|
|
@@ -719,8 +1334,8 @@ async function writeProposal(map, note, ops){
|
|
|
719
1334
|
const id = mapIdOf(map.file);
|
|
720
1335
|
if(!id) throw new Error("This map's file has no id suffix (“…__map123.tabtree”), so the app cannot match a proposal to it. Open it once in TabTree with the backup folder turned on, then try again.");
|
|
721
1336
|
const file = "prop_" + id + "__" + Date.now().toString(36) + ".json";
|
|
722
|
-
writeFileSync(join(target, file), JSON.stringify(payload(id), null, 2), "utf8");
|
|
723
|
-
return PROP_DIR + "/" + file;
|
|
1337
|
+
writeFileSync(join(target, file), JSON.stringify(payload(id, ops, false), null, 2), "utf8");
|
|
1338
|
+
return { auto: [], review: ops, ids: { review: PROP_DIR + "/" + file } };
|
|
724
1339
|
}
|
|
725
1340
|
|
|
726
1341
|
// Ce texte est lu par CHAQUE acheteur, à chaque création. Il a déjà été faux une
|
|
@@ -742,13 +1357,147 @@ function importHint(){
|
|
|
742
1357
|
+ "the connector is pointed at a different library — that is why a map can be created and never appear.";
|
|
743
1358
|
}
|
|
744
1359
|
|
|
1360
|
+
// ---------------------------------------------------------------------------
|
|
1361
|
+
// 🃏 Le KANBAN (1.5.0) — lire ses colonnes, et en fabriquer un d'un seul appel
|
|
1362
|
+
// ---------------------------------------------------------------------------
|
|
1363
|
+
function kanbanToText(doc){
|
|
1364
|
+
const k = doc && doc.kanban;
|
|
1365
|
+
if(!k || !Array.isArray(k.cols) || !k.cols.length) return "";
|
|
1366
|
+
const cols = k.cols.map(c => "“" + c.name + "” (" + c.id + (c.done ? ", done" : "") + (c.wip ? ", WIP " + c.wip : "") + ")").join(" · ");
|
|
1367
|
+
const tags = (k.tags && typeof k.tags === "object") ? Object.entries(k.tags)
|
|
1368
|
+
.filter(([key, v]) => KB_TAG_KEYS.includes(key) && typeof v === "string" && v.trim())
|
|
1369
|
+
.map(([key, v]) => key + " = “" + v.replace(/[\x00-\x1f\x7f]+/g, " ").trim().slice(0, 40) + "”") : [];
|
|
1370
|
+
return "\n\n🃏 Kanban columns: " + cols + (tags.length ? "\n🏷 Labels: " + tags.join(", ") : "")
|
|
1371
|
+
+ "\n(Cards are the nodes marked ⟪column: …⟫. Move one with propose_changes op \"move\" and `col` = a column name or id; a ticked card [x] shows in the done column.)";
|
|
1372
|
+
}
|
|
1373
|
+
function kbTagNames(doc){
|
|
1374
|
+
const t = doc && doc.kanban && doc.kanban.tags;
|
|
1375
|
+
const out = {};
|
|
1376
|
+
if(t && typeof t === "object") for(const k of KB_TAG_KEYS) if(typeof t[k] === "string" && t[k].trim()) out[k] = t[k].trim().slice(0, 40);
|
|
1377
|
+
return out;
|
|
1378
|
+
}
|
|
1379
|
+
// Une durée en SECONDES, depuis les formes que `parseDur` lit dans l'app (« 2h », « 1h30 », « 45 »
|
|
1380
|
+
// = minutes, « 2:15 », « 30m », « 90s »). L'estimation d'un nœud est stockée en secondes (`okSecs`).
|
|
1381
|
+
function durSecs(v){
|
|
1382
|
+
const q = String(v == null ? "" : v).trim().toLowerCase().replace(/\s+/g, "");
|
|
1383
|
+
if(!q || !DUR_RE.test(q)) return null;
|
|
1384
|
+
let m;
|
|
1385
|
+
if((m = /^(\d+):([0-5]\d)$/.exec(q))) return (+m[1] * 60 + +m[2]) * 60;
|
|
1386
|
+
if((m = /^(\d+)h(\d+)?m?$/.exec(q))) return (+m[1] * 60 + (+m[2] || 0)) * 60;
|
|
1387
|
+
if((m = /^(\d+)m(in)?$/.exec(q))) return +m[1] * 60;
|
|
1388
|
+
if((m = /^(\d+)s(ec)?$/.exec(q))) return +m[1];
|
|
1389
|
+
return +q * 60;
|
|
1390
|
+
}
|
|
1391
|
+
const DAY_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
|
1392
|
+
// Un identifiant sur chaque nœud créé : l'app en donnerait un à l'ouverture, mais la signature du
|
|
1393
|
+
// document changerait alors entre le serveur et l'app (un aller-retour de synchro pour rien), et
|
|
1394
|
+
// une proposition suivante ne pourrait viser une carte que par son texte.
|
|
1395
|
+
function assignIds(n){ if(!n.id) n.id = "n" + Math.random().toString(36).slice(2, 12); (n.children || []).forEach(assignIds); return n; }
|
|
1396
|
+
// Les colonnes demandées, validées et munies d'ids — même forme qu'`okKanbanCols` côté app.
|
|
1397
|
+
function kanbanColsFrom(list, where){
|
|
1398
|
+
const src = Array.isArray(list) && list.length ? list : [{ name:"To do" }, { name:"Doing" }, { name:"Done", done:true }];
|
|
1399
|
+
if(src.length > 12) throw new Error(where + ": a kanban has 12 columns at most.");
|
|
1400
|
+
const cols = src.map((k, j)=>{
|
|
1401
|
+
const name = String((k && (k.name != null ? k.name : k)) || "").replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, 60);
|
|
1402
|
+
if(!name) throw new Error(where + ": column #" + (j+1) + " has no name.");
|
|
1403
|
+
const wip = parseInt(k && k.wip, 10);
|
|
1404
|
+
return Object.assign({ id: "c" + (j+1), name }, k && k.done ? { done: true } : {}, (wip >= 1 && wip <= 99) ? { wip } : {});
|
|
1405
|
+
});
|
|
1406
|
+
if(cols.filter(k => k.done).length > 1) throw new Error(where + ": only one column can be the \"done\" column.");
|
|
1407
|
+
return cols;
|
|
1408
|
+
}
|
|
1409
|
+
// UN appel, un tableau complet : colonnes, cartes, et sur chaque carte ce que l'app affiche au recto
|
|
1410
|
+
// et au dos — responsable, dates, priorité, étiquettes, estimation, levier Ai4X, note, lien,
|
|
1411
|
+
// sous-tâches. Tout passe par les mêmes bornes que les propositions : une carte qui serait refusée
|
|
1412
|
+
// par l'app à l'ouverture est refusée ICI, en le disant.
|
|
1413
|
+
function buildKanbanDoc(args){
|
|
1414
|
+
const name = String(args.name || "").trim() || "Kanban";
|
|
1415
|
+
const cols = kanbanColsFrom(args.columns, "columns");
|
|
1416
|
+
const work = cols.filter(k => !k.done), done = cols.find(k => k.done) || null;
|
|
1417
|
+
const firstWork = (work[0] || cols[0]).id;
|
|
1418
|
+
const colOf = (raw, where)=>{
|
|
1419
|
+
if(raw == null || raw === "") return firstWork;
|
|
1420
|
+
const v = String(raw).trim().toLowerCase();
|
|
1421
|
+
const hit = cols.find(k => k.id === String(raw)) || cols.find(k => k.name.toLowerCase() === v);
|
|
1422
|
+
if(!hit) throw new Error(where + ": no column \"" + raw + "\". Columns: " + cols.map(k => "“" + k.name + "”").join(", ") + ".");
|
|
1423
|
+
return hit.id;
|
|
1424
|
+
};
|
|
1425
|
+
const cards = Array.isArray(args.cards) ? args.cards : [];
|
|
1426
|
+
if(cards.length > 300) throw new Error("A kanban created in one call holds 300 cards at most — create it, then add more with propose_changes.");
|
|
1427
|
+
const root = makeNode(name);
|
|
1428
|
+
root.color = "#dbeafe";
|
|
1429
|
+
cards.forEach((c, i)=>{
|
|
1430
|
+
const where = "card #" + (i+1);
|
|
1431
|
+
const text = String((c && c.text) || "").replace(/[\x00-\x1f\x7f]/g, " ").trim();
|
|
1432
|
+
if(!text) throw new Error(where + ": `text` is required.");
|
|
1433
|
+
const n = makeNode(text.slice(0, 2000));
|
|
1434
|
+
const col = colOf(c.column, where);
|
|
1435
|
+
if(done && col === done.id){ n.col = firstWork; n.check = true; } // la règle de l'app : Done COCHE, la colonne reste de travail
|
|
1436
|
+
else { n.col = col; n.check = false; }
|
|
1437
|
+
if(c.note != null){ if(typeof c.note !== "string") throw new Error(where + ": `note` must be text."); n.note = c.note.slice(0, 8000); }
|
|
1438
|
+
if(c.link){
|
|
1439
|
+
const l = String(c.link).trim();
|
|
1440
|
+
if(!/^(https?:\/\/[^\s\x00-\x1f]+|mailto:[^\s\x00-\x1f]+|map:[A-Za-z0-9_]+)$/i.test(l)) throw new Error(where + ": `link` must be an https:// or mailto: address, or map:<file>.");
|
|
1441
|
+
n.link = l;
|
|
1442
|
+
}
|
|
1443
|
+
if(c.assign){
|
|
1444
|
+
if(typeof c.assign !== "string" || c.assign.length > 120 || /[\x00-\x1f\x7f]/.test(c.assign)) throw new Error(where + ": `assign` must be a name or e-mail (120 characters max).");
|
|
1445
|
+
n.assign = c.assign.trim();
|
|
1446
|
+
}
|
|
1447
|
+
for(const k of ["start", "due"]) if(c[k]){ if(!DAY_RE.test(String(c[k]))) throw new Error(where + ": `" + k + "` must be YYYY-MM-DD."); n[k] = String(c[k]); }
|
|
1448
|
+
if(n.start && n.due && n.due < n.start){ const t = n.start; n.start = n.due; n.due = t; } // la règle du fichier : remis en ordre, pas refusé
|
|
1449
|
+
if(c.priority != null && c.priority !== 0){
|
|
1450
|
+
if(!Number.isInteger(c.priority) || c.priority < 1 || c.priority > 6) throw new Error(where + ": `priority` must be 1 (highest) to 6.");
|
|
1451
|
+
n.priority = c.priority;
|
|
1452
|
+
}
|
|
1453
|
+
const tags = c.labels != null ? c.labels : c.tags;
|
|
1454
|
+
if(tags != null){
|
|
1455
|
+
if(!Array.isArray(tags)) throw new Error(where + ": `labels` must be a list of colours.");
|
|
1456
|
+
const bad = tags.filter(t => !KB_TAG_KEYS.includes(t));
|
|
1457
|
+
if(bad.length) throw new Error(where + ": unknown label colour \"" + bad[0] + "\". Use any of: " + KB_TAG_KEYS.join(", ") + ".");
|
|
1458
|
+
if(tags.length) n.tags = KB_TAG_KEYS.filter(k => tags.includes(k));
|
|
1459
|
+
}
|
|
1460
|
+
if(c.est != null && c.est !== ""){
|
|
1461
|
+
const sec = durSecs(c.est);
|
|
1462
|
+
if(sec == null) throw new Error(where + ": `est` must be a duration — \"2h\", \"1h30\", \"45\" (minutes) or \"2:15\".");
|
|
1463
|
+
if(sec > 0) n.est = sec;
|
|
1464
|
+
}
|
|
1465
|
+
if(c.pic){ if(!okPic(String(c.pic))) throw new Error(where + ": `pic` must be \"a:<id>\" (" + ART_IDS_MCP.slice(0, 8).join(", ") + "…) or \"e:<emoji>\"."); n.pic = String(c.pic); }
|
|
1466
|
+
// Les sous-tâches sont des ENFANTS cochables, sans colonne — la règle du dos de la carte
|
|
1467
|
+
// (`kbAddSub`) : jamais des cartes sœurs.
|
|
1468
|
+
const subs = Array.isArray(c.subtasks) ? c.subtasks : [];
|
|
1469
|
+
if(subs.length > 50) throw new Error(where + ": 50 sub-tasks at most.");
|
|
1470
|
+
for(const t of subs){
|
|
1471
|
+
const st = String(t || "").replace(/[\x00-\x1f\x7f]/g, " ").trim();
|
|
1472
|
+
if(!st) continue;
|
|
1473
|
+
const k = makeNode(st.slice(0, 2000)); k.check = false; n.children.push(k);
|
|
1474
|
+
}
|
|
1475
|
+
// Le levier Ai4X : un attribut « Lever: <clé> », exactement ce que pose le dos de la carte.
|
|
1476
|
+
if(c.lever){
|
|
1477
|
+
const lv = String(c.lever).trim().toLowerCase();
|
|
1478
|
+
if(!KB_LEVER_KEYS_MCP.includes(lv)) throw new Error(where + ": `lever` must be one of " + KB_LEVER_KEYS_MCP.join(", ") + ".");
|
|
1479
|
+
n.children.push(makeNode("Lever: " + lv));
|
|
1480
|
+
}
|
|
1481
|
+
root.children.push(n);
|
|
1482
|
+
});
|
|
1483
|
+
assignIds(root);
|
|
1484
|
+
const kanban = { cols };
|
|
1485
|
+
const names = {};
|
|
1486
|
+
if(args.labels && typeof args.labels === "object") for(const k of KB_TAG_KEYS) if(typeof args.labels[k] === "string" && args.labels[k].trim()) names[k] = args.labels[k].replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, 40);
|
|
1487
|
+
if(Object.keys(names).length) kanban.tags = names;
|
|
1488
|
+
const doc = { v:1, root, images:[], stickies:[], kanban };
|
|
1489
|
+
const st = okStyleMcp(args.style);
|
|
1490
|
+
if(st) doc.style = st;
|
|
1491
|
+
return doc;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
745
1494
|
// ---------------------------------------------------------------------------
|
|
746
1495
|
// Définition des outils
|
|
747
1496
|
// ---------------------------------------------------------------------------
|
|
748
1497
|
const TOOLS = [
|
|
749
1498
|
{
|
|
750
1499
|
name: "list_maps",
|
|
751
|
-
description: "Lists every mind map and board in the TabTree library (name, type, folder, size, last modified).",
|
|
1500
|
+
description: "Lists every mind map, kanban, note and board in the TabTree library — the user's own and those of the team spaces they belong to (name, type, space, folder, size, last modified).",
|
|
752
1501
|
inputSchema: { type:"object", properties:{}, additionalProperties:false }
|
|
753
1502
|
},
|
|
754
1503
|
{
|
|
@@ -763,11 +1512,23 @@ const TOOLS = [
|
|
|
763
1512
|
},
|
|
764
1513
|
{
|
|
765
1514
|
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.",
|
|
1515
|
+
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
1516
|
inputSchema: { type:"object", properties:{
|
|
768
1517
|
name:{ type:"string", description:"Name of the map" },
|
|
769
1518
|
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
|
|
1519
|
+
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." },
|
|
1520
|
+
space:{ type:"string", description:"Optional TabTree SPACE to put it in, by name — one of the user's spaces, or a TEAM space they are an editor of (every member then sees it). list_maps shows the spaces. Omit for the user's own library." },
|
|
1521
|
+
view:{ type:"string", enum:["map","note","gantt"], description:"How TabTree opens it: map (default, a mind map), note (a page of text — the outline becomes headings and paragraphs, for meeting notes or a document), gantt (a timeline — give `dates`). It stays one document: the user can switch views at any time." },
|
|
1522
|
+
dates:{ type:"array", description:"Optional start/due days on nodes — what the Gantt view draws (and what the kanban and calendar show). One entry per node.", items:{ type:"object", properties:{
|
|
1523
|
+
target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\"" },
|
|
1524
|
+
start:{ type:"string", description:"YYYY-MM-DD" },
|
|
1525
|
+
due:{ type:"string", description:"YYYY-MM-DD" }
|
|
1526
|
+
}, required:["target"], additionalProperties:false } },
|
|
1527
|
+
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:{
|
|
1528
|
+
target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\"" },
|
|
1529
|
+
file:{ type:"string", description:"Path of the image file on this machine" },
|
|
1530
|
+
data:{ type:"string", description:"A data:image/(png|jpeg|webp|gif);base64,… URL — instead of `file`" }
|
|
1531
|
+
}, required:["target"], additionalProperties:false } },
|
|
771
1532
|
style:{ type:"object", description:"Optional look of the document. Omit it for the default.", properties:{
|
|
772
1533
|
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
1534
|
shape:{ type:"string", enum:["","line","plain"], description:"Node shape: cards (default), line = underlined, plain = plain text." },
|
|
@@ -799,7 +1560,8 @@ const TOOLS = [
|
|
|
799
1560
|
connections:{ type:"array", description:"Arrows anchored between elements (by id)", items:{ type:"object", properties:{
|
|
800
1561
|
from:{ type:"string" }, to:{ type:"string" }
|
|
801
1562
|
}, required:["from","to"], additionalProperties:false } },
|
|
802
|
-
folder:{ type:"string", description:"Optional TabTree
|
|
1563
|
+
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." },
|
|
1564
|
+
space:{ type:"string", description:"Optional TabTree SPACE to put it in, by name — one of the user's spaces, or a TEAM space they are an editor of (every member then sees it). list_maps shows the spaces. Omit for the user's own library." },
|
|
803
1565
|
style:{ type:"object", description:"Optional look of the document. Omit it for the default.", properties:{
|
|
804
1566
|
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
1567
|
shape:{ type:"string", enum:["","line","plain"], description:"Node shape: cards (default), line = underlined, plain = plain text." },
|
|
@@ -811,22 +1573,68 @@ const TOOLS = [
|
|
|
811
1573
|
}, additionalProperties:false }
|
|
812
1574
|
}, required:["name","elements"], additionalProperties:false }
|
|
813
1575
|
},
|
|
1576
|
+
{
|
|
1577
|
+
name: "create_kanban",
|
|
1578
|
+
description: "Creates a NEW TabTree kanban board in one call — columns and cards, each card with what TabTree shows on it: owner, start/due days, priority, colour labels, time estimate, Ai4X lever, note (links in it become clickable — a Google Meet link, a doc), a link, and sub-tasks. It opens directly as a kanban in TabTree, on every device signed in to the account, and in a TEAM space every member sees it. To add or move cards on an EXISTING kanban, use propose_changes instead.",
|
|
1579
|
+
inputSchema: { type:"object", properties:{
|
|
1580
|
+
name:{ type:"string", description:"Name of the board (e.g. \"Sprint W43\", \"Launch\")" },
|
|
1581
|
+
columns:{ type:"array", description:"The columns, left to right. Omit for To do / Doing / Done. Mark ONE column done:true: a card put there is ticked, and unticking it sends it back. 12 max.", items:{ type:"object", properties:{
|
|
1582
|
+
name:{ type:"string" }, done:{ type:"boolean" }, wip:{ type:"integer", description:"Optional work-in-progress limit 1–99 (the column turns red above it — nothing is blocked)" }
|
|
1583
|
+
}, required:["name"], additionalProperties:false } },
|
|
1584
|
+
cards:{ type:"array", description:"The cards, in order within each column. 300 max.", items:{ type:"object", properties:{
|
|
1585
|
+
text:{ type:"string", description:"The card's title" },
|
|
1586
|
+
column:{ type:"string", description:"Column NAME (or id). Default: the first column" },
|
|
1587
|
+
assign:{ type:"string", description:"Owner — a name or e-mail" },
|
|
1588
|
+
start:{ type:"string", description:"YYYY-MM-DD" },
|
|
1589
|
+
due:{ type:"string", description:"YYYY-MM-DD" },
|
|
1590
|
+
priority:{ type:"integer", description:"1 (highest) to 6" },
|
|
1591
|
+
labels:{ type:"array", items:{ type:"string" }, description:"Colour labels from: green, yellow, orange, red, purple, blue, sky, lime, pink, gray (name them with `labels` at the board level)" },
|
|
1592
|
+
est:{ type:"string", description:"Time estimate: \"2h\", \"1h30\", \"45\" (minutes), \"2:15\"" },
|
|
1593
|
+
lever:{ type:"string", enum:["human","think","accelerate","automate"], description:"Ai4X lever: human (stays human), think (think with Claude), accelerate (Claude Code or a connector), automate (a skill or a scheduled task)" },
|
|
1594
|
+
note:{ type:"string", description:"The back of the card — plain text; http(s) and mailto links become clickable" },
|
|
1595
|
+
link:{ type:"string", description:"An https:// or mailto: link on the card, or map:<file> to open another map from list_maps" },
|
|
1596
|
+
subtasks:{ type:"array", items:{ type:"string" }, description:"Sub-tasks, ticked on the back of the card (the card shows n/m)" },
|
|
1597
|
+
pic:{ type:"string", description:"\"a:<id>\" built-in illustration or \"e:<emoji>\"" }
|
|
1598
|
+
}, required:["text"], additionalProperties:false } },
|
|
1599
|
+
labels:{ type:"object", description:"Optional names for the colour labels, e.g. { \"red\": \"Bug\", \"green\": \"Quick win\" }", additionalProperties:{ type:"string" } },
|
|
1600
|
+
folder:{ type:"string", description:"Optional folder, by NAME or full PATH." },
|
|
1601
|
+
space:{ type:"string", description:"Optional TabTree SPACE to put it in, by name — one of the user's spaces, or a TEAM space they are an editor of (every member then sees it). list_maps shows the spaces. Omit for the user's own library." },
|
|
1602
|
+
style:{ type:"object", description:"Optional look (same keys as create_mindmap: font, background, skin…).", additionalProperties:true }
|
|
1603
|
+
}, required:["name"], additionalProperties:false }
|
|
1604
|
+
},
|
|
814
1605
|
{
|
|
815
1606
|
name: "propose_changes",
|
|
816
|
-
description: "
|
|
1607
|
+
description: "Changes an EXISTING mind map or kanban (also one in a team space). HYBRID, by design: SAFE changes — add cards or branches, move a card to a column, tick it, set its owner, dates, priority, labels, estimate, Ai4X lever, an attribute, a link, a comment, a picture, and set up the columns of a board that has none — are APPLIED BY TABTREE ITSELF within seconds when TabTree is open on one of the user's devices (otherwise the next time it opens), and Cmd+Z undoes them. Changes that rewrite or remove what someone wrote — rename, note, delete, replacing existing columns, a persona re-cast — appear as a banner for the user to review and approve. In one call, the safe changes BEFORE the first review-only change apply by themselves and the rest goes to review, in order. Name each target node by its exact text, or by a path \"Ancestor > Node\" when the same wording appears twice; a later change may target a node added earlier in the same call. Read the map first so the wording matches.",
|
|
817
1608
|
inputSchema: { type:"object", properties:{
|
|
818
1609
|
file:{ type:"string", description:"The map to change — a file name (or map name) from list_maps" },
|
|
819
1610
|
note:{ type:"string", description:"One line telling the user what this proposal does and why. Shown above the changes." },
|
|
820
1611
|
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" },
|
|
1612
|
+
op:{ type:"string", enum:["add","rename","note","check","delete","move","pic","img","persona","tags","assign","priority","due","start","attr","comment","est","lever","link","columns"], description:"columns = set the kanban columns (give `columns`; no target) — on a map with no kanban yet this creates the board; 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); start = the start day; lever = the card's Ai4X lever (give `lever`); link = put a link on the node (give `link`)" },
|
|
822
1613
|
target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\" if ambiguous (not used by persona)" },
|
|
823
1614
|
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
1615
|
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" },
|
|
1616
|
+
text:{ type:"string", description:"rename: the new text. comment: the comment body (plain text; http/mailto links become clickable)" },
|
|
826
1617
|
note:{ type:"string", description:"note: the note body (empty string clears it)" },
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1618
|
+
col:{ type:"string", description:"move: the kanban column — its NAME (\"Doing\") or id, both shown by read_map — or \"\" to take the card off the board. add: optional — the column the NEW cards go to" },
|
|
1619
|
+
columns:{ type:"array", description:"columns: the board's columns left to right — [{ name, done?, wip? }]; mark ONE done:true. Existing columns keep their cards when their name is kept", items:{ type:"object", properties:{ name:{ type:"string" }, done:{ type:"boolean" }, wip:{ type:"integer" } }, required:["name"], additionalProperties:false } },
|
|
1620
|
+
start:{ type:"string", description:"start: the start day as YYYY-MM-DD (\"\" removes it)" },
|
|
1621
|
+
lever:{ type:"string", description:"lever: human, think, accelerate or automate (\"\" removes it)" },
|
|
1622
|
+
link:{ type:"string", description:"link: an https:// or mailto: address, or map:<file> to open another map (\"\" removes it)" },
|
|
1623
|
+
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." },
|
|
1624
|
+
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." },
|
|
1625
|
+
data:{ type:"string", description:"img: a data:image/…;base64 URL instead of `file`" },
|
|
1626
|
+
img:{ type:"string", description:"img: pass \"\" to REMOVE the screenshot from that node (otherwise give `file` or `data`)" },
|
|
1627
|
+
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." },
|
|
1628
|
+
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" },
|
|
1629
|
+
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" },
|
|
1630
|
+
priority:{ type:"integer", description:"priority: 1 (highest) to 6, or 0 to remove it" },
|
|
1631
|
+
due:{ type:"string", description:"due: the due day as YYYY-MM-DD (\"\" removes it)" },
|
|
1632
|
+
name:{ type:"string", description:"attr: the attribute's name, e.g. \"Estimate\"" },
|
|
1633
|
+
// ⚠️ UNE seule déclaration de `value` (1.5.0) : il y en avait deux (booléen pour check, texte pour
|
|
1634
|
+
// attr) et la seconde écrasait la première — le schéma disait donc « texte » pour un check, et
|
|
1635
|
+
// Claude envoyait "true", que le contrôle d'un check refuse.
|
|
1636
|
+
value:{ type:["boolean","string"], description:"check: true to tick, false to untick. attr: its value as text, e.g. \"2h\" (\"\" removes the attribute)" },
|
|
1637
|
+
est:{ type:"string", description:"est: the estimate as a duration — \"2h\", \"1h30\", \"45\" (minutes), \"2:15\" — or \"\" to remove it" }
|
|
830
1638
|
}, required:["op"], additionalProperties:false } }
|
|
831
1639
|
}, required:["file","changes"], additionalProperties:false }
|
|
832
1640
|
}
|
|
@@ -836,18 +1644,35 @@ const HANDLERS = {
|
|
|
836
1644
|
async list_maps(){
|
|
837
1645
|
const maps = await loadLibrary();
|
|
838
1646
|
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
|
-
|
|
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}`
|
|
841
|
-
)
|
|
1647
|
+
const lines = maps.map(m=>
|
|
1648
|
+
`• ${m.name} [${m.kind}]${m.space ? " " + (m.team ? "👥 " : "◻︎ ") + m.space + (m.team ? " (" + m.role + ")" : "") : ""}${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}`
|
|
1649
|
+
);
|
|
1650
|
+
// Les espaces où Claude peut créer (`space` des outils de création) — y compris ceux qui sont VIDES,
|
|
1651
|
+
// qu'aucune carte ne ferait apparaître dans la liste.
|
|
1652
|
+
const sps = CLOUD && cloudSpacesCache && cloudSpacesCache.length
|
|
1653
|
+
? "\n\nSpaces: " + cloudSpacesCache.map(x=>"“" + x.name + "”" + (x.role !== "owner" ? " (team, " + x.role + (x.writable ? "" : ", read-only") + ")" : "")).join(", ")
|
|
1654
|
+
: "";
|
|
1655
|
+
return lines.join("\n") + sps;
|
|
842
1656
|
},
|
|
843
1657
|
async read_map(args){
|
|
844
|
-
|
|
845
|
-
const
|
|
1658
|
+
checkRelPath(args.file);
|
|
1659
|
+
const all = await loadLibrary();
|
|
1660
|
+
const m = all.find(x=>x.file===args.file) || all.find(x=>x.file.endsWith(args.file)) || all.find(x=>x.name===args.file);
|
|
1661
|
+
if(!m) throw new Error("No such map: " + args.file + ". Use list_maps to see what is available.");
|
|
1662
|
+
const head = `${m.name} [${m.kind}]${m.space ? " — space “" + m.space + "”" + (m.team ? " (team, " + m.role + ")" : "") : ""} — file: ${m.file}\n`;
|
|
846
1663
|
if(m.kind === "board") return head + boardToText(m.doc);
|
|
1664
|
+
// Les cartes qu'un lien `map:` peut ouvrir, par l'id que l'app leur donne (clé, ou id d'équipe).
|
|
1665
|
+
const maps = new Map();
|
|
1666
|
+
for(const x of all){ maps.set(x.file, { name: x.name, kind: x.kind }); if(x.key) maps.set(x.key, { name: x.name, kind: x.kind }); }
|
|
1667
|
+
const ctx = { cols: (m.doc.kanban && Array.isArray(m.doc.kanban.cols)) ? m.doc.kanban.cols : [], tagNames: kbTagNames(m.doc), maps };
|
|
847
1668
|
// Le rappel sur propose_changes vit ici et pas dans la description de l'outil : c'est au
|
|
848
1669
|
// moment où le plan est sous les yeux qu'il est utile, et il porte la règle de désignation.
|
|
849
|
-
|
|
850
|
-
|
|
1670
|
+
const ro = m.team && m.role === "viewer";
|
|
1671
|
+
return head + nodeToMarkdown(m.doc.root, 0, ctx) + kanbanToText(m.doc) + teamToText(m.doc)
|
|
1672
|
+
+ (ro ? "\n\n(You can read this team map but not change it — the user's role in its space is viewer.)"
|
|
1673
|
+
: "\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. "
|
|
1674
|
+
+ (CLOUD ? "Safe changes (add, move, tick, owner, dates, labels, lever…) are applied by TabTree itself; rename, note and delete wait for the user's approval.)"
|
|
1675
|
+
: "Nothing is applied until the user approves it in TabTree.)"));
|
|
851
1676
|
},
|
|
852
1677
|
async search_maps(args){
|
|
853
1678
|
const q = String(args.query||"").toLowerCase();
|
|
@@ -879,15 +1704,39 @@ const HANDLERS = {
|
|
|
879
1704
|
// comparé caractère par caractère avec celui de l'app, et lui ajouter une syntaxe le ferait
|
|
880
1705
|
// diverger en silence. Même partage des rôles que côté modèles.
|
|
881
1706
|
tplPicAssign(root);
|
|
1707
|
+
// Les captures d'écran APRÈS les marqueurs : une désignation se retire du texte avant que le
|
|
1708
|
+
// texte serve de cible, et une capture remplace l'illustration du même nœud.
|
|
1709
|
+
const shots = applyImages(root, args.images);
|
|
1710
|
+
// Les DATES (1.5.0) : ce que la vue Gantt dessine. Une cible introuvable ou ambiguë est refusée
|
|
1711
|
+
// en le disant — une date posée sur le mauvais nœud fausserait toute la frise.
|
|
1712
|
+
const dated = [];
|
|
1713
|
+
if(args.dates != null){
|
|
1714
|
+
if(!Array.isArray(args.dates)) throw new Error("`dates` must be a list of { target, start, due }.");
|
|
1715
|
+
args.dates.forEach((d, i)=>{
|
|
1716
|
+
const where = "dates #" + (i+1);
|
|
1717
|
+
const r = propFind(root, String((d && d.target) || ""));
|
|
1718
|
+
if(r.error === "empty") throw new Error(where + ": `target` is required.");
|
|
1719
|
+
if(r.error === "missing") throw new Error(where + ": no node \"" + d.target + "\" in this outline.");
|
|
1720
|
+
if(r.error === "ambiguous") throw new Error(where + ": \"" + d.target + "\" matches " + r.count + " nodes — use \"Ancestor > Node\".");
|
|
1721
|
+
for(const k of ["start", "due"]) if(d[k]){ if(!DAY_RE.test(String(d[k]))) throw new Error(where + ": `" + k + "` must be YYYY-MM-DD."); r.node[k] = String(d[k]); }
|
|
1722
|
+
if(r.node.start && r.node.due && r.node.due < r.node.start){ const t = r.node.start; r.node.start = r.node.due; r.node.due = t; }
|
|
1723
|
+
dated.push(r.node);
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
const view = ["note", "gantt"].includes(args.view) ? args.view : "map";
|
|
1727
|
+
if(view === "gantt" && !dated.length) throw new Error("A Gantt needs dates — pass `dates` with a start and/or due day for the nodes to draw.");
|
|
1728
|
+
assignIds(root);
|
|
882
1729
|
const doc = { v:1, root, images:[], stickies:[] };
|
|
883
1730
|
const st = okStyleMcp(args.style);
|
|
884
1731
|
if(st) doc.style = st;
|
|
885
|
-
const file = await writeDoc(args.name || root.text, doc, args.folder);
|
|
886
|
-
|
|
1732
|
+
const file = await writeDoc(args.name || root.text, doc, args.folder, { kind: view, space: args.space });
|
|
1733
|
+
const shotLines = shots.length ? `\n📷 ${shots.length} screenshot(s) on the map:\n` + shots.map(x=>" • " + x).join("\n") + "\n" : "";
|
|
1734
|
+
const label = view === "note" ? "Note" : view === "gantt" ? "Gantt" : "Map";
|
|
1735
|
+
return `✅ ${label} created (${countNodes(root)} nodes${dated.length ? ", " + dated.length + " dated" : ""}): ${file}\n${shotLines}${importHint()}`;
|
|
887
1736
|
},
|
|
888
1737
|
async create_board(args){
|
|
889
1738
|
const { doc, warnings } = buildBoardDoc(args.name || "Board", args.elements, args.connections, args.style);
|
|
890
|
-
const file = await writeDoc(args.name || "Board", doc, args.folder);
|
|
1739
|
+
const file = await writeDoc(args.name || "Board", doc, args.folder, { space: args.space });
|
|
891
1740
|
const n = doc.shapes.length + doc.stickies.length;
|
|
892
1741
|
// Les avertissements passent AVANT l'astuce d'import : c'est le seul moment où le texte
|
|
893
1742
|
// peut encore être raccourci, et une ligne noyée en fin de réponse ne se lit pas.
|
|
@@ -899,6 +1748,15 @@ const HANDLERS = {
|
|
|
899
1748
|
: "";
|
|
900
1749
|
return `✅ Board created (${n} elements, ${doc.arrows.length} connection(s)): ${file}\n${warn}${importHint()}`;
|
|
901
1750
|
},
|
|
1751
|
+
async create_kanban(args){
|
|
1752
|
+
if(typeof args.name !== "string" || !args.name.trim()) throw new Error("The `name` parameter is required (the board's name).");
|
|
1753
|
+
const doc = buildKanbanDoc(args);
|
|
1754
|
+
const file = await writeDoc(args.name, doc, args.folder, { kind: "kanban", space: args.space });
|
|
1755
|
+
const cards = doc.root.children;
|
|
1756
|
+
const per = doc.kanban.cols.map(k => k.name + " " + cards.filter(c => k.done ? c.check === true : (c.col === k.id && c.check !== true)).length).join(" · ");
|
|
1757
|
+
return `✅ Kanban created — ${cards.length} card(s): ${per}\n${file}\n${importHint()}`
|
|
1758
|
+
+ (CLOUD ? "\nIt opens as a kanban. To add or move cards later, use propose_changes on this file — those changes apply by themselves." : "");
|
|
1759
|
+
},
|
|
902
1760
|
async propose_changes(args){
|
|
903
1761
|
const m = await findMap(args.file);
|
|
904
1762
|
// Un persona est la SEULE op qui vise un board — la work map en est un — et il ne vise que ça.
|
|
@@ -906,26 +1764,36 @@ const HANDLERS = {
|
|
|
906
1764
|
if(m.kind === "board" && !allPersona)
|
|
907
1765
|
throw new Error("Proposals only apply to mind maps. A board has free positions and drawings, which a change list cannot describe.");
|
|
908
1766
|
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
|
|
1767
|
+
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 —”).");
|
|
1768
|
+
if(m.team && m.role === "viewer") throw new Error("“" + m.name + "” is in a team space where the user is a viewer — it can be read, not changed. Ask the space's owner for the editor role.");
|
|
910
1769
|
const ops = buildProposalOps(m.doc, args.changes);
|
|
911
|
-
const
|
|
1770
|
+
const res = await writeProposal(m, args.note, ops, { review: allPersona });
|
|
912
1771
|
if(allPersona){
|
|
913
|
-
return `📮 Interview filed for “${m.name}” — ${ops[0].procs
|
|
1772
|
+
return `📮 Interview filed for “${m.name}” — ${ops[0].domains ? ops[0].domains + " domains" : ops[0].procs + " processes"}: ${res.ids.review}\n`
|
|
914
1773
|
+ "NOTHING has been changed yet. The canvas file is untouched.\n"
|
|
915
1774
|
+ (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
1775
|
+ "One click rebuilds the canvas AND its companion sheet from these hours; Cmd+Z undoes it.\n"
|
|
917
1776
|
+ "Tell the user to look at TabTree — do not claim the map has been re-cast.";
|
|
918
1777
|
}
|
|
919
|
-
const
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
+ (adds ? `, ${adds} node(s) to add` : "")
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1778
|
+
const tally = (list)=>{
|
|
1779
|
+
const adds = list.reduce((n,o)=>n + (o.adds||0), 0), drops = list.reduce((n,o)=>n + (o.drops||0), 0);
|
|
1780
|
+
const shots = list.filter(o=>o.op === "img" && o.img).length;
|
|
1781
|
+
return list.length + " change(s)" + (adds ? `, ${adds} node(s) to add` : "") + (drops ? `, ${drops} node(s) to remove` : "") + (shots ? `, ${shots} screenshot(s)` : "");
|
|
1782
|
+
};
|
|
1783
|
+
const shotLines = ops.filter(o=>o.op === "img" && o.img).map(o=>" 📷 “" + String(o.was || o.target).slice(0, 60) + "” ← " + o.shot).join("\n");
|
|
1784
|
+
let out = "";
|
|
1785
|
+
if(res.auto.length){
|
|
1786
|
+
out += `⚡ ${tally(res.auto)} sent to “${m.name}” — TabTree applies them BY ITSELF within seconds when it is open on one of the user's devices (signed in), otherwise the next time it opens. `
|
|
1787
|
+
+ "Cmd+Z undoes them. They have not been applied yet at this instant: do not read the map back expecting them — tell the user they are on their way.\n";
|
|
1788
|
+
}
|
|
1789
|
+
if(res.review.length){
|
|
1790
|
+
out += `📮 Proposal filed for “${m.name}” — ${tally(res.review)}${res.auto.length ? " (everything from the first change that rewrites or removes something)" : ""}: ${CLOUD ? "cloud:" + res.ids.review : res.ids.review}\n`
|
|
1791
|
+
+ "NOTHING has been changed yet by these. The map is untouched until the user approves them.\n"
|
|
1792
|
+
+ (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). ")
|
|
1793
|
+
+ "The user ticks the changes they want, applies them, and Cmd+Z undoes the lot.\n"
|
|
1794
|
+
+ "Tell the user to look at TabTree — do not claim those changes are made.\n";
|
|
1795
|
+
}
|
|
1796
|
+
return out + (shotLines ? shotLines + "\n" : "");
|
|
929
1797
|
}
|
|
930
1798
|
};
|
|
931
1799
|
|
|
@@ -947,7 +1815,7 @@ function handle(msg){
|
|
|
947
1815
|
// déjà dérivé (1.0.0 ici, 1.1.0 dans le manifeste) sans que rien ne le
|
|
948
1816
|
// signale : un client affiche l'une, le registre publie l'autre. Un
|
|
949
1817
|
// autotest les compare désormais toutes les quatre.
|
|
950
|
-
serverInfo: { name: "tabtree", version: "1.
|
|
1818
|
+
serverInfo: { name: "tabtree", version: "1.5.0" }
|
|
951
1819
|
});
|
|
952
1820
|
} else if(method === "notifications/initialized" || (method||"").startsWith("notifications/")){
|
|
953
1821
|
// notification : pas de réponse
|