From 5a18505571353e0341197d94da3283f47c661cfc Mon Sep 17 00:00:00 2001 From: mhoennig Date: Thu, 27 Aug 2026 16:20:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(notation):=20Schlagworte=20&tag=20gebaut?= =?UTF-8?q?=20=E2=80=94=20taiga.=20benennt=20das=20Projekt=20je=20Te?= =?UTF-8?q?ilbaum,=20vererbt=20(SPEC=20=C2=A71,=20D91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC zuerst: §1 Schritt 5b samt Block und Referenz-Regex, §11 auf 'umgesetzt' umgestellt; llms.md nachgezogen. Parser extrahiert alleinstehende &-Token als node.marks (Zitier-Konvention gilt), taigaSlugs() in model.js vererbt das taiga.*-Schlagwort wie die @-Tags beim Engpass. Sichtbar im Tooltip und aria-label (a11yMarks, 9 Sprachen). Plan-Labels mit &-Erwähnungen eingeklammert; 12 neue Tests, Gegenprobe: ohne Alleinstehend-Anker fallen genau die zwei Zitier-Tests. Co-Authored-By: Claude Fable 5 --- docs/CHANGELOG.md | 1 + docs/SPEC.md | 60 ++++++++++++++-------- docs/examples/werkbaum.werkbaum | 6 +-- frontend/public/llms.md | 25 ++++++--- frontend/src/app.js | 18 +++---- frontend/src/model.js | 25 +++++++++ frontend/src/parser.js | 16 ++++-- frontend/src/render.js | 7 +++ frontend/tests/taiga.test.js | 91 +++++++++++++++++++++++++++++++++ 9 files changed, 207 insertions(+), 42 deletions(-) create mode 100644 frontend/tests/taiga.test.js diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a6fdb3d..8830467 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,7 @@ reverse. ## 2026-08-27 +- Free `&tag` keywords are part of the notation now: free-standing only, quoted mentions like `(&taiga.slug)` stay labels — and `&taiga.slug` names the Taiga project of a subtree, inherited downwards - Plans spanning several Taiga projects: a `&taiga.slug` tag names the project per subtree, inherited downwards — the first consumer of the reserved free tags, recorded as planned - Creating Taiga tickets from nodes recorded as planned: a story per node from the node window, sub-packages as tasks picked in a dialog, through a backend proxy — the ref lands in the line as `#US-123`/`#T-1234`, beside the node id - The unassigned pill in the people bar shows each language's short form now — German `N.N.`, English `TBD` — with the long wording in the tooltip diff --git a/docs/SPEC.md b/docs/SPEC.md index e5310b6..9dd9622 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -7,7 +7,7 @@ Syntaxänderungen werden zuerst hier dokumentiert, dann implementiert. ## 1. Zeilenformat ``` -[Einrückung][Zeichen] [Statusbox] [Faltmarke] Label (Größe) URL @tag … !!! %% Kommentar +[Einrückung][Zeichen] [Statusbox] [Faltmarke] Label (Größe) URL @tag &tag … !!! %% Kommentar ``` Alle Bestandteile außer dem Label sind optional. Die Extraktion erfolgt in @@ -24,6 +24,7 @@ dieser Reihenfolge (wichtig für Kollisionsfreiheit): Groß-/Kleinschreibung egal (siehe unten). Frühere Vorkommen bleiben im Label stehen. 5. Tags: alle `@name`-Vorkommen. +5b. Schlagworte: alle alleinstehend angesetzten `&name`-Token (siehe unten). 6. Knoten-ID: das **erste** alleinstehend angesetzte `#name`-Token (siehe unten). 7. Abhängigkeiten: alle alleinstehend angesetzten `:#a,#b`-Token (siehe unten). 8. Fokusmarke: `!!!` als **alleinstehendes** Token (siehe unten). @@ -99,6 +100,30 @@ Referenz-Regex (Schritt 1b, geprüft auf der kommentarfreien Zeile): (Bis D68 galt das **erste** Token; die Umkehrung trifft nur Zeilen mit mehreren Kandidaten.) +**Schlagworte `&tag`** — freie Schlagworte quer zur Hierarchie (die ID benennt +genau einen Knoten, ein Schlagwort eine **Menge**); das erste ausgewertete ist +das **Taiga-Projekt-Schlagwort** (§11, D91-Nachtrag 3): + +- Zeichenmenge wie bei `@name` (§7): Unicode-Buchstaben, Ziffern, `.`, `_`, + `-`. Mehrere pro Zeile möglich, Position im Text egal. +- Erkannt nur **alleinstehend angesetzt** (`(^|\s)&…`): „R&D“ und + „Drag & Drop“ bleiben damit gewöhnliche Labels, und die Zitier-Konvention + gilt auch hier — `(&taiga.slug)` bleibt Label. +- Schlagworte gehören **nicht** zum Label. Sichtbar sind sie im + Knoten-Tooltip und im `aria-label`; eine eigene Diagramm-Darstellung haben + sie nicht. +- Semantik trägt allein das Präfix **`taiga.`**: `&taiga.` + benennt das Taiga-Projekt, zu dem der Teilbaum gehört, und **vererbt sich** + auf die Nachkommen wie die `@`-Tags beim Zuständigen-Engpass (§7/§9) — der + nächste Vorfahr mit einem `taiga.*`-Schlagwort gewinnt, ein eigenes am + Knoten übersteuert; trägt eine Zeile mehrere, gilt das **erste**. Konsument + ist die Ticket-Anlage (§11): Der Projekt-Dialog wird damit vorbelegt, und + die erste Anlage in einem Teilbaum ohne Zuordnung schreibt das Schlagwort + zurück. +- Alle übrigen Schlagworte sind **frei** und haben (noch) keinen Konsumenten. + Sie sagen nichts über Fortschritt (§4) oder Notwendigkeit (§3) und ändern + weder Kosten noch Warnungen. + **Knoten-ID `#name`** — benennt einen Knoten im **ganzen Dokument** eindeutig; sie ist die Adresse für Abhängigkeiten und Beschreibungsblöcke (§11). @@ -223,6 +248,12 @@ bleibt stehen): (^|\s)\((XXL|XS|XL|S|M|L)\) ``` +Für die Schlagworte (Schritt 5b, alle Treffer): + +``` +(^|\s)&([\p{L}\p{N}._-]+) +``` + Für die Knoten-ID (Schritt 6, nur der erste Treffer; die letzte Gruppe ist der optionale Trenn-Doppelpunkt, der mit entfällt): @@ -1318,26 +1349,15 @@ einem Tag aufgelöst. Siehe D91-Nachträge 2 und 3. Freie Schlagworte liegen **nicht** mehr auf `#` — siehe `&tag` unten; damit ist die frühere Dreifach-Rolle von `#` aufgelöst (D34). -### Schlagworte (`&tag`) — reserviert, bewusst ungebaut +### Schlagworte (`&tag`) -- `&tag` — freies Schlagwort: benennt eine **Menge** von Knoten quer zur - Hierarchie (die ID benennt genau einen). Mehrere pro Zeile, Position egal, - gleiche Zeichenmenge wie `@name` (§7: Unicode-Buchstaben, Ziffern, `.`, - `_`, `-`). -- Erkannt nur **alleinstehend** angesetzt (`(^|\s)&\w`, wie `!!!` in §1) — - „R&D“ und „Drag & Drop“ bleiben damit gewöhnliche Labels. -- **Niedrig priorisiert:** Ohne ein Feature, das Schlagworte auswertet - (Filter-/Hervorheben-Linse, Taiga-Label-Sync), sind sie nur Kommentare mit - Extra-Syntax — gebaut werden sie erst **zusammen mit** dem ersten solchen - Konsumenten. Bis dahin gilt allein: `&` nicht anderweitig vergeben. -- **Der erste Konsument ist festgelegt** (D91-Nachtrag 3): das - **Taiga-Projekt-Schlagwort**. `&taiga.` benennt das - Taiga-Projekt, zu dem ein Teilbaum gehört, und **vererbt sich** auf die - Nachkommen wie die `@`-Tags beim Zuständigen-Engpass (§7/§9) — der - nächste Vorfahr mit einem `taiga.*`-Tag gewinnt, ein eigener Tag am - Knoten übersteuert. Alle übrigen Schlagworte bleiben frei; die Syntax - wird zusammen mit der Ticket-Anlage gebaut, und die Zitier-Konvention - gilt auch hier (`(&taiga.slug)` bleibt Label). +Die **Schreibweise ist umgesetzt** — Definition jetzt in §1 (Zeichenmenge wie +`@name`, Alleinstehend-Regel, Zitier-Konvention, `taiga.*`-Vererbung); gebaut +zusammen mit der Ticket-Anlage, wie D91-Nachtrag 3 es festlegte. Der erste +Konsument ist das **Taiga-Projekt-Schlagwort** `&taiga.` (§1). +Alle **übrigen** Schlagworte bleiben frei und ohne Konsumenten — künftige +Auswerter (Filter-/Hervorheben-Linse, Label-Sync) sind weiterhin offen und +werden hier festgelegt, bevor sie gebaut werden. ### Nutzen je Knoten (`(M/9)`) — festgelegt, noch nicht gebaut diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index 8d898b2..beb64ff 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -12,9 +12,9 @@ - [^] #not.gates: And/or decomposition (S) - [^] #not.opt: Optional nodes — neither required nor an alternative (S) - [^] #not.id: Node IDs, (#auth:) before the title (S) %% often the ticket number, see SPEC §11 - + [ ] #not.tag: Free tags, &tag (M) %% first consumer found: the Taiga project tag, D91 - - [?] #not.tag.lens: A lens: highlight every node tagged &x (S) - - [ ] #not.tag.project: (&taiga.slug) assigns the Taiga project, inherited (S) + + [~] #not.tag: Free tags, (&tag) (M) %% first consumer found: the Taiga project tag, D91 + - [?] #not.tag.lens: A lens: highlight every node tagged (&x) (S) + - [~] #not.tag.project: (&taiga.slug) assigns the Taiga project, inherited (S) - [^] #not.dep: Dependencies across the tree, (:#auth,#api) (M) - [^] #not.dep.status: They constrain the status, not when work may start (S) - [^] #not.dep.eff: Effective status: intrinsic, plus what it depends on (S) diff --git a/frontend/public/llms.md b/frontend/public/llms.md index bc5a2cf..2d99d23 100644 --- a/frontend/public/llms.md +++ b/frontend/public/llms.md @@ -15,7 +15,7 @@ people edit at once. ## Line format - [indent][gate] [status] [fold] Label (SIZE) URL @person #id :#id,#id !!! %% comment + [indent][gate] [status] [fold] Label (SIZE) URL @person &tag #id :#id,#id !!! %% comment One node per line. Everything except the label is optional. @@ -150,7 +150,15 @@ One node per line. Everything except the label is optional. size-like tokens stay in the label. 4. `@name` — people tags; several per line, any position. Characters: Unicode letters, digits, `.`, `_`, `-`. -5. `#id` — **node ID**: the *first* free-standing `#token` of the line +5. `&tag` — **free keywords**, free-standing only (so `R&D` and + `Drag & Drop` stay labels; `(&taiga.slug)` in parentheses mentions one + literally). Same character set as `@name`; several per line, any + position. Only the `taiga.` prefix carries meaning: + `&taiga.` names the Taiga project a subtree belongs to and + is **inherited downwards** — the nearest ancestor with a `taiga.*` + keyword wins, a node's own keyword overrides, the first of several on + one line counts. All other keywords are free and (so far) unconsumed. +6. `#id` — **node ID**: the *first* free-standing `#token` of the line (preceded by start-of-line or whitespace). Uniquely names the node in the whole document; a duplicate ID warns, and references resolve to the first occurrence. Later `#tokens` stay label text; `C#` stays a label. Same @@ -162,15 +170,15 @@ One node per line. Everything except the label is optional. whitespace or end of line — so a colon inside the label survives (`#auth: Rule: token required`), and `#auth:#db` still reads as ID plus dependency. Placement stays free: `Backend #auth` means the same thing. -6. `:#a,#b` — **dependencies**: one contiguous free-standing token — colon, +7. `:#a,#b` — **dependencies**: one contiguous free-standing token — colon, then comma-separated IDs, each with `#`, **no spaces** (`:#a, #b` reads only `#a`, and the free-standing ` #b` would become the node ID!). Several tokens per line merge. `(:#a,#b)` in parentheses stays label text (quoting convention, same as `(#id)`). -7. `!!!` — **focus mark**, free-standing only: "everybody look here" — a +8. `!!!` — **focus mark**, free-standing only: "everybody look here" — a shared pointer for collaborative editing. Independent of status and necessity; `Wow!!!` stays a label. -8. Whatever remains, whitespace-normalized, is the **label**. An empty label +9. Whatever remains, whitespace-normalized, is the **label**. An empty label means the line is ignored — **unless the line carries a node ID**: then `#id` becomes the label, with or without the trailing colon. `- #US-123` and `- #US-123:` both render a node titled `#US-123`. Use this when the @@ -264,5 +272,8 @@ One node per line. Everything except the label is optional. - Ticket references like `#123` or `#US-123` (Taiga user story, Jira `#ABC-123`): planned tracker integration will resolve node IDs that match - the connected tracker's reference pattern. -- Free-standing `&tag`: keywords across the hierarchy (reserved, unbuilt). + the connected tracker's reference pattern. Werkbaum writes the `US-`/`T-` + prefixes itself when it creates tickets; the ref is added to the line + **beside** an existing node ID (later `#tokens` stay label text). +- Free-standing `&tag` keywords other than `taiga.*` are parsed but have no + consumer yet — do not assign them a meaning. diff --git a/frontend/src/app.js b/frontend/src/app.js index 400d885..41ea8be 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -2506,7 +2506,7 @@ const I18N = { snapLocalHead:"Lokale Sicherungen (dieses Fenster)", kindLiveDead:"getrennt — verbindet neu …", kindLiveUnsent:"ungesendete Änderungen!", - a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: mindestens {size} (angenommen)", a11yTags:"Zuständig: {names}", a11yId:"ID: #{id}", a11yDeps:"hängt ab von: {ids}", a11yFolded:"eingeklappt, {n} verborgen", a11yEffective:"effektiv: {status}", heldTooltip:"effektiv {eff} — selbst schon {own}, wartet auf Abhängigkeiten", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt", + a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: mindestens {size} (angenommen)", a11yTags:"Zuständig: {names}", a11yId:"ID: #{id}", a11yDeps:"hängt ab von: {ids}", a11yMarks:"Schlagworte: {names}", a11yFolded:"eingeklappt, {n} verborgen", a11yEffective:"effektiv: {status}", heldTooltip:"effektiv {eff} — selbst schon {own}, wartet auf Abhängigkeiten", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt", hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.", hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen", hint_xor:"Alternative, genau eine", @@ -2641,7 +2641,7 @@ const I18N = { snapLocalHead:"Local backups (this window)", kindLiveDead:"disconnected — reconnecting …", kindLiveUnsent:"unsent changes!", - a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: at least {size} (assumed)", a11yTags:"Assigned: {names}", a11yId:"ID: #{id}", a11yDeps:"depends on: {ids}", a11yFolded:"collapsed, {n} hidden", a11yEffective:"effective: {status}", heldTooltip:"effectively {eff} — itself already {own}, waiting on dependencies", a11yOptional:"optional", a11yFocusMark:"look here", a11yLink:"has link", + a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: at least {size} (assumed)", a11yTags:"Assigned: {names}", a11yId:"ID: #{id}", a11yDeps:"depends on: {ids}", a11yMarks:"Keywords: {names}", a11yFolded:"collapsed, {n} hidden", a11yEffective:"effective: {status}", heldTooltip:"effectively {eff} — itself already {own}, waiting on dependencies", a11yOptional:"optional", a11yFocusMark:"look here", a11yLink:"has link", hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.", hint_all:"sub-task, all required", hint_any:"alternative, choose one", hint_xor:"alternative, exactly one", @@ -2776,7 +2776,7 @@ const I18N = { snapLocalHead:"Copias locales (esta ventana)", kindLiveDead:"desconectado — reconectando …", kindLiveUnsent:"¡cambios sin enviar!", - a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: al menos {size} (asumido)", a11yTags:"Responsable: {names}", a11yId:"ID: #{id}", a11yDeps:"depende de: {ids}", a11yFolded:"plegado, {n} ocultos", a11yEffective:"efectivo: {status}", heldTooltip:"efectivamente {eff} — por sí mismo ya {own}, espera dependencias", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", a11yLink:"con enlace", + a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: al menos {size} (asumido)", a11yTags:"Responsable: {names}", a11yId:"ID: #{id}", a11yDeps:"depende de: {ids}", a11yMarks:"Palabras clave: {names}", a11yFolded:"plegado, {n} ocultos", a11yEffective:"efectivo: {status}", heldTooltip:"efectivamente {eff} — por sí mismo ya {own}, espera dependencias", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", a11yLink:"con enlace", hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.", hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una", hint_xor:"alternativa, exactamente una", @@ -2911,7 +2911,7 @@ const I18N = { snapLocalHead:"Sauvegardes locales (cette fenêtre)", kindLiveDead:"déconnecté — reconnexion …", kindLiveUnsent:"modifications non envoyées !", - a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : au moins {size} (supposé)", a11yTags:"Responsable : {names}", a11yId:"ID : #{id}", a11yDeps:"dépend de : {ids}", a11yFolded:"replié, {n} masqués", a11yEffective:"effectif : {status}", heldTooltip:"effectivement {eff} — lui-même déjà {own}, en attente de dépendances", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", a11yLink:"avec lien", + a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : au moins {size} (supposé)", a11yTags:"Responsable : {names}", a11yId:"ID : #{id}", a11yDeps:"dépend de : {ids}", a11yMarks:"Mots-clés : {names}", a11yFolded:"replié, {n} masqués", a11yEffective:"effectif : {status}", heldTooltip:"effectivement {eff} — lui-même déjà {own}, en attente de dépendances", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", a11yLink:"avec lien", hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.", hint_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une", hint_xor:"alternative, exactement une", @@ -3046,7 +3046,7 @@ const I18N = { snapLocalHead:"Lokalne kopie (to okno)", kindLiveDead:"rozłączono — ponowne łączenie …", kindLiveUnsent:"niewysłane zmiany!", - a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: co najmniej {size} (założony)", a11yTags:"Przypisano: {names}", a11yId:"ID: #{id}", a11yDeps:"zależy od: {ids}", a11yFolded:"zwinięte, ukrytych: {n}", a11yEffective:"efektywnie: {status}", heldTooltip:"efektywnie {eff} — sam już {own}, czeka na zależności", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem", + a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: co najmniej {size} (założony)", a11yTags:"Przypisano: {names}", a11yId:"ID: #{id}", a11yDeps:"zależy od: {ids}", a11yMarks:"Słowa kluczowe: {names}", a11yFolded:"zwinięte, ukrytych: {n}", a11yEffective:"efektywnie: {status}", heldTooltip:"efektywnie {eff} — sam już {own}, czeka na zależności", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem", hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.", hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną", hint_xor:"alternatywa, dokładnie jedna", @@ -3181,7 +3181,7 @@ const I18N = { snapLocalHead:"Локальные копии (это окно)", kindLiveDead:"нет связи — переподключение …", kindLiveUnsent:"неотправленные изменения!", - a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: не меньше {size} (предполагается)", a11yTags:"Ответственные: {names}", a11yId:"ID: #{id}", a11yDeps:"зависит от: {ids}", a11yFolded:"свёрнуто, скрыто: {n}", a11yEffective:"фактически: {status}", heldTooltip:"фактически {eff} — сам уже {own}, ждёт зависимости", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой", + a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: не меньше {size} (предполагается)", a11yTags:"Ответственные: {names}", a11yId:"ID: #{id}", a11yDeps:"зависит от: {ids}", a11yMarks:"Метки: {names}", a11yFolded:"свёрнуто, скрыто: {n}", a11yEffective:"фактически: {status}", heldTooltip:"фактически {eff} — сам уже {own}, ждёт зависимости", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой", hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.", hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну", hint_xor:"альтернатива, ровно одна", @@ -3316,7 +3316,7 @@ const I18N = { snapLocalHead:"स्थानीय प्रतियाँ (यह विंडो)", kindLiveDead:"संपर्क टूटा — फिर जोड़ रहा है …", kindLiveUnsent:"बिना भेजे बदलाव!", - a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: कम से कम {size} (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yId:"आईडी: #{id}", a11yDeps:"निर्भर: {ids}", a11yFolded:"समेटा हुआ, {n} छिपे", a11yEffective:"प्रभावी: {status}", heldTooltip:"प्रभावी रूप से {eff} — स्वयं {own} है, निर्भरताओं की प्रतीक्षा में", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित", + a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: कम से कम {size} (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yId:"आईडी: #{id}", a11yDeps:"निर्भर: {ids}", a11yMarks:"कीवर्ड: {names}", a11yFolded:"समेटा हुआ, {n} छिपे", a11yEffective:"प्रभावी: {status}", heldTooltip:"प्रभावी रूप से {eff} — स्वयं {own} है, निर्भरताओं की प्रतीक्षा में", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित", hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।", hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें", hint_xor:"विकल्प, ठीक एक", @@ -3451,7 +3451,7 @@ const I18N = { snapLocalHead:"本地备份(此窗口)", kindLiveDead:"已断开——正在重连 …", kindLiveUnsent:"有未发送的更改!", - a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:至少 {size}(假定)", a11yTags:"负责人:{names}", a11yId:"ID:#{id}", a11yDeps:"依赖:{ids}", a11yFolded:"已折叠,隐藏 {n} 项", a11yEffective:"实际:{status}", heldTooltip:"实际为 {eff}——自身已是 {own},等待依赖完成", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接", + a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:至少 {size}(假定)", a11yTags:"负责人:{names}", a11yId:"ID:#{id}", a11yDeps:"依赖:{ids}", a11yMarks:"关键词:{names}", a11yFolded:"已折叠,隐藏 {n} 项", a11yEffective:"实际:{status}", heldTooltip:"实际为 {eff}——自身已是 {own},等待依赖完成", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接", hint_indent:"缩进(2 个空格或制表符)定义层级。", hint_all:"子任务,全部必需", hint_any:"备选项,择其一", hint_xor:"备选项,恰好一个", @@ -3586,7 +3586,7 @@ const I18N = { snapLocalHead:"ローカルの控え(このウィンドウ)", kindLiveDead:"切断 — 再接続中 …", kindLiveUnsent:"未送信の変更あり!", - a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: 少なくとも {size}(想定)", a11yTags:"担当: {names}", a11yId:"ID: #{id}", a11yDeps:"依存先: {ids}", a11yFolded:"折りたたみ中、{n} 件非表示", a11yEffective:"実効: {status}", heldTooltip:"実効では {eff} — 自身は既に {own}、依存待ち", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり", + a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: 少なくとも {size}(想定)", a11yTags:"担当: {names}", a11yId:"ID: #{id}", a11yDeps:"依存先: {ids}", a11yMarks:"キーワード: {names}", a11yFolded:"折りたたみ中、{n} 件非表示", a11yEffective:"実効: {status}", heldTooltip:"実効では {eff} — 自身は既に {own}、依存待ち", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり", hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。", hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ", hint_xor:"選択肢、ちょうど1つ", diff --git a/frontend/src/model.js b/frontend/src/model.js index 466b5b8..f7ff85b 100644 --- a/frontend/src/model.js +++ b/frontend/src/model.js @@ -565,6 +565,31 @@ export function personFoldSet(roots, tag){ return set; } +/* ---------- Taiga-Projekt-Schlagwort (SPEC §1/§11, D91) ---------- + `&taiga.` benennt das Taiga-Projekt eines Teilbaums und vererbt sich + auf die Nachkommen wie die `@`-Tags beim Zuständigen-Engpass (D71): der + nächste Vorfahr mit einem `taiga.*`-Schlagwort gewinnt, ein eigenes am + Knoten übersteuert; trägt eine Zeile mehrere, gilt das erste. Map + Knoten -> Slug, nur für Knoten, die (geerbt) eines haben — Konsument ist + die Vorbelegung des Projekt-Dialogs der Ticket-Anlage. */ +export function taigaSlugs(roots){ + const map = new Map(); + const own = n => { + for(const m of n.marks || []) + if(m.startsWith('taiga.') && m.length > 'taiga.'.length) return m.slice('taiga.'.length); + return null; + }; + const walk = (ns, inherited) => { + for(const n of ns){ + const slug = own(n) || inherited; + if(slug) map.set(n, slug); + walk(n.children, slug); + } + }; + walk(roots, null); + return map; +} + /* Alle vergebenen Personen-Tags in Dokumentreihenfolge — die Einträge der Personen-Leiste (D87). */ export function allTags(roots){ diff --git a/frontend/src/parser.js b/frontend/src/parser.js index 4d977a0..9a813c8 100644 --- a/frontend/src/parser.js +++ b/frontend/src/parser.js @@ -163,7 +163,9 @@ 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}. + size, tags, marks, id, deps, desc, focus, children, line}. + `marks` (SPEC §1, D91) sind die freien Schlagworte (`&tag`, ohne `&`); + ausgewertet wird bisher nur das Präfix `taiga.` (model.js, `taigaSlugs`). `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. @@ -181,7 +183,8 @@ const REALIZED = new Set(['arbeit', 'durchstich', 'fertig', 'prod']); 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 + -> Tags -> Schlagworte -> 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){ @@ -302,6 +305,13 @@ export function parse(text){ } } rest = rest.replace(/@([\p{L}\p{N}._-]+)/gu, (s, g) => { tags.push(g); return ''; }); + /* Schlagworte `&name` (SPEC §1 Schritt 5b, D91): nur ALLEINSTEHEND + ANGESETZT — „R&D" und „Drag & Drop" bleiben Labels, und die + Zitier-Konvention gilt auch hier (`(&taiga.slug)` bleibt Label). + Semantik trägt allein das Präfix `taiga.` (Projekt-Vererbung rechnet + `taigaSlugs()` in model.js); alle übrigen sind frei. */ + const marks = []; + rest = rest.replace(/(^|\s)&([\p{L}\p{N}._-]+)/gu, (s, pre, g) => { marks.push(g); return pre; }); /* 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 @@ -353,7 +363,7 @@ export function parse(text){ while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop(); const parent = stack[stack.length-1].node; - const node = {label, labelFromId, type, optional, fold, status, url, size, tags, id, deps, desc:null, descLines:null, focus, children:[], line:i+1}; + const node = {label, labelFromId, type, optional, fold, status, url, size, tags, marks, id, deps, desc:null, descLines:null, focus, children:[], line:i+1}; parent.children.push(node); stack.push({node, width}); lastNode = node; diff --git a/frontend/src/render.js b/frontend/src/render.js index 3e293a8..2ce7e02 100644 --- a/frontend/src/render.js +++ b/frontend/src/render.js @@ -121,6 +121,10 @@ function nodeAria(n, opts, fold){ if(n.id && !n.labelFromId) parts.push(t('a11yId', {id: n.id})); if(n.deps && n.deps.length) parts.push(t('a11yDeps', {ids: n.deps.map(d => '#' + d).join(', ')})); + /* Schlagworte (SPEC §1, D91): keine eigene Diagramm-Darstellung — sichtbar + nur im Tooltip und hier. */ + if(n.marks && n.marks.length) + parts.push(t('a11yMarks', {names: n.marks.map(m => '&' + m).join(', ')})); if(n.optional) parts.push(t('a11yOptional')); /* Die Fokusmarke ist rein als box-shadow sichtbar — ohne diese Ansage wüsste ein Screenreader nichts davon. Zugleich der einzige Ort, an dem sie sich von @@ -181,6 +185,9 @@ function nodeHtml(n, extra, opts, fold){ ein Screenreader läse die Striche einzeln vor (nodeAria oben). */ const facts = [(n.id && !n.labelFromId) ? '#' + n.id : '', n.deps && n.deps.length ? '→ ' + n.deps.map(d => '#' + d).join(', ') : '', + /* Schlagworte (SPEC §1, D91) — wörtlich, wie sie im Text + stehen; sie spiegeln die Notation und brauchen kein i18n. */ + n.marks && n.marks.length ? n.marks.map(m => '&' + m).join(' ') : '', effKey ? t('heldTooltip', {eff: t('st_' + effKey), own: t('st_' + n.status.key)}) : (n.status ? t('st_' + n.status.key) : ''), diff --git a/frontend/tests/taiga.test.js b/frontend/tests/taiga.test.js new file mode 100644 index 0000000..6cc6989 --- /dev/null +++ b/frontend/tests/taiga.test.js @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { parse } from '../src/parser.js'; +import { taigaSlugs } from '../src/model.js'; + +/* Schlagworte `&tag` (SPEC §1, D91): Extraktion im Parser und die + `taiga.*`-Vererbung in model.js — der erste Konsument der reservierten + freien Schlagworte (Projekt-Vorbelegung der Ticket-Anlage). */ + +describe('parse — Schlagworte &tag (SPEC §1, D91)', () => { + it('extrahiert alleinstehende &-Token aus dem Label', () => { + const { roots, warnings } = parse('- Backend &taiga.intern &infra (M)'); + expect(roots[0].label).toBe('Backend'); + expect(roots[0].marks).toEqual(['taiga.intern', 'infra']); + expect(roots[0].size).toBe('M'); + expect(warnings).toEqual([]); + }); + + it('Zeichenmenge wie @name: Punkt, Unterstrich, Bindestrich, Unicode', () => { + const { roots } = parse('- X &über_a.b-c'); + expect(roots[0].marks).toEqual(['über_a.b-c']); + }); + + it('nur alleinstehend angesetzt: R&D und Drag & Drop bleiben Labels', () => { + const { roots } = parse('- R&D für Drag & Drop'); + expect(roots[0].marks).toEqual([]); + expect(roots[0].label).toBe('R&D für Drag & Drop'); + }); + + it('Zitier-Konvention: (&taiga.slug) bleibt Label', () => { + const { roots } = parse('- (&taiga.slug) benennt das Projekt'); + expect(roots[0].marks).toEqual([]); + expect(roots[0].label).toBe('(&taiga.slug) benennt das Projekt'); + }); + + it('ein & in einer URL bleibt Teil der URL (URL wird zuerst extrahiert)', () => { + const { roots } = parse('- Suche https://example.org/?a=1&b=2'); + expect(roots[0].url).toBe('https://example.org/?a=1&b=2'); + expect(roots[0].marks).toEqual([]); + }); + + it('ein Schlagwort hinter %% wirkt nicht (Kommentar fällt zuerst weg)', () => { + const { roots } = parse('- Backend %% &taiga.intern'); + expect(roots[0].marks).toEqual([]); + }); + + it('ändert weder Status noch Notwendigkeit noch Kosten-Felder', () => { + const { roots } = parse('+ [x] Zugabe &taiga.intern (S)'); + const n = roots[0]; + expect(n.optional).toBe(true); + expect(n.status.key).toBe('fertig'); + expect(n.size).toBe('S'); + }); +}); + +describe('taigaSlugs — Projekt-Vererbung (SPEC §1, D91-Nachtrag 3)', () => { + const tree = text => parse(text).roots; + + it('eigenes taiga.*-Schlagwort setzt den Slug, Nachkommen erben ihn', () => { + const roots = tree('- Wurzel &taiga.intern\n - Kind\n - Enkel'); + const map = taigaSlugs(roots); + expect(map.get(roots[0])).toBe('intern'); + expect(map.get(roots[0].children[0])).toBe('intern'); + expect(map.get(roots[0].children[0].children[0])).toBe('intern'); + }); + + it('ein eigenes Schlagwort am Knoten übersteuert das geerbte', () => { + const r = tree('- Wurzel &taiga.a\n - Kind &taiga.b\n - Enkel\n - Bruder'); + const m = taigaSlugs(r); + expect(m.get(r[0])).toBe('a'); + expect(m.get(r[0].children[0])).toBe('b'); + expect(m.get(r[0].children[0].children[0])).toBe('b'); + expect(m.get(r[0].children[1])).toBe('a'); + }); + + it('ohne taiga.*-Vorfahren gibt es keinen Eintrag', () => { + const r = tree('- Wurzel &infra\n - Kind'); + const m = taigaSlugs(r); + expect(m.has(r[0])).toBe(false); + expect(m.has(r[0].children[0])).toBe(false); + }); + + it('mehrere taiga.*-Schlagworte auf einer Zeile: das erste gilt', () => { + const r = tree('- Wurzel &taiga.a &taiga.b'); + expect(taigaSlugs(r).get(r[0])).toBe('a'); + }); + + it('das nackte &taiga. ohne Slug zählt nicht', () => { + const r = tree('- Wurzel &taiga.'); + expect(taigaSlugs(r).has(r[0])).toBe(false); + }); +});