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
+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;
}