`- > [x] Backend` wird zu `- [x] > Backend`. Die alte Stellung verschob die Statusbox um genau eine Einrückungsstufe — die Box einer gefalteten Zeile stand damit in der Spalte der Boxen ihrer eigenen Kinder. Vor dem Label kostet die Verschiebung nichts, weil Labels ohnehin ausgefranst sind. Nebengewinn: Die Regel wird einfacher. Statt „zwischen Zeichen und Statusbox, bei Wurzelknoten am Zeilenanfang" heißt sie jetzt ausnahmslos „unmittelbar vor dem Label"; für Zeilen ohne Statusbox ändert sich nichts. Die alte Stellung wird weiter gelesen (Pads und ?sourceUrl=-Quellen lassen sich nicht migrieren), aber nie mehr geschrieben — setFoldMark() löst sie in die neue auf. SPEC §1 hält zusätzlich fest, dass `<` gelesen, aber nie erzeugt wird. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
261 lines
14 KiB
JavaScript
261 lines
14 KiB
JavaScript
/* Werkbaum-Parser — headless, ohne DOM/UI nutzbar.
|
||
Setzt docs/SPEC.md §1–§8 um: Zeilenformat, Hierarchie (Einrückung),
|
||
Zerlegungsart (Gate), Status, Größe, Links, Personen-Tags, Kommentare.
|
||
Verhalten ist normativ gegen SPEC — Änderungen zuerst dort dokumentieren. */
|
||
|
||
/* T-Shirt-Größen (SPEC §5), aufsteigend geordnet. */
|
||
export const SIZE_RANK = { XS: 0, S: 1, M: 2, L: 3, XL: 4, XXL: 5 };
|
||
|
||
/* Status-Vokabular (SPEC §4): Checkbox-Code -> {code, key, name}.
|
||
`name` ist der deutsche Anzeigename (Quellsprache); `code` das kanonische
|
||
Box-Zeichen (für die Diskrepanz-Marke des effektiven Status, D39). */
|
||
export const STATUS_BY_CODE = {
|
||
'?': {code:'?', key:'idee', name:'Idee'},
|
||
' ': {code:' ', key:'geplant', name:'geplant'},
|
||
'~': {code:'~', key:'arbeit', name:'in Arbeit'},
|
||
'/': {code:'/', key:'durchstich', name:'Durchstich – funktionsbereit, Feinarbeiten offen'},
|
||
'x': {code:'x', key:'fertig', name:'fertig'},
|
||
'^': {code:'^', key:'prod', name:'in Produktion'},
|
||
'-': {code:'-', key:'verworfen', name:'verworfen'},
|
||
'!': {code:'!', key:'highrisk', name:'High Risk – Aufwand unklar'}
|
||
};
|
||
|
||
/* Setzt (`'>'`) oder entfernt (`null`) die Faltmarke einer Zeile — die Umkehrung
|
||
der Extraktion aus §1, gebraucht fürs Zurückschreiben aus dem Diagramm
|
||
(D38-Nachtrag). Angefasst wird NUR die Marke samt ihrem Leerraum: Einrückung,
|
||
Zerlegungszeichen, Statusbox und Label bleiben zeichengenau stehen, auch bei
|
||
ungewöhnlicher Spaltung wie `- [x] X`. Wurzelzeilen (ohne Zeichen) bekommen
|
||
die Marke am Zeilenanfang, wie SPEC §1 es verlangt. */
|
||
export function setFoldMark(line, mark){
|
||
/* Geschrieben wird IMMER die kanonische Stellung: unmittelbar vor dem Label,
|
||
also hinter der Statusbox (SPEC §1, D34-Nachtrag 2). Eine Marke in der
|
||
alten Stellung (zwischen Zeichen und Box) wird dabei aufgelöst — sie wird
|
||
weiterhin gelesen, aber nicht mehr erzeugt. */
|
||
const m = line.match(
|
||
/^([ \t]*(?:[-|+]|=(?=[ \t]))?[ \t]*)(?:[><](?=[ \t])[ \t]*)?((?:\[[^\]]\][ \t]*)?)(?:[><](?=[ \t])[ \t]*)?/);
|
||
return m[1] + m[2] + (mark ? mark + ' ' : '') + line.slice(m[0].length);
|
||
}
|
||
|
||
/* Status, die als „realisiert" zählen (XOR-Regel, SPEC §3/D35): Kosten sind
|
||
investiert oder mehr. Absicht (`[?]`, `[ ]`, `[!]`), Ablehnung (`[-]`) und
|
||
neutrale Knoten zählen nicht. */
|
||
const REALIZED = new Set(['arbeit', 'durchstich', 'fertig', 'prod']);
|
||
|
||
/* Parst den Notationstext zu { roots, warnings }.
|
||
Jeder Knoten: {label, type:'and'|'or'|'xor', optional, fold, status, url,
|
||
size, tags, id, deps, desc, focus, children, line}.
|
||
`desc` (SPEC §11/D40) ist der Beschreibungstext: `"`-Zeilen unter dem
|
||
Knoten (Kurzform) und ID-Blöcke aus dem `---`-Beschreibungsteil (Langform),
|
||
in Dokumentreihenfolge mit Zeilenumbrüchen zusammengefügt; null ohne.
|
||
`descLines` sind die ZEILENNUMMERN dieser Beschreibung (SPEC §9): Steht der
|
||
Cursor dort, gilt dieser Knoten als ausgewählt.
|
||
`fold` ('>'|'<'|null, SPEC §1/D38) ist nur der ANFANGSZUSTAND der Faltung —
|
||
den wirksamen Zustand rechnet `initialCollapsed()` in model.js.
|
||
`deps` sind ID-Strings, keine Knoten-Referenzen — aufgelöst wird erst beim
|
||
Konsumenten (D37); der Parser prüft nur die Existenz (`unknownDep`).
|
||
`type` ist das Gate der Geschwistergruppe, `optional` (Zeichen `+`, SPEC §3)
|
||
eine Eigenschaft des einzelnen Knotens: er hängt an derselben Und-Zerlegung
|
||
(`type:'and'`), ist darin aber entbehrlich. Dadurch bleibt die
|
||
Gemischt-Warnung unverändert richtig — sie schlägt an, wenn `|` oder `=`
|
||
mit `-`/`+` (oder untereinander) gemischt wird.
|
||
Extraktionsreihenfolge (SPEC §1): Kommentar -> Zeichen/Status -> URL -> Größe
|
||
-> Tags -> Knoten-ID -> Abhängigkeiten -> Fokusmarke -> Label. Hierarchie
|
||
über Einrückungsbreite (Tab = 2 Leerzeichen);
|
||
Elternknoten ist die nächste vorangehende Zeile mit kleinerer Breite. */
|
||
export function parse(text){
|
||
const virtualRoot = {label:'', type:'and', children:[]};
|
||
const stack = [{node:virtualRoot, width:-1}];
|
||
const warnings = [];
|
||
const idLines = new Map(); /* Knoten-ID -> Zeile der ersten Vergabe (D36) */
|
||
const idNodes = new Map(); /* Knoten-ID -> Knoten der ersten Vergabe */
|
||
/* Beschreibungen (SPEC §11, D40): gesammelt je Knoten, am Ende zu `desc`
|
||
zusammengefügt. `lastNode` trägt die Kurzform-Zuordnung („vorangehender
|
||
Knoten"), `descTarget` den offenen Block des Beschreibungsteils; SKIP
|
||
schluckt Blocktext unter einer unbekannten ID, ohne je Zeile zu warnen. */
|
||
const descLines = new Map();
|
||
/* Welche ZEILEN zu welchem Knoten gehören (SPEC §9): Steht der Cursor in
|
||
einer Beschreibung, gilt ihr Knoten als ausgewählt — die Zeile trägt
|
||
keinen eigenen Knoten, gehört aber zu einem. Getrennt von `descLines`
|
||
gehalten, weil dort Absatztrenner zusammenfallen und Blocktext unter
|
||
unbekannter ID gar nicht erst ankommt. */
|
||
const descOwner = new Map();
|
||
const SKIP = {};
|
||
let lastNode = null, inDesc = false, descTarget = null;
|
||
const ownLine = (node, i) => {
|
||
if(!node || node === SKIP) return;
|
||
let arr = descOwner.get(node);
|
||
if(!arr) descOwner.set(node, arr = []);
|
||
arr.push(i + 1);
|
||
};
|
||
const addDesc = (node, text) => {
|
||
let arr = descLines.get(node);
|
||
if(!arr) descLines.set(node, arr = []);
|
||
if(text === '' && (!arr.length || arr[arr.length-1] === '')) return;
|
||
arr.push(text);
|
||
};
|
||
|
||
text.split('\n').forEach((raw, i) => {
|
||
raw = raw.replace(/%%.*$/, ''); /* %%-Kommentare entfernen (Mermaid-Konvention) */
|
||
/* Trenner `---` (SPEC §11, D40): drei oder mehr Bindestriche, umgebender
|
||
Leerraum erlaubt — ab hier gilt der Beschreibungsteil. Es gibt keinen
|
||
Schlusszaun; weitere Trennzeilen darin haben keine Bedeutung. */
|
||
if(/^[ \t]*-{3,}[ \t]*$/.test(raw)){ inDesc = true; return; }
|
||
if(inDesc){
|
||
if(!raw.trim()){
|
||
if(descTarget && descTarget !== SKIP){
|
||
addDesc(descTarget, ''); /* Absatztrenner */
|
||
ownLine(descTarget, i); /* die Leerzeile gehört noch zum Block */
|
||
}
|
||
return;
|
||
}
|
||
if(/^[ \t]/.test(raw)){ /* eingerückt: Blocktext */
|
||
if(descTarget == null){ warnings.push({type:'descStray', line:i+1}); return; }
|
||
if(descTarget !== SKIP){ addDesc(descTarget, raw.trim()); ownLine(descTarget, i); }
|
||
return;
|
||
}
|
||
/* Der trennende Doppelpunkt (siehe Knoten-ID unten) ist auch hier
|
||
zugelassen — ein Block-Kopf hat zwar keinen Titel dahinter, aber wer
|
||
die Schreibweise `#auth:` gewohnt ist, soll nicht darüber stolpern. */
|
||
const idm = raw.match(/^#([\p{L}\p{N}._-]+):?\s*$/u);
|
||
if(!idm){
|
||
/* Uneingerückt, keine ID-Zeile — bei einem versehentlichen Trenner
|
||
mitten im Plan melden sich die verschluckten Knotenzeilen so
|
||
zeilengenau selbst (SPEC §11). */
|
||
warnings.push({type:'descStray', line:i+1});
|
||
descTarget = null;
|
||
return;
|
||
}
|
||
const target = idNodes.get(idm[1]);
|
||
if(!target){ warnings.push({type:'unknownDesc', line:i+1, id:idm[1]}); descTarget = SKIP; }
|
||
else { descTarget = target; ownLine(target, i); } /* der Block-Kopf nennt den Knoten */
|
||
return;
|
||
}
|
||
if(!raw.trim()) return;
|
||
/* Kurzform `"` (SPEC §11, D40): Beschreibung des VORANGEHENDEN Knotens,
|
||
nur mit folgendem Leerraum (Leerraum-Regel) und nur auf Zeilen ohne
|
||
Zerlegungszeichen — `"Zitat"` und `- " Zitat" …` bleiben Labels.
|
||
Die Einrückung der Zeile hat keine Bedeutung. */
|
||
const ts = raw.replace(/^[ \t]*/, '');
|
||
if(ts[0] === '"' && /[ \t]/.test(ts[1] || '')){
|
||
if(lastNode){ addDesc(lastNode, ts.slice(2).trim()); ownLine(lastNode, i); }
|
||
else warnings.push({type:'descStray', line:i+1});
|
||
return;
|
||
}
|
||
/* Statusbox tolerant erfassen: irgendein einzelnes Zeichen in [ ] an der
|
||
Statusposition. Gültige Codes -> Status; unbekannte -> Warnung + neutral
|
||
(fehlertolerant: die Zeile geht nicht verloren). */
|
||
/* `=` (XOR, SPEC §3) nur mit folgendem Leerraum — die Leerraum-Regel hält
|
||
Labels wie `=SUMME(A1:B2)` heraus; `-`/`+`/`|` bleiben wie bisher.
|
||
Die Faltmarke `>`/`<` (SPEC §1, D38) steht unmittelbar vor dem Label,
|
||
also hinter der Statusbox — ebenfalls nur mit folgendem Leerraum,
|
||
`- [x] >Achtung` bleibt ein Label. Die frühere Stellung ZWISCHEN Zeichen
|
||
und Box wird weiter gelesen (D34-Nachtrag 2), aber nicht mehr
|
||
geschrieben; deshalb zwei Marken-Gruppen, die erste gewinnt. */
|
||
const m = raw.match(/^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:([><])(?=[ \t])\s*)?(?:\[([^\]])\]\s*)?(?:([><])(?=[ \t])\s*)?(.*)$/);
|
||
const width = m[1].replace(/\t/g,' ').length;
|
||
const type = m[2] === '|' ? 'or' : m[2] === '=' ? 'xor' : 'and';
|
||
const optional = m[2] === '+';
|
||
const fold = m[3] || m[5] || null;
|
||
const boxChar = m[4]; // undefined, wenn keine Statusbox
|
||
|
||
let rest = m[6], url = null, size = null;
|
||
const tags = [];
|
||
rest = rest.replace(/https?:\/\/\S+/i, s => { url = s; return ''; });
|
||
rest = rest.replace(/\((XXL|XS|XL|S|M|L)\)/i, (s, g) => { size = g.toUpperCase(); return ''; });
|
||
rest = rest.replace(/@([\p{L}\p{N}._-]+)/gu, (s, g) => { tags.push(g); return ''; });
|
||
/* Knoten-ID `#name` (SPEC §1, D36): nur ALLEINSTEHEND ANGESETZT — „C#"
|
||
bleibt Label, und das reservierte `:#a,#b` (§11) wird nicht gefressen.
|
||
Nur der ERSTE Treffer (kein /g): weitere `#`-Token bleiben im Label
|
||
stehen, dort wohnt die reservierte Ticket-Referenz. Zeichenmenge wie bei
|
||
`@name`; kein Lookbehind (Safari erst ab 16.4).
|
||
Übliche Schreibweise ist die ID **vor** dem Titel, abgetrennt durch einen
|
||
Doppelpunkt: `#auth: Backend`. Der Doppelpunkt ist optional, gehört weder
|
||
zur ID noch zum Label und verschwindet hier. Er wird nur geschluckt, wenn
|
||
**Leerraum oder Zeilenende** folgt — sonst bliebe von `#auth:#db` nicht
|
||
die Abhängigkeit `:#db` übrig. Die ID-Erkennung selbst bleibt unberührt
|
||
(die Doppelpunkt-Gruppe ist optional, verlangt also nichts). */
|
||
let id = null;
|
||
rest = rest.replace(/(^|\s)#([\p{L}\p{N}._-]+)(?::(?=\s|$))?/u, (s, pre, g) => { id = g; return pre; });
|
||
/* Abhängigkeiten `:#a,#b` (SPEC §1, D37): EIN zusammenhängendes Token ohne
|
||
Leerraum, nur ALLEINSTEHEND ANGESETZT — eingeklammerte Erwähnungen wie
|
||
`(:#auth,#api)` bleiben damit Label (dieselbe Zitier-Konvention wie bei
|
||
der ID). Mehrere Token je Zeile werden zusammengeführt. */
|
||
const deps = [];
|
||
rest = rest.replace(/(^|\s):#([\p{L}\p{N}._-]+(?:,#[\p{L}\p{N}._-]+)*)/gu,
|
||
(s, pre, list) => { for(const p of list.split(',')) deps.push(p.replace(/^#/, '')); return pre; });
|
||
/* Fokusmarke `!!!` (SPEC §1) — nur ALLEINSTEHEND, damit „Achtung!!!" ein
|
||
gewöhnliches Label bleibt. Kein Lookbehind (Safari kennt es erst ab 16.4):
|
||
der führende Leerraum wird mitgefangen und wieder eingesetzt. */
|
||
let focus = false;
|
||
rest = rest.replace(/(^|\s)!!!(?=\s|$)/g, (s, pre) => { focus = true; return pre; });
|
||
const label = rest.replace(/\s+/g, ' ').trim();
|
||
if(!label) return;
|
||
|
||
let status = null;
|
||
if(boxChar != null){
|
||
status = STATUS_BY_CODE[boxChar.toLowerCase()] || null;
|
||
if(!status) warnings.push({type:'unknownStatus', line:i+1, code:boxChar});
|
||
}
|
||
|
||
/* Doppelte ID (SPEC §1): Warnung an der späteren Zeile, mit Nennung der
|
||
ersten; die spätere ID gilt trotzdem am Knoten (fehlertolerant). Erst
|
||
hier — eine Zeile ohne Label ist schon zurückgekehrt und belegt nichts. */
|
||
if(id != null){
|
||
if(idLines.has(id)) warnings.push({type:'duplicateId', line:i+1, id, firstLine:idLines.get(id)});
|
||
else idLines.set(id, i+1);
|
||
}
|
||
|
||
while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop();
|
||
const parent = stack[stack.length-1].node;
|
||
|
||
const node = {label, type, optional, fold, status, url, size, tags, id, deps, desc:null, descLines:null, focus, children:[], line:i+1};
|
||
parent.children.push(node);
|
||
stack.push({node, width});
|
||
lastNode = node;
|
||
if(id != null && !idNodes.has(id)) idNodes.set(id, node);
|
||
});
|
||
|
||
/* Beschreibungen zusammensetzen: Zeilen in Dokumentreihenfolge, Leerzeilen
|
||
bleiben als Absatztrenner, Ränder getrimmt (SPEC §11, D40). */
|
||
descLines.forEach((lines, node) => {
|
||
while(lines.length && lines[lines.length-1] === '') lines.pop();
|
||
while(lines.length && lines[0] === '') lines.shift();
|
||
if(lines.length) node.desc = lines.join('\n');
|
||
});
|
||
/* Zeilenzuordnung der Beschreibungen (SPEC §9): unabhängig vom Text — auch
|
||
ein Block, dessen Zeilen sich zu nichts zusammenfügen, gehört dem Knoten. */
|
||
descOwner.forEach((lines, node) => { node.descLines = lines; });
|
||
|
||
/* Unbekannte Abhängigkeits-IDs (SPEC §1): erst nach dem Einlesen prüfbar —
|
||
Vorwärts-Referenzen sind normal. Zyklen (auch auf sich selbst) werden
|
||
bewusst NICHT einmal erkannt: Sie sind zulässig und bedeuten „wird
|
||
gemeinsam fertig" (D34/D37) — eine Prüfung hätte keinen Abnehmer. */
|
||
(function checkDeps(nodes){
|
||
for(const n of nodes){
|
||
for(const d of n.deps)
|
||
if(!idLines.has(d)) warnings.push({type:'unknownDep', line:n.line, id:d});
|
||
checkDeps(n.children);
|
||
}
|
||
})(virtualRoot.children);
|
||
|
||
/* XOR-Regel (SPEC §3): In einer `=`-Gruppe darf genau EINE Alternative
|
||
realisiert sein. Jede weitere wird einzeln gemeldet — die Warnung zeigt so
|
||
auf die Zeile, die man ansehen muss, statt pauschal auf die Gruppe (D35).
|
||
Gruppen-Gate nach dem ersten Kind, wie in der Darstellung (§3). */
|
||
(function checkXor(node){
|
||
const kids = node.children;
|
||
if(kids.length && kids[0].type === 'xor'){
|
||
let realized = 0;
|
||
for(const k of kids){
|
||
if(k.status && REALIZED.has(k.status.key)){
|
||
realized++;
|
||
if(realized > 1) warnings.push({type:'xorConflict', line:k.line, label:k.label});
|
||
}
|
||
}
|
||
}
|
||
kids.forEach(checkXor);
|
||
})(virtualRoot);
|
||
|
||
return {roots: virtualRoot.children, warnings};
|
||
}
|