fix(live): Wiederverbindung sendet nie von selbst — Server-Stand oder Rückfrage (D89-Nachtrag 2)
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s

Ein Fenster, das mit altem Text aufwachte, schickte ihn beim Wiederverbinden
als Diff und überschrieb den aktuellen Plan (v660, v679). Jetzt entscheidet,
ob seit dem letzten Abgleich hier gearbeitet wurde: nein → Server-Stand
übernehmen (lokaler Text in die Sicherungen), ja → Konflikt-Band fragt.
Regel headless in live.js (reconnectAction), Bandtext in neun Sprachen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-09-04 12:34:01 +02:00
co-authored by Claude Fable 5.1
parent 2e408d956c
commit 28ff6844b0
6 changed files with 134 additions and 7 deletions
+39 -7
View File
@@ -3484,6 +3484,7 @@ const I18N = {
liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.",
liveConflictText:"Jemand hat dieselben Zeilen geändert. Wessen Fassung soll gelten?",
liveReconnectText:"Dieses Fenster war getrennt und hat eigene Änderungen — der Stand auf dem Server weicht ab. Wessen Fassung soll gelten?",
liveConflictTheirs:"Fremde übernehmen",
liveConflictMine:"Eigene durchsetzen",
riskTooltip:"High Risk Aufwand noch unklar.",
@@ -3627,6 +3628,7 @@ const I18N = {
liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.",
liveConflictText:"Someone changed the same lines. Whose version should win?",
liveReconnectText:"This window was disconnected and holds changes of its own — the server state differs. Whose version should win?",
liveConflictTheirs:"Take theirs",
liveConflictMine:"Keep mine",
riskTooltip:"High risk effort still unclear.",
@@ -3769,6 +3771,7 @@ const I18N = {
liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.",
liveConflictText:"Alguien cambió las mismas líneas. ¿Qué versión debe prevalecer?",
liveReconnectText:"Esta ventana estuvo desconectada y tiene cambios propios — el estado en el servidor difiere. ¿Qué versión debe prevalecer?",
liveConflictTheirs:"Tomar la ajena",
liveConflictMine:"Mantener la mía",
riskTooltip:"Alto riesgo esfuerzo aún incierto.",
@@ -3911,6 +3914,7 @@ const I18N = {
liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/&lt;uuid&gt;) ?",
liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.",
liveConflictText:"Quelqu'un a modifié les mêmes lignes. Quelle version doit l'emporter ?",
liveReconnectText:"Cette fenêtre a été déconnectée et contient ses propres modifications — l'état sur le serveur diffère. Quelle version doit l'emporter ?",
liveConflictTheirs:"Prendre la sienne",
liveConflictMine:"Garder la mienne",
riskTooltip:"Risque élevé effort encore incertain.",
@@ -4053,6 +4057,7 @@ const I18N = {
liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.",
liveConflictText:"Ktoś zmienił te same wiersze. Która wersja ma obowiązywać?",
liveReconnectText:"To okno było rozłączone i ma własne zmiany — stan na serwerze się różni. Która wersja ma obowiązywać?",
liveConflictTheirs:"Przyjmij cudzą",
liveConflictMine:"Zachowaj własną",
riskTooltip:"Wysokie ryzyko nakład jeszcze niejasny.",
@@ -4195,6 +4200,7 @@ const I18N = {
liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.",
liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?",
liveReconnectText:"Это окно было отключено и содержит собственные изменения — состояние на сервере отличается. Чья версия должна остаться?",
liveConflictTheirs:"Принять чужую",
liveConflictMine:"Оставить свою",
riskTooltip:"Высокий риск – оценка ещё не ясна.",
@@ -4337,6 +4343,7 @@ const I18N = {
liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।",
liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?",
liveReconnectText:"यह विंडो डिस्कनेक्ट थी और इसमें अपने बदलाव हैं — सर्वर की स्थिति अलग है। किसका संस्करण रहे?",
liveConflictTheirs:"दूसरे का लें",
liveConflictMine:"अपना रखें",
riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।",
@@ -4490,6 +4497,7 @@ const I18N = {
liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/&lt;uuid&gt;)吗?",
liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。",
liveConflictText:"有人改动了同样的行。以谁的版本为准?",
liveReconnectText:"此窗口曾断开连接并有自己的改动——服务器上的状态与此不同。以谁的版本为准?",
liveConflictTheirs:"采用对方的",
liveConflictMine:"保留我的",
riskTooltip:"高风险 – 工作量尚不明确。",
@@ -4632,6 +4640,7 @@ const I18N = {
liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/&lt;uuid&gt;)ですか。",
liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。",
liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。",
liveReconnectText:"このウィンドウは切断されていて独自の変更があります — サーバー側の状態は異なります。どちらの版を採りますか。",
liveConflictTheirs:"相手の版",
liveConflictMine:"自分の版",
riskTooltip:"高リスク – 規模はまだ不明。",
@@ -5787,6 +5796,7 @@ document.addEventListener('pointerdown', e => {
});
function loadActiveIntoEditor(){ const d = activeDoc(); src.value = d ? d.text : '';
liveSynced(); /* der Text im Feld ist jetzt der geladene Stand — nichts davon ist Arbeit dieses Fensters */
clearLens(); /* die Personen-Linse (D87) gilt je Dokument-Sitzung */
/* Vergleichsstand für den nächsten Snapshot (D54): Ohne ihn legte der
erste Takt nach dem Öffnen auch ein unverändertes Dokument weg. */
@@ -6396,6 +6406,17 @@ function nextSeq(){
let liveState = null; /* {urls, id, version, shadow, seq, pushTimer, feedAbort, busy} */
let liveConflict = null; /* offener Konflikt: bis der Mensch entscheidet, ruht alles */
/* Wurde in diesem Fenster seit dem letzten gelungenen Abgleich gearbeitet?
Zwei Zähler statt eines Flags: `liveEditSeq` zählt jede Textänderung an
einem Server-Dokument (auch ohne Sitzung das Falten auf einem toten
Telefon ist genau der Fall), `liveSyncSeq` hält den Stand, den der Server
zuletzt bestätigt hat oder den wir von ihm übernommen haben. Ein Senden,
während weiter getippt wird, gilt so nur für das, was es getragen hat.
Die Wiederverbindung fragt danach, statt den Text zu vergleichen
(D89-Nachtrag 2). */
let liveEditSeq = 0, liveSyncSeq = 0;
function liveEdited(){ return liveEditSeq !== liveSyncSeq; }
function liveSynced(){ liveSyncSeq = liveEditSeq; }
function liveActive(){ return !!liveState && activeId === liveState.id; }
@@ -6488,6 +6509,8 @@ function adoptLive(doc){
weckt den wartenden Feed 39 ms nach dem PATCH (D79). Sie bleibt ein
Debounce, kein Takt: Wer durchtippt, erzeugt weiterhin keine Version. */
function scheduleLivePush(){
const d = activeDoc();
if(d && String(d.id).startsWith('live:')) liveEditSeq++; /* gearbeitet — mit oder ohne Sitzung */
if(!liveActive() || liveConflict) return;
if(liveState.pushTimer) clearTimeout(liveState.pushTimer);
liveState.pushTimer = setTimeout(() => { liveState.pushTimer = null; pushLive(); },
@@ -6520,9 +6543,12 @@ function rescueSnapshot(docId, text){
/* Eine tote Sitzung heilt sich SELBST (Nutzer-Einwand zu D89: Ohne Reload
ist wieder alles tot?" nein): Der Wachhund versucht die Wiederverbindung,
alle 10 s, solange das aktive Dokument ein Server-Dokument ohne Sitzung
ist. Ungesendeter Text bleibt dabei STEHEN und geht als Diff an den Server
(die keepMine-Semantik aus dem Konflikt-Band) die Wiederverbindung darf
nicht kosten, was sie retten soll. */
ist. Sie sendet NIE von selbst (D89-Nachtrag 2): Wurde hier seit dem
letzten Abgleich nicht gearbeitet, gilt der Server-Stand der lokale
Text ist dann nur ein alter Spiegel; wurde gearbeitet, fragt das
Konflikt-Band, wessen Fassung gelten soll. Die frühere Fassung schickte
jeden abweichenden lokalen Text als Diff und ließ damit ein aufgewachtes
Telefon den Plan mit dem Stand vom Vortag überschreiben. */
let reconnectBusy = false, reconnectNext = 0;
async function reconnectLive(d){
if(reconnectBusy || Date.now() < reconnectNext) return;
@@ -6535,7 +6561,9 @@ async function reconnectLive(d){
const serverText = live.normalize(doc.content || '');
liveState = {urls, id: d.id, version: doc.version, shadow: live.lines(serverText),
pushTimer: null, feedAbort: null, busy: false};
if(live.normalize(src.value) !== serverText) pushLive();
const what = live.reconnectAction(liveEdited(), src.value, serverText);
if(what === 'adopt') adoptLive(doc); /* sichert den lokalen Text vorher (D89) */
else if(what === 'ask') openConflict(doc.version, live.lines(serverText), 'liveReconnectText');
runFeed();
if(sourceWarning && (sourceWarning.type === 'liveLoad' || sourceWarning.type === 'liveStale')){
sourceWarning = null;
@@ -6594,6 +6622,7 @@ async function pushLive(meilenstein){
const sitzung = liveState;
sitzung.busy = true;
const seq = nextSeq();
const editStand = liveEditSeq; /* was danach getippt wird, trägt dieses Senden nicht */
/* Die Basis, gegen die `ops` gerechnet sind VOR dem Warten festgehalten.
Sie hinterher aus `liveState` zu lesen hieße anzunehmen, dass sich
dazwischen nichts ändert; genau diese Annahme ist gebrochen, sobald der
@@ -6628,6 +6657,7 @@ async function pushLive(meilenstein){
sitzung.shadow = live.applyOps(
foreign.length ? live.applyOps(basis, foreign) : basis, meine);
sitzung.version = result.version;
liveSyncSeq = editStand;
/* Ein gelungener Abgleich räumt liegengebliebene Live-Warnungen SOFORT
weg nicht geladen" oder „ungesendet" neben funktionierendem Senden
wäre eine Lüge, und der Wachhund-Tick kann gedrosselt sein (1/min in
@@ -6737,6 +6767,7 @@ function applyFeed(feed){
liveState.version = feed.currentVersion;
liveState.shadow = live.lines(neu);
setLiveText(live.text(liveState.shadow), null);
liveSynced();
return;
}
const alt = liveState.shadow;
@@ -6808,9 +6839,9 @@ function setLiveText(text, caret){
/* Echte Überschneidung: Der Server hat abgelehnt, und jetzt entscheidet der
Mensch. Bis dahin ruhen Senden und Feed sonst zöge der Stand unter der
Frage weg, die gerade gestellt ist. */
function openConflict(version, serverLines){
function openConflict(version, serverLines, textKey){
if(liveConflict) return; /* eine Frage zur Zeit */
liveConflict = {version, serverLines};
liveConflict = {version, serverLines, textKey: textKey || 'liveConflictText'};
showConflictBanner();
}
@@ -6829,6 +6860,7 @@ function takeTheirs(){
liveState.version = c.version;
liveState.shadow = c.serverLines;
setLiveText(live.text(c.serverLines), null);
liveSynced();
}
/* Eigene durchsetzen: Die Schattenkopie zieht auf den Server-Stand nach, der
@@ -6853,7 +6885,7 @@ function showConflictBanner(){
'<div class="live-conflict-actions">' +
'<button type="button" class="theirs"></button>' +
'<button type="button" class="mine"></button></div>';
bar.querySelector('span').textContent = t('liveConflictText');
bar.querySelector('span').textContent = t(liveConflict.textKey);
const theirs = bar.querySelector('.theirs');
const mine = bar.querySelector('.mine');
theirs.textContent = t('liveConflictTheirs');
+17
View File
@@ -302,3 +302,20 @@ export function feedAction(feed, shadowVersion, pushInFlight){
if(Array.isArray(feed.ops) && feed.fromVersion === shadowVersion) return 'apply';
return 'skip';
}
/* Was die Wiederverbindung nach einer toten Sitzung mit dem lokalen Text
tut (D89-Nachtrag 2). Die erste Fassung schickte ihn, sobald er vom Server
abwich — und konnte nicht unterscheiden, ob er NEUER ist als der Server
(ungesendete Arbeit) oder ÄLTER (ein Fenster, das lange geschlafen hat):
Ein Telefon, das mit dem Stand vom Vortag aufwachte, schrieb ihn als
Vollersetzung über den aktuellen Plan. Entscheidend ist deshalb nicht der
Vergleich der Texte, sondern ob in diesem Fenster seit dem letzten
gelungenen Abgleich GEARBEITET wurde.
'none' Texte gleich: nur Version und Schattenkopie übernehmen
'adopt' nicht gearbeitet: der Server-Stand gilt, der lokale weicht
'ask' gearbeitet: beide Stände existieren, ein Mensch entscheidet */
export function reconnectAction(edited, localText, serverText){
if(normalize(localText) === normalize(serverText)) return 'none';
return edited ? 'ask' : 'adopt';
}