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 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-26 17:32:59 +02:00
co-authored by Claude Opus 5
parent bc8850a518
commit 5881be1a2c
3 changed files with 390 additions and 0 deletions
+2
View File
@@ -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
+201
View File
@@ -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/<uuid>`. 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/<uuid>` 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:<hex>` — 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';
}
+187
View File
@@ -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');
});
});