diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 888324b..acdb6d1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,10 @@ reverse. ## 2026-08-28 +- "Link ticket" in the node window binds an existing Taiga story or task to a node: paste the ticket's URL — it carries project, type and number by itself — or enter a ref or bare number +- A bare number is resolved by probing story then task, and the ticket's subject is shown before anything is written — you link what you saw +- Linking appends the ref and, where needed, the `&taiga.slug` tag in one undo step, and pre-fills the ticket cache so window and divergence mark agree at once +- A ticket URL from a different Taiga instance is named as such instead of silently linking a same-numbered ticket on yours - One bulk request reads the status of all referenced tickets: the proxy fans the refs out right next to the Taiga instance, so the cost scales with the refs in the plan — not with a project that holds thousands of tickets - With a Taiga login, diverging tickets show up in the diagram: a ref whose ticket status no longer matches the node's box turns amber — as its own little badge where the ref is the node id and thus not part of the title - The bulk result pre-fills the node window's ticket cache, so hovering a ticket node usually needs no request of its own any more diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 614f3c7..bd526da 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -8471,3 +8471,65 @@ lebenden Seite umschreibt, bekommt beim Reload deren Gedächtnis zurück; wiederhergestellt wird ein Dokument als gewöhnliche Änderung über das Textfeld. Und der Debug-Reset hängt am `confirm`, das die isolierte Welt des Prüf-Panes nicht stubben kann (Nachtrag 9) — er ist dort wirkungslos. + +**Nachtrag 11 — „Ticket verknüpfen": ein bestehendes Ticket an den Knoten +binden (2026-08-28).** Nutzerwunsch: Wer eine Story oder Task schon in Taiga +hat, soll sie im Werkbaum eintragen können — „klar, kann man einfach +`#US-1234` schreiben, aber der User braucht ja ggf. auch die Taiga +Projekt-ID". Die Antwort auf genau dieses Problem liegt in Taiga selbst: +**Die URL des Tickets trägt alles** — `…/project//us/123` bzw. +`…/task/45` enthalten Slug, Typ und Nummer (dieselbe Form, die `ticketUrl` +seit Nachtrag 5 vorwärts baut). Wer auf dem Ticket steht, kopiert die +Adresszeile; die Projekt-Frage stellt sich nicht. Die Entscheidungen +(Multiple-Choice: URL/Ref-Feld, die Such-Ausbaustufe zurückgestellt): + +- **Ein Knopf „Taiga: Ticket verknüpfen"** im Knoten-Fenster, neben den + Anlege-Knöpfen und wie sie nur an Knoten ohne Ref (die Ref ist der + Idempotenz-Marker). **Ein Eingabefeld, drei Formen** (`parseTicketInput`, + headless): die Taiga-URL (Slug/Typ/Nummer aus dem Pfad, die + Projekt-Auswahl tritt zurück), eine Ref (`US-123`/`T-45`, `#` und + Kleinschreibung erlaubt, normalisiert) oder eine **nackte Nummer** — so + zeigt Taiga sie. Die ist eindeutig auflösbar: Taigas Zähler läuft je + Projekt über beide Typen gemeinsam (Nachtrag 2), probiert wird Story, + dann Task über die vorhandenen Lese-Endpunkte — genau einer trifft, kein + neuer Backend-Endpunkt. +- **Verknüpft wird, was man gesehen hat:** Aufgelöst wird schon beim Tippen + (400 ms Ruhe, überholte Antworten verwirft ein Zähler), und der + **Betreff steht im Dialog**, bevor der Verknüpfen-Knopf freigibt — eine + blind eingetragene Nummer ist der Fehler, den der Dialog verhindern soll. + Tipp-Zwischenstände sind still (kein Fehler beim halben Token); „kein + Ticket" und Taiga-Fehler stehen im Dialog. +- **Eine URL von einer fremden Taiga-Instanz wird benannt, nicht + verknüpft** (`foreignTaigaUrl` gegen die konfigurierte Web-Basis): Slug + und Nummer würden gegen UNSERE Instanz aufgelöst und träfen dort + womöglich ein fremdes Ticket gleicher Nummer. Ohne Web-Basis ist nichts + zu prüfen — dann wacht die Betreff-Bestätigung allein. +- **Geschrieben wird wie bei der Anlage:** Ref-Token an die Zeile, dazu + `&taiga.`, wenn der Teilbaum keine oder eine andere Zuordnung hat + (dieselbe Regel wie Nachtrag 3/4), ein Undo-Schritt. Der beim Auflösen + geholte Stand füllt den **Ticket-Cache** gleich mit — Fenster und + Abweichungs-Marke (Nachtrag 10) stimmen ohne weiteren Abruf. +- **Zurückgestellt: die durchsuchbare Ticket-Liste** (Taigas + `/search`-Endpunkt hinter einem neuen Proxy-Weg) — mehr Komfort, ein + eigener Bau; das URL-Feld deckt den Arbeitsfluss ab, in dem man das + Ticket ohnehin vor sich hat. + +Der Plan-Knoten `#trk.link` (S) kippte prompt die Größenprüfung des eigenen +Plans (`#trk.taiga` XL mit nun 30+2 ≥ 32, D62) — ehrlich nachgezogen auf +XXL, danach wieder 0 Warnungen (dieselbe Kaskade wie bei D64). + +**Nachgemessen** Ende-zu-Ende im Browser gegen das lokale Backend mit +Taiga-Stub (dessen `by_ref` dafür ref-bewusst wurde — 404 auf Unbekanntes, +sonst wäre die Nummer-Probe nicht prüfbar): Die eingefügte Story-URL zeigt +`#US-123 · Login bauen (Taiga-Fassung)` und sperrt die Projekt-Auswahl; +Verknüpfen schreibt `#US-123` an die Zeile OHNE redundantes Schlagwort +(geerbter Slug = gewählter). Die nackte `1234` läuft über US-404 auf +`#T-1234 · API-Teil`. Die fremde Instanz und die fehlende `999` stehen als +Meldung im Dialog, der Knopf bleibt gesperrt. Eine Ref mit abweichender +Projektwahl schreibt `&taiga.mi-intern #US-123`. Das Fenster des +verknüpften Knotens zeigt den Stand aus dem Cache (0 weitere +`by_ref`-Abfragen im Stub-Mitschnitt) samt der Abweichungs-Knöpfe; zwei +Undos nehmen die beiden Verknüpfungen je in einem Schritt zurück. 628 +Frontend-Tests (11 neue); Gegenproben per Mutation: Kleinschreibung aus dem +Ref-Muster → genau der Normalisierungs-Test fällt, URL-Wächter aus +`foreignTaigaUrl` → genau der danach benannte. diff --git a/docs/SPEC.md b/docs/SPEC.md index 691c2f3..f0e674c 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1416,6 +1416,12 @@ wird sie von selbst die ID. Erstreckt sich ein Plan über **mehrere** Taiga-Projekte, benennt das Schlagwort `&taiga.` (unten) das Projekt je Teilbaum — eine Ref wird gegen das Projekt des nächsten Vorfahren mit so einem Tag aufgelöst. Siehe D91-Nachträge 2 und 3. +An die Zeile kommt die Ref beim **Anlegen** eines Tickets aus dem Knoten +ebenso wie beim **Verknüpfen** mit einem bestehenden — dort genügt als +Eingabe die Taiga-URL des Tickets, die Projekt, Typ und Nummer selbst trägt; +eine nackte Nummer wird per Probe aufgelöst (Story, dann Task — Taigas +Zähler ist je Projekt gemeinsam). Verknüpft wird erst nach Anzeige des +Betreffs (D91-Nachtrag 11). Der **Stand** eines so bezeichneten Tickets (Betreff, Status, Zuständiger) wird im Knoten-Fenster gezeigt (§9); weicht er von der Statusbox ab, sind dort beide Richtungen als ausdrückliche Aktionen zu haben — von selbst diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index 82ea735..fd1e6ae 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -200,7 +200,7 @@ | [?] #idea.drift.fixtures: Both parsers test against the same fixtures (S) | [?] #idea.drift.js: Run the one JS parser inside the IDE (M) - [~] #trk: Tracker integration (XL) %% exactly one of these, hence = - = [~] #trk.taiga: Taiga (XL) https://taiga.io + = [~] #trk.taiga: Taiga (XXL) https://taiga.io - [/] #trk.resolve: Resolve "#US-123" over the REST API (M) - [^] #trk.resolve.read: Read title, link and status (S) - [/] #trk.resolve.map: Map the workflow onto the states (S) @@ -216,6 +216,7 @@ - [^] #trk.create.tasks: Sub-packages become tasks, picked in a dialog (M) :#trk.create.story - [^] #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 + - [x] #trk.link: Link an existing ticket to a node (S) :#trk.resolve.read - [^] #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.tenzu: Tenzu, the successor (XXL) https://tenzu.net @@ -1199,6 +1200,14 @@ on touch. Needs the instance's web address (server configuration, reported by /info) and the inherited &taiga.slug project (D91-Nachtrag 5). +#trk.link + "Link ticket" in the node window binds an EXISTING Taiga ticket to a + node: paste the ticket's URL — it carries project, type and number by + itself — or enter a ref or bare number, resolved by probing story then + task. The subject is shown before anything is written; linking appends + the ref token and, where needed, the &taiga.slug tag, one undo step + (D91-Nachtrag 11). + #trk.create.proxy A few named endpoints in the Werkbaum backend (auth, projects, user stories, tasks) relay to the Taiga instance configured server-side. No diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 3375b8f..06fee74 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -665,7 +665,11 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der Projekt-Dialog samt Häkchen → Anlegen → Ref-Rückschreiben; `taigaTask()` (D91-Nachtrag 9) legt unter einem Story-Vorfahren dialogfrei eine Task an — nur wenn der geerbte Slug am Knoten der des Vorfahren ist, sonst löste - die Ref später gegen das falsche Projekt auf. Geschrieben wird über + die Ref später gegen das falsche Projekt auf; `taigaLink()` + (D91-Nachtrag 11) verknüpft ein BESTEHENDES Ticket — `parseTicketInput` + (URL/Ref/nackte Nummer) und `foreignTaigaUrl` (fremde Instanz) headless, + die nackte Nummer wird per Probe Story-dann-Task aufgelöst, der Betreff + steht im Dialog, bevor Ok freigibt. Geschrieben wird über `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, diff --git a/frontend/src/app.js b/frontend/src/app.js index 8e3c2fe..1a57c5d 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1,7 +1,7 @@ import './style.css'; 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 { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor, collectTicketRefs, bulkPath, ticketDiverges, refVisibleInLabel } from './taiga.js'; +import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, refParts, ticketUrl, ticketRefAt, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor, collectTicketRefs, bulkPath, ticketDiverges, refVisibleInLabel, parseTicketInput, foreignTaigaUrl } from './taiga.js'; import { esc, renderTreeHtml, TIP_RULE } from './render.js'; import { formatWarning, warningText } from './warnings.js'; import * as live from './live.js'; @@ -1868,6 +1868,7 @@ function appendTaigaActions(el){ const slugs = taigaSlugs(roots); if(anc && slugs.get(node) && slugs.get(node) === slugs.get(anc.node)) wrap.appendChild(mk(t('taigaTaskBtn'), taigaTask)); + wrap.appendChild(mk(t('taigaLinkBtn'), taigaLink)); nodeTipBody.appendChild(wrap); } @@ -2430,6 +2431,128 @@ async function taigaTask(line){ } } +/* „Ticket verknüpfen" (D91-Nachtrag 11): ein BESTEHENDES Taiga-Ticket an den + Knoten binden. Ein Feld nimmt die Taiga-URL — sie trägt Slug, Typ und + Nummer selbst, die Projekt-Frage entfällt — oder eine Ref/Nummer; dann + kommt das Projekt aus der Auswahl (vorbelegt aus dem geerbten + `&taiga.`). Eine nackte Nummer wird per Probe aufgelöst (Story, dann + Task — Taigas Zähler ist je Projekt gemeinsam, genau einer trifft). + + VOR dem Schreiben wird das Ticket geholt und der Betreff gezeigt: + Verknüpft wird, was man gesehen hat, nicht eine blind eingetragene Nummer. + Geschrieben wird wie bei der Anlage — Ref-Token plus ggf. `&taiga.`, + ein Undo-Schritt —, und der geholte Stand füllt den Ticket-Cache gleich + mit, sodass Fenster und Abweichungs-Marke ohne weiteren Abruf stimmen. */ +async function taigaLink(line){ + const info = nodeAndRootsByLine(line); + const node = info.node; + if(!node || ticketRefOf(node)) return; + const session = await taigaEnsureSession(); + if(!session) return; + const inherited = taigaSlugs(info.roots).get(node) || null; + + const dlg = taigaModal(t('taigaLinkTitle'), t('taigaLinkDo')); + const subject = document.createElement('p'); + subject.className = 'taiga-subject'; + subject.textContent = node.label; + const input = document.createElement('input'); + input.type = 'text'; + const labIn = document.createElement('label'); + labIn.className = 'taiga-lab'; + const inSpan = document.createElement('span'); + inSpan.textContent = t('taigaLinkHint'); + labIn.append(inSpan, input); + const sel = document.createElement('select'); + sel.disabled = true; + const labSel = document.createElement('label'); + labSel.className = 'taiga-lab'; + const selSpan = document.createElement('span'); + selSpan.textContent = t('taigaProject'); + labSel.append(selSpan, sel); + const preview = document.createElement('p'); + preview.className = 'taiga-subject'; + dlg.body.append(subject, labIn, labSel, preview); + dlg.ok.disabled = true; + dlg.cancel.addEventListener('click', () => dlg.close()); + + let projects = []; + try{ + projects = await taigaProjects(session); + }catch(err){ dlg.fail(taigaErrText(err)); return; } + for(const p of projects){ + const o = document.createElement('option'); + o.textContent = p.name; + sel.appendChild(o); + } + const vor = inherited ? projects.findIndex(p => p.slug === inherited) : -1; + if(vor >= 0) sel.selectedIndex = vor; + sel.disabled = false; + + /* Auflösen beim Tippen (400 ms Ruhe), nicht erst am Ok — die Bestätigung + per Betreff soll DA sein, bevor man bestätigt. `seq` verwirft die + Antwort eines überholten Abrufs (wer weitertippt, meint das Neuere). */ + let resolved = null, seq = 0, timer = null; + const sag = msg => { dlg.err.textContent = msg; dlg.err.hidden = false; }; + const resolve = async () => { + const my = ++seq; + resolved = null; + dlg.ok.disabled = true; + dlg.err.hidden = true; + preview.textContent = ''; + const p = parseTicketInput(input.value); + if(!p){ sel.disabled = false; return; } + if(foreignTaigaUrl(p, taigaWeb)){ sag(t('taigaLinkForeign')); return; } + /* Eine URL trägt ihr Projekt selbst — die Auswahl tritt zurück. */ + sel.disabled = p.kind === 'url'; + const slug = p.kind === 'url' ? p.slug : (projects[sel.selectedIndex] || {}).slug; + if(!slug) return; + preview.textContent = t('taigaTicketLoading'); + try{ + let ref = p.ref, data; + if(p.kind === 'nr'){ + try{ + ref = 'US-' + p.nr; + data = await taigaFetch(ticketApiPath(ref, slug), null, session.token); + }catch(e1){ + if(!e1 || e1.status !== 404) throw e1; + ref = 'T-' + p.nr; + data = await taigaFetch(ticketApiPath(ref, slug), null, session.token); + } + }else{ + data = await taigaFetch(ticketApiPath(ref, slug), null, session.token); + } + if(my !== seq) return; + resolved = {slug, ref, data}; + preview.textContent = '#' + ref + ' · ' + (data.subject || ''); + dlg.ok.disabled = false; + }catch(err){ + if(my !== seq) return; + preview.textContent = ''; + if(err && err.status === 404){ + sag(t('taigaLinkNotFound', {ref: '#' + (p.kind === 'nr' ? p.nr : p.ref), slug})); + }else if(err && err.status === 401){ + storeTaigaSession(null); + dlg.fail(taigaErrText(err)); + }else{ + dlg.fail(taigaErrText(err)); + } + } + }; + input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(resolve, 400); }); + sel.addEventListener('change', resolve); + dlg.ok.addEventListener('click', () => { + if(!resolved) return; + const p = refParts(resolved.ref); + /* Dieselbe Schlagwort-Regel wie beim Anlegen (D91-Nachtrag 3/4). */ + const ownTag = (node.marks || []).some(m => m.startsWith('taiga.')); + const tokens = (!ownTag && inherited !== resolved.slug) ? [slugToken(resolved.slug)] : []; + writeLineTokens(node.line, tokens.concat(refToken(p.kind, p.nr))); + taigaTickets.set(resolved.slug + '/' + resolved.ref, {kind: 'ok', data: resolved.data}); + dlg.close(); + }); + input.focus(); +} + /* Welcher Knoten gehört zu einer Textzeile? Zuerst der Knoten, DER auf dieser Zeile steht; sonst der Knoten, dessen **Beschreibung** hier steht (SPEC §9): Eine `"`-Zeile und die Zeilen eines `---`-Blocks tragen keinen eigenen @@ -3144,7 +3267,7 @@ const I18N = { acHint:"{n} ID-Vorschläge – ↑/↓ wählt, Enter übernimmt", tipClose:"Schließen", tipOpenLink:"Link öffnen", - 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}", + 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", taigaLinkBtn:"Taiga: Ticket verknüpfen", taigaLinkTitle:"Taiga-Ticket verknüpfen", taigaLinkDo:"Verknüpfen", taigaLinkHint:"Taiga-URL oder Ref (US-123, T-45, 123)", taigaLinkNotFound:"Kein Ticket {ref} im Projekt „{slug}“.", taigaLinkForeign:"Die Adresse gehört zu einer anderen Taiga-Instanz.",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/<uuid>)?", 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?", @@ -3281,7 +3404,7 @@ const I18N = { acHint:"{n} id suggestions – ↑/↓ to choose, Enter to insert", tipClose:"Close", tipOpenLink:"Open link", - 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}", + 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", taigaLinkBtn:"Taiga: link ticket", taigaLinkTitle:"Link Taiga ticket", taigaLinkDo:"Link", taigaLinkHint:"Taiga URL or ref (US-123, T-45, 123)", taigaLinkNotFound:"No ticket {ref} in project “{slug}”.", taigaLinkForeign:"That address belongs to a different Taiga instance.",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/<uuid>)?", liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.", liveConflictText:"Someone changed the same lines. Whose version should win?", @@ -3417,7 +3540,7 @@ const I18N = { acHint:"{n} sugerencias de ID – ↑/↓ elige, Intro inserta", tipClose:"Cerrar", tipOpenLink:"Abrir enlace", - 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}", + 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", taigaLinkBtn:"Taiga: vincular ticket", taigaLinkTitle:"Vincular ticket de Taiga", taigaLinkDo:"Vincular", taigaLinkHint:"URL de Taiga o ref (US-123, T-45, 123)", taigaLinkNotFound:"No hay ticket {ref} en el proyecto «{slug}».", taigaLinkForeign:"La dirección pertenece a otra instancia de 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/<uuid>)?", 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?", @@ -3553,7 +3676,7 @@ const I18N = { acHint:"{n} suggestions d'ID – ↑/↓ pour choisir, Entrée pour insérer", tipClose:"Fermer", tipOpenLink:"Ouvrir le lien", - 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}", + 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", taigaLinkBtn:"Taiga : lier un ticket", taigaLinkTitle:"Lier un ticket Taiga", taigaLinkDo:"Lier", taigaLinkHint:"URL Taiga ou réf (US-123, T-45, 123)", taigaLinkNotFound:"Pas de ticket {ref} dans le projet « {slug} ».", taigaLinkForeign:"L'adresse appartient à une autre instance 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/<uuid>) ?", 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 ?", @@ -3689,7 +3812,7 @@ const I18N = { acHint:"{n} podpowiedzi ID – ↑/↓ wybiera, Enter wstawia", tipClose:"Zamknij", tipOpenLink:"Otwórz link", - 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}", + 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", taigaLinkBtn:"Taiga: powiąż zgłoszenie", taigaLinkTitle:"Powiąż zgłoszenie Taigi", taigaLinkDo:"Powiąż", taigaLinkHint:"URL Taigi lub ref (US-123, T-45, 123)", taigaLinkNotFound:"Brak zgłoszenia {ref} w projekcie „{slug}”.", taigaLinkForeign:"Adres należy do innej instancji Taigi.",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/<uuid>)?", 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ć?", @@ -3825,7 +3948,7 @@ const I18N = { acHint:"{n} подсказок ID – ↑/↓ выбирает, Enter вставляет", tipClose:"Закрыть", tipOpenLink:"Открыть ссылку", - 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}", + taigaStoryBtn:"Taiga: создать историю", taigaTaskBtn:"Taiga: создать задачу", taigaTaskNoProject:"Нет доступа к проекту «{slug}».", taigaLoginTitle:"Вход в Taiga", taigaUser:"Имя пользователя", taigaPass:"Пароль", taigaLoginDo:"Войти", taigaCancel:"Отмена", taigaProjectTitle:"Создать тикет в Taiga", taigaProject:"Проект", taigaTasksPick:"Создать подпакеты как задачи:", taigaCreateDo:"Создать", taigaError:"Не удалось: {error}", taigaOpenBtn:"Открыть {ref} в Taiga", taigaLinkBtn:"Taiga: связать тикет", taigaLinkTitle:"Связать тикет Taiga", taigaLinkDo:"Связать", taigaLinkHint:"URL Taiga или реф (US-123, T-45, 123)", taigaLinkNotFound:"Тикета {ref} нет в проекте «{slug}».", taigaLinkForeign:"Адрес относится к другой инстанции Taiga.",taigaTicketDiff:"расходится с планом:", taigaTicketNoBox:"без статуса", taigaTicketPush:"записать в Taiga", taigaTicketPull:"взять из Taiga", taigaTicketWriting:"запись …", taigaTicketNoMap:"У этого состояния нет соответствия в Taiga — ничего не записано.", taigaTicketNoColumn:"В этом проекте нет колонки «{name}».", taigaTicketFetch:"Получить статус", taigaTicketReload:"Обновить статус", taigaTicketLoading:"Получение статуса …", taigaTicketAssignee:"Назначено: {name}", liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/<uuid>)?", liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.", liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?", @@ -3961,7 +4084,7 @@ const I18N = { acHint:"{n} आईडी सुझाव – ↑/↓ से चुनें, Enter से डालें", tipClose:"बंद करें", tipOpenLink:"लिंक खोलें", - 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}", + taigaStoryBtn:"Taiga: स्टोरी बनाएँ", taigaTaskBtn:"Taiga: टास्क बनाएँ", taigaTaskNoProject:"प्रोजेक्ट “{slug}” तक पहुँच नहीं है।", taigaLoginTitle:"Taiga में साइन इन करें", taigaUser:"उपयोगकर्ता नाम", taigaPass:"पासवर्ड", taigaLoginDo:"साइन इन", taigaCancel:"रद्द करें", taigaProjectTitle:"Taiga टिकट बनाएँ", taigaProject:"प्रोजेक्ट", taigaTasksPick:"उप-पैकेज टास्क के रूप में बनाएँ:", taigaCreateDo:"बनाएँ", taigaError:"विफल: {error}", taigaOpenBtn:"Taiga में {ref} खोलें", taigaLinkBtn:"Taiga: टिकट लिंक करें", taigaLinkTitle:"Taiga टिकट लिंक करें", taigaLinkDo:"लिंक करें", taigaLinkHint:"Taiga URL या रेफ़ (US-123, T-45, 123)", taigaLinkNotFound:"प्रोजेक्ट “{slug}” में टिकट {ref} नहीं है।", taigaLinkForeign:"यह पता किसी और Taiga इंस्टेंस का है।",taigaTicketDiff:"योजना से भिन्न:", taigaTicketNoBox:"स्थिति-बॉक्स नहीं", taigaTicketPush:"Taiga में लिखें", taigaTicketPull:"Taiga से लें", taigaTicketWriting:"लिखा जा रहा है …", taigaTicketNoMap:"इस स्थिति का Taiga में कोई समकक्ष नहीं है — कुछ नहीं लिखा जाता।", taigaTicketNoColumn:"इस प्रोजेक्ट में “{name}” कॉलम नहीं है।", taigaTicketFetch:"स्थिति लाएँ", taigaTicketReload:"फिर से लाएँ", taigaTicketLoading:"स्थिति लाई जा रही है …", taigaTicketAssignee:"ज़िम्मेदार: {name}", liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/<uuid>)?", liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।", liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?", @@ -4108,7 +4231,7 @@ const I18N = { acHint:"{n} 个 ID 建议 – ↑/↓ 选择,Enter 插入", tipClose:"关闭", tipOpenLink:"打开链接", - 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}", + taigaStoryBtn:"Taiga:创建故事", taigaTaskBtn:"Taiga:创建任务", taigaTaskNoProject:"无法访问项目「{slug}」。", taigaLoginTitle:"登录 Taiga", taigaUser:"用户名", taigaPass:"密码", taigaLoginDo:"登录", taigaCancel:"取消", taigaProjectTitle:"创建 Taiga 工单", taigaProject:"项目", taigaTasksPick:"将子包创建为任务:", taigaCreateDo:"创建", taigaError:"失败:{error}", taigaOpenBtn:"在 Taiga 中打开 {ref}", taigaLinkBtn:"Taiga:关联工单", taigaLinkTitle:"关联 Taiga 工单", taigaLinkDo:"关联", taigaLinkHint:"Taiga 网址或编号(US-123、T-45、123)", taigaLinkNotFound:"项目「{slug}」中没有工单 {ref}。", taigaLinkForeign:"该地址属于另一个 Taiga 实例。",taigaTicketDiff:"与计划不一致:", taigaTicketNoBox:"没有状态框", taigaTicketPush:"写入 Taiga", taigaTicketPull:"从 Taiga 取用", taigaTicketWriting:"正在写入 …", taigaTicketNoMap:"该状态在 Taiga 中没有对应项 — 不会写入任何内容。", taigaTicketNoColumn:"本项目中没有「{name}」这一列。", taigaTicketFetch:"获取状态", taigaTicketReload:"重新获取", taigaTicketLoading:"正在获取状态 …", taigaTicketAssignee:"负责人:{name}", liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/<uuid>)吗?", liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。", liveConflictText:"有人改动了同样的行。以谁的版本为准?", @@ -4244,7 +4367,7 @@ const I18N = { acHint:"ID候補 {n} 件 – ↑/↓で選択、Enterで挿入", tipClose:"閉じる", tipOpenLink:"リンクを開く", - 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}", + taigaStoryBtn:"Taiga: ストーリーを作成", taigaTaskBtn:"Taiga: タスクを作成", taigaTaskNoProject:"プロジェクト「{slug}」にアクセスできません。", taigaLoginTitle:"Taiga にログイン", taigaUser:"ユーザー名", taigaPass:"パスワード", taigaLoginDo:"ログイン", taigaCancel:"キャンセル", taigaProjectTitle:"Taiga チケットを作成", taigaProject:"プロジェクト", taigaTasksPick:"サブパッケージをタスクとして作成:", taigaCreateDo:"作成", taigaError:"失敗: {error}", taigaOpenBtn:"Taiga で {ref} を開く", taigaLinkBtn:"Taiga: チケットを紐付け", taigaLinkTitle:"Taiga チケットを紐付け", taigaLinkDo:"紐付ける", taigaLinkHint:"Taiga の URL または参照番号 (US-123, T-45, 123)", taigaLinkNotFound:"プロジェクト「{slug}」にチケット {ref} はありません。", taigaLinkForeign:"このアドレスは別の Taiga インスタンスのものです。",taigaTicketDiff:"計画と食い違い:", taigaTicketNoBox:"ステータス欄なし", taigaTicketPush:"Taiga に書く", taigaTicketPull:"Taiga から取る", taigaTicketWriting:"書き込み中 …", taigaTicketNoMap:"この状態に対応する列が Taiga にありません — 何も書き込みません。", taigaTicketNoColumn:"このプロジェクトに「{name}」列はありません。", taigaTicketFetch:"状態を取得", taigaTicketReload:"再取得", taigaTicketLoading:"状態を取得中 …", taigaTicketAssignee:"担当: {name}", liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/<uuid>)ですか。", liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。", liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。", diff --git a/frontend/src/taiga.js b/frontend/src/taiga.js index fc68513..4af2503 100644 --- a/frontend/src/taiga.js +++ b/frontend/src/taiga.js @@ -44,6 +44,45 @@ export function ticketUrl(web, slug, ref){ return web + '/project/' + slug + '/' + (p.kind === 'US' ? 'us' : 'task') + '/' + p.nr; } +/* Die Eingabe des Verknüpfen-Dialogs (D91-Nachtrag 11): Was hat der Nutzer + da eingefügt? Drei Formen, absteigend nach Vollständigkeit — + - die **Taiga-URL** eines Tickets (die Adresszeile, `ticketUrl` rückwärts): + sie trägt Slug, Typ und Nummer selbst, keine weitere Frage; + - eine **Ref** mit Typ-Präfix (`US-123`/`T-45`, `#` und Kleinschreibung + erlaubt — normalisiert wird auf die Schreibweise der Tokens); + - eine **nackte Nummer** (`123`/`#123`, so zeigt Taiga sie): der Typ ist + offen und wird per Probe aufgelöst — Taigas Zähler läuft je Projekt über + beide Typen gemeinsam, genau einer trifft. + Alles andere ist null — der Dialog bleibt dann still (Tipp-Zwischenstände + sind kein Fehler). */ +export function parseTicketInput(text){ + const s = (text || '').trim(); + if(!s) return null; + if(/^https?:\/\//i.test(s)){ + let u; + try{ u = new URL(s); }catch(_){ return null; } + const m = /^\/project\/([^/]+)\/(us|task)\/(\d+)\/?$/.exec(u.pathname); + if(!m) return null; + return {kind: 'url', origin: u.origin, slug: m[1], + ref: (m[2] === 'us' ? 'US-' : 'T-') + m[3]}; + } + const r = /^#?(us|t)-(\d+)$/i.exec(s); + if(r) return {kind: 'ref', ref: r[1].toUpperCase() + '-' + r[2]}; + const n = /^#?(\d+)$/.exec(s); + if(n) return {kind: 'nr', nr: n[1]}; + return null; +} + +/* Gehört eine eingefügte Ticket-URL zu einer ANDEREN Taiga-Instanz als der + konfigurierten Web-Basis? Dann wäre die Verknüpfung eine Lüge: Slug und + Nummer würden gegen UNSERE Instanz aufgelöst und träfen dort womöglich ein + fremdes Ticket gleicher Nummer. Ohne Web-Basis ist nichts zu prüfen — + die Bestätigung per Betreff bleibt dann der Wächter. */ +export function foreignTaigaUrl(parsed, web){ + if(!parsed || parsed.kind !== 'url' || !web) return false; + try{ return parsed.origin !== new URL(web).origin; }catch(_){ return false; } +} + /* Der Pfad am Backend-Proxy zum LESEN eines Tickets (D91-Nachtrag 6), relativ zu `/api/v1/taiga`: zwei benannte Endpunkte statt eines mit Typ-Parameter, weil Taiga getrennte `by_ref`-Endpunkte hat. Der Slug diff --git a/frontend/tests/taiga.test.js b/frontend/tests/taiga.test.js index 98bf71e..58a3957 100644 --- a/frontend/tests/taiga.test.js +++ b/frontend/tests/taiga.test.js @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { parse } from '../src/parser.js'; import { taigaSlugs } from '../src/model.js'; -import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, refParts, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor, collectTicketRefs, bulkPath, ticketDiverges, refVisibleInLabel } from '../src/taiga.js'; +import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, refParts, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath, storyAncestor, collectTicketRefs, bulkPath, ticketDiverges, refVisibleInLabel, parseTicketInput, foreignTaigaUrl } from '../src/taiga.js'; import { setStatusBox } from '../src/parser.js'; /* Schlagworte `&tag` (SPEC §1, D91): Extraktion im Parser und die @@ -508,3 +508,56 @@ describe('setStatusBox — die Statusbox im Text setzen (SPEC §1)', () => { expect(ticketRefOf(n)).toBe('US-1'); }); }); + +/* „Ticket verknüpfen" (D91-Nachtrag 11): die Eingabe des Dialogs — Taiga-URL, + Ref mit Präfix oder nackte Nummer — und die Fremd-Instanz-Prüfung. */ + +describe('parseTicketInput — URL, Ref oder Nummer (D91-Nachtrag 11)', () => { + it('liest eine Story-URL: Slug, Typ und Nummer kommen aus dem Pfad', () => { + expect(parseTicketInput('https://plan.example.test/project/mi-kunde/us/123')) + .toEqual({kind: 'url', origin: 'https://plan.example.test', slug: 'mi-kunde', ref: 'US-123'}); + }); + it('liest eine Task-URL', () => { + expect(parseTicketInput('https://plan.example.test/project/mi-kunde/task/45')) + .toEqual({kind: 'url', origin: 'https://plan.example.test', slug: 'mi-kunde', ref: 'T-45'}); + }); + it('verträgt Schrägstrich am Ende und Query — die Adresszeile, wie sie kommt', () => { + expect(parseTicketInput('https://plan.example.test/project/p/us/7/?kanban-status=1').ref).toBe('US-7'); + }); + it('lehnt fremde Taiga-Pfade ab (Kanban, Backlog, Epics)', () => { + expect(parseTicketInput('https://plan.example.test/project/p/kanban')).toBeNull(); + expect(parseTicketInput('https://plan.example.test/project/p/epic/3')).toBeNull(); + }); + it('liest eine Ref mit Präfix, # und Kleinschreibung erlaubt, normalisiert', () => { + expect(parseTicketInput('US-123')).toEqual({kind: 'ref', ref: 'US-123'}); + expect(parseTicketInput('#us-123')).toEqual({kind: 'ref', ref: 'US-123'}); + expect(parseTicketInput(' t-45 ')).toEqual({kind: 'ref', ref: 'T-45'}); + }); + it('liest eine nackte Nummer — der Typ bleibt offen (Probe im Dialog)', () => { + expect(parseTicketInput('123')).toEqual({kind: 'nr', nr: '123'}); + expect(parseTicketInput('#123')).toEqual({kind: 'nr', nr: '123'}); + }); + it('Tipp-Zwischenstände und Fremdes sind null, kein Fehler', () => { + expect(parseTicketInput('')).toBeNull(); + expect(parseTicketInput('US-')).toBeNull(); + expect(parseTicketInput('Backend')).toBeNull(); + expect(parseTicketInput('ftp://plan.example.test/project/p/us/1')).toBeNull(); + }); +}); + +describe('foreignTaigaUrl — fremde Instanz wird benannt, nicht verknüpft', () => { + const url = parseTicketInput('https://andere.example.org/project/p/us/1'); + it('erkennt die fremde Herkunft gegen die konfigurierte Web-Basis', () => { + expect(foreignTaigaUrl(url, 'https://plan.example.test')).toBe(true); + }); + it('dieselbe Herkunft ist keine fremde', () => { + const eigene = parseTicketInput('https://plan.example.test/project/p/us/1'); + expect(foreignTaigaUrl(eigene, 'https://plan.example.test')).toBe(false); + }); + it('ohne Web-Basis ist nichts zu prüfen — die Betreff-Bestätigung wacht', () => { + expect(foreignTaigaUrl(url, null)).toBe(false); + }); + it('gilt nur für URLs — Ref und Nummer haben keine Herkunft', () => { + expect(foreignTaigaUrl(parseTicketInput('US-1'), 'https://plan.example.test')).toBe(false); + }); +});