From 5881be1a2c8e6799a3de156ab74ddcb6c430017b Mon Sep 17 00:00:00 2001 From: mhoennig Date: Wed, 26 Aug 2026 17:32:59 +0200 Subject: [PATCH] feat(frontend): Zeilen-Diff und Adressen fuer Server-Dokumente (live.js) Die entscheidbare Haelfte des Live-Editing-Clients (D76), headless und geprueft: ?live=-Adressen normalisieren, Zeilen-Diff berechnen und anwenden, die Cursor-Zeile durch fremde Aenderungen mitfuehren, und die Regel, wann eine Feed-Antwort ueberhaupt angewendet werden darf. Zerlegen und Hashen liegen hier und nicht verstreut in app.js: Beide Seiten muessen Text gleich in Zeilen zerlegen, sonst zeigen die Indizes auseinander. Das Diff-Modell ist dasselbe wie im Backend (de.werkbaum.diff.LineDiff). Die Cursor-Rechnung ist der Teil, ohne den "kein Neuladen" nichts wert waere: Ohne sie spraenge die Schreibmarke bei jeder fremden Aenderung weiter oben im Dokument. 518 Tests (31 neu). Gegenprobe: Feed-Basis nicht geprueft, Zeile im Eingriff wie darunter behandelt, Protokoll nicht geprueft -> es faellt jeweils genau die danach benannte. Co-Authored-By: Claude Opus 5 --- frontend/CLAUDE.md | 2 + frontend/src/live.js | 201 ++++++++++++++++++++++++++++++++++++ frontend/tests/live.test.js | 187 +++++++++++++++++++++++++++++++++ 3 files changed, 390 insertions(+) create mode 100644 frontend/src/live.js create mode 100644 frontend/tests/live.test.js diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index f2f82f7..464368d 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -77,6 +77,8 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der `cheapPathOn` lebt als UI-State in `app.js`. Tests: `tests/*.test.js`. - **Was entscheidbar ist, gehört in ein eigenes Modul** — auch bei Features, die wie reine UI aussehen: `remote.js` (Pad-URLs normalisieren, D31), + `live.js` (Server-Dokumente: Adressen, Zeilen-Diff, Cursor-Rechnung, wann eine + Feed-Antwort angewendet werden darf — D76), `warnings.js` (Warnung → Text), `snapshots.js` (frühere Stände: wann entsteht ein Stand, was fliegt bei Platzmangel raus, wie sieht der Speicherinhalt aus). Dort steht **was gilt**, in `app.js` bleibt **woher die Werte kommen und wohin diff --git a/frontend/src/live.js b/frontend/src/live.js new file mode 100644 index 0000000..3e6af36 --- /dev/null +++ b/frontend/src/live.js @@ -0,0 +1,201 @@ +/* Werkbaum — gemeinsam am selben Dokument arbeiten (D76, headless). + + Hier steht die entscheidbare Hälfte des Clients (Hausregel, D54-Nachtrag 3): + Adressen, das Zeilen-Diff, die Cursor-Rechnung und die Regel, wann eine + Feed-Antwort überhaupt angewendet werden darf. Das Holen selbst, der Takt + und der ganze DOM-Kram bleiben in app.js. + + Das Diff-Modell ist dasselbe wie im Backend (`de.werkbaum.diff.LineDiff`): + Operationen gegen eine Basisversion, 0-basierte Zeilenindizes, aufsteigend + sortiert und überschneidungsfrei. Beide Seiten müssen Text **gleich in + Zeilen zerlegen**, sonst zeigen die Indizes auseinander — deshalb liegt + `lines()`/`text()` hier und nicht verstreut in app.js. */ + +/* ------------------------------------------------------------------ Adressen */ + +/* Adresse eines Server-Dokuments normalisieren (?live=…). Eingabe ist die + Dokument-URL, so wie sie jemand weitergibt: + `https://host/api/v1/documents/`. Query, Fragment und Schrägstriche am + Ende fallen weg, damit derselbe Link genau ein Dokument ergibt — dieselbe + Regel wie bei ?etherpad= (D31). + + Verlangt wird `/documents/` am Ende; das ist die Prüfung, ob überhaupt + eine Dokument-Adresse vorliegt. Erlaubt sind nur http(s), wie bei + ?sourceUrl= (D23). + + Rückgabe {doc, content, changes, id} oder null. `doc` ist zugleich Name und + Identität des Dokuments — die vollständige URL. */ +export function liveUrls(raw, base){ + let u; + try{ u = base ? new URL(raw, base) : new URL(raw); }catch(_){ return null; } + if(u.protocol !== 'http:' && u.protocol !== 'https:') return null; + const path = u.pathname.replace(/\/+$/, ''); + const m = /\/documents\/([0-9a-fA-F-]{36})$/.exec(path); + if(!m) return null; + u.pathname = path; u.search = ''; u.hash = ''; + const doc = u.href; + return {doc, content: doc + '/content', changes: doc + '/changes', id: m[1].toLowerCase()}; +} + +/* ------------------------------------------------------------------ Zeilen */ + +/* Zeilenenden auf LF (SPEC §12). Der Server normalisiert beim Speichern + autoritativ, der Client beim Laden — nur so hashen beide denselben Text. */ +export function normalize(text){ + return String(text == null ? '' : text).replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +/* Es gilt `text(lines(t)) === normalize(t)`: ein abschließendes LF ergibt eine + leere letzte Zeile, der leere Text ist genau eine leere Zeile. Identisch zur + Server-Seite. */ +export function lines(text){ return normalize(text).split('\n'); } +export function text(ls){ return ls.join('\n'); } + +/* Prüfsumme des Basistexts, `sha256:` — Pflichtfeld jedes Patches: Die + Versionsnummer bestätigt nur, dass die Basis dieselbe *Version* ist, nicht + dass beide Seiten sie *gleich lesen*. + + Braucht `crypto.subtle`, und das gibt es nur im sicheren Kontext (https oder + localhost). Auf `file://` schlägt es fehl — dort ist Live-Editing ohnehin + keine Frage, aber der Fehler soll benannt sein statt still. */ +export async function checksum(t){ + const subtle = globalThis.crypto && globalThis.crypto.subtle; + if(!subtle) throw new Error('crypto.subtle nicht verfügbar (kein sicherer Kontext)'); + const bytes = new TextEncoder().encode(normalize(t)); + const digest = await subtle.digest('SHA-256', bytes); + const hex = Array.from(new Uint8Array(digest), b => b.toString(16).padStart(2, '0')).join(''); + return 'sha256:' + hex; +} + +/* ------------------------------------------------------- Diff berechnen */ + +/* Obergrenze für die LCS-Tabelle; darüber wird der abweichende Abschnitt zu + einem einzigen `replace`. Bei einem so großen Unterschied ist das ohnehin + die ehrliche Beschreibung. Gleicher Wert wie im Backend. */ +const LCS_LIMIT = 1000000; + +/* Zeilen-Diff zwischen zwei Ständen; es gilt `applyOps(from, computeOps(from, + to)) == to`. Gemeinsamer Anfang und gemeinsames Ende fallen zuerst weg — der + übliche Fall (ein paar Zeichen in einer Zeile) kostet danach fast nichts. */ +export function computeOps(from, to){ + let head = 0; + const shortest = Math.min(from.length, to.length); + while(head < shortest && from[head] === to[head]) head++; + let tail = 0; + while(tail < shortest - head && from[from.length - 1 - tail] === to[to.length - 1 - tail]) tail++; + + const a = from.slice(head, from.length - tail); + const b = to.slice(head, to.length - tail); + + if(!a.length && !b.length) return []; + if(!a.length) return [{op: 'insert', index: head, lines: b}]; + if(!b.length) return [{op: 'delete', index: head, count: a.length}]; + if(a.length * b.length > LCS_LIMIT) return [{op: 'replace', index: head, count: a.length, lines: b}]; + return lcsOps(a, b, head); +} + +function lcsOps(a, b, offset){ + const n = a.length, m = b.length; + /* lcs[i][j] = Länge der längsten gemeinsamen Teilfolge von a[i..] und b[j..] */ + const lcs = Array.from({length: n + 1}, () => new Int32Array(m + 1)); + for(let i = n - 1; i >= 0; i--){ + for(let j = m - 1; j >= 0; j--){ + lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]); + } + } + const ops = []; + let i = 0, j = 0; + while(i < n || j < m){ + if(i < n && j < m && a[i] === b[j]){ i++; j++; continue; } + const removedFrom = i; + const inserted = []; + while(i < n || j < m){ + if(i < n && j < m && a[i] === b[j]) break; + if(j < m && (i === n || lcs[i][j + 1] >= lcs[i + 1][j])){ inserted.push(b[j]); j++; } + else i++; + } + const removed = i - removedFrom; + if(removed > 0 && inserted.length) ops.push({op: 'replace', index: offset + removedFrom, count: removed, lines: inserted}); + else if(removed > 0) ops.push({op: 'delete', index: offset + removedFrom, count: removed}); + else ops.push({op: 'insert', index: offset + removedFrom, lines: inserted}); + } + return ops; +} + +/* ---------------------------------------------------------- Diff anwenden */ + +function removedCount(op){ return op.op === 'insert' ? 0 : (op.count || 0); } +function insertedLines(op){ return op.op === 'delete' ? [] : (op.lines || []); } + +/* Wendet Operationen auf Zeilen an. Wirft, wenn sie nicht passen — ein + halb angewendetes Diff wäre schlimmer als ein Neuladen. */ +export function applyOps(base, ops){ + const out = []; + let cursor = 0; + for(const op of ops){ + const end = op.index + removedCount(op); + if(op.index < cursor || op.index < 0 || end > base.length){ + throw new Error('Diff passt nicht auf diesen Stand'); + } + out.push(...base.slice(cursor, op.index), ...insertedLines(op)); + cursor = end; + } + out.push(...base.slice(cursor)); + return out; +} + +/* ------------------------------------------------------------ Cursor */ + +/* Wohin wandert eine Zeile, wenn fremde Operationen angewendet werden? + Ohne diese Rechnung spränge die Schreibmarke bei jeder fremden Änderung + weiter oben im Dokument — genau das, was „kein Neuladen" verhindern soll. + + Zeilen **innerhalb** eines ersetzten oder gelöschten Bereichs haben kein + Gegenüber; sie landen am Anfang des Bereichs. Das ist die verlässlichste + Antwort: dort, wo die fremde Änderung eingegriffen hat. */ +export function mapLine(lineIndex, ops){ + let delta = 0; + for(const op of ops){ + const start = op.index; + const end = start + removedCount(op); + if(end <= lineIndex) delta += insertedLines(op).length - removedCount(op); + else if(start <= lineIndex) return start + delta; /* mitten im Eingriff */ + else break; /* Ops sind sortiert */ + } + return lineIndex + delta; +} + +/* Zeichenposition → {line, col}; die Umkehrung braucht die neuen Zeilen. */ +export function caretToLineCol(t, offset){ + const before = normalize(t).slice(0, Math.max(0, offset)); + const line = before.split('\n').length - 1; + const col = before.length - (before.lastIndexOf('\n') + 1); + return {line, col}; +} + +export function lineColToCaret(ls, line, col){ + const clampedLine = Math.max(0, Math.min(line, ls.length - 1)); + let offset = 0; + for(let i = 0; i < clampedLine; i++) offset += ls[i].length + 1; + return offset + Math.max(0, Math.min(col, ls[clampedLine].length)); +} + +/* --------------------------------------------------------------- Feed */ + +/* Darf diese Feed-Antwort angewendet werden? + + Eine gepufferte Antwort darf **nur** angewendet werden, wenn ihr + `fromVersion` zur aktuellen Schattenkopie passt (D76). Sonst wendet der + Client dieselben Operationen doppelt an — der Fall tritt ein, wenn Feed und + 409-Antwort beide dasselbe fremde Diff liefern. + + 'apply' – Operationen anwenden + 'replace' – Volltext übernehmen (Basis verdichtet oder Erstkontakt) + 'skip' – nichts tun (schon gesehen oder passt nicht auf unseren Stand) */ +export function feedAction(feed, shadowVersion){ + if(!feed || typeof feed.currentVersion !== 'number') return 'skip'; + if(feed.currentVersion <= shadowVersion) return 'skip'; + if(typeof feed.content === 'string' && feed.fromVersion == null) return 'replace'; + if(Array.isArray(feed.ops) && feed.fromVersion === shadowVersion) return 'apply'; + return 'skip'; +} diff --git a/frontend/tests/live.test.js b/frontend/tests/live.test.js new file mode 100644 index 0000000..dcd18b9 --- /dev/null +++ b/frontend/tests/live.test.js @@ -0,0 +1,187 @@ +import {describe, it, expect} from 'vitest'; +import { + liveUrls, normalize, lines, text, computeOps, applyOps, + mapLine, caretToLineCol, lineColToCaret, feedAction, +} from '../src/live.js'; + +describe('Adressen', () => { + const doc = 'https://werkbaum.example/api/v1/documents/3f2a1b4c-5d6e-4f70-8a91-b2c3d4e5f607'; + + it('erkennt eine Dokument-Adresse und leitet die Endpunkte ab', () => { + const u = liveUrls(doc); + expect(u.doc).toBe(doc); + expect(u.content).toBe(doc + '/content'); + expect(u.changes).toBe(doc + '/changes'); + expect(u.id).toBe('3f2a1b4c-5d6e-4f70-8a91-b2c3d4e5f607'); + }); + + it('schneidet Query, Fragment und Schrägstriche ab', () => { + // Derselbe Link soll genau ein Dokument ergeben, gleich wie er kam. + expect(liveUrls(doc + '/?x=1#top').doc).toBe(doc); + }); + + it('weist an, was keine Dokument-Adresse ist', () => { + expect(liveUrls('https://werkbaum.example/api/v1/documents')).toBe(null); + expect(liveUrls('https://werkbaum.example/api/v1/documents/keine-uuid')).toBe(null); + }); + + it('erlaubt nur http und https', () => { + expect(liveUrls('javascript:alert(1)')).toBe(null); + expect(liveUrls('file:///api/v1/documents/3f2a1b4c-5d6e-4f70-8a91-b2c3d4e5f607')).toBe(null); + }); + + it('löst relative Angaben gegen die Seite auf', () => { + const u = liveUrls('/api/v1/documents/3f2a1b4c-5d6e-4f70-8a91-b2c3d4e5f607', + 'https://werkbaum.example/editor/'); + expect(u.doc).toBe(doc); + }); +}); + +describe('Zeilen', () => { + it('zerlegen und zusammensetzen sind zueinander invers', () => { + expect(text(lines('a\nb\nc'))).toBe('a\nb\nc'); + }); + + it('ein abschliessendes LF ergibt eine leere letzte Zeile', () => { + expect(lines('a\n')).toEqual(['a', '']); + }); + + it('der leere Text ist genau eine leere Zeile', () => { + expect(lines('')).toEqual(['']); + }); + + it('CRLF und CR werden auf LF normalisiert', () => { + expect(lines('a\r\nb\rc')).toEqual(['a', 'b', 'c']); + expect(normalize('a\r\nb')).toBe('a\nb'); + }); +}); + +describe('Diff berechnen', () => { + const basis = ['a', 'b', 'c', 'd']; + const rundreise = (von, nach) => expect(applyOps(von, computeOps(von, nach))).toEqual(nach); + + it('gleiche Stände ergeben kein Diff', () => { + expect(computeOps(basis, basis)).toEqual([]); + }); + + it('eine geänderte Zeile ergibt genau ein replace', () => { + expect(computeOps(basis, ['a', 'B', 'c', 'd'])) + .toEqual([{op: 'replace', index: 1, count: 1, lines: ['B']}]); + }); + + it('eine eingefügte Zeile ergibt genau ein insert', () => { + expect(computeOps(basis, ['a', 'b', 'neu', 'c', 'd'])) + .toEqual([{op: 'insert', index: 2, lines: ['neu']}]); + }); + + it('eine entfernte Zeile ergibt genau ein delete', () => { + expect(computeOps(basis, ['a', 'c', 'd'])) + .toEqual([{op: 'delete', index: 1, count: 1}]); + }); + + it('Anhängen an das Dokumentende', () => { + expect(computeOps(basis, [...basis, 'e'])) + .toEqual([{op: 'insert', index: 4, lines: ['e']}]); + }); + + it('identische Zeilen weiter unten verwirren die Zuordnung nicht', () => { + // Leerzeilen und wiederholte Einrückung sind in der Notation Alltag. + rundreise(['', 'a', '', 'a', ''], ['', 'a', '', 'a', '', 'b']); + }); + + it('mehrere getrennte Änderungen ergeben mehrere Operationen', () => { + const von = ['a', 'b', 'c', 'd', 'e', 'f']; + const nach = ['a', 'B', 'c', 'd', 'neu', 'e', 'f']; + expect(computeOps(von, nach)).toEqual([ + {op: 'replace', index: 1, count: 1, lines: ['B']}, + {op: 'insert', index: 4, lines: ['neu']}, + ]); + rundreise(von, nach); + }); + + it('ein berechnetes Diff ist immer anwendbar', () => { + const von = lines('%% Plan\n- [~] Wurzel (XL)\n - [x] Eins (S)\n - [ ] Zwei (M)\n'); + const nach = lines('%% Plan neu\n- [~] Wurzel (XL)\n + [?] Zugabe (S)\n - [ ] Zwei (L)\n'); + rundreise(von, nach); + }); + + it('ein Plan mit einer geänderten Zeile bleibt sparsam', () => { + const von = Array.from({length: 300}, (_, i) => ` - [ ] Knoten ${i} (S)`); + const nach = [...von]; + nach[150] = ' - [x] Knoten 150 (S)'; + expect(computeOps(von, nach)) + .toEqual([{op: 'replace', index: 150, count: 1, lines: [' - [x] Knoten 150 (S)']}]); + }); +}); + +describe('Diff anwenden', () => { + const basis = ['a', 'b', 'c', 'd']; + + it('mehrere Operationen wirken alle gegen dieselbe Basis', () => { + expect(applyOps(basis, [ + {op: 'insert', index: 1, lines: ['neu']}, + {op: 'delete', index: 3, count: 1}, + ])).toEqual(['a', 'neu', 'b', 'c']); + }); + + it('ein Diff, das nicht passt, wird nicht halb angewendet', () => { + expect(() => applyOps(basis, [{op: 'delete', index: 7, count: 1}])).toThrow(); + expect(() => applyOps(basis, [{op: 'delete', index: 3, count: 2}])).toThrow(); + }); +}); + +describe('Cursor', () => { + it('eine Einfügung darüber schiebt die Zeile nach unten', () => { + expect(mapLine(5, [{op: 'insert', index: 2, lines: ['x', 'y']}])).toBe(7); + }); + + it('eine Löschung darüber zieht die Zeile nach oben', () => { + expect(mapLine(5, [{op: 'delete', index: 1, count: 2}])).toBe(3); + }); + + it('eine Änderung darunter lässt die Zeile stehen', () => { + expect(mapLine(2, [{op: 'insert', index: 5, lines: ['x']}])).toBe(2); + }); + + it('eine Zeile im Eingriff landet an dessen Anfang', () => { + // Sie hat kein Gegenüber mehr; der Anfang des Bereichs ist die + // verlässlichste Antwort - dort hat die fremde Änderung eingegriffen. + expect(mapLine(6, [{op: 'replace', index: 5, count: 3, lines: ['x']}])).toBe(5); + }); + + it('Zeile und Spalte überstehen die Umrechnung', () => { + const t = 'eins\nzwei\ndrei'; + const {line, col} = caretToLineCol(t, 7); /* "zw|ei" */ + expect([line, col]).toEqual([1, 2]); + expect(lineColToCaret(lines(t), line, col)).toBe(7); + }); + + it('eine zu grosse Spalte rutscht ans Zeilenende, nicht darüber hinaus', () => { + expect(lineColToCaret(['ab', 'c'], 1, 99)).toBe(4); + }); +}); + +describe('Feed-Antwort anwenden oder nicht', () => { + it('Operationen auf passender Basis werden angewendet', () => { + expect(feedAction({fromVersion: 4, currentVersion: 6, ops: []}, 4)).toBe('apply'); + }); + + it('eine Antwort auf fremder Basis wird übersprungen', () => { + // Sonst wendet der Client dieselben Ops doppelt an - der Fall tritt ein, + // wenn Feed und 409-Antwort beide dasselbe fremde Diff liefern. + expect(feedAction({fromVersion: 3, currentVersion: 6, ops: []}, 4)).toBe('skip'); + }); + + it('was wir schon haben, wird übersprungen', () => { + expect(feedAction({fromVersion: 4, currentVersion: 4, ops: []}, 4)).toBe('skip'); + }); + + it('Volltext ersetzt den Stand', () => { + expect(feedAction({fromVersion: null, currentVersion: 87, content: 'x'}, 4)).toBe('replace'); + }); + + it('nichts Verwertbares heisst nichts tun', () => { + expect(feedAction(null, 4)).toBe('skip'); + expect(feedAction({currentVersion: 9}, 4)).toBe('skip'); + }); +});