Compare commits
6
Commits
3cafc92d49
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db7b8004ee | ||
|
|
28ff6844b0 | ||
|
|
2e408d956c | ||
|
|
c87cf6212d | ||
|
|
5ec5118cee | ||
|
|
063d2c947b |
@@ -134,6 +134,13 @@ Datei committet — Datum, Titel und Version in der Nachricht, kein Commit ohne
|
||||
Änderung. Per Cron aufgerufen archiviert sich der Plan von selbst; `--open`
|
||||
öffnet die Datei danach in IntelliJ IDEA. Siehe `docs/DECISIONS.md` D88.
|
||||
|
||||
**Mehrere Dokumente, ohne Zutun:** `tools/mirror-docs [--push] <worktree>
|
||||
<liste>` schickt jede Zeile der Liste (`<uuid-oder-url> <pfad>`) durch
|
||||
`pull-doc --git-commit` und pusht einmal am Ende; eine UUID braucht die
|
||||
Backend-Basis in `--api` oder `WERKBAUM_API`. Als systemd-Timer auf dem
|
||||
Backend-Host spiegelt es die geteilten Pläne alle 15 Minuten in einen Branch —
|
||||
siehe D88, Nachtrag 3.
|
||||
|
||||
Das Backend einrichten: siehe [backend/README.md](backend/README.md) und
|
||||
`docs/DECISIONS.md` D76.
|
||||
|
||||
|
||||
@@ -129,6 +129,12 @@ title and version in the message, and no commit when nothing changed. Run it
|
||||
from cron and the plan archives itself; `--open` opens the file in IntelliJ
|
||||
IDEA afterwards. See `docs/DECISIONS.md` D88.
|
||||
|
||||
**Several documents, unattended:** `tools/mirror-docs [--push] <worktree>
|
||||
<list>` runs every line of the list (`<uuid-or-url> <path>`) through
|
||||
`pull-doc --git-commit` and pushes once at the end; a UUID needs the backend
|
||||
base in `--api` or `WERKBAUM_API`. As a systemd timer on the backend host it
|
||||
mirrors the shared plans into a branch every 15 minutes — see D88, addendum 3.
|
||||
|
||||
Setting up the backend: see [backend/README.md](backend/README.md) and
|
||||
`docs/DECISIONS.md` D76.
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ 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-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
|
||||
|
||||
## 2026-09-03
|
||||
|
||||
- The project moved to its own Gitea at git.javagil.de — the footer's repository and version links point there now, and GitHub stays a clone carrying the very same commits
|
||||
|
||||
@@ -7638,6 +7638,63 @@ Haupttext gelten fort; neu geprüft außerdem: flaglos außerhalb jedes
|
||||
Worktrees schreibt anstandslos, `--git-commit` dort lehnt mit klarer
|
||||
Meldung ab.
|
||||
|
||||
**Nachtrag 3 — `mirror-docs`: ein Timer auf dem Backend-Host spiegelt eine
|
||||
Liste von Dokumenten in einen eigenen Branch (2026-09-04).** Anlass war der
|
||||
zweite Datenverlust an demselben geteilten Dokument: Am 3.9. um 19:03 schrieb
|
||||
ein Fenster mit veralteter Schattenkopie den Stand vom Vorabend als Volltext
|
||||
zurück — die Server-Historie hatte den ganzen Tag (Version 659 war der letzte
|
||||
gute Stand, byte-genau wiederhergestellt), der Git-Spiegel aber nicht: Er lief
|
||||
von Hand, und der letzte Lauf lag vor der verlorenen Arbeit. Ein Spiegel, der
|
||||
nur läuft, wenn jemand daran denkt, ist an dem Tag nicht da, an dem man ihn
|
||||
braucht.
|
||||
|
||||
**Der Spiegel läuft auf dem Backend-Host, nicht auf dem Laptop.** Der Server
|
||||
ist rund um die Uhr an, `Linger=yes` ist gesetzt (D77), und er holt das
|
||||
Dokument von `127.0.0.1:9080` ohne den Proxy. Ein Timer auf dem Laptop hätte
|
||||
nur die Stunden abgedeckt, in denen der zu ist — mit `Persistent=true` zwar
|
||||
nachgeholt, aber nach der Lücke, nicht davor. Gebaut als systemd-User-Timer
|
||||
(`werkbaum-mirror.timer`, alle 15 Minuten, `Persistent=true`) neben der
|
||||
Backend-Unit; der Service ruft `mirror-docs --push`.
|
||||
|
||||
**`tools/mirror-docs` ist die Schleife um `pull-doc`, nicht sein Ersatz.** Es
|
||||
liest eine Liste (`documents.list`: je Zeile UUID oder URL und Pfad im Repo),
|
||||
ruft je Zeile `pull-doc --git-commit` und pusht einmal am Ende — nur, wenn ein
|
||||
Commit entstanden ist. Jede Zeile läuft für sich: Ein gescheiterter Abruf
|
||||
bricht die übrigen nicht ab, steht aber auf stderr, und der Lauf endet mit 1,
|
||||
damit das Journal des Timers den Fehler zeigt, statt still zu bleiben.
|
||||
Nachgemessen lokal: eine Fehlerzeile in der Liste kostet genau ihr Dokument,
|
||||
die beiden anderen werden committet; der zweite Lauf ohne Änderung committet
|
||||
nichts und endet mit 0.
|
||||
|
||||
**Eine Liste statt „alle Dokumente des Servers“.** Die Dokumentenliste
|
||||
verlangt das Master-Passwort (D76-Nachtrag 6), und auf dem Server liegt davon
|
||||
nur der Hash. Für den Cron müsste das Klartext-Passwort dorthin, nur damit er
|
||||
eine Liste bekommt, die er um Dateinamen ohnehin ergänzen müsste. Eine Zeile
|
||||
je Dokument ist der ehrlichere Preis: kein Geheimnis mehr auf dem Host, und
|
||||
Probe-Dokumente bleiben von selbst draußen.
|
||||
|
||||
**Ein Branch, viele Dateien.** Der Spiegel committet blind, also auf einen
|
||||
eigenen Branch `werkbaum-mirror`, den man bei Bedarf nach `main` mergt — nicht
|
||||
auf `main` mit den Hand-Commits dazwischen, sonst kollidiert der Server-Push
|
||||
mit dem lokalen Stand und der Timer bleibt hängen. Beim ersten Lauf von Hand
|
||||
war der Stand auf dem gerade ausgecheckten Feature-Branch gelandet; genau
|
||||
das soll ein Timer nicht tun. Ein Branch je Werkbaum wäre eine dritte Spalte
|
||||
in der Liste, wenn ihn je jemand braucht.
|
||||
|
||||
**Ein eigener Deploy-Key, ein Sparse-Checkout.** Auf dem Server liegt ein
|
||||
Schlüssel des Backend-Logins; der bekommt nicht nebenbei Schreibrecht auf ein
|
||||
privates Notizen-Repo. Der Spiegel hat seinen eigenen ed25519-Schlüssel
|
||||
(`~/.ssh/werkbaum-mirror`, Host-Alias `notes-mirror` mit `IdentitiesOnly`),
|
||||
als Deploy-Key mit Schreibrecht nur für dieses eine Repo. Geklont wird mit
|
||||
`--filter=blob:none` und Sparse-Checkout auf das Zielverzeichnis — auf dem
|
||||
geteilten Host liegen so nur die gespiegelten Dateien, nicht das ganze Repo
|
||||
(5,7 MB getrackt, 666 MB Arbeitsverzeichnis lokal).
|
||||
|
||||
**Das Notes-Repo ist dabei nach Gitea gezogen** (`mi/notes` auf
|
||||
`git.javagil.de`, alle vier Branches; `origin` ist Gitea, GitHub bleibt als
|
||||
`github` — dieselbe Aufteilung wie D95). Der Deploy-Key gilt je Repo, also
|
||||
zuerst der Umzug, dann der Schlüssel, statt beides zweimal.
|
||||
|
||||
## D89 — Zwei Stunden verlorene Arbeit: vier Netze gegen den stillen Live-Verlust
|
||||
Der Vorfall (2026-08-27 vormittags, gemeldet vom Nutzer): Zwei Stunden Arbeit
|
||||
an einem geteilten Dokument (`?live=`, PWA) waren weg — die Server-Historie
|
||||
@@ -7758,6 +7815,58 @@ Entscheidungen: **D94** und `docs/rfc/002-mehrfenster.md`. Der Präsenz-Kanal
|
||||
(Herzschlag, Timeout, Notluke) und der modale Dialog sind ersatzlos
|
||||
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
|
||||
Nutzerwunsch, unmittelbar aus dem D89-Vorfall: Hinter der Brotkrume
|
||||
(„Werkbaum › Name", D81) soll erkennbar sein, was für ein Dokument vorn ist —
|
||||
|
||||
@@ -951,6 +951,13 @@ anderen, ohne neu zu laden.
|
||||
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
|
||||
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
|
||||
— kollidiert nur noch am **selben nicht geteilten Dokument**: Text, Name und
|
||||
|
||||
@@ -124,8 +124,8 @@
|
||||
- [^] #bld.backend.proxy: Apache passes /api/ on, in .htaccess (XS) %% measured: the P flag is allowed
|
||||
- [^] #bld.backend.remote: One command for everything on the server (S) %% remote <target> <action>
|
||||
- [^] #bld.backend.backup: Save the database, and prove it can be read back (XS)
|
||||
- [x] #bld.werkator: Also built by Werkator, the project's own CI (S) %% .werkator.yml, backend + frontend
|
||||
- [x] #bld.gitea: The repository lives on Gitea, GitHub stays a clone (S)
|
||||
- [^] #bld.werkator: Also built by Werkator, the project's own CI (S) %% .werkator.yml, backend + frontend
|
||||
- [^] #bld.gitea: The repository lives on Gitea, GitHub stays a clone (S)
|
||||
- [-] #bld.ghpages: Deploy through a gh-pages branch (S) %% GitHub Actions instead
|
||||
- [-] #bld.gfonts: Fonts from Google (XS) %% self-hosted, no third-party request
|
||||
- [ ] #be: Backend (XL)
|
||||
@@ -151,6 +151,7 @@
|
||||
- [~] #col.git: Git as the shared store (L)
|
||||
| [ ] #col.git.pr: A file in a repository, changed by pull request (S) %% works today, no code
|
||||
| [^] #col.git.pull: A script pulls the server document and commits it (S) %% tools/pull-doc --git-commit, dated commits — see D88
|
||||
- [^] #col.git.timer: A timer on the backend host mirrors a list of documents into a branch (S) %% tools/mirror-docs, systemd timer — see D88-Nachtrag 3
|
||||
| [?] #col.git.auto: The backend commits every change (L) :#be.scaffold
|
||||
- [?] #col.git.hist: History and restore (M)
|
||||
- [?] #col.git.diff: Diff between two versions (S)
|
||||
@@ -970,6 +971,13 @@
|
||||
worktree — date, title and version in the message, no commit when nothing
|
||||
changed. Run from cron, a shared plan archives itself.
|
||||
|
||||
#col.git.timer
|
||||
tools/mirror-docs reads a list of documents (uuid and path), runs pull-doc
|
||||
--git-commit for each and pushes once. As a systemd timer on the backend host
|
||||
it mirrors the shared plans every 15 minutes into a werkbaum-mirror branch —
|
||||
the net outside the browser and outside the backend, running whether or not
|
||||
anyone remembers.
|
||||
|
||||
#col.git.auto
|
||||
The server turns every change into a commit. History, restore and branches
|
||||
follow from that, with commit granularity as the open question.
|
||||
|
||||
+3
-3
@@ -27,7 +27,7 @@ Die Datei selbst war also einwandfrei erreichbar; es fehlte genau ein Header.
|
||||
- Die Konfiguration liegt beim Unix-Benutzer `mih09-git` auf mih09 und in **keinem** Repository; sie ist im Wortlaut in D96 festgehalten.
|
||||
- Giteas eigener `[cors]`-Abschnitt und die `.htaccess` der Domain teilen sich die Arbeit: die API-Route deckt Gitea ab, die Web-Route der Apache davor.
|
||||
|
||||
#### Szenario#1.01: Ein Beispiel-Link öffnet den Plan statt eines CORS-Fehlers
|
||||
#### Szenario#3.01: Ein Beispiel-Link öffnet den Plan statt eines CORS-Fehlers
|
||||
|
||||
Damit die Links auf `origin` zeigen können und nicht auf einen Klon.
|
||||
|
||||
@@ -39,7 +39,7 @@ Damit die Links auf `origin` zeigen können und nicht auf einen Klon.
|
||||
|
||||
- Messung 2026-09-03 auf allen fünf Beispieldateien: je HTTP 200 mit genau einem `Access-Control-Allow-Origin: *`.
|
||||
|
||||
#### Szenario#1.02: Die API-Route trägt den Header genau einmal
|
||||
#### Szenario#3.02: Die API-Route trägt den Header genau einmal
|
||||
|
||||
Damit die Antwort gültig bleibt — zwei `Access-Control-Allow-Origin`-Header verwirft der Browser als ungültig.
|
||||
|
||||
@@ -51,7 +51,7 @@ Damit die Antwort gültig bleibt — zwei `Access-Control-Allow-Origin`-Header v
|
||||
|
||||
- Messung 2026-09-03: `/api/v1/repos/mi/werkdock/raw/README.md` → ein Header (zuvor, mit der ersten Fassung der `.htaccess`, waren es zwei).
|
||||
|
||||
#### Szenario#1.03: Gewöhnliche Repo-Seiten bleiben unberührt
|
||||
#### Szenario#3.03: Gewöhnliche Repo-Seiten bleiben unberührt
|
||||
|
||||
Damit der Header nur dort steht, wo er gebraucht wird, und nicht site-weit.
|
||||
|
||||
@@ -7,6 +7,9 @@ Jeder Pull Request bekommt hier **eine** Datei.
|
||||
`JJJJ-MM-TT-PR#<nummer>-kurze-beschreibung.md`
|
||||
|
||||
Die Nummer ist die des Gitea-PR auf <https://git.javagil.de/mi/werkbaum>.
|
||||
Gitea begann nach dem Umzug (D95) wieder bei 1, während die Historie schon
|
||||
GitHub-PR #1 und #2 kennt; die Nummern 1 und 2 sind deshalb abgebrannt und die
|
||||
Zählung setzt bei **#3** auf.
|
||||
Steht sie noch nicht fest, gilt `PR#000` als Platzhalter — im Dateinamen wie in
|
||||
den Szenario-IDs — und wird nachgezogen, sobald der PR offen ist.
|
||||
|
||||
|
||||
+39
-7
@@ -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/<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?",
|
||||
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/<uuid>)?",
|
||||
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/<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?",
|
||||
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/<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 ?",
|
||||
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/<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ć?",
|
||||
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/<uuid>)?",
|
||||
liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.",
|
||||
liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?",
|
||||
liveReconnectText:"Это окно было отключено и содержит собственные изменения — состояние на сервере отличается. Чья версия должна остаться?",
|
||||
liveConflictTheirs:"Принять чужую",
|
||||
liveConflictMine:"Оставить свою",
|
||||
riskTooltip:"Высокий риск – оценка ещё не ясна.",
|
||||
@@ -4337,6 +4343,7 @@ const I18N = {
|
||||
liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/<uuid>)?",
|
||||
liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।",
|
||||
liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?",
|
||||
liveReconnectText:"यह विंडो डिस्कनेक्ट थी और इसमें अपने बदलाव हैं — सर्वर की स्थिति अलग है। किसका संस्करण रहे?",
|
||||
liveConflictTheirs:"दूसरे का लें",
|
||||
liveConflictMine:"अपना रखें",
|
||||
riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।",
|
||||
@@ -4490,6 +4497,7 @@ const I18N = {
|
||||
liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/<uuid>)吗?",
|
||||
liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。",
|
||||
liveConflictText:"有人改动了同样的行。以谁的版本为准?",
|
||||
liveReconnectText:"此窗口曾断开连接并有自己的改动——服务器上的状态与此不同。以谁的版本为准?",
|
||||
liveConflictTheirs:"采用对方的",
|
||||
liveConflictMine:"保留我的",
|
||||
riskTooltip:"高风险 – 工作量尚不明确。",
|
||||
@@ -4632,6 +4640,7 @@ const I18N = {
|
||||
liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/<uuid>)ですか。",
|
||||
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');
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {describe, it, expect} from 'vitest';
|
||||
import {
|
||||
liveUrls, normalize, lines, text, computeOps, applyOps,
|
||||
mapLine, caretToLineCol, lineColToCaret, feedAction, serverBase, documentsUrl, infoUrl,
|
||||
reconnectAction,
|
||||
} from '../src/live.js';
|
||||
|
||||
describe('Adressen', () => {
|
||||
@@ -243,3 +244,20 @@ describe('Feed-Antwort anwenden oder nicht', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
# dorthin: `main`. Feature-Branches und Tags bleiben in Gitea — was auf GitHub
|
||||
# steht, ist der veröffentlichte Stand.
|
||||
#
|
||||
# Der Klon ist kein Selbstzweck. An ihm hängen zwei Dinge, die nicht umziehen
|
||||
# können:
|
||||
# * der GitHub-Pages-Workflow (`.github/workflows/pages.yml`), also die
|
||||
# „latest build"-Instanz mhoennig.github.io/werkbaum;
|
||||
# * die Beispiel-Links der READMEs, die per `?sourceUrl=` von
|
||||
# raw.githubusercontent.com laden — Gitea sendet auf `raw`-Dateien KEIN
|
||||
# `Access-Control-Allow-Origin`, der Browser blockte sie also (D23, D95).
|
||||
# Der Klon ist kein Selbstzweck. An ihm hängt der GitHub-Pages-Workflow
|
||||
# (`.github/workflows/pages.yml`), also die „latest build"-Instanz
|
||||
# mhoennig.github.io/werkbaum — die kann nicht umziehen.
|
||||
#
|
||||
# Die Beispiel-Links der READMEs waren ein zweiter Grund und sind es nicht mehr:
|
||||
# `git.javagil.de` sendet auf `raw`-Dateien inzwischen
|
||||
# `Access-Control-Allow-Origin`, sie laden deshalb von Gitea (D96, revidiert D95).
|
||||
#
|
||||
# Verwendung:
|
||||
# scripts/push-github.sh [-n] [-y]
|
||||
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Werkbaum — mirror-docs: mehrere Server-Dokumente in ein git-Worktree
|
||||
# spiegeln. Liest eine Liste, ruft je Zeile `pull-doc --git-commit` und pusht
|
||||
# auf Wunsch einmal am Ende. Gedacht für einen Timer auf dem Backend-Host
|
||||
# (D88-Nachtrag 3): Der geteilte Plan bekommt so ohne Zutun eine Git-Historie.
|
||||
#
|
||||
# Verwendung:
|
||||
# mirror-docs [--push] [--api <basis>] <worktree> <liste>
|
||||
#
|
||||
# --push Nach dem Durchlauf `git push`, wenn etwas committet wurde.
|
||||
# Der Branch braucht dafür einen Upstream.
|
||||
# --api <basis> Basis-Adresse des Backends (…/api/v1) für Zeilen, die nur
|
||||
# eine UUID nennen; sonst aus WERKBAUM_API.
|
||||
# <worktree> Wurzel des git-Worktrees, in das gespiegelt wird.
|
||||
# <liste> Textdatei, je Zeile `<uuid-oder-url> <pfad>`; der Pfad ist
|
||||
# relativ zum Worktree. Leerzeilen und `#`-Zeilen sind
|
||||
# Kommentar. Ein neues Dokument ist eine Zeile mehr.
|
||||
#
|
||||
# Jede Zeile läuft für sich: Ein gescheiterter Abruf bricht die übrigen nicht
|
||||
# ab, steht aber auf stderr, und der Lauf endet dann mit 1 — ein Timer sieht
|
||||
# so im Journal, was fehlt, statt still zu bleiben. Unverändertes committet
|
||||
# nichts (das regelt pull-doc); ohne neuen Commit wird nicht gepusht.
|
||||
#
|
||||
# Siehe docs/DECISIONS.md D88.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage(){ sed -n '2,25p' "$0" | sed 's/^# \{0,1\}//'; }
|
||||
|
||||
do_push=0; api="${WERKBAUM_API:-}"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0;;
|
||||
--push) do_push=1; shift;;
|
||||
--api) [ $# -ge 2 ] || { echo "Fehler: --api braucht eine Adresse." >&2; exit 2; }; api="$2"; shift 2;;
|
||||
--*) echo "Fehler: unbekannter Schalter $1" >&2; echo >&2; usage >&2; exit 2;;
|
||||
*) break;;
|
||||
esac
|
||||
done
|
||||
[ $# -eq 2 ] || { echo "Fehler: erwartet [--push] [--api <basis>] Worktree und Liste." >&2; echo >&2; usage >&2; exit 2; }
|
||||
tree="$1"; list="$2"
|
||||
pulldoc="$(dirname -- "$0")/pull-doc"
|
||||
|
||||
[ -x "$pulldoc" ] || { echo "Fehler: $pulldoc fehlt oder ist nicht ausführbar." >&2; exit 2; }
|
||||
[ -f "$list" ] || { echo "Fehler: Liste $list gibt es nicht." >&2; exit 2; }
|
||||
[ "$(git -C "$tree" rev-parse --is-inside-work-tree 2>/dev/null)" = "true" ] \
|
||||
|| { echo "Fehler: $tree ist kein git-Worktree." >&2; exit 2; }
|
||||
|
||||
before="$(git -C "$tree" rev-parse HEAD)"
|
||||
fail=0; n=0
|
||||
while read -r src path _; do
|
||||
case "$src" in ''|'#'*) continue;; esac
|
||||
n=$((n+1))
|
||||
[ -n "$path" ] || { echo "Fehler: Zeile ohne Pfad: $src" >&2; fail=1; continue; }
|
||||
case "$src" in
|
||||
http://*|https://*) url="$src";;
|
||||
*) [ -n "$api" ] || { echo "Fehler: $src ist keine URL, und --api/WERKBAUM_API fehlt." >&2; fail=1; continue; }
|
||||
url="${api%/}/documents/$src";;
|
||||
esac
|
||||
case "$path" in /*|*..*) echo "Fehler: Pfad muss relativ und ohne .. sein: $path" >&2; fail=1; continue;; esac
|
||||
mkdir -p -- "$tree/$(dirname -- "$path")"
|
||||
"$pulldoc" --git-commit "$url" "$tree/$path" || { echo "Fehler bei $path (siehe oben)." >&2; fail=1; }
|
||||
done < "$list"
|
||||
[ "$n" -gt 0 ] || echo "Hinweis: Liste $list nennt kein Dokument." >&2
|
||||
|
||||
after="$(git -C "$tree" rev-parse HEAD)"
|
||||
if [ "$do_push" = 1 ] && [ "$before" != "$after" ]; then
|
||||
git -C "$tree" push -q || { echo "Fehler: push gescheitert — Commits liegen lokal in $tree." >&2; fail=1; }
|
||||
[ "$fail" = 0 ] && echo "Gepusht: $(git -C "$tree" rev-parse --short HEAD)"
|
||||
fi
|
||||
exit "$fail"
|
||||
Reference in New Issue
Block a user