feat: Knoten-IDs (#auth) parsen — doppelte ID warnt (SPEC §1, D36)

Extraktionsschritt 6 (nach den Tags): das erste alleinstehend angesetzte
`#name`-Token wird die Knoten-ID (Zeichenmenge wie `@name`); weitere
`#`-Token bleiben im Label (reservierte Ticket-Referenzen), `:#a,#b`
und `C#` werden nicht gefressen. Doppelte ID → Warnung duplicateId an
der späteren Zeile mit Nennung der ersten; die spätere gilt trotzdem.
Sichtbar im Tooltip (erste Position) und als a11yId im aria-label —
noch keine eigene Diagramm-Darstellung. Legendenzeile + Warntext in
allen 9 Sprachen; die drei `#`-Erwähnungen im mitgelieferten Plan sind
eingeklammert, damit sie Erwähnungen bleiben. 12 neue Tests
(tests/ids.test.js).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-22 16:15:15 +02:00
co-authored by Claude Fable 5
parent d276c94840
commit 6ecf3e4c69
11 changed files with 229 additions and 25 deletions
+42
View File
@@ -1660,3 +1660,45 @@ kopierten Regeln — hätte jede künftige Layoutänderung doppelt pflegen lasse
Im Modell ist `'xor'` ein eigener Gate-Wert (`gateOf`), damit die
`mixedGate`-Warnung Mischungen mit `|` von selbst meldet; alle
Disjunktiv-Abfragen prüfen `!== 'and'`.
## D36 — Knoten-IDs (`#auth`) umgesetzt: eng gefasst, sichtbar nur im Tooltip
Der erste Baustein der Phase-4-Kette (ohne IDs keine Abhängigkeiten, ohne die
kein effektiver Status). Vier Festlegungen, die §11 offen ließ:
**Zeichenmenge wie `@name`, nicht „whitespace-frei“.** §11 sagte „ein
whitespace-freier Bezeichner“; umgesetzt ist die engere Menge aus §7
(Unicode-Buchstaben, Ziffern, `.`, `_`, `-`). Drei Gründe: Konsistenz mit den
beiden Nachbarn (`@name` heute, `&tag` reserviert mit derselben Menge, D34);
ein `#a/b` oder `#a:b` liefe sonst in dieselben Kollisionen, vor denen §11 bei
`:` und Pfaden gerade ausweicht; und enger → weiter ist später kompatibel
möglich, umgekehrt nicht.
**Nur alleinstehend angesetzt** (`(^|\s)#…`, wie beim reservierten `&tag`):
„C#“ bleibt ein Label, und — entscheidend für den nächsten Schritt — die
Abhängigkeits-Schreibweise `:#a,#b` wird **nicht** als ID gefressen, weil dort
`:` bzw. `,` vor dem `#` steht. Die ID-Extraktion muss beim Bau der
Abhängigkeiten also nicht angefasst werden.
**Das erste Token ist die ID, weitere bleiben im Label.** Die ID benennt genau
einen Knoten — mehr als eine pro Zeile ergibt keinen Sinn. Alles nach dem
ersten Treffer bleibt unangetastet stehen, denn dort wohnt die reservierte
Ticket-Referenz (`… #123 …`, §11): Sie soll sichtbar im Label bleiben, bis das
Taiga-Feature sie auflöst. Deshalb wurden auch die drei `#`-Vorkommen im
mitgelieferten Plan eingeklammert (`(#auth)`, `(#123)`) — als Erwähnungen sind
sie keine IDs, und `#123` wäre sonst doppelt vergeben gewesen (Zeile 25/162).
**Sichtbar im Tooltip und `aria-label`, sonst nirgends.** Die ID gehört nicht
zum Label (sonst änderte das spätere Entfernen die Knoten-Identität der
„Was ist neu?“-Anzeige, D28). Ganz unsichtbar wäre aber nutzerfeindlich —
getippter Text verschwände spurlos. Der Tooltip zeigt `#id` als erste Zeile,
der Screenreader bekommt `a11yId`; ein eigenes Badge bekommt sie erst, wenn
etwas darauf zeigt (Querverbindungen, §11) — die Knoten-Ecken sind belegt
(D18).
**Doppelte ID: Warnung an der späteren Zeile, mit Nennung der ersten.** Die
Meldung zeigt dorthin, wo man eingreifen muss, und `{firstLine}` erspart das
Suchen. Die spätere ID gilt trotzdem am Knoten (fehlertolerant wie §4);
welcher Knoten bei Verweisen „gewinnt“, entscheidet erst die
Abhängigkeits-Auflösung — dort ist die Warnung dann schon da. Eine Zeile, die
**nur** aus einer ID besteht, wird wie jede leere Zeile ignoriert und belegt
die ID nicht.
+31 -7
View File
@@ -19,8 +19,27 @@ dieser Reihenfolge (wichtig für Kollisionsfreiheit):
3. URL: erstes Token, das auf `https?://\S+` passt (dadurch stören `@` in URLs nicht).
4. Größe: erstes `(XS|S|M|L|XL|XXL)`, Groß-/Kleinschreibung egal.
5. Tags: alle `@name`-Vorkommen.
6. Fokusmarke: `!!!` als **alleinstehendes** Token (siehe unten).
7. Rest, whitespace-normalisiert = Label. Leeres Label ⇒ Zeile ignorieren.
6. Knoten-ID: das **erste** alleinstehend angesetzte `#name`-Token (siehe unten).
7. Fokusmarke: `!!!` als **alleinstehendes** Token (siehe unten).
8. Rest, whitespace-normalisiert = Label. Leeres Label ⇒ Zeile ignorieren.
**Knoten-ID `#name`** — benennt einen Knoten im **ganzen Dokument** eindeutig;
sie ist die Adresse für Abhängigkeiten und Beschreibungsblöcke (§11).
- Zeichenmenge wie bei `@name` (§7): Unicode-Buchstaben, Ziffern, `.`, `_`, `-`.
(Enger als das frühere „whitespace-frei“ aus §11 — Begründung: D36.)
- Erkannt nur **alleinstehend angesetzt** (`(^|\s)#…`): „C#“ bleibt damit ein
Label, und der für Abhängigkeiten reservierte Doppelpunkt `:#a,#b` (§11)
kollidiert nicht.
- Das **erste** solche Token der Zeile ist die ID; weitere `#`-Token bleiben im
Label stehen (dort liegt die reservierte Ticket-Referenz `#123`, §11). Eine
rein numerische ID ist zugleich die künftige Ticket-Referenz — oft ist die
Ticket-Nummer die natürliche Knoten-ID (D34).
- Die ID gehört **nicht** zum Label. Eine eigene Darstellung im Diagramm hat
sie (noch) nicht; sichtbar ist sie im Knoten-Tooltip und im `aria-label`.
- **Doppelte ID:** Warnung `duplicateId` mit beiden Zeilennummern; die spätere
ID gilt trotzdem am Knoten (fehlertolerant wie §4 — die Zeile geht nicht
verloren).
**Fokusmarke `!!!`** — „schau hier hin": Der Knoten wird im Diagramm
hervorgehoben und ins Bild geholt (§9). Gedacht für das gemeinsame Arbeiten an
@@ -45,7 +64,13 @@ Referenz-Regex der Implementierung:
^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:\[([ ?~xX^/-])\]\s*)?(.*)$
```
Für die Fokusmarke (Schritt 6):
Für die Knoten-ID (Schritt 6, nur der erste Treffer):
```
(^|\s)#([\p{L}\p{N}._-]+)
```
Für die Fokusmarke (Schritt 7):
```
(^|\s)!!!(?=\s|$)
@@ -532,10 +557,9 @@ das auch. Begründung und Zusammenhang: D34.
- `#123` — Referenz auf externe Tickets (geplant für Taiga-Integration).
Ticket-Referenzen werden **so** notiert, weil es die etablierte
Kurzschreibweise ist; sie haben unter den `#`-Verwendungen Vorrang.
- `#auth`**Knoten-ID**: ein whitespace-freier Bezeichner, der einen Knoten im
ganzen Dokument eindeutig benennt. Ziel für Abhängigkeiten und
Beschreibungsblöcke (siehe unten). Zwei Knoten mit derselben ID sind ein
Fehler und bekommen eine Warnung mit Zeilennummer (§4).
- `#auth`**Knoten-ID**: **umgesetzt**, Definition jetzt in §1 (Zeichenmenge,
Alleinstehend-Regel, Warnung `duplicateId`). Ziel für Abhängigkeiten und
Beschreibungsblöcke (siehe unten).
Beide Rollen vertragen sich: Oft **ist** die Ticket-Nummer die natürliche
Knoten-ID. Als Ticket-Link behandelt wird heuristisch das rein **numerische**
+6 -1
View File
@@ -71,7 +71,12 @@ entscheiden, **bevor** Code entsteht.
numerisch, oft zugleich die natürliche Knoten-ID; notfalls Präfix wie
`#t123`); Schlagworte gehen auf `&tag` — niedrig priorisiert, gebaut
erst mit dem ersten Konsumenten (D34-Nachtrag).
- [ ] Knoten-IDs parsen; doppelte ID → Warnung mit Zeilennummer.
- [x] Knoten-IDs parsen; doppelte ID → Warnung mit Zeilennummer.
→ Umgesetzt (D36): Zeichenmenge wie `@name`, nur alleinstehend angesetzt
und nur das erste Token je Zeile (`:#a,#b` und Ticket-Erwähnungen bleiben
unberührt); Warnung `duplicateId` nennt beide Zeilen; sichtbar im
Tooltip + `aria-label`; SPEC-§11-Teil nach §1 überführt;
`tests/ids.test.js`.
- [ ] Abhängigkeiten `:#a,#b` parsen; unbekannte ID → Warnung, Zyklen erlaubt.
- [ ] Effektiven Status rechnen (intrinsisch + Abhängigkeiten); Darstellung
entscheiden — die Knotenfarbe zeigt heute den intrinsischen Status.
+3 -3
View File
@@ -9,7 +9,7 @@
- [^] People tags, bare URLs, %% comments (XS)
- [^] And/or decomposition (S)
- [^] Optional nodes — neither required nor an alternative (S)
- [?] Node IDs, #auth (S) %% often just the ticket number, see SPEC §11
- [x] Node IDs (#auth) (S) %% often just the ticket number, see SPEC §11
+ [?] Free tags, &tag (M) %% only together with a consumer, see D34
- [?] A lens: highlight every node tagged &x (S)
- [?] Dependencies across the tree, :#auth,#api (M)
@@ -22,7 +22,7 @@
- [?] A short text right at the node (S) %% indentation already means hierarchy
- [?] A long block at the end, addressed by its id (S)
- [-] A separate storage format for the structure (L) %% the text is the format
- [ ] Ticket references #123 (S)
- [ ] Ticket references (#123) (S)
- [?] Benefit per node, not only cost (M)
| [?] Another sigil next to the size (S)
| [?] Story points behind the T-shirt sizes (M)
@@ -159,7 +159,7 @@
| [?] Run the one JS parser inside the IDE (M)
- [?] Tracker integration (XL)
| [?] Taiga (L) https://taiga.io
- [ ] Resolve #123 over the REST API (M)
- [ ] Resolve "#123" over the REST API (M)
- [ ] Read title, link and status (S)
- [ ] Map the workflow onto the states (S)
- [?] Write the status back (M)
+8 -1
View File
@@ -47,7 +47,8 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
Alternative-Rahmen grau (Basis-CSS `ul.or`). Kein Petrol im Diagramm mehr;
`var(--or)` nur noch für UI-Akzente/Logo (SPEC §9, D15).
- Extraktionsreihenfolge im Parser nicht umstellen: Kommentar → Zeichen/
Status → URL → Größe → Tags (sonst kollidiert `@` in URLs).
Status → URL → Größe → Tags → Knoten-ID → Fokusmarke (sonst kollidieren
`@` und `#` in URLs).
- Fehlertoleranz (SPEC §4): der Parser erfasst die Statusbox als *beliebiges*
Einzelzeichen `\[([^\]])\]` und validiert gegen `STATUS_BY_CODE`; unbekannte
Codes → `parse().warnings` als `{type:'unknownStatus', line, code}`, Knoten
@@ -266,6 +267,12 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
passiert: Zähler stimmte, nichts leuchtete). Vorgehalten wird nur
`freshPrevRoots` (Basis, einmal geparst). Basis je Dokument in `werkbaum-seen`,
fortgeschrieben **erst beim Bestätigen** über `#freshBtn`.
- Knoten-IDs `#name` (SPEC §1/D36): nur **alleinstehend angesetzt** und nur
der **erste** Treffer der Zeile (kein `/g`!) — weitere `#`-Token bleiben im
Label (reservierte Ticket-Referenzen), und `:#a,#b` (künftige Abhängigkeiten)
darf nicht gefressen werden. Zeichenmenge wie `@name`. Doppelte ID →
`{type:'duplicateId', line, id, firstLine}`; die spätere gilt trotzdem.
Keine eigene Darstellung — nur Tooltip (erste Position) und `a11yId`.
- XOR-Gruppen `=` (SPEC §3/D35): Der Parser setzt `type:'xor'` (nur mit
folgendem Leerraum — `=SUMME(…)` bleibt Label); der Renderer gibt
`<ul class="or xor">` aus, damit die **gesamte** any-of-Geometrie (alle drei
+1
View File
@@ -120,6 +120,7 @@
Ab <code>(M)</code> gilt: weiter untergliedern — fehlt die Untergliederung, erscheint ein Platzhalter im Diagramm.<br>
Kommentare mit <code>%%</code> — als ganze Zeile oder am Zeilenende.
Personen mit <code>@name</code> — erscheinen unten rechts am Knoten.
Knoten-ID mit <code>#name</code> — erscheint im Tooltip des Knotens.
</div>
</div>
</div>
+28 -9
View File
@@ -1102,12 +1102,13 @@ const I18N = {
empty:"Noch keine Struktur — einfach lostippen.", ghost:"…",
mixedWarn:"Zeile {line}: Unter „{label}“ sind - und | gemischt — dargestellt nach dem ersten Kind.",
xorConflictWarn:"Zeile {line}: „{label}“ ist eine weitere realisierte Alternative — eine =-Gruppe erlaubt genau eine.",
duplicateIdWarn:"Zeile {line}: Die ID #{id} ist schon vergeben (Zeile {firstLine}).",
st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich",
st_fertig:"fertig", st_prod:"in Produktion", st_highrisk:"High Risk", st_verworfen:"verworfen",
unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.",
sourceLoadWarn:"„{url}“ konnte nicht geladen werden ({error}). Die Datei muss per http(s) erreichbar sein und CORS erlauben (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"„{url}“ hat innerhalb von {seconds} s nicht geantwortet — der Abruf wurde abgebrochen. Etherpad begrenzt, wie oft der Export geholt werden darf (serienmäßig 10-mal pro 90 s); warte einen Moment und lade dann erneut.",
a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt",
a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yId:"ID: #{id}", 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",
@@ -1119,6 +1120,7 @@ const I18N = {
hint_break:"Ab (M) gilt: weiter untergliedern — fehlt die Untergliederung, erscheint ein Platzhalter im Diagramm.",
hint_comment:"Kommentare mit %% — als ganze Zeile oder am Zeilenende.",
hint_people:"Personen mit @name — erscheinen unten rechts am Knoten.",
hint_id:"Knoten-ID mit #name — erscheint im Tooltip des Knotens.",
hint_jump:"Alt+Klick auf einen Knoten (mobil: langer Druck) springt zur zugehörigen Textzeile; Alt+Klick im Text holt den Knoten ins Bild."
},
en: {
@@ -1168,12 +1170,13 @@ const I18N = {
empty:"No structure yet — just start typing.", ghost:"…",
mixedWarn:"Line {line}: under “{label}”, - and | are mixed — rendered by the first child.",
xorConflictWarn:"Line {line}: “{label}” is another realized alternative — an = group allows exactly one.",
duplicateIdWarn:"Line {line}: ID #{id} is already taken (line {firstLine}).",
st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton",
st_fertig:"done", st_prod:"in production", st_highrisk:"high risk", st_verworfen:"discarded",
unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.",
sourceLoadWarn:"Could not load “{url}” ({error}). The file must be reachable via http(s) and allow CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"“{url}” did not answer within {seconds} s — the request was aborted. Etherpad limits how often the export may be fetched (10 times per 90 s by default); wait a moment, then reload.",
a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yOptional:"optional", a11yFocusMark:"look here", a11yLink:"has link",
a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yId:"ID: #{id}", 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",
@@ -1185,6 +1188,7 @@ const I18N = {
hint_break:"From (M) on: break it down further — if the breakdown is missing, a placeholder appears in the diagram.",
hint_comment:"Comments with %% — whole line or at the end of a line.",
hint_people:"People with @name — shown at the bottom-right of the node.",
hint_id:"Node ID with #name — shown in the node's tooltip.",
hint_jump:"Alt+click a node (long press on touch) jumps to its line in the text; Alt+click in the text brings the node into view."
},
es: {
@@ -1234,12 +1238,13 @@ const I18N = {
empty:"Aún no hay estructura — simplemente empieza a escribir.", ghost:"…",
mixedWarn:"Línea {line}: bajo «{label}» se mezclan - y | — se representa según el primer hijo.",
xorConflictWarn:"Línea {line}: «{label}» es otra alternativa realizada — un grupo = permite exactamente una.",
duplicateIdWarn:"Línea {line}: la ID #{id} ya está asignada (línea {firstLine}).",
st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional",
st_fertig:"terminado", st_prod:"en producción", st_highrisk:"alto riesgo", st_verworfen:"descartado",
unknownStatusWarn:"Línea {line}: código de estado desconocido «{code}» — mostrado como neutral.",
sourceLoadWarn:"No se pudo cargar «{url}» ({error}). El archivo debe ser accesible por http(s) y permitir CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"«{url}» no respondió en {seconds} s — se canceló la petición. Etherpad limita la frecuencia de descarga del export (10 veces por 90 s de forma predeterminada); espera un momento y vuelve a cargar.",
a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", a11yLink:"con enlace",
a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yId:"ID: #{id}", 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",
@@ -1251,6 +1256,7 @@ const I18N = {
hint_break:"A partir de (M): sigue desglosando — si falta el desglose, aparece un marcador de posición en el diagrama.",
hint_comment:"Comentarios con %% — línea completa o al final de la línea.",
hint_people:"Personas con @nombre — aparecen abajo a la derecha del nodo.",
hint_id:"ID de nodo con #nombre — visible en el tooltip del nodo.",
hint_jump:"Alt+clic en un nodo (pulsación larga en táctil) salta a su línea en el texto; Alt+clic en el texto trae el nodo a la vista."
},
fr: {
@@ -1300,12 +1306,13 @@ const I18N = {
empty:"Pas encore de structure — commencez à taper.", ghost:"…",
mixedWarn:"Ligne {line} : sous « {label} », - et | sont mélangés — rendu selon le premier enfant.",
xorConflictWarn:"Ligne {line} : « {label} » est une alternative réalisée de plus — un groupe = nen autorise quune seule.",
duplicateIdWarn:"Ligne {line} : lID #{id} est déjà attribué (ligne {firstLine}).",
st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel",
st_fertig:"terminé", st_prod:"en production", st_highrisk:"risque élevé", st_verworfen:"abandonné",
unknownStatusWarn:"Ligne {line} : code de statut inconnu « {code} » — affiché comme neutre.",
sourceLoadWarn:"Impossible de charger « {url} » ({error}). Le fichier doit être accessible en http(s) et autoriser CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"« {url} » na pas répondu en {seconds} s — la requête a été interrompue. Etherpad limite la fréquence de récupération de lexport (10 fois par 90 s par défaut) ; attends un instant, puis recharge.",
a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", a11yLink:"avec lien",
a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yId:"ID : #{id}", 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",
@@ -1317,6 +1324,7 @@ const I18N = {
hint_break:"À partir de (M) : décomposer davantage — si la décomposition manque, un espace réservé apparaît dans le diagramme.",
hint_comment:"Commentaires avec %% — ligne entière ou en fin de ligne.",
hint_people:"Personnes avec @nom — affichées en bas à droite du nœud.",
hint_id:"ID de nœud avec #nom — visible dans linfobulle du nœud.",
hint_jump:"Alt+clic sur un nœud (appui long sur tactile) saute à sa ligne dans le texte ; Alt+clic dans le texte amène le nœud à l’écran."
},
pl: {
@@ -1366,12 +1374,13 @@ const I18N = {
empty:"Brak struktury — zacznij pisać.", ghost:"…",
mixedWarn:"Wiersz {line}: pod „{label}” mieszają się - i | — renderowane według pierwszego dziecka.",
xorConflictWarn:"Wiersz {line}: „{label}” to kolejna zrealizowana alternatywa — grupa = dopuszcza dokładnie jedną.",
duplicateIdWarn:"Wiersz {line}: ID #{id} jest już zajęte (wiersz {firstLine}).",
st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet",
st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone",
unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.",
sourceLoadWarn:"Nie udało się wczytać „{url}” ({error}). Plik musi być dostępny przez http(s) i zezwalać na CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"„{url}” nie odpowiedział w ciągu {seconds} s — żądanie przerwano. Etherpad ogranicza częstość pobierania eksportu (domyślnie 10 razy na 90 s); odczekaj chwilę i wczytaj ponownie.",
a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem",
a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yId:"ID: #{id}", 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",
@@ -1383,6 +1392,7 @@ const I18N = {
hint_break:"Od (M): dziel dalej — gdy brakuje podziału, w diagramie pojawia się symbol zastępczy.",
hint_comment:"Komentarze z %% — cały wiersz lub na końcu wiersza.",
hint_people:"Osoby z @nazwa — pokazywane w prawym dolnym rogu węzła.",
hint_id:"ID węzła przez #nazwa — widoczne w podpowiedzi węzła.",
hint_jump:"Alt+kliknięcie węzła (długie naciśnięcie na dotyku) przechodzi do jego wiersza w tekście; Alt+kliknięcie w tekście pokazuje węzeł na diagramie."
},
ru: {
@@ -1432,12 +1442,13 @@ const I18N = {
empty:"Пока нет структуры — просто начните печатать.", ghost:"…",
mixedWarn:"Строка {line}: под «{label}» смешаны - и | — отображается по первому потомку.",
xorConflictWarn:"Строка {line}: «{label}» — ещё одна реализованная альтернатива, а группа = допускает ровно одну.",
duplicateIdWarn:"Строка {line}: ID #{id} уже занят (строка {firstLine}).",
st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип",
st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено",
unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.",
sourceLoadWarn:"Не удалось загрузить «{url}» ({error}). Файл должен быть доступен по http(s) и разрешать CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"«{url}» не ответил за {seconds} с — запрос прерван. Etherpad ограничивает частоту загрузки экспорта (по умолчанию 10 раз за 90 с); подождите немного и обновите снова.",
a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой",
a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yId:"ID: #{id}", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой",
hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.",
hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну",
hint_xor:"альтернатива, ровно одна",
@@ -1449,6 +1460,7 @@ const I18N = {
hint_break:"С (M): дробите дальше — если декомпозиции нет, в диаграмме появляется заполнитель.",
hint_comment:"Комментарии через %% — вся строка или в конце строки.",
hint_people:"Люди через @имя — показываются справа внизу узла.",
hint_id:"ID узла через #имя — виден во всплывающей подсказке узла.",
hint_jump:"Alt+клик по узлу (долгое нажатие на сенсоре) переходит к его строке в тексте; Alt+клик в тексте показывает узел на диаграмме."
},
hi: {
@@ -1498,12 +1510,13 @@ const I18N = {
empty:"अभी कोई संरचना नहीं — बस टाइप करना शुरू करें।", ghost:"…",
mixedWarn:"पंक्ति {line}: „{label}“ के अंतर्गत - और | मिश्रित हैं — पहले चाइल्ड के अनुसार दिखाया गया।",
xorConflictWarn:"पंक्ति {line}: „{label}“ एक और साकार विकल्प है — = समूह में केवल एक की अनुमति है।",
duplicateIdWarn:"पंक्ति {line}: आईडी #{id} पहले से प्रयुक्त है (पंक्ति {firstLine})।",
st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा",
st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत",
unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।",
sourceLoadWarn:"„{url}“ लोड नहीं हो सका ({error})। फ़ाइल http(s) से उपलब्ध होनी चाहिए और CORS की अनुमति देनी चाहिए (Access-Control-Allow-Origin)।",
sourceTimeoutWarn:"„{url}“ ने {seconds} स॰ में उत्तर नहीं दिया — अनुरोध रद्द कर दिया गया। Etherpad सीमित करता है कि एक्सपोर्ट कितनी बार लिया जा सके (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार); कुछ क्षण रुकें, फिर दोबारा लोड करें।",
a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित",
a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yId:"आईडी: #{id}", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित",
hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।",
hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें",
hint_xor:"विकल्प, ठीक एक",
@@ -1515,6 +1528,7 @@ const I18N = {
hint_break:"(M) से आगे: और विभाजित करें — विभाजन न होने पर आरेख में प्लेसहोल्डर दिखता है।",
hint_comment:"%% से टिप्पणियाँ — पूरी पंक्ति या पंक्ति के अंत में।",
hint_people:"@नाम से व्यक्ति — नोड के नीचे-दाएँ दिखते हैं।",
hint_id:"#नाम से नोड आईडी — नोड के टूलटिप में दिखती है।",
hint_jump:"किसी नोड पर Alt+क्लिक (टच पर लंबा दबाव) टेक्स्ट में उसकी पंक्ति पर ले जाता है; टेक्स्ट में Alt+क्लिक उस नोड को आरेख में दिखाता है।"
},
zh: {
@@ -1564,12 +1578,13 @@ const I18N = {
empty:"还没有结构——直接开始输入吧。", ghost:"…",
mixedWarn:"第 {line} 行:在「{label}」下 - 与 | 混用——按第一个子项渲染。",
xorConflictWarn:"第 {line} 行:「{label}」是又一个已实现的备选项——= 组只允许恰好一个。",
duplicateIdWarn:"第 {line} 行:ID #{id} 已被占用(第 {firstLine} 行)。",
st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架",
st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃",
unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。",
sourceLoadWarn:"无法加载“{url}”({error})。该文件必须可通过 http(s) 访问并允许 CORSAccess-Control-Allow-Origin)。",
sourceTimeoutWarn:"“{url}” 在 {seconds} 秒内没有响应 — 请求已中止。Etherpad 会限制导出的获取频率(默认每 90 秒 10 次);请稍候再重新加载。",
a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接",
a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yId:"ID#{id}", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接",
hint_indent:"缩进(2 个空格或制表符)定义层级。",
hint_all:"子任务,全部必需", hint_any:"备选项,择其一",
hint_xor:"备选项,恰好一个",
@@ -1581,6 +1596,7 @@ const I18N = {
hint_break:"从 (M) 起:继续细分——若缺少细分,图表中会出现占位符。",
hint_comment:"用 %% 注释——整行或行尾。",
hint_people:"用 @姓名 表示人员——显示在节点右下角。",
hint_id:"用 #名称 指定节点 ID——显示在节点提示中。",
hint_jump:"Alt+点击节点(触摸屏为长按)可跳转到文本中对应的行;在文本中 Alt+点击则把该节点带入视野。"
},
ja: {
@@ -1630,12 +1646,13 @@ const I18N = {
empty:"まだ構造がありません — 入力を始めてください。", ghost:"…",
mixedWarn:"{line} 行目:「{label}」の下で - と | が混在 — 最初の子に従って表示。",
xorConflictWarn:"{line} 行目:「{label}」も実現済みの選択肢です — = グループで実現できるのは 1 つだけです。",
duplicateIdWarn:"{line} 行目:ID #{id} は既に使われています({firstLine} 行目)。",
st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン",
st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄",
unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。",
sourceLoadWarn:"「{url}」を読み込めませんでした({error})。ファイルは http(s) でアクセス可能で、CORSAccess-Control-Allow-Origin)を許可する必要があります。",
sourceTimeoutWarn:"「{url}」が {seconds} 秒以内に応答しませんでした — 要求を中止しました。Etherpad はエクスポートの取得回数を制限します(既定で 90 秒あたり 10 回)。少し待ってから再読み込みしてください。",
a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり",
a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yId:"ID: #{id}", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり",
hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。",
hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ",
hint_xor:"選択肢、ちょうど1つ",
@@ -1647,6 +1664,7 @@ const I18N = {
hint_break:"(M) 以上:さらに分解 — 分解がないと図にプレースホルダーが表示されます。",
hint_comment:"%% でコメント — 行全体または行末。",
hint_people:"@名前 で担当者 — ノードの右下に表示されます。",
hint_id:"#名前 でノード ID — ノードのツールチップに表示されます。",
hint_jump:"ノードを Alt+クリック(タッチでは長押し)すると、テキストの該当行へ移動します。テキスト内で Alt+クリックすると、そのノードが図の中央に表示されます。"
}
};
@@ -1679,6 +1697,7 @@ function buildHint(){
${esc(t('hint_break'))}<br>
${esc(t('hint_comment'))}
${esc(t('hint_people'))}
${esc(t('hint_id'))}
<code>!!!</code>&nbsp; ${esc(t('hint_focus'))}
<div class="hint-op">${esc(t('hint_jump'))}</div>`;
}
+20 -3
View File
@@ -26,19 +26,21 @@ const REALIZED = new Set(['arbeit', 'durchstich', 'fertig', 'prod']);
/* Parst den Notationstext zu { roots, warnings }.
Jeder Knoten: {label, type:'and'|'or'|'xor', optional, status, url, size,
tags, focus, children, line}.
tags, id, focus, children, line}.
`type` ist das Gate der Geschwistergruppe, `optional` (Zeichen `+`, SPEC §3)
eine Eigenschaft des einzelnen Knotens: er hängt an derselben Und-Zerlegung
(`type:'and'`), ist darin aber entbehrlich. Dadurch bleibt die
Gemischt-Warnung unverändert richtig sie schlägt an, wenn `|` oder `=`
mit `-`/`+` (oder untereinander) gemischt wird.
Extraktionsreihenfolge (SPEC §1): Kommentar -> Zeichen/Status -> URL -> Größe
-> Tags -> Fokusmarke -> Label. Hierarchie über Einrückungsbreite (Tab = 2 Leerzeichen);
-> Tags -> Knoten-ID -> Fokusmarke -> Label. Hierarchie über Einrückungsbreite
(Tab = 2 Leerzeichen);
Elternknoten ist die nächste vorangehende Zeile mit kleinerer Breite. */
export function parse(text){
const virtualRoot = {label:'', type:'and', children:[]};
const stack = [{node:virtualRoot, width:-1}];
const warnings = [];
const idLines = new Map(); /* Knoten-ID -> Zeile der ersten Vergabe (D36) */
text.split('\n').forEach((raw, i) => {
raw = raw.replace(/%%.*$/, ''); /* %%-Kommentare entfernen (Mermaid-Konvention) */
@@ -59,6 +61,13 @@ export function parse(text){
rest = rest.replace(/https?:\/\/\S+/i, s => { url = s; return ''; });
rest = rest.replace(/\((XXL|XS|XL|S|M|L)\)/i, (s, g) => { size = g.toUpperCase(); return ''; });
rest = rest.replace(/@([\p{L}\p{N}._-]+)/gu, (s, g) => { tags.push(g); return ''; });
/* Knoten-ID `#name` (SPEC §1, D36): nur ALLEINSTEHEND ANGESETZT C#"
bleibt Label, und das reservierte `:#a,#b` (§11) wird nicht gefressen.
Nur der ERSTE Treffer (kein /g): weitere `#`-Token bleiben im Label
stehen, dort wohnt die reservierte Ticket-Referenz. Zeichenmenge wie bei
`@name`; kein Lookbehind (Safari erst ab 16.4). */
let id = null;
rest = rest.replace(/(^|\s)#([\p{L}\p{N}._-]+)/u, (s, pre, g) => { id = g; return pre; });
/* Fokusmarke `!!!` (SPEC §1) nur ALLEINSTEHEND, damit Achtung!!!" ein
gewöhnliches Label bleibt. Kein Lookbehind (Safari kennt es erst ab 16.4):
der führende Leerraum wird mitgefangen und wieder eingesetzt. */
@@ -73,10 +82,18 @@ export function parse(text){
if(!status) warnings.push({type:'unknownStatus', line:i+1, code:boxChar});
}
/* Doppelte ID (SPEC §1): Warnung an der späteren Zeile, mit Nennung der
ersten; die spätere ID gilt trotzdem am Knoten (fehlertolerant). Erst
hier eine Zeile ohne Label ist schon zurückgekehrt und belegt nichts. */
if(id != null){
if(idLines.has(id)) warnings.push({type:'duplicateId', line:i+1, id, firstLine:idLines.get(id)});
else idLines.set(id, i+1);
}
while(stack.length > 1 && stack[stack.length-1].width >= width) stack.pop();
const parent = stack[stack.length-1].node;
const node = {label, type, optional, status, url, size, tags, focus, children:[], line:i+1};
const node = {label, type, optional, status, url, size, tags, id, focus, children:[], line:i+1};
parent.children.push(node);
stack.push({node, width});
});
+5 -1
View File
@@ -51,6 +51,9 @@ function nodeAria(n, opts){
if(n.size) parts.push(t('a11ySize', {size: n.size}));
else if(cheapPath) parts.push(t('a11ySizeImplicit'));
if(n.tags && n.tags.length) parts.push(t('a11yTags', {names: n.tags.join(', ')}));
/* Knoten-ID (SPEC §1, D36): keine eigene Darstellung im Diagramm sichtbar
nur im Tooltip und hier. */
if(n.id) parts.push(t('a11yId', {id: n.id}));
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
@@ -69,7 +72,8 @@ function nodeHtml(n, extra, opts){
für die Gegenrichtung (Cursor-Zeile -> Knoten hervorheben). Der Hinweis im
Tooltip macht die sonst unsichtbare Alt-Klick-Geste auffindbar. */
const lineAttr = n.line ? ` data-line="${n.line}"` : '';
const tip = [n.status ? t('st_' + n.status.key) : '',
const tip = [n.id ? '#' + n.id : '',
n.status ? t('st_' + n.status.key) : '',
n.optional ? t('a11yOptional') : '', t('jumpHint')]
.filter(Boolean).join(' · ');
const title = ` title="${attr(tip)}"`;
+5
View File
@@ -12,6 +12,9 @@
- xorConflict { line, label } weitere realisierte Alternative in einer
`=`-Gruppe (SPEC §3/D35); je Zeile eine
Warnung, damit sie dorthin zeigt
- duplicateId { line, id, firstLine } Knoten-ID doppelt vergeben
(SPEC §1/D36); gemeldet an der späteren
Zeile, die erste wird genannt
- sourceLoad { url, error } ?sourceUrl= nicht ladbar (D23); ohne
Zeilennummer, erscheint dadurch zuoberst
- padRateLimit { seconds } zu früh nachgeladen; Werkbaum hat gar nicht
@@ -34,6 +37,8 @@ export function formatWarning(w, t){
return t('unknownStatusWarn', {line: w.line, code: esc(w.code)});
case 'xorConflict':
return t('xorConflictWarn', {line: w.line, label: esc(w.label)});
case 'duplicateId':
return t('duplicateIdWarn', {line: w.line, id: esc(w.id), firstLine: w.firstLine});
case 'sourceLoad':
return t('sourceLoadWarn', {url: esc(w.url), error: esc(w.error)});
case 'padRateLimit':
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import { parse } from '../src/parser.js';
import { renderTreeHtml } from '../src/render.js';
const t = key => key;
const roots = txt => parse(txt).roots;
const render = txt => renderTreeHtml(roots(txt),
{t, showDiscarded: false, cheapPath: false, cheapSet: new Set()});
/* Knoten-IDs `#name` (SPEC §1, D36): Adresse für Abhängigkeiten und
Beschreibungsblöcke; eindeutig im ganzen Dokument. */
describe('Parser — `#name` als Knoten-ID', () => {
it('extrahiert die ID und nimmt sie aus dem Label', () => {
const [wurzel] = roots(`[ ] Auth-Modul #auth (M)\n - [ ] Login`);
expect([wurzel.label, wurzel.id, wurzel.size]).toEqual(['Auth-Modul', 'auth', 'M']);
expect(wurzel.children[0].id).toBe(null);
});
it('erkennt IDs nur alleinstehend angesetzt — „C#" bleibt Label', () => {
const [wurzel] = roots(`[ ] C# Kurs`);
expect([wurzel.label, wurzel.id]).toEqual(['C# Kurs', null]);
});
it('frisst das reservierte `:#a,#b` (Abhängigkeiten, §11) nicht', () => {
const [wurzel] = roots(`[ ] Deploy :#auth,#api`);
expect([wurzel.label, wurzel.id]).toEqual(['Deploy :#auth,#api', null]);
});
it('nimmt nur das ERSTE Token — weitere `#` bleiben im Label (Tickets)', () => {
const [wurzel] = roots(`[ ] Auth #auth siehe #123`);
expect([wurzel.label, wurzel.id]).toEqual(['Auth siehe #123', 'auth']);
});
it('lässt `#` in URLs unberührt (URL wird vorher extrahiert)', () => {
const [wurzel] = roots(`[ ] Doku https://example.org/seite#abschnitt`);
expect([wurzel.id, wurzel.url]).toEqual([null, 'https://example.org/seite#abschnitt']);
});
it('erlaubt numerische und Unicode-IDs', () => {
const nodes = roots(`[ ] Ticket #123\n[ ] Umlaut #größe-1`);
expect(nodes.map(n => n.id)).toEqual(['123', 'größe-1']);
});
it('ignoriert eine Zeile, die nur aus einer ID besteht — die ID bleibt frei', () => {
const {roots: r, warnings} = parse(`- #auth\n- [ ] Echt #auth`);
expect(r.map(n => [n.label, n.id])).toEqual([['Echt', 'auth']]);
expect(warnings).toEqual([]);
});
});
describe('Doppelte IDs — Warnung an der späteren Zeile', () => {
it('meldet die spätere Zeile und nennt die erste', () => {
const {warnings} = parse(`[ ] A #auth\n[ ] B\n[ ] C #auth`);
expect(warnings).toEqual([{type: 'duplicateId', line: 3, id: 'auth', firstLine: 1}]);
});
it('lässt die spätere ID trotzdem am Knoten stehen (fehlertolerant)', () => {
const nodes = roots(`[ ] A #x\n[ ] B #x`);
expect(nodes.map(n => n.id)).toEqual(['x', 'x']);
});
it('meldet jede weitere Doppelvergabe einzeln', () => {
const {warnings} = parse(`[ ] A #x\n[ ] B #x\n[ ] C #x`);
expect(warnings.map(w => w.line)).toEqual([2, 3]);
});
});
describe('Darstellung — Tooltip und aria, kein Badge', () => {
it('zeigt die ID im Tooltip und im aria-label', () => {
const {html} = render(`[ ] Auth #auth`);
expect(html).toContain('title="#auth · st_geplant · jumpHint"');
expect(html).toContain('aria-label="Auth, a11yStatus, a11yId"');
});
it('ändert Knoten ohne ID nicht', () => {
const {html} = render(`[ ] Ohne`);
expect(html).not.toContain('a11yId');
expect(html).toContain('title="st_geplant · jumpHint"');
});
});