272 lines
12 KiB
JavaScript
272 lines
12 KiB
JavaScript
/* 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';
|
|
import { STATUS_BY_CODE } from './parser.js';
|
|
|
|
/* Das Tracker-Muster (SPEC §11): `US-\d+` (Story) und `T-\d+` (Task). */
|
|
export const TICKET_ID_RE = /^(?:US|T)-\d+$/;
|
|
|
|
/* Zerlegt eine Ref in Typ und nackte Nummer: `US-123` -> {kind:'US', nr:123}.
|
|
Das Präfix trägt den Typ (SPEC §11) — daran hängen der Frontend-Pfad
|
|
(unten), Taigas getrennte `by_ref`-Endpunkte und damit auch der Proxy-Pfad.
|
|
Ungültiges ergibt null; geraten wird nirgends. */
|
|
export function refParts(ref){
|
|
const m = /^(US|T)-(\d+)$/.exec(ref || '');
|
|
return m ? {kind: m[1], nr: m[2]} : null;
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* Die Adresse eines Tickets im Taiga-FRONTEND (D91-Nachtrag 5): Stories
|
|
liegen unter `/us/`, Tasks unter `/task/` — das Präfix trägt den Typ,
|
|
genau dafür schreibt Werkbaum es (SPEC §11). Ohne Web-Basis, Projekt-Slug
|
|
oder gültige Ref gibt es keine Adresse (null). */
|
|
export function ticketUrl(web, slug, ref){
|
|
const p = refParts(ref);
|
|
if(!p || !web || !slug) return null;
|
|
return web + '/project/' + slug + '/' + (p.kind === 'US' ? 'us' : 'task') + '/' + p.nr;
|
|
}
|
|
|
|
/* Der Pfad am Backend-Proxy zum LESEN eines Tickets (D91-Nachtrag 6),
|
|
relativ zu `/api/v1/taiga`: zwei benannte Endpunkte statt eines mit
|
|
Typ-Parameter, weil Taiga getrennte `by_ref`-Endpunkte hat. Der Slug
|
|
kommt als Query dazu (eine Ref ist nur je Projekt eindeutig). */
|
|
export function ticketApiPath(ref, slug){
|
|
const p = refParts(ref);
|
|
if(!p || !slug) return null;
|
|
return '/' + (p.kind === 'US' ? 'userstories' : 'tasks') + '/' + p.nr +
|
|
'?slug=' + encodeURIComponent(slug);
|
|
}
|
|
|
|
/* Taiga-Workflow -> Statusbox der Notation (SPEC §4/§9, D91-Nachtrag 6).
|
|
Die Vorgabe steht in backend/CLAUDE.md; abgebildet wird im **Editor**, denn
|
|
die Statuscodes sind Notations-Vokabular und das Backend parst die Notation
|
|
nicht (D14). Groß-/Kleinschreibung und Leerraum sind egal; ein Name
|
|
außerhalb der Liste bleibt unabgebildet (null) — geraten wird nicht, und
|
|
Taigas Workflows sind je Projekt frei benennbar. */
|
|
export const TAIGA_STATUS_CODE = {
|
|
'new': ' ',
|
|
'in progress': '~',
|
|
'ready for test': '/',
|
|
'done': 'x',
|
|
'archived': '^',
|
|
};
|
|
export function mapTaigaStatus(name){
|
|
if(typeof name !== 'string') return null;
|
|
const code = TAIGA_STATUS_CODE[normName(name)];
|
|
return code ? STATUS_BY_CODE[code] : null;
|
|
}
|
|
const normName = s => s.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
|
|
/* Die Gegenrichtung (D91-Nachtrag 7/8): Zu welcher Taiga-Spalte gehört eine
|
|
Statusbox? Nur die fünf abgebildeten Zustände haben eine — `[?]`, `[!]`,
|
|
`[-]` und der neutrale Knoten (code null) haben keine Entsprechung und
|
|
lassen das Ticket unangetastet; erfunden wird nichts. */
|
|
export const TAIGA_STATUS_NAME = Object.fromEntries(
|
|
Object.entries(TAIGA_STATUS_CODE).map(([name, code]) => [code, name]));
|
|
export function taigaStatusName(code){
|
|
const name = code ? TAIGA_STATUS_NAME[code] : null;
|
|
/* Zurück in die Schreibweise, in der Taiga die Spalten führt — gesucht wird
|
|
ohnehin normalisiert (`pickStatus`), aber gemeldet wird sie im Klartext. */
|
|
return name ? name.replace(/^./, c => c.toUpperCase()) : null;
|
|
}
|
|
|
|
/* Die Spalte des Projekts zu einem Namen — Taiga schreibt nach **Id**, und
|
|
die Namen sind je Projekt frei. Verglichen wird mit derselben
|
|
Normalisierung wie beim Lesen; findet sich nichts, wird nicht geschrieben
|
|
(der Aufrufer sagt, welche Spalte fehlte). */
|
|
export function pickStatus(list, name){
|
|
if(!Array.isArray(list) || typeof name !== 'string') return null;
|
|
const gesucht = normName(name);
|
|
return list.find(s => s && typeof s.name === 'string' && normName(s.name) === gesucht) || null;
|
|
}
|
|
|
|
/* Die beiden Schreib-Pfade am Proxy: der Status des Tickets und die Spalten
|
|
des Projekts — je Typ ein eigener Endpunkt, wie beim Lesen. */
|
|
export function statusApiPath(ref, slug){
|
|
const p = ticketApiPath(ref, slug);
|
|
if(!p) return null;
|
|
const [pfad, query] = p.split('?');
|
|
return pfad + '/status?' + query;
|
|
}
|
|
export function statusListPath(ref, slug){
|
|
const p = refParts(ref);
|
|
if(!p || !slug) return null;
|
|
return '/' + (p.kind === 'US' ? 'userstory-statuses' : 'task-statuses') +
|
|
'?slug=' + encodeURIComponent(slug);
|
|
}
|
|
|
|
/* Alle Ticket-Refs eines Baums, gruppiert nach ihrem geerbten Projekt-Slug
|
|
(D91-Nachtrag 10): die Menge, die die Bulk-Abfrage holt — die Refs im Plan
|
|
sind der Filter, nie die Projektgröße. Knoten ohne Slug fallen heraus
|
|
(ohne Projekt ist eine Ref nicht auflösbar — dieselbe stille Regel wie
|
|
beim Öffnen, D91-Nachtrag 5). Map slug -> [{ref, node}]; dieselbe Ref an
|
|
mehreren Knoten steht mehrfach darin (markiert wird je Knoten),
|
|
dedupliziert wird erst im Anfrage-Pfad. */
|
|
export function collectTicketRefs(roots, slugMap){
|
|
const out = new Map();
|
|
(function w(ns){
|
|
for(const n of ns){
|
|
const ref = ticketRefOf(n);
|
|
const slug = slugMap.get(n);
|
|
if(ref && slug){
|
|
if(!out.has(slug)) out.set(slug, []);
|
|
out.get(slug).push({ref, node: n});
|
|
}
|
|
w(n.children || []);
|
|
}
|
|
})(roots);
|
|
return out;
|
|
}
|
|
|
|
/* Der Bulk-Pfad am Proxy (eine Anfrage je Projekt): Refs dedupliziert und
|
|
bei 200 gedeckelt — der benannte Server-Deckel; mehr referenziert kein
|
|
Plan, und die ersten 200 markiert zu bekommen ist besser als wegen eines
|
|
400 gar keine. */
|
|
export function bulkPath(slug, refs){
|
|
const uniq = [...new Set(refs)].slice(0, 200);
|
|
if(!uniq.length || !slug) return null;
|
|
return '/tickets?slug=' + encodeURIComponent(slug) + '&refs=' + uniq.join(',');
|
|
}
|
|
|
|
/* Steht die Ref sichtbar im LABEL des Knotens? Nein heißt: Sie ist (nur)
|
|
seine Knoten-ID (§1, erstes `#`-Token) — die Abweichungs-Marke braucht
|
|
dann ein Badge hinter dem Label als sichtbaren Träger (D91-Nachtrag 10).
|
|
Ein D60-Knoten (die ID vertritt das Label) zählt als sichtbar. */
|
|
export function refVisibleInLabel(n, ref){
|
|
return new RegExp('(^|\\s)#' + ref + '(?=\\s|$)').test(n.label);
|
|
}
|
|
|
|
/* Weicht der Ticket-Status von der Statusbox des Knotens ab? (Die
|
|
Abweichungs-Marke im Diagramm, D91-Nachtrag 10.) Verglichen wird nur, was
|
|
abbildbar ist — ein unbekannter Spaltenname sagt nichts über den Plan
|
|
(dieselbe Regel wie im Knoten-Fenster). Ein Knoten OHNE Statusbox weicht
|
|
von jedem abgebildeten Ticket-Status ab — auch das zeigt das Fenster so. */
|
|
export function ticketDiverges(node, statusName){
|
|
const ticket = mapTaigaStatus(statusName);
|
|
if(!ticket) return false;
|
|
return (node.status ? node.status.code : null) !== ticket.code;
|
|
}
|
|
|
|
/* Die Ticket-Referenz unter der Schreibmarke (Strg+Klick im Text,
|
|
D91-Nachtrag 5): ein FREISTEHENDES `#US-123`/`#T-1234`-Token im Baumteil.
|
|
Dieselben Ausschlüsse wie beim Abhängigkeits-Sprung (D67): nicht im
|
|
Kommentar, nicht im Beschreibungsteil hinter `---`, nicht innerhalb einer
|
|
URL. In einer Abhängigkeitsliste (`:#US-123`) ist die Ref nicht
|
|
freistehend — dort behält Strg+Klick den Sprung zur Zeile. Liefert
|
|
{ref, line} (Zeile 1-basiert) oder null. */
|
|
export function ticketRefAt(text, caret){
|
|
const sol = text.lastIndexOf('\n', caret - 1) + 1;
|
|
let eol = text.indexOf('\n', caret);
|
|
if(eol === -1) eol = text.length;
|
|
const before = text.slice(0, sol);
|
|
for(const l of before.split('\n')){
|
|
if(/^\s*-{3,}\s*$/.test(l)) return null;
|
|
}
|
|
const line = text.slice(sol, eol);
|
|
const col = caret - sol;
|
|
const k = line.indexOf('%%');
|
|
if(k !== -1 && col >= k) return null;
|
|
const head = k === -1 ? line : line.slice(0, k);
|
|
const u = /https?:\/\/\S+/.exec(head);
|
|
if(u && col > u.index && col < u.index + u[0].length) return null;
|
|
const re = /(^|\s)#((?:US|T)-\d+)/g;
|
|
let m;
|
|
while((m = re.exec(head))){
|
|
const p = m.index + m[1].length;
|
|
const end = p + 1 + m[2].length;
|
|
/* Kein Treffer mitten in einem längeren Token (`#US-123abc`) — hinter
|
|
den Ziffern darf kein weiteres ID-Zeichen stehen (der Doppelpunkt als
|
|
Trenner, §1, ist keines). */
|
|
if(/[\p{L}\p{N}._-]/u.test(head[end] || '')) continue;
|
|
if(col >= p && col <= end)
|
|
return {ref: m[2], line: before.split('\n').length};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/* Der nächste Vorfahr mit einer STORY-Ref (D91-Nachtrag 9): Unter so einem
|
|
Knoten wird ein Teilpaket direkt als Task angelegt — in dessen Story, ohne
|
|
eigenen Dialog (Projekt und Story folgen aus dem Baum, es gibt nichts zu
|
|
wählen). Vorfahren mit Task-Ref (`T-…`) werden übersprungen: Taiga-Tasks
|
|
haben keine Subtasks, die Task eines Task-Knotens gehört in die Story
|
|
darüber. null, wenn es keinen gibt oder der Knoten nicht im Baum steht. */
|
|
export function storyAncestor(roots, node){
|
|
let result = null, hit = false;
|
|
(function w(ns, chain){
|
|
for(const n of ns){
|
|
if(hit) return;
|
|
if(n === node){
|
|
hit = true;
|
|
for(let i = chain.length - 1; i >= 0; i--){
|
|
const r = ticketRefOf(chain[i]);
|
|
if(r && r.startsWith('US-')){ result = {node: chain[i], ref: r}; return; }
|
|
}
|
|
return;
|
|
}
|
|
w(n.children || [], chain.concat(n));
|
|
}
|
|
})(roots, []);
|
|
return result;
|
|
}
|
|
|
|
/* 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;
|
|
}
|