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
- 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
+40 -20
View File
@@ -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.<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;
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.<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).
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.<projekt-slug>` (§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
+3 -3
View File
@@ -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)
+18 -7
View File
@@ -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.<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
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.
+9 -9
View File
@@ -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つ",
+25
View File
@@ -565,6 +565,31 @@ export function personFoldSet(roots, tag){
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
Personen-Leiste (D87). */
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 }.
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;
+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.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) : ''),
+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);
});
});