Files
werkbaum/frontend/src/parser.js
T
mhoennigandClaude Fable 5 be729239ce notation: Faltmarke rückt hinter die Statusbox (D34-Nachtrag 2)
`- > [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>
2026-08-23 10:04:16 +02:00

261 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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};
}