feat(editor): Taiga-Story und -Tasks aus dem Knoten-Fenster anlegen (D91, #trk.create)

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.<slug>, 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 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-27 16:51:10 +02:00
co-authored by Claude Fable 5
parent fd0e730656
commit d1de8758b1
9 changed files with 552 additions and 12 deletions
+7
View File
@@ -39,3 +39,10 @@ BACKEND_SSH=mih00@mih00.hostsharing.net
# sicher wie die Platte. `.env` ist git-ignoriert — setz sie zusätzlich auf # sicher wie die Platte. `.env` ist git-ignoriert — setz sie zusätzlich auf
# `chmod 600`, sonst lesen andere Konten dieses Rechners mit. # `chmod 600`, sonst lesen andere Konten dieses Rechners mit.
#WERKBAUM_MASTER_PASSWORT= #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 (<BACKEND_DIR>/env) nach.
# Fehlt sie, bleibt das Feature aus und die Server-Zeile unangetastet.
#TAIGA_API_URL=https://plan-api.hostsharing.net/api/v1
+4
View File
@@ -19,6 +19,10 @@ reverse.
## 2026-08-27 ## 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 - 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 - 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 - 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
+10 -10
View File
@@ -12,9 +12,9 @@
- [^] #not.gates: And/or decomposition (S) - [^] #not.gates: And/or decomposition (S)
- [^] #not.opt: Optional nodes — neither required nor an alternative (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.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.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: Dependencies across the tree, (:#auth,#api) (M)
- [^] #not.dep.status: They constrain the status, not when work may start (S) - [^] #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) - [^] #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: 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.fixtures: Both parsers test against the same fixtures (S)
| [?] #idea.drift.js: Run the one JS parser inside the IDE (M) | [?] #idea.drift.js: Run the one JS parser inside the IDE (M)
- [?] #trk: Tracker integration (XL) %% exactly one of these, hence = - [~] #trk: Tracker integration (XL) %% exactly one of these, hence =
= [?] #trk.taiga: Taiga (XL) https://taiga.io = [~] #trk.taiga: Taiga (XL) https://taiga.io
- [ ] #trk.resolve: Resolve "#US-123" over the REST API (M) - [ ] #trk.resolve: Resolve "#US-123" over the REST API (M)
- [ ] #trk.resolve.read: Read title, link and status (S) - [ ] #trk.resolve.read: Read title, link and status (S)
- [ ] #trk.resolve.map: Map the workflow onto the states (S) - [ ] #trk.resolve.map: Map the workflow onto the states (S)
- [?] #trk.write: Write the status back (M) :#trk.resolve - [?] #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) - [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 - [x] #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 - [x] #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 - [x] #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 - [x] #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.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.plugin: A plugin inside Taiga itself (L) %% legacy frontend, self-hosted only
= [?] #trk.tenzu: Tenzu, the successor (XXL) https://tenzu.net = [?] #trk.tenzu: Tenzu, the successor (XXL) https://tenzu.net
= [ ] #trk.none: No tracker, plain text is enough (XS) = [ ] #trk.none: No tracker, plain text is enough (XS)
+13
View File
@@ -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, Es schließt bei allem, was seine Aussage hinfällig macht — Tipp daneben,
zweiter Tipp, Esc, ×, Diagramm-Scroll, Bereichswechsel, Sprung in den Text, zweiter Tipp, Esc, ×, Diagramm-Scroll, Bereichswechsel, Sprung in den Text,
`render()`. Die letzten drei sind beim Bauen zuerst vergessen worden. `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 - **Nie `src.value = …` während des Bearbeitens (D53).** Es löscht die
Undo-Historie des Textfelds **komplett** — nicht nur den eigenen Schritt, Undo-Historie des Textfelds **komplett** — nicht nur den eigenen Schritt,
sondern alles davor Getippte. Gemessen: nach so einem Schreiben ändert das sondern alles davor Getippte. Gemessen: nach so einem Schreiben ändert das
+312 -1
View File
@@ -1,6 +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 } 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 { 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';
@@ -1620,6 +1621,7 @@ function showNodeTip(el, touch){
(paras.length ? `<div class="nodetip-desc">${paras.map(p => `<p>${esc(p)}</p>`).join('')}</div>` : '') + (paras.length ? `<div class="nodetip-desc">${paras.map(p => `<p>${esc(p)}</p>`).join('')}</div>` : '') +
(facts ? `<div class="nodetip-facts">${esc(facts)}</div>` : '') + (facts ? `<div class="nodetip-facts">${esc(facts)}</div>` : '') +
(href ? `<a class="nodetip-link" tabindex="-1" href="${esc(href)}" target="_blank" rel="noopener">↗ ${esc(t('tipOpenLink'))}</a>` : ''); (href ? `<a class="nodetip-link" tabindex="-1" href="${esc(href)}" target="_blank" rel="noopener">↗ ${esc(t('tipOpenLink'))}</a>` : '');
appendTaigaActions(el); /* Taiga-Aktionen (D91) — vor dem Messen in placeNodeTip */
tipNode = el; tipNode = el;
el.classList.add('tipped'); el.classList.add('tipped');
nodeTip.hidden = false; 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. */ nicht es zeigte dann auf etwas anderes. Also zumachen. */
document.querySelector('.diagram').addEventListener('scroll', closeNodeTip, {passive: true}); 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<boolean> (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 = '<div class="tabmodal taigadlg" role="dialog" aria-modal="true">' +
'<h2></h2><div class="taiga-body"></div><p class="taiga-err" hidden></p>' +
'<div class="taiga-btns"><button type="button" class="taiga-cancel"></button>' +
'<button type="button" class="taiga-ok"></button></div></div>';
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.<slug>` (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 /* Welcher Knoten gehört zu einer Textzeile? Zuerst der Knoten, DER auf dieser
Zeile steht; sonst der Knoten, dessen **Beschreibung** hier steht (SPEC §9): Zeile steht; sonst der Knoten, dessen **Beschreibung** hier steht (SPEC §9):
Eine `"`-Zeile und die Zeilen eines `---`-Blocks tragen keinen eigenen 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", 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}",
liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.", 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?",
@@ -2540,6 +2841,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}",
liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.", 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?",
@@ -2675,6 +2977,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}",
liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.", 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?",
@@ -2810,6 +3113,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}",
liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/&lt;uuid&gt;) ?", liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/&lt;uuid&gt;) ?",
liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.", 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 ?",
@@ -2945,6 +3249,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}",
liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.", 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ć?",
@@ -3080,6 +3385,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}",
liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.", liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.",
liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?", liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?",
@@ -3215,6 +3521,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}",
liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।", liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।",
liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?", liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?",
@@ -3361,6 +3668,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}",
liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/&lt;uuid&gt;)吗?", liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/&lt;uuid&gt;)吗?",
liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。", liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。",
liveConflictText:"有人改动了同样的行。以谁的版本为准?", liveConflictText:"有人改动了同样的行。以谁的版本为准?",
@@ -3496,6 +3804,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}",
liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/&lt;uuid&gt;)ですか。", liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/&lt;uuid&gt;)ですか。",
liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。", liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。",
liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。", liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。",
@@ -4598,6 +4907,7 @@ function followActiveDoc(){
if(liveState && liveState.id !== activeId) stopLive(); if(liveState && liveState.id !== activeId) stopLive();
const d = activeDoc(); const d = activeDoc();
if(!liveState && d && String(d.id).startsWith('live:')) startLive(d.id.slice(5)); 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){ 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 /* Beide haben ihren Parameter jetzt gelesen (synchron, vor dem ersten
`await`). Ab hier folgt die Adresszeile dem aktiven Dokument (D80). */ `await`). Ab hier folgt die Adresszeile dem aktiven Dokument (D80). */
bootDone = true; bootDone = true;
refreshTaiga(); /* kann das Backend Taiga? (asynchron, D91) */
/* ---------- PWA: Service Worker (D73) ---------- /* ---------- PWA: Service Worker (D73) ----------
Ein reiner Offline-Mantel (public/sw.js): Navigationen network-first, der Ein reiner Offline-Mantel (public/sw.js): Navigationen network-first, der
+30
View File
@@ -1070,6 +1070,15 @@
padding:5px 10px;border:1px solid var(--or);border-radius:7px; padding:5px 10px;border:1px solid var(--or);border-radius:7px;
color:var(--or);text-decoration:none;font-size:.74rem;font-weight:500; 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 <button>. */
.nodetip-taiga{display:flex;flex-wrap:wrap;gap:6px;margin-top:9px}
.nodetip-taigabtn{
padding:5px 10px;border:1px solid var(--or);border-radius:7px;
background:none;color:var(--or);cursor:pointer;
font:inherit;font-size:.74rem;font-weight:500;
}
.nodetip-taigabtn:hover{background:rgba(15,118,110,.08)}
/* Die Spitze zeigt auf den Knoten — ohne sie wäre bei dicht stehenden Knoten /* Die Spitze zeigt auf den Knoten — ohne sie wäre bei dicht stehenden Knoten
nicht zu sehen, welcher gemeint ist. Ein gedrehtes Quadrat mit zwei Kanten: nicht zu sehen, welcher gemeint ist. Ein gedrehtes Quadrat mit zwei Kanten:
so erbt es Rahmen UND Schatten des Fensters. JS setzt --tipx (Spitze) und so erbt es Rahmen UND Schatten des Fensters. JS setzt --tipx (Spitze) und
@@ -1672,6 +1681,27 @@
} }
@media print{ .tabmodal-overlay{ display:none; } } @media print{ .tabmodal-overlay{ display:none; } }
/* ---------- Taiga-Anlege- und Anmeldedialog (D91) ----------
Im tabmodal-Idiom (Overlay + Karte); nur die Innereien sind eigen. */
.taigadlg .taiga-subject{margin:0 0 12px;font-weight:600}
.taigadlg .taiga-lab{display:block;margin:0 0 10px}
.taigadlg .taiga-lab span{display:block;font-size:.78rem;color:var(--muted);margin-bottom:3px}
.taigadlg .taiga-lab input,.taigadlg .taiga-lab select{
width:100%;box-sizing:border-box;padding:7px 9px;font:inherit;
border:1px solid rgba(36,52,71,.3);border-radius:7px;background:var(--paper);color:var(--ink);
}
.taigadlg .taiga-pick{margin:12px 0 6px;font-size:.82rem;color:var(--muted)}
.taigadlg .taiga-cands{max-height:11rem;overflow-y:auto;display:flex;flex-direction:column;gap:4px}
.taigadlg .taiga-cand{display:flex;align-items:center;gap:6px;font-size:.88rem}
.taigadlg .taiga-err{color:var(--warn);font-size:.85rem;margin:10px 0 0}
.taigadlg .taiga-btns{display:flex;justify-content:flex-end;gap:10px;margin-top:16px}
.taigadlg .taiga-ok,.taigadlg .taiga-cancel{
padding:7px 14px;border-radius:7px;font:inherit;cursor:pointer;
}
.taigadlg .taiga-ok{border:1px solid var(--or);background:var(--or);color:#fff;font-weight:500}
.taigadlg .taiga-ok:disabled,.taigadlg .taiga-cancel:disabled{opacity:.5;cursor:default}
.taigadlg .taiga-cancel{border:1px solid rgba(36,52,71,.3);background:none;color:var(--ink)}
/* Abschnitts-Kopf „Lokale Sicherungen" im Uhr-Menü (D89) */ /* Abschnitts-Kopf „Lokale Sicherungen" im Uhr-Menü (D89) */
.snaphead{ .snaphead{
padding:8px 12px 4px;margin-top:4px; padding:8px 12px 4px;margin-top:4px;
+68
View File
@@ -0,0 +1,68 @@
/* Werkbaum-Taiga — die entscheidbaren Regeln der Ticket-Anlage (D91),
headless nach Hausregel (D54-Nachtrag 3): Was hier steht, gilt; app.js
verdrahtet nur Dialoge, Netz und Textfeld.
Der Zuschnitt (D91): eine Story je Knoten, Tasks nur unter einem Knoten
mit Story — storyless Tasks sind im Kanban unsichtbar. Die Ref kommt
ZUSÄTZLICH zur Knoten-ID als eigenes Token an die Zeile (`#US-123` /
`#T-1234`, D91-Nachtrag 2); die Präfixe schreibt Werkbaum selbst, Taiga
zeigt nur die Nummer. */
import { gateOf, isDone, isRealized } from './model.js';
/* Das Tracker-Muster (SPEC §11): `US-\d+` (Story) und `T-\d+` (Task). */
export const TICKET_ID_RE = /^(?:US|T)-\d+$/;
/* Trägt die Zeile eines Knotens schon eine Ticket-Referenz? Das ist der
Idempotenz-Marker (D91-Nachtrag 2): So ein Knoten wird nicht erneut
angelegt. Die Ref ist entweder die Knoten-ID selbst (erstes `#`-Token,
ggf. als Label-Vertreter) oder ein weiteres `#`-Token, das im Label
stehen geblieben ist (§1: nur das erste ist die ID). */
export function ticketRefOf(n){
if(n.id && TICKET_ID_RE.test(n.id)) return n.id;
const m = (' ' + n.label + ' ').match(/[ ]#((?:US|T)-\d+)(?=[ ])/);
return m ? m[1] : null;
}
/* Das Token, das an die Zeile geschrieben wird. */
export function refToken(kind, ref){ return '#' + kind + '-' + ref; }
export function slugToken(slug){ return '&taiga.' + slug; }
/* Kandidaten für den Häkchen-Dialog „Story + Teilpakete als Tasks" (D91):
die direkten Kinder, je mit Vorbelegung —
- verworfene (`[-]`) erscheinen gar nicht;
- erledigte (`[x]`/`[^]`) starten ABGEWÄHLT (dort ist nichts mehr zu tun,
und angelegt wird ohnehin alles offen — Status-Sync ist #trk.write);
- konjunktiv sind Pflicht- UND optionale (`+`) Kinder vorbelegt;
- in einer `|`/`=`-Gruppe nur die REALISIERTEN Alternativen — ist nichts
realisiert, ist die Wahl nicht getroffen und nichts vorbelegt.
Kinder, die selbst schon eine Ref tragen, erscheinen abgewählt und
gesperrt — sie sind schon angelegt. */
export function taskCandidates(n){
const kids = (n.children || []).filter(k => !(k.status && k.status.key === 'verworfen'));
if(!kids.length) return [];
const disjunktiv = gateOf(kids) !== 'and';
return kids.map(k => {
const exists = !!ticketRefOf(k);
const checked = !exists && !isDone(k) && (disjunktiv ? isRealized(k) : true);
return {node: k, checked, exists};
});
}
/* Hängt ein Token ans sichtbare Ende einer Textzeile — VOR einen
`%%`-Kommentar und vor die Fortsetzungsmarke ` \` (beides gehört nicht zum
Zeileninhalt, SPEC §1); vorhandener Leerraum vor dem Kommentar bleibt
stehen. Eine Zeile ohne Inhalt bleibt unangetastet. */
export function appendToken(line, token){
const k = line.indexOf('%%');
let head = k === -1 ? line : line.slice(0, k);
const tail = k === -1 ? '' : line.slice(k);
let cont = '';
const cm = head.match(/[ \t]\\[ \t]*$/);
if(cm){ cont = head.slice(cm.index); head = head.slice(0, cm.index); }
const wm = head.match(/[ \t]*$/);
const base = head.slice(0, wm.index);
const ws = head.slice(wm.index);
if(!base.trim()) return line;
return base + ' ' + token + ws + cont + tail;
}
+91
View File
@@ -1,6 +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';
/* 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
@@ -89,3 +90,93 @@ describe('taigaSlugs — Projekt-Vererbung (SPEC §1, D91-Nachtrag 3)', () => {
expect(taigaSlugs(r).has(r[0])).toBe(false); expect(taigaSlugs(r).has(r[0])).toBe(false);
}); });
}); });
describe('ticketRefOf — der Idempotenz-Marker (D91-Nachtrag 2)', () => {
const node = text => parse(text).roots[0];
it('erkennt die Ref als Knoten-ID (Label-Vertreter, SPEC §1)', () => {
expect(ticketRefOf(node('- #US-123'))).toBe('US-123');
});
it('erkennt die Ref als weiteres #-Token im Label, neben der Knoten-ID', () => {
const n = node('- #auth: Backend #US-123 (M)');
expect(n.id).toBe('auth');
expect(ticketRefOf(n)).toBe('US-123');
});
it('erkennt Task-Refs (#T-…)', () => {
expect(ticketRefOf(node('- API-Teil #T-1234'))).toBe('T-1234');
});
it('ein Knoten ohne Tracker-Token hat keine Ref', () => {
expect(ticketRefOf(node('- #auth: Backend (M)'))).toBe(null);
expect(ticketRefOf(node('- US-123 als Text'))).toBe(null); /* ohne # */
expect(ticketRefOf(node('- #ABC-123: Jira-artig'))).toBe(null);
});
});
describe('taskCandidates — Vorbelegung des Häkchen-Dialogs (D91)', () => {
const kids = text => taskCandidates(parse(text).roots[0]);
it('konjunktiv: Pflicht- und optionale Kinder vorbelegt, erledigte abgewählt, verworfene fehlen', () => {
const c = kids('- P\n - [ ] a\n + [?] b\n - [x] c\n - [-] d');
expect(c.map(x => x.node.label)).toEqual(['a', 'b', 'c']);
expect(c.map(x => x.checked)).toEqual([true, true, false]);
});
it('disjunktiv: nur realisierte Alternativen vorbelegt', () => {
const c = kids('- P\n | [~] a\n | [ ] b\n | [x] c');
expect(c.map(x => x.checked)).toEqual([true, false, false]); /* c ist erledigt */
});
it('disjunktiv ohne Realisiertes: nichts vorbelegt — die Wahl ist nicht getroffen', () => {
const c = kids('= P\n = [ ] a\n = [?] b');
expect(c.map(x => x.checked)).toEqual([false, false]);
});
it('ein Kind mit eigener Ref ist gesperrt und abgewählt', () => {
const c = kids('- P\n - [ ] a #T-9\n - [ ] b');
expect(c[0].exists).toBe(true);
expect(c[0].checked).toBe(false);
expect(c[1].checked).toBe(true);
});
it('ein Blatt hat keine Kandidaten', () => {
expect(kids('- P')).toEqual([]);
});
});
describe('appendToken — Token ans sichtbare Zeilenende (D91)', () => {
it('hängt hinter den Inhalt an', () => {
expect(appendToken(' - [ ] Backend (M)', '#US-123')).toBe(' - [ ] Backend (M) #US-123');
});
it('vor einen %%-Kommentar, dessen Leerraum bleibt', () => {
expect(appendToken('- X %% Notiz', '#US-1')).toBe('- X #US-1 %% Notiz');
});
it('vor die Fortsetzungsmarke ` \\` (SPEC §1)', () => {
expect(appendToken('- Langer Titel \\', '#US-1')).toBe('- Langer Titel #US-1 \\');
});
it('eine leere oder reine Kommentarzeile bleibt unangetastet', () => {
expect(appendToken(' ', '#US-1')).toBe(' ');
expect(appendToken('%% nur Kommentar', '#US-1')).toBe('%% nur Kommentar');
});
it('das Ergebnis parst mit der Ref im Label und unveränderter Struktur', () => {
const zeile = appendToken(' - [ ] #auth: Backend (M) @anna', refToken('US', 123));
const { roots } = parse('- Wurzel\n' + zeile);
const n = roots[0].children[0];
expect(n.id).toBe('auth');
expect(n.label).toBe('Backend #US-123');
expect(n.size).toBe('M');
expect(ticketRefOf(n)).toBe('US-123');
});
it('slugToken ergibt das Schlagwort der Projekt-Zuordnung', () => {
const zeile = appendToken('- Teilbaum', slugToken('mi-intern'));
const { roots } = parse(zeile);
expect(roots[0].marks).toEqual(['taiga.mi-intern']);
});
});
+17 -1
View File
@@ -74,6 +74,9 @@ DIR="$(env_value BACKEND_DIR)"; DIR="${DIR:-opt/werkbaum}"
JDK_DIR="$(env_value BACKEND_JDK_DIR)"; JDK_DIR="${JDK_DIR:-opt/jdk21}" JDK_DIR="$(env_value BACKEND_JDK_DIR)"; JDK_DIR="${JDK_DIR:-opt/jdk21}"
PORT="$(env_value BACKEND_PORT)"; PORT="${PORT:-9080}" PORT="$(env_value BACKEND_PORT)"; PORT="${PORT:-9080}"
XMX="$(env_value BACKEND_XMX)"; XMX="${XMX:-192m}" XMX="$(env_value BACKEND_XMX)"; XMX="${XMX:-192m}"
# Taiga-Proxy (D91): API-URL aus der lokalen .env; der Deploy zieht sie als
# WERKBAUM_TAIGA_API_URL in die Server-Umgebung nach (leer = nichts anfassen).
TAIGA_API_URL="$(env_value TAIGA_API_URL)"
# Derselbe Pfad in DREI Schreibweisen, weil ihn drei Werkzeuge lesen: # Derselbe Pfad in DREI Schreibweisen, weil ihn drei Werkzeuge lesen:
# #
@@ -147,7 +150,7 @@ ssh "$SSH_TARGET" "mkdir -p \"$DIR_SH\" \"\$HOME/.config/systemd/user\""
rsync -az --chmod=F644 "$STAGE/werkbaum-backend.service" \ rsync -az --chmod=F644 "$STAGE/werkbaum-backend.service" \
"$SSH_TARGET:~/.config/systemd/user/werkbaum-backend.service" "$SSH_TARGET:~/.config/systemd/user/werkbaum-backend.service"
ssh "$SSH_TARGET" DIR="$DIR_SH" PORT="$PORT" RESTART="$RESTART" 'bash -s' <<'REMOTE' ssh "$SSH_TARGET" DIR="$DIR_SH" PORT="$PORT" RESTART="$RESTART" TAIGA_URL="$TAIGA_API_URL" 'bash -s' <<'REMOTE'
set -euo pipefail set -euo pipefail
DIR="$(eval echo "$DIR")" DIR="$(eval echo "$DIR")"
@@ -188,6 +191,19 @@ ENV
fi fi
chmod 600 "$DIR/env" chmod 600 "$DIR/env"
# Taiga-Proxy (D91): Steht TAIGA_API_URL in der lokalen .env, wird genau diese
# eine Zeile hier nachgezogen — idempotent, alles andere (insbesondere der
# Passwort-Hash) bleibt unangetastet. Ohne Wert wird nichts angefasst: Eine von
# Hand gesetzte Server-Zeile soll ein Deploy ohne .env-Eintrag nicht löschen.
if [ -n "${TAIGA_URL:-}" ]; then
if grep -q '^WERKBAUM_TAIGA_API_URL=' "$DIR/env"; then
sed -i "s#^WERKBAUM_TAIGA_API_URL=.*#WERKBAUM_TAIGA_API_URL=${TAIGA_URL}#" "$DIR/env"
else
printf 'WERKBAUM_TAIGA_API_URL=%s\n' "$TAIGA_URL" >> "$DIR/env"
fi
echo " WERKBAUM_TAIGA_API_URL -> ${TAIGA_URL}"
fi
if ! loginctl show-user "$(id -un)" -p Linger 2>/dev/null | grep -q 'Linger=yes'; then if ! loginctl show-user "$(id -un)" -p Linger 2>/dev/null | grep -q 'Linger=yes'; then
echo " ! Linger ist nicht gesetzt — der Dienst endet mit der Sitzung." echo " ! Linger ist nicht gesetzt — der Dienst endet mit der Sitzung."
echo " Abhilfe: loginctl enable-linger $(id -un)" echo " Abhilfe: loginctl enable-linger $(id -un)"