/** * Wortdiff für die Fassungsvergleiche an Abschnitten und Verlaufseinträgen. * * Bewusst ohne Paket: eine längste gemeinsame Teilfolge über Wörter ist ein * Dutzend Zeilen, und die Alternative wären 30 kB im Bündel für etwas, das * ausschliesslich serverseitig läuft. Siehe docs/09-performance-und-ux.md. */ export type DiffPart = { kind: 'gleich' | 'entfernt' | 'neu'; text: string } function tokenize(text: string): string[] { // Wörter samt folgendem Leerraum, damit sich der Text verlustfrei // zusammensetzen lässt. return text.match(/\S+\s*|\s+/g) ?? [] } export function wordDiff(before: string, after: string): DiffPart[] { const a = tokenize(before) const b = tokenize(after) // Längste gemeinsame Teilfolge, klassisch über eine Tabelle. const lengths: number[][] = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0), ) for (let i = a.length - 1; i >= 0; i--) { for (let j = b.length - 1; j >= 0; j--) { lengths[i]![j] = a[i] === b[j] ? lengths[i + 1]![j + 1]! + 1 : Math.max(lengths[i + 1]![j]!, lengths[i]![j + 1]!) } } const parts: DiffPart[] = [] const push = (kind: DiffPart['kind'], text: string) => { const last = parts.at(-1) if (last && last.kind === kind) last.text += text else parts.push({ kind, text }) } let i = 0 let j = 0 while (i < a.length && j < b.length) { if (a[i] === b[j]) { push('gleich', a[i]!) i++ j++ } else if (lengths[i + 1]![j]! >= lengths[i]![j + 1]!) { push('entfernt', a[i]!) i++ } else { push('neu', b[j]!) j++ } } while (i < a.length) push('entfernt', a[i++]!) while (j < b.length) push('neu', b[j++]!) return parts } /** Grobe Kennzahl für das Protokoll, ohne den Text zu doppeln. */ export function diffSummary(before: string, after: string): { entfernt: number; neu: number } { const parts = wordDiff(before, after) return { entfernt: parts.filter((p) => p.kind === 'entfernt').reduce((n, p) => n + p.text.length, 0), neu: parts.filter((p) => p.kind === 'neu').reduce((n, p) => n + p.text.length, 0), } }