tabtree-mcp 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +100 -216
- package/package.json +4 -4
- package/tabtree-mcp.mjs +522 -124
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 : "";
|
|
@@ -509,16 +529,33 @@ function propFind(root, spec){
|
|
|
509
529
|
// prédécesseur ou cocherait une tâche qui repart aussitôt, sans rien savoir de ce qu'il fait.
|
|
510
530
|
const RECUR_LABEL = { d:"daily", wd:"weekdays", w:"weekly", "2w":"every 2 weeks", m:"monthly", q:"quarterly", y:"yearly" };
|
|
511
531
|
function durText(sec){ const m = Math.round(sec / 60), h = Math.floor(m / 60); return h ? (h + "h" + (m % 60 ? String(m % 60).padStart(2, "0") : "")) : (m + "m"); }
|
|
512
|
-
|
|
532
|
+
// `ctx` (1.5.0) : ce que la carte sait du KANBAN et des autres cartes — la colonne de chaque carte,
|
|
533
|
+
// son responsable, ses dates, sa priorité, ses étiquettes, et le NOM de la carte qu'un lien `map:`
|
|
534
|
+
// ouvre (un board attaché à une carte kanban se lit « ⟪opens “Vision” [board]⟫ », pas « map:x7 »).
|
|
535
|
+
function nodeToMarkdown(n, depth, ctx){
|
|
513
536
|
const lines = [];
|
|
537
|
+
const cx = ctx || {};
|
|
538
|
+
const colName = new Map(((cx.cols) || []).map(k => [k.id, k]));
|
|
539
|
+
const doneCol = ((cx.cols) || []).find(k => k.done) || null;
|
|
514
540
|
const byId = new Map();
|
|
515
541
|
(function idx(node){ if(node && node.id) byId.set(node.id, node); (node.children||[]).forEach(idx); })(n);
|
|
516
542
|
(function walk(node, d){
|
|
517
543
|
let bullet;
|
|
518
544
|
if(d===0) bullet = "# ";
|
|
519
545
|
else bullet = " ".repeat(d-1) + (node.check!=null ? (node.check ? "- [x] " : "- [ ] ") : "- ");
|
|
520
|
-
const
|
|
546
|
+
const mlink = /^map:([A-Za-z0-9_]+)$/.exec(String(node.link || ""));
|
|
547
|
+
const linked = mlink && cx.maps ? cx.maps.get(mlink[1]) : null;
|
|
548
|
+
const body = node.link && !mlink ? `[${node.text}](${node.link})` : node.text;
|
|
521
549
|
const marks = [];
|
|
550
|
+
if(mlink) marks.push(linked ? "⟪opens “" + linked.name + "” [" + linked.kind + "]⟫" : "⟪opens another map (" + mlink[1] + ")⟫");
|
|
551
|
+
if(d > 0 && colName.size){
|
|
552
|
+
if(node.check === true && doneCol && node.col) marks.push("⟪column: " + doneCol.name + "⟫");
|
|
553
|
+
else if(node.col && colName.has(node.col)) marks.push("⟪column: " + colName.get(node.col).name + "⟫");
|
|
554
|
+
}
|
|
555
|
+
if(typeof node.assign === "string" && node.assign.trim()) marks.push("⟪owner: " + node.assign.trim() + "⟫");
|
|
556
|
+
if(node.start || node.due) marks.push("⟪" + (node.start && node.due ? node.start + " → " + node.due : node.due ? "due " + node.due : "starts " + node.start) + "⟫");
|
|
557
|
+
if(Number.isInteger(node.priority) && node.priority > 0) marks.push("⟪P" + node.priority + "⟫");
|
|
558
|
+
if(Array.isArray(node.tags) && node.tags.length) marks.push("⟪labels: " + node.tags.map(t => (cx.tagNames && cx.tagNames[t]) ? t + " “" + cx.tagNames[t] + "”" : t).join(", ") + "⟫");
|
|
522
559
|
if(node.note && node.note.trim()) marks.push("⟪note⟫");
|
|
523
560
|
if(Array.isArray(node.deps) && node.deps.length){
|
|
524
561
|
const names = node.deps.map(id => byId.get(id)).filter(Boolean).map(p => String(p.text || "").trim()).filter(Boolean);
|
|
@@ -803,26 +840,56 @@ function scanLibrary(){
|
|
|
803
840
|
return out;
|
|
804
841
|
}
|
|
805
842
|
// La bibliothèque du COMPTE, présentée exactement comme celle du dossier : mêmes champs, donc
|
|
806
|
-
// les
|
|
807
|
-
//
|
|
843
|
+
// les outils n'ont rien à savoir de la source. `file` est l'identifiant que l'APP donne à la carte
|
|
844
|
+
// (≡ `teamLocalId`) : sa clé pour une carte à moi, « T<10 hexa du propriétaire>_<clé> » pour une
|
|
845
|
+
// carte d'équipe — c'est lui qu'une proposition doit porter, l'app le cherche dans SA bibliothèque.
|
|
846
|
+
//
|
|
847
|
+
// ⚠️ PAGINÉE (1.5.0) : la version précédente lisait 300 documents et s'arrêtait EN SILENCE — la
|
|
848
|
+
// recherche ratait des cartes sur un gros compte sans le dire. On boucle jusqu'au `total`.
|
|
849
|
+
function teamLocalId(owner, key){
|
|
850
|
+
const t = String(owner || "").replace(/-/g, "").toLowerCase().slice(0, 10);
|
|
851
|
+
if(!/^[0-9a-f]{10}$/.test(t) || !/^[A-Za-z0-9_]{1,52}$/.test(String(key || ""))) return null;
|
|
852
|
+
return "T" + t + "_" + key;
|
|
853
|
+
}
|
|
854
|
+
let cloudSpacesCache = null; // les espaces vus au dernier chargement — la création les résout par nom
|
|
808
855
|
async function cloudLibrary(){
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
856
|
+
const pages = [];
|
|
857
|
+
let total = Infinity, off = 0, j = null;
|
|
858
|
+
while(off < total && off < 5000){
|
|
859
|
+
j = await cloudCall("docs", { limit: 100, offset: off });
|
|
860
|
+
const got = j.maps || [];
|
|
861
|
+
pages.push(...got);
|
|
862
|
+
total = typeof j.total === "number" ? j.total : got.length; // edge function d'avant la 1.5.0 : une seule page
|
|
863
|
+
if(!got.length || typeof j.total !== "number") break;
|
|
864
|
+
off += got.length;
|
|
865
|
+
}
|
|
866
|
+
const me = (j && j.me) || null;
|
|
867
|
+
cloudSpacesCache = (j && j.spaces) || [];
|
|
868
|
+
const spaceName = (owner, key)=>{ const sp = cloudSpacesCache.find(x=>x.owner === owner && x.space_key === key); return sp ? sp.name : null; };
|
|
869
|
+
const folders = new Map((j && j.folders || []).filter(f=>f && f.folder_key).map(f=>[(f.owner || "") + "|" + f.folder_key, f]));
|
|
870
|
+
const pathOf = (owner, k)=>{
|
|
812
871
|
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; }
|
|
872
|
+
let f = folders.get((owner || "") + "|" + k) || folders.get("|" + k);
|
|
873
|
+
while(f && !seen.has(f.folder_key) && out.length <= 16){ seen.add(f.folder_key); out.unshift(f.name); f = f.parent_key ? (folders.get((owner || "") + "|" + f.parent_key) || folders.get("|" + f.parent_key)) : null; }
|
|
815
874
|
return out.length ? out.join(" / ") : null;
|
|
816
875
|
};
|
|
817
876
|
const out = [];
|
|
818
|
-
for(const r of
|
|
877
|
+
for(const r of pages){
|
|
819
878
|
const doc = r && r.doc;
|
|
820
879
|
if(!doc || !doc.root) continue;
|
|
880
|
+
const owner = r.owner || me;
|
|
881
|
+
const team = !!(owner && me && owner !== me);
|
|
882
|
+
const file = team ? teamLocalId(owner, r.map_key) : r.map_key;
|
|
883
|
+
if(!file) continue;
|
|
821
884
|
out.push({
|
|
822
|
-
file: r.map_key,
|
|
885
|
+
file, key: r.map_key, owner, team,
|
|
886
|
+
role: r.role || "owner",
|
|
887
|
+
space: r.space_key ? spaceName(owner, r.space_key) : null,
|
|
823
888
|
name: String(r.name || "") || String(doc.root.text || "").slice(0, 60) || r.map_key,
|
|
824
|
-
folder: pathOf(r.folder_key),
|
|
825
|
-
|
|
889
|
+
folder: pathOf(owner, r.folder_key),
|
|
890
|
+
// La SORTE de l'index (kanban, note, gantt, sheet) quand le compte la dit : c'est elle qui décide
|
|
891
|
+
// de la vue d'ouverture dans l'app. Le document seul ne distingue que board / carte.
|
|
892
|
+
kind: doc.board ? "board" : (r.kind && r.kind !== "board" ? r.kind : "map"),
|
|
826
893
|
nodes: countNodes(doc.root),
|
|
827
894
|
elements: doc.board ? ((doc.shapes||[]).length + (doc.stickies||[]).length + (doc.arrows||[]).length + (doc.draws||[]).length) : undefined,
|
|
828
895
|
mtime: String(r.updated_at || "").slice(0,16).replace("T"," "),
|
|
@@ -877,52 +944,70 @@ async function findMap(file){
|
|
|
877
944
|
// ---------------------------------------------------------------------------
|
|
878
945
|
// Écriture (toujours un NOUVEAU fichier — jamais de modification en place)
|
|
879
946
|
// ---------------------------------------------------------------------------
|
|
880
|
-
async function writeDoc(name, doc, folder){
|
|
947
|
+
async function writeDoc(name, doc, folder, opts){
|
|
948
|
+
const o = opts || {};
|
|
881
949
|
if(CLOUD){
|
|
882
950
|
// Le dossier est un NOM ou un CHEMIN de dossier de bibliothèque : on le résout en clé s'il
|
|
883
951
|
// existe déjà, sinon la carte arrive à la racine et le texte de réponse le dit
|
|
884
952
|
// (« no silent caps »).
|
|
885
953
|
//
|
|
886
|
-
// ⚠️ 2026-09-23 — LE NOM SEUL NE SUFFIT PAS.
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
//
|
|
893
|
-
|
|
954
|
+
// ⚠️ 2026-09-23 — LE NOM SEUL NE SUFFIT PAS. Un compte qui a « Ai4X » à la racine ET un « Ai4X »
|
|
955
|
+
// imbriqué dedans faisait tirer au sort. Deux règles : un CHEMIN complet (« Ai4X / Ai4X ») gagne
|
|
956
|
+
// toujours, et un nom seul prend le dossier le MOINS PROFOND. L'ambiguïté est dite.
|
|
957
|
+
//
|
|
958
|
+
// 👥 1.5.0 — LES ESPACES D'ÉQUIPE. `space` (un nom d'espace) dit OÙ : un de mes espaces, ou un
|
|
959
|
+
// espace dont je suis membre — la carte vit alors chez son PROPRIÉTAIRE, comme toute carte
|
|
960
|
+
// d'équipe. Les dossiers candidats sont ceux de cet espace ; sans `space`, mes dossiers d'abord,
|
|
961
|
+
// puis ceux des espaces d'équipe (un dossier d'équipe se désigne aussi par son chemin).
|
|
962
|
+
const j = await cloudCall("list", {});
|
|
963
|
+
const me = j.me || null;
|
|
964
|
+
const spaces = (j.spaces || []).filter(x=>x && x.space_key);
|
|
965
|
+
const all = (j.folders || []).filter(f=>f && f.folder_key);
|
|
966
|
+
const byKey = new Map(all.map(f=>[(f.owner || "") + "|" + f.folder_key, f]));
|
|
967
|
+
const chain = (f0)=>{ const out=[]; const seen=new Set(); let f=f0;
|
|
968
|
+
while(f && !seen.has(f.folder_key) && out.length<=16){ seen.add(f.folder_key); out.unshift(f.name); f = f.parent_key ? byKey.get((f.owner || "") + "|" + f.parent_key) : null; }
|
|
969
|
+
return out; };
|
|
970
|
+
const norm = (x)=>String(x||"").split("/").map(t=>t.trim()).filter(Boolean).join(" / ").toLowerCase();
|
|
971
|
+
let sp = null;
|
|
972
|
+
if(o.space){
|
|
973
|
+
const want = norm(o.space);
|
|
974
|
+
const hits = spaces.filter(x=>norm(x.name) === want);
|
|
975
|
+
if(!hits.length) throw new Error("No space named “" + o.space + "” in this account" + (spaces.length ? ". Spaces you can use: " + spaces.map(x=>"“" + x.name + "”" + (x.owner !== me ? " (team, " + x.role + ")" : "")).join(", ") : " — it has no spaces") + ".");
|
|
976
|
+
sp = hits.find(x=>x.owner === me) || hits[0];
|
|
977
|
+
if(!sp.writable) throw new Error("You cannot add maps to the space “" + sp.name + "” — " + (sp.role === "viewer" ? "your role there is viewer. Ask its owner for the editor role." : "its owner's plan has ended, so it is read-only."));
|
|
978
|
+
}
|
|
979
|
+
let f0 = null, folder_twins = 0;
|
|
894
980
|
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
981
|
const want = norm(folder);
|
|
903
|
-
let
|
|
904
|
-
if(!
|
|
905
|
-
|
|
906
|
-
if(hits.length)
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
folder_twins = hits.length;
|
|
910
|
-
}
|
|
982
|
+
let cands = all.filter(f=>!sp || (f.owner === sp.owner && f.space_key === sp.space_key));
|
|
983
|
+
if(!sp) cands = cands.slice().sort((a,b)=>(a.owner === me ? 0 : 1) - (b.owner === me ? 0 : 1));
|
|
984
|
+
let hits = cands.filter(f=>norm(chain(f).join(" / ")) === want); // chemin exact
|
|
985
|
+
if(!hits.length) hits = cands.filter(f=>norm(f.name) === want) // nom de feuille
|
|
986
|
+
.sort((a,b)=>((a.owner === me ? 0 : 1) - (b.owner === me ? 0 : 1)) || (chain(a).length - chain(b).length));
|
|
987
|
+
if(hits.length){ f0 = hits[0]; folder_twins = hits.length; }
|
|
911
988
|
}
|
|
912
|
-
const
|
|
913
|
-
const
|
|
914
|
-
|
|
915
|
-
|
|
989
|
+
const owner = f0 ? (f0.owner || me) : (sp ? sp.owner : me);
|
|
990
|
+
const kind = o.kind || (doc.board ? "board" : (doc.kanban ? "kanban" : "map"));
|
|
991
|
+
const body = { name: String(name||"").slice(0,200), kind, doc, folder_key: f0 ? f0.folder_key : null };
|
|
992
|
+
if(owner && me && owner !== me) body.owner = owner;
|
|
993
|
+
if(!f0 && sp) body.space_key = sp.space_key;
|
|
994
|
+
const r = await cloudCall("create", body);
|
|
995
|
+
// On DIT toujours où la carte a atterri : l'espace, le chemin complet — pas le nom demandé.
|
|
996
|
+
const spaceOf = (own, key)=>{ const x = spaces.find(y=>y.owner === own && y.space_key === key); return x ? x.name : null; };
|
|
997
|
+
const inSpace = f0 ? spaceOf(f0.owner, f0.space_key) : (sp ? sp.name : null);
|
|
916
998
|
let where = "";
|
|
917
|
-
if(
|
|
918
|
-
where = " (in “" +
|
|
999
|
+
if(f0){
|
|
1000
|
+
where = " (in “" + chain(f0).join(" / ") + "”" + (inSpace ? ", space “" + inSpace + "”" : "") + ")";
|
|
919
1001
|
if(folder_twins > 1) where += " ⚠ " + folder_twins + " folders are named “" + folder
|
|
920
1002
|
+ "” in this account; the shallowest was used. Pass the full path (e.g. “" + folder + " / " + folder
|
|
921
1003
|
+ "”) to choose another one.";
|
|
922
1004
|
} else if(folder){
|
|
923
|
-
where = " (folder “" + folder + "” does not exist in this account — the map is at the root)";
|
|
1005
|
+
where = " (folder “" + folder + "” does not exist" + (sp ? " in the space “" + sp.name + "”" : " in this account") + " — the map is at the root" + (sp ? " of that space" : "") + ")";
|
|
1006
|
+
} else if(sp){
|
|
1007
|
+
where = " (space “" + sp.name + "”" + (owner !== me ? " — a team space: every member sees it" : "") + ")";
|
|
924
1008
|
}
|
|
925
|
-
|
|
1009
|
+
const id = (owner && me && owner !== me) ? teamLocalId(owner, r.map_key) : r.map_key;
|
|
1010
|
+
return "cloud:" + (id || r.map_key) + where;
|
|
926
1011
|
}
|
|
927
1012
|
dirOk();
|
|
928
1013
|
let target = DIR;
|
|
@@ -953,7 +1038,22 @@ function countForest(roots){ return roots.reduce((n,r)=>n + countNodes(r), 0); }
|
|
|
953
1038
|
// connecteur la refuserait à l'écriture, ou l'app la jetterait à la lecture — dans les deux
|
|
954
1039
|
// cas Claude croirait avoir proposé quelque chose qui n'arrive jamais chez l'utilisateur.
|
|
955
1040
|
// 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"];
|
|
1041
|
+
const PROP_OPS = ["add","rename","note","check","delete","move","pic","img","persona","tags","assign","priority","due","attr","comment","est","start","cols","link"];
|
|
1042
|
+
// ⚡ Les ops que l'app applique D'ELLE-MÊME (1.5.0, décidé « hybride » le 2026-10-20) — ≡
|
|
1043
|
+
// `PROP_AUTO_OPS` d'app/index.html (le test compare les deux). Plus `cols` quand le tableau n'a
|
|
1044
|
+
// pas encore de colonnes (`seed`). C'est l'APP qui décide : le connecteur ne fait que demander
|
|
1045
|
+
// (`auto:true`) et découper le lot pour que le reste parte à la revue.
|
|
1046
|
+
const PROP_AUTO_OPS = ["add","move","check","tags","assign","priority","due","start","est","attr","comment","pic","img","link"];
|
|
1047
|
+
// Les ops que Claude peut ÉCRIRE et qui n'existent pas telles quelles dans l'app : `lever` devient
|
|
1048
|
+
// un attribut « Lever: <clé> » (c'est ainsi que l'app le range, `KB_LEVER_ATTR`), `columns` est le
|
|
1049
|
+
// nom lisible de `cols`.
|
|
1050
|
+
const INPUT_OPS = PROP_OPS.concat(["lever", "columns"]);
|
|
1051
|
+
// Les quatre leviers Ai4X — ≡ les clés de `KB_LEVERS` d'app/index.html (le test compare).
|
|
1052
|
+
const KB_LEVER_KEYS_MCP = ["human","think","accelerate","automate"];
|
|
1053
|
+
// Un identifiant de nœud neuf, de la forme qu'`okNodeId` accepte. Posé sur chaque `add` (`nid`) :
|
|
1054
|
+
// c'est ce qui rend l'ajout IDEMPOTENT dans l'app — rejoué par deux appareils, il ne double pas.
|
|
1055
|
+
function newNid(){ return "mcp" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8); }
|
|
1056
|
+
function cloneDoc(d){ return JSON.parse(JSON.stringify(d)); }
|
|
957
1057
|
// Les dix étiquettes de couleur du kanban — ≡ `KB_TAGS` de index.html (le test compare les deux).
|
|
958
1058
|
const KB_TAG_KEYS = ["green","yellow","orange","red","purple","blue","sky","lime","pink","gray"];
|
|
959
1059
|
|
|
@@ -962,13 +1062,54 @@ const KB_TAG_KEYS = ["green","yellow","orange","red","purple","blue","sky","lime
|
|
|
962
1062
|
// alors qu'une proposition à moitié valide arriverait chez l'utilisateur sous forme de
|
|
963
1063
|
// lignes barrées qu'il ne peut ni comprendre ni réparer. La boîte aux lettres ne reçoit
|
|
964
1064
|
// que des propositions applicables.
|
|
965
|
-
|
|
1065
|
+
// ⚠️ SÉQUENTIEL depuis la 1.5.0 : chaque op est validée contre la carte TELLE QUE LES OPS
|
|
1066
|
+
// PRÉCÉDENTES L'ONT LAISSÉE (une copie, `work`). Sans ça, « ajoute la carte X, puis mets-lui une
|
|
1067
|
+
// échéance » échouait : X n'existait pas encore dans la carte lue. L'app applique dans le même
|
|
1068
|
+
// ordre, donc la cible validée ici est celle qu'elle trouvera.
|
|
1069
|
+
function buildProposalOps(mapDoc0, changes){
|
|
966
1070
|
if(!Array.isArray(changes) || !changes.length) throw new Error("`changes` must be a non-empty list.");
|
|
967
1071
|
if(changes.length > 100) throw new Error("Too many changes in one proposal (max 100). Send several smaller ones — the user reviews them one by one.");
|
|
1072
|
+
const mapDoc = cloneDoc(mapDoc0);
|
|
1073
|
+
const colsNow = ()=>(mapDoc.kanban && Array.isArray(mapDoc.kanban.cols) && mapDoc.kanban.cols.length) ? mapDoc.kanban.cols : null;
|
|
1074
|
+
// Une colonne se désigne par son id OU par son nom (« Doing ») — Claude lit les noms.
|
|
1075
|
+
const colRef = (raw, where)=>{
|
|
1076
|
+
const cols = colsNow();
|
|
1077
|
+
const v = String(raw);
|
|
1078
|
+
if(!cols) throw new Error(`${where}: this map has no kanban board yet. Add a "columns" change first (e.g. { op: "columns", columns: [{ name: "To do" }, { name: "Doing" }, { name: "Done", done: true }] }), then refer to those column names.`);
|
|
1079
|
+
const hit = cols.find(k => k.id === v) || cols.find(k => String(k.name).trim().toLowerCase() === v.trim().toLowerCase());
|
|
1080
|
+
if(!hit) throw new Error(`${where}: no column "${v}" on this board. Use one of: ${cols.map(k => `"${k.name}" (${k.id})`).join(", ")}.`);
|
|
1081
|
+
return hit.id;
|
|
1082
|
+
};
|
|
968
1083
|
return changes.map((c, i)=>{
|
|
969
1084
|
const where = "change #" + (i+1);
|
|
970
1085
|
const op = String((c && c.op) || "").trim();
|
|
971
|
-
if(!
|
|
1086
|
+
if(!INPUT_OPS.includes(op)) throw new Error(`${where}: unknown op "${op}". Use one of: ${INPUT_OPS.filter(x=>x !== "cols").join(", ")}.`);
|
|
1087
|
+
if(op === "columns" || op === "cols"){
|
|
1088
|
+
// Les colonnes du tableau, en un bloc. Un tableau qui n'en a PAS reçoit `seed` : l'app pose ces
|
|
1089
|
+
// colonnes d'elle-même, sans écraser celles qu'un collègue aurait posées entre-temps. Un tableau
|
|
1090
|
+
// qui en a déjà : un REMPLACEMENT, qui part à la revue (les cartes d'une colonne retirée
|
|
1091
|
+
// quittent le tableau). Un id existant est GARDÉ quand le nom revient — sinon chaque carte de
|
|
1092
|
+
// « Doing » perdrait sa colonne au passage.
|
|
1093
|
+
const list = Array.isArray(c.columns) ? c.columns : (Array.isArray(c.cols) ? c.cols : null);
|
|
1094
|
+
if(!list || !list.length || list.length > 12) throw new Error(`${where}: \`columns\` must list 1 to 12 columns — { name, done?, wip? }.`);
|
|
1095
|
+
const before = colsNow() || [];
|
|
1096
|
+
const used = new Set();
|
|
1097
|
+
const cols = list.map((k, j)=>{
|
|
1098
|
+
const name = String((k && (k.name != null ? k.name : k)) || "").replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, 60);
|
|
1099
|
+
if(!name) throw new Error(`${where}: column #${j+1} has no name.`);
|
|
1100
|
+
const prev = before.find(b => String(b.name).trim().toLowerCase() === name.toLowerCase() && !used.has(b.id));
|
|
1101
|
+
let id = prev ? prev.id : (k && typeof k.id === "string" && /^[\w-]{1,24}$/.test(k.id) && !before.some(b=>b.id === k.id) ? k.id : "c" + (j+1));
|
|
1102
|
+
while(used.has(id) || (!prev && before.some(b => b.id === id))) id = "c" + Math.random().toString(36).slice(2, 7);
|
|
1103
|
+
used.add(id);
|
|
1104
|
+
const wip = parseInt(k && k.wip, 10);
|
|
1105
|
+
return Object.assign({ id, name }, k && k.done ? { done: true } : {}, (wip >= 1 && wip <= 99) ? { wip } : {});
|
|
1106
|
+
});
|
|
1107
|
+
if(cols.filter(k => k.done).length > 1) throw new Error(`${where}: only one column can be the "done" column.`);
|
|
1108
|
+
const out = { op: "cols", cols };
|
|
1109
|
+
if(!before.length) out.seed = true;
|
|
1110
|
+
mapDoc.kanban = Object.assign({}, mapDoc.kanban || {}, { cols });
|
|
1111
|
+
return out;
|
|
1112
|
+
}
|
|
972
1113
|
if(op === "persona"){
|
|
973
1114
|
// `persona` (2026-09-08) vise le DOCUMENT entier — la toile d'une work map — jamais un
|
|
974
1115
|
// nœud : donc pas de `target`. C'est le relais skill → carte sans copier-coller : le skill
|
|
@@ -1015,15 +1156,13 @@ function buildProposalOps(mapDoc, changes){
|
|
|
1015
1156
|
if(!forest.length) throw new Error(`${where}: \`markdown\` has no usable structure (a bullet list, indented by 2 spaces).`);
|
|
1016
1157
|
out.markdown = c.markdown;
|
|
1017
1158
|
out.adds = countForest(forest);
|
|
1159
|
+
out.nid = newNid();
|
|
1018
1160
|
// `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
|
-
}
|
|
1161
|
+
// Validé comme pour `move` — une colonne inventée est refusée ICI, en nommant les vraies ;
|
|
1162
|
+
// un NOM de colonne est accepté et traduit en id (1.5.0).
|
|
1163
|
+
if(c.col != null && c.col !== "") out.col = colRef(c.col, where);
|
|
1164
|
+
// La copie de travail reçoit les nœuds : une op suivante du même lot peut les viser.
|
|
1165
|
+
r.node.children = (r.node.children || []).concat(forest);
|
|
1027
1166
|
// `assign` facultatif (2026-10-12, 👥) : le RESPONSABLE des cartes neuves. Mêmes bornes que l'op
|
|
1028
1167
|
// `assign` ; refusé ICI en le disant, plutôt que de laisser l'app le retirer en silence.
|
|
1029
1168
|
if(c.assign != null && c.assign !== ""){
|
|
@@ -1034,15 +1173,18 @@ function buildProposalOps(mapDoc, changes){
|
|
|
1034
1173
|
} else if(op === "rename"){
|
|
1035
1174
|
if(typeof c.text !== "string" || !c.text.trim()) throw new Error(`${where}: \`text\` is required for a "rename".`);
|
|
1036
1175
|
out.text = c.text.trim().slice(0, 2000);
|
|
1176
|
+
r.node.text = out.text;
|
|
1037
1177
|
} else if(op === "note"){
|
|
1038
1178
|
if(typeof c.note !== "string") throw new Error(`${where}: \`note\` is required for a "note" (an empty string clears it).`);
|
|
1039
1179
|
out.note = c.note.slice(0, 8000);
|
|
1040
1180
|
} else if(op === "check"){
|
|
1041
|
-
|
|
1042
|
-
|
|
1181
|
+
const v = c.value === "true" ? true : c.value === "false" ? false : c.value;
|
|
1182
|
+
if(typeof v !== "boolean") throw new Error(`${where}: \`value\` must be true or false for a "check".`);
|
|
1183
|
+
out.value = v;
|
|
1043
1184
|
} else if(op === "delete"){
|
|
1044
1185
|
if(r.node === mapDoc.root) throw new Error(`${where}: the centre of the map cannot be deleted.`);
|
|
1045
1186
|
out.drops = countNodes(r.node);
|
|
1187
|
+
(function cut(n){ const k = (n.children || []).indexOf(r.node); if(k >= 0){ n.children.splice(k, 1); return true; } return (n.children || []).some(cut); })(mapDoc.root);
|
|
1046
1188
|
} else if(op === "pic"){
|
|
1047
1189
|
// Une désignation, jamais des pixels. La liste est FERMÉE : un id inventé est refusé ICI,
|
|
1048
1190
|
// contre la carte réelle, plutôt que d'arriver chez l'utilisateur sous forme d'une ligne
|
|
@@ -1065,16 +1207,11 @@ function buildProposalOps(mapDoc, changes){
|
|
|
1065
1207
|
} else if(op === "move"){
|
|
1066
1208
|
// ⚠️ Dans le FICHIER les colonnes sont `{cols:[…]}` ; en mémoire, côté app, c'est un
|
|
1067
1209
|
// 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
1210
|
// 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;
|
|
1211
|
+
// colonne RÉELLE (id ou nom) : une carte envoyée ailleurs disparaîtrait de l'écran sans un message.
|
|
1212
|
+
const raw = String(c.col == null ? "" : c.col);
|
|
1213
|
+
if(!colsNow()) colRef(raw || "?", where); // lève le message « pas encore de kanban »
|
|
1214
|
+
out.col = raw ? colRef(raw, where) : "";
|
|
1078
1215
|
} else if(op === "tags"){
|
|
1079
1216
|
// Les dix couleurs, liste FERMÉE — ≡ `okTags` de l'app. Une clé inventée est refusée ICI,
|
|
1080
1217
|
// en nommant la liste, plutôt que de tomber en silence à la lecture. [] retire tout.
|
|
@@ -1090,10 +1227,23 @@ function buildProposalOps(mapDoc, changes){
|
|
|
1090
1227
|
const q = (c.priority == null) ? 0 : c.priority;
|
|
1091
1228
|
if(!Number.isInteger(q) || q < 0 || q > 6) throw new Error(`${where}: \`priority\` must be a whole number from 1 (highest) to 6, or 0 to remove it.`);
|
|
1092
1229
|
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
|
|
1230
|
+
} else if(op === "due" || op === "start"){
|
|
1231
|
+
const q = (c[op] == null) ? "" : String(c[op]);
|
|
1232
|
+
if(q && !/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(q)) throw new Error(`${where}: \`${op}\` must be a day written YYYY-MM-DD (or "" to remove it).`);
|
|
1233
|
+
out[op] = q;
|
|
1234
|
+
} else if(op === "link"){
|
|
1235
|
+
// La liste blanche de l'app (`okOpLink`), en plus étroit : une page web, un e-mail, ou une
|
|
1236
|
+
// autre carte (`map:<id>` — l'id que list_maps donne). Jamais `javascript:` ni `data:`.
|
|
1237
|
+
const q = (c.link == null) ? "" : String(c.link).trim();
|
|
1238
|
+
if(q && !/^(https?:\/\/[^\s\x00-\x1f]+|mailto:[^\s\x00-\x1f]+|map:[A-Za-z0-9_]+)$/i.test(q))
|
|
1239
|
+
throw new Error(`${where}: \`link\` must be an https:// address, a mailto: address, or "map:<file>" to open another map from list_maps (or "" to remove it).`);
|
|
1240
|
+
out.link = q;
|
|
1241
|
+
} else if(op === "lever"){
|
|
1242
|
+
// Le levier Ai4X d'une carte. L'app le range en ATTRIBUT « Lever: <clé> » (`kbLeverSet` →
|
|
1243
|
+
// `shAttrSet`) : on écrit exactement ça, par l'op `attr`, que l'app sait appliquer.
|
|
1244
|
+
const q = String(c.lever == null ? "" : c.lever).trim().toLowerCase();
|
|
1245
|
+
if(q && !KB_LEVER_KEYS_MCP.includes(q)) throw new Error(`${where}: \`lever\` must be one of ${KB_LEVER_KEYS_MCP.join(", ")} (or "" to remove it).`);
|
|
1246
|
+
out.op = "attr"; out.name = "Lever"; out.value = q;
|
|
1097
1247
|
} else if(op === "attr"){
|
|
1098
1248
|
if(typeof c.name !== "string" || !c.name.trim() || c.name.length > 60 || /[:\x00-\x1f]/.test(c.name))
|
|
1099
1249
|
throw new Error(`${where}: \`name\` is required for an "attr" — the attribute's name, without a colon (60 characters max).`);
|
|
@@ -1146,19 +1296,37 @@ function parts_last(spec){ const p = String(spec||"").split(">"); return p[p.len
|
|
|
1146
1296
|
// mot, puisqu'elle ne peut pas savoir ce qu'il contenait. Avec des captures d'écran dedans, la
|
|
1147
1297
|
// borne se rencontre : on refuse ICI en nommant le geste, jamais un dépôt qui n'arrive pas.
|
|
1148
1298
|
const PROP_FILE_MAX = 1400000;
|
|
1149
|
-
|
|
1150
|
-
|
|
1299
|
+
// ⚡ LE DÉCOUPAGE « HYBRIDE » (1.5.0). Le début du lot fait d'ops sûres part avec `auto:true` —
|
|
1300
|
+
// l'app l'applique d'elle-même ; à partir de la PREMIÈRE op qui demande une revue, tout le reste
|
|
1301
|
+
// part dans une seconde proposition, à revoir. Découper AU PREMIER refus, jamais op par op : une
|
|
1302
|
+
// op sûre qui suit un renommage vise souvent le texte renommé, et l'appliquer avant lui la ferait
|
|
1303
|
+
// échouer chez l'utilisateur.
|
|
1304
|
+
function autoOk(o){ return o.op === "cols" ? o.seed === true : PROP_AUTO_OPS.includes(o.op); }
|
|
1305
|
+
function splitAuto(ops){
|
|
1306
|
+
let k = 0;
|
|
1307
|
+
while(k < ops.length && autoOk(ops[k])) k++;
|
|
1308
|
+
return { auto: ops.slice(0, k), review: ops.slice(k) };
|
|
1309
|
+
}
|
|
1310
|
+
async function writeProposal(map, note, ops, opts){
|
|
1311
|
+
const o = opts || {};
|
|
1312
|
+
const payload = (id, list, auto)=>Object.assign({
|
|
1151
1313
|
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)
|
|
1314
|
+
createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops: list
|
|
1315
|
+
}, auto ? { auto: true } : {});
|
|
1316
|
+
const shots = ops.filter(x=>x.op === "img" && x.img).length;
|
|
1317
|
+
if(JSON.stringify(payload("x", ops)).length > PROP_FILE_MAX)
|
|
1156
1318
|
throw new Error("This proposal is too large for TabTree's mailbox (" + Math.round(PROP_FILE_MAX / 1024) + " KB max) — it carries " + shots + " screenshot(s). Send it as several proposals with fewer screenshots each.");
|
|
1157
1319
|
if(CLOUD){
|
|
1158
1320
|
// La boîte aux lettres cloud : la table `proposals`, que l'app relit au sondage suivant.
|
|
1159
1321
|
// Même charge utile que le fichier — c'est le même `parseProposal` qui la lit côté app.
|
|
1160
|
-
|
|
1161
|
-
|
|
1322
|
+
// Une carte d'équipe : la clé chez son propriétaire, et `mapId` = l'id que l'app lui donne.
|
|
1323
|
+
const parts = o.review ? { auto: [], review: ops } : splitAuto(ops);
|
|
1324
|
+
const base = { map_key: map.key || map.file };
|
|
1325
|
+
if(map.team && map.owner) base.owner = map.owner;
|
|
1326
|
+
const ids = {};
|
|
1327
|
+
if(parts.auto.length){ const r = await cloudCall("propose", Object.assign({ payload: payload(map.file, parts.auto, true) }, base)); ids.auto = r.id || "proposal"; }
|
|
1328
|
+
if(parts.review.length){ const r = await cloudCall("propose", Object.assign({ payload: payload(map.file, parts.review, false) }, base)); ids.review = r.id || "proposal"; }
|
|
1329
|
+
return { auto: parts.auto, review: parts.review, ids };
|
|
1162
1330
|
}
|
|
1163
1331
|
dirOk();
|
|
1164
1332
|
const target = join(DIR, PROP_DIR);
|
|
@@ -1166,8 +1334,8 @@ async function writeProposal(map, note, ops){
|
|
|
1166
1334
|
const id = mapIdOf(map.file);
|
|
1167
1335
|
if(!id) throw new Error("This map's file has no id suffix (“…__map123.tabtree”), so the app cannot match a proposal to it. Open it once in TabTree with the backup folder turned on, then try again.");
|
|
1168
1336
|
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;
|
|
1337
|
+
writeFileSync(join(target, file), JSON.stringify(payload(id, ops, false), null, 2), "utf8");
|
|
1338
|
+
return { auto: [], review: ops, ids: { review: PROP_DIR + "/" + file } };
|
|
1171
1339
|
}
|
|
1172
1340
|
|
|
1173
1341
|
// Ce texte est lu par CHAQUE acheteur, à chaque création. Il a déjà été faux une
|
|
@@ -1189,13 +1357,147 @@ function importHint(){
|
|
|
1189
1357
|
+ "the connector is pointed at a different library — that is why a map can be created and never appear.";
|
|
1190
1358
|
}
|
|
1191
1359
|
|
|
1360
|
+
// ---------------------------------------------------------------------------
|
|
1361
|
+
// 🃏 Le KANBAN (1.5.0) — lire ses colonnes, et en fabriquer un d'un seul appel
|
|
1362
|
+
// ---------------------------------------------------------------------------
|
|
1363
|
+
function kanbanToText(doc){
|
|
1364
|
+
const k = doc && doc.kanban;
|
|
1365
|
+
if(!k || !Array.isArray(k.cols) || !k.cols.length) return "";
|
|
1366
|
+
const cols = k.cols.map(c => "“" + c.name + "” (" + c.id + (c.done ? ", done" : "") + (c.wip ? ", WIP " + c.wip : "") + ")").join(" · ");
|
|
1367
|
+
const tags = (k.tags && typeof k.tags === "object") ? Object.entries(k.tags)
|
|
1368
|
+
.filter(([key, v]) => KB_TAG_KEYS.includes(key) && typeof v === "string" && v.trim())
|
|
1369
|
+
.map(([key, v]) => key + " = “" + v.replace(/[\x00-\x1f\x7f]+/g, " ").trim().slice(0, 40) + "”") : [];
|
|
1370
|
+
return "\n\n🃏 Kanban columns: " + cols + (tags.length ? "\n🏷 Labels: " + tags.join(", ") : "")
|
|
1371
|
+
+ "\n(Cards are the nodes marked ⟪column: …⟫. Move one with propose_changes op \"move\" and `col` = a column name or id; a ticked card [x] shows in the done column.)";
|
|
1372
|
+
}
|
|
1373
|
+
function kbTagNames(doc){
|
|
1374
|
+
const t = doc && doc.kanban && doc.kanban.tags;
|
|
1375
|
+
const out = {};
|
|
1376
|
+
if(t && typeof t === "object") for(const k of KB_TAG_KEYS) if(typeof t[k] === "string" && t[k].trim()) out[k] = t[k].trim().slice(0, 40);
|
|
1377
|
+
return out;
|
|
1378
|
+
}
|
|
1379
|
+
// Une durée en SECONDES, depuis les formes que `parseDur` lit dans l'app (« 2h », « 1h30 », « 45 »
|
|
1380
|
+
// = minutes, « 2:15 », « 30m », « 90s »). L'estimation d'un nœud est stockée en secondes (`okSecs`).
|
|
1381
|
+
function durSecs(v){
|
|
1382
|
+
const q = String(v == null ? "" : v).trim().toLowerCase().replace(/\s+/g, "");
|
|
1383
|
+
if(!q || !DUR_RE.test(q)) return null;
|
|
1384
|
+
let m;
|
|
1385
|
+
if((m = /^(\d+):([0-5]\d)$/.exec(q))) return (+m[1] * 60 + +m[2]) * 60;
|
|
1386
|
+
if((m = /^(\d+)h(\d+)?m?$/.exec(q))) return (+m[1] * 60 + (+m[2] || 0)) * 60;
|
|
1387
|
+
if((m = /^(\d+)m(in)?$/.exec(q))) return +m[1] * 60;
|
|
1388
|
+
if((m = /^(\d+)s(ec)?$/.exec(q))) return +m[1];
|
|
1389
|
+
return +q * 60;
|
|
1390
|
+
}
|
|
1391
|
+
const DAY_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
|
1392
|
+
// Un identifiant sur chaque nœud créé : l'app en donnerait un à l'ouverture, mais la signature du
|
|
1393
|
+
// document changerait alors entre le serveur et l'app (un aller-retour de synchro pour rien), et
|
|
1394
|
+
// une proposition suivante ne pourrait viser une carte que par son texte.
|
|
1395
|
+
function assignIds(n){ if(!n.id) n.id = "n" + Math.random().toString(36).slice(2, 12); (n.children || []).forEach(assignIds); return n; }
|
|
1396
|
+
// Les colonnes demandées, validées et munies d'ids — même forme qu'`okKanbanCols` côté app.
|
|
1397
|
+
function kanbanColsFrom(list, where){
|
|
1398
|
+
const src = Array.isArray(list) && list.length ? list : [{ name:"To do" }, { name:"Doing" }, { name:"Done", done:true }];
|
|
1399
|
+
if(src.length > 12) throw new Error(where + ": a kanban has 12 columns at most.");
|
|
1400
|
+
const cols = src.map((k, j)=>{
|
|
1401
|
+
const name = String((k && (k.name != null ? k.name : k)) || "").replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, 60);
|
|
1402
|
+
if(!name) throw new Error(where + ": column #" + (j+1) + " has no name.");
|
|
1403
|
+
const wip = parseInt(k && k.wip, 10);
|
|
1404
|
+
return Object.assign({ id: "c" + (j+1), name }, k && k.done ? { done: true } : {}, (wip >= 1 && wip <= 99) ? { wip } : {});
|
|
1405
|
+
});
|
|
1406
|
+
if(cols.filter(k => k.done).length > 1) throw new Error(where + ": only one column can be the \"done\" column.");
|
|
1407
|
+
return cols;
|
|
1408
|
+
}
|
|
1409
|
+
// UN appel, un tableau complet : colonnes, cartes, et sur chaque carte ce que l'app affiche au recto
|
|
1410
|
+
// et au dos — responsable, dates, priorité, étiquettes, estimation, levier Ai4X, note, lien,
|
|
1411
|
+
// sous-tâches. Tout passe par les mêmes bornes que les propositions : une carte qui serait refusée
|
|
1412
|
+
// par l'app à l'ouverture est refusée ICI, en le disant.
|
|
1413
|
+
function buildKanbanDoc(args){
|
|
1414
|
+
const name = String(args.name || "").trim() || "Kanban";
|
|
1415
|
+
const cols = kanbanColsFrom(args.columns, "columns");
|
|
1416
|
+
const work = cols.filter(k => !k.done), done = cols.find(k => k.done) || null;
|
|
1417
|
+
const firstWork = (work[0] || cols[0]).id;
|
|
1418
|
+
const colOf = (raw, where)=>{
|
|
1419
|
+
if(raw == null || raw === "") return firstWork;
|
|
1420
|
+
const v = String(raw).trim().toLowerCase();
|
|
1421
|
+
const hit = cols.find(k => k.id === String(raw)) || cols.find(k => k.name.toLowerCase() === v);
|
|
1422
|
+
if(!hit) throw new Error(where + ": no column \"" + raw + "\". Columns: " + cols.map(k => "“" + k.name + "”").join(", ") + ".");
|
|
1423
|
+
return hit.id;
|
|
1424
|
+
};
|
|
1425
|
+
const cards = Array.isArray(args.cards) ? args.cards : [];
|
|
1426
|
+
if(cards.length > 300) throw new Error("A kanban created in one call holds 300 cards at most — create it, then add more with propose_changes.");
|
|
1427
|
+
const root = makeNode(name);
|
|
1428
|
+
root.color = "#dbeafe";
|
|
1429
|
+
cards.forEach((c, i)=>{
|
|
1430
|
+
const where = "card #" + (i+1);
|
|
1431
|
+
const text = String((c && c.text) || "").replace(/[\x00-\x1f\x7f]/g, " ").trim();
|
|
1432
|
+
if(!text) throw new Error(where + ": `text` is required.");
|
|
1433
|
+
const n = makeNode(text.slice(0, 2000));
|
|
1434
|
+
const col = colOf(c.column, where);
|
|
1435
|
+
if(done && col === done.id){ n.col = firstWork; n.check = true; } // la règle de l'app : Done COCHE, la colonne reste de travail
|
|
1436
|
+
else { n.col = col; n.check = false; }
|
|
1437
|
+
if(c.note != null){ if(typeof c.note !== "string") throw new Error(where + ": `note` must be text."); n.note = c.note.slice(0, 8000); }
|
|
1438
|
+
if(c.link){
|
|
1439
|
+
const l = String(c.link).trim();
|
|
1440
|
+
if(!/^(https?:\/\/[^\s\x00-\x1f]+|mailto:[^\s\x00-\x1f]+|map:[A-Za-z0-9_]+)$/i.test(l)) throw new Error(where + ": `link` must be an https:// or mailto: address, or map:<file>.");
|
|
1441
|
+
n.link = l;
|
|
1442
|
+
}
|
|
1443
|
+
if(c.assign){
|
|
1444
|
+
if(typeof c.assign !== "string" || c.assign.length > 120 || /[\x00-\x1f\x7f]/.test(c.assign)) throw new Error(where + ": `assign` must be a name or e-mail (120 characters max).");
|
|
1445
|
+
n.assign = c.assign.trim();
|
|
1446
|
+
}
|
|
1447
|
+
for(const k of ["start", "due"]) if(c[k]){ if(!DAY_RE.test(String(c[k]))) throw new Error(where + ": `" + k + "` must be YYYY-MM-DD."); n[k] = String(c[k]); }
|
|
1448
|
+
if(n.start && n.due && n.due < n.start){ const t = n.start; n.start = n.due; n.due = t; } // la règle du fichier : remis en ordre, pas refusé
|
|
1449
|
+
if(c.priority != null && c.priority !== 0){
|
|
1450
|
+
if(!Number.isInteger(c.priority) || c.priority < 1 || c.priority > 6) throw new Error(where + ": `priority` must be 1 (highest) to 6.");
|
|
1451
|
+
n.priority = c.priority;
|
|
1452
|
+
}
|
|
1453
|
+
const tags = c.labels != null ? c.labels : c.tags;
|
|
1454
|
+
if(tags != null){
|
|
1455
|
+
if(!Array.isArray(tags)) throw new Error(where + ": `labels` must be a list of colours.");
|
|
1456
|
+
const bad = tags.filter(t => !KB_TAG_KEYS.includes(t));
|
|
1457
|
+
if(bad.length) throw new Error(where + ": unknown label colour \"" + bad[0] + "\". Use any of: " + KB_TAG_KEYS.join(", ") + ".");
|
|
1458
|
+
if(tags.length) n.tags = KB_TAG_KEYS.filter(k => tags.includes(k));
|
|
1459
|
+
}
|
|
1460
|
+
if(c.est != null && c.est !== ""){
|
|
1461
|
+
const sec = durSecs(c.est);
|
|
1462
|
+
if(sec == null) throw new Error(where + ": `est` must be a duration — \"2h\", \"1h30\", \"45\" (minutes) or \"2:15\".");
|
|
1463
|
+
if(sec > 0) n.est = sec;
|
|
1464
|
+
}
|
|
1465
|
+
if(c.pic){ if(!okPic(String(c.pic))) throw new Error(where + ": `pic` must be \"a:<id>\" (" + ART_IDS_MCP.slice(0, 8).join(", ") + "…) or \"e:<emoji>\"."); n.pic = String(c.pic); }
|
|
1466
|
+
// Les sous-tâches sont des ENFANTS cochables, sans colonne — la règle du dos de la carte
|
|
1467
|
+
// (`kbAddSub`) : jamais des cartes sœurs.
|
|
1468
|
+
const subs = Array.isArray(c.subtasks) ? c.subtasks : [];
|
|
1469
|
+
if(subs.length > 50) throw new Error(where + ": 50 sub-tasks at most.");
|
|
1470
|
+
for(const t of subs){
|
|
1471
|
+
const st = String(t || "").replace(/[\x00-\x1f\x7f]/g, " ").trim();
|
|
1472
|
+
if(!st) continue;
|
|
1473
|
+
const k = makeNode(st.slice(0, 2000)); k.check = false; n.children.push(k);
|
|
1474
|
+
}
|
|
1475
|
+
// Le levier Ai4X : un attribut « Lever: <clé> », exactement ce que pose le dos de la carte.
|
|
1476
|
+
if(c.lever){
|
|
1477
|
+
const lv = String(c.lever).trim().toLowerCase();
|
|
1478
|
+
if(!KB_LEVER_KEYS_MCP.includes(lv)) throw new Error(where + ": `lever` must be one of " + KB_LEVER_KEYS_MCP.join(", ") + ".");
|
|
1479
|
+
n.children.push(makeNode("Lever: " + lv));
|
|
1480
|
+
}
|
|
1481
|
+
root.children.push(n);
|
|
1482
|
+
});
|
|
1483
|
+
assignIds(root);
|
|
1484
|
+
const kanban = { cols };
|
|
1485
|
+
const names = {};
|
|
1486
|
+
if(args.labels && typeof args.labels === "object") for(const k of KB_TAG_KEYS) if(typeof args.labels[k] === "string" && args.labels[k].trim()) names[k] = args.labels[k].replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, 40);
|
|
1487
|
+
if(Object.keys(names).length) kanban.tags = names;
|
|
1488
|
+
const doc = { v:1, root, images:[], stickies:[], kanban };
|
|
1489
|
+
const st = okStyleMcp(args.style);
|
|
1490
|
+
if(st) doc.style = st;
|
|
1491
|
+
return doc;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1192
1494
|
// ---------------------------------------------------------------------------
|
|
1193
1495
|
// Définition des outils
|
|
1194
1496
|
// ---------------------------------------------------------------------------
|
|
1195
1497
|
const TOOLS = [
|
|
1196
1498
|
{
|
|
1197
1499
|
name: "list_maps",
|
|
1198
|
-
description: "Lists every mind map and board in the TabTree library (name, type, folder, size, last modified).",
|
|
1500
|
+
description: "Lists every mind map, kanban, note and board in the TabTree library — the user's own and those of the team spaces they belong to (name, type, space, folder, size, last modified).",
|
|
1199
1501
|
inputSchema: { type:"object", properties:{}, additionalProperties:false }
|
|
1200
1502
|
},
|
|
1201
1503
|
{
|
|
@@ -1215,6 +1517,13 @@ const TOOLS = [
|
|
|
1215
1517
|
name:{ type:"string", description:"Name of the map" },
|
|
1216
1518
|
markdown:{ type:"string", description:"Markdown outline (headings and/or bullet list). End any line with \" @a:<id>\" to give that node a built-in illustration, or \" @e:<emoji>\" for one large emoji — e.g. \"Launch @a:rocket\". Ids: person, team, chat, idea, target, trophy, star, heart, warning, done, flag, clock, laptop, phone, mail, folder, book, chart, money, calendar, building, car, globe, rocket, house, pin, plane, coffee, plant, sun, camera, music, lock, key, health, gift. An unknown id is left as plain text rather than dropped." },
|
|
1217
1519
|
folder:{ type:"string", description:"Optional TabTree folder. A NAME (\"From Claude\") or a full PATH (\"Ai4X / Programmes\"). When several folders share a name, the shallowest is used and the answer says which — pass the full path to choose another." },
|
|
1520
|
+
space:{ type:"string", description:"Optional TabTree SPACE to put it in, by name — one of the user's spaces, or a TEAM space they are an editor of (every member then sees it). list_maps shows the spaces. Omit for the user's own library." },
|
|
1521
|
+
view:{ type:"string", enum:["map","note","gantt"], description:"How TabTree opens it: map (default, a mind map), note (a page of text — the outline becomes headings and paragraphs, for meeting notes or a document), gantt (a timeline — give `dates`). It stays one document: the user can switch views at any time." },
|
|
1522
|
+
dates:{ type:"array", description:"Optional start/due days on nodes — what the Gantt view draws (and what the kanban and calendar show). One entry per node.", items:{ type:"object", properties:{
|
|
1523
|
+
target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\"" },
|
|
1524
|
+
start:{ type:"string", description:"YYYY-MM-DD" },
|
|
1525
|
+
due:{ type:"string", description:"YYYY-MM-DD" }
|
|
1526
|
+
}, required:["target"], additionalProperties:false } },
|
|
1218
1527
|
images:{ type:"array", description:"Optional screenshots, one per node: { target, file } or { target, data }. `target` = the exact text of a line of the outline (or \"Ancestor > Node\" when the wording repeats); `file` = full path of a PNG/JPEG/WebP/GIF on this machine (take the screenshot, save it, pass the path); `data` = a data:image/… URL instead of a file. The image is re-encoded to about 840 px and becomes the node's cover picture (it replaces any @a:/@e: illustration on that node). 60 max.", items:{ type:"object", properties:{
|
|
1219
1528
|
target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\"" },
|
|
1220
1529
|
file:{ type:"string", description:"Path of the image file on this machine" },
|
|
@@ -1252,6 +1561,7 @@ const TOOLS = [
|
|
|
1252
1561
|
from:{ type:"string" }, to:{ type:"string" }
|
|
1253
1562
|
}, required:["from","to"], additionalProperties:false } },
|
|
1254
1563
|
folder:{ type:"string", description:"Optional TabTree folder. A NAME or a full PATH (\"Ai4X / Programmes\"). When several folders share a name, the shallowest is used and the answer says which." },
|
|
1564
|
+
space:{ type:"string", description:"Optional TabTree SPACE to put it in, by name — one of the user's spaces, or a TEAM space they are an editor of (every member then sees it). list_maps shows the spaces. Omit for the user's own library." },
|
|
1255
1565
|
style:{ type:"object", description:"Optional look of the document. Omit it for the default.", properties:{
|
|
1256
1566
|
font:{ type:"string", enum:["","grotesk","humanist","condensed","rounded","serif","garamond","didone","slab","typewriter","mono","hand"], description:"Type family: grotesk (corporate), humanist (warm), condensed (dense), rounded (friendly), serif/garamond (editorial), didone (elegant), slab (sturdy), typewriter, mono (technical), hand (handwritten)." },
|
|
1257
1567
|
shape:{ type:"string", enum:["","line","plain"], description:"Node shape: cards (default), line = underlined, plain = plain text." },
|
|
@@ -1263,21 +1573,53 @@ const TOOLS = [
|
|
|
1263
1573
|
}, additionalProperties:false }
|
|
1264
1574
|
}, required:["name","elements"], additionalProperties:false }
|
|
1265
1575
|
},
|
|
1576
|
+
{
|
|
1577
|
+
name: "create_kanban",
|
|
1578
|
+
description: "Creates a NEW TabTree kanban board in one call — columns and cards, each card with what TabTree shows on it: owner, start/due days, priority, colour labels, time estimate, Ai4X lever, note (links in it become clickable — a Google Meet link, a doc), a link, and sub-tasks. It opens directly as a kanban in TabTree, on every device signed in to the account, and in a TEAM space every member sees it. To add or move cards on an EXISTING kanban, use propose_changes instead.",
|
|
1579
|
+
inputSchema: { type:"object", properties:{
|
|
1580
|
+
name:{ type:"string", description:"Name of the board (e.g. \"Sprint W43\", \"Launch\")" },
|
|
1581
|
+
columns:{ type:"array", description:"The columns, left to right. Omit for To do / Doing / Done. Mark ONE column done:true: a card put there is ticked, and unticking it sends it back. 12 max.", items:{ type:"object", properties:{
|
|
1582
|
+
name:{ type:"string" }, done:{ type:"boolean" }, wip:{ type:"integer", description:"Optional work-in-progress limit 1–99 (the column turns red above it — nothing is blocked)" }
|
|
1583
|
+
}, required:["name"], additionalProperties:false } },
|
|
1584
|
+
cards:{ type:"array", description:"The cards, in order within each column. 300 max.", items:{ type:"object", properties:{
|
|
1585
|
+
text:{ type:"string", description:"The card's title" },
|
|
1586
|
+
column:{ type:"string", description:"Column NAME (or id). Default: the first column" },
|
|
1587
|
+
assign:{ type:"string", description:"Owner — a name or e-mail" },
|
|
1588
|
+
start:{ type:"string", description:"YYYY-MM-DD" },
|
|
1589
|
+
due:{ type:"string", description:"YYYY-MM-DD" },
|
|
1590
|
+
priority:{ type:"integer", description:"1 (highest) to 6" },
|
|
1591
|
+
labels:{ type:"array", items:{ type:"string" }, description:"Colour labels from: green, yellow, orange, red, purple, blue, sky, lime, pink, gray (name them with `labels` at the board level)" },
|
|
1592
|
+
est:{ type:"string", description:"Time estimate: \"2h\", \"1h30\", \"45\" (minutes), \"2:15\"" },
|
|
1593
|
+
lever:{ type:"string", enum:["human","think","accelerate","automate"], description:"Ai4X lever: human (stays human), think (think with Claude), accelerate (Claude Code or a connector), automate (a skill or a scheduled task)" },
|
|
1594
|
+
note:{ type:"string", description:"The back of the card — plain text; http(s) and mailto links become clickable" },
|
|
1595
|
+
link:{ type:"string", description:"An https:// or mailto: link on the card, or map:<file> to open another map from list_maps" },
|
|
1596
|
+
subtasks:{ type:"array", items:{ type:"string" }, description:"Sub-tasks, ticked on the back of the card (the card shows n/m)" },
|
|
1597
|
+
pic:{ type:"string", description:"\"a:<id>\" built-in illustration or \"e:<emoji>\"" }
|
|
1598
|
+
}, required:["text"], additionalProperties:false } },
|
|
1599
|
+
labels:{ type:"object", description:"Optional names for the colour labels, e.g. { \"red\": \"Bug\", \"green\": \"Quick win\" }", additionalProperties:{ type:"string" } },
|
|
1600
|
+
folder:{ type:"string", description:"Optional folder, by NAME or full PATH." },
|
|
1601
|
+
space:{ type:"string", description:"Optional TabTree SPACE to put it in, by name — one of the user's spaces, or a TEAM space they are an editor of (every member then sees it). list_maps shows the spaces. Omit for the user's own library." },
|
|
1602
|
+
style:{ type:"object", description:"Optional look (same keys as create_mindmap: font, background, skin…).", additionalProperties:true }
|
|
1603
|
+
}, required:["name"], additionalProperties:false }
|
|
1604
|
+
},
|
|
1266
1605
|
{
|
|
1267
1606
|
name: "propose_changes",
|
|
1268
|
-
description: "
|
|
1607
|
+
description: "Changes an EXISTING mind map or kanban (also one in a team space). HYBRID, by design: SAFE changes — add cards or branches, move a card to a column, tick it, set its owner, dates, priority, labels, estimate, Ai4X lever, an attribute, a link, a comment, a picture, and set up the columns of a board that has none — are APPLIED BY TABTREE ITSELF within seconds when TabTree is open on one of the user's devices (otherwise the next time it opens), and Cmd+Z undoes them. Changes that rewrite or remove what someone wrote — rename, note, delete, replacing existing columns, a persona re-cast — appear as a banner for the user to review and approve. In one call, the safe changes BEFORE the first review-only change apply by themselves and the rest goes to review, in order. Name each target node by its exact text, or by a path \"Ancestor > Node\" when the same wording appears twice; a later change may target a node added earlier in the same call. Read the map first so the wording matches.",
|
|
1269
1608
|
inputSchema: { type:"object", properties:{
|
|
1270
1609
|
file:{ type:"string", description:"The map to change — a file name (or map name) from list_maps" },
|
|
1271
1610
|
note:{ type:"string", description:"One line telling the user what this proposal does and why. Shown above the changes." },
|
|
1272
1611
|
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)" },
|
|
1612
|
+
op:{ type:"string", enum:["add","rename","note","check","delete","move","pic","img","persona","tags","assign","priority","due","start","attr","comment","est","lever","link","columns"], description:"columns = set the kanban columns (give `columns`; no target) — on a map with no kanban yet this creates the board; add = new children under `target`; rename = change its text; note = set its note; check = tick/untick the task; delete = remove it and its children; move = send the card to a kanban column; pic = put an illustration on it; img = put a SCREENSHOT on it (give `file`, the path of the image on this machine — or `data`; `img: \"\"` removes it); persona = re-cast a work map canvas (a board whose name starts with “Work map —” / “Cartographie —”) or a Second Brain Blueprint (“Second brain —”) from an interview — no target; tags = set the card's colour labels; assign = who the task is for; priority = 1 (highest) to 6; due = the due day; attr = set a “Name: value” attribute on the node (an estimate, a budget…); comment = add a signed comment to the card's thread (give `text`); est = set the time ESTIMATE of the task (give `est`, e.g. \"2h\", \"45\" = 45 minutes, \"\" removes it); start = the start day; lever = the card's Ai4X lever (give `lever`); link = put a link on the node (give `link`)" },
|
|
1274
1613
|
target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\" if ambiguous (not used by persona)" },
|
|
1275
1614
|
persona:{ type:"object", description:"persona: the interview object exactly as the skill returns it — who, role, mission, tools, meetings, opener, modules[{name, procs[{name,h,after,freq,rep,data,stakes,lever,step,why}]}], trajectory, signs, parking. TabTree rebuilds the canvas AND its companion sheet from it." },
|
|
1276
1615
|
markdown:{ type:"string", description:"add: the new branch as a bullet list, 2 spaces per level" },
|
|
1277
1616
|
text:{ type:"string", description:"rename: the new text. comment: the comment body (plain text; http/mailto links become clickable)" },
|
|
1278
1617
|
note:{ type:"string", description:"note: the note body (empty string clears it)" },
|
|
1279
|
-
|
|
1280
|
-
|
|
1618
|
+
col:{ type:"string", description:"move: the kanban column — its NAME (\"Doing\") or id, both shown by read_map — or \"\" to take the card off the board. add: optional — the column the NEW cards go to" },
|
|
1619
|
+
columns:{ type:"array", description:"columns: the board's columns left to right — [{ name, done?, wip? }]; mark ONE done:true. Existing columns keep their cards when their name is kept", items:{ type:"object", properties:{ name:{ type:"string" }, done:{ type:"boolean" }, wip:{ type:"integer" } }, required:["name"], additionalProperties:false } },
|
|
1620
|
+
start:{ type:"string", description:"start: the start day as YYYY-MM-DD (\"\" removes it)" },
|
|
1621
|
+
lever:{ type:"string", description:"lever: human, think, accelerate or automate (\"\" removes it)" },
|
|
1622
|
+
link:{ type:"string", description:"link: an https:// or mailto: address, or map:<file> to open another map (\"\" removes it)" },
|
|
1281
1623
|
pic:{ type:"string", description:"pic: \"a:<id>\" for a built-in illustration, or \"e:<emoji>\" for one large emoji, or \"\" to remove it. Ids: person, team, chat, idea, target, trophy, star, heart, warning, done, flag, clock, laptop, phone, mail, folder, book, chart, money, calendar, building, car, globe, rocket, house, pin, plane, coffee, plant, sun, camera, music, lock, key, health, gift." },
|
|
1282
1624
|
file:{ type:"string", description:"img: full path of the screenshot (PNG/JPEG/WebP/GIF) on this machine. It is re-encoded to about 840 px and becomes the node's cover picture." },
|
|
1283
1625
|
data:{ type:"string", description:"img: a data:image/…;base64 URL instead of `file`" },
|
|
@@ -1288,7 +1630,10 @@ const TOOLS = [
|
|
|
1288
1630
|
priority:{ type:"integer", description:"priority: 1 (highest) to 6, or 0 to remove it" },
|
|
1289
1631
|
due:{ type:"string", description:"due: the due day as YYYY-MM-DD (\"\" removes it)" },
|
|
1290
1632
|
name:{ type:"string", description:"attr: the attribute's name, e.g. \"Estimate\"" },
|
|
1291
|
-
|
|
1633
|
+
// ⚠️ UNE seule déclaration de `value` (1.5.0) : il y en avait deux (booléen pour check, texte pour
|
|
1634
|
+
// attr) et la seconde écrasait la première — le schéma disait donc « texte » pour un check, et
|
|
1635
|
+
// Claude envoyait "true", que le contrôle d'un check refuse.
|
|
1636
|
+
value:{ type:["boolean","string"], description:"check: true to tick, false to untick. attr: its value as text, e.g. \"2h\" (\"\" removes the attribute)" },
|
|
1292
1637
|
est:{ type:"string", description:"est: the estimate as a duration — \"2h\", \"1h30\", \"45\" (minutes), \"2:15\" — or \"\" to remove it" }
|
|
1293
1638
|
}, required:["op"], additionalProperties:false } }
|
|
1294
1639
|
}, required:["file","changes"], additionalProperties:false }
|
|
@@ -1299,18 +1644,35 @@ const HANDLERS = {
|
|
|
1299
1644
|
async list_maps(){
|
|
1300
1645
|
const maps = await loadLibrary();
|
|
1301
1646
|
if(!maps.length) return CLOUD ? "This TabTree account has no maps yet." : "The library is empty (or TABTREE_DIR points at the wrong folder: " + DIR + ").";
|
|
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
|
-
)
|
|
1647
|
+
const lines = maps.map(m=>
|
|
1648
|
+
`• ${m.name} [${m.kind}]${m.space ? " " + (m.team ? "👥 " : "◻︎ ") + m.space + (m.team ? " (" + m.role + ")" : "") : ""}${m.folder ? " 📁 " + m.folder : ""}${m.tabOf ? " ↳ tab of “" + m.tabOf + "”" : ""}\n file: ${m.file}\n ${m.kind==="board" ? m.elements + " elements" : m.nodes + " nodes"} · modified ${m.mtime}`
|
|
1649
|
+
);
|
|
1650
|
+
// Les espaces où Claude peut créer (`space` des outils de création) — y compris ceux qui sont VIDES,
|
|
1651
|
+
// qu'aucune carte ne ferait apparaître dans la liste.
|
|
1652
|
+
const sps = CLOUD && cloudSpacesCache && cloudSpacesCache.length
|
|
1653
|
+
? "\n\nSpaces: " + cloudSpacesCache.map(x=>"“" + x.name + "”" + (x.role !== "owner" ? " (team, " + x.role + (x.writable ? "" : ", read-only") + ")" : "")).join(", ")
|
|
1654
|
+
: "";
|
|
1655
|
+
return lines.join("\n") + sps;
|
|
1305
1656
|
},
|
|
1306
1657
|
async read_map(args){
|
|
1307
|
-
|
|
1308
|
-
const
|
|
1658
|
+
checkRelPath(args.file);
|
|
1659
|
+
const all = await loadLibrary();
|
|
1660
|
+
const m = all.find(x=>x.file===args.file) || all.find(x=>x.file.endsWith(args.file)) || all.find(x=>x.name===args.file);
|
|
1661
|
+
if(!m) throw new Error("No such map: " + args.file + ". Use list_maps to see what is available.");
|
|
1662
|
+
const head = `${m.name} [${m.kind}]${m.space ? " — space “" + m.space + "”" + (m.team ? " (team, " + m.role + ")" : "") : ""} — file: ${m.file}\n`;
|
|
1309
1663
|
if(m.kind === "board") return head + boardToText(m.doc);
|
|
1664
|
+
// Les cartes qu'un lien `map:` peut ouvrir, par l'id que l'app leur donne (clé, ou id d'équipe).
|
|
1665
|
+
const maps = new Map();
|
|
1666
|
+
for(const x of all){ maps.set(x.file, { name: x.name, kind: x.kind }); if(x.key) maps.set(x.key, { name: x.name, kind: x.kind }); }
|
|
1667
|
+
const ctx = { cols: (m.doc.kanban && Array.isArray(m.doc.kanban.cols)) ? m.doc.kanban.cols : [], tagNames: kbTagNames(m.doc), maps };
|
|
1310
1668
|
// Le rappel sur propose_changes vit ici et pas dans la description de l'outil : c'est au
|
|
1311
1669
|
// moment où le plan est sous les yeux qu'il est utile, et il porte la règle de désignation.
|
|
1312
|
-
|
|
1313
|
-
|
|
1670
|
+
const ro = m.team && m.role === "viewer";
|
|
1671
|
+
return head + nodeToMarkdown(m.doc.root, 0, ctx) + kanbanToText(m.doc) + teamToText(m.doc)
|
|
1672
|
+
+ (ro ? "\n\n(You can read this team map but not change it — the user's role in its space is viewer.)"
|
|
1673
|
+
: "\n\n(To change this map, use propose_changes with `file: \"" + m.file + "\"`. Name a node by the exact text above, or \"Ancestor > Node\" when it appears twice. "
|
|
1674
|
+
+ (CLOUD ? "Safe changes (add, move, tick, owner, dates, labels, lever…) are applied by TabTree itself; rename, note and delete wait for the user's approval.)"
|
|
1675
|
+
: "Nothing is applied until the user approves it in TabTree.)"));
|
|
1314
1676
|
},
|
|
1315
1677
|
async search_maps(args){
|
|
1316
1678
|
const q = String(args.query||"").toLowerCase();
|
|
@@ -1345,16 +1707,36 @@ const HANDLERS = {
|
|
|
1345
1707
|
// Les captures d'écran APRÈS les marqueurs : une désignation se retire du texte avant que le
|
|
1346
1708
|
// texte serve de cible, et une capture remplace l'illustration du même nœud.
|
|
1347
1709
|
const shots = applyImages(root, args.images);
|
|
1710
|
+
// Les DATES (1.5.0) : ce que la vue Gantt dessine. Une cible introuvable ou ambiguë est refusée
|
|
1711
|
+
// en le disant — une date posée sur le mauvais nœud fausserait toute la frise.
|
|
1712
|
+
const dated = [];
|
|
1713
|
+
if(args.dates != null){
|
|
1714
|
+
if(!Array.isArray(args.dates)) throw new Error("`dates` must be a list of { target, start, due }.");
|
|
1715
|
+
args.dates.forEach((d, i)=>{
|
|
1716
|
+
const where = "dates #" + (i+1);
|
|
1717
|
+
const r = propFind(root, String((d && d.target) || ""));
|
|
1718
|
+
if(r.error === "empty") throw new Error(where + ": `target` is required.");
|
|
1719
|
+
if(r.error === "missing") throw new Error(where + ": no node \"" + d.target + "\" in this outline.");
|
|
1720
|
+
if(r.error === "ambiguous") throw new Error(where + ": \"" + d.target + "\" matches " + r.count + " nodes — use \"Ancestor > Node\".");
|
|
1721
|
+
for(const k of ["start", "due"]) if(d[k]){ if(!DAY_RE.test(String(d[k]))) throw new Error(where + ": `" + k + "` must be YYYY-MM-DD."); r.node[k] = String(d[k]); }
|
|
1722
|
+
if(r.node.start && r.node.due && r.node.due < r.node.start){ const t = r.node.start; r.node.start = r.node.due; r.node.due = t; }
|
|
1723
|
+
dated.push(r.node);
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
const view = ["note", "gantt"].includes(args.view) ? args.view : "map";
|
|
1727
|
+
if(view === "gantt" && !dated.length) throw new Error("A Gantt needs dates — pass `dates` with a start and/or due day for the nodes to draw.");
|
|
1728
|
+
assignIds(root);
|
|
1348
1729
|
const doc = { v:1, root, images:[], stickies:[] };
|
|
1349
1730
|
const st = okStyleMcp(args.style);
|
|
1350
1731
|
if(st) doc.style = st;
|
|
1351
|
-
const file = await writeDoc(args.name || root.text, doc, args.folder);
|
|
1732
|
+
const file = await writeDoc(args.name || root.text, doc, args.folder, { kind: view, space: args.space });
|
|
1352
1733
|
const shotLines = shots.length ? `\n📷 ${shots.length} screenshot(s) on the map:\n` + shots.map(x=>" • " + x).join("\n") + "\n" : "";
|
|
1353
|
-
|
|
1734
|
+
const label = view === "note" ? "Note" : view === "gantt" ? "Gantt" : "Map";
|
|
1735
|
+
return `✅ ${label} created (${countNodes(root)} nodes${dated.length ? ", " + dated.length + " dated" : ""}): ${file}\n${shotLines}${importHint()}`;
|
|
1354
1736
|
},
|
|
1355
1737
|
async create_board(args){
|
|
1356
1738
|
const { doc, warnings } = buildBoardDoc(args.name || "Board", args.elements, args.connections, args.style);
|
|
1357
|
-
const file = await writeDoc(args.name || "Board", doc, args.folder);
|
|
1739
|
+
const file = await writeDoc(args.name || "Board", doc, args.folder, { space: args.space });
|
|
1358
1740
|
const n = doc.shapes.length + doc.stickies.length;
|
|
1359
1741
|
// Les avertissements passent AVANT l'astuce d'import : c'est le seul moment où le texte
|
|
1360
1742
|
// peut encore être raccourci, et une ligne noyée en fin de réponse ne se lit pas.
|
|
@@ -1366,6 +1748,15 @@ const HANDLERS = {
|
|
|
1366
1748
|
: "";
|
|
1367
1749
|
return `✅ Board created (${n} elements, ${doc.arrows.length} connection(s)): ${file}\n${warn}${importHint()}`;
|
|
1368
1750
|
},
|
|
1751
|
+
async create_kanban(args){
|
|
1752
|
+
if(typeof args.name !== "string" || !args.name.trim()) throw new Error("The `name` parameter is required (the board's name).");
|
|
1753
|
+
const doc = buildKanbanDoc(args);
|
|
1754
|
+
const file = await writeDoc(args.name, doc, args.folder, { kind: "kanban", space: args.space });
|
|
1755
|
+
const cards = doc.root.children;
|
|
1756
|
+
const per = doc.kanban.cols.map(k => k.name + " " + cards.filter(c => k.done ? c.check === true : (c.col === k.id && c.check !== true)).length).join(" · ");
|
|
1757
|
+
return `✅ Kanban created — ${cards.length} card(s): ${per}\n${file}\n${importHint()}`
|
|
1758
|
+
+ (CLOUD ? "\nIt opens as a kanban. To add or move cards later, use propose_changes on this file — those changes apply by themselves." : "");
|
|
1759
|
+
},
|
|
1369
1760
|
async propose_changes(args){
|
|
1370
1761
|
const m = await findMap(args.file);
|
|
1371
1762
|
// Un persona est la SEULE op qui vise un board — la work map en est un — et il ne vise que ça.
|
|
@@ -1374,28 +1765,35 @@ const HANDLERS = {
|
|
|
1374
1765
|
throw new Error("Proposals only apply to mind maps. A board has free positions and drawings, which a change list cannot describe.");
|
|
1375
1766
|
if(allPersona && m.kind !== "board")
|
|
1376
1767
|
throw new Error("A persona re-casts a WORK MAP or a SECOND BRAIN BLUEPRINT canvas, which is a board. “" + m.name + "” is a mind map — pick the person's canvas from list_maps (its name starts with “Work map —”, “Cartographie —” or “Second brain —”).");
|
|
1768
|
+
if(m.team && m.role === "viewer") throw new Error("“" + m.name + "” is in a team space where the user is a viewer — it can be read, not changed. Ask the space's owner for the editor role.");
|
|
1377
1769
|
const ops = buildProposalOps(m.doc, args.changes);
|
|
1378
|
-
const
|
|
1770
|
+
const res = await writeProposal(m, args.note, ops, { review: allPersona });
|
|
1379
1771
|
if(allPersona){
|
|
1380
|
-
return `📮 Interview filed for “${m.name}” — ${ops[0].domains ? ops[0].domains + " domains" : ops[0].procs + " processes"}: ${
|
|
1772
|
+
return `📮 Interview filed for “${m.name}” — ${ops[0].domains ? ops[0].domains + " domains" : ops[0].procs + " processes"}: ${res.ids.review}\n`
|
|
1381
1773
|
+ "NOTHING has been changed yet. The canvas file is untouched.\n"
|
|
1382
1774
|
+ (CLOUD ? "TabTree shows a banner on that work map within a minute (app open, signed in): " : "TabTree shows a banner on that work map within a few seconds (backup folder connected, app open): ") + "“Your Claude interview is ready — re-cast this work map?”. "
|
|
1383
1775
|
+ "One click rebuilds the canvas AND its companion sheet from these hours; Cmd+Z undoes it.\n"
|
|
1384
1776
|
+ "Tell the user to look at TabTree — do not claim the map has been re-cast.";
|
|
1385
1777
|
}
|
|
1386
|
-
const
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1778
|
+
const tally = (list)=>{
|
|
1779
|
+
const adds = list.reduce((n,o)=>n + (o.adds||0), 0), drops = list.reduce((n,o)=>n + (o.drops||0), 0);
|
|
1780
|
+
const shots = list.filter(o=>o.op === "img" && o.img).length;
|
|
1781
|
+
return list.length + " change(s)" + (adds ? `, ${adds} node(s) to add` : "") + (drops ? `, ${drops} node(s) to remove` : "") + (shots ? `, ${shots} screenshot(s)` : "");
|
|
1782
|
+
};
|
|
1783
|
+
const shotLines = ops.filter(o=>o.op === "img" && o.img).map(o=>" 📷 “" + String(o.was || o.target).slice(0, 60) + "” ← " + o.shot).join("\n");
|
|
1784
|
+
let out = "";
|
|
1785
|
+
if(res.auto.length){
|
|
1786
|
+
out += `⚡ ${tally(res.auto)} sent to “${m.name}” — TabTree applies them BY ITSELF within seconds when it is open on one of the user's devices (signed in), otherwise the next time it opens. `
|
|
1787
|
+
+ "Cmd+Z undoes them. They have not been applied yet at this instant: do not read the map back expecting them — tell the user they are on their way.\n";
|
|
1788
|
+
}
|
|
1789
|
+
if(res.review.length){
|
|
1790
|
+
out += `📮 Proposal filed for “${m.name}” — ${tally(res.review)}${res.auto.length ? " (everything from the first change that rewrites or removes something)" : ""}: ${CLOUD ? "cloud:" + res.ids.review : res.ids.review}\n`
|
|
1791
|
+
+ "NOTHING has been changed yet by these. The map is untouched until the user approves them.\n"
|
|
1792
|
+
+ (CLOUD ? "TabTree shows a banner on that map within a minute (the app open and signed in). " : "TabTree shows a banner on that map within a few seconds (the backup folder must be connected, and the app open). ")
|
|
1793
|
+
+ "The user ticks the changes they want, applies them, and Cmd+Z undoes the lot.\n"
|
|
1794
|
+
+ "Tell the user to look at TabTree — do not claim those changes are made.\n";
|
|
1795
|
+
}
|
|
1796
|
+
return out + (shotLines ? shotLines + "\n" : "");
|
|
1399
1797
|
}
|
|
1400
1798
|
};
|
|
1401
1799
|
|
|
@@ -1417,7 +1815,7 @@ function handle(msg){
|
|
|
1417
1815
|
// déjà dérivé (1.0.0 ici, 1.1.0 dans le manifeste) sans que rien ne le
|
|
1418
1816
|
// signale : un client affiche l'une, le registre publie l'autre. Un
|
|
1419
1817
|
// autotest les compare désormais toutes les quatre.
|
|
1420
|
-
serverInfo: { name: "tabtree", version: "1.
|
|
1818
|
+
serverInfo: { name: "tabtree", version: "1.5.0" }
|
|
1421
1819
|
});
|
|
1422
1820
|
} else if(method === "notifications/initialized" || (method||"").startsWith("notifications/")){
|
|
1423
1821
|
// notification : pas de réponse
|