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:
co-authored by
Claude Opus 5
parent
ce657e74c1
commit
1a33ebdac8
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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/<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:"同じ行が他の人にも変更されました。どちらの版を採りますか。",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user