Compare commits

...
7 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
mhoennigandClaude Opus 5 3cafc92d49 docs: Beispiel-Links laden aus dem eigenen Gitea (D96)
Deploy to GitHub Pages / build (push) Canceled after 0s
Deploy to GitHub Pages / deploy (push) Canceled after 0s
git.javagil.de sendete auf raw-Dateien kein Access-Control-Allow-Origin,
weshalb die ?sourceUrl=-Beispiel-Links nach dem Umzug (D95) auf dem
GitHub-Klon bleiben mussten. Der Header ist jetzt auf mih09 gesetzt:
Giteas [cors]-Abschnitt deckt nur /api/v1 ab, die Web-raw-Route bekommt
ihn im Apache davor. Die Links zeigen nun auf origin.

Nebenbei: "Werkbaum selbst" war schon vorher tot (example-werkbaum.werkbaum
existiert nicht, die Datei heisst werkbaum.werkbaum) und ist mit korrigiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 18:31:37 +02:00
14 changed files with 526 additions and 47 deletions
+2
View File
@@ -24,6 +24,8 @@ Integrations-Backend.
Eintrag begründen, alte Einträge nie löschen. Besonders D13 (Backend-Stack) Eintrag begründen, alte Einträge nie löschen. Besonders D13 (Backend-Stack)
und D14 (Parser-Hoheit) beachten. und D14 (Parser-Hoheit) beachten.
- Ziele: docs/ROADMAP.md · Offene Arbeit: docs/TASKS.md (Checkboxen pflegen). - Ziele: docs/ROADMAP.md · Offene Arbeit: docs/TASKS.md (Checkboxen pflegen).
- Pull Requests: jeder PR bekommt ein Dokument unter `docs/prs/` — Konvention
in docs/prs/README.md.
- Änderungen: @docs/CHANGELOG.md — **jedes Feature und jeder behobene Fehler - Änderungen: @docs/CHANGELOG.md — **jedes Feature und jeder behobene Fehler
bekommt dort eine Zeile**, englisch, ein Satz, unter der Überschrift des bekommt dort eine Zeile**, englisch, ein Satz, unter der Überschrift des
Tages (`## JJJJ-MM-TT`). Die Datei speist das Neuigkeiten-Popup im Editor Tages (`## JJJJ-MM-TT`). Die Datei speist das Neuigkeiten-Popup im Editor
+22 -15
View File
@@ -57,7 +57,7 @@ einer externen Textdatei beziehen — praktisch zum Teilen eines Plans oder wenn
die Quelle in Git oder einem Wiki gepflegt wird: die Quelle in Git oder einem Wiki gepflegt wird:
``` ```
https://werkbaum.javagil.de/?sourceUrl=https://raw.githubusercontent.com/mhoennig/werkbaum/main/docs/examples/example-plan-0.werkbaum https://werkbaum.javagil.de/?sourceUrl=https://git.javagil.de/mi/werkbaum/raw/branch/main/docs/examples/example-plan-0.werkbaum
``` ```
Fertige Beispiele in [`docs/examples/`](docs/examples/) — **nacheinander Fertige Beispiele in [`docs/examples/`](docs/examples/) — **nacheinander
@@ -66,11 +66,11 @@ Editor-Titelzeile zwischen allen umschalten:
| Beispiel | Zeigt | | Beispiel | Zeigt |
|---|---| |---|---|
| **▶ [0 · Online-Shop-Relaunch](https://werkbaum.javagil.de/?sourceUrl=https://raw.githubusercontent.com/mhoennig/werkbaum/main/docs/examples/example-plan-0.werkbaum)** | ein Softwareprojekt mit allen acht Status | | **▶ [0 · Online-Shop-Relaunch](https://werkbaum.javagil.de/?sourceUrl=https://git.javagil.de/mi/werkbaum/raw/branch/main/docs/examples/example-plan-0.werkbaum)** | ein Softwareprojekt mit allen acht Status |
| **▶ [1 · Neue Küche](https://werkbaum.javagil.de/?sourceUrl=https://raw.githubusercontent.com/mhoennig/werkbaum/main/docs/examples/example-plan-1.werkbaum)** | ein Plan ohne Software, viele Alternativen — gut für den Günstigster-Pfad-Schalter | | **▶ [1 · Neue Küche](https://werkbaum.javagil.de/?sourceUrl=https://git.javagil.de/mi/werkbaum/raw/branch/main/docs/examples/example-plan-1.werkbaum)** | ein Plan ohne Software, viele Alternativen — gut für den Günstigster-Pfad-Schalter |
| **▶ [2 · Community-Konferenz](https://werkbaum.javagil.de/?sourceUrl=https://raw.githubusercontent.com/mhoennig/werkbaum/main/docs/examples/example-plan-2.werkbaum)** | ein breiter Plan mit vielen Beteiligten; horizontal vs. kompakt vergleichen | | **▶ [2 · Community-Konferenz](https://werkbaum.javagil.de/?sourceUrl=https://git.javagil.de/mi/werkbaum/raw/branch/main/docs/examples/example-plan-2.werkbaum)** | ein breiter Plan mit vielen Beteiligten; horizontal vs. kompakt vergleichen |
| **▶ [3 · Drei Arbeitsstränge](https://werkbaum.javagil.de/?sourceUrl=https://raw.githubusercontent.com/mhoennig/werkbaum/main/docs/examples/example-plan-3.werkbaum)** | mehrere Wurzeln = mehrere Bäume nebeneinander | | **▶ [3 · Drei Arbeitsstränge](https://werkbaum.javagil.de/?sourceUrl=https://git.javagil.de/mi/werkbaum/raw/branch/main/docs/examples/example-plan-3.werkbaum)** | mehrere Wurzeln = mehrere Bäume nebeneinander |
| **▶ [Werkbaum selbst](https://werkbaum.javagil.de/?sourceUrl=https://raw.githubusercontent.com/mhoennig/werkbaum/main/docs/examples/example-werkbaum.werkbaum)** | Bestand und mögliche Weiterentwicklung | | **▶ [Werkbaum selbst](https://werkbaum.javagil.de/?sourceUrl=https://git.javagil.de/mi/werkbaum/raw/branch/main/docs/examples/werkbaum.werkbaum)** | Bestand und mögliche Weiterentwicklung |
Der geladene Text wird als **eigenes Dokument** geführt, dessen **Name die URL** Der geladene Text wird als **eigenes Dokument** geführt, dessen **Name die URL**
ist — eigene Dokumente bleiben unberührt, und derselbe Link aktualisiert dieses ist — eigene Dokumente bleiben unberührt, und derselbe Link aktualisiert dieses
@@ -83,10 +83,10 @@ Notationsdateien tragen die Endung **`.werkbaum`** (UTF-8; siehe `docs/SPEC.md`
erreichbare Textdatei, unabhängig von Endung und Content-Type. erreichbare Textdatei, unabhängig von Endung und Content-Type.
**Einschränkung — CORS:** Der Browser lädt fremde Hosts nur, wenn sie **Einschränkung — CORS:** Der Browser lädt fremde Hosts nur, wenn sie
`Access-Control-Allow-Origin` senden. `raw.githubusercontent.com` und `Access-Control-Allow-Origin` senden. `raw.githubusercontent.com`,
GitLab-Raw-Links tun das; ein beliebiger Webserver oft nicht. Scheitert das GitLab-Raw-Links und `git.javagil.de` tun das (D96); ein beliebiger Webserver
Laden, bleibt der bisherige Stand stehen und eine Warnung nennt die Ursache. oft nicht. Scheitert das Laden, bleibt der bisherige Stand stehen und eine
Zugelassen sind nur `http`/`https`. Warnung nennt die Ursache. Zugelassen sind nur `http`/`https`.
### Zusammen an einem Plan arbeiten (`?live=`) ### Zusammen an einem Plan arbeiten (`?live=`)
@@ -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` Änderung. Per Cron aufgerufen archiviert sich der Plan von selbst; `--open`
öffnet die Datei danach in IntelliJ IDEA. Siehe `docs/DECISIONS.md` D88. ö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 Das Backend einrichten: siehe [backend/README.md](backend/README.md) und
`docs/DECISIONS.md` D76. `docs/DECISIONS.md` D76.
@@ -152,11 +159,11 @@ git clone mih09-git@git.javagil.de:mi/werkbaum.git # oder https://git.javag
``` ```
**GitHub bleibt ein Klon.** `main` wird dorthin von Hand gespiegelt **GitHub bleibt ein Klon.** `main` wird dorthin von Hand gespiegelt
(`scripts/push-github.sh`), denn zwei Dinge hängen daran: die (`scripts/push-github.sh`), denn die GitHub-Pages-Instanz hängt daran — der
GitHub-Pages-Instanz (der 🚧 *latest build* ganz oben) und die Beispiel-Links 🚧 *latest build* ganz oben. Die Beispiel-Links waren ein zweiter Grund und sind
weiter oben, die ihre Pläne per `?sourceUrl=` von `raw.githubusercontent.com` es nicht mehr: `git.javagil.de` liefert `raw`-Dateien inzwischen mit
laden. Gitea liefert `raw`-Dateien **ohne** `Access-Control-Allow-Origin` aus — `Access-Control-Allow-Origin`, sie zeigen deshalb auf Gitea
dort blockte der Browser sie (`docs/DECISIONS.md` D23, D95). (`docs/DECISIONS.md` D96, revidiert D95).
### Lokal ausführen ### Lokal ausführen
+21 -14
View File
@@ -54,7 +54,7 @@ The editor can pull its notation text from an external text file via the
Git or a wiki: Git or a wiki:
``` ```
https://werkbaum.javagil.de/?sourceUrl=https://raw.githubusercontent.com/mhoennig/werkbaum/main/docs/examples/example-plan-0.werkbaum https://werkbaum.javagil.de/?sourceUrl=https://git.javagil.de/mi/werkbaum/raw/branch/main/docs/examples/example-plan-0.werkbaum
``` ```
Ready-made examples in [`docs/examples/`](docs/examples/) — **open them one Ready-made examples in [`docs/examples/`](docs/examples/) — **open them one
@@ -63,11 +63,11 @@ between all of them in the editor title bar:
| Example | Shows | | Example | Shows |
|---|---| |---|---|
| **▶ [0 · Online shop relaunch](https://werkbaum.javagil.de/?sourceUrl=https://raw.githubusercontent.com/mhoennig/werkbaum/main/docs/examples/example-plan-0.werkbaum)** | a software project with all eight states | | **▶ [0 · Online shop relaunch](https://werkbaum.javagil.de/?sourceUrl=https://git.javagil.de/mi/werkbaum/raw/branch/main/docs/examples/example-plan-0.werkbaum)** | a software project with all eight states |
| **▶ [1 · New kitchen](https://werkbaum.javagil.de/?sourceUrl=https://raw.githubusercontent.com/mhoennig/werkbaum/main/docs/examples/example-plan-1.werkbaum)** | a non-software plan, lots of alternatives — good for the cheapest-path toggle | | **▶ [1 · New kitchen](https://werkbaum.javagil.de/?sourceUrl=https://git.javagil.de/mi/werkbaum/raw/branch/main/docs/examples/example-plan-1.werkbaum)** | a non-software plan, lots of alternatives — good for the cheapest-path toggle |
| **▶ [2 · Community conference](https://werkbaum.javagil.de/?sourceUrl=https://raw.githubusercontent.com/mhoennig/werkbaum/main/docs/examples/example-plan-2.werkbaum)** | a wide plan with many people; compare horizontal vs. compact | | **▶ [2 · Community conference](https://werkbaum.javagil.de/?sourceUrl=https://git.javagil.de/mi/werkbaum/raw/branch/main/docs/examples/example-plan-2.werkbaum)** | a wide plan with many people; compare horizontal vs. compact |
| **▶ [3 · Three workstreams](https://werkbaum.javagil.de/?sourceUrl=https://raw.githubusercontent.com/mhoennig/werkbaum/main/docs/examples/example-plan-3.werkbaum)** | several roots = several trees side by side | | **▶ [3 · Three workstreams](https://werkbaum.javagil.de/?sourceUrl=https://git.javagil.de/mi/werkbaum/raw/branch/main/docs/examples/example-plan-3.werkbaum)** | several roots = several trees side by side |
| **▶ [Werkbaum itself](https://werkbaum.javagil.de/?sourceUrl=https://raw.githubusercontent.com/mhoennig/werkbaum/main/docs/examples/example-werkbaum.werkbaum)** | what exists today and where it could go | | **▶ [Werkbaum itself](https://werkbaum.javagil.de/?sourceUrl=https://git.javagil.de/mi/werkbaum/raw/branch/main/docs/examples/werkbaum.werkbaum)** | what exists today and where it could go |
The loaded text becomes its own document whose **name is the URL**, so your own The loaded text becomes its own document whose **name is the URL**, so your own
documents stay untouched; the same link updates that one document instead of documents stay untouched; the same link updates that one document instead of
@@ -79,9 +79,10 @@ and D24) — a convention, not a contract: `sourceUrl` reads any text file over
http(s) regardless of extension or content type. http(s) regardless of extension or content type.
**Caveat — CORS:** the browser only fetches a foreign host if it sends **Caveat — CORS:** the browser only fetches a foreign host if it sends
`Access-Control-Allow-Origin`. `raw.githubusercontent.com` and GitLab raw links `Access-Control-Allow-Origin`. `raw.githubusercontent.com`, GitLab raw links and
do; an arbitrary web server often does not. If loading fails the previous `git.javagil.de` do (D96); an arbitrary web server often does not. If loading
content stays and a warning explains why. Only `http`/`https` are allowed. fails the previous content stays and a warning explains why. Only `http`/`https`
are allowed.
### Working on one plan together (`?live=`) ### Working on one plan together (`?live=`)
@@ -128,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 from cron and the plan archives itself; `--open` opens the file in IntelliJ
IDEA afterwards. See `docs/DECISIONS.md` D88. 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 Setting up the backend: see [backend/README.md](backend/README.md) and
`docs/DECISIONS.md` D76. `docs/DECISIONS.md` D76.
@@ -146,11 +153,11 @@ git clone mih09-git@git.javagil.de:mi/werkbaum.git # or https://git.javagil
``` ```
**GitHub stays a clone.** `main` is mirrored there by hand **GitHub stays a clone.** `main` is mirrored there by hand
(`scripts/push-github.sh`), because two things hang off it: the GitHub Pages (`scripts/push-github.sh`), because the GitHub Pages instance hangs off it — the
instance (the 🚧 *latest build* linked at the top) and the example links above, 🚧 *latest build* linked at the top. The example links above used to be a second
which load plan files from `raw.githubusercontent.com` via `?sourceUrl=`. Gitea reason and are not any more: `git.javagil.de` now serves raw files with an
serves raw files **without** an `Access-Control-Allow-Origin` header, so the `Access-Control-Allow-Origin` header, so they point at Gitea
browser would block them there (`docs/DECISIONS.md` D23, D95). (`docs/DECISIONS.md` D96, revising D95).
### Running it locally ### Running it locally
+6
View File
@@ -17,10 +17,16 @@ 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 a link without having a note (someone forgot to write one) — but never the
reverse. 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 ## 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 - 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
- Every push is built by Werkator, the project's own CI, alongside the GitHub Pages build: backend and frontend as two named builds - Every push is built by Werkator, the project's own CI, alongside the GitHub Pages build: backend and frontend as two named builds
- The example plan links now load from git.javagil.de instead of the GitHub clone, and the "Werkbaum itself" link works again — it pointed at a file name that does not exist
## 2026-09-02 ## 2026-09-02
+170 -2
View File
@@ -635,8 +635,8 @@ mögliche Erweiterung.)
**CORS ist die eigentliche Einschränkung.** Der Browser lädt fremde Hosts nur, **CORS ist die eigentliche Einschränkung.** Der Browser lädt fremde Hosts nur,
wenn die Zielseite `Access-Control-Allow-Origin` sendet. Das tun u. a. wenn die Zielseite `Access-Control-Allow-Origin` sendet. Das tun u. a.
`raw.githubusercontent.com` und GitLab-Raw-Links; ein beliebiger Webserver `raw.githubusercontent.com`, GitLab-Raw-Links und — seit D96 — das eigene
oft **nicht**. Scheitert das Laden (CORS, 404, Netz), bleibt der bisherige Stand `git.javagil.de`; ein beliebiger Webserver oft **nicht**. Scheitert das Laden (CORS, 404, Netz), bleibt der bisherige Stand
stehen und es erscheint eine **Warnung** im Warnbereich (Typ `sourceLoad`, stehen und es erscheint eine **Warnung** im Warnbereich (Typ `sourceLoad`,
zeilenlos ⇒ zuoberst), die CORS ausdrücklich als wahrscheinliche Ursache nennt. zeilenlos ⇒ zuoberst), die CORS ausdrücklich als wahrscheinliche Ursache nennt.
Bewusst kein Proxy-Dienst als Ausweg: das würde fremde Inhalte über einen Bewusst kein Proxy-Dienst als Ausweg: das würde fremde Inhalte über einen
@@ -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 Worktrees schreibt anstandslos, `--git-commit` dort lehnt mit klarer
Meldung ab. 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 ## D89 — Zwei Stunden verlorene Arbeit: vier Netze gegen den stillen Live-Verlust
Der Vorfall (2026-08-27 vormittags, gemeldet vom Nutzer): Zwei Stunden Arbeit Der Vorfall (2026-08-27 vormittags, gemeldet vom Nutzer): Zwei Stunden Arbeit
an einem geteilten Dokument (`?live=`, PWA) waren weg — die Server-Historie 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 (Herzschlag, Timeout, Notluke) und der modale Dialog sind ersatzlos
ausgebaut; die drei anderen Netze (Sicherungen, Rettung, Wachhund) bleiben. ausgebaut; die drei anderen Netze (Sicherungen, Rettung, Wachhund) bleiben.
**Nachtrag 2 — die Wiederverbindung sendet nie von selbst (2026-09-04).**
Der zweite Datenverlust am selben Dokument, diesmal nachgemessen bis zur
Ursache: Version 679 (11:49 Uhr) kam von einem anderen Client (der PWA auf
dem Telefon) und war byte-genau Version 667 — der letzte Stand, den das
Telefon am Vortag gesehen hatte — plus drei Faltmarken. Die 16 Zeilen aus
668 bis 671 waren weg; der Nutzer hat 671 per Rollback (v680) zurückgeholt.
Dasselbe Muster wie v660 am Abend davor (byte-gleich mit v339).
**Der Mechanismus stand im D90-Nachtrag, als Rettung gemeint.** Die
Wiederverbindung nach einer toten Sitzung holte den Server-Stand als
Schattenkopie und schickte dann jeden abweichenden lokalen Text als Diff —
„ungesendeter Text bleibt stehen, die keepMine-Semantik". Sie konnte nicht
unterscheiden, ob der lokale Text **neuer** ist als der Server (ungesendete
Arbeit) oder **älter** (ein Fenster, das lange geschlafen hat): In beiden
Fällen ist er „anders", und die alte Fassung ging als Diff gegen den
aktuellen Server hinaus, was einer Vollersetzung gleichkommt. Die Kette auf
dem Telefon: Die PWA wacht auf, der erste Abruf scheitert (Funk noch aus),
die Sitzung ist tot, der Editor zeigt den lokal gespeicherten Text vom
Vortag. Ein Tipp auf ein Falt-Zeichen ändert ihn (Falten schreibt in den
Text, D38-Nachtrag 2). Der Wachhund verbindet neu, sieht „lokal ≠ Server"
und schickt.
**Entscheidend ist nicht der Vergleich der Texte, sondern ob hier gearbeitet
wurde.** Zwei Zähler in app.js: einer zählt jede Textänderung an einem
Server-Dokument — mit oder ohne Sitzung, denn das Falten auf einem toten
Telefon ist genau der Fall —, der andere hält den Stand, den der Server
zuletzt bestätigt hat oder den wir von ihm übernommen haben (gelungenes
Senden, Laden, Volltext aus dem Feed, *Fremde übernehmen*). Ein Senden,
während weiter getippt wird, bestätigt nur, was es getragen hat. Die Regel
selbst steht headless in `live.js` (`reconnectAction`, D54-Nachtrag 3):
Texte gleich → nichts zu entscheiden; nicht gearbeitet → der Server gilt,
der lokale Text wandert in die lokalen Sicherungen; gearbeitet → das
vorhandene Konflikt-Band fragt, mit eigenem Text („Dieses Fenster war
getrennt und hat eigene Änderungen — der Stand auf dem Server weicht ab").
Nichts geschieht stumm, und beide Stände existieren, bis ein Mensch
entscheidet.
**Nachgemessen** im Browser gegen ein lokales Backend, das Telefon-Szenario
nachgestellt: Server auf v2 gesetzt, Backend gestoppt, Seite geladen — Chip
„getrennt", Editor zeigt den alten Spiegel (v1). Backend wieder da, nichts
angefasst: nach ≤ 20 s zeigt der Editor v2, der Server steht **unverändert
auf v2** (vorher hätte das Fenster hier v3 mit dem alten Text geschrieben).
Zweiter Durchlauf mit einer getippten Zeile im getrennten Zustand: Das Band
erscheint mit dem neuen Text, der Server bleibt auf v2, der lokale Text
steht; *Eigene durchsetzen* schreibt v3 mit genau dieser Zeile. 669 Tests,
davon 3 neue.
**Was bleibt:** Ein Fenster, das mit **ungesendeter** Arbeit ganz beendet
wird (Android räumt die PWA ab), startet mit dem Server-Stand; die Arbeit
liegt dann in den lokalen Sicherungen (Netz 3 aus dem Haupttext), nicht im
Editor — unverändert seit D89.
## D90 — Die Dokumentart steht grau hinter dem Namens-Chip ## D90 — Die Dokumentart steht grau hinter dem Namens-Chip
Nutzerwunsch, unmittelbar aus dem D89-Vorfall: Hinter der Brotkrume Nutzerwunsch, unmittelbar aus dem D89-Vorfall: Hinter der Brotkrume
(„Werkbaum Name", D81) soll erkennbar sein, was für ein Dokument vorn ist — („Werkbaum Name", D81) soll erkennbar sein, was für ein Dokument vorn ist —
@@ -9144,3 +9253,62 @@ beobachtete Klon `~/werkbaum` auf mih09 bekommt Gitea als `origin` — sonst
sähe der Watcher neue Commits erst nach dem Spiegeln. **Status-Checks werden sähe der Watcher neue Commits erst nach dem Spiegeln. **Status-Checks werden
weiterhin nicht gepostet**, solange auf der Instanz kein Gitea-Token liegt; weiterhin nicht gepostet**, solange auf der Instanz kein Gitea-Token liegt;
die Sektion ist bis dahin eine Beschriftung. die Sektion ist bis dahin eine Beschriftung.
**Nachtrag zu D95 — der CORS-Zwang ist weg (2026-09-03).** Von den zwei
gemessenen Gründen für den Klon hält nur noch einer: `git.javagil.de` liefert
`raw`-Dateien inzwischen mit `Access-Control-Allow-Origin` (D96), die
Beispiel-Links zeigen deshalb auf Gitea. **GitHub Pages bleibt** — der
Actions-Workflow der „latest build“-Instanz (D16) lässt sich nicht mitnehmen,
und daran hängt das Spiegeln weiterhin.
## D96 — `git.javagil.de` liefert `raw`-Dateien mit `Access-Control-Allow-Origin` (2026-09-03)
D95 hielt fest, dass die Beispiel-Links auf `raw.githubusercontent.com` bleiben
müssen, weil Gitea den CORS-Header nicht sendet. Das war eine
Server-Konfiguration, keine Eigenschaft von Gitea — sie ist jetzt gesetzt, und
die Links zeigen auf `origin`.
**Gemessen, nicht vermutet.** Giteas eigener `[cors]`-Abschnitt allein reicht
nicht: mit `ENABLED = true` / `ALLOW_DOMAIN = *` / `METHODS = GET,HEAD` in
`app.ini` trägt `/api/v1/repos/mi/werkdock/raw/README.md` den Header, die
**Web**-Route `/mi/werkdock/raw/branch/main/README.md` aber weiterhin nicht —
Gitea 1.27 legt die CORS-Middleware nur auf `/api/v1`. Die Beispiel-Links
benutzen die Web-Route.
**Also beides, mit klarer Aufteilung.** Der `[cors]`-Abschnitt bleibt für die
API-Route; die Web-Route bekommt den Header im Apache davor, in der
`.htaccess` der Domain:
```apache
SetEnvIf Request_URI "^/[^/]+/[^/]+/(raw|media)/" GITEA_RAW_CORS
Header always set Access-Control-Allow-Origin "*" env=GITEA_RAW_CORS
```
Zwei Fallen, beide beim Umsetzen aufgelaufen: `<LocationMatch>` ist in einer
`.htaccess` **nicht erlaubt** (nur Server-Config/VHost) — auf einem Managed
Webspace gibt es aber nur `.htaccess`, daher `SetEnvIf`. Und die Regel darf
`/api/v1` **nicht** mitfassen: `Header always` schreibt in `err_headers_out`
und *ergänzt* dort, statt zu ersetzen — zusammen mit Giteas eigenem Header
standen zwei `Access-Control-Allow-Origin: *` in der Antwort, was Browser als
ungültig verwerfen. Gemessen: je genau ein Header auf beiden raw-Routen, keiner
auf gewöhnlichen Repo-Seiten.
**`*` ohne Credentials ist hier die harmlose Variante.** Unter einem
Wildcard-Ursprung sendet der Browser grundsätzlich keine Cookies; ein fremder
Ursprung liest also nur, was ohnehin anonym abrufbar ist. Private Repositories
brauchen die Sitzung und antworten weiter mit 404/403 (nachgemessen: 404 auf
einen nicht existierenden Pfad). Ausdrücklich **nicht** getan: kein
`Access-Control-Allow-Credentials: true` (damit könnten fremde Seiten private
Repositories im Namen des angemeldeten Benutzers lesen), kein Zurückspiegeln
des `Origin` (dasselbe Risiko, sobald jemand später Credentials ergänzt), und
der Header steht nicht site-weit, sondern nur auf den raw-Pfaden.
**Kein Preflight nötig.** `OPTIONS` auf die Web-raw-Route antwortet weiterhin
`405`. Das ist folgenlos: der Abruf aus D23 ist ein *simple request*
schlichtes `GET`, `credentials:'omit'`, keine eigenen Header —, und dafür
schickt der Browser keinen Preflight.
**Die Konfiguration liegt in keinem Repository.** Sie gehört dem Unix-Benutzer
`mih09-git` auf mih09 (`gitea/custom/conf/app.ini` und
`doms/git.javagil.de/htdocs-ssl/.htaccess`, beide mit Zeitstempel-Sicherung
daneben) — deshalb steht sie hier im Wortlaut, damit sie nach einem Neuaufsetzen
wiederherstellbar ist.
+7
View File
@@ -951,6 +951,13 @@ anderen, ohne neu zu laden.
wandert er in die **lokalen Sicherungen**: Für Server-Dokumente sammelt der wandert er in die **lokalen Sicherungen**: Für Server-Dokumente sammelt der
10-Minuten-Takt sie wieder, und das Uhr-Menü zeigt sie als eigenen Abschnitt 10-Minuten-Takt sie wieder, und das Uhr-Menü zeigt sie als eigenen Abschnitt
unter den Server-Meilensteinen. Siehe D89. unter den Server-Meilensteinen. Siehe D89.
- **Eine tote Sitzung verbindet sich von selbst neu — und sendet dabei nie
von selbst.** Wurde in diesem Fenster seit dem letzten gelungenen Abgleich
nicht gearbeitet, gilt der Stand des Servers; der lokale Text ist dann nur
ein alter Spiegel und wandert in die lokalen Sicherungen. Wurde gearbeitet,
fragt das Konflikt-Band, wessen Fassung gelten soll. Ein Fenster, das mit
dem Stand vom Vortag aufwacht, kann den Plan so nicht mehr überschreiben.
Siehe D89-Nachtrag 2.
**Ein zweites Werkbaum-Fenster desselben Browsers** — Tab, Fenster oder PWA **Ein zweites Werkbaum-Fenster desselben Browsers** — Tab, Fenster oder PWA
— kollidiert nur noch am **selben nicht geteilten Dokument**: Text, Name und — kollidiert nur noch am **selben nicht geteilten Dokument**: Text, Name und
+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.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.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) - [^] #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 - [^] #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.gitea: The repository lives on Gitea, GitHub stays a clone (S)
- [-] #bld.ghpages: Deploy through a gh-pages branch (S) %% GitHub Actions instead - [-] #bld.ghpages: Deploy through a gh-pages branch (S) %% GitHub Actions instead
- [-] #bld.gfonts: Fonts from Google (XS) %% self-hosted, no third-party request - [-] #bld.gfonts: Fonts from Google (XS) %% self-hosted, no third-party request
- [ ] #be: Backend (XL) - [ ] #be: Backend (XL)
@@ -151,6 +151,7 @@
- [~] #col.git: Git as the shared store (L) - [~] #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.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.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.auto: The backend commits every change (L) :#be.scaffold
- [?] #col.git.hist: History and restore (M) - [?] #col.git.hist: History and restore (M)
- [?] #col.git.diff: Diff between two versions (S) - [?] #col.git.diff: Diff between two versions (S)
@@ -970,6 +971,13 @@
worktree — date, title and version in the message, no commit when nothing worktree — date, title and version in the message, no commit when nothing
changed. Run from cron, a shared plan archives itself. 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 #col.git.auto
The server turns every change into a commit. History, restore and branches The server turns every change into a commit. History, restore and branches
follow from that, with commit granularity as the open question. follow from that, with commit granularity as the open question.
@@ -0,0 +1,98 @@
> **Hinweis:** Dieses Dokument beschreibt nur die Änderung dieses PR.
> Es kann veraltet sein, sobald der nächste PR gemergt ist.
> Historische PR-Dokumentation wird nicht nachgepflegt — sie ist eine Momentaufnahme, keine aktuelle Doku.
## Das Problem
Die Beispiel-Links beider READMEs laden ihren Plan per `?sourceUrl=` aus einer Textdatei im Netz (D23).
Der Browser holt eine fremde Herkunft nur, wenn die Antwort `Access-Control-Allow-Origin` trägt.
`raw.githubusercontent.com` sendet den Header, `git.javagil.de` sendete ihn nicht.
Deshalb mussten die Links nach dem Umzug nach Gitea (D95) weiterhin auf den GitHub-Klon zeigen, obwohl das Repository dort gar nicht mehr zuhause ist.
Gemessen am 2026-09-03, mit `Origin:`-Header: HTTP 200, `Access-Control-Expose-Headers: Content-Disposition`, `Content-Type: text/plain; charset=utf-8` — aber kein `Access-Control-Allow-Origin`.
Die Datei selbst war also einwandfrei erreichbar; es fehlte genau ein Header.
## Nicht das Ziel
- Der GitHub-Klon verschwindet nicht: die GitHub-Pages-Instanz (D16) hängt weiter daran, sie ist ein Actions-Workflow und lässt sich nicht mitnehmen.
- Kein Proxy-Dienst als Ausweg — D23 verwirft ihn ausdrücklich, weil er fremde Inhalte über einen Dritt-Host leiten würde.
- Keine Änderung am Frontend: `sourceUrl` bleibt, wie es ist; der Fehler lag ausschließlich auf der Serverseite.
## Die Szenarien
### Feature: Beispiel-Pläne laden aus dem eigenen Gitea
#### Hintergrund
- 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#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.
- **Gegeben** ein Beispiel-Link mit `?sourceUrl=https://git.javagil.de/mi/werkbaum/raw/branch/main/…`
- **Wenn** der Browser die Datei aus der fremden Herkunft holt
- **Dann** trägt die Antwort `Access-Control-Allow-Origin: *` und der Plan wird geöffnet
##### Nachgewiesen durch
- Messung 2026-09-03 auf allen fünf Beispieldateien: je HTTP 200 mit genau einem `Access-Control-Allow-Origin: *`.
#### 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.
- **Gegeben** Gitea setzt den Header auf `/api/v1/…` bereits selbst
- **Wenn** die `.htaccess` diese Route nicht mitfasst
- **Dann** steht in der Antwort genau ein `Access-Control-Allow-Origin: *`
##### Nachgewiesen durch
- 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#3.03: Gewöhnliche Repo-Seiten bleiben unberührt
Damit der Header nur dort steht, wo er gebraucht wird, und nicht site-weit.
- **Gegeben** eine gewöhnliche Seite wie `/mi/werkdock`
- **Wenn** sie abgerufen wird
- **Dann** trägt die Antwort keinen `Access-Control-Allow-Origin`
##### Nachgewiesen durch
- Messung 2026-09-03: `/mi/werkdock` → HTTP 200, null `Access-Control-Allow-Origin`-Header.
## Die Lösung
Auf mih09, beim Benutzer `mih09-git` (Sicherungskopien mit Zeitstempel liegen jeweils daneben):
- `gitea/custom/conf/app.ini` bekommt einen `[cors]`-Abschnitt (`ENABLED = true`, `ALLOW_DOMAIN = *`, `METHODS = GET,HEAD`); Gitea neu gestartet.
- `doms/git.javagil.de/htdocs-ssl/.htaccess` bekommt `SetEnvIf Request_URI "^/[^/]+/[^/]+/(raw|media)/"` plus `Header always set Access-Control-Allow-Origin "*" env=…`, vor den vorhandenen Proxy-Regeln.
Im Repository:
- Die Beispiel-Links beider READMEs zeigen auf `git.javagil.de/mi/werkbaum/raw/branch/main/…`.
- Die CORS-Einschränkung in beiden READMEs und in D23 nennt `git.javagil.de` jetzt unter den Hosts, die den Header senden.
- Der Absatz „GitHub bleibt ein Klon“ nennt nur noch GitHub Pages als Grund; D95 bekommt einen Nachtrag, D96 hält die Konfiguration im Wortlaut fest.
Warum die Aufteilung auf zwei Stellen: Gitea 1.27 legt die CORS-Middleware nur auf `/api/v1`, nicht auf die Web-raw-Route — und genau die benutzen die Links.
Warum `SetEnvIf` statt `<LocationMatch>`: Location-Direktiven sind in einer `.htaccess` nicht erlaubt, und auf einem Managed Webspace gibt es nur `.htaccess`.
Warum `*` ohne Credentials: unter einem Wildcard-Ursprung sendet der Browser keine Cookies, ein fremder Ursprung liest also nur, was ohnehin anonym abrufbar ist; private Repositories antworten weiter mit 404/403.
## Offene Fragen
- Keine. Beide Fallen (verbotenes `<LocationMatch>`, doppelter Header) sind beim Umsetzen aufgetreten und behoben; die Messungen decken Web-raw, API-raw und eine gewöhnliche Seite ab.
## Weitere Änderungen
- Der Link „Werkbaum selbst“ war schon vorher tot: er zeigte auf `example-werkbaum.werkbaum`, die Datei heißt `werkbaum.werkbaum` — auch auf GitHub lieferte er 404. Beim Umstellen mit korrigiert.
- `docs/prs/` samt Konvention neu eingeführt; `CLAUDE.md` verweist darauf.
## Vorausgesetzte PRs
- Keine; setzt den Umzug nach Gitea (D95) voraus, der bereits auf `main` liegt.
## Folge-PRs
- Keine geplant.
+37
View File
@@ -0,0 +1,37 @@
# PR-Dokumentation
Jeder Pull Request bekommt hier **eine** Datei.
## Dateiname
`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.
## Aufbau
Die `##`-Abschnitte in genau dieser Reihenfolge, nicht Zutreffendes weglassen:
1. Das Problem
2. Nicht das Ziel
3. Die Szenarien
4. Die Lösung
5. Offene Fragen
6. Weitere Änderungen
7. Vorausgesetzte PRs
8. Folge-PRs
## Regeln
- Deutsch (CLAUDE.md), Markdown, ein Satz je Zeile, kurz halten.
- Das „Warum“ erklären, nicht nur das „Was“.
- Szenarien in Markdown-eigenem Pseudo-Gherkin mit IDs `Szenario#<pr-nummer>.<nn>`,
jedes mit einer Liste `##### Nachgewiesen durch`.
- Ein PR-doc beschreibt den Stand **dieses** PR. Ältere PR-docs werden nicht
nachgepflegt, wenn spätere PRs das Verhalten ändern — dauerhafte Begründungen
gehören nach `docs/DECISIONS.md`.
+39 -7
View File
@@ -3484,6 +3484,7 @@ const I18N = {
liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.", liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.",
liveConflictText:"Jemand hat dieselben Zeilen geändert. Wessen Fassung soll gelten?", liveConflictText:"Jemand hat dieselben Zeilen geändert. Wessen Fassung soll gelten?",
liveReconnectText:"Dieses Fenster war getrennt und hat eigene Änderungen — der Stand auf dem Server weicht ab. Wessen Fassung soll gelten?",
liveConflictTheirs:"Fremde übernehmen", liveConflictTheirs:"Fremde übernehmen",
liveConflictMine:"Eigene durchsetzen", liveConflictMine:"Eigene durchsetzen",
riskTooltip:"High Risk Aufwand noch unklar.", riskTooltip:"High Risk Aufwand noch unklar.",
@@ -3627,6 +3628,7 @@ const I18N = {
liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.", liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.",
liveConflictText:"Someone changed the same lines. Whose version should win?", liveConflictText:"Someone changed the same lines. Whose version should win?",
liveReconnectText:"This window was disconnected and holds changes of its own — the server state differs. Whose version should win?",
liveConflictTheirs:"Take theirs", liveConflictTheirs:"Take theirs",
liveConflictMine:"Keep mine", liveConflictMine:"Keep mine",
riskTooltip:"High risk effort still unclear.", riskTooltip:"High risk effort still unclear.",
@@ -3769,6 +3771,7 @@ const I18N = {
liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.", liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.",
liveConflictText:"Alguien cambió las mismas líneas. ¿Qué versión debe prevalecer?", liveConflictText:"Alguien cambió las mismas líneas. ¿Qué versión debe prevalecer?",
liveReconnectText:"Esta ventana estuvo desconectada y tiene cambios propios — el estado en el servidor difiere. ¿Qué versión debe prevalecer?",
liveConflictTheirs:"Tomar la ajena", liveConflictTheirs:"Tomar la ajena",
liveConflictMine:"Mantener la mía", liveConflictMine:"Mantener la mía",
riskTooltip:"Alto riesgo esfuerzo aún incierto.", riskTooltip:"Alto riesgo esfuerzo aún incierto.",
@@ -3911,6 +3914,7 @@ const I18N = {
liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/&lt;uuid&gt;) ?", liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/&lt;uuid&gt;) ?",
liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.", liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.",
liveConflictText:"Quelqu'un a modifié les mêmes lignes. Quelle version doit l'emporter ?", liveConflictText:"Quelqu'un a modifié les mêmes lignes. Quelle version doit l'emporter ?",
liveReconnectText:"Cette fenêtre a été déconnectée et contient ses propres modifications — l'état sur le serveur diffère. Quelle version doit l'emporter ?",
liveConflictTheirs:"Prendre la sienne", liveConflictTheirs:"Prendre la sienne",
liveConflictMine:"Garder la mienne", liveConflictMine:"Garder la mienne",
riskTooltip:"Risque élevé effort encore incertain.", riskTooltip:"Risque élevé effort encore incertain.",
@@ -4053,6 +4057,7 @@ const I18N = {
liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.", liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.",
liveConflictText:"Ktoś zmienił te same wiersze. Która wersja ma obowiązywać?", liveConflictText:"Ktoś zmienił te same wiersze. Która wersja ma obowiązywać?",
liveReconnectText:"To okno było rozłączone i ma własne zmiany — stan na serwerze się różni. Która wersja ma obowiązywać?",
liveConflictTheirs:"Przyjmij cudzą", liveConflictTheirs:"Przyjmij cudzą",
liveConflictMine:"Zachowaj własną", liveConflictMine:"Zachowaj własną",
riskTooltip:"Wysokie ryzyko nakład jeszcze niejasny.", riskTooltip:"Wysokie ryzyko nakład jeszcze niejasny.",
@@ -4195,6 +4200,7 @@ const I18N = {
liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.", liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.",
liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?", liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?",
liveReconnectText:"Это окно было отключено и содержит собственные изменения — состояние на сервере отличается. Чья версия должна остаться?",
liveConflictTheirs:"Принять чужую", liveConflictTheirs:"Принять чужую",
liveConflictMine:"Оставить свою", liveConflictMine:"Оставить свою",
riskTooltip:"Высокий риск – оценка ещё не ясна.", riskTooltip:"Высокий риск – оценка ещё не ясна.",
@@ -4337,6 +4343,7 @@ const I18N = {
liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।", liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।",
liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?", liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?",
liveReconnectText:"यह विंडो डिस्कनेक्ट थी और इसमें अपने बदलाव हैं — सर्वर की स्थिति अलग है। किसका संस्करण रहे?",
liveConflictTheirs:"दूसरे का लें", liveConflictTheirs:"दूसरे का लें",
liveConflictMine:"अपना रखें", liveConflictMine:"अपना रखें",
riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।", riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।",
@@ -4490,6 +4497,7 @@ const I18N = {
liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/&lt;uuid&gt;)吗?", liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/&lt;uuid&gt;)吗?",
liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。", liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。",
liveConflictText:"有人改动了同样的行。以谁的版本为准?", liveConflictText:"有人改动了同样的行。以谁的版本为准?",
liveReconnectText:"此窗口曾断开连接并有自己的改动——服务器上的状态与此不同。以谁的版本为准?",
liveConflictTheirs:"采用对方的", liveConflictTheirs:"采用对方的",
liveConflictMine:"保留我的", liveConflictMine:"保留我的",
riskTooltip:"高风险 – 工作量尚不明确。", riskTooltip:"高风险 – 工作量尚不明确。",
@@ -4632,6 +4640,7 @@ const I18N = {
liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/&lt;uuid&gt;)ですか。", liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/&lt;uuid&gt;)ですか。",
liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。", liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。",
liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。", liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。",
liveReconnectText:"このウィンドウは切断されていて独自の変更があります — サーバー側の状態は異なります。どちらの版を採りますか。",
liveConflictTheirs:"相手の版", liveConflictTheirs:"相手の版",
liveConflictMine:"自分の版", liveConflictMine:"自分の版",
riskTooltip:"高リスク – 規模はまだ不明。", riskTooltip:"高リスク – 規模はまだ不明。",
@@ -5787,6 +5796,7 @@ document.addEventListener('pointerdown', e => {
}); });
function loadActiveIntoEditor(){ const d = activeDoc(); src.value = d ? d.text : ''; function loadActiveIntoEditor(){ const d = activeDoc(); src.value = d ? d.text : '';
liveSynced(); /* der Text im Feld ist jetzt der geladene Stand — nichts davon ist Arbeit dieses Fensters */
clearLens(); /* die Personen-Linse (D87) gilt je Dokument-Sitzung */ clearLens(); /* die Personen-Linse (D87) gilt je Dokument-Sitzung */
/* Vergleichsstand für den nächsten Snapshot (D54): Ohne ihn legte der /* Vergleichsstand für den nächsten Snapshot (D54): Ohne ihn legte der
erste Takt nach dem Öffnen auch ein unverändertes Dokument weg. */ erste Takt nach dem Öffnen auch ein unverändertes Dokument weg. */
@@ -6396,6 +6406,17 @@ function nextSeq(){
let liveState = null; /* {urls, id, version, shadow, seq, pushTimer, feedAbort, busy} */ let liveState = null; /* {urls, id, version, shadow, seq, pushTimer, feedAbort, busy} */
let liveConflict = null; /* offener Konflikt: bis der Mensch entscheidet, ruht alles */ let liveConflict = null; /* offener Konflikt: bis der Mensch entscheidet, ruht alles */
/* Wurde in diesem Fenster seit dem letzten gelungenen Abgleich gearbeitet?
Zwei Zähler statt eines Flags: `liveEditSeq` zählt jede Textänderung an
einem Server-Dokument (auch ohne Sitzung das Falten auf einem toten
Telefon ist genau der Fall), `liveSyncSeq` hält den Stand, den der Server
zuletzt bestätigt hat oder den wir von ihm übernommen haben. Ein Senden,
während weiter getippt wird, gilt so nur für das, was es getragen hat.
Die Wiederverbindung fragt danach, statt den Text zu vergleichen
(D89-Nachtrag 2). */
let liveEditSeq = 0, liveSyncSeq = 0;
function liveEdited(){ return liveEditSeq !== liveSyncSeq; }
function liveSynced(){ liveSyncSeq = liveEditSeq; }
function liveActive(){ return !!liveState && activeId === liveState.id; } function liveActive(){ return !!liveState && activeId === liveState.id; }
@@ -6488,6 +6509,8 @@ function adoptLive(doc){
weckt den wartenden Feed 39 ms nach dem PATCH (D79). Sie bleibt ein weckt den wartenden Feed 39 ms nach dem PATCH (D79). Sie bleibt ein
Debounce, kein Takt: Wer durchtippt, erzeugt weiterhin keine Version. */ Debounce, kein Takt: Wer durchtippt, erzeugt weiterhin keine Version. */
function scheduleLivePush(){ function scheduleLivePush(){
const d = activeDoc();
if(d && String(d.id).startsWith('live:')) liveEditSeq++; /* gearbeitet — mit oder ohne Sitzung */
if(!liveActive() || liveConflict) return; if(!liveActive() || liveConflict) return;
if(liveState.pushTimer) clearTimeout(liveState.pushTimer); if(liveState.pushTimer) clearTimeout(liveState.pushTimer);
liveState.pushTimer = setTimeout(() => { liveState.pushTimer = null; pushLive(); }, liveState.pushTimer = setTimeout(() => { liveState.pushTimer = null; pushLive(); },
@@ -6520,9 +6543,12 @@ function rescueSnapshot(docId, text){
/* Eine tote Sitzung heilt sich SELBST (Nutzer-Einwand zu D89: Ohne Reload /* Eine tote Sitzung heilt sich SELBST (Nutzer-Einwand zu D89: Ohne Reload
ist wieder alles tot?" nein): Der Wachhund versucht die Wiederverbindung, ist wieder alles tot?" nein): Der Wachhund versucht die Wiederverbindung,
alle 10 s, solange das aktive Dokument ein Server-Dokument ohne Sitzung alle 10 s, solange das aktive Dokument ein Server-Dokument ohne Sitzung
ist. Ungesendeter Text bleibt dabei STEHEN und geht als Diff an den Server ist. Sie sendet NIE von selbst (D89-Nachtrag 2): Wurde hier seit dem
(die keepMine-Semantik aus dem Konflikt-Band) die Wiederverbindung darf letzten Abgleich nicht gearbeitet, gilt der Server-Stand der lokale
nicht kosten, was sie retten soll. */ Text ist dann nur ein alter Spiegel; wurde gearbeitet, fragt das
Konflikt-Band, wessen Fassung gelten soll. Die frühere Fassung schickte
jeden abweichenden lokalen Text als Diff und ließ damit ein aufgewachtes
Telefon den Plan mit dem Stand vom Vortag überschreiben. */
let reconnectBusy = false, reconnectNext = 0; let reconnectBusy = false, reconnectNext = 0;
async function reconnectLive(d){ async function reconnectLive(d){
if(reconnectBusy || Date.now() < reconnectNext) return; if(reconnectBusy || Date.now() < reconnectNext) return;
@@ -6535,7 +6561,9 @@ async function reconnectLive(d){
const serverText = live.normalize(doc.content || ''); const serverText = live.normalize(doc.content || '');
liveState = {urls, id: d.id, version: doc.version, shadow: live.lines(serverText), liveState = {urls, id: d.id, version: doc.version, shadow: live.lines(serverText),
pushTimer: null, feedAbort: null, busy: false}; pushTimer: null, feedAbort: null, busy: false};
if(live.normalize(src.value) !== serverText) pushLive(); const what = live.reconnectAction(liveEdited(), src.value, serverText);
if(what === 'adopt') adoptLive(doc); /* sichert den lokalen Text vorher (D89) */
else if(what === 'ask') openConflict(doc.version, live.lines(serverText), 'liveReconnectText');
runFeed(); runFeed();
if(sourceWarning && (sourceWarning.type === 'liveLoad' || sourceWarning.type === 'liveStale')){ if(sourceWarning && (sourceWarning.type === 'liveLoad' || sourceWarning.type === 'liveStale')){
sourceWarning = null; sourceWarning = null;
@@ -6594,6 +6622,7 @@ async function pushLive(meilenstein){
const sitzung = liveState; const sitzung = liveState;
sitzung.busy = true; sitzung.busy = true;
const seq = nextSeq(); const seq = nextSeq();
const editStand = liveEditSeq; /* was danach getippt wird, trägt dieses Senden nicht */
/* Die Basis, gegen die `ops` gerechnet sind VOR dem Warten festgehalten. /* Die Basis, gegen die `ops` gerechnet sind VOR dem Warten festgehalten.
Sie hinterher aus `liveState` zu lesen hieße anzunehmen, dass sich Sie hinterher aus `liveState` zu lesen hieße anzunehmen, dass sich
dazwischen nichts ändert; genau diese Annahme ist gebrochen, sobald der dazwischen nichts ändert; genau diese Annahme ist gebrochen, sobald der
@@ -6628,6 +6657,7 @@ async function pushLive(meilenstein){
sitzung.shadow = live.applyOps( sitzung.shadow = live.applyOps(
foreign.length ? live.applyOps(basis, foreign) : basis, meine); foreign.length ? live.applyOps(basis, foreign) : basis, meine);
sitzung.version = result.version; sitzung.version = result.version;
liveSyncSeq = editStand;
/* Ein gelungener Abgleich räumt liegengebliebene Live-Warnungen SOFORT /* Ein gelungener Abgleich räumt liegengebliebene Live-Warnungen SOFORT
weg nicht geladen" oder „ungesendet" neben funktionierendem Senden weg nicht geladen" oder „ungesendet" neben funktionierendem Senden
wäre eine Lüge, und der Wachhund-Tick kann gedrosselt sein (1/min in wäre eine Lüge, und der Wachhund-Tick kann gedrosselt sein (1/min in
@@ -6737,6 +6767,7 @@ function applyFeed(feed){
liveState.version = feed.currentVersion; liveState.version = feed.currentVersion;
liveState.shadow = live.lines(neu); liveState.shadow = live.lines(neu);
setLiveText(live.text(liveState.shadow), null); setLiveText(live.text(liveState.shadow), null);
liveSynced();
return; return;
} }
const alt = liveState.shadow; const alt = liveState.shadow;
@@ -6808,9 +6839,9 @@ function setLiveText(text, caret){
/* Echte Überschneidung: Der Server hat abgelehnt, und jetzt entscheidet der /* Echte Überschneidung: Der Server hat abgelehnt, und jetzt entscheidet der
Mensch. Bis dahin ruhen Senden und Feed sonst zöge der Stand unter der Mensch. Bis dahin ruhen Senden und Feed sonst zöge der Stand unter der
Frage weg, die gerade gestellt ist. */ Frage weg, die gerade gestellt ist. */
function openConflict(version, serverLines){ function openConflict(version, serverLines, textKey){
if(liveConflict) return; /* eine Frage zur Zeit */ if(liveConflict) return; /* eine Frage zur Zeit */
liveConflict = {version, serverLines}; liveConflict = {version, serverLines, textKey: textKey || 'liveConflictText'};
showConflictBanner(); showConflictBanner();
} }
@@ -6829,6 +6860,7 @@ function takeTheirs(){
liveState.version = c.version; liveState.version = c.version;
liveState.shadow = c.serverLines; liveState.shadow = c.serverLines;
setLiveText(live.text(c.serverLines), null); setLiveText(live.text(c.serverLines), null);
liveSynced();
} }
/* Eigene durchsetzen: Die Schattenkopie zieht auf den Server-Stand nach, der /* Eigene durchsetzen: Die Schattenkopie zieht auf den Server-Stand nach, der
@@ -6853,7 +6885,7 @@ function showConflictBanner(){
'<div class="live-conflict-actions">' + '<div class="live-conflict-actions">' +
'<button type="button" class="theirs"></button>' + '<button type="button" class="theirs"></button>' +
'<button type="button" class="mine"></button></div>'; '<button type="button" class="mine"></button></div>';
bar.querySelector('span').textContent = t('liveConflictText'); bar.querySelector('span').textContent = t(liveConflict.textKey);
const theirs = bar.querySelector('.theirs'); const theirs = bar.querySelector('.theirs');
const mine = bar.querySelector('.mine'); const mine = bar.querySelector('.mine');
theirs.textContent = t('liveConflictTheirs'); theirs.textContent = t('liveConflictTheirs');
+17
View File
@@ -302,3 +302,20 @@ export function feedAction(feed, shadowVersion, pushInFlight){
if(Array.isArray(feed.ops) && feed.fromVersion === shadowVersion) return 'apply'; if(Array.isArray(feed.ops) && feed.fromVersion === shadowVersion) return 'apply';
return 'skip'; return 'skip';
} }
/* Was die Wiederverbindung nach einer toten Sitzung mit dem lokalen Text
tut (D89-Nachtrag 2). Die erste Fassung schickte ihn, sobald er vom Server
abwich und konnte nicht unterscheiden, ob er NEUER ist als der Server
(ungesendete Arbeit) oder ÄLTER (ein Fenster, das lange geschlafen hat):
Ein Telefon, das mit dem Stand vom Vortag aufwachte, schrieb ihn als
Vollersetzung über den aktuellen Plan. Entscheidend ist deshalb nicht der
Vergleich der Texte, sondern ob in diesem Fenster seit dem letzten
gelungenen Abgleich GEARBEITET wurde.
'none' Texte gleich: nur Version und Schattenkopie übernehmen
'adopt' nicht gearbeitet: der Server-Stand gilt, der lokale weicht
'ask' gearbeitet: beide Stände existieren, ein Mensch entscheidet */
export function reconnectAction(edited, localText, serverText){
if(normalize(localText) === normalize(serverText)) return 'none';
return edited ? 'ask' : 'adopt';
}
+18
View File
@@ -2,6 +2,7 @@ import {describe, it, expect} from 'vitest';
import { import {
liveUrls, normalize, lines, text, computeOps, applyOps, liveUrls, normalize, lines, text, computeOps, applyOps,
mapLine, caretToLineCol, lineColToCaret, feedAction, serverBase, documentsUrl, infoUrl, mapLine, caretToLineCol, lineColToCaret, feedAction, serverBase, documentsUrl, infoUrl,
reconnectAction,
} from '../src/live.js'; } from '../src/live.js';
describe('Adressen', () => { describe('Adressen', () => {
@@ -243,3 +244,20 @@ describe('Feed-Antwort anwenden oder nicht', () => {
expect(feedAction({fromVersion: null, currentVersion: 87, content: 'x'}, 4, true)).toBe('skip'); expect(feedAction({fromVersion: null, currentVersion: 87, content: 'x'}, 4, true)).toBe('skip');
}); });
}); });
describe('Wiederverbindung (D89-Nachtrag 2)', () => {
// Der Vorfall: Ein Telefon wacht mit dem Stand vom Vortag auf, die Sitzung
// ist tot, ein Tipp auf ein Falt-Zeichen — und die alte Fassung ging als
// Diff über den aktuellen Plan. Entscheidend ist nicht, ob die Texte
// abweichen, sondern ob hier gearbeitet wurde.
it('gleicher Text: nichts zu entscheiden', () => {
expect(reconnectAction(false, 'a\nb', 'a\nb')).toBe('none');
expect(reconnectAction(true, 'a\r\nb', 'a\nb')).toBe('none'); // Zeilenenden normalisiert
});
it('nicht gearbeitet: der Server gilt, auch wenn der lokale Text abweicht', () => {
expect(reconnectAction(false, 'alter Stand', 'neuer Stand')).toBe('adopt');
});
it('gearbeitet: fragen statt senden — beide Stände existieren', () => {
expect(reconnectAction(true, 'alter Stand plus Faltmarke', 'neuer Stand')).toBe('ask');
});
});
+7 -7
View File
@@ -7,13 +7,13 @@
# dorthin: `main`. Feature-Branches und Tags bleiben in Gitea — was auf GitHub # dorthin: `main`. Feature-Branches und Tags bleiben in Gitea — was auf GitHub
# steht, ist der veröffentlichte Stand. # steht, ist der veröffentlichte Stand.
# #
# Der Klon ist kein Selbstzweck. An ihm hängen zwei Dinge, die nicht umziehen # Der Klon ist kein Selbstzweck. An ihm hängt der GitHub-Pages-Workflow
# können: # (`.github/workflows/pages.yml`), also die „latest build"-Instanz
# * der GitHub-Pages-Workflow (`.github/workflows/pages.yml`), also die # mhoennig.github.io/werkbaum — die kann nicht umziehen.
# „latest build"-Instanz mhoennig.github.io/werkbaum; #
# * die Beispiel-Links der READMEs, die per `?sourceUrl=` von # Die Beispiel-Links der READMEs waren ein zweiter Grund und sind es nicht mehr:
# raw.githubusercontent.com laden — Gitea sendet auf `raw`-Dateien KEIN # `git.javagil.de` sendet auf `raw`-Dateien inzwischen
# `Access-Control-Allow-Origin`, der Browser blockte sie also (D23, D95). # `Access-Control-Allow-Origin`, sie laden deshalb von Gitea (D96, revidiert D95).
# #
# Verwendung: # Verwendung:
# scripts/push-github.sh [-n] [-y] # 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"