diff --git a/backend/docs/live-editing-proposal.md b/backend/docs/live-editing-proposal.md index aebe777..9f4435c 100644 --- a/backend/docs/live-editing-proposal.md +++ b/backend/docs/live-editing-proposal.md @@ -1,9 +1,10 @@ # Live-Editing über HTTP (Variante „Simpel") -Status: **Konzept entschieden** (D76), **Schritte 1–5 der Umsetzungsreihenfolge -gebaut** (Zeilen-Diff, zweistufige Historie, `PATCH /content`, Änderungsfeed, -Master-Passwort); der Client steht aus, ebenso das Umbenennen per -`PATCH /title` und damit das Ereignis `RENAMED`. Die offenen +Status: **umgesetzt** — alle sechs Schritte der Umsetzungsreihenfolge stehen +(Zeilen-Diff, zweistufige Historie, `PATCH /content`, Änderungsfeed, +Master-Passwort, Client). Offen bleiben das Umbenennen per `PATCH /title` +(und damit das Ereignis `RENAMED`), ein Eingabefeld für den Anzeigenamen und +die Präsenz-Anzeige. Die offenen Punkte des ersten Entwurfs sind beantwortet; die Begründungen stehen in `docs/DECISIONS.md` unter D76 und werden hier nicht wiederholt, sondern nur verwiesen. Was beim Bauen zusätzlich zu entscheiden war, steht dort in @@ -24,7 +25,9 @@ Nachtrag 4. deshalb ausschließlich auf **physischen Zeilen** und braucht keine IDs. - Bei echtem Gleichzeitig-Konflikt: Update ablehnen, **der Client entscheidet** (rebase, neu laden, verwerfen). Das Dokument darf nie - kaputtgehen. + kaputtgehen. **Der Client erkennt den Konflikt zusätzlich selbst**, wenn eine + fremde Änderung die Zeilen trifft, an denen gerade getippt wird — der Server + kann das nicht sehen, er kennt den ungesendeten Text nicht (D76-Nachtrag 7). ### Zugriff und Identität @@ -433,7 +436,8 @@ verworfen. 4. ~~`GET /changes` mit Long Polling, Volltext-Fall und Ereignistypen (Spec + Cucumber)~~ — gebaut, `ChangeNotifier` + `LiveEditingService` 5. ~~Master-Passwort für `GET /documents` (Spring Security)~~ — gebaut -6. Client-Anpassung (Feed-Schleife, lokales Anwenden, Konfliktdialog) +6. ~~Client-Anpassung (Feed-Schleife, lokales Anwenden, Konfliktdialog)~~ — + gebaut, `frontend/src/live.js` + `app.js`; Befunde in D76-Nachtrag 7 **Vor Schritt 4** steht die Vermessung der Zielumgebung (siehe „Betrieb") — sie bestimmt den `wait`-Wert und im Extremfall, ob Long Polling dort diff --git a/backend/src/main/kotlin/de/werkbaum/api/CorsConfiguration.kt b/backend/src/main/kotlin/de/werkbaum/api/CorsConfiguration.kt new file mode 100644 index 0000000..bc723cf --- /dev/null +++ b/backend/src/main/kotlin/de/werkbaum/api/CorsConfiguration.kt @@ -0,0 +1,46 @@ +package de.werkbaum.api + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.CorsConfigurationSource +import org.springframework.web.cors.UrlBasedCorsConfigurationSource + +/** + * Von welchen Herkünften darf der Editor die API ansprechen? + * + * Voreinstellung `*`. Das ist hier keine Nachlässigkeit, sondern das + * Zugriffsmodell (D76): Wer die unerratbare UUID hat, darf; wer sie nicht hat, + * findet sie auch über eine erlaubte Herkunft nicht. CORS schützt Cookies und + * mitgesendete Anmeldedaten — beides gibt es hier nicht (`credentials: omit`). + * Der Editor wiederum läuft je nach Installation überall: GitHub Pages, eigene + * Domain, Dev-Server, `file://`. + * + * Wer es enger will, setzt `werkbaum.cors.allowed-origins` auf die eigenen + * Adressen. + */ +@ConfigurationProperties(prefix = "werkbaum.cors") +data class CorsProperties( + val allowedOrigins: List = listOf("*"), +) + +@Configuration +class CorsConfiguration(private val properties: CorsProperties) { + + @Bean + fun corsConfigurationSource(): CorsConfigurationSource { + val config = CorsConfiguration().apply { + allowedOriginPatterns = properties.allowedOrigins + allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + // Authorization fuer das Master-Passwort, Content-Type fuer JSON. + allowedHeaders = listOf("Authorization", "Content-Type") + // Nichts Vertrauliches im Spiel; Cookies werden nie mitgesendet. + allowCredentials = false + maxAge = 3600 + } + return UrlBasedCorsConfigurationSource().apply { + registerCorsConfiguration("/api/**", config) + } + } +} diff --git a/backend/src/main/kotlin/de/werkbaum/api/SecurityConfiguration.kt b/backend/src/main/kotlin/de/werkbaum/api/SecurityConfiguration.kt index 1f2633f..8f2fb4b 100644 --- a/backend/src/main/kotlin/de/werkbaum/api/SecurityConfiguration.kt +++ b/backend/src/main/kotlin/de/werkbaum/api/SecurityConfiguration.kt @@ -75,6 +75,10 @@ class SecurityConfiguration { properties: MasterPasswordProperties, ): SecurityFilterChain = http + // CORS-Regeln gelten auch fuer die Preflight-Anfrage - die traegt + // keine Anmeldedaten und darf deshalb nicht an der Kette haengen + // bleiben. + .cors { } // Zustandslose API: keine Sitzung, kein CSRF-Token. Der Schutz // hängt am Passwort, nicht an einem Cookie – ein CSRF-Token // schützte hier nichts und bräche jeden Client. diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 2c2f57d..f7ea50e 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -37,6 +37,12 @@ werkbaum: # haelt einen Long-Poll 30 s durch, seine Zeitgrenzen liegen bei 300 s. max-wait: 25s + # Von welchen Herkuenften darf der Editor die API ansprechen? "*" ist die + # Voreinstellung: Zugriff regelt die unerratbare UUID, nicht die Herkunft, + # und Cookies werden nie mitgesendet. Enger geht ueber eine Liste. + cors: + allowed-origins: "*" + # Schutz der Dokumentenliste. BCrypt-Hash, NIE im Repository - er kommt aus # der Umgebung. Ohne ihn bleibt GET /documents gesperrt. master-password: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d2331f0..d163c90 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -17,6 +17,12 @@ the git history of `docs/examples/werkbaum.werkbaum`. A day can therefore carry a link without having a note (someone forgot to write one) — but never the reverse. +## 2026-08-26 + +- Plans can live on a Werkbaum backend now: open `?live=` and everyone edits the same text, seeing each other's changes without reloading +- When two people change the same lines, a bar asks whose version should win — everything else the server merges by itself +- The caret stays where you put it when someone else inserts lines above you + ## 2026-08-25 - The fold button cycles through four presets now: size M and smaller, everything off the cheapest path, everything, all open diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c20f579..8de1e78 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6196,3 +6196,69 @@ gehasht wurde, ein Wechsel des Verfahrens bricht nichts — und die Tests dürfe ausdrücklich vorgesehen („geprüft über Spring Security") und damit von der Rückfragepflicht der Wurzel-CLAUDE.md gedeckt. Sie ist zugleich der Platz für die spätere richtige Authentifizierung. + +**Nachtrag 7 — der Client: was der Live-Test gefunden hat (2026-08-26).** +Schritt 6 steht: `?live=` führt ein Server-Dokument, schickt nach +1,5 s Ruhe das Diff und hält einen Feed offen. Die entscheidbare Hälfte liegt +headless in `frontend/src/live.js`, die I/O in `app.js` (Hausregel, +D54-Nachtrag 3). Fünf Festlegungen und zwei Funde: + +**Der Name ist der Titel des Servers, nicht die URL.** Das weicht vom +Haupttext ab („der Name ist die URL", wie bei `?sourceUrl=` und `?etherpad=`), +und zwar mit Grund: Anders als eine Datei oder ein Pad **hat** ein +Server-Dokument einen Namen, und alle sehen denselben. Die vollständige +Adresse steht wie dort im Tooltip. Identität und Wiederfinden hängen +unverändert an der URL. + +**Konflikte entstehen beim Tippen, nicht erst beim Senden — und das war der +erste Fund.** Der Server sieht nur, was eingereicht wird; den ungesendeten +Text im Editor kennt er nicht. Mit laufendem Feed zieht die Schattenkopie +ständig nach, die eigene Basis ist also nie veraltet — ein 409 käme praktisch +nie zustande, und die fremde Zeile wäre **stillschweigend überschrieben**. +Deshalb prüft der Client beim Einblenden fremder Änderungen selbst, ob sie +sich mit dem gerade Getippten überschneiden, und stellt dann dieselbe Frage. +Ein Zustand, zwei Wege hinein: lokal erkannt oder vom Server gemeldet. + +**Die Frage lautet „wessen Fassung", nicht „welche Zeile".** Zwei Knöpfe, +ganzes Dokument: *Fremde übernehmen* setzt den Text auf den Server-Stand, +*Eigene durchsetzen* zieht nur die Schattenkopie nach und schickt den eigenen +Text darauf. Zeilenweises Zusammenführen im Konfliktfall wäre eine eigene +Oberfläche; verloren geht dabei ohnehin nichts — der verworfene Stand liegt in +den früheren Ständen (D54) und jede Version in der Historie des Servers. +Solange die Frage offen ist, ruhen Senden **und** Feed: Sonst zöge der Stand +unter der Frage weg, die gerade gestellt ist. + +**Kennung und laufende Nummer liegen im sessionStorage, also je Tab — das war +der zweite Fund.** Erst lag beides im localStorage. Nach einem Neuladen begann +`seq` wieder bei 1, die Kennung blieb — und der Server hielt die erste echte +Änderung für die Wiederholung der letzten von vorhin und tat **nichts**. Im +Live-Test sofort sichtbar, in keinem Unit-Test: Genau die Naht zwischen Modul +und Verdrahtung, vor der D54-Nachtrag 3 warnt. Je Tab ist zugleich die +richtige Aussage: Zwei Tabs sind zwei Schreiber; mit gemeinsamer Nummer +schickte der eine bald eine kleinere `seq` als der andere. + +**Fremde Änderungen werden nicht undo-fähig eingespielt.** Ein Strg+Z, das den +Beitrag eines anderen zurücknimmt, wäre eine Lüge über die Herkunft. Die +Schreibmarke wandert dagegen mit (`mapLine`) — ohne das spränge sie bei jeder +fremden Änderung weiter oben im Dokument, und „kein Neuladen" wäre nichts +wert. + +**CORS steht auf `*`, und das ist hier keine Nachlässigkeit.** Der Editor läuft +je nach Installation überall (Pages, eigene Domain, Dev-Server); Zugriff regelt +die unerratbare UUID, nicht die Herkunft, und Cookies werden nie mitgesendet +(`credentials: omit`). CORS schützt Anmeldedaten — die es hier nicht gibt. Wer +es enger will, setzt `werkbaum.cors.allowed-origins`. + +**Nachgemessen im Browser gegen das laufende Backend**, weil das der einzige +Ort ist, an dem sich das beweisen lässt: Ein Server-Dokument lädt und rendert; +eine fremde Änderung erscheint ohne Neuladen; Getipptes erreicht nach 1,5 s +den Server; zwei Änderungen an derselben Zeile öffnen das Konflikt-Band, und +beide Knöpfe tun, was sie sagen; die Schreibmarke steht nach zwei fremd +eingefügten Zeilen darüber unverändert bei Zeile+2, Spalte 8. **Werkzeuggrenze, +wie in D25 und D17-Nachtrag 4:** Der Automatisierungs-Tab meldet sich dauerhaft +als `document.hidden` — die Sichtbarkeits-Sperre des Feeds (Nachtrag 1) greift +also nachweislich, ließ sich aber nur mit gestellter Sichtbarkeit umgehen, um +alles Übrige zu sehen. + +**Offen bleibt** `PATCH /title` (und damit das Ereignis `RENAMED`), ein +Eingabefeld für den Anzeigenamen und die Präsenz-Anzeige. diff --git a/docs/SPEC.md b/docs/SPEC.md index 70b566a..1d5c152 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -818,6 +818,29 @@ Export- oder `/timeslider`-Pfad wird abgeschnitten. Siehe D31. +### Gemeinsam an einem Server-Dokument arbeiten (`?live=`) +Liegt der Plan auf einem Werkbaum-Backend, nimmt der Editor dessen +Dokument-Adresse: `…?live=https://example.org/api/v1/documents/`. +Anders als beim Pad (`?etherpad=`) wird hier **im Editor selbst geschrieben**, +und alle sehen die Änderungen der anderen, ohne neu zu laden. + +- **Das Textfeld bleibt beschreibbar.** Nach kurzer Ruhe (1,5 s) schickt der + Editor die Änderung als Zeilen-Diff; ein offener Abruf hält die Gegenrichtung + bereit und spielt fremde Änderungen ein. Die **Schreibmarke wandert mit** — + fügt jemand oberhalb Zeilen ein, bleibt sie an ihrer Stelle im Text. +- **Der Name ist der Titel des Dokuments** (alle sehen denselben), die + vollständige Adresse steht im Tooltip. +- **Überschneiden sich zwei Änderungen wirklich** — dieselben Zeilen —, fragt + ein Band oben, wessen Fassung gelten soll: *Fremde übernehmen* oder *Eigene + durchsetzen*. Alles andere führt der Server selbst zusammen, ohne zu fragen. + Verloren geht nichts: Der verworfene Stand bleibt in den früheren Ständen, + jede Version in der Historie des Servers. +- Der Abruf läuft **nur im sichtbaren Tab**; im Hintergrund ruht er und holt + beim Zurückkommen den Rückstand. + +Siehe D76 (Protokoll und Begründung) und +`backend/docs/live-editing-proposal.md`. + ### Legende im Editor-Panel Neben dem Textfeld steht eine aufklappbare **Legende** (Notation in Kurzform, abschließend eine Bedienungs-Zeile). Sie ist **scrollbar**, wenn ihr Inhalt diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 464368d..0886515 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -114,7 +114,18 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der Knoten-Eigenschaften dort in `nodeAria()` mitpflegen und dafür a11y-i18n-Keys (`a11y*`) in **allen 9 Sprachen** anlegen. Knoten sind `tabindex="0"` (Fokus = Lesereihenfolge), `#warn` ist eine Live-Region. -- Zustand wird im `localStorage` gehalten (noch kein Backend): `werkbaum-lang` +- **Server-Dokumente (`?live=`, D76):** `live.js` hält die entscheidbare Hälfte + (Adressen, Zeilen-Diff, Rebasen, Cursor-Rechnung, Feed-Regel), `app.js` die + Verdrahtung: `loadLive()` holt Text und Version und merkt beides als + **Schattenkopie**, `scheduleLivePush()` schickt nach 1,5 s Ruhe das Diff, + `runFeed()` hält einen Abruf offen (nur im sichtbaren Tab). Zwei Fallen, beide + im Live-Test gefunden (D76-Nachtrag 7): `clientId` **und** `seq` gehören in den + `sessionStorage` (je Tab, überlebt Neuladen — sonst hält der Server die erste + Änderung nach einem Reload für eine Wiederholung und tut nichts), und der + **Konflikt entsteht beim Tippen**, nicht erst beim Senden — der Server kennt + den ungesendeten Text nicht. Fremde Änderungen werden bewusst **nicht** + undo-fähig eingespielt. +- Zustand wird im `localStorage` gehalten (Server-Dokumente ausgenommen): `werkbaum-lang` (Sprache), `werkbaum-docs` (JSON-Array der Dokumente `[{id,name,text}]`), `werkbaum-active` (id des aktiven Dokuments), `werkbaum-src` (Spiegel des aktiven Texts, Abwärtskompatibilität), `werkbaum-ui` (JSON: Modus, verworfene, diff --git a/frontend/src/app.js b/frontend/src/app.js index 1a82ce4..3a32e6b 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -4,6 +4,7 @@ import { computeCheapPlan, overloadedAssignee, freshProdSet, initialCollapsed, n import { esc, renderTreeHtml, TIP_RULE } from './render.js'; import { formatWarning, warningText } from './warnings.js'; import { padUrls } from './remote.js'; +import * as live from './live.js'; import { depFragment, collectIds, matchIds, depIdAt, idLine } from './autocomplete.js'; import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel } from './snapshots.js'; @@ -913,6 +914,7 @@ src.addEventListener('keydown', e => { src.addEventListener('input', render); src.addEventListener('input', saveSrc); +src.addEventListener('input', scheduleLivePush); /* Server-Dokument: Diff nach 1,5 s Ruhe (D76) */ /* ---------- Sprung zwischen Diagramm und Text (D25) ---------- Jeder Knoten trägt seine Zeilennummer als `data-line` (render.js). @@ -2222,6 +2224,11 @@ const I18N = { padReadonly:"Wird im Pad bearbeitet — hier nur lesen.", padEdit:"Pad zum Bearbeiten öffnen", padRefresh:"Vom Pad neu laden", + liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/<uuid>)?", + 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?", + liveConflictTheirs:"Fremde übernehmen", + liveConflictMine:"Eigene durchsetzen", padWait:"Noch {seconds} s — Etherpad begrenzt die Abrufe", padRateLimitWarn:"Noch nicht neu geladen: Etherpad erlaubt nur wenige Abrufe je Zeitfenster (serienmäßig 10 pro 90 s), so häufiges Nachladen ist leider nicht möglich. In {seconds} s geht es wieder.", padViewTooltip:"Ansicht: {state} — klicken zum Wechseln", @@ -2335,6 +2342,11 @@ const I18N = { padReadonly:"Edited in the pad — read-only here.", padEdit:"Open the pad to edit", padRefresh:"Reload from the pad", + liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/<uuid>)?", + liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.", + liveConflictText:"Someone changed the same lines. Whose version should win?", + liveConflictTheirs:"Take theirs", + liveConflictMine:"Keep mine", padWait:"{seconds} s to go — Etherpad limits how often we may fetch", padRateLimitWarn:"Not reloaded yet: Etherpad only allows a few fetches per time window (10 per 90 s by default), so syncing this often is unfortunately not possible. Try again in {seconds} s.", padViewTooltip:"View: {state} — click to switch", @@ -2447,6 +2459,11 @@ const I18N = { padReadonly:"Se edita en el pad — aquí solo lectura.", padEdit:"Abrir el pad para editar", padRefresh:"Recargar desde el pad", + liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/<uuid>)?", + 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?", + liveConflictTheirs:"Tomar la ajena", + liveConflictMine:"Mantener la mía", padWait:"Faltan {seconds} s — Etherpad limita la frecuencia", padRateLimitWarn:"Aún no se ha recargado: Etherpad solo permite unas pocas descargas por ventana de tiempo (10 por 90 s de forma predeterminada), así que sincronizar tan a menudo no es posible. Vuelve a intentarlo en {seconds} s.", padViewTooltip:"Vista: {state} — clic para cambiar", @@ -2559,6 +2576,11 @@ const I18N = { padReadonly:"Modifié dans le pad — lecture seule ici.", padEdit:"Ouvrir le pad pour modifier", padRefresh:"Recharger depuis le pad", + liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/<uuid>) ?", + 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 ?", + liveConflictTheirs:"Prendre la sienne", + liveConflictMine:"Garder la mienne", padWait:"Encore {seconds} s — Etherpad limite la fréquence", padRateLimitWarn:"Pas encore rechargé : Etherpad n’autorise que quelques récupérations par fenêtre de temps (10 par 90 s par défaut), une synchronisation aussi fréquente n’est donc pas possible. Réessaie dans {seconds} s.", padViewTooltip:"Vue : {state} — cliquer pour changer", @@ -2671,6 +2693,11 @@ const I18N = { padReadonly:"Edytowane w padzie — tu tylko do czytania.", padEdit:"Otwórz pad do edycji", padRefresh:"Wczytaj ponownie z padu", + liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/<uuid>)?", + 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ć?", + liveConflictTheirs:"Przyjmij cudzą", + liveConflictMine:"Zachowaj własną", padWait:"Jeszcze {seconds} s — Etherpad ogranicza częstość", padRateLimitWarn:"Jeszcze nie wczytano ponownie: Etherpad dopuszcza tylko kilka pobrań w okresie (domyślnie 10 na 90 s), więc tak częsta synchronizacja nie jest możliwa. Spróbuj za {seconds} s.", padViewTooltip:"Widok: {state} — kliknij, aby zmienić", @@ -2783,6 +2810,11 @@ const I18N = { padReadonly:"Редактируется в паде — здесь только чтение.", padEdit:"Открыть пад для редактирования", padRefresh:"Обновить из пада", + liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/<uuid>)?", + liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.", + liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?", + liveConflictTheirs:"Принять чужую", + liveConflictMine:"Оставить свою", padWait:"Ещё {seconds} с — Etherpad ограничивает частоту", padRateLimitWarn:"Пока не обновлено: Etherpad разрешает лишь несколько загрузок за окно времени (по умолчанию 10 за 90 с), поэтому столь частая синхронизация невозможна. Повторите через {seconds} с.", padViewTooltip:"Вид: {state} — нажмите для переключения", @@ -2895,6 +2927,11 @@ const I18N = { padReadonly:"पैड में संपादित होता है — यहाँ केवल पढ़ें।", padEdit:"संपादित करने के लिए पैड खोलें", padRefresh:"पैड से फिर लोड करें", + liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/<uuid>)?", + liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।", + liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?", + liveConflictTheirs:"दूसरे का लें", + liveConflictMine:"अपना रखें", padWait:"{seconds} स॰ बाकी — Etherpad बार-बार लेने की सीमा रखता है", padRateLimitWarn:"अभी दोबारा लोड नहीं किया: Etherpad प्रति समय-खिड़की केवल कुछ ही बार लेने देता है (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार), इसलिए इतनी बार सिंक करना संभव नहीं है। {seconds} स॰ में फिर कोशिश करें।", padViewTooltip:"दृश्य: {state} — बदलने के लिए क्लिक करें", @@ -3018,6 +3055,11 @@ const I18N = { padReadonly:"在 Pad 中编辑 — 此处只读。", padEdit:"打开 Pad 进行编辑", padRefresh:"从 Pad 重新加载", + liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/<uuid>)吗?", + liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。", + liveConflictText:"有人改动了同样的行。以谁的版本为准?", + liveConflictTheirs:"采用对方的", + liveConflictMine:"保留我的", padWait:"还需 {seconds} 秒 — Etherpad 限制获取频率", padRateLimitWarn:"尚未重新加载:Etherpad 每个时间窗口只允许少量获取(默认每 90 秒 10 次),因此无法如此频繁地同步。请在 {seconds} 秒后再试。", padViewTooltip:"视图:{state} — 点击切换", @@ -3130,6 +3172,11 @@ const I18N = { padReadonly:"パッドで編集します — ここでは読み取り専用です。", padEdit:"編集するにはパッドを開く", padRefresh:"パッドから再読み込み", + liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/<uuid>)ですか。", + liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。", + liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。", + liveConflictTheirs:"相手の版", + liveConflictMine:"自分の版", padWait:"あと {seconds} 秒 — Etherpad は取得頻度を制限します", padRateLimitWarn:"まだ再読み込みしていません: Etherpad は一定時間内の取得回数を制限します(既定で 90 秒あたり 10 回)。これほど頻繁な同期はできません。{seconds} 秒後にもう一度お試しください。", padViewTooltip:"表示: {state} — クリックで切り替え", @@ -3970,6 +4017,7 @@ function deleteDoc(){ if(!d) return; if(!window.confirm(t('docDeleteConfirm', {name: d.name}))) return; if(padSource && padSource.id === d.id) stopPad(); /* danach gibt es nichts mehr zu holen (D31) */ + if(liveState && liveState.id === d.id) stopLive(); /* dito fürs Server-Dokument (D76) */ if(fileHandles.has(d.id)){ fileHandles.delete(d.id); idbDeleteHandle(d.id); } /* mit dem Dokument geht sein Datei-Handle (D72) */ docs = docs.filter(x => x.id !== d.id); if(snaps[d.id]){ delete snaps[d.id]; persistSnaps(snaps, localStorage); } /* mit dem Dokument gehen seine Stände (D54) */ @@ -4335,6 +4383,375 @@ async function loadRemoteSource(){ } } +/* ---------- Gemeinsam am selben Dokument arbeiten: ?live= (D76) ---------- + Der dritte Eingang für Text von außen — und der einzige, in den auch + zurückgeschrieben wird. Adressiert wird die Dokument-URL des Backends + (`…/api/v1/documents/`); Identität und Endpunkte leitet live.js daraus + ab, dort steht auch die Begründung. + + Der Ablauf in drei Sätzen: Beim Laden holen wir Text und Version und merken + uns beides als **Schattenkopie** — den Stand, den der Server kennt. Wer tippt, + schickt nach 1,5 s Ruhe das **Diff** von der Schattenkopie zum jetzigen Text. + Ein **Feed** hält die Gegenrichtung offen und wendet fremde Änderungen an, + ohne das Dokument neu zu laden — dafür wandert die Schreibmarke mit + (live.js, `mapLine`). + + Was hier NICHT passiert: Zusammenführen. Überschneiden sich zwei Änderungen + wirklich, entscheidet der Mensch (Konflikt-Band unten). Alles andere + verschiebt der Server selbst. */ +const LIVE_PARAM = 'live'; +const LIVE_DEBOUNCE_MS = 1500; /* Ruhe vor dem Senden; D76 */ +const LIVE_WAIT_S = 25; /* Wartezeit des Feeds; der Server klemmt sie ohnehin */ +const LIVE_RETRY_MS = 5000; /* nach einem Netzfehler, bevor der Feed erneut fragt */ + +/* Zufällige, pseudonyme Kennung dieses Clients — samt laufender Nummer. + Beide zusammen machen das Einreichen wiederholbar: Geht die Antwort + unterwegs verloren, erkennt der Server die Wiederholung und wendet sie nicht + ein zweites Mal an (D76). + + Beide liegen im **sessionStorage**, also je Tab: + - Über den Tab hinaus geteilt wären sie falsch. Zwei Tabs desselben Browsers + sind zwei Schreiber; mit gemeinsamer Nummer schickte der eine bald eine + kleinere `seq` als der andere — für den Server eine veraltete Nummer (422). + - Innerhalb des Tabs müssen sie das Neuladen überleben. Sonst finge die + Nummer wieder bei 1 an, und der Server hielte die erste echte Änderung für + die Wiederholung der letzten von vorhin und täte **nichts** — im Live-Test + genau so passiert. */ +const SS_CLIENT = 'werkbaum-client'; +const SS_SEQ = 'werkbaum-seq'; +function clientId(){ + let id = null; + try{ id = sessionStorage.getItem(SS_CLIENT); }catch(_){} + if(!id){ + id = 'c-' + Math.random().toString(36).slice(2) + Date.now().toString(36); + try{ sessionStorage.setItem(SS_CLIENT, id); }catch(_){} + } + return id; +} +function nextSeq(){ + let n = 0; + try{ n = parseInt(sessionStorage.getItem(SS_SEQ) || '0', 10) || 0; }catch(_){} + n++; + try{ sessionStorage.setItem(SS_SEQ, String(n)); }catch(_){} + return n; +} + +let liveState = null; /* {urls, id, version, shadow, seq, pushTimer, feedAbort, busy} */ +let liveConflict = null; /* offener Konflikt: bis der Mensch entscheidet, ruht alles */ + +function liveActive(){ return !!liveState && activeId === liveState.id; } + +/* Wer das Live-Dokument löscht, meint es (wie beim Pad, D31). */ +function stopLive(){ + if(!liveState) return; + if(liveState.pushTimer) clearTimeout(liveState.pushTimer); + if(liveState.feedAbort) liveState.feedAbort.abort(); + liveState = null; + liveConflict = null; + hideConflictBanner(); +} + +async function loadLive(){ + const raw = urlParam(LIVE_PARAM); + if(!raw) return; + const urls = live.liveUrls(raw, location.href); + if(!urls){ + sourceWarning = {type:'liveLoad', url: raw, error: 'not a Werkbaum document URL'}; + render(); + return; + } + liveState = {urls, id: 'live:' + urls.doc, version: 0, shadow: [''], + pushTimer: null, feedAbort: null, busy: false}; + try{ + const doc = await fetchJson(urls.doc); + adoptLive(doc); + runFeed(); + }catch(err){ + sourceWarning = {type:'liveLoad', url: urls.doc, error: (err && err.message) || String(err)}; + render(); + } +} + +async function fetchJson(url, options){ + const resp = await fetch(url, Object.assign({cache:'no-store', credentials:'omit'}, options)); + if(resp.status === 204) return null; + if(!resp.ok){ + const err = new Error('HTTP ' + resp.status); + err.status = resp.status; + try{ err.body = await resp.json(); }catch(_){} + throw err; + } + return await resp.json(); +} + +/* Server-Stand übernehmen: Text ins Dokument, Schattenkopie und Version merken. + Der Name ist der **Titel des Servers**, nicht die URL wie bei ?sourceUrl= und + ?etherpad= — anders als eine Datei oder ein Pad hat ein Server-Dokument einen + Namen, und alle sehen denselben. Die volle Adresse steht im Tooltip + (D76-Nachtrag 7). */ +function adoptLive(doc){ + const content = live.normalize(doc.content || ''); + liveState.version = doc.version; + liveState.shadow = live.lines(content); + flushActive(); + let d = docs.find(x => x.id === liveState.id); + if(d){ d.text = content; d.name = doc.title || liveState.urls.doc; } + else { d = {id: liveState.id, name: doc.title || liveState.urls.doc, text: content}; docs.push(d); } + d.source = liveState.urls.doc; + activeId = liveState.id; + sourceWarning = null; + computeFresh(liveState.id, content); + loadActiveIntoEditor(); + persistDocs(); +} + +/* ---------- Hinschicken ---------- */ + +/* Nach jeder Eingabe neu gestartet: Gesendet wird erst, wenn 1,5 s Ruhe ist. + Ohne den Takt entstünde je Tastendruck eine Version — die Historie wäre ein + Transaktionslog, und das Netz hätte zu tun. */ +function scheduleLivePush(){ + if(!liveActive() || liveConflict) return; + if(liveState.pushTimer) clearTimeout(liveState.pushTimer); + liveState.pushTimer = setTimeout(() => { liveState.pushTimer = null; pushLive(); }, + LIVE_DEBOUNCE_MS); +} + +async function pushLive(){ + if(!liveActive() || liveConflict || liveState.busy) return; + const now = live.lines(live.normalize(src.value)); + const ops = live.computeOps(liveState.shadow, now); + if(!ops.length) return; + + liveState.busy = true; + const seq = nextSeq(); + try{ + const body = { + baseVersion: liveState.version, + checksum: await live.checksum(live.text(liveState.shadow)), + clientId: clientId(), + displayName: displayName(), + seq, + ops, + }; + const result = await fetchJson(liveState.urls.content, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(body), + }); + /* Angenommen. Hat der Server verschoben, stehen die fremden Operationen in + `opsSinceBase`: Die Schattenkopie zieht erst darüber nach, dann kommt + unsere eigene Änderung darauf — verschoben um die fremde. Genau diese + Rechnung hat der Server auch gemacht; wir kommen deshalb auf denselben + Text, ohne ihn abholen zu müssen. */ + const foreign = (result.opsSinceBase || []); + const alt = liveState.shadow; + const meine = foreign.length ? live.rebaseOps(ops, foreign) : ops; + if(meine == null){ await reloadLive(); return; } /* kann nicht sein - dann lieber neu */ + liveState.shadow = live.applyOps( + foreign.length ? live.applyOps(alt, foreign) : alt, meine); + liveState.version = result.version; + if(foreign.length) applyForeign(alt, foreign, liveState.shadow, liveState.version); + }catch(err){ + handlePushError(err); + }finally{ + liveState.busy = false; + } +} + +function handlePushError(err){ + if(err && err.status === 409 && err.body){ + /* Der andere Weg in denselben Zustand: Unser Senden hat das Rennen gegen + den Feed gewonnen, und der Server hat die Überschneidung gesehen. */ + let serverLines; + try{ serverLines = live.applyOps(liveState.shadow, err.body.opsSinceBase || []); } + catch(_){ reloadLive(); return; } + openConflict(err.body.currentVersion, serverLines); + return; + } + if(err && (err.status === 422 || err.status === 404)){ + /* Nicht anwendbar oder weg: einmal neu laden ist der vereinbarte Ausweg. */ + sourceWarning = {type:'liveStale', error: (err.body && err.body.detail) || err.message}; + render(); + reloadLive(); + return; + } + sourceWarning = {type:'liveLoad', url: liveState.urls.doc, + error: (err && err.message) || String(err)}; + render(); +} + +async function reloadLive(){ + if(!liveState) return; + try{ adoptLive(await fetchJson(liveState.urls.doc)); }catch(_){ /* der Feed versucht es weiter */ } +} + +/* ---------- Herbekommen: der Feed ---------- */ + +/* Eine offene Anfrage, die der Server beantwortet, sobald sich etwas tut. + Läuft **nur im sichtbaren Tab** (D76-Nachtrag 1): Ein Hintergrund-Tab braucht + keinen Live-Feed, niemand schaut hin, und ohne HTTP/2 belegt jeder Tab eine + der sechs Verbindungen zur Herkunft. Beim Zurückkommen holt ein einziger + Abruf den Rückstand. */ +async function runFeed(){ + while(liveState){ + if(document.hidden || liveConflict){ await sleep(500); continue; } + const ctl = new AbortController(); + liveState.feedAbort = ctl; + try{ + const url = liveState.urls.changes + + '?since=' + liveState.version + '&wait=' + LIVE_WAIT_S; + const feed = await fetchJson(url, {signal: ctl.signal}); + if(feed) applyFeed(feed); + }catch(err){ + if(!liveState) return; + if(err && err.name === 'AbortError') continue; + if(err && err.status === 404){ stopLive(); return; } + await sleep(LIVE_RETRY_MS); + }finally{ + if(liveState) liveState.feedAbort = null; + } + } +} + +function sleep(ms){ return new Promise(r => setTimeout(r, ms)); } + +/* Beim Wegschalten die offene Verbindung **schließen**, nicht bloß die Antwort + ignorieren: Ohne HTTP/2 belegt jeder Tab eine der sechs Verbindungen zur + Herkunft, und ein Hintergrund-Tab braucht keinen Feed (D76-Nachtrag 1). Beim + Zurückkommen holt der nächste Durchlauf den Rückstand in einem Zug. */ +document.addEventListener('visibilitychange', () => { + if(document.hidden && liveState && liveState.feedAbort) liveState.feedAbort.abort(); +}); + +function applyFeed(feed){ + const what = live.feedAction(feed, liveState.version); + if(what === 'skip') return; + if(what === 'replace'){ + /* Volltext: die Basis ist verdichtet, ein Diff gibt es nicht mehr. */ + liveState.version = feed.currentVersion; + liveState.shadow = live.lines(live.normalize(feed.content)); + setLiveText(live.text(liveState.shadow), null); + return; + } + const alt = liveState.shadow; + liveState.shadow = live.applyOps(alt, feed.ops); + liveState.version = feed.currentVersion; + applyForeign(alt, feed.ops, liveState.shadow, liveState.version); +} + +/* Fremde Operationen auf den **sichtbaren** Text anwenden. + Hat der Nutzer inzwischen selbst getippt, ist sein Text nicht mehr die alte + Schattenkopie: Dann wird die fremde Änderung auf seinen Stand angewendet und + sein eigenes Diff beim nächsten Senden neu gebildet. Passt das nicht (die + Zeilen sind unter ihm weggezogen), bleibt sein Text stehen — das nächste + Senden klärt es, notfalls über einen Konflikt. */ +function applyForeign(oldShadow, ops, serverLines, version){ + const editor = live.lines(live.normalize(src.value)); + /* Was der Server noch nicht kennt: alles, was seit der Schattenkopie + getippt wurde. Die fremden Operationen zählen gegen die Schattenkopie - + um sie auf den getippten Text anzuwenden, müssen sie daran vorbei. */ + const ungesendet = live.computeOps(oldShadow, editor); + const verschoben = live.rebaseOps(ops, ungesendet); + if(verschoben == null){ + /* Überschneidung mit dem, was gerade getippt wird — **hier** entsteht der + Konflikt, nicht erst beim Senden. Ohne diese Stelle bliebe er praktisch + aus: Der Feed zieht die Schattenkopie nach, das nächste Senden hätte + eine aktuelle Basis, und die fremde Zeile wäre stillschweigend + überschrieben. Der Server kann das nicht sehen — er kennt den + ungesendeten Text nicht. */ + openConflict(version, serverLines); + return; + } + let after; + try{ after = live.applyOps(editor, verschoben); }catch(_){ return; } + const caret = live.caretToLineCol(src.value, src.selectionStart); + const line = live.mapLine(caret.line, verschoben); + setLiveText(live.text(after), live.lineColToCaret(after, line, caret.col)); +} + +/* Text setzen, ohne die eigene Änderungslogik erneut auszulösen. + Bewusst **nicht** undo-fähig geschrieben: Eine fremde Änderung ist nicht + meine Eingabe, und ein Strg+Z, das den Beitrag eines anderen zurücknimmt, + wäre eine Lüge über die Herkunft. */ +function setLiveText(text, caret){ + src.value = text; + if(caret != null){ src.selectionStart = src.selectionEnd = caret; } + const d = activeDoc(); + if(d) d.text = text; + persistDocs(); + render(); +} + +/* ---------- Konflikt ---------- */ + +/* 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){ + if(liveConflict) return; /* eine Frage zur Zeit */ + liveConflict = {version, serverLines}; + showConflictBanner(); +} + +/* Fremde Fassung übernehmen: Der eigene Text weicht. Verloren ist er nicht — + er steht in den früheren Ständen (D54), und jede Version steht in der + Historie des Servers. */ +function takeTheirs(){ + const c = liveConflict; + liveConflict = null; + hideConflictBanner(); + liveState.version = c.version; + liveState.shadow = c.serverLines; + setLiveText(live.text(c.serverLines), null); +} + +/* Eigene durchsetzen: Die Schattenkopie zieht auf den Server-Stand nach, der + eigene Text bleibt stehen. Das nächste Senden bildet das Diff dagegen — es + überschneidet sich dann per Konstruktion nicht mehr. */ +function keepMine(){ + const c = liveConflict; + liveConflict = null; + hideConflictBanner(); + liveState.version = c.version; + liveState.shadow = c.serverLines; + pushLive(); +} + +function showConflictBanner(){ + hideConflictBanner(); + const bar = document.createElement('div'); + bar.id = 'liveConflict'; + bar.className = 'live-conflict'; + bar.setAttribute('role', 'alertdialog'); + bar.innerHTML = '' + + '
' + + '' + + '
'; + bar.querySelector('span').textContent = t('liveConflictText'); + const theirs = bar.querySelector('.theirs'); + const mine = bar.querySelector('.mine'); + theirs.textContent = t('liveConflictTheirs'); + mine.textContent = t('liveConflictMine'); + theirs.addEventListener('click', takeTheirs); + mine.addEventListener('click', keepMine); + document.body.appendChild(bar); + theirs.focus(); +} + +function hideConflictBanner(){ + const el = document.getElementById('liveConflict'); + if(el) el.remove(); +} + +/* Anzeigename: ohne Anmeldung eine Behauptung (D76) — die Oberfläche darf ihn + nicht wie einen Nachweis aussehen lassen. Vorerst der gemerkte Name oder + nichts; ein Eingabefeld dafür kommt mit der Präsenz-Anzeige. */ +const LS_NAME = 'werkbaum-name'; +function displayName(){ + try{ return localStorage.getItem(LS_NAME) || undefined; }catch(_){ return undefined; } +} + /* ---------- Pad: auf Knopfdruck neu holen (D31) ---------- Kein Hintergrund-Takt. Der erste Entwurf holte alle 2,5 s selbsttätig — und lief damit in Etherpads **Drosselung**: `importExportRateLimiting` ist @@ -4796,6 +5213,7 @@ if('launchQueue' in window){ } applyMobile(); /* Mobil-Verhalten (nach Sprache/Restore) anwenden */ loadRemoteSource(); /* ?sourceUrl= / ?etherpad= nachladen (asynchron, D23/D31) */ +loadLive(); /* ?live= — Server-Dokument samt Feed (asynchron, D76) */ /* ---------- PWA: Service Worker (D73) ---------- Ein reiner Offline-Mantel (public/sw.js): Navigationen network-first, der diff --git a/frontend/src/live.js b/frontend/src/live.js index 3e6af36..614f2d0 100644 --- a/frontend/src/live.js +++ b/frontend/src/live.js @@ -144,6 +144,48 @@ export function applyOps(base, ops){ return out; } +/* ------------------------------------------------------------ Rebasen */ + +/* Überschneiden sich zwei Operationen derselben Basis? Dieselbe Regel wie im + Backend: `replace`/`delete` belegen `[index, index+count)`, `insert` ist ein + Punkt **zwischen** den Zeilen und kollidiert nur mit dem Inneren eines + fremden Bereichs. Zwei Einfügungen an derselben Stelle vertragen sich. */ +export function conflicts(a, b){ + const ai = a.index, ae = ai + removedCount(a); + const bi = b.index, be = bi + removedCount(b); + if(a.op === 'insert' && b.op === 'insert') return false; + if(a.op === 'insert') return bi < ai && ai < be; + if(b.op === 'insert') return ai < bi && bi < ae; + return ai < be && bi < ae; +} + +/* Verschiebt [ops] auf den Stand, der durch [onto] aus derselben Basis + entstanden ist. `null` heißt echte Überschneidung — dann darf nichts + angewendet werden, und der Mensch entscheidet. + + Der Client braucht das an zwei Stellen: um die eigene Schattenkopie + nachzuziehen, nachdem der Server verschoben hat, und um fremde Änderungen in + einen Text einzublenden, in dem schon ungesendet getippt wurde. */ +export function rebaseOps(ops, onto){ + if(!onto || !onto.length || !ops.length) return ops; + for(const mine of ops){ + for(const theirs of onto){ + if(conflicts(mine, theirs)) return null; + } + } + return ops.map(op => { + let delta = 0; + for(const other of onto){ + /* Eine fremde Einfügung an derselben Stelle zählt dazu: Sie ist bereits + bestätigt und steht deshalb oben. */ + if(other.index + removedCount(other) <= op.index){ + delta += insertedLines(other).length - removedCount(other); + } + } + return delta ? Object.assign({}, op, {index: op.index + delta}) : op; + }); +} + /* ------------------------------------------------------------ Cursor */ /* Wohin wandert eine Zeile, wenn fremde Operationen angewendet werden? diff --git a/frontend/src/style.css b/frontend/src/style.css index 3f2127c..0d85757 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1544,3 +1544,27 @@ .node:not(.folded) .fold{display:none!important} @page{margin:12mm} } + +/* ---------- Konflikt beim gemeinsamen Bearbeiten (D76) ---------- + Band oben, wie der Update-Hinweis: Es unterbricht nicht die Arbeit, steht + aber über allem, bis jemand entscheidet. In `--warn`, nicht in Petrol — es + ist keine Einladung, sondern eine Sache, die geklärt werden muss. */ +.live-conflict{ + position: fixed; inset: 0 0 auto 0; z-index: 1200; + display: flex; align-items: center; justify-content: space-between; + gap: 16px; padding: 10px 16px; + background: var(--warn, #B45309); color: #fff; + font: 500 14px/1.4 'IBM Plex Sans', system-ui, sans-serif; + box-shadow: 0 2px 8px rgba(0,0,0,.25); +} +.live-conflict-actions{ display: flex; gap: 8px; flex: 0 0 auto; } +.live-conflict button{ + font: 500 13px/1 'IBM Plex Sans', system-ui, sans-serif; + padding: 6px 10px; border-radius: 4px; cursor: pointer; + border: 1px solid #fff; background: transparent; color: #fff; +} +/* „Eigene durchsetzen" ist die Antwort, die nichts von der eigenen Arbeit + kostet - sie steht deshalb gefüllt da, ohne die andere zu verstecken. */ +.live-conflict button.mine{ background: #fff; color: var(--warn, #B45309); border-color: #fff; } +.live-conflict button:focus-visible{ outline: 2px solid #fff; outline-offset: 2px; } +@media print{ .live-conflict{ display: none; } } diff --git a/frontend/src/warnings.js b/frontend/src/warnings.js index 4225aaf..8e068dc 100644 --- a/frontend/src/warnings.js +++ b/frontend/src/warnings.js @@ -42,6 +42,11 @@ - padRateLimit { seconds } — zu früh nachgeladen; Werkbaum hat gar nicht erst geholt, um Etherpads Grenze nicht auszulösen (D31) + - liveLoad { url, error } — Server-Dokument (?live=, D76) nicht + erreichbar oder keine Dokument-Adresse + - liveStale { error } — eigene Änderung nicht anwendbar + (Prüfsumme, Index, Basis verdichtet); + der Client hat einmal neu geladen (D76) - sourceTimeout { url, seconds } — Abruf abgebrochen, Gegenseite zu langsam (D31). Eigener Typ, weil `sourceLoad` auf CORS zeigt — bei einem Zeitablauf schickt @@ -92,6 +97,10 @@ function build(w, t, esc){ return t('sourceLoadWarn', {url: esc(w.url), error: esc(w.error)}); case 'padRateLimit': return t('padRateLimitWarn', {seconds: w.seconds}); + case 'liveLoad': + return t('liveLoadWarn', {url: esc(w.url), error: esc(w.error)}); + case 'liveStale': + return t('liveStaleWarn', {error: esc(w.error)}); case 'sourceTimeout': return t('sourceTimeoutWarn', {url: esc(w.url), seconds: w.seconds}); default: