diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 53211d4..372fb83 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,8 @@ reverse. ## 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 - 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 diff --git a/docs/SPEC.md b/docs/SPEC.md index 576e59d..a684a5c 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1018,6 +1018,18 @@ verknüpft (siehe D25): ist schon als `unknownDep` gewarnt). Kein Sprung im Kommentar, im Beschreibungsteil hinter `---` und innerhalb einer URL; Alt+Klick behält 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.` (§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 öffnet sie weiterhin im neuen Tab. Deshalb Alt und nicht der einfache Klick. - **Auffindbarkeit:** Solange **Alt gedrückt** ist, zeigen alle Knoten den diff --git a/frontend/src/app.js b/frontend/src/app.js index 00aa9a1..f367b62 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1,7 +1,7 @@ import './style.css'; 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 { 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 { formatWarning, warningText } from './warnings.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 (Vorbelegung, Token-Anhängen) liegen headless in taiga.js. */ const LS_TAIGA = 'werkbaum-taiga'; -let taigaBase = null, taigaOn = false; -const taigaProbeCache = new Map(); /* Basis -> Promise (je Basis EIN /info) */ +let taigaBase = null, taigaOn = false, taigaWeb = null; +const taigaProbeCache = new Map(); /* Basis -> Promise<{on, web}> (je Basis EIN /info) */ /* Die Taiga-Sitzung des Browsers: Token samt Benutzer-Id, wie vom Proxy 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); taigaBase = basis; taigaOn = false; + taigaWeb = null; if(!basis) return; if(!taigaProbeCache.has(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); - if(taigaBase === basis) taigaOn = on; + const info = await taigaProbeCache.get(basis); + if(taigaBase === basis){ taigaOn = info.on; taigaWeb = info.web; } } function taigaFetch(path, options, token){ @@ -1781,14 +1786,41 @@ function nodeAndRootsByLine(line){ /* Die Aktions-Knöpfe im Knoten-Fenster (aufgerufen aus showNodeTip). `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){ if(!taigaOn) return; const line = +el.dataset.line; if(!line) return; - const {node} = nodeAndRootsByLine(line); - if(!node || ticketRefOf(node)) return; + const {node, roots} = nodeAndRootsByLine(line); + if(!node) return; const wrap = document.createElement('div'); 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 b = document.createElement('button'); b.type = 'button'; b.className = 'nodetip-taigabtn'; b.tabIndex = -1; @@ -1801,6 +1833,22 @@ function appendTaigaActions(el){ 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 Dialog statt als window.alert — der ist in manchen Browser-Kontexten unterdrückt (dieselbe Lehre wie beim Inline-Umbenennen, D22). */ @@ -2173,13 +2221,29 @@ function jumpToDepTarget(){ if(line) jumpToLine(line); 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 => { if(e.altKey || !(e.ctrlKey || e.metaKey)) return; - jumpToDepTarget(); + if(!jumpToDepTarget()) openTicketAtCaret(); }); src.addEventListener('keydown', e => { 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 @@ -2704,7 +2768,7 @@ const I18N = { acHint:"{n} ID-Vorschläge – ↑/↓ wählt, Enter übernimmt", tipClose:"Schließen", 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>)?", 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?", @@ -2841,7 +2905,7 @@ const I18N = { acHint:"{n} id suggestions – ↑/↓ to choose, Enter to insert", tipClose:"Close", 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>)?", liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.", liveConflictText:"Someone changed the same lines. Whose version should win?", @@ -2977,7 +3041,7 @@ const I18N = { acHint:"{n} sugerencias de ID – ↑/↓ elige, Intro inserta", tipClose:"Cerrar", 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>)?", 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?", @@ -3113,7 +3177,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", 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>) ?", 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 ?", @@ -3249,7 +3313,7 @@ const I18N = { acHint:"{n} podpowiedzi ID – ↑/↓ wybiera, Enter wstawia", tipClose:"Zamknij", 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>)?", 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ć?", @@ -3385,7 +3449,7 @@ const I18N = { acHint:"{n} подсказок ID – ↑/↓ выбирает, Enter вставляет", tipClose:"Закрыть", 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>)?", liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.", liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?", @@ -3521,7 +3585,7 @@ const I18N = { acHint:"{n} आईडी सुझाव – ↑/↓ से चुनें, Enter से डालें", tipClose:"बंद करें", 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>)?", liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।", liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?", @@ -3668,7 +3732,7 @@ const I18N = { acHint:"{n} 个 ID 建议 – ↑/↓ 选择,Enter 插入", tipClose:"关闭", 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>)吗?", liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。", liveConflictText:"有人改动了同样的行。以谁的版本为准?", @@ -3804,7 +3868,7 @@ const I18N = { acHint:"ID候補 {n} 件 – ↑/↓で選択、Enterで挿入", tipClose:"閉じる", 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>)ですか。", liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。", liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。", diff --git a/frontend/src/taiga.js b/frontend/src/taiga.js index 3ea3ac8..a1f5e11 100644 --- a/frontend/src/taiga.js +++ b/frontend/src/taiga.js @@ -24,6 +24,53 @@ export function ticketRefOf(n){ 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. */ export function refToken(kind, ref){ return '#' + kind + '-' + ref; } export function slugToken(slug){ return '&taiga.' + slug; } diff --git a/frontend/tests/taiga.test.js b/frontend/tests/taiga.test.js index 27189a5..d70b1f5 100644 --- a/frontend/tests/taiga.test.js +++ b/frontend/tests/taiga.test.js @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { parse } from '../src/parser.js'; import { taigaSlugs } from '../src/model.js'; -import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken } 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 `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']); }); }); + +/* 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); + }); +});