feat(taiga): Ticket verknüpfen — URL/Ref-Feld mit Betreff-Bestätigung (D91-Nachtrag 11)

Ein Knopf im Knoten-Fenster bindet ein bestehendes Taiga-Ticket an den
Knoten: Die eingefügte Ticket-URL trägt Slug, Typ und Nummer selbst; eine
nackte Nummer wird per Probe Story-dann-Task aufgelöst. Verknüpft wird erst
nach Anzeige des Betreffs; geschrieben werden Ref-Token und ggf.
&taiga.<slug> in einem Undo-Schritt, der geholte Stand füllt den
Ticket-Cache. Eine URL einer fremden Instanz wird benannt statt verknüpft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-28 12:48:32 +02:00
co-authored by Claude Opus 5
parent ce657e74c1
commit 1a33ebdac8
8 changed files with 313 additions and 13 deletions
+4
View File
@@ -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
+62
View File
@@ -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/<slug>/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.<slug>`, 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.
+6
View File
@@ -1416,6 +1416,12 @@ wird sie von selbst die ID. Erstreckt sich ein Plan über **mehrere**
Taiga-Projekte, benennt das Schlagwort `&taiga.<slug>` (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
+10 -1
View File
@@ -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
+5 -1
View File
@@ -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,
+133 -10
View File
@@ -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.<slug>`). 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.<slug>`,
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/&lt;uuid&gt;)?",
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/&lt;uuid&gt;)?",
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/&lt;uuid&gt;)?",
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/&lt;uuid&gt;) ?",
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/&lt;uuid&gt;)?",
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/&lt;uuid&gt;)?",
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/&lt;uuid&gt;)?",
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/&lt;uuid&gt;)吗?",
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/&lt;uuid&gt;)ですか。",
liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。",
liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。",
+39
View File
@@ -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
+54 -1
View File
@@ -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);
});
});