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
+1
View File
@@ -19,6 +19,7 @@ reverse.
## 2026-09-04 ## 2026-09-04
- Fix: a shared document's reconnect no longer pushes whatever text the window holds — a phone waking up with yesterday's state overwrote the current plan twice; now the server state is taken when nothing was edited here, and the conflict bar asks when it was
- A `mirror-docs` script runs a list of shared documents through `pull-doc --git-commit` and pushes once — as a 15-minute timer on the backend host it gives a shared plan a git history without anyone remembering to run it - A `mirror-docs` script runs a list of shared documents through `pull-doc --git-commit` and pushes once — as a 15-minute timer on the backend host it gives a shared plan a git history without anyone remembering to run it
## 2026-09-03 ## 2026-09-03
+52
View File
@@ -7815,6 +7815,58 @@ Entscheidungen: **D94** und `docs/rfc/002-mehrfenster.md`. Der Präsenz-Kanal
(Herzschlag, Timeout, Notluke) und der modale Dialog sind ersatzlos (Herzschlag, Timeout, Notluke) und der modale Dialog sind ersatzlos
ausgebaut; die drei anderen Netze (Sicherungen, Rettung, Wachhund) bleiben. ausgebaut; die drei anderen Netze (Sicherungen, Rettung, Wachhund) bleiben.
**Nachtrag 2 — die Wiederverbindung sendet nie von selbst (2026-09-04).**
Der zweite Datenverlust am selben Dokument, diesmal nachgemessen bis zur
Ursache: Version 679 (11:49 Uhr) kam von einem anderen Client (der PWA auf
dem Telefon) und war byte-genau Version 667 — der letzte Stand, den das
Telefon am Vortag gesehen hatte — plus drei Faltmarken. Die 16 Zeilen aus
668 bis 671 waren weg; der Nutzer hat 671 per Rollback (v680) zurückgeholt.
Dasselbe Muster wie v660 am Abend davor (byte-gleich mit v339).
**Der Mechanismus stand im D90-Nachtrag, als Rettung gemeint.** Die
Wiederverbindung nach einer toten Sitzung holte den Server-Stand als
Schattenkopie und schickte dann jeden abweichenden lokalen Text als Diff —
„ungesendeter Text bleibt stehen, die keepMine-Semantik". Sie konnte nicht
unterscheiden, ob der lokale Text **neuer** ist als der Server (ungesendete
Arbeit) oder **älter** (ein Fenster, das lange geschlafen hat): In beiden
Fällen ist er „anders", und die alte Fassung ging als Diff gegen den
aktuellen Server hinaus, was einer Vollersetzung gleichkommt. Die Kette auf
dem Telefon: Die PWA wacht auf, der erste Abruf scheitert (Funk noch aus),
die Sitzung ist tot, der Editor zeigt den lokal gespeicherten Text vom
Vortag. Ein Tipp auf ein Falt-Zeichen ändert ihn (Falten schreibt in den
Text, D38-Nachtrag 2). Der Wachhund verbindet neu, sieht „lokal ≠ Server"
und schickt.
**Entscheidend ist nicht der Vergleich der Texte, sondern ob hier gearbeitet
wurde.** Zwei Zähler in app.js: einer zählt jede Textänderung an einem
Server-Dokument — mit oder ohne Sitzung, denn das Falten auf einem toten
Telefon ist genau der Fall —, der andere hält den Stand, den der Server
zuletzt bestätigt hat oder den wir von ihm übernommen haben (gelungenes
Senden, Laden, Volltext aus dem Feed, *Fremde übernehmen*). Ein Senden,
während weiter getippt wird, bestätigt nur, was es getragen hat. Die Regel
selbst steht headless in `live.js` (`reconnectAction`, D54-Nachtrag 3):
Texte gleich → nichts zu entscheiden; nicht gearbeitet → der Server gilt,
der lokale Text wandert in die lokalen Sicherungen; gearbeitet → das
vorhandene Konflikt-Band fragt, mit eigenem Text („Dieses Fenster war
getrennt und hat eigene Änderungen — der Stand auf dem Server weicht ab").
Nichts geschieht stumm, und beide Stände existieren, bis ein Mensch
entscheidet.
**Nachgemessen** im Browser gegen ein lokales Backend, das Telefon-Szenario
nachgestellt: Server auf v2 gesetzt, Backend gestoppt, Seite geladen — Chip
„getrennt", Editor zeigt den alten Spiegel (v1). Backend wieder da, nichts
angefasst: nach ≤ 20 s zeigt der Editor v2, der Server steht **unverändert
auf v2** (vorher hätte das Fenster hier v3 mit dem alten Text geschrieben).
Zweiter Durchlauf mit einer getippten Zeile im getrennten Zustand: Das Band
erscheint mit dem neuen Text, der Server bleibt auf v2, der lokale Text
steht; *Eigene durchsetzen* schreibt v3 mit genau dieser Zeile. 669 Tests,
davon 3 neue.
**Was bleibt:** Ein Fenster, das mit **ungesendeter** Arbeit ganz beendet
wird (Android räumt die PWA ab), startet mit dem Server-Stand; die Arbeit
liegt dann in den lokalen Sicherungen (Netz 3 aus dem Haupttext), nicht im
Editor — unverändert seit D89.
## D90 — Die Dokumentart steht grau hinter dem Namens-Chip ## D90 — Die Dokumentart steht grau hinter dem Namens-Chip
Nutzerwunsch, unmittelbar aus dem D89-Vorfall: Hinter der Brotkrume Nutzerwunsch, unmittelbar aus dem D89-Vorfall: Hinter der Brotkrume
(„Werkbaum Name", D81) soll erkennbar sein, was für ein Dokument vorn ist — („Werkbaum Name", D81) soll erkennbar sein, was für ein Dokument vorn ist —
+7
View File
@@ -951,6 +951,13 @@ anderen, ohne neu zu laden.
wandert er in die **lokalen Sicherungen**: Für Server-Dokumente sammelt der wandert er in die **lokalen Sicherungen**: Für Server-Dokumente sammelt der
10-Minuten-Takt sie wieder, und das Uhr-Menü zeigt sie als eigenen Abschnitt 10-Minuten-Takt sie wieder, und das Uhr-Menü zeigt sie als eigenen Abschnitt
unter den Server-Meilensteinen. Siehe D89. unter den Server-Meilensteinen. Siehe D89.
- **Eine tote Sitzung verbindet sich von selbst neu — und sendet dabei nie
von selbst.** Wurde in diesem Fenster seit dem letzten gelungenen Abgleich
nicht gearbeitet, gilt der Stand des Servers; der lokale Text ist dann nur
ein alter Spiegel und wandert in die lokalen Sicherungen. Wurde gearbeitet,
fragt das Konflikt-Band, wessen Fassung gelten soll. Ein Fenster, das mit
dem Stand vom Vortag aufwacht, kann den Plan so nicht mehr überschreiben.
Siehe D89-Nachtrag 2.
**Ein zweites Werkbaum-Fenster desselben Browsers** — Tab, Fenster oder PWA **Ein zweites Werkbaum-Fenster desselben Browsers** — Tab, Fenster oder PWA
— kollidiert nur noch am **selben nicht geteilten Dokument**: Text, Name und — kollidiert nur noch am **selben nicht geteilten Dokument**: Text, Name und
+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;)?", 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.", 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?", 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", liveConflictTheirs:"Fremde übernehmen",
liveConflictMine:"Eigene durchsetzen", liveConflictMine:"Eigene durchsetzen",
riskTooltip:"High Risk Aufwand noch unklar.", 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;)?", 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.", liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.",
liveConflictText:"Someone changed the same lines. Whose version should win?", 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", liveConflictTheirs:"Take theirs",
liveConflictMine:"Keep mine", liveConflictMine:"Keep mine",
riskTooltip:"High risk effort still unclear.", 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;)?", 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.", 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?", 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", liveConflictTheirs:"Tomar la ajena",
liveConflictMine:"Mantener la mía", liveConflictMine:"Mantener la mía",
riskTooltip:"Alto riesgo esfuerzo aún incierto.", 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;) ?", 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.", 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 ?", 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", liveConflictTheirs:"Prendre la sienne",
liveConflictMine:"Garder la mienne", liveConflictMine:"Garder la mienne",
riskTooltip:"Risque élevé effort encore incertain.", 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;)?", 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.", 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ć?", 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ą", liveConflictTheirs:"Przyjmij cudzą",
liveConflictMine:"Zachowaj własną", liveConflictMine:"Zachowaj własną",
riskTooltip:"Wysokie ryzyko nakład jeszcze niejasny.", riskTooltip:"Wysokie ryzyko nakład jeszcze niejasny.",
@@ -4195,6 +4200,7 @@ const I18N = {
liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.", liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.",
liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?", liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?",
liveReconnectText:"Это окно было отключено и содержит собственные изменения — состояние на сервере отличается. Чья версия должна остаться?",
liveConflictTheirs:"Принять чужую", liveConflictTheirs:"Принять чужую",
liveConflictMine:"Оставить свою", liveConflictMine:"Оставить свою",
riskTooltip:"Высокий риск – оценка ещё не ясна.", riskTooltip:"Высокий риск – оценка ещё не ясна.",
@@ -4337,6 +4343,7 @@ const I18N = {
liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।", liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।",
liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?", liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?",
liveReconnectText:"यह विंडो डिस्कनेक्ट थी और इसमें अपने बदलाव हैं — सर्वर की स्थिति अलग है। किसका संस्करण रहे?",
liveConflictTheirs:"दूसरे का लें", liveConflictTheirs:"दूसरे का लें",
liveConflictMine:"अपना रखें", liveConflictMine:"अपना रखें",
riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।", riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।",
@@ -4490,6 +4497,7 @@ const I18N = {
liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/&lt;uuid&gt;)吗?", liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/&lt;uuid&gt;)吗?",
liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。", liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。",
liveConflictText:"有人改动了同样的行。以谁的版本为准?", liveConflictText:"有人改动了同样的行。以谁的版本为准?",
liveReconnectText:"此窗口曾断开连接并有自己的改动——服务器上的状态与此不同。以谁的版本为准?",
liveConflictTheirs:"采用对方的", liveConflictTheirs:"采用对方的",
liveConflictMine:"保留我的", liveConflictMine:"保留我的",
riskTooltip:"高风险 – 工作量尚不明确。", riskTooltip:"高风险 – 工作量尚不明确。",
@@ -4632,6 +4640,7 @@ const I18N = {
liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/&lt;uuid&gt;)ですか。", liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/&lt;uuid&gt;)ですか。",
liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。", liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。",
liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。", liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。",
liveReconnectText:"このウィンドウは切断されていて独自の変更があります — サーバー側の状態は異なります。どちらの版を採りますか。",
liveConflictTheirs:"相手の版", liveConflictTheirs:"相手の版",
liveConflictMine:"自分の版", liveConflictMine:"自分の版",
riskTooltip:"高リスク – 規模はまだ不明。", riskTooltip:"高リスク – 規模はまだ不明。",
@@ -5787,6 +5796,7 @@ document.addEventListener('pointerdown', e => {
}); });
function loadActiveIntoEditor(){ const d = activeDoc(); src.value = d ? d.text : ''; 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 */ clearLens(); /* die Personen-Linse (D87) gilt je Dokument-Sitzung */
/* Vergleichsstand für den nächsten Snapshot (D54): Ohne ihn legte der /* Vergleichsstand für den nächsten Snapshot (D54): Ohne ihn legte der
erste Takt nach dem Öffnen auch ein unverändertes Dokument weg. */ 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 liveState = null; /* {urls, id, version, shadow, seq, pushTimer, feedAbort, busy} */
let liveConflict = null; /* offener Konflikt: bis der Mensch entscheidet, ruht alles */ 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; } 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 weckt den wartenden Feed 39 ms nach dem PATCH (D79). Sie bleibt ein
Debounce, kein Takt: Wer durchtippt, erzeugt weiterhin keine Version. */ Debounce, kein Takt: Wer durchtippt, erzeugt weiterhin keine Version. */
function scheduleLivePush(){ function scheduleLivePush(){
const d = activeDoc();
if(d && String(d.id).startsWith('live:')) liveEditSeq++; /* gearbeitet — mit oder ohne Sitzung */
if(!liveActive() || liveConflict) return; if(!liveActive() || liveConflict) return;
if(liveState.pushTimer) clearTimeout(liveState.pushTimer); if(liveState.pushTimer) clearTimeout(liveState.pushTimer);
liveState.pushTimer = setTimeout(() => { liveState.pushTimer = null; pushLive(); }, 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 /* Eine tote Sitzung heilt sich SELBST (Nutzer-Einwand zu D89: Ohne Reload
ist wieder alles tot?" nein): Der Wachhund versucht die Wiederverbindung, ist wieder alles tot?" nein): Der Wachhund versucht die Wiederverbindung,
alle 10 s, solange das aktive Dokument ein Server-Dokument ohne Sitzung 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 ist. Sie sendet NIE von selbst (D89-Nachtrag 2): Wurde hier seit dem
(die keepMine-Semantik aus dem Konflikt-Band) die Wiederverbindung darf letzten Abgleich nicht gearbeitet, gilt der Server-Stand der lokale
nicht kosten, was sie retten soll. */ 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; let reconnectBusy = false, reconnectNext = 0;
async function reconnectLive(d){ async function reconnectLive(d){
if(reconnectBusy || Date.now() < reconnectNext) return; if(reconnectBusy || Date.now() < reconnectNext) return;
@@ -6535,7 +6561,9 @@ async function reconnectLive(d){
const serverText = live.normalize(doc.content || ''); const serverText = live.normalize(doc.content || '');
liveState = {urls, id: d.id, version: doc.version, shadow: live.lines(serverText), liveState = {urls, id: d.id, version: doc.version, shadow: live.lines(serverText),
pushTimer: null, feedAbort: null, busy: false}; 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(); runFeed();
if(sourceWarning && (sourceWarning.type === 'liveLoad' || sourceWarning.type === 'liveStale')){ if(sourceWarning && (sourceWarning.type === 'liveLoad' || sourceWarning.type === 'liveStale')){
sourceWarning = null; sourceWarning = null;
@@ -6594,6 +6622,7 @@ async function pushLive(meilenstein){
const sitzung = liveState; const sitzung = liveState;
sitzung.busy = true; sitzung.busy = true;
const seq = nextSeq(); 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. /* Die Basis, gegen die `ops` gerechnet sind VOR dem Warten festgehalten.
Sie hinterher aus `liveState` zu lesen hieße anzunehmen, dass sich Sie hinterher aus `liveState` zu lesen hieße anzunehmen, dass sich
dazwischen nichts ändert; genau diese Annahme ist gebrochen, sobald der dazwischen nichts ändert; genau diese Annahme ist gebrochen, sobald der
@@ -6628,6 +6657,7 @@ async function pushLive(meilenstein){
sitzung.shadow = live.applyOps( sitzung.shadow = live.applyOps(
foreign.length ? live.applyOps(basis, foreign) : basis, meine); foreign.length ? live.applyOps(basis, foreign) : basis, meine);
sitzung.version = result.version; sitzung.version = result.version;
liveSyncSeq = editStand;
/* Ein gelungener Abgleich räumt liegengebliebene Live-Warnungen SOFORT /* Ein gelungener Abgleich räumt liegengebliebene Live-Warnungen SOFORT
weg nicht geladen" oder „ungesendet" neben funktionierendem Senden weg nicht geladen" oder „ungesendet" neben funktionierendem Senden
wäre eine Lüge, und der Wachhund-Tick kann gedrosselt sein (1/min in 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.version = feed.currentVersion;
liveState.shadow = live.lines(neu); liveState.shadow = live.lines(neu);
setLiveText(live.text(liveState.shadow), null); setLiveText(live.text(liveState.shadow), null);
liveSynced();
return; return;
} }
const alt = liveState.shadow; const alt = liveState.shadow;
@@ -6808,9 +6839,9 @@ function setLiveText(text, caret){
/* Echte Überschneidung: Der Server hat abgelehnt, und jetzt entscheidet der /* Echte Überschneidung: Der Server hat abgelehnt, und jetzt entscheidet der
Mensch. Bis dahin ruhen Senden und Feed sonst zöge der Stand unter der Mensch. Bis dahin ruhen Senden und Feed sonst zöge der Stand unter der
Frage weg, die gerade gestellt ist. */ Frage weg, die gerade gestellt ist. */
function openConflict(version, serverLines){ function openConflict(version, serverLines, textKey){
if(liveConflict) return; /* eine Frage zur Zeit */ if(liveConflict) return; /* eine Frage zur Zeit */
liveConflict = {version, serverLines}; liveConflict = {version, serverLines, textKey: textKey || 'liveConflictText'};
showConflictBanner(); showConflictBanner();
} }
@@ -6829,6 +6860,7 @@ function takeTheirs(){
liveState.version = c.version; liveState.version = c.version;
liveState.shadow = c.serverLines; liveState.shadow = c.serverLines;
setLiveText(live.text(c.serverLines), null); setLiveText(live.text(c.serverLines), null);
liveSynced();
} }
/* Eigene durchsetzen: Die Schattenkopie zieht auf den Server-Stand nach, der /* Eigene durchsetzen: Die Schattenkopie zieht auf den Server-Stand nach, der
@@ -6853,7 +6885,7 @@ function showConflictBanner(){
'<div class="live-conflict-actions">' + '<div class="live-conflict-actions">' +
'<button type="button" class="theirs"></button>' + '<button type="button" class="theirs"></button>' +
'<button type="button" class="mine"></button></div>'; '<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 theirs = bar.querySelector('.theirs');
const mine = bar.querySelector('.mine'); const mine = bar.querySelector('.mine');
theirs.textContent = t('liveConflictTheirs'); 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'; if(Array.isArray(feed.ops) && feed.fromVersion === shadowVersion) return 'apply';
return 'skip'; 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';
}
+18
View File
@@ -2,6 +2,7 @@ import {describe, it, expect} from 'vitest';
import { import {
liveUrls, normalize, lines, text, computeOps, applyOps, liveUrls, normalize, lines, text, computeOps, applyOps,
mapLine, caretToLineCol, lineColToCaret, feedAction, serverBase, documentsUrl, infoUrl, mapLine, caretToLineCol, lineColToCaret, feedAction, serverBase, documentsUrl, infoUrl,
reconnectAction,
} from '../src/live.js'; } from '../src/live.js';
describe('Adressen', () => { describe('Adressen', () => {
@@ -243,3 +244,20 @@ describe('Feed-Antwort anwenden oder nicht', () => {
expect(feedAction({fromVersion: null, currentVersion: 87, content: 'x'}, 4, true)).toBe('skip'); expect(feedAction({fromVersion: null, currentVersion: 87, content: 'x'}, 4, true)).toBe('skip');
}); });
}); });
describe('Wiederverbindung (D89-Nachtrag 2)', () => {
// Der Vorfall: Ein Telefon wacht mit dem Stand vom Vortag auf, die Sitzung
// ist tot, ein Tipp auf ein Falt-Zeichen — und die alte Fassung ging als
// Diff über den aktuellen Plan. Entscheidend ist nicht, ob die Texte
// abweichen, sondern ob hier gearbeitet wurde.
it('gleicher Text: nichts zu entscheiden', () => {
expect(reconnectAction(false, 'a\nb', 'a\nb')).toBe('none');
expect(reconnectAction(true, 'a\r\nb', 'a\nb')).toBe('none'); // Zeilenenden normalisiert
});
it('nicht gearbeitet: der Server gilt, auch wenn der lokale Text abweicht', () => {
expect(reconnectAction(false, 'alter Stand', 'neuer Stand')).toBe('adopt');
});
it('gearbeitet: fragen statt senden — beide Stände existieren', () => {
expect(reconnectAction(true, 'alter Stand plus Faltmarke', 'neuer Stand')).toBe('ask');
});
});