Compare commits

..
6 Commits
Author SHA1 Message Date
mhoennig db7b8004ee docs: Werkbaum-Plan — 1 Knoten in Produktion
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
Beim Deploy der stabilen Instanz gehen diese Knoten live; SPEC §4
unterscheidet [x] fertig von [^] in Produktion (D30):

- #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
2026-09-04 12:40:55 +02:00
mhoennigandClaude Fable 5.1 28ff6844b0 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>
2026-09-04 12:34:01 +02:00
mhoennigandClaude Fable 5.1 2e408d956c feat(tools): mirror-docs — Liste geteilter Dokumente per Timer nach git spiegeln (D88-Nachtrag 3)
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 09:52:02 +02:00
mhoennigandClaude Opus 5 c87cf6212d docs(prs): PR-doc auf Nummer 3 — 1 und 2 gehören der GitHub-Historie
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
Gitea begann nach dem Umzug wieder bei 1, Werkbaums Historie kennt aber
schon GitHub-PR #1 und #2. Beide Nummern sind in Gitea abgebrannt; die
Zählung setzt bei 3 auf, damit eine Nummer in docs/prs/ eindeutig bleibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 19:26:31 +02:00
mhoennig 5ec5118cee docs: Werkbaum-Plan — 2 Knoten in Produktion
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
Beim Deploy der stabilen Instanz gehen diese Knoten live; SPEC §4
unterscheidet [x] fertig von [^] in Produktion (D30):

- #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
2026-09-03 19:24:14 +02:00
mhoennigandClaude Opus 5 063d2c947b docs(scripts): push-github.sh nennt nicht mehr CORS als Grund für den Klon (D96)
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
Der Kopfkommentar behauptete noch, Gitea sende kein
Access-Control-Allow-Origin. Seit D96 tut es das; übrig bleibt GitHub Pages.

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