feat(taiga): Anlage-Knöpfe neu geschnitten — ein Story-Knopf mit Dialog, dialogfreie Task unter einem Story-Vorfahren (D91-Nachtrag 9)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-28 11:08:13 +02:00
co-authored by Claude Opus 5
parent 4f6d45045a
commit 0ed30e9eae
7 changed files with 226 additions and 42 deletions
+5
View File
@@ -17,6 +17,11 @@ the git history of `docs/examples/werkbaum.werkbaum`. A day can therefore carry
a link without having a note (someone forgot to write one) — but never the a link without having a note (someone forgot to write one) — but never the
reverse. reverse.
## 2026-08-28
- The create buttons in the node window are recut: one "create story" button always opens the dialog with project choice and sub-package checkboxes — unchecking covers the story-only case
- A "create task" button appears where an ancestor node already carries a story: the task lands in that story without any dialog — project and story follow from the tree, only an error gets a surface
## 2026-08-27 ## 2026-08-27
- Where a ticket and the plan disagree, the node window says so and offers both directions as explicit buttons — nothing happens by itself - Where a ticket and the plan disagree, the node window says so and offers both directions as explicit buttons — nothing happens by itself
+55
View File
@@ -8331,3 +8331,58 @@ Wer i18n-Texte mit Anführungszeichen schreibt, nimmt die typografischen
(`„…“`, `«…»`, `“…”`) und prüft die Datei einmal mit `npx esbuild src/app.js (`„…“`, `«…»`, `“…”`) und prüft die Datei einmal mit `npx esbuild src/app.js
--outfile=…` — das ist die schnellste ehrliche Syntaxprobe für eine Datei, --outfile=…` — das ist die schnellste ehrliche Syntaxprobe für eine Datei,
die kein Test anfasst. die kein Test anfasst.
**Nachtrag 9 — die Anlage-Knöpfe neu geschnitten: ein Story-Knopf mit
Dialog, ein dialogfreier Task-Knopf (2026-08-28).** Nutzerwunsch nach dem
ersten Arbeiten mit der Anlage: statt „Story anlegen"/„Story + Tasks
anlegen" **ein** „Story anlegen" (mit dem Häkchen-Dialog über die
Teilpakete, wie bisher bei der zweiten Aktion) und daneben **„Task
anlegen"** — nur dort, wo ein übergeordneter Knoten schon eine Story trägt,
denn in der wird die Task angelegt; „für Task-Anlegen braucht man dann auch
keine Auswahl". Die Entscheidungen des Baus:
- **Der eine Story-Knopf trägt den Dialog immer** — wer nur die Story will,
wählt die Häkchen ab (an einem Blatt gibt es ohnehin keine). Der frühere
Story-only-Knopf entfällt ersatzlos; ein Knopf, dessen ganze Aussage „wie
der andere, nur ohne Häkchen" ist, war einer zu viel.
- **„Task anlegen" hängt am nächsten Vorfahren mit `#US-…`-Ref**
(`storyAncestor`, headless in taiga.js). Vorfahren mit **Task**-Ref werden
übersprungen: Taiga-Tasks haben keine Subtasks (D91), die Task eines
Task-Knotens gehört in die Story darüber.
- **Ohne Dialog, weil es nichts zu wählen gibt:** Das Projekt folgt aus dem
geerbten `&taiga.<slug>`, die Story aus dem Baum. Das weicht bewusst von
der Haupttext-Festlegung „das Projekt wird bei jeder Anlage gefragt" ab —
hier hätte die Frage genau eine richtige Antwort, und eine Frage mit einer
Antwort ist keine. Der Story-Dialog fragt unverändert.
- **Der Knopf entfällt, wo ein `&taiga.*` zwischen Story und Knoten den Slug
übersteuert:** Die Ref an so einer Zeile würde später gegen das **falsche**
Projekt aufgelöst (Fenster, Öffnen, Status) — lieber kein Knopf als eine
Adresse, die lügt.
- **Drei Umläufe, kein Backend-Umbau:** Mitgliederliste (die Projekt-Id zum
Slug — der Lese-Endpunkt trägt keine), Story-Detail per `by_ref` (die
Story-Id, das `id`-Feld liefert Nachtrag 6 schon), `POST /tasks`.
- **Der Erfolg zeigt sich als Ref an der Zeile** (der Neubau ist die
Rückmeldung, wie beim Falten); nur ein **Fehler** bekommt eine Fläche —
ein kleiner Dialog mit Taigas Meldung (Ok-Knopf verborgen, Abbrechen
schließt), kein `window.alert` (D22-Lehre). Ein Slug außerhalb der eigenen
Projekte meldet sich mit eigenem Text (`taigaTaskNoProject`).
- i18n: `taigaTaskBtn`/`taigaTaskNoProject` neu ×9, `taigaTasksBtn` entfällt.
**Nachgemessen** Ende-zu-Ende im Browser gegen das lokale Backend mit
Taiga-Stub, alle vier Knopf-Konstellationen: Story + Task unter einem
Story-Vorfahren; nur Story bei übersteuerndem Slug; nur Story ohne
Story-Vorfahren; keine Anlage-Knöpfe an einer Ref (dort Stand + Öffnen wie
gehabt). Der Task-Klick schreibt `#T-201` an die Zeile, ohne Dialog;
Stub-Mitschnitt: `projects``by_slug``by_ref` → `POST /tasks
{project: 7, user_story: 1234}`. Der Story-Knopf auf einem Knoten mit
Kindern öffnet den Dialog mit Projekt-Vorbelegung und beiden Häkchen; der
Fremd-Slug-Fehlerpfad zeigt den Dialog mit Taigas Meldung und lässt die
Zeile unangetastet. 602 Tests (6 neue für `storyAncestor`); Gegenprobe per
Mutation: ohne das Überspringen der Task-Refs fällt genau der danach
benannte Test.
**Werkzeug-Notiz:** Die JavaScript-Ausführung des Prüf-Panes lebt in einer
**isolierten Welt** — ein überschriebenes `window.confirm` erreicht die
Seite nicht (Klicks schon: das DOM ist geteilt, die JS-Objekte nicht). Eine
Rückfrage lässt sich dort also nicht wegstubben; Aufräumen, das durch eine
Rückfrage führt, geht stattdessen über die Ablage selbst (D83-Schema).
+11 -2
View File
@@ -211,6 +211,7 @@
- [^] #trk.create.project: The project comes from the inherited tag (S) :#not.tag.project - [^] #trk.create.project: The project comes from the inherited tag (S) :#not.tag.project
- [^] #trk.create.story: A "create story" action in the node window (S) :#trk.create.login - [^] #trk.create.story: A "create story" action in the node window (S) :#trk.create.login
- [^] #trk.create.tasks: Sub-packages become tasks, picked in a dialog (M) :#trk.create.story - [^] #trk.create.tasks: Sub-packages become tasks, picked in a dialog (M) :#trk.create.story
- [x] #trk.create.task: A dialog-free "create task" under a story ancestor (S) :#trk.create.tasks,#trk.resolve.read
- [^] #trk.create.ref: The ref lands in the line beside the id (S) :#trk.create.story - [^] #trk.create.ref: The ref lands in the line beside the id (S) :#trk.create.story
- [^] #trk.open: Open a ticket ref in Taiga (S) :#trk.create.ref,#not.tag.project - [^] #trk.open: Open a ticket ref in Taiga (S) :#trk.create.ref,#not.tag.project
- [-] #trk.plugin: A plugin inside Taiga itself (L) %% legacy frontend, self-hosted only - [-] #trk.plugin: A plugin inside Taiga itself (L) %% legacy frontend, self-hosted only
@@ -1192,10 +1193,18 @@
created. A node that already carries a ticket ref is left alone. created. A node that already carries a ticket ref is left alone.
#trk.create.tasks #trk.create.tasks
The second action also creates the direct sub-packages as tasks under the The story dialog also creates the direct sub-packages as tasks under the
story, chosen in a checkbox dialog: preselected are required and optional story, chosen in a checkbox dialog: preselected are required and optional
children plus the realized alternatives of a |/= group; discarded ones children plus the realized alternatives of a |/= group; discarded ones
never appear, finished ones start unchecked. never appear, finished ones start unchecked. Unchecking everything covers
the story-only case, so there is just one create-story button.
#trk.create.task
Where an ancestor node already carries a story ref, the node window offers
"create task": the task lands in that story, without any dialog — project
and story follow from the tree. Ancestors with a task ref are skipped
(Taiga tasks have no subtasks), and the button is absent where a &taiga
tag in between would resolve the new ref against the wrong project.
#trk.create.project #trk.create.project
The dialog's project choice is prefilled from the nearest (&taiga.slug) The dialog's project choice is prefilled from the nearest (&taiga.slug)
+14 -11
View File
@@ -656,17 +656,20 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
`render()`. Die letzten drei sind beim Bauen zuerst vergessen worden. `render()`. Die letzten drei sind beim Bauen zuerst vergessen worden.
- **Taiga-Ticket-Anlage (D91):** Die entscheidbaren Regeln liegen headless in - **Taiga-Ticket-Anlage (D91):** Die entscheidbaren Regeln liegen headless in
`taiga.js` (`ticketRefOf` = Idempotenz-Marker, `taskCandidates` = `taiga.js` (`ticketRefOf` = Idempotenz-Marker, `taskCandidates` =
Dialog-Vorbelegung, `appendToken` = Token vor `%%` und vor der Dialog-Vorbelegung, `storyAncestor` = der nächste Vorfahr mit Story-Ref,
Fortsetzungsmarke ` \`); die `taiga.*`-Vererbung rechnet `taigaSlugs()` in `appendToken` = Token vor `%%` und vor der Fortsetzungsmarke ` \`); die
model.js. app.js verdrahtet: `refreshTaiga()` fragt je Basis EINMAL `taiga.*`-Vererbung rechnet `taigaSlugs()` in model.js. app.js verdrahtet:
`GET /info` (`taiga`-Flag; Aufruf aus Boot und `followActiveDoc`), `refreshTaiga()` fragt je Basis EINMAL `GET /info` (`taiga`-Flag; Aufruf
`appendTaigaActions()` hängt die zwei Knöpfe ins Knoten-Fenster (nur ohne aus Boot und `followActiveDoc`), `appendTaigaActions()` hängt die Knöpfe
vorhandene Ref), `taigaCreate()` führt Login → Projekt-Dialog → ins Knoten-Fenster (nur ohne vorhandene Ref): `taigaCreate()` = Login →
Anlegen → Ref-Rückschreiben. Geschrieben wird über `replaceTextUndoable` Projekt-Dialog samt Häkchen → Anlegen → Ref-Rückschreiben; `taigaTask()`
(Fokus/Undo/Schreibmarke, D53) — nie `src.value =`. Das Token liegt als (D91-Nachtrag 9) legt unter einem Story-Vorfahren dialogfrei eine Task an
`werkbaum-taiga` im localStorage (nur Token, nie ein Passwort); ein 401 — nur wenn der geerbte Slug am Knoten der des Vorfahren ist, sonst löste
löscht es und fragt neu. Fehler erscheinen IM Dialog, nicht als die Ref später gegen das falsche Projekt auf. Geschrieben wird über
`window.alert` (in manchen Kontexten unterdrückt, D22-Lehre). `replaceTextUndoable` (Fokus/Undo/Schreibmarke, D53) — nie `src.value =`.
Das Token liegt als `werkbaum-taiga` im localStorage (nur Token, nie ein
Passwort); ein 401 löscht es und fragt neu. Fehler erscheinen IM Dialog,
nicht als `window.alert` (in manchen Kontexten unterdrückt, D22-Lehre).
- **Ticket-Stand lesen (D91-Nachtrag 6):** `ticketApiPath()` baut den - **Ticket-Stand lesen (D91-Nachtrag 6):** `ticketApiPath()` baut den
Proxy-Pfad (getrennte by_ref-Endpunkte je Typ, Slug kodiert), Proxy-Pfad (getrennte by_ref-Endpunkte je Typ, Slug kodiert),
`mapTaigaStatus()` bildet Taigas Statusnamen auf `STATUS_BY_CODE` ab — `mapTaigaStatus()` bildet Taigas Statusnamen auf `STATUS_BY_CODE` ab —
+74 -28
View File
@@ -1,7 +1,7 @@
import './style.css'; import './style.css';
import { parse, setFoldMark, setStatusBox, expandShortIds, shortIdClosed } from './parser.js'; import { parse, setFoldMark, setStatusBox, expandShortIds, shortIdClosed } from './parser.js';
import { computeCheapPlan, overloadedAssignee, assigneeLoads, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, presetFoldSet, personFoldSet, allTags, lineTargets, taigaSlugs } from './model.js'; import { computeCheapPlan, overloadedAssignee, assigneeLoads, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, presetFoldSet, personFoldSet, allTags, lineTargets, taigaSlugs } from './model.js';
import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath } from './taiga.js'; import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor } from './taiga.js';
import { esc, renderTreeHtml, TIP_RULE } from './render.js'; import { esc, renderTreeHtml, TIP_RULE } from './render.js';
import { formatWarning, warningText } from './warnings.js'; import { formatWarning, warningText } from './warnings.js';
import * as live from './live.js'; import * as live from './live.js';
@@ -1718,13 +1718,15 @@ document.addEventListener('keydown', e => { if(e.key === 'Escape'){ closeNodeTip
document.querySelector('.diagram').addEventListener('scroll', closeNodeTip, {passive: true}); document.querySelector('.diagram').addEventListener('scroll', closeNodeTip, {passive: true});
/* ---------- Taiga-Ticket-Anlage (SPEC §11, D91) ---------- /* ---------- Taiga-Ticket-Anlage (SPEC §11, D91) ----------
Zwei Aktionen im Knoten-Fenster Story anlegen" und Story + Teilpakete Zwei Aktionen im Knoten-Fenster Story anlegen" (mit dem Häkchen-Dialog
als Tasks" , über den Backend-Proxy (D91: die Taiga-Adresse ist über die Teilpakete) und, unter einem Vorfahren mit Story, Task anlegen"
Server-Konfiguration, das Token bleibt im Browser). Die Aktionen erscheinen ohne jeden Dialog (D91-Nachtrag 9) , über den Backend-Proxy (D91: die
nur, wo `GET /info` das Feature meldet (`taiga`, Lebendprobe-Muster wie Taiga-Adresse ist Server-Konfiguration, das Token bleibt im Browser). Die
beim Teilen, D81-Nachtrag), und nur an Knoten OHNE Ticket-Referenz die Aktionen erscheinen nur, wo `GET /info` das Feature meldet (`taiga`,
Ref an der Zeile ist der Idempotenz-Marker (D91-Nachtrag 2). Die Regeln Lebendprobe-Muster wie beim Teilen, D81-Nachtrag), und nur an Knoten OHNE
(Vorbelegung, Token-Anhängen) liegen headless in taiga.js. */ Ticket-Referenz die Ref an der Zeile ist der Idempotenz-Marker
(D91-Nachtrag 2). Die Regeln (Vorbelegung, Story-Vorfahr, Token-Anhängen)
liegen headless in taiga.js. */
const LS_TAIGA = 'werkbaum-taiga'; const LS_TAIGA = 'werkbaum-taiga';
let taigaBase = null, taigaOn = false, taigaWeb = null; let taigaBase = null, taigaOn = false, taigaWeb = null;
const taigaProbeCache = new Map(); /* Basis -> Promise<{on, web}> (je Basis EIN /info) */ const taigaProbeCache = new Map(); /* Basis -> Promise<{on, web}> (je Basis EIN /info) */
@@ -1836,15 +1838,22 @@ function appendTaigaActions(el){
} }
return; return;
} }
const mk = (label, mitTasks) => { const mk = (label, action) => {
const b = document.createElement('button'); const b = document.createElement('button');
b.type = 'button'; b.className = 'nodetip-taigabtn'; b.tabIndex = -1; b.type = 'button'; b.className = 'nodetip-taigabtn'; b.tabIndex = -1;
b.textContent = label; b.textContent = label;
b.addEventListener('click', () => { closeNodeTip(); taigaCreate(line, mitTasks); }); b.addEventListener('click', () => { closeNodeTip(); action(line); });
return b; return b;
}; };
wrap.appendChild(mk(t('taigaStoryBtn'), false)); wrap.appendChild(mk(t('taigaStoryBtn'), taigaCreate));
if(taskCandidates(node).some(c => !c.exists)) wrap.appendChild(mk(t('taigaTasksBtn'), true)); /* Task anlegen" nur unter einem Vorfahren mit Story (D91-Nachtrag 9)
und nur, wenn dessen Projekt-Slug auch am Knoten gilt: Übersteuerte ein
`&taiga.*` dazwischen, trüge die Zeile nach dem Anlegen eine Ref, die
gegen das FALSCHE Projekt aufgelöst würde. */
const anc = storyAncestor(roots, node);
const slugs = taigaSlugs(roots);
if(anc && slugs.get(node) && slugs.get(node) === slugs.get(anc.node))
wrap.appendChild(mk(t('taigaTaskBtn'), taigaTask));
nodeTipBody.appendChild(wrap); nodeTipBody.appendChild(wrap);
} }
@@ -2188,24 +2197,25 @@ function writeLine(lineNo, mapper){
} }
/* Der Anlege-Dialog (D91): Projekt wählen vorbelegt aus dem geerbten /* Der Anlege-Dialog (D91): Projekt wählen vorbelegt aus dem geerbten
`&taiga.<slug>` (D91-Nachtrag 3) , bei Story + Tasks" dazu der `&taiga.<slug>` (D91-Nachtrag 3) samt Häkchen-Dialog über die direkten
Häkchen-Dialog über die direkten Kinder. Nach dem Anlegen landet die Ref Kinder (D91-Nachtrag 9: der eine Story-Knopf trägt beides; wer nur die
als Token an der Zeile (`#US-…`/`#T-…`); die ERSTE Anlage in einem Story will, wählt ab). Nach dem Anlegen landet die Ref als Token an der
Teilbaum ohne Zuordnung schreibt zusätzlich das Projekt-Schlagwort zurück Zeile (`#US-…`/`#T-…`); die ERSTE Anlage in einem Teilbaum ohne Zuordnung
beides in einem Undo-Schritt je Zeile. schreibt zusätzlich das Projekt-Schlagwort zurück beides in einem
Undo-Schritt je Zeile.
Scheitert eine Task, bleibt der Dialog mit dem Fehler offen; die Story und Scheitert eine Task, bleibt der Dialog mit dem Fehler offen; die Story und
schon angelegte Tasks werden beim erneuten Anlegen NICHT wiederholt schon angelegte Tasks werden beim erneuten Anlegen NICHT wiederholt
(`story`/`b.done` halten fest, was bezahlt ist und die geschriebenen (`story`/`b.done` halten fest, was bezahlt ist und die geschriebenen
Refs sind ohnehin der Idempotenz-Marker). */ Refs sind ohnehin der Idempotenz-Marker). */
async function taigaCreate(line, mitTasks){ async function taigaCreate(line){
const info = nodeAndRootsByLine(line); const info = nodeAndRootsByLine(line);
const node = info.node; const node = info.node;
if(!node || ticketRefOf(node)) return; if(!node || ticketRefOf(node)) return;
const session = await taigaEnsureSession(); const session = await taigaEnsureSession();
if(!session) return; if(!session) return;
const inherited = taigaSlugs(info.roots).get(node) || null; const inherited = taigaSlugs(info.roots).get(node) || null;
const cands = mitTasks ? taskCandidates(node).filter(c => !c.exists) : []; const cands = taskCandidates(node).filter(c => !c.exists);
const dlg = taigaModal(t('taigaProjectTitle'), t('taigaCreateDo')); const dlg = taigaModal(t('taigaProjectTitle'), t('taigaCreateDo'));
const subject = document.createElement('p'); const subject = document.createElement('p');
@@ -2290,6 +2300,42 @@ async function taigaCreate(line, mitTasks){
}); });
} }
/* Task anlegen" (D91-Nachtrag 9): Der Knoten wird eine Task in der Story
des nächsten Vorfahren mit `#US-…`-Ref OHNE Dialog: Das Projekt folgt
aus dem geerbten `&taiga.<slug>`, die Story aus dem Baum, es gibt nichts
zu wählen. Der Erfolg zeigt sich als Ref an der Zeile (der Neubau ist die
Rückmeldung, wie beim Falten); nur ein FEHLER bekommt eine Fläche ein
kleiner Dialog mit Taigas Meldung, kein window.alert (D22-Lehre). */
async function taigaTask(line){
const info = nodeAndRootsByLine(line);
const node = info.node;
if(!node || ticketRefOf(node)) return;
const anc = storyAncestor(info.roots, node);
const slug = taigaSlugs(info.roots).get(node) || null;
if(!anc || !slug) return;
const session = await taigaEnsureSession();
if(!session) return;
try{
/* Die Projekt-Id zum Slug aus der Mitgliederliste (dieselbe, die der
Story-Dialog zeigt) die Task-Anlage braucht sie, der Story-Detail
trägt sie nicht. Die Story-Id liefert der Lese-Endpunkt (Nachtrag 6). */
const projects = await taigaProjects(session);
const projekt = projects.find(p => p.slug === slug);
if(!projekt) throw {body: {detail: t('taigaTaskNoProject', {slug})}};
const story = await taigaFetch(ticketApiPath(anc.ref, slug), null, session.token);
const task = await taigaFetch('/tasks', {method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project: projekt.id, subject: node.label,
userStory: story.id})}, session.token);
writeLineTokens(node.line, [refToken('T', task.ref)]);
}catch(err){
const dlg = taigaModal(t('taigaTaskBtn'), t('taigaCancel'));
dlg.ok.hidden = true;
dlg.fail(taigaErrText(err));
dlg.cancel.addEventListener('click', () => dlg.close());
}
}
/* Welcher Knoten gehört zu einer Textzeile? Zuerst der Knoten, DER auf dieser /* Welcher Knoten gehört zu einer Textzeile? Zuerst der Knoten, DER auf dieser
Zeile steht; sonst der Knoten, dessen **Beschreibung** hier steht (SPEC §9): Zeile steht; sonst der Knoten, dessen **Beschreibung** hier steht (SPEC §9):
Eine `"`-Zeile und die Zeilen eines `---`-Blocks tragen keinen eigenen Eine `"`-Zeile und die Zeilen eines `---`-Blocks tragen keinen eigenen
@@ -3004,7 +3050,7 @@ const I18N = {
acHint:"{n} ID-Vorschläge ↑/↓ wählt, Enter übernimmt", acHint:"{n} ID-Vorschläge ↑/↓ wählt, Enter übernimmt",
tipClose:"Schließen", tipClose:"Schließen",
tipOpenLink:"Link öffnen", tipOpenLink:"Link öffnen",
taigaStoryBtn:"Taiga: Story anlegen", taigaTasksBtn:"Taiga: Story + Tasks anlegen", taigaLoginTitle:"Bei Taiga anmelden", taigaUser:"Benutzername", taigaPass:"Passwort", taigaLoginDo:"Anmelden", taigaCancel:"Abbrechen", taigaProjectTitle:"Taiga-Ticket anlegen", taigaProject:"Projekt", taigaTasksPick:"Teilpakete als Tasks anlegen:", taigaCreateDo:"Anlegen", taigaError:"Fehlgeschlagen: {error}", taigaOpenBtn:"{ref} in Taiga öffnen", taigaTicketDiff:"weicht vom Plan ab:", taigaTicketNoBox:"ohne Statusbox", taigaTicketPush:"nach Taiga schreiben", taigaTicketPull:"aus Taiga übernehmen", taigaTicketWriting:"wird geschrieben …", taigaTicketNoMap:"Dieser Zustand hat in Taiga keine Entsprechung — geschrieben wird nichts.", taigaTicketNoColumn:"Die Spalte „{name}“ gibt es in diesem Projekt nicht.", taigaTicketFetch:"Stand holen", taigaTicketReload:"Stand neu holen", taigaTicketLoading:"Stand wird geholt …", taigaTicketAssignee:"Zuständig: {name}", taigaStoryBtn:"Taiga: Story anlegen", taigaTaskBtn:"Taiga: Task anlegen", taigaTaskNoProject:"Kein Zugriff auf das Projekt „{slug}“.", taigaLoginTitle:"Bei Taiga anmelden", taigaUser:"Benutzername", taigaPass:"Passwort", taigaLoginDo:"Anmelden", taigaCancel:"Abbrechen", taigaProjectTitle:"Taiga-Ticket anlegen", taigaProject:"Projekt", taigaTasksPick:"Teilpakete als Tasks anlegen:", taigaCreateDo:"Anlegen", taigaError:"Fehlgeschlagen: {error}", taigaOpenBtn:"{ref} in Taiga öffnen", taigaTicketDiff:"weicht vom Plan ab:", taigaTicketNoBox:"ohne Statusbox", taigaTicketPush:"nach Taiga schreiben", taigaTicketPull:"aus Taiga übernehmen", taigaTicketWriting:"wird geschrieben …", taigaTicketNoMap:"Dieser Zustand hat in Taiga keine Entsprechung — geschrieben wird nichts.", taigaTicketNoColumn:"Die Spalte „{name}“ gibt es in diesem Projekt nicht.", taigaTicketFetch:"Stand holen", taigaTicketReload:"Stand neu holen", taigaTicketLoading:"Stand wird geholt …", taigaTicketAssignee:"Zuständig: {name}",
liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.", liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.",
liveConflictText:"Jemand hat dieselben Zeilen geändert. Wessen Fassung soll gelten?", liveConflictText:"Jemand hat dieselben Zeilen geändert. Wessen Fassung soll gelten?",
@@ -3141,7 +3187,7 @@ const I18N = {
acHint:"{n} id suggestions ↑/↓ to choose, Enter to insert", acHint:"{n} id suggestions ↑/↓ to choose, Enter to insert",
tipClose:"Close", tipClose:"Close",
tipOpenLink:"Open link", tipOpenLink:"Open link",
taigaStoryBtn:"Taiga: create story", taigaTasksBtn:"Taiga: create story + tasks", taigaLoginTitle:"Log in to Taiga", taigaUser:"Username", taigaPass:"Password", taigaLoginDo:"Log in", taigaCancel:"Cancel", taigaProjectTitle:"Create Taiga ticket", taigaProject:"Project", taigaTasksPick:"Create sub-packages as tasks:", taigaCreateDo:"Create", taigaError:"Failed: {error}", taigaOpenBtn:"Open {ref} in Taiga", taigaTicketDiff:"differs from the plan:", taigaTicketNoBox:"no status box", taigaTicketPush:"write to Taiga", taigaTicketPull:"take from Taiga", taigaTicketWriting:"writing …", taigaTicketNoMap:"This state has no counterpart in Taiga — nothing is written.", taigaTicketNoColumn:"There is no column “{name}” in this project.", taigaTicketFetch:"Fetch state", taigaTicketReload:"Fetch again", taigaTicketLoading:"Fetching state …", taigaTicketAssignee:"Assigned to: {name}", taigaStoryBtn:"Taiga: create story", taigaTaskBtn:"Taiga: create task", taigaTaskNoProject:"No access to project “{slug}”.", taigaLoginTitle:"Log in to Taiga", taigaUser:"Username", taigaPass:"Password", taigaLoginDo:"Log in", taigaCancel:"Cancel", taigaProjectTitle:"Create Taiga ticket", taigaProject:"Project", taigaTasksPick:"Create sub-packages as tasks:", taigaCreateDo:"Create", taigaError:"Failed: {error}", taigaOpenBtn:"Open {ref} in Taiga", taigaTicketDiff:"differs from the plan:", taigaTicketNoBox:"no status box", taigaTicketPush:"write to Taiga", taigaTicketPull:"take from Taiga", taigaTicketWriting:"writing …", taigaTicketNoMap:"This state has no counterpart in Taiga — nothing is written.", taigaTicketNoColumn:"There is no column “{name}” in this project.", taigaTicketFetch:"Fetch state", taigaTicketReload:"Fetch again", taigaTicketLoading:"Fetching state …", taigaTicketAssignee:"Assigned to: {name}",
liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.", liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.",
liveConflictText:"Someone changed the same lines. Whose version should win?", liveConflictText:"Someone changed the same lines. Whose version should win?",
@@ -3277,7 +3323,7 @@ const I18N = {
acHint:"{n} sugerencias de ID ↑/↓ elige, Intro inserta", acHint:"{n} sugerencias de ID ↑/↓ elige, Intro inserta",
tipClose:"Cerrar", tipClose:"Cerrar",
tipOpenLink:"Abrir enlace", tipOpenLink:"Abrir enlace",
taigaStoryBtn:"Taiga: crear historia", taigaTasksBtn:"Taiga: crear historia + tareas", taigaLoginTitle:"Iniciar sesión en Taiga", taigaUser:"Usuario", taigaPass:"Contraseña", taigaLoginDo:"Iniciar sesión", taigaCancel:"Cancelar", taigaProjectTitle:"Crear ticket de Taiga", taigaProject:"Proyecto", taigaTasksPick:"Crear subpaquetes como tareas:", taigaCreateDo:"Crear", taigaError:"Error: {error}", taigaOpenBtn:"Abrir {ref} en Taiga", taigaTicketDiff:"difiere del plan:", taigaTicketNoBox:"sin casilla de estado", taigaTicketPush:"escribir en Taiga", taigaTicketPull:"tomar de Taiga", taigaTicketWriting:"escribiendo …", taigaTicketNoMap:"Este estado no tiene equivalente en Taiga: no se escribe nada.", taigaTicketNoColumn:"En este proyecto no existe la columna «{name}».", taigaTicketFetch:"Consultar estado", taigaTicketReload:"Volver a consultar", taigaTicketLoading:"Consultando estado …", taigaTicketAssignee:"Asignado a: {name}", taigaStoryBtn:"Taiga: crear historia", taigaTaskBtn:"Taiga: crear tarea", taigaTaskNoProject:"Sin acceso al proyecto «{slug}».", taigaLoginTitle:"Iniciar sesión en Taiga", taigaUser:"Usuario", taigaPass:"Contraseña", taigaLoginDo:"Iniciar sesión", taigaCancel:"Cancelar", taigaProjectTitle:"Crear ticket de Taiga", taigaProject:"Proyecto", taigaTasksPick:"Crear subpaquetes como tareas:", taigaCreateDo:"Crear", taigaError:"Error: {error}", taigaOpenBtn:"Abrir {ref} en Taiga", taigaTicketDiff:"difiere del plan:", taigaTicketNoBox:"sin casilla de estado", taigaTicketPush:"escribir en Taiga", taigaTicketPull:"tomar de Taiga", taigaTicketWriting:"escribiendo …", taigaTicketNoMap:"Este estado no tiene equivalente en Taiga: no se escribe nada.", taigaTicketNoColumn:"En este proyecto no existe la columna «{name}».", taigaTicketFetch:"Consultar estado", taigaTicketReload:"Volver a consultar", taigaTicketLoading:"Consultando estado …", taigaTicketAssignee:"Asignado a: {name}",
liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.", liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.",
liveConflictText:"Alguien cambió las mismas líneas. ¿Qué versión debe prevalecer?", liveConflictText:"Alguien cambió las mismas líneas. ¿Qué versión debe prevalecer?",
@@ -3413,7 +3459,7 @@ const I18N = {
acHint:"{n} suggestions d'ID ↑/↓ pour choisir, Entrée pour insérer", acHint:"{n} suggestions d'ID ↑/↓ pour choisir, Entrée pour insérer",
tipClose:"Fermer", tipClose:"Fermer",
tipOpenLink:"Ouvrir le lien", tipOpenLink:"Ouvrir le lien",
taigaStoryBtn:"Taiga : créer la story", taigaTasksBtn:"Taiga : créer story + tâches", taigaLoginTitle:"Se connecter à Taiga", taigaUser:"Nom d'utilisateur", taigaPass:"Mot de passe", taigaLoginDo:"Se connecter", taigaCancel:"Annuler", taigaProjectTitle:"Créer un ticket Taiga", taigaProject:"Projet", taigaTasksPick:"Créer les sous-lots comme tâches :", taigaCreateDo:"Créer", taigaError:"Échec : {error}", taigaOpenBtn:"Ouvrir {ref} dans Taiga", taigaTicketDiff:"diffère du plan :", taigaTicketNoBox:"sans case d'état", taigaTicketPush:"écrire dans Taiga", taigaTicketPull:"reprendre de Taiga", taigaTicketWriting:"écriture …", taigaTicketNoMap:"Cet état n'a pas d'équivalent dans Taiga — rien n'est écrit.", taigaTicketNoColumn:"La colonne « {name} » n'existe pas dans ce projet.", taigaTicketFetch:"Relever l'état", taigaTicketReload:"Relever à nouveau", taigaTicketLoading:"Relevé de l'état …", taigaTicketAssignee:"Assigné à : {name}", taigaStoryBtn:"Taiga : créer la story", taigaTaskBtn:"Taiga : créer une tâche", taigaTaskNoProject:"Pas d'accès au projet « {slug} ».", taigaLoginTitle:"Se connecter à Taiga", taigaUser:"Nom d'utilisateur", taigaPass:"Mot de passe", taigaLoginDo:"Se connecter", taigaCancel:"Annuler", taigaProjectTitle:"Créer un ticket Taiga", taigaProject:"Projet", taigaTasksPick:"Créer les sous-lots comme tâches :", taigaCreateDo:"Créer", taigaError:"Échec : {error}", taigaOpenBtn:"Ouvrir {ref} dans Taiga", taigaTicketDiff:"diffère du plan :", taigaTicketNoBox:"sans case d'état", taigaTicketPush:"écrire dans Taiga", taigaTicketPull:"reprendre de Taiga", taigaTicketWriting:"écriture …", taigaTicketNoMap:"Cet état n'a pas d'équivalent dans Taiga — rien n'est écrit.", taigaTicketNoColumn:"La colonne « {name} » n'existe pas dans ce projet.", taigaTicketFetch:"Relever l'état", taigaTicketReload:"Relever à nouveau", taigaTicketLoading:"Relevé de l'état …", taigaTicketAssignee:"Assigné à : {name}",
liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/&lt;uuid&gt;) ?", liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/&lt;uuid&gt;) ?",
liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.", liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.",
liveConflictText:"Quelqu'un a modifié les mêmes lignes. Quelle version doit l'emporter ?", liveConflictText:"Quelqu'un a modifié les mêmes lignes. Quelle version doit l'emporter ?",
@@ -3549,7 +3595,7 @@ const I18N = {
acHint:"{n} podpowiedzi ID ↑/↓ wybiera, Enter wstawia", acHint:"{n} podpowiedzi ID ↑/↓ wybiera, Enter wstawia",
tipClose:"Zamknij", tipClose:"Zamknij",
tipOpenLink:"Otwórz link", tipOpenLink:"Otwórz link",
taigaStoryBtn:"Taiga: utwórz historyjkę", taigaTasksBtn:"Taiga: historyjka + zadania", taigaLoginTitle:"Zaloguj się do Taigi", taigaUser:"Nazwa użytkownika", taigaPass:"Hasło", taigaLoginDo:"Zaloguj", taigaCancel:"Anuluj", taigaProjectTitle:"Utwórz zgłoszenie w Taidze", taigaProject:"Projekt", taigaTasksPick:"Utwórz podpakiety jako zadania:", taigaCreateDo:"Utwórz", taigaError:"Niepowodzenie: {error}", taigaOpenBtn:"Otwórz {ref} w Taidze", taigaTicketDiff:"różni się od planu:", taigaTicketNoBox:"bez pola statusu", taigaTicketPush:"zapisz w Taidze", taigaTicketPull:"pobierz z Taigi", taigaTicketWriting:"zapisywanie …", taigaTicketNoMap:"Ten stan nie ma odpowiednika w Taidze — nic nie zostanie zapisane.", taigaTicketNoColumn:"W tym projekcie nie ma kolumny „{name}”.", taigaTicketFetch:"Pobierz stan", taigaTicketReload:"Pobierz ponownie", taigaTicketLoading:"Pobieranie stanu …", taigaTicketAssignee:"Przypisane do: {name}", taigaStoryBtn:"Taiga: utwórz historyjkę", taigaTaskBtn:"Taiga: utwórz zadanie", taigaTaskNoProject:"Brak dostępu do projektu „{slug}”.", taigaLoginTitle:"Zaloguj się do Taigi", taigaUser:"Nazwa użytkownika", taigaPass:"Hasło", taigaLoginDo:"Zaloguj", taigaCancel:"Anuluj", taigaProjectTitle:"Utwórz zgłoszenie w Taidze", taigaProject:"Projekt", taigaTasksPick:"Utwórz podpakiety jako zadania:", taigaCreateDo:"Utwórz", taigaError:"Niepowodzenie: {error}", taigaOpenBtn:"Otwórz {ref} w Taidze", taigaTicketDiff:"różni się od planu:", taigaTicketNoBox:"bez pola statusu", taigaTicketPush:"zapisz w Taidze", taigaTicketPull:"pobierz z Taigi", taigaTicketWriting:"zapisywanie …", taigaTicketNoMap:"Ten stan nie ma odpowiednika w Taidze — nic nie zostanie zapisane.", taigaTicketNoColumn:"W tym projekcie nie ma kolumny „{name}”.", taigaTicketFetch:"Pobierz stan", taigaTicketReload:"Pobierz ponownie", taigaTicketLoading:"Pobieranie stanu …", taigaTicketAssignee:"Przypisane do: {name}",
liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.", liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.",
liveConflictText:"Ktoś zmienił te same wiersze. Która wersja ma obowiązywać?", liveConflictText:"Ktoś zmienił te same wiersze. Która wersja ma obowiązywać?",
@@ -3685,7 +3731,7 @@ const I18N = {
acHint:"{n} подсказок ID – ↑/↓ выбирает, Enter вставляет", acHint:"{n} подсказок ID – ↑/↓ выбирает, Enter вставляет",
tipClose:"Закрыть", tipClose:"Закрыть",
tipOpenLink:"Открыть ссылку", tipOpenLink:"Открыть ссылку",
taigaStoryBtn:"Taiga: создать историю", taigaTasksBtn:"Taiga: история + задачи", taigaLoginTitle:"Вход в Taiga", taigaUser:"Имя пользователя", taigaPass:"Пароль", taigaLoginDo:"Войти", taigaCancel:"Отмена", taigaProjectTitle:"Создать тикет в Taiga", taigaProject:"Проект", taigaTasksPick:"Создать подпакеты как задачи:", taigaCreateDo:"Создать", taigaError:"Не удалось: {error}", taigaOpenBtn:"Открыть {ref} в Taiga", taigaTicketDiff:"расходится с планом:", taigaTicketNoBox:"без статуса", taigaTicketPush:"записать в Taiga", taigaTicketPull:"взять из Taiga", taigaTicketWriting:"запись …", taigaTicketNoMap:"У этого состояния нет соответствия в Taiga — ничего не записано.", taigaTicketNoColumn:"В этом проекте нет колонки «{name}».", taigaTicketFetch:"Получить статус", taigaTicketReload:"Обновить статус", taigaTicketLoading:"Получение статуса …", taigaTicketAssignee:"Назначено: {name}", taigaStoryBtn:"Taiga: создать историю", taigaTaskBtn:"Taiga: создать задачу", taigaTaskNoProject:"Нет доступа к проекту «{slug}».", taigaLoginTitle:"Вход в Taiga", taigaUser:"Имя пользователя", taigaPass:"Пароль", taigaLoginDo:"Войти", taigaCancel:"Отмена", taigaProjectTitle:"Создать тикет в Taiga", taigaProject:"Проект", taigaTasksPick:"Создать подпакеты как задачи:", taigaCreateDo:"Создать", taigaError:"Не удалось: {error}", taigaOpenBtn:"Открыть {ref} в Taiga", taigaTicketDiff:"расходится с планом:", taigaTicketNoBox:"без статуса", taigaTicketPush:"записать в Taiga", taigaTicketPull:"взять из Taiga", taigaTicketWriting:"запись …", taigaTicketNoMap:"У этого состояния нет соответствия в Taiga — ничего не записано.", taigaTicketNoColumn:"В этом проекте нет колонки «{name}».", taigaTicketFetch:"Получить статус", taigaTicketReload:"Обновить статус", taigaTicketLoading:"Получение статуса …", taigaTicketAssignee:"Назначено: {name}",
liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.", liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.",
liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?", liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?",
@@ -3821,7 +3867,7 @@ const I18N = {
acHint:"{n} आईडी सुझाव – ↑/↓ से चुनें, Enter से डालें", acHint:"{n} आईडी सुझाव – ↑/↓ से चुनें, Enter से डालें",
tipClose:"बंद करें", tipClose:"बंद करें",
tipOpenLink:"लिंक खोलें", tipOpenLink:"लिंक खोलें",
taigaStoryBtn:"Taiga: स्टोरी बनाएँ", taigaTasksBtn:"Taiga: स्टोरी + टास्क बनाएँ", taigaLoginTitle:"Taiga में साइन इन करें", taigaUser:"उपयोगकर्ता नाम", taigaPass:"पासवर्ड", taigaLoginDo:"साइन इन", taigaCancel:"रद्द करें", taigaProjectTitle:"Taiga टिकट बनाएँ", taigaProject:"प्रोजेक्ट", taigaTasksPick:"उप-पैकेज टास्क के रूप में बनाएँ:", taigaCreateDo:"बनाएँ", taigaError:"विफल: {error}", taigaOpenBtn:"Taiga में {ref} खोलें", taigaTicketDiff:"योजना से भिन्न:", taigaTicketNoBox:"स्थिति-बॉक्स नहीं", taigaTicketPush:"Taiga में लिखें", taigaTicketPull:"Taiga से लें", taigaTicketWriting:"लिखा जा रहा है …", taigaTicketNoMap:"इस स्थिति का Taiga में कोई समकक्ष नहीं है — कुछ नहीं लिखा जाता।", taigaTicketNoColumn:"इस प्रोजेक्ट में “{name}” कॉलम नहीं है।", taigaTicketFetch:"स्थिति लाएँ", taigaTicketReload:"फिर से लाएँ", taigaTicketLoading:"स्थिति लाई जा रही है …", taigaTicketAssignee:"ज़िम्मेदार: {name}", taigaStoryBtn:"Taiga: स्टोरी बनाएँ", taigaTaskBtn:"Taiga: टास्क बनाएँ", taigaTaskNoProject:"प्रोजेक्ट “{slug}” तक पहुँच नहीं है।", taigaLoginTitle:"Taiga में साइन इन करें", taigaUser:"उपयोगकर्ता नाम", taigaPass:"पासवर्ड", taigaLoginDo:"साइन इन", taigaCancel:"रद्द करें", taigaProjectTitle:"Taiga टिकट बनाएँ", taigaProject:"प्रोजेक्ट", taigaTasksPick:"उप-पैकेज टास्क के रूप में बनाएँ:", taigaCreateDo:"बनाएँ", taigaError:"विफल: {error}", taigaOpenBtn:"Taiga में {ref} खोलें", taigaTicketDiff:"योजना से भिन्न:", taigaTicketNoBox:"स्थिति-बॉक्स नहीं", taigaTicketPush:"Taiga में लिखें", taigaTicketPull:"Taiga से लें", taigaTicketWriting:"लिखा जा रहा है …", taigaTicketNoMap:"इस स्थिति का Taiga में कोई समकक्ष नहीं है — कुछ नहीं लिखा जाता।", taigaTicketNoColumn:"इस प्रोजेक्ट में “{name}” कॉलम नहीं है।", taigaTicketFetch:"स्थिति लाएँ", taigaTicketReload:"फिर से लाएँ", taigaTicketLoading:"स्थिति लाई जा रही है …", taigaTicketAssignee:"ज़िम्मेदार: {name}",
liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।", liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।",
liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?", liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?",
@@ -3968,7 +4014,7 @@ const I18N = {
acHint:"{n} 个 ID 建议 ↑/↓ 选择,Enter 插入", acHint:"{n} 个 ID 建议 ↑/↓ 选择,Enter 插入",
tipClose:"关闭", tipClose:"关闭",
tipOpenLink:"打开链接", tipOpenLink:"打开链接",
taigaStoryBtn:"Taiga:创建故事", taigaTasksBtn:"Taiga:创建故事和任务", taigaLoginTitle:"登录 Taiga", taigaUser:"用户名", taigaPass:"密码", taigaLoginDo:"登录", taigaCancel:"取消", taigaProjectTitle:"创建 Taiga 工单", taigaProject:"项目", taigaTasksPick:"将子包创建为任务:", taigaCreateDo:"创建", taigaError:"失败:{error}", taigaOpenBtn:"在 Taiga 中打开 {ref}", taigaTicketDiff:"与计划不一致:", taigaTicketNoBox:"没有状态框", taigaTicketPush:"写入 Taiga", taigaTicketPull:"从 Taiga 取用", taigaTicketWriting:"正在写入 …", taigaTicketNoMap:"该状态在 Taiga 中没有对应项 — 不会写入任何内容。", taigaTicketNoColumn:"本项目中没有「{name}」这一列。", taigaTicketFetch:"获取状态", taigaTicketReload:"重新获取", taigaTicketLoading:"正在获取状态 …", taigaTicketAssignee:"负责人:{name}", taigaStoryBtn:"Taiga:创建故事", taigaTaskBtn:"Taiga:创建任务", taigaTaskNoProject:"无法访问项目「{slug}」。", taigaLoginTitle:"登录 Taiga", taigaUser:"用户名", taigaPass:"密码", taigaLoginDo:"登录", taigaCancel:"取消", taigaProjectTitle:"创建 Taiga 工单", taigaProject:"项目", taigaTasksPick:"将子包创建为任务:", taigaCreateDo:"创建", taigaError:"失败:{error}", taigaOpenBtn:"在 Taiga 中打开 {ref}", taigaTicketDiff:"与计划不一致:", taigaTicketNoBox:"没有状态框", taigaTicketPush:"写入 Taiga", taigaTicketPull:"从 Taiga 取用", taigaTicketWriting:"正在写入 …", taigaTicketNoMap:"该状态在 Taiga 中没有对应项 — 不会写入任何内容。", taigaTicketNoColumn:"本项目中没有「{name}」这一列。", taigaTicketFetch:"获取状态", taigaTicketReload:"重新获取", taigaTicketLoading:"正在获取状态 …", taigaTicketAssignee:"负责人:{name}",
liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/&lt;uuid&gt;)吗?", liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/&lt;uuid&gt;)吗?",
liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。", liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。",
liveConflictText:"有人改动了同样的行。以谁的版本为准?", liveConflictText:"有人改动了同样的行。以谁的版本为准?",
@@ -4104,7 +4150,7 @@ const I18N = {
acHint:"ID候補 {n} 件 ↑/↓で選択、Enterで挿入", acHint:"ID候補 {n} 件 ↑/↓で選択、Enterで挿入",
tipClose:"閉じる", tipClose:"閉じる",
tipOpenLink:"リンクを開く", tipOpenLink:"リンクを開く",
taigaStoryBtn:"Taiga: ストーリーを作成", taigaTasksBtn:"Taiga: ストーリー+タスクを作成", taigaLoginTitle:"Taiga にログイン", taigaUser:"ユーザー名", taigaPass:"パスワード", taigaLoginDo:"ログイン", taigaCancel:"キャンセル", taigaProjectTitle:"Taiga チケットを作成", taigaProject:"プロジェクト", taigaTasksPick:"サブパッケージをタスクとして作成:", taigaCreateDo:"作成", taigaError:"失敗: {error}", taigaOpenBtn:"Taiga で {ref} を開く", taigaTicketDiff:"計画と食い違い:", taigaTicketNoBox:"ステータス欄なし", taigaTicketPush:"Taiga に書く", taigaTicketPull:"Taiga から取る", taigaTicketWriting:"書き込み中 …", taigaTicketNoMap:"この状態に対応する列が Taiga にありません — 何も書き込みません。", taigaTicketNoColumn:"このプロジェクトに「{name}」列はありません。", taigaTicketFetch:"状態を取得", taigaTicketReload:"再取得", taigaTicketLoading:"状態を取得中 …", taigaTicketAssignee:"担当: {name}", taigaStoryBtn:"Taiga: ストーリーを作成", taigaTaskBtn:"Taiga: タスクを作成", taigaTaskNoProject:"プロジェクト「{slug}」にアクセスできません。", taigaLoginTitle:"Taiga にログイン", taigaUser:"ユーザー名", taigaPass:"パスワード", taigaLoginDo:"ログイン", taigaCancel:"キャンセル", taigaProjectTitle:"Taiga チケットを作成", taigaProject:"プロジェクト", taigaTasksPick:"サブパッケージをタスクとして作成:", taigaCreateDo:"作成", taigaError:"失敗: {error}", taigaOpenBtn:"Taiga で {ref} を開く", taigaTicketDiff:"計画と食い違い:", taigaTicketNoBox:"ステータス欄なし", taigaTicketPush:"Taiga に書く", taigaTicketPull:"Taiga から取る", taigaTicketWriting:"書き込み中 …", taigaTicketNoMap:"この状態に対応する列が Taiga にありません — 何も書き込みません。", taigaTicketNoColumn:"このプロジェクトに「{name}」列はありません。", taigaTicketFetch:"状態を取得", taigaTicketReload:"再取得", taigaTicketLoading:"状態を取得中 …", taigaTicketAssignee:"担当: {name}",
liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/&lt;uuid&gt;)ですか。", liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/&lt;uuid&gt;)ですか。",
liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。", liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。",
liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。", liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。",
+25
View File
@@ -150,6 +150,31 @@ export function ticketRefAt(text, caret){
return null; return null;
} }
/* Der nächste Vorfahr mit einer STORY-Ref (D91-Nachtrag 9): Unter so einem
Knoten wird ein Teilpaket direkt als Task angelegt — in dessen Story, ohne
eigenen Dialog (Projekt und Story folgen aus dem Baum, es gibt nichts zu
wählen). Vorfahren mit Task-Ref (`T-…`) werden übersprungen: Taiga-Tasks
haben keine Subtasks, die Task eines Task-Knotens gehört in die Story
darüber. null, wenn es keinen gibt oder der Knoten nicht im Baum steht. */
export function storyAncestor(roots, node){
let result = null, hit = false;
(function w(ns, chain){
for(const n of ns){
if(hit) return;
if(n === node){
hit = true;
for(let i = chain.length - 1; i >= 0; i--){
const r = ticketRefOf(chain[i]);
if(r && r.startsWith('US-')){ result = {node: chain[i], ref: r}; return; }
}
return;
}
w(n.children || [], chain.concat(n));
}
})(roots, []);
return result;
}
/* Das Token, das an die Zeile geschrieben wird. */ /* Das Token, das an die Zeile geschrieben wird. */
export function refToken(kind, ref){ return '#' + kind + '-' + ref; } export function refToken(kind, ref){ return '#' + kind + '-' + ref; }
export function slugToken(slug){ return '&taiga.' + slug; } export function slugToken(slug){ return '&taiga.' + slug; }
+42 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { parse } from '../src/parser.js'; import { parse } from '../src/parser.js';
import { taigaSlugs } from '../src/model.js'; import { taigaSlugs } from '../src/model.js';
import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, refParts, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath } from '../src/taiga.js'; import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, refParts, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor } from '../src/taiga.js';
import { setStatusBox } from '../src/parser.js'; import { setStatusBox } from '../src/parser.js';
/* Schlagworte `&tag` (SPEC §1, D91): Extraktion im Parser und die /* Schlagworte `&tag` (SPEC §1, D91): Extraktion im Parser und die
@@ -147,6 +147,47 @@ describe('taskCandidates — Vorbelegung des Häkchen-Dialogs (D91)', () => {
}); });
}); });
describe('storyAncestor — der nächste Vorfahr mit Story-Ref (D91-Nachtrag 9)', () => {
const find = (text, label) => {
const { roots } = parse(text);
let node = null;
(function w(ns){ for(const n of ns){ if(n.label === label) node = n; w(n.children); } })(roots);
return {roots, node};
};
it('findet den nächsten Vorfahren mit US-Ref', () => {
const {roots, node} = find('- A #US-1\n - B #US-2\n - C', 'C');
const anc = storyAncestor(roots, node);
expect(anc.ref).toBe('US-2');
expect(anc.node.label).toBe('B');
});
it('überspringt Vorfahren mit Task-Ref — die Task gehört in die Story darüber', () => {
const {roots, node} = find('- A #US-1\n - B #T-7\n - C', 'C');
expect(storyAncestor(roots, node).ref).toBe('US-1');
});
it('null ohne Story-Vorfahren — auch wenn der Knoten selbst eine Ref trägt', () => {
const {roots, node} = find('- A\n - B #US-9', 'B');
expect(storyAncestor(roots, node)).toBe(null);
});
it('null am Wurzelknoten', () => {
const {roots, node} = find('- A #US-1', 'A');
expect(storyAncestor(roots, node)).toBe(null);
});
it('null für einen Knoten, der nicht im Baum steht', () => {
const {roots} = find('- A #US-1\n - B', 'B');
expect(storyAncestor(roots, {label: 'fremd', children: []})).toBe(null);
});
it('die Ref darf auch die Knoten-ID selbst sein (D60)', () => {
const {roots, node} = find('- #US-42\n - Kind', 'Kind');
expect(storyAncestor(roots, node).ref).toBe('US-42');
});
});
describe('appendToken — Token ans sichtbare Zeilenende (D91)', () => { describe('appendToken — Token ans sichtbare Zeilenende (D91)', () => {
it('hängt hinter den Inhalt an', () => { it('hängt hinter den Inhalt an', () => {
expect(appendToken(' - [ ] Backend (M)', '#US-123')).toBe(' - [ ] Backend (M) #US-123'); expect(appendToken(' - [ ] Backend (M)', '#US-123')).toBe(' - [ ] Backend (M) #US-123');