From d1de8758b164b8796d04e11986d44ecee8c22225 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Thu, 27 Aug 2026 16:51:10 +0200 Subject: [PATCH] feat(editor): Taiga-Story und -Tasks aus dem Knoten-Fenster anlegen (D91, #trk.create) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Aktionen im Knoten-Fenster, nur wo GET /info das Feature meldet (Lebendprobe-Muster, je Basis einmal gecacht) und nur an Knoten ohne Ticket-Referenz — die Ref an der Zeile ist der Idempotenz-Marker. Login-Dialog (type=password, kein window.prompt; nur das Token landet im Browser, 401 fragt neu), Projekt-Dialog vorbelegt aus dem geerbten &taiga., Häkchen-Dialog nach der D91-Vorbelegung; Refs und ggf. das Projekt-Schlagwort gehen undo-fähig als Tokens an die Zeilen (appendToken: vor %% und vor der Fortsetzungsmarke). Regeln headless in taiga.js (15 neue Tests); Deploy zieht TAIGA_API_URL aus der .env als WERKBAUM_TAIGA_API_URL in die Server-Umgebung nach (Nutzerwunsch). E2E im Browser gegen Backend + Taiga-Stub gemessen: Buttons nur ohne Ref, Vorbelegung Kundenprojekt aus geerbtem Tag, Refs #US-/#T- an den richtigen Zeilen, Erledigtes uebersprungen, abweichende Projektwahl schreibt &taiga.mi-intern zurueck, Undo/Redo je Schreibzug, falsches Passwort bleibt als Taiga-Fehlertext im Dialog. Co-Authored-By: Claude Fable 5 --- .env.example | 7 + docs/CHANGELOG.md | 4 + docs/examples/werkbaum.werkbaum | 20 +- frontend/CLAUDE.md | 13 ++ frontend/src/app.js | 313 +++++++++++++++++++++++++++++++- frontend/src/style.css | 30 +++ frontend/src/taiga.js | 68 +++++++ frontend/tests/taiga.test.js | 91 ++++++++++ scripts/deploy-backend.sh | 18 +- 9 files changed, 552 insertions(+), 12 deletions(-) create mode 100644 frontend/src/taiga.js diff --git a/.env.example b/.env.example index 82997a6..628b986 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,10 @@ BACKEND_SSH=mih00@mih00.hostsharing.net # sicher wie die Platte. `.env` ist git-ignoriert — setz sie zusätzlich auf # `chmod 600`, sonst lesen andere Konten dieses Rechners mit. #WERKBAUM_MASTER_PASSWORT= + +# --- Taiga-Proxy (D91) ----------------------------------------------------- +# Basis-URL der Taiga-API — nicht des Frontends; bei plan.hostsharing.net +# liegt sie auf einem eigenen Host. deploy-backend.sh zieht die Zeile als +# WERKBAUM_TAIGA_API_URL in die Server-Umgebung (/env) nach. +# Fehlt sie, bleibt das Feature aus und die Server-Zeile unangetastet. +#TAIGA_API_URL=https://plan-api.hostsharing.net/api/v1 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3f5abbe..54d4882 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,10 @@ reverse. ## 2026-08-27 +- 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 +- The project dialog is prefilled from the inherited `&taiga.slug` keyword, and a creation without or against the inherited assignment writes the keyword back to the node's line +- A node whose line already carries a `#US-…`/`#T-…` token offers no create action — the ref on the line is the idempotency marker - The backend proxies Taiga through four named endpoints — login, projects, create story, create task; the Taiga API address is server configuration, the token stays in the browser, and `/api/v1/info` says whether the feature is on - Free `&tag` keywords are part of the notation now: free-standing only, quoted mentions like `(&taiga.slug)` stay labels — and `&taiga.slug` names the Taiga project of a subtree, inherited downwards - Plans spanning several Taiga projects: a `&taiga.slug` tag names the project per subtree, inherited downwards — the first consumer of the reserved free tags, recorded as planned diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index 5d16d3c..b45d592 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -12,9 +12,9 @@ - [^] #not.gates: And/or decomposition (S) - [^] #not.opt: Optional nodes — neither required nor an alternative (S) - [^] #not.id: Node IDs, (#auth:) before the title (S) %% often the ticket number, see SPEC §11 - + [~] #not.tag: Free tags, (&tag) (M) %% first consumer found: the Taiga project tag, D91 + + [/] #not.tag: Free tags, (&tag) (M) %% first consumer found: the Taiga project tag, D91 - [?] #not.tag.lens: A lens: highlight every node tagged (&x) (S) - - [~] #not.tag.project: (&taiga.slug) assigns the Taiga project, inherited (S) + - [x] #not.tag.project: (&taiga.slug) assigns the Taiga project, inherited (S) - [^] #not.dep: Dependencies across the tree, (:#auth,#api) (M) - [^] #not.dep.status: They constrain the status, not when work may start (S) - [^] #not.dep.eff: Effective status: intrinsic, plus what it depends on (S) @@ -199,19 +199,19 @@ - [!] < #idea.drift: A second parser that must not drift from the SPEC (M) %% exactly what D14 warns about | [?] #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: Tracker integration (XL) %% exactly one of these, hence = + = [~] #trk.taiga: Taiga (XL) 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) - [?] #trk.write: Write the status back (M) :#trk.resolve - - [ ] #trk.create: Create tickets from nodes (XL) + - [/] #trk.create: Create tickets from nodes (XL) - [x] #trk.create.proxy: Backend proxy with named endpoints (M) - - [ ] #trk.create.login: Log in to Taiga, token stays in the browser (S) :#trk.create.proxy - - [ ] #trk.create.project: The project comes from the inherited tag (S) :#not.tag.project - - [ ] #trk.create.story: A "create story" action in the node window (S) :#trk.create.login - - [ ] #trk.create.tasks: Sub-packages become tasks, picked in a dialog (M) :#trk.create.story - - [ ] #trk.create.ref: The ref lands in the line beside the id (S) :#trk.create.story + - [x] #trk.create.login: Log in to Taiga, token stays in the browser (S) :#trk.create.proxy + - [x] #trk.create.project: The project comes from the inherited tag (S) :#not.tag.project + - [x] #trk.create.story: A "create story" action in the node window (S) :#trk.create.login + - [x] #trk.create.tasks: Sub-packages become tasks, picked in a dialog (M) :#trk.create.story + - [x] #trk.create.ref: The ref lands in the line beside the id (S) :#trk.create.story - [-] #trk.plugin: A plugin inside Taiga itself (L) %% legacy frontend, self-hosted only = [?] #trk.tenzu: Tenzu, the successor (XXL) https://tenzu.net = [ ] #trk.none: No tracker, plain text is enough (XS) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 6be928f..bb39690 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -654,6 +654,19 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der Es schließt bei allem, was seine Aussage hinfällig macht — Tipp daneben, zweiter Tipp, Esc, ×, Diagramm-Scroll, Bereichswechsel, Sprung in den Text, `render()`. Die letzten drei sind beim Bauen zuerst vergessen worden. +- **Taiga-Ticket-Anlage (D91):** Die entscheidbaren Regeln liegen headless in + `taiga.js` (`ticketRefOf` = Idempotenz-Marker, `taskCandidates` = + Dialog-Vorbelegung, `appendToken` = Token vor `%%` und vor der + Fortsetzungsmarke ` \`); die `taiga.*`-Vererbung rechnet `taigaSlugs()` in + model.js. app.js verdrahtet: `refreshTaiga()` fragt je Basis EINMAL + `GET /info` (`taiga`-Flag; Aufruf aus Boot und `followActiveDoc`), + `appendTaigaActions()` hängt die zwei Knöpfe ins Knoten-Fenster (nur ohne + vorhandene Ref), `taigaCreate()` führt Login → Projekt-Dialog → + Anlegen → Ref-Rückschreiben. 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, nicht als + `window.alert` (in manchen Kontexten unterdrückt, D22-Lehre). - **Nie `src.value = …` während des Bearbeitens (D53).** Es löscht die Undo-Historie des Textfelds **komplett** — nicht nur den eigenen Schritt, sondern alles davor Getippte. Gemessen: nach so einem Schreiben ändert das diff --git a/frontend/src/app.js b/frontend/src/app.js index 41ea8be..00aa9a1 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1,6 +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 } 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 { esc, renderTreeHtml, TIP_RULE } from './render.js'; import { formatWarning, warningText } from './warnings.js'; import * as live from './live.js'; @@ -1620,6 +1621,7 @@ function showNodeTip(el, touch){ (paras.length ? `
${paras.map(p => `

${esc(p)}

`).join('')}
` : '') + (facts ? `
${esc(facts)}
` : '') + (href ? `↗ ${esc(t('tipOpenLink'))}` : ''); + appendTaigaActions(el); /* Taiga-Aktionen (D91) — vor dem Messen in placeNodeTip */ tipNode = el; el.classList.add('tipped'); nodeTip.hidden = false; @@ -1706,6 +1708,304 @@ document.addEventListener('keydown', e => { if(e.key === 'Escape'){ closeNodeTip nicht — es zeigte dann auf etwas anderes. Also zumachen. */ document.querySelector('.diagram').addEventListener('scroll', closeNodeTip, {passive: true}); +/* ---------- Taiga-Ticket-Anlage (SPEC §11, D91) ---------- + Zwei Aktionen im Knoten-Fenster — „Story anlegen" und „Story + Teilpakete + als Tasks" —, über den Backend-Proxy (D91: die Taiga-Adresse ist + Server-Konfiguration, das Token bleibt im Browser). Die Aktionen erscheinen + nur, wo `GET /info` das Feature meldet (`taiga`, Lebendprobe-Muster wie + beim Teilen, D81-Nachtrag), und nur an Knoten OHNE Ticket-Referenz — die + 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) */ + +/* Die Taiga-Sitzung des Browsers: Token samt Benutzer-Id, wie vom Proxy + geliefert. Nur das Token — nie ein Passwort — und löschbar durch Abmelden + bzw. ein 401 (Token abgelaufen -> neu anmelden). */ +function taigaSession(){ + try{ return JSON.parse(localStorage.getItem(LS_TAIGA) || 'null'); }catch(_){ return null; } +} +function storeTaigaSession(s){ + try{ + if(s) localStorage.setItem(LS_TAIGA, JSON.stringify(s)); + else localStorage.removeItem(LS_TAIGA); + }catch(_){} +} + +/* Welches Backend, und kann es Taiga? Dieselbe Basis-Herleitung wie beim + Teilen (serverBaseOrAsk, ohne den Dialog): das offene Server-Dokument vor + `?server=`/gemerkter Adresse vor der eigenen Herkunft. Je Basis wird genau + einmal gefragt (Cache) — die Antwort ändert sich nur mit einem Deploy. */ +async function refreshTaiga(){ + let gemerkt = null; + try{ gemerkt = localStorage.getItem(LS_SERVER); }catch(_){} + const offen = liveState ? liveState.urls.doc : null; + const basis = live.serverBase(urlParam(SERVER_PARAM) || gemerkt, offen, location.href); + taigaBase = basis; + taigaOn = false; + if(!basis) return; + if(!taigaProbeCache.has(basis)){ + taigaProbeCache.set(basis, + fetchJson(live.infoUrl(basis)).then(i => !!(i && i.taiga)).catch(() => false)); + } + const on = await taigaProbeCache.get(basis); + if(taigaBase === basis) taigaOn = on; +} + +function taigaFetch(path, options, token){ + const opts = Object.assign({}, options); + opts.headers = Object.assign({}, opts.headers, token ? {'X-Taiga-Token': token} : {}); + return fetchJson(taigaBase + '/api/v1/taiga' + path, opts); +} +/* Der Fehlertext: Taigas eigener (der Proxy reicht `_error_message` als + ProblemDetail durch) vor dem nackten HTTP-Status. */ +function taigaErrText(err){ + return (err && err.body && err.body.detail) || (err && err.message) || String(err); +} + +/* Knoten samt frischem Baum zu einer Zeilennummer — frisch geparst, denn das + Knoten-Fenster kann eine Weile offen gestanden haben. */ +function nodeAndRootsByLine(line){ + const { roots } = parse(src.value); + let found = null; + (function w(ns){ + for(const n of ns){ + if(found) return; + if(n.line === line){ found = n; return; } + w(n.children); + } + })(roots); + return {node: found, roots}; +} + +/* Die Aktions-Knöpfe im Knoten-Fenster (aufgerufen aus showNodeTip). + `tabindex="-1"` wie × und ↗ — das Fenster ist aria-hidden (D57). */ +function appendTaigaActions(el){ + if(!taigaOn) return; + const line = +el.dataset.line; + if(!line) return; + const {node} = nodeAndRootsByLine(line); + if(!node || ticketRefOf(node)) return; + const wrap = document.createElement('div'); + wrap.className = 'nodetip-taiga'; + const mk = (label, mitTasks) => { + const b = document.createElement('button'); + b.type = 'button'; b.className = 'nodetip-taigabtn'; b.tabIndex = -1; + b.textContent = label; + b.addEventListener('click', () => { closeNodeTip(); taigaCreate(line, mitTasks); }); + return b; + }; + wrap.appendChild(mk(t('taigaStoryBtn'), false)); + if(taskCandidates(node).some(c => !c.exists)) wrap.appendChild(mk(t('taigaTasksBtn'), true)); + nodeTipBody.appendChild(wrap); +} + +/* 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). */ +function taigaModal(title, okLabel){ + const ov = document.createElement('div'); + ov.className = 'tabmodal-overlay'; + ov.innerHTML = ''; + ov.querySelector('h2').textContent = title; + const err = ov.querySelector('.taiga-err'); + const ok = ov.querySelector('.taiga-ok'); + const cancel = ov.querySelector('.taiga-cancel'); + ok.textContent = okLabel; + cancel.textContent = t('taigaCancel'); + ov.addEventListener('keydown', e => { + if(e.key === 'Escape'){ e.stopPropagation(); cancel.click(); } + if(e.key === 'Enter' && e.target.tagName === 'INPUT'){ e.preventDefault(); ok.click(); } + }); + document.body.appendChild(ov); + return { + body: ov.querySelector('.taiga-body'), err, ok, cancel, + close(){ ov.remove(); }, + busy(){ err.hidden = true; ok.disabled = true; cancel.disabled = true; }, + fail(msg){ + err.textContent = t('taigaError', {error: msg}); + err.hidden = false; ok.disabled = false; cancel.disabled = false; + }, + }; +} + +/* Anmeldedialog: Benutzername + Passwort gehen EINMAL an den Proxy + (`POST /taiga/auth`), gespeichert wird nur das Token. Kein window.prompt — + ein Passwort gehört in ein type="password"-Feld. */ +function taigaLoginDialog(){ + return new Promise(resolve => { + const dlg = taigaModal(t('taigaLoginTitle'), t('taigaLoginDo')); + const lab = (text, input) => { + const l = document.createElement('label'); + l.className = 'taiga-lab'; + const s = document.createElement('span'); + s.textContent = text; + l.append(s, input); + return l; + }; + const user = document.createElement('input'); + user.type = 'text'; user.autocomplete = 'username'; + const pass = document.createElement('input'); + pass.type = 'password'; pass.autocomplete = 'current-password'; + dlg.body.append(lab(t('taigaUser'), user), lab(t('taigaPass'), pass)); + dlg.ok.addEventListener('click', async () => { + dlg.busy(); + try{ + const s = await taigaFetch('/auth', {method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({username: user.value.trim(), password: pass.value})}); + const session = {token: s.authToken, userId: s.userId, username: s.username}; + storeTaigaSession(session); + dlg.close(); + resolve(session); + }catch(e2){ dlg.fail(taigaErrText(e2)); } + }); + dlg.cancel.addEventListener('click', () => { dlg.close(); resolve(null); }); + user.focus(); + }); +} + +async function taigaEnsureSession(){ + const s = taigaSession(); + if(s && s.token) return s; + return taigaLoginDialog(); +} + +/* Projektliste, mit einem Neu-Anmelden bei 401 (Token abgelaufen). */ +async function taigaProjects(session){ + try{ + return await taigaFetch('/projects?member=' + session.userId, null, session.token); + }catch(err){ + if(!err || err.status !== 401) throw err; + storeTaigaSession(null); + const neu = await taigaLoginDialog(); + if(!neu) throw err; + Object.assign(session, neu); + return taigaFetch('/projects?member=' + session.userId, null, session.token); + } +} + +/* Hängt Tokens ans sichtbare Ende einer Textzeile — undo-fähig über + `replaceTextUndoable` (das kümmert sich um Fokus, Bildschirmtastatur und + Schreibmarke; ein nacktes execCommand griffe ohne Fokus nicht und fiele + auf den Historien-Killer `src.value =` zurück, D53). */ +function writeLineTokens(lineNo, tokens){ + const zeilen = src.value.split('\n'); + if(lineNo < 1 || lineNo > zeilen.length) return; + let zeile = zeilen[lineNo - 1]; + for(const tok of tokens) zeile = appendToken(zeile, tok); + if(zeile === zeilen[lineNo - 1]) return; + zeilen[lineNo - 1] = zeile; + withEditorWritable(() => replaceTextUndoable(zeilen.join('\n'))); +} + +/* Der Anlege-Dialog (D91): Projekt wählen — vorbelegt aus dem geerbten + `&taiga.` (D91-Nachtrag 3) —, bei „Story + Tasks" dazu der + Häkchen-Dialog über die direkten Kinder. Nach dem Anlegen landet die Ref + als Token an der Zeile (`#US-…`/`#T-…`); die ERSTE Anlage in einem + Teilbaum ohne Zuordnung schreibt zusätzlich das Projekt-Schlagwort zurück + — beides in einem Undo-Schritt je Zeile. + + Scheitert eine Task, bleibt der Dialog mit dem Fehler offen; die Story und + schon angelegte Tasks werden beim erneuten Anlegen NICHT wiederholt + (`story`/`b.done` halten fest, was bezahlt ist — und die geschriebenen + Refs sind ohnehin der Idempotenz-Marker). */ +async function taigaCreate(line, mitTasks){ + 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 cands = mitTasks ? taskCandidates(node).filter(c => !c.exists) : []; + + const dlg = taigaModal(t('taigaProjectTitle'), t('taigaCreateDo')); + const subject = document.createElement('p'); + subject.className = 'taiga-subject'; + subject.textContent = node.label; + const sel = document.createElement('select'); + sel.disabled = true; + const lab = document.createElement('label'); + lab.className = 'taiga-lab'; + const labSpan = document.createElement('span'); + labSpan.textContent = t('taigaProject'); + lab.append(labSpan, sel); + dlg.body.append(subject, lab); + let boxes = []; + if(cands.length){ + const head = document.createElement('p'); + head.className = 'taiga-pick'; + head.textContent = t('taigaTasksPick'); + const list = document.createElement('div'); + list.className = 'taiga-cands'; + boxes = cands.map(c => { + const l = document.createElement('label'); + l.className = 'taiga-cand'; + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = c.checked; + l.append(cb, document.createTextNode(' ' + c.node.label)); + list.appendChild(l); + return {cb, cand: c, done: false}; + }); + dlg.body.append(head, list); + } + 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; + dlg.ok.disabled = !projects.length; + + let story = null; + dlg.ok.addEventListener('click', async () => { + const projekt = projects[sel.selectedIndex]; + if(!projekt) return; + dlg.busy(); + try{ + if(!story){ + story = await taigaFetch('/userstories', {method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({project: projekt.id, subject: node.label})}, session.token); + /* Das Projekt-Schlagwort zurückschreiben (D91-Nachtrag 3): wenn die + Wahl nicht schon aus dem Baum folgt — also ohne Zuordnung oder + abweichend vom GEERBTEN. Trägt die Zeile selbst schon ein + `taiga.*`, wird nichts angehängt: Das erste Token gewinnt (§1), + ein zweites dahinter wäre wirkungslos. */ + const ownTag = (node.marks || []).some(m => m.startsWith('taiga.')); + const tokens = (!ownTag && inherited !== projekt.slug) ? [slugToken(projekt.slug)] : []; + writeLineTokens(node.line, tokens.concat(refToken('US', story.ref))); + sel.disabled = true; /* die Wahl ist getroffen und bezahlt */ + } + for(const b of boxes){ + if(!b.cb.checked || b.done) continue; + const task = await taigaFetch('/tasks', {method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({project: projekt.id, subject: b.cand.node.label, + userStory: story.id})}, session.token); + b.done = true; + b.cb.disabled = true; + writeLineTokens(b.cand.node.line, [refToken('T', task.ref)]); + } + dlg.close(); + }catch(err){ dlg.fail(taigaErrText(err)); } + }); +} + /* 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 @@ -2404,6 +2704,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}", 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?", @@ -2540,6 +2841,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}", 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?", @@ -2675,6 +2977,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}", 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?", @@ -2810,6 +3113,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}", 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 ?", @@ -2945,6 +3249,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}", 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ć?", @@ -3080,6 +3385,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}", liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/<uuid>)?", liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.", liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?", @@ -3215,6 +3521,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}", liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/<uuid>)?", liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।", liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?", @@ -3361,6 +3668,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}", liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/<uuid>)吗?", liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。", liveConflictText:"有人改动了同样的行。以谁的版本为准?", @@ -3496,6 +3804,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}", liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/<uuid>)ですか。", liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。", liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。", @@ -4598,6 +4907,7 @@ function followActiveDoc(){ if(liveState && liveState.id !== activeId) stopLive(); const d = activeDoc(); if(!liveState && d && String(d.id).startsWith('live:')) startLive(d.id.slice(5)); + refreshTaiga(); /* die Basis kann mit dem Dokument wechseln (D91); gecacht */ } function switchDoc(id){ @@ -5968,6 +6278,7 @@ loadLive(); /* ?live= — Server-Dokument samt Feed (asynchron, D76) /* Beide haben ihren Parameter jetzt gelesen (synchron, vor dem ersten `await`). Ab hier folgt die Adresszeile dem aktiven Dokument (D80). */ bootDone = true; +refreshTaiga(); /* kann das Backend Taiga? (asynchron, D91) */ /* ---------- PWA: Service Worker (D73) ---------- Ein reiner Offline-Mantel (public/sw.js): Navigationen network-first, der diff --git a/frontend/src/style.css b/frontend/src/style.css index d081656..bbdcda3 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1070,6 +1070,15 @@ padding:5px 10px;border:1px solid var(--or);border-radius:7px; color:var(--or);text-decoration:none;font-size:.74rem;font-weight:500; } + /* Taiga-Aktionen im Knoten-Fenster (D91) — dasselbe Knopf-Idiom wie der + ↗-Link, nur als