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:
co-authored by
Claude Fable 5
parent
fd0e730656
commit
d1de8758b1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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/<uuid>)?",
|
liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/<uuid>)?",
|
||||||
liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.",
|
liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.",
|
||||||
liveConflictText:"Jemand hat dieselben Zeilen geändert. Wessen Fassung soll gelten?",
|
liveConflictText:"Jemand hat dieselben Zeilen geändert. Wessen Fassung soll gelten?",
|
||||||
@@ -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/<uuid>)?",
|
liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/<uuid>)?",
|
||||||
liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.",
|
liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.",
|
||||||
liveConflictText:"Someone changed the same lines. Whose version should win?",
|
liveConflictText:"Someone changed the same lines. Whose version should win?",
|
||||||
@@ -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/<uuid>)?",
|
liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/<uuid>)?",
|
||||||
liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.",
|
liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.",
|
||||||
liveConflictText:"Alguien cambió las mismas líneas. ¿Qué versión debe prevalecer?",
|
liveConflictText:"Alguien cambió las mismas líneas. ¿Qué versión debe prevalecer?",
|
||||||
@@ -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/<uuid>) ?",
|
liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/<uuid>) ?",
|
||||||
liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.",
|
liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.",
|
||||||
liveConflictText:"Quelqu'un a modifié les mêmes lignes. Quelle version doit l'emporter ?",
|
liveConflictText:"Quelqu'un a modifié les mêmes lignes. Quelle version doit l'emporter ?",
|
||||||
@@ -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/<uuid>)?",
|
liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/<uuid>)?",
|
||||||
liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.",
|
liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.",
|
||||||
liveConflictText:"Ktoś zmienił te same wiersze. Która wersja ma obowiązywać?",
|
liveConflictText:"Ktoś zmienił te same wiersze. Która wersja ma obowiązywać?",
|
||||||
@@ -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/<uuid>)?",
|
liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/<uuid>)?",
|
||||||
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/<uuid>)?",
|
liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/<uuid>)?",
|
||||||
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/<uuid>)吗?",
|
liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/<uuid>)吗?",
|
||||||
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/<uuid>)ですか。",
|
liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/<uuid>)ですか。",
|
||||||
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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
Reference in New Issue
Block a user