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
# `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 (<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
- 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
+10 -10
View File
@@ -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)
+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,
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
+312 -1
View File
@@ -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 ? `<div class="nodetip-desc">${paras.map(p => `<p>${esc(p)}</p>`).join('')}</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>` : '');
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<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
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/&lt;uuid&gt;)?",
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/&lt;uuid&gt;)?",
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/&lt;uuid&gt;)?",
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/&lt;uuid&gt;) ?",
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/&lt;uuid&gt;)?",
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/&lt;uuid&gt;)?",
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/&lt;uuid&gt;)?",
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/&lt;uuid&gt;)吗?",
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/&lt;uuid&gt;)ですか。",
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
+30
View File
@@ -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 <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
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
@@ -1672,6 +1681,27 @@
}
@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) */
.snaphead{
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 { parse } from '../src/parser.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
`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);
});
});
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}"
PORT="$(env_value BACKEND_PORT)"; PORT="${PORT:-9080}"
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:
#
@@ -147,7 +150,7 @@ ssh "$SSH_TARGET" "mkdir -p \"$DIR_SH\" \"\$HOME/.config/systemd/user\""
rsync -az --chmod=F644 "$STAGE/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
DIR="$(eval echo "$DIR")"
@@ -188,6 +191,19 @@ ENV
fi
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
echo " ! Linger ist nicht gesetzt — der Dienst endet mit der Sitzung."
echo " Abhilfe: loginctl enable-linger $(id -un)"