feat(notation): Schlagworte &tag gebaut — taiga.<slug> benennt das Projekt je Teilbaum, vererbt (SPEC §1, D91)

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 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-27 16:20:03 +02:00
co-authored by Claude Fable 5
parent c712e81970
commit 5a18505571
9 changed files with 207 additions and 42 deletions
+1
View File
@@ -19,6 +19,7 @@ reverse.
## 2026-08-27 ## 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 - 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 - 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 - 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
+40 -20
View File
@@ -7,7 +7,7 @@ Syntaxänderungen werden zuerst hier dokumentiert, dann implementiert.
## 1. Zeilenformat ## 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 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 Groß-/Kleinschreibung egal (siehe unten). Frühere Vorkommen bleiben im
Label stehen. Label stehen.
5. Tags: alle `@name`-Vorkommen. 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). 6. Knoten-ID: das **erste** alleinstehend angesetzte `#name`-Token (siehe unten).
7. Abhängigkeiten: alle alleinstehend angesetzten `:#a,#b`-Token (siehe unten). 7. Abhängigkeiten: alle alleinstehend angesetzten `:#a,#b`-Token (siehe unten).
8. Fokusmarke: `!!!` als **alleinstehendes** 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 (Bis D68 galt das **erste** Token; die Umkehrung trifft nur Zeilen mit
mehreren Kandidaten.) 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.<projekt-slug>`
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; **Knoten-ID `#name`** — benennt einen Knoten im **ganzen Dokument** eindeutig;
sie ist die Adresse für Abhängigkeiten und Beschreibungsblöcke (§11). 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)\) (^|\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 Für die Knoten-ID (Schritt 6, nur der erste Treffer; die letzte Gruppe ist der
optionale Trenn-Doppelpunkt, der mit entfällt): 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 Freie Schlagworte liegen **nicht** mehr auf `#` — siehe `&tag` unten; damit
ist die frühere Dreifach-Rolle von `#` aufgelöst (D34). 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 Die **Schreibweise ist umgesetzt** — Definition jetzt in §1 (Zeichenmenge wie
Hierarchie (die ID benennt genau einen). Mehrere pro Zeile, Position egal, `@name`, Alleinstehend-Regel, Zitier-Konvention, `taiga.*`-Vererbung); gebaut
gleiche Zeichenmenge wie `@name` (§7: Unicode-Buchstaben, Ziffern, `.`, zusammen mit der Ticket-Anlage, wie D91-Nachtrag 3 es festlegte. Der erste
`_`, `-`). Konsument ist das **Taiga-Projekt-Schlagwort** `&taiga.<projekt-slug>` (§1).
- Erkannt nur **alleinstehend** angesetzt (`(^|\s)&\w`, wie `!!!` in §1) — Alle **übrigen** Schlagworte bleiben frei und ohne Konsumenten — künftige
„R&D“ und „Drag & Drop“ bleiben damit gewöhnliche Labels. Auswerter (Filter-/Hervorheben-Linse, Label-Sync) sind weiterhin offen und
- **Niedrig priorisiert:** Ohne ein Feature, das Schlagworte auswertet werden hier festgelegt, bevor sie gebaut werden.
(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.<projekt-slug>` 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).
### Nutzen je Knoten (`(M/9)`) — festgelegt, noch nicht gebaut ### Nutzen je Knoten (`(M/9)`) — festgelegt, noch nicht gebaut
+3 -3
View File
@@ -12,9 +12,9 @@
- [^] #not.gates: And/or decomposition (S) - [^] #not.gates: And/or decomposition (S)
- [^] #not.opt: Optional nodes — neither required nor an alternative (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.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: 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.lens: A lens: highlight every node tagged (&x) (S)
- [ ] #not.tag.project: (&taiga.slug) assigns the Taiga project, inherited (S) - [~] #not.tag.project: (&taiga.slug) assigns the Taiga project, inherited (S)
- [^] #not.dep: Dependencies across the tree, (:#auth,#api) (M) - [^] #not.dep: Dependencies across the tree, (:#auth,#api) (M)
- [^] #not.dep.status: They constrain the status, not when work may start (S) - [^] #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) - [^] #not.dep.eff: Effective status: intrinsic, plus what it depends on (S)
+18 -7
View File
@@ -15,7 +15,7 @@ people edit at once.
## Line format ## 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. 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. size-like tokens stay in the label.
4. `@name` — people tags; several per line, any position. Characters: 4. `@name` — people tags; several per line, any position. Characters:
Unicode letters, digits, `.`, `_`, `-`. 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.<project-slug>` 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 (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 whole document; a duplicate ID warns, and references resolve to the first
occurrence. Later `#tokens` stay label text; `C#` stays a label. Same 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 whitespace or end of line — so a colon inside the label survives
(`#auth: Rule: token required`), and `#auth:#db` still reads as ID plus (`#auth: Rule: token required`), and `#auth:#db` still reads as ID plus
dependency. Placement stays free: `Backend #auth` means the same thing. 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 then comma-separated IDs, each with `#`, **no spaces** (`:#a, #b` reads
only `#a`, and the free-standing ` #b` would become the node ID!). only `#a`, and the free-standing ` #b` would become the node ID!).
Several tokens per line merge. `(:#a,#b)` in parentheses stays label text Several tokens per line merge. `(:#a,#b)` in parentheses stays label text
(quoting convention, same as `(#id)`). (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 shared pointer for collaborative editing. Independent of status and
necessity; `Wow!!!` stays a label. 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 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` `#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 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 - Ticket references like `#123` or `#US-123` (Taiga user story, Jira
`#ABC-123`): planned tracker integration will resolve node IDs that match `#ABC-123`): planned tracker integration will resolve node IDs that match
the connected tracker's reference pattern. the connected tracker's reference pattern. Werkbaum writes the `US-`/`T-`
- Free-standing `&tag`: keywords across the hierarchy (reserved, unbuilt). 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.
+9 -9
View File
@@ -2506,7 +2506,7 @@ const I18N = {
snapLocalHead:"Lokale Sicherungen (dieses Fenster)", snapLocalHead:"Lokale Sicherungen (dieses Fenster)",
kindLiveDead:"getrennt — verbindet neu …", kindLiveDead:"getrennt — verbindet neu …",
kindLiveUnsent:"ungesendete Änderungen!", 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_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.",
hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen", hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen",
hint_xor:"Alternative, genau eine", hint_xor:"Alternative, genau eine",
@@ -2641,7 +2641,7 @@ const I18N = {
snapLocalHead:"Local backups (this window)", snapLocalHead:"Local backups (this window)",
kindLiveDead:"disconnected — reconnecting …", kindLiveDead:"disconnected — reconnecting …",
kindLiveUnsent:"unsent changes!", 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_indent:"Indentation (2 spaces or a tab) defines the hierarchy.",
hint_all:"sub-task, all required", hint_any:"alternative, choose one", hint_all:"sub-task, all required", hint_any:"alternative, choose one",
hint_xor:"alternative, exactly one", hint_xor:"alternative, exactly one",
@@ -2776,7 +2776,7 @@ const I18N = {
snapLocalHead:"Copias locales (esta ventana)", snapLocalHead:"Copias locales (esta ventana)",
kindLiveDead:"desconectado — reconectando …", kindLiveDead:"desconectado — reconectando …",
kindLiveUnsent:"¡cambios sin enviar!", 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_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.",
hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una", hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una",
hint_xor:"alternativa, exactamente una", hint_xor:"alternativa, exactamente una",
@@ -2911,7 +2911,7 @@ const I18N = {
snapLocalHead:"Sauvegardes locales (cette fenêtre)", snapLocalHead:"Sauvegardes locales (cette fenêtre)",
kindLiveDead:"déconnecté — reconnexion …", kindLiveDead:"déconnecté — reconnexion …",
kindLiveUnsent:"modifications non envoyées !", 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_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_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une",
hint_xor:"alternative, exactement une", hint_xor:"alternative, exactement une",
@@ -3046,7 +3046,7 @@ const I18N = {
snapLocalHead:"Lokalne kopie (to okno)", snapLocalHead:"Lokalne kopie (to okno)",
kindLiveDead:"rozłączono — ponowne łączenie …", kindLiveDead:"rozłączono — ponowne łączenie …",
kindLiveUnsent:"niewysłane zmiany!", 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_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.",
hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną", hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną",
hint_xor:"alternatywa, dokładnie jedna", hint_xor:"alternatywa, dokładnie jedna",
@@ -3181,7 +3181,7 @@ const I18N = {
snapLocalHead:"Локальные копии (это окно)", snapLocalHead:"Локальные копии (это окно)",
kindLiveDead:"нет связи — переподключение …", kindLiveDead:"нет связи — переподключение …",
kindLiveUnsent:"неотправленные изменения!", 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_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.",
hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну", hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну",
hint_xor:"альтернатива, ровно одна", hint_xor:"альтернатива, ровно одна",
@@ -3316,7 +3316,7 @@ const I18N = {
snapLocalHead:"स्थानीय प्रतियाँ (यह विंडो)", snapLocalHead:"स्थानीय प्रतियाँ (यह विंडो)",
kindLiveDead:"संपर्क टूटा — फिर जोड़ रहा है …", kindLiveDead:"संपर्क टूटा — फिर जोड़ रहा है …",
kindLiveUnsent:"बिना भेजे बदलाव!", 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_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।",
hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें", hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें",
hint_xor:"विकल्प, ठीक एक", hint_xor:"विकल्प, ठीक एक",
@@ -3451,7 +3451,7 @@ const I18N = {
snapLocalHead:"本地备份(此窗口)", snapLocalHead:"本地备份(此窗口)",
kindLiveDead:"已断开——正在重连 …", kindLiveDead:"已断开——正在重连 …",
kindLiveUnsent:"有未发送的更改!", 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_indent:"缩进(2 个空格或制表符)定义层级。",
hint_all:"子任务,全部必需", hint_any:"备选项,择其一", hint_all:"子任务,全部必需", hint_any:"备选项,择其一",
hint_xor:"备选项,恰好一个", hint_xor:"备选项,恰好一个",
@@ -3586,7 +3586,7 @@ const I18N = {
snapLocalHead:"ローカルの控え(このウィンドウ)", snapLocalHead:"ローカルの控え(このウィンドウ)",
kindLiveDead:"切断 — 再接続中 …", kindLiveDead:"切断 — 再接続中 …",
kindLiveUnsent:"未送信の変更あり!", 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_indent:"インデント(スペース2つまたはタブ)で階層を定義します。",
hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ", hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ",
hint_xor:"選択肢、ちょうど1つ", hint_xor:"選択肢、ちょうど1つ",
+25
View File
@@ -565,6 +565,31 @@ export function personFoldSet(roots, tag){
return set; return set;
} }
/* ---------- Taiga-Projekt-Schlagwort (SPEC §1/§11, D91) ----------
`&taiga.<slug>` 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 /* Alle vergebenen Personen-Tags in Dokumentreihenfolge die Einträge der
Personen-Leiste (D87). */ Personen-Leiste (D87). */
export function allTags(roots){ export function allTags(roots){
+13 -3
View File
@@ -163,7 +163,9 @@ const REALIZED = new Set(['arbeit', 'durchstich', 'fertig', 'prod']);
/* Parst den Notationstext zu { roots, warnings }. /* Parst den Notationstext zu { roots, warnings }.
Jeder Knoten: {label, type:'and'|'or'|'xor', optional, fold, status, url, 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 `desc` (SPEC §11/D40) ist der Beschreibungstext: `"`-Zeilen unter dem
Knoten (Kurzform) und ID-Blöcke aus dem `---`-Beschreibungsteil (Langform), Knoten (Kurzform) und ID-Blöcke aus dem `---`-Beschreibungsteil (Langform),
in Dokumentreihenfolge mit Zeilenumbrüchen zusammengefügt; null ohne. 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 `=` Gemischt-Warnung unverändert richtig sie schlägt an, wenn `|` oder `=`
mit `-`/`+` (oder untereinander) gemischt wird. mit `-`/`+` (oder untereinander) gemischt wird.
Extraktionsreihenfolge (SPEC §1): Kommentar -> Zeichen/Status -> URL -> Größe 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); über Einrückungsbreite (Tab = 2 Leerzeichen);
Elternknoten ist die nächste vorangehende Zeile mit kleinerer Breite. */ Elternknoten ist die nächste vorangehende Zeile mit kleinerer Breite. */
export function parse(text){ 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 ''; }); 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#" /* Knoten-ID `#name` (SPEC §1, D36): nur ALLEINSTEHEND ANGESETZT C#"
bleibt Label, und das reservierte `:#a,#b` (§11) wird nicht gefressen. bleibt Label, und das reservierte `:#a,#b` (§11) wird nicht gefressen.
Nur der ERSTE Treffer (kein /g): weitere `#`-Token bleiben im Label 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(); while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop();
const parent = stack[stack.length-1].node; 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); parent.children.push(node);
stack.push({node, width}); stack.push({node, width});
lastNode = node; lastNode = node;
+7
View File
@@ -121,6 +121,10 @@ function nodeAria(n, opts, fold){
if(n.id && !n.labelFromId) parts.push(t('a11yId', {id: n.id})); if(n.id && !n.labelFromId) parts.push(t('a11yId', {id: n.id}));
if(n.deps && n.deps.length) if(n.deps && n.deps.length)
parts.push(t('a11yDeps', {ids: n.deps.map(d => '#' + d).join(', ')})); 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')); if(n.optional) parts.push(t('a11yOptional'));
/* Die Fokusmarke ist rein als box-shadow sichtbar ohne diese Ansage wüsste /* 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 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). */ ein Screenreader läse die Striche einzeln vor (nodeAria oben). */
const facts = [(n.id && !n.labelFromId) ? '#' + n.id : '', const facts = [(n.id && !n.labelFromId) ? '#' + n.id : '',
n.deps && n.deps.length ? '→ ' + n.deps.map(d => '#' + d).join(', ') : '', 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 effKey
? t('heldTooltip', {eff: t('st_' + effKey), own: t('st_' + n.status.key)}) ? t('heldTooltip', {eff: t('st_' + effKey), own: t('st_' + n.status.key)})
: (n.status ? t('st_' + n.status.key) : ''), : (n.status ? t('st_' + n.status.key) : ''),
+91
View File
@@ -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);
});
});