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 Im Modell ist `'xor'` ein eigener Gate-Wert (`gateOf`), damit die
`mixedGate`-Warnung Mischungen mit `|` von selbst meldet; alle `mixedGate`-Warnung Mischungen mit `|` von selbst meldet; alle
Disjunktiv-Abfragen prüfen `!== 'and'`. 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). 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. 4. Größe: erstes `(XS|S|M|L|XL|XXL)`, Groß-/Kleinschreibung egal.
5. Tags: alle `@name`-Vorkommen. 5. Tags: alle `@name`-Vorkommen.
6. Fokusmarke: `!!!` als **alleinstehendes** Token (siehe unten). 6. Knoten-ID: das **erste** alleinstehend angesetzte `#name`-Token (siehe unten).
7. Rest, whitespace-normalisiert = Label. Leeres Label ⇒ Zeile ignorieren. 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 **Fokusmarke `!!!`** — „schau hier hin": Der Knoten wird im Diagramm
hervorgehoben und ins Bild geholt (§9). Gedacht für das gemeinsame Arbeiten an 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*)?(.*)$ ^([ \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|$) (^|\s)!!!(?=\s|$)
@@ -532,10 +557,9 @@ das auch. Begründung und Zusammenhang: D34.
- `#123` — Referenz auf externe Tickets (geplant für Taiga-Integration). - `#123` — Referenz auf externe Tickets (geplant für Taiga-Integration).
Ticket-Referenzen werden **so** notiert, weil es die etablierte Ticket-Referenzen werden **so** notiert, weil es die etablierte
Kurzschreibweise ist; sie haben unter den `#`-Verwendungen Vorrang. Kurzschreibweise ist; sie haben unter den `#`-Verwendungen Vorrang.
- `#auth`**Knoten-ID**: ein whitespace-freier Bezeichner, der einen Knoten im - `#auth`**Knoten-ID**: **umgesetzt**, Definition jetzt in §1 (Zeichenmenge,
ganzen Dokument eindeutig benennt. Ziel für Abhängigkeiten und Alleinstehend-Regel, Warnung `duplicateId`). Ziel für Abhängigkeiten und
Beschreibungsblöcke (siehe unten). Zwei Knoten mit derselben ID sind ein Beschreibungsblöcke (siehe unten).
Fehler und bekommen eine Warnung mit Zeilennummer (§4).
Beide Rollen vertragen sich: Oft **ist** die Ticket-Nummer die natürliche Beide Rollen vertragen sich: Oft **ist** die Ticket-Nummer die natürliche
Knoten-ID. Als Ticket-Link behandelt wird heuristisch das rein **numerische** 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 numerisch, oft zugleich die natürliche Knoten-ID; notfalls Präfix wie
`#t123`); Schlagworte gehen auf `&tag` — niedrig priorisiert, gebaut `#t123`); Schlagworte gehen auf `&tag` — niedrig priorisiert, gebaut
erst mit dem ersten Konsumenten (D34-Nachtrag). 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. - [ ] Abhängigkeiten `:#a,#b` parsen; unbekannte ID → Warnung, Zyklen erlaubt.
- [ ] Effektiven Status rechnen (intrinsisch + Abhängigkeiten); Darstellung - [ ] Effektiven Status rechnen (intrinsisch + Abhängigkeiten); Darstellung
entscheiden — die Knotenfarbe zeigt heute den intrinsischen Status. entscheiden — die Knotenfarbe zeigt heute den intrinsischen Status.
+3 -3
View File
@@ -9,7 +9,7 @@
- [^] People tags, bare URLs, %% comments (XS) - [^] People tags, bare URLs, %% comments (XS)
- [^] And/or decomposition (S) - [^] And/or decomposition (S)
- [^] Optional nodes — neither required nor an alternative (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 + [?] Free tags, &tag (M) %% only together with a consumer, see D34
- [?] A lens: highlight every node tagged &x (S) - [?] A lens: highlight every node tagged &x (S)
- [?] Dependencies across the tree, :#auth,#api (M) - [?] Dependencies across the tree, :#auth,#api (M)
@@ -22,7 +22,7 @@
- [?] A short text right at the node (S) %% indentation already means hierarchy - [?] A short text right at the node (S) %% indentation already means hierarchy
- [?] A long block at the end, addressed by its id (S) - [?] A long block at the end, addressed by its id (S)
- [-] A separate storage format for the structure (L) %% the text is the format - [-] 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) - [?] Benefit per node, not only cost (M)
| [?] Another sigil next to the size (S) | [?] Another sigil next to the size (S)
| [?] Story points behind the T-shirt sizes (M) | [?] Story points behind the T-shirt sizes (M)
@@ -159,7 +159,7 @@
| [?] Run the one JS parser inside the IDE (M) | [?] Run the one JS parser inside the IDE (M)
- [?] Tracker integration (XL) - [?] Tracker integration (XL)
| [?] Taiga (L) https://taiga.io | [?] 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) - [ ] Read title, link and status (S)
- [ ] Map the workflow onto the states (S) - [ ] Map the workflow onto the states (S)
- [?] Write the status back (M) - [?] 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; Alternative-Rahmen grau (Basis-CSS `ul.or`). Kein Petrol im Diagramm mehr;
`var(--or)` nur noch für UI-Akzente/Logo (SPEC §9, D15). `var(--or)` nur noch für UI-Akzente/Logo (SPEC §9, D15).
- Extraktionsreihenfolge im Parser nicht umstellen: Kommentar → Zeichen/ - 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* - Fehlertoleranz (SPEC §4): der Parser erfasst die Statusbox als *beliebiges*
Einzelzeichen `\[([^\]])\]` und validiert gegen `STATUS_BY_CODE`; unbekannte Einzelzeichen `\[([^\]])\]` und validiert gegen `STATUS_BY_CODE`; unbekannte
Codes → `parse().warnings` als `{type:'unknownStatus', line, code}`, Knoten 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 passiert: Zähler stimmte, nichts leuchtete). Vorgehalten wird nur
`freshPrevRoots` (Basis, einmal geparst). Basis je Dokument in `werkbaum-seen`, `freshPrevRoots` (Basis, einmal geparst). Basis je Dokument in `werkbaum-seen`,
fortgeschrieben **erst beim Bestätigen** über `#freshBtn`. 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 - XOR-Gruppen `=` (SPEC §3/D35): Der Parser setzt `type:'xor'` (nur mit
folgendem Leerraum — `=SUMME(…)` bleibt Label); der Renderer gibt folgendem Leerraum — `=SUMME(…)` bleibt Label); der Renderer gibt
`<ul class="or xor">` aus, damit die **gesamte** any-of-Geometrie (alle drei `<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> 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. Kommentare mit <code>%%</code> — als ganze Zeile oder am Zeilenende.
Personen mit <code>@name</code> — erscheinen unten rechts am Knoten. Personen mit <code>@name</code> — erscheinen unten rechts am Knoten.
Knoten-ID mit <code>#name</code> — erscheint im Tooltip des Knotens.
</div> </div>
</div> </div>
</div> </div>
+28 -9
View File
@@ -1102,12 +1102,13 @@ const I18N = {
empty:"Noch keine Struktur — einfach lostippen.", ghost:"…", empty:"Noch keine Struktur — einfach lostippen.", ghost:"…",
mixedWarn:"Zeile {line}: Unter „{label}“ sind - und | gemischt — dargestellt nach dem ersten Kind.", 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.", 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_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", st_fertig:"fertig", st_prod:"in Produktion", st_highrisk:"High Risk", st_verworfen:"verworfen",
unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.", 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).", 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.", 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_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",
@@ -1119,6 +1120,7 @@ const I18N = {
hint_break:"Ab (M) gilt: weiter untergliedern — fehlt die Untergliederung, erscheint ein Platzhalter im Diagramm.", 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_comment:"Kommentare mit %% — als ganze Zeile oder am Zeilenende.",
hint_people:"Personen mit @name — erscheinen unten rechts am Knoten.", 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." 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: { en: {
@@ -1168,12 +1170,13 @@ const I18N = {
empty:"No structure yet — just start typing.", ghost:"…", empty:"No structure yet — just start typing.", ghost:"…",
mixedWarn:"Line {line}: under “{label}”, - and | are mixed — rendered by the first child.", 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.", 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_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", st_fertig:"done", st_prod:"in production", st_highrisk:"high risk", st_verworfen:"discarded",
unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.", 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).", 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.", 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_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",
@@ -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_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_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_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." 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: { es: {
@@ -1234,12 +1238,13 @@ const I18N = {
empty:"Aún no hay estructura — simplemente empieza a escribir.", ghost:"…", 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.", 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.", 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_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", 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.", 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).", 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.", 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_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",
@@ -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_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_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_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." 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: { fr: {
@@ -1300,12 +1306,13 @@ const I18N = {
empty:"Pas encore de structure — commencez à taper.", ghost:"…", empty:"Pas encore de structure — commencez à taper.", ghost:"…",
mixedWarn:"Ligne {line} : sous « {label} », - et | sont mélangés — rendu selon le premier enfant.", 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.", 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_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é", 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.", 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).", 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.", 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_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",
@@ -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_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_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_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." 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: { pl: {
@@ -1366,12 +1374,13 @@ const I18N = {
empty:"Brak struktury — zacznij pisać.", ghost:"…", empty:"Brak struktury — zacznij pisać.", ghost:"…",
mixedWarn:"Wiersz {line}: pod „{label}” mieszają się - i | — renderowane według pierwszego dziecka.", 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ą.", 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_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", st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone",
unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.", 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).", 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.", 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_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",
@@ -1383,6 +1392,7 @@ const I18N = {
hint_break:"Od (M): dziel dalej — gdy brakuje podziału, w diagramie pojawia się symbol zastępczy.", 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_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_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." 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: { ru: {
@@ -1432,12 +1442,13 @@ const I18N = {
empty:"Пока нет структуры — просто начните печатать.", ghost:"…", empty:"Пока нет структуры — просто начните печатать.", ghost:"…",
mixedWarn:"Строка {line}: под «{label}» смешаны - и | — отображается по первому потомку.", mixedWarn:"Строка {line}: под «{label}» смешаны - и | — отображается по первому потомку.",
xorConflictWarn:"Строка {line}: «{label}» — ещё одна реализованная альтернатива, а группа = допускает ровно одну.", xorConflictWarn:"Строка {line}: «{label}» — ещё одна реализованная альтернатива, а группа = допускает ровно одну.",
duplicateIdWarn:"Строка {line}: ID #{id} уже занят (строка {firstLine}).",
st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип", st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип",
st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено", st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено",
unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.", unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.",
sourceLoadWarn:"Не удалось загрузить «{url}» ({error}). Файл должен быть доступен по http(s) и разрешать CORS (Access-Control-Allow-Origin).", sourceLoadWarn:"Не удалось загрузить «{url}» ({error}). Файл должен быть доступен по http(s) и разрешать CORS (Access-Control-Allow-Origin).",
sourceTimeoutWarn:"«{url}» не ответил за {seconds} с — запрос прерван. Etherpad ограничивает частоту загрузки экспорта (по умолчанию 10 раз за 90 с); подождите немного и обновите снова.", 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_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.",
hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну", hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну",
hint_xor:"альтернатива, ровно одна", hint_xor:"альтернатива, ровно одна",
@@ -1449,6 +1460,7 @@ const I18N = {
hint_break:"С (M): дробите дальше — если декомпозиции нет, в диаграмме появляется заполнитель.", hint_break:"С (M): дробите дальше — если декомпозиции нет, в диаграмме появляется заполнитель.",
hint_comment:"Комментарии через %% — вся строка или в конце строки.", hint_comment:"Комментарии через %% — вся строка или в конце строки.",
hint_people:"Люди через @имя — показываются справа внизу узла.", hint_people:"Люди через @имя — показываются справа внизу узла.",
hint_id:"ID узла через #имя — виден во всплывающей подсказке узла.",
hint_jump:"Alt+клик по узлу (долгое нажатие на сенсоре) переходит к его строке в тексте; Alt+клик в тексте показывает узел на диаграмме." hint_jump:"Alt+клик по узлу (долгое нажатие на сенсоре) переходит к его строке в тексте; Alt+клик в тексте показывает узел на диаграмме."
}, },
hi: { hi: {
@@ -1498,12 +1510,13 @@ const I18N = {
empty:"अभी कोई संरचना नहीं — बस टाइप करना शुरू करें।", ghost:"…", empty:"अभी कोई संरचना नहीं — बस टाइप करना शुरू करें।", ghost:"…",
mixedWarn:"पंक्ति {line}: „{label}“ के अंतर्गत - और | मिश्रित हैं — पहले चाइल्ड के अनुसार दिखाया गया।", mixedWarn:"पंक्ति {line}: „{label}“ के अंतर्गत - और | मिश्रित हैं — पहले चाइल्ड के अनुसार दिखाया गया।",
xorConflictWarn:"पंक्ति {line}: „{label}“ एक और साकार विकल्प है — = समूह में केवल एक की अनुमति है।", xorConflictWarn:"पंक्ति {line}: „{label}“ एक और साकार विकल्प है — = समूह में केवल एक की अनुमति है।",
duplicateIdWarn:"पंक्ति {line}: आईडी #{id} पहले से प्रयुक्त है (पंक्ति {firstLine})।",
st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा", st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा",
st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत", st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत",
unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।", unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।",
sourceLoadWarn:"„{url}“ लोड नहीं हो सका ({error})। फ़ाइल http(s) से उपलब्ध होनी चाहिए और CORS की अनुमति देनी चाहिए (Access-Control-Allow-Origin)।", sourceLoadWarn:"„{url}“ लोड नहीं हो सका ({error})। फ़ाइल http(s) से उपलब्ध होनी चाहिए और CORS की अनुमति देनी चाहिए (Access-Control-Allow-Origin)।",
sourceTimeoutWarn:"„{url}“ ने {seconds} स॰ में उत्तर नहीं दिया — अनुरोध रद्द कर दिया गया। Etherpad सीमित करता है कि एक्सपोर्ट कितनी बार लिया जा सके (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार); कुछ क्षण रुकें, फिर दोबारा लोड करें।", 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_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।",
hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें", hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें",
hint_xor:"विकल्प, ठीक एक", hint_xor:"विकल्प, ठीक एक",
@@ -1515,6 +1528,7 @@ const I18N = {
hint_break:"(M) से आगे: और विभाजित करें — विभाजन न होने पर आरेख में प्लेसहोल्डर दिखता है।", hint_break:"(M) से आगे: और विभाजित करें — विभाजन न होने पर आरेख में प्लेसहोल्डर दिखता है।",
hint_comment:"%% से टिप्पणियाँ — पूरी पंक्ति या पंक्ति के अंत में।", hint_comment:"%% से टिप्पणियाँ — पूरी पंक्ति या पंक्ति के अंत में।",
hint_people:"@नाम से व्यक्ति — नोड के नीचे-दाएँ दिखते हैं।", hint_people:"@नाम से व्यक्ति — नोड के नीचे-दाएँ दिखते हैं।",
hint_id:"#नाम से नोड आईडी — नोड के टूलटिप में दिखती है।",
hint_jump:"किसी नोड पर Alt+क्लिक (टच पर लंबा दबाव) टेक्स्ट में उसकी पंक्ति पर ले जाता है; टेक्स्ट में Alt+क्लिक उस नोड को आरेख में दिखाता है।" hint_jump:"किसी नोड पर Alt+क्लिक (टच पर लंबा दबाव) टेक्स्ट में उसकी पंक्ति पर ले जाता है; टेक्स्ट में Alt+क्लिक उस नोड को आरेख में दिखाता है।"
}, },
zh: { zh: {
@@ -1564,12 +1578,13 @@ const I18N = {
empty:"还没有结构——直接开始输入吧。", ghost:"…", empty:"还没有结构——直接开始输入吧。", ghost:"…",
mixedWarn:"第 {line} 行:在「{label}」下 - 与 | 混用——按第一个子项渲染。", mixedWarn:"第 {line} 行:在「{label}」下 - 与 | 混用——按第一个子项渲染。",
xorConflictWarn:"第 {line} 行:「{label}」是又一个已实现的备选项——= 组只允许恰好一个。", xorConflictWarn:"第 {line} 行:「{label}」是又一个已实现的备选项——= 组只允许恰好一个。",
duplicateIdWarn:"第 {line} 行:ID #{id} 已被占用(第 {firstLine} 行)。",
st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架", st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架",
st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃", st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃",
unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。", unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。",
sourceLoadWarn:"无法加载“{url}”({error})。该文件必须可通过 http(s) 访问并允许 CORSAccess-Control-Allow-Origin)。", sourceLoadWarn:"无法加载“{url}”({error})。该文件必须可通过 http(s) 访问并允许 CORSAccess-Control-Allow-Origin)。",
sourceTimeoutWarn:"“{url}” 在 {seconds} 秒内没有响应 — 请求已中止。Etherpad 会限制导出的获取频率(默认每 90 秒 10 次);请稍候再重新加载。", 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_indent:"缩进(2 个空格或制表符)定义层级。",
hint_all:"子任务,全部必需", hint_any:"备选项,择其一", hint_all:"子任务,全部必需", hint_any:"备选项,择其一",
hint_xor:"备选项,恰好一个", hint_xor:"备选项,恰好一个",
@@ -1581,6 +1596,7 @@ const I18N = {
hint_break:"从 (M) 起:继续细分——若缺少细分,图表中会出现占位符。", hint_break:"从 (M) 起:继续细分——若缺少细分,图表中会出现占位符。",
hint_comment:"用 %% 注释——整行或行尾。", hint_comment:"用 %% 注释——整行或行尾。",
hint_people:"用 @姓名 表示人员——显示在节点右下角。", hint_people:"用 @姓名 表示人员——显示在节点右下角。",
hint_id:"用 #名称 指定节点 ID——显示在节点提示中。",
hint_jump:"Alt+点击节点(触摸屏为长按)可跳转到文本中对应的行;在文本中 Alt+点击则把该节点带入视野。" hint_jump:"Alt+点击节点(触摸屏为长按)可跳转到文本中对应的行;在文本中 Alt+点击则把该节点带入视野。"
}, },
ja: { ja: {
@@ -1630,12 +1646,13 @@ const I18N = {
empty:"まだ構造がありません — 入力を始めてください。", ghost:"…", empty:"まだ構造がありません — 入力を始めてください。", ghost:"…",
mixedWarn:"{line} 行目:「{label}」の下で - と | が混在 — 最初の子に従って表示。", mixedWarn:"{line} 行目:「{label}」の下で - と | が混在 — 最初の子に従って表示。",
xorConflictWarn:"{line} 行目:「{label}」も実現済みの選択肢です — = グループで実現できるのは 1 つだけです。", xorConflictWarn:"{line} 行目:「{label}」も実現済みの選択肢です — = グループで実現できるのは 1 つだけです。",
duplicateIdWarn:"{line} 行目:ID #{id} は既に使われています({firstLine} 行目)。",
st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン", st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン",
st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄", st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄",
unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。", unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。",
sourceLoadWarn:"「{url}」を読み込めませんでした({error})。ファイルは http(s) でアクセス可能で、CORSAccess-Control-Allow-Origin)を許可する必要があります。", sourceLoadWarn:"「{url}」を読み込めませんでした({error})。ファイルは http(s) でアクセス可能で、CORSAccess-Control-Allow-Origin)を許可する必要があります。",
sourceTimeoutWarn:"「{url}」が {seconds} 秒以内に応答しませんでした — 要求を中止しました。Etherpad はエクスポートの取得回数を制限します(既定で 90 秒あたり 10 回)。少し待ってから再読み込みしてください。", 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_indent:"インデント(スペース2つまたはタブ)で階層を定義します。",
hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ", hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ",
hint_xor:"選択肢、ちょうど1つ", hint_xor:"選択肢、ちょうど1つ",
@@ -1647,6 +1664,7 @@ const I18N = {
hint_break:"(M) 以上:さらに分解 — 分解がないと図にプレースホルダーが表示されます。", hint_break:"(M) 以上:さらに分解 — 分解がないと図にプレースホルダーが表示されます。",
hint_comment:"%% でコメント — 行全体または行末。", hint_comment:"%% でコメント — 行全体または行末。",
hint_people:"@名前 で担当者 — ノードの右下に表示されます。", hint_people:"@名前 で担当者 — ノードの右下に表示されます。",
hint_id:"#名前 でノード ID — ノードのツールチップに表示されます。",
hint_jump:"ノードを Alt+クリック(タッチでは長押し)すると、テキストの該当行へ移動します。テキスト内で Alt+クリックすると、そのノードが図の中央に表示されます。" hint_jump:"ノードを Alt+クリック(タッチでは長押し)すると、テキストの該当行へ移動します。テキスト内で Alt+クリックすると、そのノードが図の中央に表示されます。"
} }
}; };
@@ -1679,6 +1697,7 @@ function buildHint(){
${esc(t('hint_break'))}<br> ${esc(t('hint_break'))}<br>
${esc(t('hint_comment'))} ${esc(t('hint_comment'))}
${esc(t('hint_people'))} ${esc(t('hint_people'))}
${esc(t('hint_id'))}
<code>!!!</code>&nbsp; ${esc(t('hint_focus'))} <code>!!!</code>&nbsp; ${esc(t('hint_focus'))}
<div class="hint-op">${esc(t('hint_jump'))}</div>`; <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 }. /* Parst den Notationstext zu { roots, warnings }.
Jeder Knoten: {label, type:'and'|'or'|'xor', optional, status, url, size, 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) `type` ist das Gate der Geschwistergruppe, `optional` (Zeichen `+`, SPEC §3)
eine Eigenschaft des einzelnen Knotens: er hängt an derselben Und-Zerlegung eine Eigenschaft des einzelnen Knotens: er hängt an derselben Und-Zerlegung
(`type:'and'`), ist darin aber entbehrlich. Dadurch bleibt die (`type:'and'`), ist darin aber entbehrlich. Dadurch bleibt die
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 -> 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. */ Elternknoten ist die nächste vorangehende Zeile mit kleinerer Breite. */
export function parse(text){ export function parse(text){
const virtualRoot = {label:'', type:'and', children:[]}; const virtualRoot = {label:'', type:'and', children:[]};
const stack = [{node:virtualRoot, width:-1}]; const stack = [{node:virtualRoot, width:-1}];
const warnings = []; const warnings = [];
const idLines = new Map(); /* Knoten-ID -> Zeile der ersten Vergabe (D36) */
text.split('\n').forEach((raw, i) => { text.split('\n').forEach((raw, i) => {
raw = raw.replace(/%%.*$/, ''); /* %%-Kommentare entfernen (Mermaid-Konvention) */ 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(/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(/\((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 ''; }); 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 /* Fokusmarke `!!!` (SPEC §1) nur ALLEINSTEHEND, damit Achtung!!!" ein
gewöhnliches Label bleibt. Kein Lookbehind (Safari kennt es erst ab 16.4): gewöhnliches Label bleibt. Kein Lookbehind (Safari kennt es erst ab 16.4):
der führende Leerraum wird mitgefangen und wieder eingesetzt. */ 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}); 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(); 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, 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); parent.children.push(node);
stack.push({node, width}); 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})); if(n.size) parts.push(t('a11ySize', {size: n.size}));
else if(cheapPath) parts.push(t('a11ySizeImplicit')); else if(cheapPath) parts.push(t('a11ySizeImplicit'));
if(n.tags && n.tags.length) parts.push(t('a11yTags', {names: n.tags.join(', ')})); 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')); 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
@@ -69,7 +72,8 @@ function nodeHtml(n, extra, opts){
für die Gegenrichtung (Cursor-Zeile -> Knoten hervorheben). Der Hinweis im für die Gegenrichtung (Cursor-Zeile -> Knoten hervorheben). Der Hinweis im
Tooltip macht die sonst unsichtbare Alt-Klick-Geste auffindbar. */ Tooltip macht die sonst unsichtbare Alt-Klick-Geste auffindbar. */
const lineAttr = n.line ? ` data-line="${n.line}"` : ''; 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')] n.optional ? t('a11yOptional') : '', t('jumpHint')]
.filter(Boolean).join(' · '); .filter(Boolean).join(' · ');
const title = ` title="${attr(tip)}"`; const title = ` title="${attr(tip)}"`;
+5
View File
@@ -12,6 +12,9 @@
- xorConflict { line, label } weitere realisierte Alternative in einer - xorConflict { line, label } weitere realisierte Alternative in einer
`=`-Gruppe (SPEC §3/D35); je Zeile eine `=`-Gruppe (SPEC §3/D35); je Zeile eine
Warnung, damit sie dorthin zeigt 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 - sourceLoad { url, error } ?sourceUrl= nicht ladbar (D23); ohne
Zeilennummer, erscheint dadurch zuoberst Zeilennummer, erscheint dadurch zuoberst
- padRateLimit { seconds } zu früh nachgeladen; Werkbaum hat gar nicht - 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)}); return t('unknownStatusWarn', {line: w.line, code: esc(w.code)});
case 'xorConflict': case 'xorConflict':
return t('xorConflictWarn', {line: w.line, label: esc(w.label)}); 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': case 'sourceLoad':
return t('sourceLoadWarn', {url: esc(w.url), error: esc(w.error)}); return t('sourceLoadWarn', {url: esc(w.url), error: esc(w.error)});
case 'padRateLimit': 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"');
});
});