tabtree-mcp 1.4.0 → 1.6.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 +101 -216
- package/package.json +4 -4
- package/tabtree-mcp.mjs +886 -135
package/tabtree-mcp.mjs
CHANGED
|
@@ -7,14 +7,18 @@
|
|
|
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
|
|
|
@@ -24,7 +28,12 @@ import { execFileSync } from "node:child_process";
|
|
|
24
28
|
import { inflateSync, deflateSync } from "node:zlib";
|
|
25
29
|
import { tmpdir, platform } from "node:os";
|
|
26
30
|
|
|
27
|
-
|
|
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;
|
|
28
37
|
// ---------------------------------------------------------------------------
|
|
29
38
|
// DEUX SOURCES, UN CONNECTEUR (2026-09-08). Le dossier 🛟 sert TabTree Classic (le fichier
|
|
30
39
|
// local) ; le CLOUD sert l'abonné TabTree SaaS. La source est choisie par la présence d'une
|
|
@@ -39,7 +48,11 @@ let API_KEY = String(process.env.TABTREE_API_KEY || "").trim();
|
|
|
39
48
|
// Trousseau et liée à l'installation précédente). `CLOUD = !!API_KEY` prenait ce littéral pour
|
|
40
49
|
// une clé, l'envoyait, et le serveur répondait « Unknown or revoked connector key » : un
|
|
41
50
|
// message FAUX, qui envoie chercher une clé révoquée là où il n'y a simplement rien de saisi.
|
|
42
|
-
|
|
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 = "";
|
|
43
56
|
const API_URL = String(process.env.TABTREE_API_URL || "https://aynmiptyvisxoslzkzxn.supabase.co/functions/v1/mcp").trim();
|
|
44
57
|
const CLOUD = !!API_KEY;
|
|
45
58
|
// La FORME d'une clé se vérifie ici, avant tout aller-retour : l'app en fabrique une en
|
|
@@ -57,7 +70,9 @@ async function cloudCall(action, body){
|
|
|
57
70
|
try{
|
|
58
71
|
r = await fetch(API_URL, { method:"POST",
|
|
59
72
|
headers:{ "content-type":"application/json", "x-tabtree-key": API_KEY },
|
|
60
|
-
|
|
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 || {})) });
|
|
61
76
|
}catch(e){ throw new Error("Could not reach the TabTree cloud (" + (e && e.message || e) + ")."); }
|
|
62
77
|
let j = null; try{ j = await r.json(); }catch(e){}
|
|
63
78
|
if(!r.ok || !j || j.ok === false) throw new Error((j && j.error) || ("The TabTree cloud answered HTTP " + r.status + "."));
|
|
@@ -75,6 +90,7 @@ const PROP_DIR = "Propositions";
|
|
|
75
90
|
// ---------------------------------------------------------------------------
|
|
76
91
|
function dirOk(){
|
|
77
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.");
|
|
78
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.)");
|
|
79
95
|
if(!existsSync(DIR)) throw new Error("The TABTREE_DIR folder does not exist: " + DIR);
|
|
80
96
|
}
|
|
@@ -167,6 +183,10 @@ const ART_IDS_MCP = [
|
|
|
167
183
|
"pencil", "gradcap", "calc", "palette", "ruler", "backpack"
|
|
168
184
|
];
|
|
169
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);
|
|
170
190
|
const EMO_PIC_PAT = "\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?)*";
|
|
171
191
|
const PIC_EMO_RE = (()=>{
|
|
172
192
|
try{ return new RegExp("^(?:" + EMO_PIC_PAT + ")$", "u"); }
|
|
@@ -176,7 +196,7 @@ const ART = Object.fromEntries(ART_IDS_MCP.map(id=>[id, true]));
|
|
|
176
196
|
})();
|
|
177
197
|
function okPic(v){
|
|
178
198
|
if(typeof v !== "string" || !v || v.length > PIC_MAX) return "";
|
|
179
|
-
if(v.startsWith("a:")) return
|
|
199
|
+
if(v.startsWith("a:")) return artHas(v.slice(2)) ? v : "";
|
|
180
200
|
if(v.startsWith("e:")){
|
|
181
201
|
const ch = v.slice(2);
|
|
182
202
|
return (ch && PIC_EMO_RE.test(ch)) ? "e:" + ch : "";
|
|
@@ -462,6 +482,53 @@ function applyImages(root, images){
|
|
|
462
482
|
return notes;
|
|
463
483
|
}
|
|
464
484
|
|
|
485
|
+
// --- Les IMAGES d'un board (2026-10-23) ----------------------------------------------
|
|
486
|
+
// Un logo, une photo, une affiche posés LIBREMENT sur la toile : `doc.images` de l'app
|
|
487
|
+
// ({ id, src, x, y, w, h }), le même tableau que remplit un glisser-déposer. Rapporté : « l'outil
|
|
488
|
+
// qui crée les boards ne sait poser que des pictogrammes intégrés, pas tes fichiers de logo » —
|
|
489
|
+
// Claude avait dû écrire le fichier du board à la main. Un PNG/JPEG/WebP/GIF passe par la porte
|
|
490
|
+
// des captures (`coverFrom` : même budget, même ré-encodage) ; un SVG — la forme ordinaire d'un
|
|
491
|
+
// logo — est gardé tel quel, sans rasterisation (aucun codec ici). Il ne court aucun risque :
|
|
492
|
+
// l'app le peint dans un `<image href>`, où un script ne s'exécute jamais, et `deserialize`
|
|
493
|
+
// accepte tout `data:image/` sur un élément image (c'est ainsi que vit le logo de la toile Ai4X).
|
|
494
|
+
const SVG_BUDGET = 300000;
|
|
495
|
+
function svgSize(txt){
|
|
496
|
+
const head = (/<svg\b[^>]*>/i.exec(txt) || [""])[0];
|
|
497
|
+
const num = re => { const m = re.exec(head); const v = m ? parseFloat(m[1]) : NaN; return v > 0 ? v : 0; };
|
|
498
|
+
let w = num(/\swidth\s*=\s*["']\s*([\d.]+)\s*(?:px)?\s*["']/i), h = num(/\sheight\s*=\s*["']\s*([\d.]+)\s*(?:px)?\s*["']/i);
|
|
499
|
+
const vb = /\sviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(head);
|
|
500
|
+
if((!w || !h) && vb){ w = parseFloat(vb[1]); h = parseFloat(vb[2]); }
|
|
501
|
+
return { w, h };
|
|
502
|
+
}
|
|
503
|
+
function boardImageFrom(spec, where){
|
|
504
|
+
if(spec && typeof spec.data === "string" && /^data:image\/svg\+xml/i.test(spec.data.trim())){
|
|
505
|
+
const d = spec.data.trim();
|
|
506
|
+
if(d.length > SVG_BUDGET) throw new Error(`${where}: the SVG is over ${Math.round(SVG_BUDGET / 1024)} KB.`);
|
|
507
|
+
let txt = "";
|
|
508
|
+
const b64 = /^data:image\/svg\+xml;base64,([A-Za-z0-9+/=\s]+)$/i.exec(d);
|
|
509
|
+
if(b64) txt = Buffer.from(b64[1].replace(/\s+/g, ""), "base64").toString("utf8");
|
|
510
|
+
else { const c = d.indexOf(","); try{ txt = decodeURIComponent(d.slice(c + 1)); }catch(e){ txt = ""; } }
|
|
511
|
+
if(!/<svg\b/i.test(txt)) throw new Error(`${where}: \`data\` is not an SVG image.`);
|
|
512
|
+
return { src: "data:image/svg+xml;base64," + Buffer.from(txt, "utf8").toString("base64"), ...svgSize(txt), note: `inline SVG kept as is` };
|
|
513
|
+
}
|
|
514
|
+
if(spec && typeof spec.file === "string" && spec.file.trim()){
|
|
515
|
+
const p = resolve(spec.file.trim());
|
|
516
|
+
if(existsSync(p) && statSync(p).isFile() && statSync(p).size <= IMG_FILE_MAX){
|
|
517
|
+
const raw = readFileSync(p);
|
|
518
|
+
const head = raw.subarray(0, 512).toString("utf8").replace(/^\uFEFF/, "").trimStart();
|
|
519
|
+
if(/^(<\?xml|<svg\b|<!--|<!DOCTYPE svg)/i.test(head) && /<svg\b/i.test(raw.toString("utf8"))){
|
|
520
|
+
const txt = raw.toString("utf8");
|
|
521
|
+
const src = "data:image/svg+xml;base64," + Buffer.from(txt, "utf8").toString("base64");
|
|
522
|
+
if(src.length > SVG_BUDGET) throw new Error(`${where}: ${basename(p)} is an SVG of ${Math.round(raw.length / 1024)} KB — over the ${Math.round(SVG_BUDGET / 1024)} KB budget. Simplify it, or export it as a PNG.`);
|
|
523
|
+
return { src, ...svgSize(txt), note: `${basename(p)} (SVG, ${Math.round(src.length / 1024)} KB, kept as is)` };
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
const { img, note } = coverFrom(spec, where);
|
|
528
|
+
const info = imageInfo(Buffer.from(img.slice(img.indexOf(",") + 1), "base64")) || {};
|
|
529
|
+
return { src: img, w: info.w || 0, h: info.h || 0, note };
|
|
530
|
+
}
|
|
531
|
+
|
|
465
532
|
// ---------------------------------------------------------------------------
|
|
466
533
|
// Désignation d'un nœud (partagé mot pour mot avec l'app — voir tabtree-mcp.test.mjs)
|
|
467
534
|
//
|
|
@@ -509,16 +576,33 @@ function propFind(root, spec){
|
|
|
509
576
|
// prédécesseur ou cocherait une tâche qui repart aussitôt, sans rien savoir de ce qu'il fait.
|
|
510
577
|
const RECUR_LABEL = { d:"daily", wd:"weekdays", w:"weekly", "2w":"every 2 weeks", m:"monthly", q:"quarterly", y:"yearly" };
|
|
511
578
|
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"); }
|
|
512
|
-
|
|
579
|
+
// `ctx` (1.5.0) : ce que la carte sait du KANBAN et des autres cartes — la colonne de chaque carte,
|
|
580
|
+
// son responsable, ses dates, sa priorité, ses étiquettes, et le NOM de la carte qu'un lien `map:`
|
|
581
|
+
// ouvre (un board attaché à une carte kanban se lit « ⟪opens “Vision” [board]⟫ », pas « map:x7 »).
|
|
582
|
+
function nodeToMarkdown(n, depth, ctx){
|
|
513
583
|
const lines = [];
|
|
584
|
+
const cx = ctx || {};
|
|
585
|
+
const colName = new Map(((cx.cols) || []).map(k => [k.id, k]));
|
|
586
|
+
const doneCol = ((cx.cols) || []).find(k => k.done) || null;
|
|
514
587
|
const byId = new Map();
|
|
515
588
|
(function idx(node){ if(node && node.id) byId.set(node.id, node); (node.children||[]).forEach(idx); })(n);
|
|
516
589
|
(function walk(node, d){
|
|
517
590
|
let bullet;
|
|
518
591
|
if(d===0) bullet = "# ";
|
|
519
592
|
else bullet = " ".repeat(d-1) + (node.check!=null ? (node.check ? "- [x] " : "- [ ] ") : "- ");
|
|
520
|
-
const
|
|
593
|
+
const mlink = /^map:([A-Za-z0-9_]+)$/.exec(String(node.link || ""));
|
|
594
|
+
const linked = mlink && cx.maps ? cx.maps.get(mlink[1]) : null;
|
|
595
|
+
const body = node.link && !mlink ? `[${node.text}](${node.link})` : node.text;
|
|
521
596
|
const marks = [];
|
|
597
|
+
if(mlink) marks.push(linked ? "⟪opens “" + linked.name + "” [" + linked.kind + "]⟫" : "⟪opens another map (" + mlink[1] + ")⟫");
|
|
598
|
+
if(d > 0 && colName.size){
|
|
599
|
+
if(node.check === true && doneCol && node.col) marks.push("⟪column: " + doneCol.name + "⟫");
|
|
600
|
+
else if(node.col && colName.has(node.col)) marks.push("⟪column: " + colName.get(node.col).name + "⟫");
|
|
601
|
+
}
|
|
602
|
+
if(typeof node.assign === "string" && node.assign.trim()) marks.push("⟪owner: " + node.assign.trim() + "⟫");
|
|
603
|
+
if(node.start || node.due) marks.push("⟪" + (node.start && node.due ? node.start + " → " + node.due : node.due ? "due " + node.due : "starts " + node.start) + "⟫");
|
|
604
|
+
if(Number.isInteger(node.priority) && node.priority > 0) marks.push("⟪P" + node.priority + "⟫");
|
|
605
|
+
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(", ") + "⟫");
|
|
522
606
|
if(node.note && node.note.trim()) marks.push("⟪note⟫");
|
|
523
607
|
if(Array.isArray(node.deps) && node.deps.length){
|
|
524
608
|
const names = node.deps.map(id => byId.get(id)).filter(Boolean).map(p => String(p.text || "").trim()).filter(Boolean);
|
|
@@ -590,7 +674,25 @@ function fitSize(text, w, h, kind, want){
|
|
|
590
674
|
|
|
591
675
|
function buildBoardDoc(name, elements, connections, style){
|
|
592
676
|
if(!Array.isArray(elements) || !elements.length) throw new Error("`elements` must be a non-empty list.");
|
|
677
|
+
const imgCount = elements.filter(e=>e && e.kind === "image").length;
|
|
678
|
+
if(imgCount > 60) throw new Error("Too many images in one board (60 max).");
|
|
679
|
+
const imageNotes = [];
|
|
593
680
|
const els = elements.map((e,i)=>{
|
|
681
|
+
if(e && e.kind === "image"){
|
|
682
|
+
const where = "elements[" + i + "] (image" + (e.id != null ? " “" + e.id + "”" : "") + ")";
|
|
683
|
+
const im = boardImageFrom(e, where);
|
|
684
|
+
imageNotes.push(im.note);
|
|
685
|
+
// La boîte garde les PROPORTIONS de l'image : un logo étiré se voit tout de suite. Une
|
|
686
|
+
// seule cote donnée → l'autre en découle ; aucune → 220 px sur le côté long.
|
|
687
|
+
const ar = (im.w > 0 && im.h > 0) ? im.w / im.h : 1;
|
|
688
|
+
let w = (+e.w >= 24) ? Math.round(+e.w) : null, h = (+e.h >= 24) ? Math.round(+e.h) : null;
|
|
689
|
+
if(w && !h) h = Math.max(24, Math.round(w / ar));
|
|
690
|
+
else if(h && !w) w = Math.max(24, Math.round(h * ar));
|
|
691
|
+
else if(!w && !h){ if(ar >= 1){ w = 220; h = Math.max(24, Math.round(220 / ar)); } else { h = 220; w = Math.max(24, Math.round(220 * ar)); } }
|
|
692
|
+
return { lid: String(e.id != null ? e.id : i), kind: "image", text: "", src: im.src,
|
|
693
|
+
x: Number.isFinite(+e.x) && e.x!=null ? Math.round(+e.x) : null,
|
|
694
|
+
y: Number.isFinite(+e.y) && e.y!=null ? Math.round(+e.y) : null, w, h };
|
|
695
|
+
}
|
|
594
696
|
const kind = KINDS.includes(e.kind) ? e.kind : "rect";
|
|
595
697
|
return { lid: String(e.id != null ? e.id : i), kind, text: String(e.text||""),
|
|
596
698
|
x: Number.isFinite(+e.x) && e.x!=null ? Math.round(+e.x) : null,
|
|
@@ -647,6 +749,7 @@ function buildBoardDoc(name, elements, connections, style){
|
|
|
647
749
|
e.x = 60 + lv*320;
|
|
648
750
|
e.y = 60 + idx*170;
|
|
649
751
|
}
|
|
752
|
+
if(e.kind === "image") continue;
|
|
650
753
|
if(!e.color) e.color = e.kind==="sticky" ? "#fff59d" : (level.get(e.lid)===0 && conns.length ? "#dbeafe" : "#ffffff");
|
|
651
754
|
}
|
|
652
755
|
|
|
@@ -658,6 +761,7 @@ function buildBoardDoc(name, elements, connections, style){
|
|
|
658
761
|
// C'est l'uniformité qui fait qu'une toile remplie a l'air composée.
|
|
659
762
|
const warnings = [];
|
|
660
763
|
for(const e of els){
|
|
764
|
+
if(e.kind === "image") continue;
|
|
661
765
|
const f = fitSize(e.text, e.w, e.h, e.kind, e.size || 15);
|
|
662
766
|
if(!f.shrunk) continue;
|
|
663
767
|
e.size = f.size;
|
|
@@ -667,9 +771,12 @@ function buildBoardDoc(name, elements, connections, style){
|
|
|
667
771
|
}
|
|
668
772
|
|
|
669
773
|
// Sortie au format de l'app : shapes / stickies + refs "sh:i" / "st:i"
|
|
670
|
-
const shapes = [], stickies = [], refOf = new Map();
|
|
774
|
+
const shapes = [], stickies = [], images = [], refOf = new Map();
|
|
671
775
|
for(const e of els){
|
|
672
|
-
if(e.kind==="
|
|
776
|
+
if(e.kind==="image"){
|
|
777
|
+
refOf.set(e.lid, "im:" + images.length);
|
|
778
|
+
images.push({ id: "im" + images.length, src: e.src, x: e.x, y: e.y, w: e.w, h: e.h });
|
|
779
|
+
} else if(e.kind==="sticky"){
|
|
673
780
|
refOf.set(e.lid, "st:" + stickies.length);
|
|
674
781
|
stickies.push({ text:e.text, x:e.x, y:e.y, w:e.w, h:e.h, color:e.color, size:e.size||undefined, pic:e.pic||undefined });
|
|
675
782
|
} else {
|
|
@@ -689,12 +796,12 @@ function buildBoardDoc(name, elements, connections, style){
|
|
|
689
796
|
from: refOf.get(c.from), to: refOf.get(c.to) };
|
|
690
797
|
});
|
|
691
798
|
const root = makeNode(name); root.color = "#dbeafe";
|
|
692
|
-
const doc = { v:1, root, images
|
|
799
|
+
const doc = { v:1, root, images, stickies, board:true, shapes, arrows, draws:[] };
|
|
693
800
|
// `style` n'est émis que s'il est posé : l'app elle-même l'omet quand il vaut le défaut
|
|
694
801
|
// (styleParDefaut), et le JSON d'un board ordinaire ne doit pas gagner un octet.
|
|
695
802
|
const st = okStyleMcp(style);
|
|
696
803
|
if(st) doc.style = st;
|
|
697
|
-
return { doc, warnings };
|
|
804
|
+
return { doc, warnings, imageNotes };
|
|
698
805
|
}
|
|
699
806
|
|
|
700
807
|
// Habillage du document : familles de caractères, forme des nœuds, fond de page. Liste
|
|
@@ -803,29 +910,72 @@ function scanLibrary(){
|
|
|
803
910
|
return out;
|
|
804
911
|
}
|
|
805
912
|
// La bibliothèque du COMPTE, présentée exactement comme celle du dossier : mêmes champs, donc
|
|
806
|
-
// les
|
|
807
|
-
//
|
|
913
|
+
// les outils n'ont rien à savoir de la source. `file` est l'identifiant que l'APP donne à la carte
|
|
914
|
+
// (≡ `teamLocalId`) : sa clé pour une carte à moi, « T<10 hexa du propriétaire>_<clé> » pour une
|
|
915
|
+
// carte d'équipe — c'est lui qu'une proposition doit porter, l'app le cherche dans SA bibliothèque.
|
|
916
|
+
//
|
|
917
|
+
// ⚠️ PAGINÉE (1.5.0) : la version précédente lisait 300 documents et s'arrêtait EN SILENCE — la
|
|
918
|
+
// recherche ratait des cartes sur un gros compte sans le dire. On boucle jusqu'au `total`.
|
|
919
|
+
function teamLocalId(owner, key){
|
|
920
|
+
const t = String(owner || "").replace(/-/g, "").toLowerCase().slice(0, 10);
|
|
921
|
+
if(!/^[0-9a-f]{10}$/.test(t) || !/^[A-Za-z0-9_]{1,52}$/.test(String(key || ""))) return null;
|
|
922
|
+
return "T" + t + "_" + key;
|
|
923
|
+
}
|
|
924
|
+
let cloudSpacesCache = null; // les espaces vus au dernier chargement — la création les résout par nom
|
|
925
|
+
let cloudMe = null; // le compte de la clé, vu au dernier chargement — la récolte en a besoin pour « Personal »
|
|
808
926
|
async function cloudLibrary(){
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
927
|
+
const pages = [];
|
|
928
|
+
let total = Infinity, off = 0, j = null;
|
|
929
|
+
while(off < total && off < 5000){
|
|
930
|
+
j = await cloudCall("docs", { limit: 100, offset: off });
|
|
931
|
+
const got = j.maps || [];
|
|
932
|
+
pages.push(...got);
|
|
933
|
+
total = typeof j.total === "number" ? j.total : got.length; // edge function d'avant la 1.5.0 : une seule page
|
|
934
|
+
if(!got.length || typeof j.total !== "number") break;
|
|
935
|
+
off += got.length;
|
|
936
|
+
}
|
|
937
|
+
const me = (j && j.me) || null;
|
|
938
|
+
cloudMe = me;
|
|
939
|
+
cloudSpacesCache = (j && j.spaces) || [];
|
|
940
|
+
const spaceName = (owner, key)=>{ const sp = cloudSpacesCache.find(x=>x.owner === owner && x.space_key === key); return sp ? sp.name : null; };
|
|
941
|
+
const folders = new Map((j && j.folders || []).filter(f=>f && f.folder_key).map(f=>[(f.owner || "") + "|" + f.folder_key, f]));
|
|
942
|
+
const pathOf = (owner, k)=>{
|
|
812
943
|
const out = [], seen = new Set();
|
|
813
|
-
let f = folders.get(k);
|
|
814
|
-
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; }
|
|
944
|
+
let f = folders.get((owner || "") + "|" + k) || folders.get("|" + k);
|
|
945
|
+
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; }
|
|
815
946
|
return out.length ? out.join(" / ") : null;
|
|
816
947
|
};
|
|
948
|
+
// L'ESPACE d'une carte (1.6.0, pour la récolte) : le sien, sinon celui du dossier RACINE qui la contient —
|
|
949
|
+
// la règle de l'app (`spaceOfMapIn` : ce qui est dans un dossier hérite de la racine). Sans elle, une carte
|
|
950
|
+
// « Company knowledge » rangée dans un dossier d'un espace passerait pour une carte de Personal.
|
|
951
|
+
const rootSpaceOf = (owner, k)=>{
|
|
952
|
+
const seen = new Set();
|
|
953
|
+
let f = folders.get((owner || "") + "|" + k) || folders.get("|" + k);
|
|
954
|
+
while(f && f.parent_key && !seen.has(f.folder_key) && seen.size <= 16){ seen.add(f.folder_key); f = folders.get((owner || "") + "|" + f.parent_key) || folders.get("|" + f.parent_key); }
|
|
955
|
+
return (f && f.space_key) || null;
|
|
956
|
+
};
|
|
817
957
|
const out = [];
|
|
818
|
-
for(const r of
|
|
958
|
+
for(const r of pages){
|
|
819
959
|
const doc = r && r.doc;
|
|
820
960
|
if(!doc || !doc.root) continue;
|
|
961
|
+
const owner = r.owner || me;
|
|
962
|
+
const team = !!(owner && me && owner !== me);
|
|
963
|
+
const file = team ? teamLocalId(owner, r.map_key) : r.map_key;
|
|
964
|
+
if(!file) continue;
|
|
821
965
|
out.push({
|
|
822
|
-
file: r.map_key,
|
|
966
|
+
file, key: r.map_key, owner, team,
|
|
967
|
+
role: r.role || "owner",
|
|
968
|
+
space: r.space_key ? spaceName(owner, r.space_key) : null,
|
|
823
969
|
name: String(r.name || "") || String(doc.root.text || "").slice(0, 60) || r.map_key,
|
|
824
|
-
folder: pathOf(r.folder_key),
|
|
825
|
-
|
|
970
|
+
folder: pathOf(owner, r.folder_key),
|
|
971
|
+
// La SORTE de l'index (kanban, note, gantt, sheet) quand le compte la dit : c'est elle qui décide
|
|
972
|
+
// de la vue d'ouverture dans l'app. Le document seul ne distingue que board / carte.
|
|
973
|
+
kind: doc.board ? "board" : (r.kind && r.kind !== "board" ? r.kind : "map"),
|
|
826
974
|
nodes: countNodes(doc.root),
|
|
827
975
|
elements: doc.board ? ((doc.shapes||[]).length + (doc.stickies||[]).length + (doc.arrows||[]).length + (doc.draws||[]).length) : undefined,
|
|
828
976
|
mtime: String(r.updated_at || "").slice(0,16).replace("T"," "),
|
|
977
|
+
updatedAt: String(r.updated_at || ""),
|
|
978
|
+
spaceKey: r.space_key || (r.folder_key ? rootSpaceOf(owner, r.folder_key) : null),
|
|
829
979
|
doc
|
|
830
980
|
});
|
|
831
981
|
}
|
|
@@ -877,52 +1027,73 @@ async function findMap(file){
|
|
|
877
1027
|
// ---------------------------------------------------------------------------
|
|
878
1028
|
// Écriture (toujours un NOUVEAU fichier — jamais de modification en place)
|
|
879
1029
|
// ---------------------------------------------------------------------------
|
|
880
|
-
async function writeDoc(name, doc, folder){
|
|
1030
|
+
async function writeDoc(name, doc, folder, opts){
|
|
1031
|
+
const o = opts || {};
|
|
881
1032
|
if(CLOUD){
|
|
882
1033
|
// Le dossier est un NOM ou un CHEMIN de dossier de bibliothèque : on le résout en clé s'il
|
|
883
1034
|
// existe déjà, sinon la carte arrive à la racine et le texte de réponse le dit
|
|
884
1035
|
// (« no silent caps »).
|
|
885
1036
|
//
|
|
886
|
-
// ⚠️ 2026-09-23 — LE NOM SEUL NE SUFFIT PAS.
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
//
|
|
893
|
-
|
|
1037
|
+
// ⚠️ 2026-09-23 — LE NOM SEUL NE SUFFIT PAS. Un compte qui a « Ai4X » à la racine ET un « Ai4X »
|
|
1038
|
+
// imbriqué dedans faisait tirer au sort. Deux règles : un CHEMIN complet (« Ai4X / Ai4X ») gagne
|
|
1039
|
+
// toujours, et un nom seul prend le dossier le MOINS PROFOND. L'ambiguïté est dite.
|
|
1040
|
+
//
|
|
1041
|
+
// 👥 1.5.0 — LES ESPACES D'ÉQUIPE. `space` (un nom d'espace) dit OÙ : un de mes espaces, ou un
|
|
1042
|
+
// espace dont je suis membre — la carte vit alors chez son PROPRIÉTAIRE, comme toute carte
|
|
1043
|
+
// d'équipe. Les dossiers candidats sont ceux de cet espace ; sans `space`, mes dossiers d'abord,
|
|
1044
|
+
// puis ceux des espaces d'équipe (un dossier d'équipe se désigne aussi par son chemin).
|
|
1045
|
+
const j = await cloudCall("list", {});
|
|
1046
|
+
const me = j.me || null;
|
|
1047
|
+
const spaces = (j.spaces || []).filter(x=>x && x.space_key);
|
|
1048
|
+
const all = (j.folders || []).filter(f=>f && f.folder_key);
|
|
1049
|
+
const byKey = new Map(all.map(f=>[(f.owner || "") + "|" + f.folder_key, f]));
|
|
1050
|
+
const chain = (f0)=>{ const out=[]; const seen=new Set(); let f=f0;
|
|
1051
|
+
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; }
|
|
1052
|
+
return out; };
|
|
1053
|
+
const norm = (x)=>String(x||"").split("/").map(t=>t.trim()).filter(Boolean).join(" / ").toLowerCase();
|
|
1054
|
+
let sp = null;
|
|
1055
|
+
if(o.space){
|
|
1056
|
+
const want = norm(o.space);
|
|
1057
|
+
const hits = spaces.filter(x=>norm(x.name) === want);
|
|
1058
|
+
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") + ".");
|
|
1059
|
+
sp = hits.find(x=>x.owner === me) || hits[0];
|
|
1060
|
+
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."));
|
|
1061
|
+
}
|
|
1062
|
+
let f0 = null, folder_twins = 0;
|
|
894
1063
|
if(folder){
|
|
895
|
-
const j = await cloudCall("list", {});
|
|
896
|
-
const all = (j.folders||[]).filter(f=>f && f.folder_key);
|
|
897
|
-
const byKey = new Map(all.map(f=>[f.folder_key, f]));
|
|
898
|
-
const chain = (k)=>{ const out=[]; const seen=new Set(); let f=byKey.get(k);
|
|
899
|
-
while(f && !seen.has(f.folder_key) && out.length<=16){ seen.add(f.folder_key); out.unshift(f.name); f = f.parent_key ? byKey.get(f.parent_key) : null; }
|
|
900
|
-
return out; };
|
|
901
|
-
const norm = (x)=>String(x||"").split("/").map(t=>t.trim()).filter(Boolean).join(" / ").toLowerCase();
|
|
902
1064
|
const want = norm(folder);
|
|
903
|
-
let
|
|
904
|
-
if(!
|
|
905
|
-
|
|
906
|
-
if(hits.length)
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
folder_twins = hits.length;
|
|
910
|
-
}
|
|
1065
|
+
let cands = all.filter(f=>!sp || (f.owner === sp.owner && f.space_key === sp.space_key));
|
|
1066
|
+
if(!sp) cands = cands.slice().sort((a,b)=>(a.owner === me ? 0 : 1) - (b.owner === me ? 0 : 1));
|
|
1067
|
+
let hits = cands.filter(f=>norm(chain(f).join(" / ")) === want); // chemin exact
|
|
1068
|
+
if(!hits.length) hits = cands.filter(f=>norm(f.name) === want) // nom de feuille
|
|
1069
|
+
.sort((a,b)=>((a.owner === me ? 0 : 1) - (b.owner === me ? 0 : 1)) || (chain(a).length - chain(b).length));
|
|
1070
|
+
if(hits.length){ f0 = hits[0]; folder_twins = hits.length; }
|
|
911
1071
|
}
|
|
912
|
-
const
|
|
913
|
-
const
|
|
914
|
-
|
|
915
|
-
|
|
1072
|
+
const owner = f0 ? (f0.owner || me) : (sp ? sp.owner : me);
|
|
1073
|
+
const kind = o.kind || (doc.board ? "board" : (doc.kanban ? "kanban" : "map"));
|
|
1074
|
+
const body = { name: String(name||"").slice(0,200), kind, doc, folder_key: f0 ? f0.folder_key : null };
|
|
1075
|
+
if(owner && me && owner !== me) body.owner = owner;
|
|
1076
|
+
if(!f0 && sp) body.space_key = sp.space_key;
|
|
1077
|
+
const r = await cloudCall("create", body);
|
|
1078
|
+
// On DIT toujours où la carte a atterri : l'espace, le chemin complet — pas le nom demandé.
|
|
1079
|
+
const spaceOf = (own, key)=>{ const x = spaces.find(y=>y.owner === own && y.space_key === key); return x ? x.name : null; };
|
|
1080
|
+
const inSpace = f0 ? spaceOf(f0.owner, f0.space_key) : (sp ? sp.name : null);
|
|
916
1081
|
let where = "";
|
|
917
|
-
if(
|
|
918
|
-
where = " (in “" +
|
|
1082
|
+
if(f0){
|
|
1083
|
+
where = " (in “" + chain(f0).join(" / ") + "”" + (inSpace ? ", space “" + inSpace + "”" : "") + ")";
|
|
919
1084
|
if(folder_twins > 1) where += " ⚠ " + folder_twins + " folders are named “" + folder
|
|
920
1085
|
+ "” in this account; the shallowest was used. Pass the full path (e.g. “" + folder + " / " + folder
|
|
921
1086
|
+ "”) to choose another one.";
|
|
922
1087
|
} else if(folder){
|
|
923
|
-
where = " (folder “" + folder + "” does not exist in this account — the map is at the root)";
|
|
1088
|
+
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" : "") + ")";
|
|
1089
|
+
} else if(sp){
|
|
1090
|
+
where = " (space “" + sp.name + "”" + (owner !== me ? " — a team space: every member sees it" : "") + ")";
|
|
924
1091
|
}
|
|
925
|
-
|
|
1092
|
+
const id = (owner && me && owner !== me) ? teamLocalId(owner, r.map_key) : r.map_key;
|
|
1093
|
+
// La récolte (1.6.0) crée une carte PUIS y dépose une proposition : il lui faut la clé et le
|
|
1094
|
+
// propriétaire, que la phrase ne rend pas sans perte (une carte d'équipe s'y nomme par son id d'app).
|
|
1095
|
+
if(o.info) return { text: "cloud:" + (id || r.map_key) + where, key: r.map_key, owner, id: id || r.map_key, team: !!(owner && me && owner !== me) };
|
|
1096
|
+
return "cloud:" + (id || r.map_key) + where;
|
|
926
1097
|
}
|
|
927
1098
|
dirOk();
|
|
928
1099
|
let target = DIR;
|
|
@@ -953,7 +1124,22 @@ function countForest(roots){ return roots.reduce((n,r)=>n + countNodes(r), 0); }
|
|
|
953
1124
|
// connecteur la refuserait à l'écriture, ou l'app la jetterait à la lecture — dans les deux
|
|
954
1125
|
// cas Claude croirait avoir proposé quelque chose qui n'arrive jamais chez l'utilisateur.
|
|
955
1126
|
// C'est le défaut du losange, rejoué. Un test compare les deux listes en ensembles.
|
|
956
|
-
const PROP_OPS = ["add","rename","note","check","delete","move","pic","img","persona","tags","assign","priority","due","attr","comment","est"];
|
|
1127
|
+
const PROP_OPS = ["add","rename","note","check","delete","move","pic","img","persona","tags","assign","priority","due","attr","comment","est","start","cols","link","vote"];
|
|
1128
|
+
// ⚡ Les ops que l'app applique D'ELLE-MÊME (1.5.0, décidé « hybride » le 2026-10-20) — ≡
|
|
1129
|
+
// `PROP_AUTO_OPS` d'app/index.html (le test compare les deux). Plus `cols` quand le tableau n'a
|
|
1130
|
+
// pas encore de colonnes (`seed`). C'est l'APP qui décide : le connecteur ne fait que demander
|
|
1131
|
+
// (`auto:true`) et découper le lot pour que le reste parte à la revue.
|
|
1132
|
+
const PROP_AUTO_OPS = ["add","move","check","tags","assign","priority","due","start","est","attr","comment","pic","img","link"];
|
|
1133
|
+
// Les ops que Claude peut ÉCRIRE et qui n'existent pas telles quelles dans l'app : `lever` devient
|
|
1134
|
+
// un attribut « Lever: <clé> » (c'est ainsi que l'app le range, `KB_LEVER_ATTR`), `columns` est le
|
|
1135
|
+
// nom lisible de `cols`.
|
|
1136
|
+
const INPUT_OPS = PROP_OPS.concat(["lever", "columns"]);
|
|
1137
|
+
// Les quatre leviers Ai4X — ≡ les clés de `KB_LEVERS` d'app/index.html (le test compare).
|
|
1138
|
+
const KB_LEVER_KEYS_MCP = ["human","think","accelerate","automate"];
|
|
1139
|
+
// Un identifiant de nœud neuf, de la forme qu'`okNodeId` accepte. Posé sur chaque `add` (`nid`) :
|
|
1140
|
+
// c'est ce qui rend l'ajout IDEMPOTENT dans l'app — rejoué par deux appareils, il ne double pas.
|
|
1141
|
+
function newNid(){ return "mcp" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
|
|
1142
|
+
function cloneDoc(d){ return JSON.parse(JSON.stringify(d)); }
|
|
957
1143
|
// Les dix étiquettes de couleur du kanban — ≡ `KB_TAGS` de index.html (le test compare les deux).
|
|
958
1144
|
const KB_TAG_KEYS = ["green","yellow","orange","red","purple","blue","sky","lime","pink","gray"];
|
|
959
1145
|
|
|
@@ -962,13 +1148,54 @@ const KB_TAG_KEYS = ["green","yellow","orange","red","purple","blue","sky","lime
|
|
|
962
1148
|
// alors qu'une proposition à moitié valide arriverait chez l'utilisateur sous forme de
|
|
963
1149
|
// lignes barrées qu'il ne peut ni comprendre ni réparer. La boîte aux lettres ne reçoit
|
|
964
1150
|
// que des propositions applicables.
|
|
965
|
-
|
|
1151
|
+
// ⚠️ SÉQUENTIEL depuis la 1.5.0 : chaque op est validée contre la carte TELLE QUE LES OPS
|
|
1152
|
+
// PRÉCÉDENTES L'ONT LAISSÉE (une copie, `work`). Sans ça, « ajoute la carte X, puis mets-lui une
|
|
1153
|
+
// échéance » échouait : X n'existait pas encore dans la carte lue. L'app applique dans le même
|
|
1154
|
+
// ordre, donc la cible validée ici est celle qu'elle trouvera.
|
|
1155
|
+
function buildProposalOps(mapDoc0, changes){
|
|
966
1156
|
if(!Array.isArray(changes) || !changes.length) throw new Error("`changes` must be a non-empty list.");
|
|
967
1157
|
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.");
|
|
1158
|
+
const mapDoc = cloneDoc(mapDoc0);
|
|
1159
|
+
const colsNow = ()=>(mapDoc.kanban && Array.isArray(mapDoc.kanban.cols) && mapDoc.kanban.cols.length) ? mapDoc.kanban.cols : null;
|
|
1160
|
+
// Une colonne se désigne par son id OU par son nom (« Doing ») — Claude lit les noms.
|
|
1161
|
+
const colRef = (raw, where)=>{
|
|
1162
|
+
const cols = colsNow();
|
|
1163
|
+
const v = String(raw);
|
|
1164
|
+
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.`);
|
|
1165
|
+
const hit = cols.find(k => k.id === v) || cols.find(k => String(k.name).trim().toLowerCase() === v.trim().toLowerCase());
|
|
1166
|
+
if(!hit) throw new Error(`${where}: no column "${v}" on this board. Use one of: ${cols.map(k => `"${k.name}" (${k.id})`).join(", ")}.`);
|
|
1167
|
+
return hit.id;
|
|
1168
|
+
};
|
|
968
1169
|
return changes.map((c, i)=>{
|
|
969
1170
|
const where = "change #" + (i+1);
|
|
970
1171
|
const op = String((c && c.op) || "").trim();
|
|
971
|
-
if(!
|
|
1172
|
+
if(!INPUT_OPS.includes(op)) throw new Error(`${where}: unknown op "${op}". Use one of: ${INPUT_OPS.filter(x=>x !== "cols").join(", ")}.`);
|
|
1173
|
+
if(op === "columns" || op === "cols"){
|
|
1174
|
+
// Les colonnes du tableau, en un bloc. Un tableau qui n'en a PAS reçoit `seed` : l'app pose ces
|
|
1175
|
+
// colonnes d'elle-même, sans écraser celles qu'un collègue aurait posées entre-temps. Un tableau
|
|
1176
|
+
// qui en a déjà : un REMPLACEMENT, qui part à la revue (les cartes d'une colonne retirée
|
|
1177
|
+
// quittent le tableau). Un id existant est GARDÉ quand le nom revient — sinon chaque carte de
|
|
1178
|
+
// « Doing » perdrait sa colonne au passage.
|
|
1179
|
+
const list = Array.isArray(c.columns) ? c.columns : (Array.isArray(c.cols) ? c.cols : null);
|
|
1180
|
+
if(!list || !list.length || list.length > 12) throw new Error(`${where}: \`columns\` must list 1 to 12 columns — { name, done?, wip? }.`);
|
|
1181
|
+
const before = colsNow() || [];
|
|
1182
|
+
const used = new Set();
|
|
1183
|
+
const cols = list.map((k, j)=>{
|
|
1184
|
+
const name = String((k && (k.name != null ? k.name : k)) || "").replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, 60);
|
|
1185
|
+
if(!name) throw new Error(`${where}: column #${j+1} has no name.`);
|
|
1186
|
+
const prev = before.find(b => String(b.name).trim().toLowerCase() === name.toLowerCase() && !used.has(b.id));
|
|
1187
|
+
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));
|
|
1188
|
+
while(used.has(id) || (!prev && before.some(b => b.id === id))) id = "c" + Math.random().toString(36).slice(2, 7);
|
|
1189
|
+
used.add(id);
|
|
1190
|
+
const wip = parseInt(k && k.wip, 10);
|
|
1191
|
+
return Object.assign({ id, name }, k && k.done ? { done: true } : {}, (wip >= 1 && wip <= 99) ? { wip } : {});
|
|
1192
|
+
});
|
|
1193
|
+
if(cols.filter(k => k.done).length > 1) throw new Error(`${where}: only one column can be the "done" column.`);
|
|
1194
|
+
const out = { op: "cols", cols };
|
|
1195
|
+
if(!before.length) out.seed = true;
|
|
1196
|
+
mapDoc.kanban = Object.assign({}, mapDoc.kanban || {}, { cols });
|
|
1197
|
+
return out;
|
|
1198
|
+
}
|
|
972
1199
|
if(op === "persona"){
|
|
973
1200
|
// `persona` (2026-09-08) vise le DOCUMENT entier — la toile d'une work map — jamais un
|
|
974
1201
|
// nœud : donc pas de `target`. C'est le relais skill → carte sans copier-coller : le skill
|
|
@@ -1015,15 +1242,13 @@ function buildProposalOps(mapDoc, changes){
|
|
|
1015
1242
|
if(!forest.length) throw new Error(`${where}: \`markdown\` has no usable structure (a bullet list, indented by 2 spaces).`);
|
|
1016
1243
|
out.markdown = c.markdown;
|
|
1017
1244
|
out.adds = countForest(forest);
|
|
1245
|
+
out.nid = newNid();
|
|
1018
1246
|
// `col` facultatif (2026-10-12) : les cartes NEUVES vont dans cette colonne du kanban.
|
|
1019
|
-
// Validé comme pour `move` — une colonne inventée est refusée ICI, en nommant les vraies
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
throw new Error(`${where}: no column "${col}" on this board${cols ? ". Use one of: " + cols.map(k => `"${k.name}" (${k.id})`).join(", ") : " — it has no kanban yet"}.`);
|
|
1025
|
-
out.col = col;
|
|
1026
|
-
}
|
|
1247
|
+
// Validé comme pour `move` — une colonne inventée est refusée ICI, en nommant les vraies ;
|
|
1248
|
+
// un NOM de colonne est accepté et traduit en id (1.5.0).
|
|
1249
|
+
if(c.col != null && c.col !== "") out.col = colRef(c.col, where);
|
|
1250
|
+
// La copie de travail reçoit les nœuds : une op suivante du même lot peut les viser.
|
|
1251
|
+
r.node.children = (r.node.children || []).concat(forest);
|
|
1027
1252
|
// `assign` facultatif (2026-10-12, 👥) : le RESPONSABLE des cartes neuves. Mêmes bornes que l'op
|
|
1028
1253
|
// `assign` ; refusé ICI en le disant, plutôt que de laisser l'app le retirer en silence.
|
|
1029
1254
|
if(c.assign != null && c.assign !== ""){
|
|
@@ -1034,15 +1259,18 @@ function buildProposalOps(mapDoc, changes){
|
|
|
1034
1259
|
} else if(op === "rename"){
|
|
1035
1260
|
if(typeof c.text !== "string" || !c.text.trim()) throw new Error(`${where}: \`text\` is required for a "rename".`);
|
|
1036
1261
|
out.text = c.text.trim().slice(0, 2000);
|
|
1262
|
+
r.node.text = out.text;
|
|
1037
1263
|
} else if(op === "note"){
|
|
1038
1264
|
if(typeof c.note !== "string") throw new Error(`${where}: \`note\` is required for a "note" (an empty string clears it).`);
|
|
1039
1265
|
out.note = c.note.slice(0, 8000);
|
|
1040
1266
|
} else if(op === "check"){
|
|
1041
|
-
|
|
1042
|
-
|
|
1267
|
+
const v = c.value === "true" ? true : c.value === "false" ? false : c.value;
|
|
1268
|
+
if(typeof v !== "boolean") throw new Error(`${where}: \`value\` must be true or false for a "check".`);
|
|
1269
|
+
out.value = v;
|
|
1043
1270
|
} else if(op === "delete"){
|
|
1044
1271
|
if(r.node === mapDoc.root) throw new Error(`${where}: the centre of the map cannot be deleted.`);
|
|
1045
1272
|
out.drops = countNodes(r.node);
|
|
1273
|
+
(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);
|
|
1046
1274
|
} else if(op === "pic"){
|
|
1047
1275
|
// Une désignation, jamais des pixels. La liste est FERMÉE : un id inventé est refusé ICI,
|
|
1048
1276
|
// contre la carte réelle, plutôt que d'arriver chez l'utilisateur sous forme d'une ligne
|
|
@@ -1065,16 +1293,11 @@ function buildProposalOps(mapDoc, changes){
|
|
|
1065
1293
|
} else if(op === "move"){
|
|
1066
1294
|
// ⚠️ Dans le FICHIER les colonnes sont `{cols:[…]}` ; en mémoire, côté app, c'est un
|
|
1067
1295
|
// tableau nu. Confondre les deux donne un contrôle qui ne regarde rien.
|
|
1068
|
-
const cols = (mapDoc.kanban && Array.isArray(mapDoc.kanban.cols)) ? mapDoc.kanban.cols : null;
|
|
1069
|
-
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.`);
|
|
1070
|
-
const col = String(c.col == null ? "" : c.col);
|
|
1071
1296
|
// Vide = retirer du tableau, comme le ✕ d'une carte. Toute autre valeur doit désigner une
|
|
1072
|
-
// colonne RÉELLE : une carte envoyée ailleurs disparaîtrait de l'écran sans un message.
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
}
|
|
1077
|
-
out.col = col;
|
|
1297
|
+
// colonne RÉELLE (id ou nom) : une carte envoyée ailleurs disparaîtrait de l'écran sans un message.
|
|
1298
|
+
const raw = String(c.col == null ? "" : c.col);
|
|
1299
|
+
if(!colsNow()) colRef(raw || "?", where); // lève le message « pas encore de kanban »
|
|
1300
|
+
out.col = raw ? colRef(raw, where) : "";
|
|
1078
1301
|
} else if(op === "tags"){
|
|
1079
1302
|
// Les dix couleurs, liste FERMÉE — ≡ `okTags` de l'app. Une clé inventée est refusée ICI,
|
|
1080
1303
|
// en nommant la liste, plutôt que de tomber en silence à la lecture. [] retire tout.
|
|
@@ -1090,10 +1313,26 @@ function buildProposalOps(mapDoc, changes){
|
|
|
1090
1313
|
const q = (c.priority == null) ? 0 : c.priority;
|
|
1091
1314
|
if(!Number.isInteger(q) || q < 0 || q > 6) throw new Error(`${where}: \`priority\` must be a whole number from 1 (highest) to 6, or 0 to remove it.`);
|
|
1092
1315
|
out.priority = q;
|
|
1093
|
-
} else if(op === "due"){
|
|
1094
|
-
const q = (c
|
|
1095
|
-
if(q && !/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(q)) throw new Error(`${where}: \`
|
|
1096
|
-
out
|
|
1316
|
+
} else if(op === "due" || op === "start"){
|
|
1317
|
+
const q = (c[op] == null) ? "" : String(c[op]);
|
|
1318
|
+
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).`);
|
|
1319
|
+
out[op] = q;
|
|
1320
|
+
} else if(op === "vote"){
|
|
1321
|
+
// 👍 Un vote est le geste d'une PERSONNE sur le tableau (app, page publiée) : Claude ne vote pas à la place de quelqu'un.
|
|
1322
|
+
throw new Error(`${where}: "vote" is cast by people on the board, not proposed by Claude.`);
|
|
1323
|
+
} else if(op === "link"){
|
|
1324
|
+
// La liste blanche de l'app (`okOpLink`), en plus étroit : une page web, un e-mail, ou une
|
|
1325
|
+
// autre carte (`map:<id>` — l'id que list_maps donne). Jamais `javascript:` ni `data:`.
|
|
1326
|
+
const q = (c.link == null) ? "" : String(c.link).trim();
|
|
1327
|
+
if(q && !/^(https?:\/\/[^\s\x00-\x1f]+|mailto:[^\s\x00-\x1f]+|map:[A-Za-z0-9_]+)$/i.test(q))
|
|
1328
|
+
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).`);
|
|
1329
|
+
out.link = q;
|
|
1330
|
+
} else if(op === "lever"){
|
|
1331
|
+
// Le levier Ai4X d'une carte. L'app le range en ATTRIBUT « Lever: <clé> » (`kbLeverSet` →
|
|
1332
|
+
// `shAttrSet`) : on écrit exactement ça, par l'op `attr`, que l'app sait appliquer.
|
|
1333
|
+
const q = String(c.lever == null ? "" : c.lever).trim().toLowerCase();
|
|
1334
|
+
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).`);
|
|
1335
|
+
out.op = "attr"; out.name = "Lever"; out.value = q;
|
|
1097
1336
|
} else if(op === "attr"){
|
|
1098
1337
|
if(typeof c.name !== "string" || !c.name.trim() || c.name.length > 60 || /[:\x00-\x1f]/.test(c.name))
|
|
1099
1338
|
throw new Error(`${where}: \`name\` is required for an "attr" — the attribute's name, without a colon (60 characters max).`);
|
|
@@ -1146,19 +1385,37 @@ function parts_last(spec){ const p = String(spec||"").split(">"); return p[p.len
|
|
|
1146
1385
|
// mot, puisqu'elle ne peut pas savoir ce qu'il contenait. Avec des captures d'écran dedans, la
|
|
1147
1386
|
// borne se rencontre : on refuse ICI en nommant le geste, jamais un dépôt qui n'arrive pas.
|
|
1148
1387
|
const PROP_FILE_MAX = 1400000;
|
|
1149
|
-
|
|
1150
|
-
|
|
1388
|
+
// ⚡ LE DÉCOUPAGE « HYBRIDE » (1.5.0). Le début du lot fait d'ops sûres part avec `auto:true` —
|
|
1389
|
+
// l'app l'applique d'elle-même ; à partir de la PREMIÈRE op qui demande une revue, tout le reste
|
|
1390
|
+
// part dans une seconde proposition, à revoir. Découper AU PREMIER refus, jamais op par op : une
|
|
1391
|
+
// op sûre qui suit un renommage vise souvent le texte renommé, et l'appliquer avant lui la ferait
|
|
1392
|
+
// échouer chez l'utilisateur.
|
|
1393
|
+
function autoOk(o){ return o.op === "cols" ? o.seed === true : PROP_AUTO_OPS.includes(o.op); }
|
|
1394
|
+
function splitAuto(ops){
|
|
1395
|
+
let k = 0;
|
|
1396
|
+
while(k < ops.length && autoOk(ops[k])) k++;
|
|
1397
|
+
return { auto: ops.slice(0, k), review: ops.slice(k) };
|
|
1398
|
+
}
|
|
1399
|
+
async function writeProposal(map, note, ops, opts){
|
|
1400
|
+
const o = opts || {};
|
|
1401
|
+
const payload = (id, list, auto)=>Object.assign({
|
|
1151
1402
|
v: 1, kind: "tabtree-proposal", mapId: id, mapFile: CLOUD ? map.file : basename(map.file), mapName: map.name,
|
|
1152
|
-
createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops
|
|
1153
|
-
});
|
|
1154
|
-
const shots = ops.filter(
|
|
1155
|
-
if(JSON.stringify(payload("x")).length > PROP_FILE_MAX)
|
|
1403
|
+
createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops: list
|
|
1404
|
+
}, auto ? { auto: true } : {}, o.extra || {});
|
|
1405
|
+
const shots = ops.filter(x=>x.op === "img" && x.img).length;
|
|
1406
|
+
if(JSON.stringify(payload("x", ops)).length > PROP_FILE_MAX)
|
|
1156
1407
|
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.");
|
|
1157
1408
|
if(CLOUD){
|
|
1158
1409
|
// La boîte aux lettres cloud : la table `proposals`, que l'app relit au sondage suivant.
|
|
1159
1410
|
// Même charge utile que le fichier — c'est le même `parseProposal` qui la lit côté app.
|
|
1160
|
-
|
|
1161
|
-
|
|
1411
|
+
// Une carte d'équipe : la clé chez son propriétaire, et `mapId` = l'id que l'app lui donne.
|
|
1412
|
+
const parts = o.review ? { auto: [], review: ops } : splitAuto(ops);
|
|
1413
|
+
const base = { map_key: map.key || map.file };
|
|
1414
|
+
if(map.team && map.owner) base.owner = map.owner;
|
|
1415
|
+
const ids = {};
|
|
1416
|
+
if(parts.auto.length){ const r = await cloudCall("propose", Object.assign({ payload: payload(map.file, parts.auto, true) }, base)); ids.auto = r.id || "proposal"; }
|
|
1417
|
+
if(parts.review.length){ const r = await cloudCall("propose", Object.assign({ payload: payload(map.file, parts.review, false) }, base)); ids.review = r.id || "proposal"; }
|
|
1418
|
+
return { auto: parts.auto, review: parts.review, ids };
|
|
1162
1419
|
}
|
|
1163
1420
|
dirOk();
|
|
1164
1421
|
const target = join(DIR, PROP_DIR);
|
|
@@ -1166,8 +1423,8 @@ async function writeProposal(map, note, ops){
|
|
|
1166
1423
|
const id = mapIdOf(map.file);
|
|
1167
1424
|
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.");
|
|
1168
1425
|
const file = "prop_" + id + "__" + Date.now().toString(36) + ".json";
|
|
1169
|
-
writeFileSync(join(target, file), JSON.stringify(payload(id), null, 2), "utf8");
|
|
1170
|
-
return PROP_DIR + "/" + file;
|
|
1426
|
+
writeFileSync(join(target, file), JSON.stringify(payload(id, ops, false), null, 2), "utf8");
|
|
1427
|
+
return { auto: [], review: ops, ids: { review: PROP_DIR + "/" + file } };
|
|
1171
1428
|
}
|
|
1172
1429
|
|
|
1173
1430
|
// Ce texte est lu par CHAQUE acheteur, à chaque création. Il a déjà été faux une
|
|
@@ -1189,13 +1446,232 @@ function importHint(){
|
|
|
1189
1446
|
+ "the connector is pointed at a different library — that is why a map can be created and never appear.";
|
|
1190
1447
|
}
|
|
1191
1448
|
|
|
1449
|
+
// ---------------------------------------------------------------------------
|
|
1450
|
+
// 🃏 Le KANBAN (1.5.0) — lire ses colonnes, et en fabriquer un d'un seul appel
|
|
1451
|
+
// ---------------------------------------------------------------------------
|
|
1452
|
+
function kanbanToText(doc){
|
|
1453
|
+
const k = doc && doc.kanban;
|
|
1454
|
+
if(!k || !Array.isArray(k.cols) || !k.cols.length) return "";
|
|
1455
|
+
const cols = k.cols.map(c => "“" + c.name + "” (" + c.id + (c.done ? ", done" : "") + (c.wip ? ", WIP " + c.wip : "") + ")").join(" · ");
|
|
1456
|
+
const tags = (k.tags && typeof k.tags === "object") ? Object.entries(k.tags)
|
|
1457
|
+
.filter(([key, v]) => KB_TAG_KEYS.includes(key) && typeof v === "string" && v.trim())
|
|
1458
|
+
.map(([key, v]) => key + " = “" + v.replace(/[\x00-\x1f\x7f]+/g, " ").trim().slice(0, 40) + "”") : [];
|
|
1459
|
+
return "\n\n🃏 Kanban columns: " + cols + (tags.length ? "\n🏷 Labels: " + tags.join(", ") : "")
|
|
1460
|
+
+ "\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.)";
|
|
1461
|
+
}
|
|
1462
|
+
function kbTagNames(doc){
|
|
1463
|
+
const t = doc && doc.kanban && doc.kanban.tags;
|
|
1464
|
+
const out = {};
|
|
1465
|
+
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);
|
|
1466
|
+
return out;
|
|
1467
|
+
}
|
|
1468
|
+
// Une durée en SECONDES, depuis les formes que `parseDur` lit dans l'app (« 2h », « 1h30 », « 45 »
|
|
1469
|
+
// = minutes, « 2:15 », « 30m », « 90s »). L'estimation d'un nœud est stockée en secondes (`okSecs`).
|
|
1470
|
+
function durSecs(v){
|
|
1471
|
+
const q = String(v == null ? "" : v).trim().toLowerCase().replace(/\s+/g, "");
|
|
1472
|
+
if(!q || !DUR_RE.test(q)) return null;
|
|
1473
|
+
let m;
|
|
1474
|
+
if((m = /^(\d+):([0-5]\d)$/.exec(q))) return (+m[1] * 60 + +m[2]) * 60;
|
|
1475
|
+
if((m = /^(\d+)h(\d+)?m?$/.exec(q))) return (+m[1] * 60 + (+m[2] || 0)) * 60;
|
|
1476
|
+
if((m = /^(\d+)m(in)?$/.exec(q))) return +m[1] * 60;
|
|
1477
|
+
if((m = /^(\d+)s(ec)?$/.exec(q))) return +m[1];
|
|
1478
|
+
return +q * 60;
|
|
1479
|
+
}
|
|
1480
|
+
const DAY_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
|
1481
|
+
// Un identifiant sur chaque nœud créé : l'app en donnerait un à l'ouverture, mais la signature du
|
|
1482
|
+
// document changerait alors entre le serveur et l'app (un aller-retour de synchro pour rien), et
|
|
1483
|
+
// une proposition suivante ne pourrait viser une carte que par son texte.
|
|
1484
|
+
function assignIds(n){ if(!n.id) n.id = "n" + Math.random().toString(36).slice(2, 12); (n.children || []).forEach(assignIds); return n; }
|
|
1485
|
+
// Les colonnes demandées, validées et munies d'ids — même forme qu'`okKanbanCols` côté app.
|
|
1486
|
+
function kanbanColsFrom(list, where){
|
|
1487
|
+
const src = Array.isArray(list) && list.length ? list : [{ name:"To do" }, { name:"Doing" }, { name:"Done", done:true }];
|
|
1488
|
+
if(src.length > 12) throw new Error(where + ": a kanban has 12 columns at most.");
|
|
1489
|
+
const cols = src.map((k, j)=>{
|
|
1490
|
+
const name = String((k && (k.name != null ? k.name : k)) || "").replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, 60);
|
|
1491
|
+
if(!name) throw new Error(where + ": column #" + (j+1) + " has no name.");
|
|
1492
|
+
const wip = parseInt(k && k.wip, 10);
|
|
1493
|
+
return Object.assign({ id: "c" + (j+1), name }, k && k.done ? { done: true } : {}, (wip >= 1 && wip <= 99) ? { wip } : {});
|
|
1494
|
+
});
|
|
1495
|
+
if(cols.filter(k => k.done).length > 1) throw new Error(where + ": only one column can be the \"done\" column.");
|
|
1496
|
+
return cols;
|
|
1497
|
+
}
|
|
1498
|
+
// UN appel, un tableau complet : colonnes, cartes, et sur chaque carte ce que l'app affiche au recto
|
|
1499
|
+
// et au dos — responsable, dates, priorité, étiquettes, estimation, levier Ai4X, note, lien,
|
|
1500
|
+
// sous-tâches. Tout passe par les mêmes bornes que les propositions : une carte qui serait refusée
|
|
1501
|
+
// par l'app à l'ouverture est refusée ICI, en le disant.
|
|
1502
|
+
function buildKanbanDoc(args){
|
|
1503
|
+
const name = String(args.name || "").trim() || "Kanban";
|
|
1504
|
+
const cols = kanbanColsFrom(args.columns, "columns");
|
|
1505
|
+
const work = cols.filter(k => !k.done), done = cols.find(k => k.done) || null;
|
|
1506
|
+
const firstWork = (work[0] || cols[0]).id;
|
|
1507
|
+
const colOf = (raw, where)=>{
|
|
1508
|
+
if(raw == null || raw === "") return firstWork;
|
|
1509
|
+
const v = String(raw).trim().toLowerCase();
|
|
1510
|
+
const hit = cols.find(k => k.id === String(raw)) || cols.find(k => k.name.toLowerCase() === v);
|
|
1511
|
+
if(!hit) throw new Error(where + ": no column \"" + raw + "\". Columns: " + cols.map(k => "“" + k.name + "”").join(", ") + ".");
|
|
1512
|
+
return hit.id;
|
|
1513
|
+
};
|
|
1514
|
+
const cards = Array.isArray(args.cards) ? args.cards : [];
|
|
1515
|
+
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.");
|
|
1516
|
+
const root = makeNode(name);
|
|
1517
|
+
root.color = "#dbeafe";
|
|
1518
|
+
cards.forEach((c, i)=>{
|
|
1519
|
+
const where = "card #" + (i+1);
|
|
1520
|
+
const text = String((c && c.text) || "").replace(/[\x00-\x1f\x7f]/g, " ").trim();
|
|
1521
|
+
if(!text) throw new Error(where + ": `text` is required.");
|
|
1522
|
+
const n = makeNode(text.slice(0, 2000));
|
|
1523
|
+
const col = colOf(c.column, where);
|
|
1524
|
+
if(done && col === done.id){ n.col = firstWork; n.check = true; } // la règle de l'app : Done COCHE, la colonne reste de travail
|
|
1525
|
+
else { n.col = col; n.check = false; }
|
|
1526
|
+
if(c.note != null){ if(typeof c.note !== "string") throw new Error(where + ": `note` must be text."); n.note = c.note.slice(0, 8000); }
|
|
1527
|
+
if(c.link){
|
|
1528
|
+
const l = String(c.link).trim();
|
|
1529
|
+
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>.");
|
|
1530
|
+
n.link = l;
|
|
1531
|
+
}
|
|
1532
|
+
if(c.assign){
|
|
1533
|
+
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).");
|
|
1534
|
+
n.assign = c.assign.trim();
|
|
1535
|
+
}
|
|
1536
|
+
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]); }
|
|
1537
|
+
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é
|
|
1538
|
+
if(c.priority != null && c.priority !== 0){
|
|
1539
|
+
if(!Number.isInteger(c.priority) || c.priority < 1 || c.priority > 6) throw new Error(where + ": `priority` must be 1 (highest) to 6.");
|
|
1540
|
+
n.priority = c.priority;
|
|
1541
|
+
}
|
|
1542
|
+
const tags = c.labels != null ? c.labels : c.tags;
|
|
1543
|
+
if(tags != null){
|
|
1544
|
+
if(!Array.isArray(tags)) throw new Error(where + ": `labels` must be a list of colours.");
|
|
1545
|
+
const bad = tags.filter(t => !KB_TAG_KEYS.includes(t));
|
|
1546
|
+
if(bad.length) throw new Error(where + ": unknown label colour \"" + bad[0] + "\". Use any of: " + KB_TAG_KEYS.join(", ") + ".");
|
|
1547
|
+
if(tags.length) n.tags = KB_TAG_KEYS.filter(k => tags.includes(k));
|
|
1548
|
+
}
|
|
1549
|
+
if(c.est != null && c.est !== ""){
|
|
1550
|
+
const sec = durSecs(c.est);
|
|
1551
|
+
if(sec == null) throw new Error(where + ": `est` must be a duration — \"2h\", \"1h30\", \"45\" (minutes) or \"2:15\".");
|
|
1552
|
+
if(sec > 0) n.est = sec;
|
|
1553
|
+
}
|
|
1554
|
+
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); }
|
|
1555
|
+
// Les sous-tâches sont des ENFANTS cochables, sans colonne — la règle du dos de la carte
|
|
1556
|
+
// (`kbAddSub`) : jamais des cartes sœurs.
|
|
1557
|
+
const subs = Array.isArray(c.subtasks) ? c.subtasks : [];
|
|
1558
|
+
if(subs.length > 50) throw new Error(where + ": 50 sub-tasks at most.");
|
|
1559
|
+
for(const t of subs){
|
|
1560
|
+
const st = String(t || "").replace(/[\x00-\x1f\x7f]/g, " ").trim();
|
|
1561
|
+
if(!st) continue;
|
|
1562
|
+
const k = makeNode(st.slice(0, 2000)); k.check = false; n.children.push(k);
|
|
1563
|
+
}
|
|
1564
|
+
// Le levier Ai4X : un attribut « Lever: <clé> », exactement ce que pose le dos de la carte.
|
|
1565
|
+
if(c.lever){
|
|
1566
|
+
const lv = String(c.lever).trim().toLowerCase();
|
|
1567
|
+
if(!KB_LEVER_KEYS_MCP.includes(lv)) throw new Error(where + ": `lever` must be one of " + KB_LEVER_KEYS_MCP.join(", ") + ".");
|
|
1568
|
+
n.children.push(makeNode("Lever: " + lv));
|
|
1569
|
+
}
|
|
1570
|
+
root.children.push(n);
|
|
1571
|
+
});
|
|
1572
|
+
assignIds(root);
|
|
1573
|
+
const kanban = { cols };
|
|
1574
|
+
const names = {};
|
|
1575
|
+
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);
|
|
1576
|
+
if(Object.keys(names).length) kanban.tags = names;
|
|
1577
|
+
const doc = { v:1, root, images:[], stickies:[], kanban };
|
|
1578
|
+
const st = okStyleMcp(args.style);
|
|
1579
|
+
if(st) doc.style = st;
|
|
1580
|
+
return doc;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
// ---------------------------------------------------------------------------
|
|
1584
|
+
// 🏁 LA RÉCOLTE D'UNE CONVERSATION (1.6.0, 2026-10-20) — docs/second-brain-harvest-spec.md
|
|
1585
|
+
// ---------------------------------------------------------------------------
|
|
1586
|
+
// À la fin d'une conversation, Claude envoie ce qui mérite d'être gardé — faits, décisions, leçons — et
|
|
1587
|
+
// la personne le range d'un clic. Trois règles portent tout :
|
|
1588
|
+
// · JAMAIS SANS CLIC. Toute la récolte part à la revue (`review`), marquée `origin: "harvest"`, et l'app
|
|
1589
|
+
// refuse de l'appliquer seule même si chaque op est dans PROP_AUTO_OPS (`propAutoOk`). L'appel d'outil
|
|
1590
|
+
// est fait par le MODÈLE, qui a pu lire une page qui lui dicte quoi envoyer : rien de ce qu'il envoie ne
|
|
1591
|
+
// devient un FAIT sans un geste dans l'app.
|
|
1592
|
+
// · LE LOT ENTIER OU RIEN. Un élément invalide fait échouer l'appel, en le nommant : une récolte est
|
|
1593
|
+
// petite et la personne l'a vue dans la conversation — mieux vaut la corriger que la ranger à moitié.
|
|
1594
|
+
// · UN ESPACE PAR APPEL. Les faits ne traversent pas les domaines ; les LEÇONS, elles, vont toujours dans
|
|
1595
|
+
// la carte « 🌱 Lessons » de Personal : la pensée appartient à la personne.
|
|
1596
|
+
// Les entrées ont la grammaire de 📚 File it (`kbFileEntry`) : un titre, une note qui finit par sa
|
|
1597
|
+
// provenance, des attributs « Nom: valeur » en enfants, rien de coché (une case recopiée serait une tâche
|
|
1598
|
+
// FANTÔME dans My work).
|
|
1599
|
+
// ≡ `KNOW_NAME` et `KNOW_DEFAULT` d'app/index.html (le test compare) : le connecteur cherche la carte de
|
|
1600
|
+
// connaissance par ce NOM — la marque `know` est un champ d'index du compte, qu'il ne lit pas encore — et
|
|
1601
|
+
// la crée avec ces sections quand l'espace n'en a pas.
|
|
1602
|
+
const KNOW_NAME = "Company knowledge";
|
|
1603
|
+
const KNOW_DEFAULT = ["🔑 Access — who has what", "🌐 Domains & infrastructure", "🧰 Tools & subscriptions",
|
|
1604
|
+
"💼 Clients & deals", "📖 Playbooks", "⚖️ Decisions"];
|
|
1605
|
+
const LESSONS_NAME = "🌱 Lessons";
|
|
1606
|
+
const HARVEST_MAX = 12;
|
|
1607
|
+
// ≡ app/index.html, comparés caractère par caractère par le test : un secret que l'app retirerait au
|
|
1608
|
+
// rangement et que le connecteur aurait laissé partir serait déjà dans la table `proposals`.
|
|
1609
|
+
const KB_SECRET_RES = [
|
|
1610
|
+
/\b(pass(word|wd|code|phrase)|pwd|mdp|mot de passe|secret|api[ _-]?key|token)\s*[:=]/i,
|
|
1611
|
+
/\b(sk-(ant-)?[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{12,}|xox[abpr]-[A-Za-z0-9-]{8,}|AKIA[0-9A-Z]{12,}|AIza[0-9A-Za-z_-]{20,})/,
|
|
1612
|
+
/\bBearer [A-Za-z0-9._~+\/-]{12,}/i
|
|
1613
|
+
];
|
|
1614
|
+
function kbSecretLike(line){
|
|
1615
|
+
const s = String(line == null ? "" : line);
|
|
1616
|
+
if(KB_SECRET_RES.some(re => re.test(s))) return true;
|
|
1617
|
+
for(const w of s.split(/\s+/)){
|
|
1618
|
+
if(w.length >= 32 && !/^(https?:|mailto:|map:|fsref:)/i.test(w) && /^[A-Za-z0-9._~+\/=-]+$/.test(w)
|
|
1619
|
+
&& /[A-Za-z]/.test(w) && /[0-9]/.test(w)) return true;
|
|
1620
|
+
}
|
|
1621
|
+
return false;
|
|
1622
|
+
}
|
|
1623
|
+
function knowNorm(s){ return String(s || "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, ""); }
|
|
1624
|
+
const hvSame = (a, b)=>knowNorm(a).trim() === knowNorm(b).trim();
|
|
1625
|
+
// Un texte d'une ligne, borné. `[` et `]` deviennent des parenthèses dans ce qui passe par le markdown d'un
|
|
1626
|
+
// `add` : « [x] » y serait lu comme une case, « [a](b) » comme un lien.
|
|
1627
|
+
function hvLine(v, max){ return (typeof v === "string") ? v.replace(/[\x00-\x1f\x7f]/g, " ").replace(/\s+/g, " ").trim().slice(0, max) : ""; }
|
|
1628
|
+
function hvMd(v){ return String(v).replace(/\[/g, "(").replace(/\]/g, ")"); }
|
|
1629
|
+
// Un JOUR qui existe : la forme (`DAY_RE`) puis l'aller-retour par le calendrier — le piège du 30 février,
|
|
1630
|
+
// payé par 🏁 Harvest côté app.
|
|
1631
|
+
function hvDayOk(s){ if(!DAY_RE.test(s)) return false; const d = new Date(s + "T00:00:00Z"); return !isNaN(d) && d.toISOString().slice(0, 10) === s; }
|
|
1632
|
+
const okNid = v=>typeof v === "string" && /^[A-Za-z0-9_]{1,40}$/.test(v);
|
|
1633
|
+
// Le document d'une carte de connaissance (ou de leçons) NEUVE : celui que `kbKnowCreate` écrirait.
|
|
1634
|
+
function hvNewDoc(name, sections){
|
|
1635
|
+
const root = makeNode(name);
|
|
1636
|
+
root.color = "#dbeafe";
|
|
1637
|
+
root.children = sections.map(t=>makeNode(t));
|
|
1638
|
+
assignIds(root);
|
|
1639
|
+
return { v:1, root, images:[], stickies:[] };
|
|
1640
|
+
}
|
|
1641
|
+
// La carte la plus récente qui porte EXACTEMENT ce nom dans une portée (propriétaire + espace ; Personal =
|
|
1642
|
+
// pas d'espace), jamais un board, un kanban, un tableur ni un Gantt — la règle de `kbKnowMapId`.
|
|
1643
|
+
function hvFindMap(lib, owner, spaceKey, name){
|
|
1644
|
+
return lib.filter(m=>m.owner === owner && (m.spaceKey || null) === (spaceKey || null)
|
|
1645
|
+
&& (m.kind === "map" || m.kind === "note") && hvSame(m.name, name))
|
|
1646
|
+
.sort((x, y)=>String(y.updatedAt).localeCompare(String(x.updatedAt)))[0] || null;
|
|
1647
|
+
}
|
|
1648
|
+
// Les ops d'UNE carte : un état de travail (la carte telle que les ops précédentes la laissent) et la liste.
|
|
1649
|
+
function hvTarget(doc){ return { seed: doc, doc: cloneDoc(doc), ops: [] }; }
|
|
1650
|
+
// Une op qui vise un nœud : par son ID quand il en a un valide (exact, jamais ambigu), avec le chemin
|
|
1651
|
+
// lisible pour le panneau ; sinon par le chemin seul.
|
|
1652
|
+
function hvAt(node, path){ return Object.assign(okNid(node.id) ? { node: node.id } : {}, { target: path.slice(0, 600) }); }
|
|
1653
|
+
// La section d'une carte de connaissance, par son nom (casse et accents ignorés, la règle de
|
|
1654
|
+
// `kbKnowSection`) ; absente, elle est AJOUTÉE par une op de section, une seule fois par appel. Elle ne
|
|
1655
|
+
// porte pas de `hv` : deux éléments peuvent y aller, et décocher l'un ne doit pas retirer l'autre.
|
|
1656
|
+
function hvSection(t, name){
|
|
1657
|
+
const root = t.doc.root;
|
|
1658
|
+
const hit = (root.children || []).find(c=>hvSame(c.text, name));
|
|
1659
|
+
if(hit) return hit;
|
|
1660
|
+
const nid = newNid();
|
|
1661
|
+
const node = Object.assign(makeNode(name), { id: nid });
|
|
1662
|
+
root.children = (root.children || []).concat([node]);
|
|
1663
|
+
t.ops.push(Object.assign({ op:"add" }, hvAt(root, String(root.text || "")), { markdown: "- " + hvMd(name), text: name, nid, adds: 1, why: "New section of your " + KNOW_NAME }));
|
|
1664
|
+
return node;
|
|
1665
|
+
}
|
|
1666
|
+
function hvFindEntry(section, text){ return (section.children || []).find(c=>hvSame(c.text, text)) || null; }
|
|
1667
|
+
|
|
1192
1668
|
// ---------------------------------------------------------------------------
|
|
1193
1669
|
// Définition des outils
|
|
1194
1670
|
// ---------------------------------------------------------------------------
|
|
1195
1671
|
const TOOLS = [
|
|
1196
1672
|
{
|
|
1197
1673
|
name: "list_maps",
|
|
1198
|
-
description: "Lists every mind map and board in the TabTree library (name, type, folder, size, last modified).",
|
|
1674
|
+
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).",
|
|
1199
1675
|
inputSchema: { type:"object", properties:{}, additionalProperties:false }
|
|
1200
1676
|
},
|
|
1201
1677
|
{
|
|
@@ -1215,6 +1691,13 @@ const TOOLS = [
|
|
|
1215
1691
|
name:{ type:"string", description:"Name of the map" },
|
|
1216
1692
|
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." },
|
|
1217
1693
|
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." },
|
|
1694
|
+
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." },
|
|
1695
|
+
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." },
|
|
1696
|
+
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:{
|
|
1697
|
+
target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\"" },
|
|
1698
|
+
start:{ type:"string", description:"YYYY-MM-DD" },
|
|
1699
|
+
due:{ type:"string", description:"YYYY-MM-DD" }
|
|
1700
|
+
}, required:["target"], additionalProperties:false } },
|
|
1218
1701
|
images:{ type:"array", description:"Optional screenshots, one per node: { target, file } or { target, data }. `target` = the exact text of a line of the outline (or \"Ancestor > Node\" when the wording repeats); `file` = full path of a PNG/JPEG/WebP/GIF on this machine (take the screenshot, save it, pass the path); `data` = a data:image/… URL instead of a file. The image is re-encoded to about 840 px and becomes the node's cover picture (it replaces any @a:/@e: illustration on that node). 60 max.", items:{ type:"object", properties:{
|
|
1219
1702
|
target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\"" },
|
|
1220
1703
|
file:{ type:"string", description:"Path of the image file on this machine" },
|
|
@@ -1232,12 +1715,12 @@ const TOOLS = [
|
|
|
1232
1715
|
},
|
|
1233
1716
|
{
|
|
1234
1717
|
name: "create_board",
|
|
1235
|
-
description: "Creates a NEW TabTree board (whiteboard): shapes (rect, ellipse, diamond, text, sticky) and arrow connections that stay anchored to the shapes in the app. With no x/y coordinates, layout is automatic by level (the connections define the hierarchy, left to right). Good for a diagram, a flow, a brainstorm. In a flowchart use ellipse for start/end, rect for a step, and diamond for a decision — its text should read as a closed question with exactly two ways out.\n\nFIT THE TEXT TO THE BOX. A box shows roughly (w−24)/7 characters per line and (h−10)/20 lines, and every blank line costs a full line. Write captions, not paragraphs: when filling a canvas template (Business Model Canvas, Lean Canvas, SWOT…), the shipped templates use 70–110 characters per block, and that is what looks composed. Longer text is shrunk to fit — nothing ever spills out — but past ~11px it stops being readable and the response will warn you.",
|
|
1718
|
+
description: "Creates a NEW TabTree board (whiteboard): shapes (rect, ellipse, diamond, text, sticky), IMAGES (kind \"image\" with a `file` path — your logos, photos, posters, placed where you say) and arrow connections that stay anchored to the shapes in the app. With no x/y coordinates, layout is automatic by level (the connections define the hierarchy, left to right). Good for a diagram, a flow, a brainstorm. In a flowchart use ellipse for start/end, rect for a step, and diamond for a decision — its text should read as a closed question with exactly two ways out.\n\nFIT THE TEXT TO THE BOX. A box shows roughly (w−24)/7 characters per line and (h−10)/20 lines, and every blank line costs a full line. Write captions, not paragraphs: when filling a canvas template (Business Model Canvas, Lean Canvas, SWOT…), the shipped templates use 70–110 characters per block, and that is what looks composed. Longer text is shrunk to fit — nothing ever spills out — but past ~11px it stops being readable and the response will warn you.",
|
|
1236
1719
|
inputSchema: { type:"object", properties:{
|
|
1237
1720
|
name:{ type:"string", description:"Name of the board" },
|
|
1238
1721
|
elements:{ type:"array", description:"Board elements", items:{ type:"object", properties:{
|
|
1239
1722
|
id:{ type:"string", description:"Logical id used by connections (defaults to the index)" },
|
|
1240
|
-
kind:{ type:"string", enum:["rect","ellipse","diamond","trap","chev","arc","text","sticky"], description:"Type (defaults to rect). diamond = a decision in a flowchart, trap = a pyramid/funnel tier (tk = small-side ratio, negative when the base is the narrow side), chev = a process step pointing forward, arc = a pie slice (a0/a1 in degrees from 12 o'clock, ir = inner-radius fraction for a donut)." },
|
|
1723
|
+
kind:{ type:"string", enum:["rect","ellipse","diamond","trap","chev","arc","text","sticky","image"], description:"Type (defaults to rect). image = a PICTURE placed freely on the board — a logo, a photo, a poster: give `file` (full path of a PNG/JPEG/WebP/GIF/SVG on this machine) or `data` (a data:image/… URL); w/h keep the image's proportions when only one (or neither, 220 px on the long side) is given; arrows can start or end on it; `text` is ignored. diamond = a decision in a flowchart, trap = a pyramid/funnel tier (tk = small-side ratio, negative when the base is the narrow side), chev = a process step pointing forward, arc = a pie slice (a0/a1 in degrees from 12 o'clock, ir = inner-radius fraction for a donut)." },
|
|
1241
1724
|
text:{ type:"string" },
|
|
1242
1725
|
x:{ type:"number" }, y:{ type:"number" }, w:{ type:"number" }, h:{ type:"number" },
|
|
1243
1726
|
color:{ type:"string", description:"Hex fill colour (shapes) or sticky-note colour" },
|
|
@@ -1246,12 +1729,15 @@ const TOOLS = [
|
|
|
1246
1729
|
tk:{ type:"number", description:"trap only — small-side/large-side ratio, 0..0.95 (0 = triangle); NEGATIVE puts the narrow side at the bottom (funnel tier)" },
|
|
1247
1730
|
a0:{ type:"number", description:"arc only — start angle in degrees, 0 at 12 o'clock, clockwise" },
|
|
1248
1731
|
a1:{ type:"number", description:"arc only — end angle in degrees (a1 > a0; a full circle is a0=0, a1=360)" },
|
|
1249
|
-
ir:{ type:"number", description:"arc only — inner-radius fraction 0..0.9 (0 = pie slice, 0.55 = donut)" }
|
|
1250
|
-
|
|
1732
|
+
ir:{ type:"number", description:"arc only — inner-radius fraction 0..0.9 (0 = pie slice, 0.55 = donut)" },
|
|
1733
|
+
file:{ type:"string", description:"image only — full path of a PNG/JPEG/WebP/GIF/SVG file on this machine. Rasters are re-encoded to about 840 px; an SVG is kept as is (300 KB max)." },
|
|
1734
|
+
data:{ type:"string", description:"image only — a data:image/(png|jpeg|webp|gif|svg+xml) URL instead of a file" }
|
|
1735
|
+
}, additionalProperties:false } },
|
|
1251
1736
|
connections:{ type:"array", description:"Arrows anchored between elements (by id)", items:{ type:"object", properties:{
|
|
1252
1737
|
from:{ type:"string" }, to:{ type:"string" }
|
|
1253
1738
|
}, required:["from","to"], additionalProperties:false } },
|
|
1254
1739
|
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." },
|
|
1740
|
+
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." },
|
|
1255
1741
|
style:{ type:"object", description:"Optional look of the document. Omit it for the default.", properties:{
|
|
1256
1742
|
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)." },
|
|
1257
1743
|
shape:{ type:"string", enum:["","line","plain"], description:"Node shape: cards (default), line = underlined, plain = plain text." },
|
|
@@ -1263,21 +1749,53 @@ const TOOLS = [
|
|
|
1263
1749
|
}, additionalProperties:false }
|
|
1264
1750
|
}, required:["name","elements"], additionalProperties:false }
|
|
1265
1751
|
},
|
|
1752
|
+
{
|
|
1753
|
+
name: "create_kanban",
|
|
1754
|
+
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.",
|
|
1755
|
+
inputSchema: { type:"object", properties:{
|
|
1756
|
+
name:{ type:"string", description:"Name of the board (e.g. \"Sprint W43\", \"Launch\")" },
|
|
1757
|
+
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:{
|
|
1758
|
+
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)" }
|
|
1759
|
+
}, required:["name"], additionalProperties:false } },
|
|
1760
|
+
cards:{ type:"array", description:"The cards, in order within each column. 300 max.", items:{ type:"object", properties:{
|
|
1761
|
+
text:{ type:"string", description:"The card's title" },
|
|
1762
|
+
column:{ type:"string", description:"Column NAME (or id). Default: the first column" },
|
|
1763
|
+
assign:{ type:"string", description:"Owner — a name or e-mail" },
|
|
1764
|
+
start:{ type:"string", description:"YYYY-MM-DD" },
|
|
1765
|
+
due:{ type:"string", description:"YYYY-MM-DD" },
|
|
1766
|
+
priority:{ type:"integer", description:"1 (highest) to 6" },
|
|
1767
|
+
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)" },
|
|
1768
|
+
est:{ type:"string", description:"Time estimate: \"2h\", \"1h30\", \"45\" (minutes), \"2:15\"" },
|
|
1769
|
+
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)" },
|
|
1770
|
+
note:{ type:"string", description:"The back of the card — plain text; http(s) and mailto links become clickable" },
|
|
1771
|
+
link:{ type:"string", description:"An https:// or mailto: link on the card, or map:<file> to open another map from list_maps" },
|
|
1772
|
+
subtasks:{ type:"array", items:{ type:"string" }, description:"Sub-tasks, ticked on the back of the card (the card shows n/m)" },
|
|
1773
|
+
pic:{ type:"string", description:"\"a:<id>\" built-in illustration or \"e:<emoji>\"" }
|
|
1774
|
+
}, required:["text"], additionalProperties:false } },
|
|
1775
|
+
labels:{ type:"object", description:"Optional names for the colour labels, e.g. { \"red\": \"Bug\", \"green\": \"Quick win\" }", additionalProperties:{ type:"string" } },
|
|
1776
|
+
folder:{ type:"string", description:"Optional folder, by NAME or full PATH." },
|
|
1777
|
+
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." },
|
|
1778
|
+
style:{ type:"object", description:"Optional look (same keys as create_mindmap: font, background, skin…).", additionalProperties:true }
|
|
1779
|
+
}, required:["name"], additionalProperties:false }
|
|
1780
|
+
},
|
|
1266
1781
|
{
|
|
1267
1782
|
name: "propose_changes",
|
|
1268
|
-
description: "
|
|
1783
|
+
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.",
|
|
1269
1784
|
inputSchema: { type:"object", properties:{
|
|
1270
1785
|
file:{ type:"string", description:"The map to change — a file name (or map name) from list_maps" },
|
|
1271
1786
|
note:{ type:"string", description:"One line telling the user what this proposal does and why. Shown above the changes." },
|
|
1272
1787
|
changes:{ type:"array", description:"The proposed changes, applied in order", items:{ type:"object", properties:{
|
|
1273
|
-
op:{ type:"string", enum:["add","rename","note","check","delete","move","pic","img","persona","tags","assign","priority","due","attr","comment","est"], description:"add = new children under `target`; rename = change its text; note = set its note; check = tick/untick the task; delete = remove it and its children; move = send the card to a kanban column; pic = put an illustration on it; img = put a SCREENSHOT on it (give `file`, the path of the image on this machine — or `data`; `img: \"\"` removes it); persona = re-cast a work map canvas (a board whose name starts with “Work map —” / “Cartographie —”) or a Second Brain Blueprint (“Second brain —”) from an interview — no target; tags = set the card's colour labels; assign = who the task is for; priority = 1 (highest) to 6; due = the due day; attr = set a “Name: value” attribute on the node (an estimate, a budget…); comment = add a signed comment to the card's thread (give `text`); est = set the time ESTIMATE of the task (give `est`, e.g. \"2h\", \"45\" = 45 minutes, \"\" removes it)" },
|
|
1788
|
+
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`)" },
|
|
1274
1789
|
target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\" if ambiguous (not used by persona)" },
|
|
1275
1790
|
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." },
|
|
1276
1791
|
markdown:{ type:"string", description:"add: the new branch as a bullet list, 2 spaces per level" },
|
|
1277
1792
|
text:{ type:"string", description:"rename: the new text. comment: the comment body (plain text; http/mailto links become clickable)" },
|
|
1278
1793
|
note:{ type:"string", description:"note: the note body (empty string clears it)" },
|
|
1279
|
-
|
|
1280
|
-
|
|
1794
|
+
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" },
|
|
1795
|
+
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 } },
|
|
1796
|
+
start:{ type:"string", description:"start: the start day as YYYY-MM-DD (\"\" removes it)" },
|
|
1797
|
+
lever:{ type:"string", description:"lever: human, think, accelerate or automate (\"\" removes it)" },
|
|
1798
|
+
link:{ type:"string", description:"link: an https:// or mailto: address, or map:<file> to open another map (\"\" removes it)" },
|
|
1281
1799
|
pic:{ type:"string", description:"pic: \"a:<id>\" for a built-in illustration, or \"e:<emoji>\" for one large emoji, or \"\" to remove it. Ids: person, team, chat, idea, target, trophy, star, heart, warning, done, flag, clock, laptop, phone, mail, folder, book, chart, money, calendar, building, car, globe, rocket, house, pin, plane, coffee, plant, sun, camera, music, lock, key, health, gift." },
|
|
1282
1800
|
file:{ type:"string", description:"img: full path of the screenshot (PNG/JPEG/WebP/GIF) on this machine. It is re-encoded to about 840 px and becomes the node's cover picture." },
|
|
1283
1801
|
data:{ type:"string", description:"img: a data:image/…;base64 URL instead of `file`" },
|
|
@@ -1288,10 +1806,34 @@ const TOOLS = [
|
|
|
1288
1806
|
priority:{ type:"integer", description:"priority: 1 (highest) to 6, or 0 to remove it" },
|
|
1289
1807
|
due:{ type:"string", description:"due: the due day as YYYY-MM-DD (\"\" removes it)" },
|
|
1290
1808
|
name:{ type:"string", description:"attr: the attribute's name, e.g. \"Estimate\"" },
|
|
1291
|
-
|
|
1809
|
+
// ⚠️ UNE seule déclaration de `value` (1.5.0) : il y en avait deux (booléen pour check, texte pour
|
|
1810
|
+
// attr) et la seconde écrasait la première — le schéma disait donc « texte » pour un check, et
|
|
1811
|
+
// Claude envoyait "true", que le contrôle d'un check refuse.
|
|
1812
|
+
value:{ type:["boolean","string"], description:"check: true to tick, false to untick. attr: its value as text, e.g. \"2h\" (\"\" removes the attribute)" },
|
|
1292
1813
|
est:{ type:"string", description:"est: the estimate as a duration — \"2h\", \"1h30\", \"45\" (minutes), \"2:15\" — or \"\" to remove it" }
|
|
1293
1814
|
}, required:["op"], additionalProperties:false } }
|
|
1294
1815
|
}, required:["file","changes"], additionalProperties:false }
|
|
1816
|
+
},
|
|
1817
|
+
{
|
|
1818
|
+
// 🏁 1.6.0. La description porte la règle de déclenchement : c'est elle que Claude lit au moment de choisir.
|
|
1819
|
+
name: "harvest",
|
|
1820
|
+
description: "Keeps what matters from THIS conversation in the user's TabTree second brain: facts, decisions and lessons. Call it ONLY after you listed the items to the user in the conversation and they agreed — then once. Nothing is filed until the user accepts it in TabTree (a banner, on any of their devices). Use it when the user ends a conversation (thanks, \"that's all\", \"ok bye\") or right after a decision was made. Facts and decisions go to the \"Company knowledge\" map of ONE space (created if the space has none); lessons always go to the user's \"🌱 Lessons\" map in Personal. Never for tasks (use propose_changes or create_kanban). Never a password, key or token. If nothing is worth keeping, call it with nothing: true and tell the user so.",
|
|
1821
|
+
inputSchema: { type:"object", properties:{
|
|
1822
|
+
space: { type:"string", description:"The space the conversation was about, by name (list_maps shows them). Omit for Personal. If two spaces could apply, ASK the user — never guess: facts never cross between spaces." },
|
|
1823
|
+
title: { type:"string", description:"One line naming the conversation, e.g. \"Discount request — Batimar\". Shown in TabTree and written as the source of every entry." },
|
|
1824
|
+
nothing: { type:"boolean", description:"true when nothing is worth keeping — then send no items." },
|
|
1825
|
+
items: { type:"array", description:"What to keep, 12 at most.", items:{ type:"object", properties:{
|
|
1826
|
+
kind: { type:"string", enum:["fact","decision","lesson"], description:"fact = what is true today (a price, an access, a client's answer); decision = what was decided, appended and never rewritten; lesson = what the user learned, reusable anywhere." },
|
|
1827
|
+
text: { type:"string", description:"The entry's title: one sentence that stays true on its own, without the conversation. 300 characters max." },
|
|
1828
|
+
detail: { type:"string", description:"Optional: what a reader needs in six months. 1500 characters max." },
|
|
1829
|
+
section: { type:"string", description:"fact only, required: the section of the Company knowledge map — its EXACT name when it exists (read_map shows them; the defaults are " + KNOW_DEFAULT.map(s=>"\"" + s + "\"").join(", ") + "), or a new name to create one." },
|
|
1830
|
+
updates: { type:"string", description:"fact only: the EXACT text of the entry of that section this fact replaces (its new value). Omit for a new entry." },
|
|
1831
|
+
signal: { type:"string", description:"decision only: the observable sign that will say whether it was right, e.g. \"Q4 margins back above 22%\"." },
|
|
1832
|
+
check_on:{ type:"string", description:"decision only: YYYY-MM-DD, the day to look at the signal." },
|
|
1833
|
+
replaces:{ type:"string", description:"decision only: the EXACT text of an earlier decision this one supersedes. The earlier one is kept." },
|
|
1834
|
+
why: { type:"string", description:"One short line: why keep it. Shown next to the item when the user reviews it." }
|
|
1835
|
+
}, required:["kind","text"], additionalProperties:false } }
|
|
1836
|
+
}, additionalProperties:false }
|
|
1295
1837
|
}
|
|
1296
1838
|
];
|
|
1297
1839
|
|
|
@@ -1299,18 +1841,35 @@ const HANDLERS = {
|
|
|
1299
1841
|
async list_maps(){
|
|
1300
1842
|
const maps = await loadLibrary();
|
|
1301
1843
|
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 + ").";
|
|
1302
|
-
|
|
1303
|
-
`• ${m.name} [${m.kind}]${m.folder ? " 📁 " + m.folder : ""}${m.tabOf ? " ↳ tab of “" + m.tabOf + "”" : ""}\n file: ${m.file}\n ${m.kind==="board" ? m.elements + " elements" : m.nodes + " nodes"} · modified ${m.mtime}`
|
|
1304
|
-
)
|
|
1844
|
+
const lines = maps.map(m=>
|
|
1845
|
+
`• ${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}`
|
|
1846
|
+
);
|
|
1847
|
+
// Les espaces où Claude peut créer (`space` des outils de création) — y compris ceux qui sont VIDES,
|
|
1848
|
+
// qu'aucune carte ne ferait apparaître dans la liste.
|
|
1849
|
+
const sps = CLOUD && cloudSpacesCache && cloudSpacesCache.length
|
|
1850
|
+
? "\n\nSpaces: " + cloudSpacesCache.map(x=>"“" + x.name + "”" + (x.role !== "owner" ? " (team, " + x.role + (x.writable ? "" : ", read-only") + ")" : "")).join(", ")
|
|
1851
|
+
: "";
|
|
1852
|
+
return lines.join("\n") + sps;
|
|
1305
1853
|
},
|
|
1306
1854
|
async read_map(args){
|
|
1307
|
-
|
|
1308
|
-
const
|
|
1855
|
+
checkRelPath(args.file);
|
|
1856
|
+
const all = await loadLibrary();
|
|
1857
|
+
const m = all.find(x=>x.file===args.file) || all.find(x=>x.file.endsWith(args.file)) || all.find(x=>x.name===args.file);
|
|
1858
|
+
if(!m) throw new Error("No such map: " + args.file + ". Use list_maps to see what is available.");
|
|
1859
|
+
const head = `${m.name} [${m.kind}]${m.space ? " — space “" + m.space + "”" + (m.team ? " (team, " + m.role + ")" : "") : ""} — file: ${m.file}\n`;
|
|
1309
1860
|
if(m.kind === "board") return head + boardToText(m.doc);
|
|
1861
|
+
// Les cartes qu'un lien `map:` peut ouvrir, par l'id que l'app leur donne (clé, ou id d'équipe).
|
|
1862
|
+
const maps = new Map();
|
|
1863
|
+
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 }); }
|
|
1864
|
+
const ctx = { cols: (m.doc.kanban && Array.isArray(m.doc.kanban.cols)) ? m.doc.kanban.cols : [], tagNames: kbTagNames(m.doc), maps };
|
|
1310
1865
|
// Le rappel sur propose_changes vit ici et pas dans la description de l'outil : c'est au
|
|
1311
1866
|
// moment où le plan est sous les yeux qu'il est utile, et il porte la règle de désignation.
|
|
1312
|
-
|
|
1313
|
-
|
|
1867
|
+
const ro = m.team && m.role === "viewer";
|
|
1868
|
+
return head + nodeToMarkdown(m.doc.root, 0, ctx) + kanbanToText(m.doc) + teamToText(m.doc)
|
|
1869
|
+
+ (ro ? "\n\n(You can read this team map but not change it — the user's role in its space is viewer.)"
|
|
1870
|
+
: "\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. "
|
|
1871
|
+
+ (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.)"
|
|
1872
|
+
: "Nothing is applied until the user approves it in TabTree.)"));
|
|
1314
1873
|
},
|
|
1315
1874
|
async search_maps(args){
|
|
1316
1875
|
const q = String(args.query||"").toLowerCase();
|
|
@@ -1345,17 +1904,38 @@ const HANDLERS = {
|
|
|
1345
1904
|
// Les captures d'écran APRÈS les marqueurs : une désignation se retire du texte avant que le
|
|
1346
1905
|
// texte serve de cible, et une capture remplace l'illustration du même nœud.
|
|
1347
1906
|
const shots = applyImages(root, args.images);
|
|
1907
|
+
// Les DATES (1.5.0) : ce que la vue Gantt dessine. Une cible introuvable ou ambiguë est refusée
|
|
1908
|
+
// en le disant — une date posée sur le mauvais nœud fausserait toute la frise.
|
|
1909
|
+
const dated = [];
|
|
1910
|
+
if(args.dates != null){
|
|
1911
|
+
if(!Array.isArray(args.dates)) throw new Error("`dates` must be a list of { target, start, due }.");
|
|
1912
|
+
args.dates.forEach((d, i)=>{
|
|
1913
|
+
const where = "dates #" + (i+1);
|
|
1914
|
+
const r = propFind(root, String((d && d.target) || ""));
|
|
1915
|
+
if(r.error === "empty") throw new Error(where + ": `target` is required.");
|
|
1916
|
+
if(r.error === "missing") throw new Error(where + ": no node \"" + d.target + "\" in this outline.");
|
|
1917
|
+
if(r.error === "ambiguous") throw new Error(where + ": \"" + d.target + "\" matches " + r.count + " nodes — use \"Ancestor > Node\".");
|
|
1918
|
+
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]); }
|
|
1919
|
+
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; }
|
|
1920
|
+
dated.push(r.node);
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
const view = ["note", "gantt"].includes(args.view) ? args.view : "map";
|
|
1924
|
+
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.");
|
|
1925
|
+
assignIds(root);
|
|
1348
1926
|
const doc = { v:1, root, images:[], stickies:[] };
|
|
1349
1927
|
const st = okStyleMcp(args.style);
|
|
1350
1928
|
if(st) doc.style = st;
|
|
1351
|
-
const file = await writeDoc(args.name || root.text, doc, args.folder);
|
|
1929
|
+
const file = await writeDoc(args.name || root.text, doc, args.folder, { kind: view, space: args.space });
|
|
1352
1930
|
const shotLines = shots.length ? `\n📷 ${shots.length} screenshot(s) on the map:\n` + shots.map(x=>" • " + x).join("\n") + "\n" : "";
|
|
1353
|
-
|
|
1931
|
+
const label = view === "note" ? "Note" : view === "gantt" ? "Gantt" : "Map";
|
|
1932
|
+
return `✅ ${label} created (${countNodes(root)} nodes${dated.length ? ", " + dated.length + " dated" : ""}): ${file}\n${shotLines}${importHint()}`;
|
|
1354
1933
|
},
|
|
1355
1934
|
async create_board(args){
|
|
1356
|
-
const { doc, warnings } = buildBoardDoc(args.name || "Board", args.elements, args.connections, args.style);
|
|
1357
|
-
const file = await writeDoc(args.name || "Board", doc, args.folder);
|
|
1358
|
-
const n = doc.shapes.length + doc.stickies.length;
|
|
1935
|
+
const { doc, warnings, imageNotes } = buildBoardDoc(args.name || "Board", args.elements, args.connections, args.style);
|
|
1936
|
+
const file = await writeDoc(args.name || "Board", doc, args.folder, { space: args.space });
|
|
1937
|
+
const n = doc.shapes.length + doc.stickies.length + doc.images.length;
|
|
1938
|
+
const imgs = imageNotes.length ? `🖼 ${imageNotes.length} image(s):\n` + imageNotes.map(t=>" • " + t).join("\n") + "\n" : "";
|
|
1359
1939
|
// Les avertissements passent AVANT l'astuce d'import : c'est le seul moment où le texte
|
|
1360
1940
|
// peut encore être raccourci, et une ligne noyée en fin de réponse ne se lit pas.
|
|
1361
1941
|
const warn = warnings.length
|
|
@@ -1364,7 +1944,16 @@ const HANDLERS = {
|
|
|
1364
1944
|
+ `\n Nothing spills out — the text was shrunk to fit — but blocks at different sizes look\n`
|
|
1365
1945
|
+ ` uneven on a canvas. Recreate the board with shorter text to keep it clean.\n`
|
|
1366
1946
|
: "";
|
|
1367
|
-
return `✅ Board created (${n} elements, ${doc.arrows.length} connection(s)): ${file}\n${warn}${importHint()}`;
|
|
1947
|
+
return `✅ Board created (${n} elements, ${doc.arrows.length} connection(s)): ${file}\n${imgs}${warn}${importHint()}`;
|
|
1948
|
+
},
|
|
1949
|
+
async create_kanban(args){
|
|
1950
|
+
if(typeof args.name !== "string" || !args.name.trim()) throw new Error("The `name` parameter is required (the board's name).");
|
|
1951
|
+
const doc = buildKanbanDoc(args);
|
|
1952
|
+
const file = await writeDoc(args.name, doc, args.folder, { kind: "kanban", space: args.space });
|
|
1953
|
+
const cards = doc.root.children;
|
|
1954
|
+
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(" · ");
|
|
1955
|
+
return `✅ Kanban created — ${cards.length} card(s): ${per}\n${file}\n${importHint()}`
|
|
1956
|
+
+ (CLOUD ? "\nIt opens as a kanban. To add or move cards later, use propose_changes on this file — those changes apply by themselves." : "");
|
|
1368
1957
|
},
|
|
1369
1958
|
async propose_changes(args){
|
|
1370
1959
|
const m = await findMap(args.file);
|
|
@@ -1374,28 +1963,190 @@ const HANDLERS = {
|
|
|
1374
1963
|
throw new Error("Proposals only apply to mind maps. A board has free positions and drawings, which a change list cannot describe.");
|
|
1375
1964
|
if(allPersona && m.kind !== "board")
|
|
1376
1965
|
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 —”).");
|
|
1966
|
+
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.");
|
|
1377
1967
|
const ops = buildProposalOps(m.doc, args.changes);
|
|
1378
|
-
const
|
|
1968
|
+
const res = await writeProposal(m, args.note, ops, { review: allPersona });
|
|
1379
1969
|
if(allPersona){
|
|
1380
|
-
return `📮 Interview filed for “${m.name}” — ${ops[0].domains ? ops[0].domains + " domains" : ops[0].procs + " processes"}: ${
|
|
1970
|
+
return `📮 Interview filed for “${m.name}” — ${ops[0].domains ? ops[0].domains + " domains" : ops[0].procs + " processes"}: ${res.ids.review}\n`
|
|
1381
1971
|
+ "NOTHING has been changed yet. The canvas file is untouched.\n"
|
|
1382
1972
|
+ (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?”. "
|
|
1383
1973
|
+ "One click rebuilds the canvas AND its companion sheet from these hours; Cmd+Z undoes it.\n"
|
|
1384
1974
|
+ "Tell the user to look at TabTree — do not claim the map has been re-cast.";
|
|
1385
1975
|
}
|
|
1386
|
-
const
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1976
|
+
const tally = (list)=>{
|
|
1977
|
+
const adds = list.reduce((n,o)=>n + (o.adds||0), 0), drops = list.reduce((n,o)=>n + (o.drops||0), 0);
|
|
1978
|
+
const shots = list.filter(o=>o.op === "img" && o.img).length;
|
|
1979
|
+
return list.length + " change(s)" + (adds ? `, ${adds} node(s) to add` : "") + (drops ? `, ${drops} node(s) to remove` : "") + (shots ? `, ${shots} screenshot(s)` : "");
|
|
1980
|
+
};
|
|
1981
|
+
const shotLines = ops.filter(o=>o.op === "img" && o.img).map(o=>" 📷 “" + String(o.was || o.target).slice(0, 60) + "” ← " + o.shot).join("\n");
|
|
1982
|
+
let out = "";
|
|
1983
|
+
if(res.auto.length){
|
|
1984
|
+
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. `
|
|
1985
|
+
+ "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";
|
|
1986
|
+
}
|
|
1987
|
+
if(res.review.length){
|
|
1988
|
+
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`
|
|
1989
|
+
+ "NOTHING has been changed yet by these. The map is untouched until the user approves them.\n"
|
|
1990
|
+
+ (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). ")
|
|
1991
|
+
+ "The user ticks the changes they want, applies them, and Cmd+Z undoes the lot.\n"
|
|
1992
|
+
+ "Tell the user to look at TabTree — do not claim those changes are made.\n";
|
|
1993
|
+
}
|
|
1994
|
+
return out + (shotLines ? shotLines + "\n" : "");
|
|
1995
|
+
},
|
|
1996
|
+
async harvest(args){
|
|
1997
|
+
if(!CLOUD) throw new Error("harvest keeps what matters in the user's TabTree ACCOUNT, and this connector reads a backup folder. Connect it with a connector key (TabTree → Settings → Account → Claude connector) — the backup-folder mode does not receive new features.");
|
|
1998
|
+
const a = args || {};
|
|
1999
|
+
const items = Array.isArray(a.items) ? a.items : [];
|
|
2000
|
+
if(a.nothing === true){
|
|
2001
|
+
if(items.length) throw new Error("`nothing: true` means nothing is worth keeping — send no items with it.");
|
|
2002
|
+
return "Nothing filed: nothing from this conversation needed keeping. Tell the user so.";
|
|
2003
|
+
}
|
|
2004
|
+
if(!items.length) throw new Error("No items. List what is worth keeping — facts, decisions, lessons — or pass nothing: true.");
|
|
2005
|
+
if(items.length > HARVEST_MAX) throw new Error(items.length + " items: a harvest keeps " + HARVEST_MAX + " at most. Beyond that it is a report — put it in a map of its own with create_mindmap, or keep only what matters.");
|
|
2006
|
+
const title = hvLine(a.title, 120);
|
|
2007
|
+
if(!title) throw new Error("`title` is required — one line naming the conversation; it is written as the source of every entry.");
|
|
2008
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
2009
|
+
|
|
2010
|
+
// 1. Contrôler chaque élément AVANT de toucher au compte : un appel refusé ne crée rien.
|
|
2011
|
+
const clean = items.map((it, i)=>{
|
|
2012
|
+
const where = "item #" + (i + 1);
|
|
2013
|
+
if(!it || typeof it !== "object") throw new Error(where + ": must be an object { kind, text, … }.");
|
|
2014
|
+
const kind = it.kind;
|
|
2015
|
+
if(!["fact", "decision", "lesson"].includes(kind)) throw new Error(where + ": `kind` must be fact, decision or lesson.");
|
|
2016
|
+
const text = hvLine(it.text, 300);
|
|
2017
|
+
if(!text) throw new Error(where + ": `text` is required — one sentence that stays true on its own.");
|
|
2018
|
+
if(kbSecretLike(text)) throw new Error(where + ": its text looks like a password, key or token. Never file a secret — leave it out.");
|
|
2019
|
+
let secrets = 0;
|
|
2020
|
+
const detail = (typeof it.detail === "string" ? it.detail.replace(/\r/g, "").slice(0, 1500) : "").split("\n")
|
|
2021
|
+
.filter(l=>{ if(kbSecretLike(l)){ secrets++; return false; } return true; }).join("\n").trim();
|
|
2022
|
+
const out = { i, kind, text, detail, secrets, why: hvLine(it.why, 200) };
|
|
2023
|
+
const only = (k, kinds)=>{ if(it[k] != null && it[k] !== "" && !kinds.includes(kind)) throw new Error(where + ": `" + k + "` is for a " + kinds.join(" or ") + ", not a " + kind + "."); };
|
|
2024
|
+
only("section", ["fact"]); only("updates", ["fact"]);
|
|
2025
|
+
only("signal", ["decision"]); only("check_on", ["decision"]); only("replaces", ["decision"]);
|
|
2026
|
+
if(kind === "fact"){
|
|
2027
|
+
out.section = hvLine(it.section, 80);
|
|
2028
|
+
if(!out.section) throw new Error(where + ": a fact needs its `section` of the " + KNOW_NAME + " map (read_map shows them).");
|
|
2029
|
+
out.updates = hvLine(it.updates, 300);
|
|
2030
|
+
}
|
|
2031
|
+
if(kind === "decision"){
|
|
2032
|
+
out.signal = hvLine(it.signal, 300);
|
|
2033
|
+
out.replaces = hvLine(it.replaces, 300);
|
|
2034
|
+
out.check_on = hvLine(it.check_on, 10);
|
|
2035
|
+
if(out.check_on && !hvDayOk(out.check_on)) throw new Error(where + ": `check_on` must be a real day written YYYY-MM-DD.");
|
|
2036
|
+
if(kbSecretLike(out.signal)) throw new Error(where + ": its signal looks like a password, key or token.");
|
|
2037
|
+
}
|
|
2038
|
+
return out;
|
|
2039
|
+
});
|
|
2040
|
+
|
|
2041
|
+
// 2. L'espace, puis les deux cartes (sans rien créer encore).
|
|
2042
|
+
const lib = await loadLibrary();
|
|
2043
|
+
const me = cloudMe;
|
|
2044
|
+
let sp = null;
|
|
2045
|
+
if(a.space != null && String(a.space).trim()){
|
|
2046
|
+
const hits = (cloudSpacesCache || []).filter(x=>hvSame(x.name, a.space));
|
|
2047
|
+
if(!hits.length) throw new Error("No space named “" + a.space + "” in this account" + ((cloudSpacesCache || []).length ? ". Spaces: " + cloudSpacesCache.map(x=>"“" + x.name + "”").join(", ") + " — or omit `space` for Personal" : "") + ".");
|
|
2048
|
+
sp = hits.find(x=>x.owner === me) || hits[0];
|
|
2049
|
+
if(!sp.writable) throw new Error("The space “" + sp.name + "” is read-only for this user (" + (sp.role === "viewer" ? "their role there is viewer — ask its owner for the editor role" : "its owner's plan has ended") + "). Nothing was filed.");
|
|
2050
|
+
}
|
|
2051
|
+
const knowOwner = sp ? sp.owner : me, knowKey = sp ? sp.space_key : null;
|
|
2052
|
+
const needKnow = clean.some(x=>x.kind !== "lesson"), needLessons = clean.some(x=>x.kind === "lesson");
|
|
2053
|
+
const knowMap = needKnow ? hvFindMap(lib, knowOwner, knowKey, KNOW_NAME) : null;
|
|
2054
|
+
const lessMap = needLessons ? hvFindMap(lib, me, null, LESSONS_NAME) : null;
|
|
2055
|
+
const know = needKnow ? hvTarget(knowMap ? knowMap.doc : hvNewDoc(KNOW_NAME, KNOW_DEFAULT)) : null;
|
|
2056
|
+
const less = needLessons ? hvTarget(lessMap ? lessMap.doc : hvNewDoc(LESSONS_NAME, [])) : null;
|
|
2057
|
+
|
|
2058
|
+
// 3. Les ops, élément par élément, contre la carte TELLE QUE LES ÉLÉMENTS PRÉCÉDENTS LA LAISSENT.
|
|
2059
|
+
const said = [], skipped = [];
|
|
2060
|
+
let secretLines = 0;
|
|
2061
|
+
const src = (verb)=>verb + " a conversation with Claude — “" + title + "” · " + today;
|
|
2062
|
+
for(const x of clean){
|
|
2063
|
+
const where = "item #" + (x.i + 1);
|
|
2064
|
+
secretLines += x.secrets;
|
|
2065
|
+
const hv = x.i;
|
|
2066
|
+
const noteOf = (verb)=>(x.detail ? x.detail + "\n\n" : "") + src(verb);
|
|
2067
|
+
const addEntry = (t, parent, path, attrs)=>{
|
|
2068
|
+
const nid = newNid();
|
|
2069
|
+
const md = "- " + hvMd(x.text) + attrs.map(([k, v])=>"\n - " + k + ": " + hvMd(v)).join("");
|
|
2070
|
+
t.ops.push(Object.assign({ op:"add" }, hvAt(parent, path), { markdown: md, text: x.text, nid, adds: 1 + attrs.length, hv }, x.why ? { why: x.why } : {}));
|
|
2071
|
+
t.ops.push({ op:"note", node: nid, target: (path + " > " + x.text).slice(0, 600), note: noteOf("From"), hv });
|
|
2072
|
+
const node = Object.assign(makeNode(x.text), { id: nid });
|
|
2073
|
+
node.children = attrs.map(([k, v])=>makeNode(k + ": " + v));
|
|
2074
|
+
parent.children = (parent.children || []).concat([node]);
|
|
2075
|
+
};
|
|
2076
|
+
if(x.kind === "lesson"){
|
|
2077
|
+
const root = less.doc.root;
|
|
2078
|
+
if(hvFindEntry(root, x.text)){ skipped.push("“" + x.text + "” is already in your " + LESSONS_NAME); continue; }
|
|
2079
|
+
addEntry(less, root, String(root.text || LESSONS_NAME), []);
|
|
2080
|
+
said.push("🌱 lesson — " + x.text);
|
|
2081
|
+
continue;
|
|
2082
|
+
}
|
|
2083
|
+
const root = know.doc.root;
|
|
2084
|
+
if(x.kind === "decision"){
|
|
2085
|
+
const sec = (root.children || []).find(c=>/decision/.test(knowNorm(c.text))) || hvSection(know, "⚖️ Decisions");
|
|
2086
|
+
if(hvFindEntry(sec, x.text)){ skipped.push("“" + x.text + "” is already among the decisions"); continue; }
|
|
2087
|
+
const attrs = [];
|
|
2088
|
+
if(x.signal) attrs.push(["Signal", x.signal]);
|
|
2089
|
+
if(x.check_on) attrs.push(["Check on", x.check_on]);
|
|
2090
|
+
if(x.replaces){
|
|
2091
|
+
const prev = hvFindEntry(sec, x.replaces);
|
|
2092
|
+
if(!prev) throw new Error(where + ": no decision “" + x.replaces + "” in “" + sec.text + "” — `replaces` takes the EXACT text of an earlier decision" + ((sec.children || []).length ? " (" + sec.children.slice(-6).map(c=>"“" + c.text + "”").join(", ") + ")" : "") + ".");
|
|
2093
|
+
attrs.push(["Replaces", String(prev.text)]);
|
|
2094
|
+
}
|
|
2095
|
+
addEntry(know, sec, String(root.text) + " > " + String(sec.text), attrs);
|
|
2096
|
+
said.push("⚖️ decision — " + x.text + (x.check_on ? " (check on " + x.check_on + ")" : ""));
|
|
2097
|
+
continue;
|
|
2098
|
+
}
|
|
2099
|
+
// fact
|
|
2100
|
+
const sec = hvSection(know, x.section);
|
|
2101
|
+
const path = String(root.text) + " > " + String(sec.text);
|
|
2102
|
+
if(x.updates){
|
|
2103
|
+
const prev = hvFindEntry(sec, x.updates);
|
|
2104
|
+
if(!prev) throw new Error(where + ": no entry “" + x.updates + "” in “" + sec.text + "” — `updates` takes the EXACT text of the entry it replaces" + ((sec.children || []).length ? " (" + sec.children.slice(0, 8).map(c=>"“" + c.text + "”").join(", ") + ")" : ", and that section is empty") + ".");
|
|
2105
|
+
const at = hvAt(prev, path + " > " + String(prev.text));
|
|
2106
|
+
if(!hvSame(prev.text, x.text)) know.ops.push(Object.assign({ op:"rename" }, at, { was: String(prev.text), text: x.text, hv }, x.why ? { why: x.why } : {}));
|
|
2107
|
+
know.ops.push(Object.assign({ op:"note" }, at, { was: String(prev.text), note: noteOf("Updated from"), hv }));
|
|
2108
|
+
prev.text = x.text;
|
|
2109
|
+
said.push("✏️ updated in " + sec.text + " — " + x.text);
|
|
2110
|
+
continue;
|
|
2111
|
+
}
|
|
2112
|
+
const dup = hvFindEntry(sec, x.text);
|
|
2113
|
+
if(dup){
|
|
2114
|
+
if(!x.detail){ skipped.push("“" + x.text + "” is already in “" + sec.text + "”"); continue; }
|
|
2115
|
+
know.ops.push(Object.assign({ op:"note" }, hvAt(dup, path + " > " + String(dup.text)), { was: String(dup.text), note: noteOf("Updated from"), hv }));
|
|
2116
|
+
said.push("✏️ detail updated in " + sec.text + " — " + x.text);
|
|
2117
|
+
continue;
|
|
2118
|
+
}
|
|
2119
|
+
addEntry(know, sec, path, []);
|
|
2120
|
+
said.push("📚 " + sec.text + " — " + x.text);
|
|
2121
|
+
}
|
|
2122
|
+
const targets = [];
|
|
2123
|
+
if(know && know.ops.length) targets.push([know, knowMap, KNOW_NAME, sp ? sp.name : undefined]);
|
|
2124
|
+
if(less && less.ops.length) targets.push([less, lessMap, LESSONS_NAME, undefined]);
|
|
2125
|
+
if(!targets.length) return "Nothing new to file — everything is already there:\n" + skipped.map(s=>" • " + s).join("\n") + "\nTell the user so.";
|
|
2126
|
+
|
|
2127
|
+
// 4. Créer ce qui manque (un squelette sans un fait : rien à approuver), puis déposer, à la revue.
|
|
2128
|
+
const harvest_id = "hv" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
2129
|
+
const created = [], filed = [];
|
|
2130
|
+
for(const [t, map0, name, space] of targets){
|
|
2131
|
+
let map = map0;
|
|
2132
|
+
if(!map){
|
|
2133
|
+
// Le squelette créé est EXACTEMENT celui contre lequel les ops ont été validées (`seed`, avant
|
|
2134
|
+
// toute op) : mêmes ids — les ops de la proposition visent ses sections par leur id.
|
|
2135
|
+
const info = await writeDoc(name, t.seed, null, { kind: "map", space, info: true });
|
|
2136
|
+
map = { file: info.id, key: info.key, owner: info.owner, team: info.team, name };
|
|
2137
|
+
created.push("“" + name + "”" + (space ? " in the space “" + space + "”" : " in Personal"));
|
|
2138
|
+
}
|
|
2139
|
+
const res = await writeProposal(map, "🏁 From your conversation with Claude: “" + title + "”", t.ops, { review: true, extra: { origin: "harvest", harvest_id, title } });
|
|
2140
|
+
filed.push(res.ids.review);
|
|
2141
|
+
}
|
|
2142
|
+
const n = said.length;
|
|
2143
|
+
return "🏁 " + n + " item" + (n > 1 ? "s are" : " is") + " waiting in TabTree" + (sp ? " (space “" + sp.name + "”)" : "") + ":\n"
|
|
2144
|
+
+ said.map(s=>" • " + s).join("\n") + "\n"
|
|
2145
|
+
+ (skipped.length ? "Left out, already there:\n" + skipped.map(s=>" • " + s).join("\n") + "\n" : "")
|
|
2146
|
+
+ (secretLines ? "🔒 " + secretLines + " line(s) of detail looked like a password, key or token and were left out.\n" : "")
|
|
2147
|
+
+ (created.length ? "📚 Created " + created.join(" and ") + " to hold them.\n" : "")
|
|
2148
|
+
+ "NOTHING is filed yet. TabTree shows a banner on any device where it is open (otherwise the next time it opens): the user ticks what to keep and accepts it; Cmd+Z undoes it.\n"
|
|
2149
|
+
+ "Tell the user it is waiting in TabTree — do not claim it is filed.";
|
|
1399
2150
|
}
|
|
1400
2151
|
};
|
|
1401
2152
|
|
|
@@ -1417,7 +2168,7 @@ function handle(msg){
|
|
|
1417
2168
|
// déjà dérivé (1.0.0 ici, 1.1.0 dans le manifeste) sans que rien ne le
|
|
1418
2169
|
// signale : un client affiche l'une, le registre publie l'autre. Un
|
|
1419
2170
|
// autotest les compare désormais toutes les quatre.
|
|
1420
|
-
serverInfo: { name: "tabtree", version: "1.
|
|
2171
|
+
serverInfo: { name: "tabtree", version: "1.6.0" }
|
|
1421
2172
|
});
|
|
1422
2173
|
} else if(method === "notifications/initialized" || (method||"").startsWith("notifications/")){
|
|
1423
2174
|
// notification : pas de réponse
|