feat(editor): Strg+Klick öffnet eine Ticket-Ref in Taiga (D91, SPEC §9)
Im Text (freistehendes #US-123/#T-1234, D67-Ausschlüsse) wie auf dem Knoten; das Knoten-Fenster trägt an Refs einen Öffnen-Knopf — der Weg auf Touch. Adresse aus taigaWeb (/info) + &taiga.slug-Vererbung; in einem :#…-Token gewinnt weiter der Abhängigkeits-Sprung. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
55cade1a65
commit
a22c320909
@@ -19,6 +19,8 @@ reverse.
|
|||||||
|
|
||||||
## 2026-08-27
|
## 2026-08-27
|
||||||
|
|
||||||
|
- Ctrl+click a ticket ref like `#US-123` — in the text or on its node — opens the ticket in Taiga's web UI; the node window carries an open button, which is the way on touch
|
||||||
|
- The backend names the Taiga web address now (`WERKBAUM_TAIGA_WEB_URL`, reported by `/api/v1/info`) — without it, creating tickets still works and opening is simply absent
|
||||||
- A ticket ref like `#US-123` in a node title no longer breaks across two lines at its hyphen
|
- A ticket ref like `#US-123` in a node title no longer breaks across two lines at its hyphen
|
||||||
- Create a Taiga story from a diagram node: the node window offers "create story" and "create story + tasks" — log in once (the token stays in the browser), pick the project, and the ref lands in the line as `#US-123`, undoable
|
- Create a Taiga story from a diagram node: the node window offers "create story" and "create story + tasks" — log in once (the token stays in the browser), pick the project, and the ref lands in the line as `#US-123`, undoable
|
||||||
- Sub-packages become Taiga tasks via a checkbox dialog: required and optional children start checked, in `|`/`=` groups only realized alternatives, done children unchecked, discarded ones absent — each created task writes its `#T-…` ref to its line
|
- Sub-packages become Taiga tasks via a checkbox dialog: required and optional children start checked, in `|`/`=` groups only realized alternatives, done children unchecked, discarded ones absent — each created task writes its `#T-…` ref to its line
|
||||||
|
|||||||
@@ -1018,6 +1018,18 @@ verknüpft (siehe D25):
|
|||||||
ist schon als `unknownDep` gewarnt). Kein Sprung im Kommentar, im
|
ist schon als `unknownDep` gewarnt). Kein Sprung im Kommentar, im
|
||||||
Beschreibungsteil hinter `---` und innerhalb einer URL; Alt+Klick behält
|
Beschreibungsteil hinter `---` und innerhalb einer URL; Alt+Klick behält
|
||||||
daneben unverändert seine Richtung ins Diagramm. Siehe D67.
|
daneben unverändert seine Richtung ins Diagramm. Siehe D67.
|
||||||
|
- **Text/Diagramm → Taiga:** **Strg+Klick** auf eine **freistehende**
|
||||||
|
Ticket-Referenz (`#US-123`/`#T-1234`, §11) im Text — und ebenso auf einen
|
||||||
|
Knoten, dessen Zeile eine solche Ref trägt — öffnet das Ticket im
|
||||||
|
Taiga-Frontend (neuer Tab, `noopener`); im Knoten-Fenster steht dafür
|
||||||
|
zusätzlich ein Öffnen-Knopf (auf Touch der einzige Weg — dort gibt es kein
|
||||||
|
Strg). Voraussetzung: Das Backend meldet die Web-Basis der Instanz
|
||||||
|
(`GET /info`, `taigaWeb`) und der Teilbaum ist per `&taiga.<slug>` (§1)
|
||||||
|
einem Projekt zugeordnet; fehlt eines, geschieht still nichts (wie beim
|
||||||
|
Abhängigkeits-Sprung). In einem `:#…`-Token ist die Ref **nicht**
|
||||||
|
freistehend — dort behält Strg+Klick den Sprung zur Zeile; die
|
||||||
|
D67-Ausschlüsse (Kommentar, Beschreibungsteil, URL) gelten auch hier.
|
||||||
|
Siehe D91-Nachtrag 5.
|
||||||
- Der **einfache** Klick bleibt unverändert der Link (§6): Ein Knoten mit URL
|
- Der **einfache** Klick bleibt unverändert der Link (§6): Ein Knoten mit URL
|
||||||
öffnet sie weiterhin im neuen Tab. Deshalb Alt und nicht der einfache Klick.
|
öffnet sie weiterhin im neuen Tab. Deshalb Alt und nicht der einfache Klick.
|
||||||
- **Auffindbarkeit:** Solange **Alt gedrückt** ist, zeigen alle Knoten den
|
- **Auffindbarkeit:** Solange **Alt gedrückt** ist, zeigen alle Knoten den
|
||||||
|
|||||||
+83
-19
@@ -1,7 +1,7 @@
|
|||||||
import './style.css';
|
import './style.css';
|
||||||
import { parse, setFoldMark, expandShortIds, shortIdClosed } from './parser.js';
|
import { parse, setFoldMark, expandShortIds, shortIdClosed } from './parser.js';
|
||||||
import { computeCheapPlan, overloadedAssignee, assigneeLoads, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, presetFoldSet, personFoldSet, allTags, lineTargets, taigaSlugs } from './model.js';
|
import { computeCheapPlan, overloadedAssignee, assigneeLoads, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, presetFoldSet, personFoldSet, allTags, lineTargets, taigaSlugs } from './model.js';
|
||||||
import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken } from './taiga.js';
|
import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt } from './taiga.js';
|
||||||
import { esc, renderTreeHtml, TIP_RULE } from './render.js';
|
import { esc, renderTreeHtml, TIP_RULE } from './render.js';
|
||||||
import { formatWarning, warningText } from './warnings.js';
|
import { formatWarning, warningText } from './warnings.js';
|
||||||
import * as live from './live.js';
|
import * as live from './live.js';
|
||||||
@@ -1717,8 +1717,8 @@ document.querySelector('.diagram').addEventListener('scroll', closeNodeTip, {pas
|
|||||||
Ref an der Zeile ist der Idempotenz-Marker (D91-Nachtrag 2). Die Regeln
|
Ref an der Zeile ist der Idempotenz-Marker (D91-Nachtrag 2). Die Regeln
|
||||||
(Vorbelegung, Token-Anhängen) liegen headless in taiga.js. */
|
(Vorbelegung, Token-Anhängen) liegen headless in taiga.js. */
|
||||||
const LS_TAIGA = 'werkbaum-taiga';
|
const LS_TAIGA = 'werkbaum-taiga';
|
||||||
let taigaBase = null, taigaOn = false;
|
let taigaBase = null, taigaOn = false, taigaWeb = null;
|
||||||
const taigaProbeCache = new Map(); /* Basis -> Promise<boolean> (je Basis EIN /info) */
|
const taigaProbeCache = new Map(); /* Basis -> Promise<{on, web}> (je Basis EIN /info) */
|
||||||
|
|
||||||
/* Die Taiga-Sitzung des Browsers: Token samt Benutzer-Id, wie vom Proxy
|
/* Die Taiga-Sitzung des Browsers: Token samt Benutzer-Id, wie vom Proxy
|
||||||
geliefert. Nur das Token — nie ein Passwort — und löschbar durch Abmelden
|
geliefert. Nur das Token — nie ein Passwort — und löschbar durch Abmelden
|
||||||
@@ -1744,13 +1744,18 @@ async function refreshTaiga(){
|
|||||||
const basis = live.serverBase(urlParam(SERVER_PARAM) || gemerkt, offen, location.href);
|
const basis = live.serverBase(urlParam(SERVER_PARAM) || gemerkt, offen, location.href);
|
||||||
taigaBase = basis;
|
taigaBase = basis;
|
||||||
taigaOn = false;
|
taigaOn = false;
|
||||||
|
taigaWeb = null;
|
||||||
if(!basis) return;
|
if(!basis) return;
|
||||||
if(!taigaProbeCache.has(basis)){
|
if(!taigaProbeCache.has(basis)){
|
||||||
taigaProbeCache.set(basis,
|
taigaProbeCache.set(basis,
|
||||||
fetchJson(live.infoUrl(basis)).then(i => !!(i && i.taiga)).catch(() => false));
|
fetchJson(live.infoUrl(basis))
|
||||||
|
/* `taigaWeb` (D91-Nachtrag 5): die Web-Basis der Instanz für die
|
||||||
|
Ticket-Links — fehlt sie, entfällt nur das Öffnen, nicht die Anlage. */
|
||||||
|
.then(i => ({on: !!(i && i.taiga), web: (i && i.taigaWeb) || null}))
|
||||||
|
.catch(() => ({on: false, web: null})));
|
||||||
}
|
}
|
||||||
const on = await taigaProbeCache.get(basis);
|
const info = await taigaProbeCache.get(basis);
|
||||||
if(taigaBase === basis) taigaOn = on;
|
if(taigaBase === basis){ taigaOn = info.on; taigaWeb = info.web; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function taigaFetch(path, options, token){
|
function taigaFetch(path, options, token){
|
||||||
@@ -1781,14 +1786,41 @@ function nodeAndRootsByLine(line){
|
|||||||
|
|
||||||
/* Die Aktions-Knöpfe im Knoten-Fenster (aufgerufen aus showNodeTip).
|
/* Die Aktions-Knöpfe im Knoten-Fenster (aufgerufen aus showNodeTip).
|
||||||
`tabindex="-1"` wie × und ↗ — das Fenster ist aria-hidden (D57). */
|
`tabindex="-1"` wie × und ↗ — das Fenster ist aria-hidden (D57). */
|
||||||
|
/* Die Taiga-Adresse des Knotens einer Zeile (D91-Nachtrag 5): Ref von der
|
||||||
|
Zeile, Slug vom nächsten Vorfahren mit `&taiga.*`-Tag (SPEC §1), Web-Basis
|
||||||
|
aus `GET /info`. null, wo eines davon fehlt — dann gibt es nichts zu
|
||||||
|
öffnen, still (dieselbe Haltung wie beim Abhängigkeits-Sprung, D67). */
|
||||||
|
function taigaTicketUrl(lineNo){
|
||||||
|
if(!taigaWeb) return null;
|
||||||
|
const {node, roots} = nodeAndRootsByLine(lineNo);
|
||||||
|
if(!node) return null;
|
||||||
|
return ticketUrl(taigaWeb, taigaSlugs(roots).get(node), ticketRefOf(node));
|
||||||
|
}
|
||||||
|
|
||||||
function appendTaigaActions(el){
|
function appendTaigaActions(el){
|
||||||
if(!taigaOn) return;
|
if(!taigaOn) return;
|
||||||
const line = +el.dataset.line;
|
const line = +el.dataset.line;
|
||||||
if(!line) return;
|
if(!line) return;
|
||||||
const {node} = nodeAndRootsByLine(line);
|
const {node, roots} = nodeAndRootsByLine(line);
|
||||||
if(!node || ticketRefOf(node)) return;
|
if(!node) return;
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'nodetip-taiga';
|
wrap.className = 'nodetip-taiga';
|
||||||
|
const ref = ticketRefOf(node);
|
||||||
|
if(ref){
|
||||||
|
/* Der Knoten IST schon angelegt (Idempotenz-Marker) — statt der
|
||||||
|
Anlage-Knöpfe gibt es den Weg zum Ticket. Als Knopf im Fenster ist er
|
||||||
|
auch auf Touch erreichbar (dort gibt es kein Strg) und macht die
|
||||||
|
Strg+Klick-Geste nebenbei auffindbar (D25-Lehre). */
|
||||||
|
const url = ticketUrl(taigaWeb, taigaSlugs(roots).get(node), ref);
|
||||||
|
if(!url) return;
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button'; b.className = 'nodetip-taigabtn'; b.tabIndex = -1;
|
||||||
|
b.textContent = '↗ ' + t('taigaOpenBtn', {ref: '#' + ref});
|
||||||
|
b.addEventListener('click', () => { closeNodeTip(); window.open(url, '_blank', 'noopener'); });
|
||||||
|
wrap.appendChild(b);
|
||||||
|
nodeTipBody.appendChild(wrap);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const mk = (label, mitTasks) => {
|
const mk = (label, mitTasks) => {
|
||||||
const b = document.createElement('button');
|
const b = document.createElement('button');
|
||||||
b.type = 'button'; b.className = 'nodetip-taigabtn'; b.tabIndex = -1;
|
b.type = 'button'; b.className = 'nodetip-taigabtn'; b.tabIndex = -1;
|
||||||
@@ -1801,6 +1833,22 @@ function appendTaigaActions(el){
|
|||||||
nodeTipBody.appendChild(wrap);
|
nodeTipBody.appendChild(wrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Strg+Klick (macOS auch Cmd) auf einen Knoten mit Ticket-Referenz öffnet
|
||||||
|
das Ticket im Taiga-Frontend (D91-Nachtrag 5) — dieselbe Geste wie im
|
||||||
|
Text. Der einfache Klick bleibt der Link (§6), Alt der Sprung (D25); ohne
|
||||||
|
Taiga-Adresse bleibt auch der Browser-Default (Strg+Klick auf einen
|
||||||
|
Link-Knoten öffnet dessen URL im Hintergrund-Tab) unangetastet. */
|
||||||
|
out.addEventListener('click', e => {
|
||||||
|
if(e.altKey || !(e.ctrlKey || e.metaKey)) return;
|
||||||
|
const el = nodeFromEvent(e);
|
||||||
|
if(!el || !el.dataset.line) return;
|
||||||
|
const url = taigaTicketUrl(+el.dataset.line);
|
||||||
|
if(!url) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
window.open(url, '_blank', 'noopener');
|
||||||
|
});
|
||||||
|
|
||||||
/* Kleiner modaler Dialog im tabmodal-Idiom (D89). Fehler erscheinen IM
|
/* Kleiner modaler Dialog im tabmodal-Idiom (D89). Fehler erscheinen IM
|
||||||
Dialog statt als window.alert — der ist in manchen Browser-Kontexten
|
Dialog statt als window.alert — der ist in manchen Browser-Kontexten
|
||||||
unterdrückt (dieselbe Lehre wie beim Inline-Umbenennen, D22). */
|
unterdrückt (dieselbe Lehre wie beim Inline-Umbenennen, D22). */
|
||||||
@@ -2173,13 +2221,29 @@ function jumpToDepTarget(){
|
|||||||
if(line) jumpToLine(line);
|
if(line) jumpToLine(line);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
/* … und auf eine FREISTEHENDE Ticket-Referenz (`#US-123`/`#T-1234`, SPEC
|
||||||
|
§11) öffnet dieselbe Geste das Ticket im Taiga-Frontend (D91-Nachtrag 5) —
|
||||||
|
sofern das Backend die Web-Basis meldet (`taigaWeb`) und der Teilbaum
|
||||||
|
einem `&taiga.*`-Projekt zugeordnet ist. Die Abhängigkeits-Lesart gewinnt:
|
||||||
|
In einem `:#…`-Token ist die Ref nicht freistehend, dort bleibt es beim
|
||||||
|
Sprung zur Zeile. */
|
||||||
|
function openTicketAtCaret(){
|
||||||
|
const hit = ticketRefAt(src.value, src.selectionStart);
|
||||||
|
if(!hit || !taigaWeb) return false;
|
||||||
|
const {node, roots} = nodeAndRootsByLine(hit.line);
|
||||||
|
if(!node) return false;
|
||||||
|
const url = ticketUrl(taigaWeb, taigaSlugs(roots).get(node), hit.ref);
|
||||||
|
if(!url) return false;
|
||||||
|
window.open(url, '_blank', 'noopener');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
src.addEventListener('click', e => {
|
src.addEventListener('click', e => {
|
||||||
if(e.altKey || !(e.ctrlKey || e.metaKey)) return;
|
if(e.altKey || !(e.ctrlKey || e.metaKey)) return;
|
||||||
jumpToDepTarget();
|
if(!jumpToDepTarget()) openTicketAtCaret();
|
||||||
});
|
});
|
||||||
src.addEventListener('keydown', e => {
|
src.addEventListener('keydown', e => {
|
||||||
if(e.key !== 'Enter' || !(e.ctrlKey || e.metaKey) || e.altKey) return;
|
if(e.key !== 'Enter' || !(e.ctrlKey || e.metaKey) || e.altKey) return;
|
||||||
if(jumpToDepTarget()) e.preventDefault();
|
if(jumpToDepTarget() || openTicketAtCaret()) e.preventDefault();
|
||||||
});
|
});
|
||||||
|
|
||||||
/* Alt-Modus sichtbar machen: solange Alt gedrückt ist, zeigt jeder Knoten den
|
/* Alt-Modus sichtbar machen: solange Alt gedrückt ist, zeigt jeder Knoten den
|
||||||
@@ -2704,7 +2768,7 @@ const I18N = {
|
|||||||
acHint:"{n} ID-Vorschläge – ↑/↓ wählt, Enter übernimmt",
|
acHint:"{n} ID-Vorschläge – ↑/↓ wählt, Enter übernimmt",
|
||||||
tipClose:"Schließen",
|
tipClose:"Schließen",
|
||||||
tipOpenLink:"Link öffnen",
|
tipOpenLink:"Link öffnen",
|
||||||
taigaStoryBtn:"Taiga: Story anlegen", taigaTasksBtn:"Taiga: Story + Tasks anlegen", taigaLoginTitle:"Bei Taiga anmelden", taigaUser:"Benutzername", taigaPass:"Passwort", taigaLoginDo:"Anmelden", taigaCancel:"Abbrechen", taigaProjectTitle:"Taiga-Ticket anlegen", taigaProject:"Projekt", taigaTasksPick:"Teilpakete als Tasks anlegen:", taigaCreateDo:"Anlegen", taigaError:"Fehlgeschlagen: {error}",
|
taigaStoryBtn:"Taiga: Story anlegen", taigaTasksBtn:"Taiga: Story + Tasks anlegen", taigaLoginTitle:"Bei Taiga anmelden", taigaUser:"Benutzername", taigaPass:"Passwort", taigaLoginDo:"Anmelden", taigaCancel:"Abbrechen", taigaProjectTitle:"Taiga-Ticket anlegen", taigaProject:"Projekt", taigaTasksPick:"Teilpakete als Tasks anlegen:", taigaCreateDo:"Anlegen", taigaError:"Fehlgeschlagen: {error}", taigaOpenBtn:"{ref} in Taiga öffnen",
|
||||||
liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/<uuid>)?",
|
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.",
|
liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.",
|
||||||
liveConflictText:"Jemand hat dieselben Zeilen geändert. Wessen Fassung soll gelten?",
|
liveConflictText:"Jemand hat dieselben Zeilen geändert. Wessen Fassung soll gelten?",
|
||||||
@@ -2841,7 +2905,7 @@ const I18N = {
|
|||||||
acHint:"{n} id suggestions – ↑/↓ to choose, Enter to insert",
|
acHint:"{n} id suggestions – ↑/↓ to choose, Enter to insert",
|
||||||
tipClose:"Close",
|
tipClose:"Close",
|
||||||
tipOpenLink:"Open link",
|
tipOpenLink:"Open link",
|
||||||
taigaStoryBtn:"Taiga: create story", taigaTasksBtn:"Taiga: create story + tasks", taigaLoginTitle:"Log in to Taiga", taigaUser:"Username", taigaPass:"Password", taigaLoginDo:"Log in", taigaCancel:"Cancel", taigaProjectTitle:"Create Taiga ticket", taigaProject:"Project", taigaTasksPick:"Create sub-packages as tasks:", taigaCreateDo:"Create", taigaError:"Failed: {error}",
|
taigaStoryBtn:"Taiga: create story", taigaTasksBtn:"Taiga: create story + tasks", taigaLoginTitle:"Log in to Taiga", taigaUser:"Username", taigaPass:"Password", taigaLoginDo:"Log in", taigaCancel:"Cancel", taigaProjectTitle:"Create Taiga ticket", taigaProject:"Project", taigaTasksPick:"Create sub-packages as tasks:", taigaCreateDo:"Create", taigaError:"Failed: {error}", taigaOpenBtn:"Open {ref} in Taiga",
|
||||||
liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/<uuid>)?",
|
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.",
|
liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.",
|
||||||
liveConflictText:"Someone changed the same lines. Whose version should win?",
|
liveConflictText:"Someone changed the same lines. Whose version should win?",
|
||||||
@@ -2977,7 +3041,7 @@ const I18N = {
|
|||||||
acHint:"{n} sugerencias de ID – ↑/↓ elige, Intro inserta",
|
acHint:"{n} sugerencias de ID – ↑/↓ elige, Intro inserta",
|
||||||
tipClose:"Cerrar",
|
tipClose:"Cerrar",
|
||||||
tipOpenLink:"Abrir enlace",
|
tipOpenLink:"Abrir enlace",
|
||||||
taigaStoryBtn:"Taiga: crear historia", taigaTasksBtn:"Taiga: crear historia + tareas", taigaLoginTitle:"Iniciar sesión en Taiga", taigaUser:"Usuario", taigaPass:"Contraseña", taigaLoginDo:"Iniciar sesión", taigaCancel:"Cancelar", taigaProjectTitle:"Crear ticket de Taiga", taigaProject:"Proyecto", taigaTasksPick:"Crear subpaquetes como tareas:", taigaCreateDo:"Crear", taigaError:"Error: {error}",
|
taigaStoryBtn:"Taiga: crear historia", taigaTasksBtn:"Taiga: crear historia + tareas", taigaLoginTitle:"Iniciar sesión en Taiga", taigaUser:"Usuario", taigaPass:"Contraseña", taigaLoginDo:"Iniciar sesión", taigaCancel:"Cancelar", taigaProjectTitle:"Crear ticket de Taiga", taigaProject:"Proyecto", taigaTasksPick:"Crear subpaquetes como tareas:", taigaCreateDo:"Crear", taigaError:"Error: {error}", taigaOpenBtn:"Abrir {ref} en Taiga",
|
||||||
liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/<uuid>)?",
|
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.",
|
liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.",
|
||||||
liveConflictText:"Alguien cambió las mismas líneas. ¿Qué versión debe prevalecer?",
|
liveConflictText:"Alguien cambió las mismas líneas. ¿Qué versión debe prevalecer?",
|
||||||
@@ -3113,7 +3177,7 @@ const I18N = {
|
|||||||
acHint:"{n} suggestions d'ID – ↑/↓ pour choisir, Entrée pour insérer",
|
acHint:"{n} suggestions d'ID – ↑/↓ pour choisir, Entrée pour insérer",
|
||||||
tipClose:"Fermer",
|
tipClose:"Fermer",
|
||||||
tipOpenLink:"Ouvrir le lien",
|
tipOpenLink:"Ouvrir le lien",
|
||||||
taigaStoryBtn:"Taiga : créer la story", taigaTasksBtn:"Taiga : créer story + tâches", taigaLoginTitle:"Se connecter à Taiga", taigaUser:"Nom d'utilisateur", taigaPass:"Mot de passe", taigaLoginDo:"Se connecter", taigaCancel:"Annuler", taigaProjectTitle:"Créer un ticket Taiga", taigaProject:"Projet", taigaTasksPick:"Créer les sous-lots comme tâches :", taigaCreateDo:"Créer", taigaError:"Échec : {error}",
|
taigaStoryBtn:"Taiga : créer la story", taigaTasksBtn:"Taiga : créer story + tâches", taigaLoginTitle:"Se connecter à Taiga", taigaUser:"Nom d'utilisateur", taigaPass:"Mot de passe", taigaLoginDo:"Se connecter", taigaCancel:"Annuler", taigaProjectTitle:"Créer un ticket Taiga", taigaProject:"Projet", taigaTasksPick:"Créer les sous-lots comme tâches :", taigaCreateDo:"Créer", taigaError:"Échec : {error}", taigaOpenBtn:"Ouvrir {ref} dans Taiga",
|
||||||
liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/<uuid>) ?",
|
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.",
|
liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.",
|
||||||
liveConflictText:"Quelqu'un a modifié les mêmes lignes. Quelle version doit l'emporter ?",
|
liveConflictText:"Quelqu'un a modifié les mêmes lignes. Quelle version doit l'emporter ?",
|
||||||
@@ -3249,7 +3313,7 @@ const I18N = {
|
|||||||
acHint:"{n} podpowiedzi ID – ↑/↓ wybiera, Enter wstawia",
|
acHint:"{n} podpowiedzi ID – ↑/↓ wybiera, Enter wstawia",
|
||||||
tipClose:"Zamknij",
|
tipClose:"Zamknij",
|
||||||
tipOpenLink:"Otwórz link",
|
tipOpenLink:"Otwórz link",
|
||||||
taigaStoryBtn:"Taiga: utwórz historyjkę", taigaTasksBtn:"Taiga: historyjka + zadania", taigaLoginTitle:"Zaloguj się do Taigi", taigaUser:"Nazwa użytkownika", taigaPass:"Hasło", taigaLoginDo:"Zaloguj", taigaCancel:"Anuluj", taigaProjectTitle:"Utwórz zgłoszenie w Taidze", taigaProject:"Projekt", taigaTasksPick:"Utwórz podpakiety jako zadania:", taigaCreateDo:"Utwórz", taigaError:"Niepowodzenie: {error}",
|
taigaStoryBtn:"Taiga: utwórz historyjkę", taigaTasksBtn:"Taiga: historyjka + zadania", taigaLoginTitle:"Zaloguj się do Taigi", taigaUser:"Nazwa użytkownika", taigaPass:"Hasło", taigaLoginDo:"Zaloguj", taigaCancel:"Anuluj", taigaProjectTitle:"Utwórz zgłoszenie w Taidze", taigaProject:"Projekt", taigaTasksPick:"Utwórz podpakiety jako zadania:", taigaCreateDo:"Utwórz", taigaError:"Niepowodzenie: {error}", taigaOpenBtn:"Otwórz {ref} w Taidze",
|
||||||
liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/<uuid>)?",
|
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.",
|
liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.",
|
||||||
liveConflictText:"Ktoś zmienił te same wiersze. Która wersja ma obowiązywać?",
|
liveConflictText:"Ktoś zmienił te same wiersze. Która wersja ma obowiązywać?",
|
||||||
@@ -3385,7 +3449,7 @@ const I18N = {
|
|||||||
acHint:"{n} подсказок ID – ↑/↓ выбирает, Enter вставляет",
|
acHint:"{n} подсказок ID – ↑/↓ выбирает, Enter вставляет",
|
||||||
tipClose:"Закрыть",
|
tipClose:"Закрыть",
|
||||||
tipOpenLink:"Открыть ссылку",
|
tipOpenLink:"Открыть ссылку",
|
||||||
taigaStoryBtn:"Taiga: создать историю", taigaTasksBtn:"Taiga: история + задачи", taigaLoginTitle:"Вход в Taiga", taigaUser:"Имя пользователя", taigaPass:"Пароль", taigaLoginDo:"Войти", taigaCancel:"Отмена", taigaProjectTitle:"Создать тикет в Taiga", taigaProject:"Проект", taigaTasksPick:"Создать подпакеты как задачи:", taigaCreateDo:"Создать", taigaError:"Не удалось: {error}",
|
taigaStoryBtn:"Taiga: создать историю", taigaTasksBtn:"Taiga: история + задачи", taigaLoginTitle:"Вход в Taiga", taigaUser:"Имя пользователя", taigaPass:"Пароль", taigaLoginDo:"Войти", taigaCancel:"Отмена", taigaProjectTitle:"Создать тикет в Taiga", taigaProject:"Проект", taigaTasksPick:"Создать подпакеты как задачи:", taigaCreateDo:"Создать", taigaError:"Не удалось: {error}", taigaOpenBtn:"Открыть {ref} в Taiga",
|
||||||
liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/<uuid>)?",
|
liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/<uuid>)?",
|
||||||
liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.",
|
liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.",
|
||||||
liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?",
|
liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?",
|
||||||
@@ -3521,7 +3585,7 @@ const I18N = {
|
|||||||
acHint:"{n} आईडी सुझाव – ↑/↓ से चुनें, Enter से डालें",
|
acHint:"{n} आईडी सुझाव – ↑/↓ से चुनें, Enter से डालें",
|
||||||
tipClose:"बंद करें",
|
tipClose:"बंद करें",
|
||||||
tipOpenLink:"लिंक खोलें",
|
tipOpenLink:"लिंक खोलें",
|
||||||
taigaStoryBtn:"Taiga: स्टोरी बनाएँ", taigaTasksBtn:"Taiga: स्टोरी + टास्क बनाएँ", taigaLoginTitle:"Taiga में साइन इन करें", taigaUser:"उपयोगकर्ता नाम", taigaPass:"पासवर्ड", taigaLoginDo:"साइन इन", taigaCancel:"रद्द करें", taigaProjectTitle:"Taiga टिकट बनाएँ", taigaProject:"प्रोजेक्ट", taigaTasksPick:"उप-पैकेज टास्क के रूप में बनाएँ:", taigaCreateDo:"बनाएँ", taigaError:"विफल: {error}",
|
taigaStoryBtn:"Taiga: स्टोरी बनाएँ", taigaTasksBtn:"Taiga: स्टोरी + टास्क बनाएँ", taigaLoginTitle:"Taiga में साइन इन करें", taigaUser:"उपयोगकर्ता नाम", taigaPass:"पासवर्ड", taigaLoginDo:"साइन इन", taigaCancel:"रद्द करें", taigaProjectTitle:"Taiga टिकट बनाएँ", taigaProject:"प्रोजेक्ट", taigaTasksPick:"उप-पैकेज टास्क के रूप में बनाएँ:", taigaCreateDo:"बनाएँ", taigaError:"विफल: {error}", taigaOpenBtn:"Taiga में {ref} खोलें",
|
||||||
liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/<uuid>)?",
|
liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/<uuid>)?",
|
||||||
liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।",
|
liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।",
|
||||||
liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?",
|
liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?",
|
||||||
@@ -3668,7 +3732,7 @@ const I18N = {
|
|||||||
acHint:"{n} 个 ID 建议 – ↑/↓ 选择,Enter 插入",
|
acHint:"{n} 个 ID 建议 – ↑/↓ 选择,Enter 插入",
|
||||||
tipClose:"关闭",
|
tipClose:"关闭",
|
||||||
tipOpenLink:"打开链接",
|
tipOpenLink:"打开链接",
|
||||||
taigaStoryBtn:"Taiga:创建故事", taigaTasksBtn:"Taiga:创建故事和任务", taigaLoginTitle:"登录 Taiga", taigaUser:"用户名", taigaPass:"密码", taigaLoginDo:"登录", taigaCancel:"取消", taigaProjectTitle:"创建 Taiga 工单", taigaProject:"项目", taigaTasksPick:"将子包创建为任务:", taigaCreateDo:"创建", taigaError:"失败:{error}",
|
taigaStoryBtn:"Taiga:创建故事", taigaTasksBtn:"Taiga:创建故事和任务", taigaLoginTitle:"登录 Taiga", taigaUser:"用户名", taigaPass:"密码", taigaLoginDo:"登录", taigaCancel:"取消", taigaProjectTitle:"创建 Taiga 工单", taigaProject:"项目", taigaTasksPick:"将子包创建为任务:", taigaCreateDo:"创建", taigaError:"失败:{error}", taigaOpenBtn:"在 Taiga 中打开 {ref}",
|
||||||
liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/<uuid>)吗?",
|
liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/<uuid>)吗?",
|
||||||
liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。",
|
liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。",
|
||||||
liveConflictText:"有人改动了同样的行。以谁的版本为准?",
|
liveConflictText:"有人改动了同样的行。以谁的版本为准?",
|
||||||
@@ -3804,7 +3868,7 @@ const I18N = {
|
|||||||
acHint:"ID候補 {n} 件 – ↑/↓で選択、Enterで挿入",
|
acHint:"ID候補 {n} 件 – ↑/↓で選択、Enterで挿入",
|
||||||
tipClose:"閉じる",
|
tipClose:"閉じる",
|
||||||
tipOpenLink:"リンクを開く",
|
tipOpenLink:"リンクを開く",
|
||||||
taigaStoryBtn:"Taiga: ストーリーを作成", taigaTasksBtn:"Taiga: ストーリー+タスクを作成", taigaLoginTitle:"Taiga にログイン", taigaUser:"ユーザー名", taigaPass:"パスワード", taigaLoginDo:"ログイン", taigaCancel:"キャンセル", taigaProjectTitle:"Taiga チケットを作成", taigaProject:"プロジェクト", taigaTasksPick:"サブパッケージをタスクとして作成:", taigaCreateDo:"作成", taigaError:"失敗: {error}",
|
taigaStoryBtn:"Taiga: ストーリーを作成", taigaTasksBtn:"Taiga: ストーリー+タスクを作成", taigaLoginTitle:"Taiga にログイン", taigaUser:"ユーザー名", taigaPass:"パスワード", taigaLoginDo:"ログイン", taigaCancel:"キャンセル", taigaProjectTitle:"Taiga チケットを作成", taigaProject:"プロジェクト", taigaTasksPick:"サブパッケージをタスクとして作成:", taigaCreateDo:"作成", taigaError:"失敗: {error}", taigaOpenBtn:"Taiga で {ref} を開く",
|
||||||
liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/<uuid>)ですか。",
|
liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/<uuid>)ですか。",
|
||||||
liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。",
|
liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。",
|
||||||
liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。",
|
liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。",
|
||||||
|
|||||||
@@ -24,6 +24,53 @@ export function ticketRefOf(n){
|
|||||||
return m ? m[1] : null;
|
return m ? m[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Die Adresse eines Tickets im Taiga-FRONTEND (D91-Nachtrag 5): Stories
|
||||||
|
liegen unter `/us/`, Tasks unter `/task/` — das Präfix trägt den Typ,
|
||||||
|
genau dafür schreibt Werkbaum es (SPEC §11). Ohne Web-Basis, Projekt-Slug
|
||||||
|
oder gültige Ref gibt es keine Adresse (null). */
|
||||||
|
export function ticketUrl(web, slug, ref){
|
||||||
|
const m = /^(US|T)-(\d+)$/.exec(ref || '');
|
||||||
|
if(!m || !web || !slug) return null;
|
||||||
|
return web + '/project/' + slug + '/' + (m[1] === 'US' ? 'us' : 'task') + '/' + m[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Ticket-Referenz unter der Schreibmarke (Strg+Klick im Text,
|
||||||
|
D91-Nachtrag 5): ein FREISTEHENDES `#US-123`/`#T-1234`-Token im Baumteil.
|
||||||
|
Dieselben Ausschlüsse wie beim Abhängigkeits-Sprung (D67): nicht im
|
||||||
|
Kommentar, nicht im Beschreibungsteil hinter `---`, nicht innerhalb einer
|
||||||
|
URL. In einer Abhängigkeitsliste (`:#US-123`) ist die Ref nicht
|
||||||
|
freistehend — dort behält Strg+Klick den Sprung zur Zeile. Liefert
|
||||||
|
{ref, line} (Zeile 1-basiert) oder null. */
|
||||||
|
export function ticketRefAt(text, caret){
|
||||||
|
const sol = text.lastIndexOf('\n', caret - 1) + 1;
|
||||||
|
let eol = text.indexOf('\n', caret);
|
||||||
|
if(eol === -1) eol = text.length;
|
||||||
|
const before = text.slice(0, sol);
|
||||||
|
for(const l of before.split('\n')){
|
||||||
|
if(/^\s*-{3,}\s*$/.test(l)) return null;
|
||||||
|
}
|
||||||
|
const line = text.slice(sol, eol);
|
||||||
|
const col = caret - sol;
|
||||||
|
const k = line.indexOf('%%');
|
||||||
|
if(k !== -1 && col >= k) return null;
|
||||||
|
const head = k === -1 ? line : line.slice(0, k);
|
||||||
|
const u = /https?:\/\/\S+/.exec(head);
|
||||||
|
if(u && col > u.index && col < u.index + u[0].length) return null;
|
||||||
|
const re = /(^|\s)#((?:US|T)-\d+)/g;
|
||||||
|
let m;
|
||||||
|
while((m = re.exec(head))){
|
||||||
|
const p = m.index + m[1].length;
|
||||||
|
const end = p + 1 + m[2].length;
|
||||||
|
/* Kein Treffer mitten in einem längeren Token (`#US-123abc`) — hinter
|
||||||
|
den Ziffern darf kein weiteres ID-Zeichen stehen (der Doppelpunkt als
|
||||||
|
Trenner, §1, ist keines). */
|
||||||
|
if(/[\p{L}\p{N}._-]/u.test(head[end] || '')) continue;
|
||||||
|
if(col >= p && col <= end)
|
||||||
|
return {ref: m[2], line: before.split('\n').length};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/* Das Token, das an die Zeile geschrieben wird. */
|
/* Das Token, das an die Zeile geschrieben wird. */
|
||||||
export function refToken(kind, ref){ return '#' + kind + '-' + ref; }
|
export function refToken(kind, ref){ return '#' + kind + '-' + ref; }
|
||||||
export function slugToken(slug){ return '&taiga.' + slug; }
|
export function slugToken(slug){ return '&taiga.' + slug; }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { parse } from '../src/parser.js';
|
import { parse } from '../src/parser.js';
|
||||||
import { taigaSlugs } from '../src/model.js';
|
import { taigaSlugs } from '../src/model.js';
|
||||||
import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken } from '../src/taiga.js';
|
import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt } from '../src/taiga.js';
|
||||||
|
|
||||||
/* Schlagworte `&tag` (SPEC §1, D91): Extraktion im Parser und die
|
/* Schlagworte `&tag` (SPEC §1, D91): Extraktion im Parser und die
|
||||||
`taiga.*`-Vererbung in model.js — der erste Konsument der reservierten
|
`taiga.*`-Vererbung in model.js — der erste Konsument der reservierten
|
||||||
@@ -180,3 +180,65 @@ describe('appendToken — Token ans sichtbare Zeilenende (D91)', () => {
|
|||||||
expect(roots[0].marks).toEqual(['taiga.mi-intern']);
|
expect(roots[0].marks).toEqual(['taiga.mi-intern']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* Ticket-Links (D91-Nachtrag 5): die Adresse im Taiga-Frontend und die
|
||||||
|
Ref-Erkennung unter der Schreibmarke (Strg+Klick). */
|
||||||
|
|
||||||
|
describe('ticketUrl — Adresse im Taiga-Frontend', () => {
|
||||||
|
it('Stories unter /us/, Tasks unter /task/ — das Präfix trägt den Typ', () => {
|
||||||
|
expect(ticketUrl('https://plan.example', 'mi-kunde', 'US-123'))
|
||||||
|
.toBe('https://plan.example/project/mi-kunde/us/123');
|
||||||
|
expect(ticketUrl('https://plan.example', 'mi-kunde', 'T-45'))
|
||||||
|
.toBe('https://plan.example/project/mi-kunde/task/45');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Web-Basis, Slug oder gültige Ref: keine Adresse', () => {
|
||||||
|
expect(ticketUrl(null, 'mi-kunde', 'US-1')).toBe(null);
|
||||||
|
expect(ticketUrl('https://plan.example', null, 'US-1')).toBe(null);
|
||||||
|
expect(ticketUrl('https://plan.example', 'mi-kunde', 'ABC-1')).toBe(null);
|
||||||
|
expect(ticketUrl('https://plan.example', 'mi-kunde', 'US-1x')).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ticketRefAt — die Ref unter der Schreibmarke', () => {
|
||||||
|
const at = (text, needle) => text.indexOf(needle);
|
||||||
|
|
||||||
|
it('trifft ein freistehendes Token, über seine ganze Breite', () => {
|
||||||
|
const text = '- Login bauen #US-123 (M)';
|
||||||
|
const p = at(text, '#US-123');
|
||||||
|
expect(ticketRefAt(text, p)).toEqual({ref: 'US-123', line: 1});
|
||||||
|
expect(ticketRefAt(text, p + 7)).toEqual({ref: 'US-123', line: 1});
|
||||||
|
/* das Leerzeichen davor gehört nicht mehr zum Token */
|
||||||
|
expect(ticketRefAt(text, p - 1)).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nennt die richtige Zeile', () => {
|
||||||
|
const text = '- A\n - B #T-77';
|
||||||
|
expect(ticketRefAt(text, at(text, '#T-77') + 2)).toEqual({ref: 'T-77', line: 2});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mit Trenn-Doppelpunkt dahinter (§1) bleibt es ein Treffer', () => {
|
||||||
|
const text = '- #US-123: Titel dazu';
|
||||||
|
expect(ticketRefAt(text, at(text, 'US') + 1)).toEqual({ref: 'US-123', line: 1});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('in einem Abhängigkeits-Token ist die Ref nicht freistehend', () => {
|
||||||
|
const text = '- A :#US-123';
|
||||||
|
expect(ticketRefAt(text, at(text, 'US') + 1)).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kein Treffer neben dem Token, im Kommentar, in der URL, hinter ---', () => {
|
||||||
|
expect(ticketRefAt('- A #US-1 B', 0)).toBe(null);
|
||||||
|
const c = '- A %% siehe #US-123';
|
||||||
|
expect(ticketRefAt(c, at(c, 'US'))).toBe(null);
|
||||||
|
const u = '- A https://x.example/#US-123 dazu';
|
||||||
|
expect(ticketRefAt(u, at(u, 'US'))).toBe(null);
|
||||||
|
const d = '- A\n---\n#US-123\n Text';
|
||||||
|
expect(ticketRefAt(d, at(d, 'US'))).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kein Treffer mitten in einem längeren Token', () => {
|
||||||
|
const text = '- A #US-123abc B';
|
||||||
|
expect(ticketRefAt(text, at(text, 'US'))).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user