feat(editor): Mehr-Fenster-Betrieb gebaut — Ablageschema v3, Tombstones, docsync, Sperre je Dokument (RFC 002, D94)
Kein Fenster schreibt mehr den Schlüssel eines anderen: Text, Meta und frühere Stände liegen je Dokument unter eigenen Schlüsseln, der Index ist nur noch Reihenfolge-Hinweis und löscht nie, Löschungen hinterlassen einen Tombstone (7 Tage). Der Voll-Flush weicht dem Dirty-Flush. Damit sind Befund 2 und 3 des RFC weg — verschiedene Dokumente in zwei Fenstern kollidieren gar nicht mehr. docsync.js zieht Liste, Namen, Tombstones und den Stände-Cache aus dem storage-Ereignis nach; am aktiven Dokument die drei Fälle umbenannt / anderswo gelöscht (behalten, bis getippt wird) / fremd geschrieben. Der modale D89-Dialog samt Präsenz-Kanal ist ersatzlos raus; den einen Verlustfall — dasselbe nicht-`live:`-Dokument in beiden Fenstern vorn — findet eine Sperre per Web Locks, und das zweite Fenster bekommt drei Auswege statt „trotzdem fortfahren". Im Browser mit zwei echten Tabs nachgemessen (RFC §10): Fälle 1–8 grün, Fall 1 im Vor-v3-Build gegengeprüft und dort nachweislich rot. Fall 9 deckte auf, dass reviveGoneDoc() den Index-Hinweis nicht mitschrieb — ein durch Tippen wiederbelebtes Dokument hing bis zum nächsten Flush allein an seinen eigenen Schlüsseln und wäre bei einem Rückbau auf einen Build vor v3 still verlorengegangen; behoben, Gegenprobe per Mutation gezogen. Headless 666 Tests grün. Offen bleibt die Handarbeit: PWA neben Tab, Firefox, Safari, ein Browser ohne Locks-API. SPEC §9, D94, D89-Nachtrag, RFC (Status, §10, Revisionsgeschichte), CHANGELOG und Plan-Knoten #ed.docs.windows nachgezogen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
112ada4e58
commit
caaf7c15e1
@@ -19,6 +19,10 @@ reverse.
|
|||||||
|
|
||||||
## 2026-09-02
|
## 2026-09-02
|
||||||
|
|
||||||
|
- Running the installed app and a browser tab side by side works now: text, names and earlier states live under their own storage key per document and deleting leaves a tombstone, so windows stop deleting each other's work
|
||||||
|
- A document created, renamed or deleted in another window shows up without a reload now, and a document deleted elsewhere while open here stays until you type — typing brings it back
|
||||||
|
- The modal "open more than once" dialog is gone; the one remaining loss case — the same non-shared document open in two windows — is found by a per-document lock, and the second window chooses: open another document, view only until the other window lets go, or edit anyway
|
||||||
|
- The other-window warning now names the document and says whether it is open in the app or in the browser — and stays silent when nothing collides
|
||||||
- Running the installed app and a browser tab side by side recorded as planned: documents get their own storage keys so windows stop deleting each other's work, a per-document lock finds the one real loss case, and the second window gets three exits instead of "continue anyway" — spelled out in RFC 002 before it is built
|
- Running the installed app and a browser tab side by side recorded as planned: documents get their own storage keys so windows stop deleting each other's work, a per-document lock finds the one real loss case, and the second window gets three exits instead of "continue anyway" — spelled out in RFC 002 before it is built
|
||||||
- An MCP server for AI agents recorded as planned: it lives in the backend, runs the one JS parser through GraalJS, hands plans to Claude Code and other agents as resources and tools over streamable HTTP, and writes changes as conflict-safe line diffs — no new runtime, spelled out in an RFC before it is built
|
- An MCP server for AI agents recorded as planned: it lives in the backend, runs the one JS parser through GraalJS, hands plans to Claude Code and other agents as resources and tools over streamable HTTP, and writes changes as conflict-safe line diffs — no new runtime, spelled out in an RFC before it is built
|
||||||
|
|
||||||
|
|||||||
+69
-8
@@ -7747,15 +7747,16 @@ Warnung den Zustand, und die Sicherungen halten den Text. Unabhängig davon
|
|||||||
kann `tools/pull-doc --git-commit` (D88) per Cron eine Git-Historie des
|
kann `tools/pull-doc --git-commit` (D88) per Cron eine Git-Historie des
|
||||||
Server-Dokuments führen — ein Netz außerhalb des Browsers.
|
Server-Dokuments führen — ein Netz außerhalb des Browsers.
|
||||||
|
|
||||||
**Nachtrag — der modale Zwei-Fenster-Dialog wird revidiert (2026-09-02).**
|
**Nachtrag — der modale Zwei-Fenster-Dialog ist weg (revidiert, gebaut 2026-09-02).**
|
||||||
Das erste Netz oben ist in der Sache überholt: Es erschien auch dort, wo
|
Das erste Netz oben war in der Sache überholt: Es erschien auch dort, wo
|
||||||
nichts kollidiert (dasselbe geteilte Dokument in App und Tab — zwei
|
nichts kollidiert (dasselbe geteilte Dokument in App und Tab — zwei
|
||||||
Live-Clients, der Server führt zusammen), und es schützte nicht vor dem,
|
Live-Clients, der Server führt zusammen), und es schützte nicht vor dem,
|
||||||
wovor es warnte — hinter der Overlay-Schicht liefen Start, Flush und Feed
|
wovor es warnte — hinter der Overlay-Schicht liefen Start, Flush und Feed
|
||||||
weiter. Der eigentliche Verlust zwischen zwei Fenstern liegt in den
|
weiter. Der eigentliche Verlust zwischen zwei Fenstern lag in den
|
||||||
Sammel-Schlüsseln der Ablage, nicht im Dialog. Analyse, Alternativen und
|
Sammel-Schlüsseln der Ablage, nicht im Dialog. Analyse, Alternativen und
|
||||||
Entscheidungen: **D94** und `docs/rfc/002-mehrfenster.md`. Die drei
|
Entscheidungen: **D94** und `docs/rfc/002-mehrfenster.md`. Der Präsenz-Kanal
|
||||||
anderen Netze (Sicherungen, Rettung, Wachhund) bleiben.
|
(Herzschlag, Timeout, Notluke) und der modale Dialog sind ersatzlos
|
||||||
|
ausgebaut; die drei anderen Netze (Sicherungen, Rettung, Wachhund) bleiben.
|
||||||
|
|
||||||
## 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
|
||||||
@@ -8830,7 +8831,7 @@ ungepuffert durch. Fällt er durch, ist die Antwort nicht Node, sondern eine
|
|||||||
neue Frage an den Entwickler. Offen sonst nur die Prompts. Gebaut ist
|
neue Frage an den Entwickler. Offen sonst nur die Prompts. Gebaut ist
|
||||||
nichts.
|
nichts.
|
||||||
|
|
||||||
## D94 — Mehr-Fenster-Betrieb: getrennte Schlüssel, Sperre je Dokument, Dialog mit Auswegen — als RFC vorgelegt, nichts gebaut
|
## D94 — Mehr-Fenster-Betrieb: getrennte Schlüssel, Sperre je Dokument, Dialog mit Auswegen — gebaut (2026-09-02)
|
||||||
Gemeldet: Werkbaum als installierte App mit einem `?live=`-Dokument, dazu
|
Gemeldet: Werkbaum als installierte App mit einem `?live=`-Dokument, dazu
|
||||||
dieselbe Seite im Browser-Tab — der Tab stellt das zuletzt aktive
|
dieselbe Seite im Browser-Tab — der Tab stellt das zuletzt aktive
|
||||||
(geteilte) Dokument her, und in beiden Fenstern steht sofort der modale
|
(geteilte) Dokument her, und in beiden Fenstern steht sofort der modale
|
||||||
@@ -8918,5 +8919,65 @@ Browser-Bestand (Chrome 69, Firefox 96, Safari 15.4).
|
|||||||
im Browser mit zwei Tabs — vor allem der Fall, der heute rot ist: B legt
|
im Browser mit zwei Tabs — vor allem der Fall, der heute rot ist: B legt
|
||||||
ein Dokument an, A wechselt das Dokument, B's Dokument muss überleben.
|
ein Dokument an, A wechselt das Dokument, B's Dokument muss überleben.
|
||||||
Die PWA-Nachstellung selbst bleibt Handtest (D73), ebenso Firefox und
|
Die PWA-Nachstellung selbst bleibt Handtest (D73), ebenso Firefox und
|
||||||
Safari. Werkzeuggrenzen wie in D79/D82/D83/D91-Nachtrag 9 benannt. Gebaut
|
Safari. Werkzeuggrenzen wie in D79/D82/D83/D91-Nachtrag 9 benannt.
|
||||||
ist nichts.
|
|
||||||
|
**Gebaut (2026-09-02, vier Schritte wie im RFC §12):**
|
||||||
|
|
||||||
|
- **Schema v3** (`docstore.js`): `readDocs` liest die Union aus Meta-, Text-
|
||||||
|
und Index-ids minus Tombstones; `writeDoc`/`writeIndexHint`/`removeDoc`/
|
||||||
|
`expireTombstones` ersetzen den Voll-Flush — der Index schreibt sich aus
|
||||||
|
`Speicher-ids ∪ eigene Liste` und entfernt nichts. Migration
|
||||||
|
(`migrateV3`) verteilt die alten Sammel-Stände und schreibt fehlende
|
||||||
|
Meta, idempotent. Die Stände liegen je Dokument (`werkbaum-snaps:<id>`,
|
||||||
|
`snapshots.js`); nur der Quota-Notfall fasst noch einen fremden
|
||||||
|
Schlüssel an — lesend gekürzt, nie aus dem eigenen Gedächtnis
|
||||||
|
überschrieben (§6.8).
|
||||||
|
- **Dirty-Flush** statt Voll-Flush (`app.js`): die Flush-Punkte schreiben
|
||||||
|
nur, was dieses Fenster angelegt, umbenannt oder getippt hat, plus den
|
||||||
|
Index-Hinweis. Der Tastendruck bleibt `storeDocText` — er hebt einen
|
||||||
|
Tombstone selbst (Tippen ist Absicht).
|
||||||
|
- **`docsync.js` (neu, headless, getestet)** wendet storage-Ereignisse an:
|
||||||
|
Liste, Namen, Tombstones, Stände-Cache, Reihenfolge-Hinweis; am aktiven
|
||||||
|
Dokument die drei Fälle umbenannt / anderswo gelöscht (behalten, bis
|
||||||
|
getippt wird) / fremd geschrieben. Datei-Handles werden bei `meta` neu
|
||||||
|
lazy aus IndexedDB nachgeladen bzw. bei `deleted` verworfen (§6.9).
|
||||||
|
- **Web Locks je Dokument** (`ifAvailable`, gehalten, solange aktiv):
|
||||||
|
Fällt von selbst, wartende Requests werden geweckt. Bekommt ein Fenster
|
||||||
|
die Sperre nicht, zeigt es den Dialog über dem Editor mit drei Auswegen
|
||||||
|
(anderes Dokument öffnen / nur ansehen / trotzdem bearbeiten); ohne
|
||||||
|
Locks-API warnt der storage-Rückfall ohne Dialog. Die `tabConflict`-
|
||||||
|
Warnung nennt Dokument und Fensterart (aus der eigenen Sicht gefolgert,
|
||||||
|
`display-mode: standalone`).
|
||||||
|
- Headless: 666 Tests grün, die Gegenprobe per Mutation gezogen — der
|
||||||
|
zurückgebaute Sweep lässt genau den benannten Test fallen.
|
||||||
|
|
||||||
|
**Nachgemessen im Browser, zwei echte Tabs (2026-09-02).** Die neun Fälle
|
||||||
|
aus RFC §10, Ergebnisse dort im Einzelnen: **1 bis 8 grün** — B's neues
|
||||||
|
Dokument überlebt A's Flush (der Test für Befund 2, im Vor-v3-Build per
|
||||||
|
Worktree gegengeprüft und dort nachweislich rot), beide Kamera-Stände
|
||||||
|
bleiben nebeneinander, „anderswo gelöscht" behält den Editor und wird durch
|
||||||
|
Tippen zurückgeholt, der Chip folgt einer fremden Umbenennung, der Dialog
|
||||||
|
steht nur im zweiten Fenster und dessen „nur ansehen" wird von selbst
|
||||||
|
beschreibbar, sobald das erste wegwechselt, dasselbe `live:`-Dokument in
|
||||||
|
beiden Fenstern gibt **keinen** Dialog (das gemeldete Symptom), und bei
|
||||||
|
wirklich voller Quota weichen die Stände, während die Dokumente bleiben.
|
||||||
|
|
||||||
|
**Fall 9 deckte eine Lücke auf — geschlossen.** `reviveGoneDoc()` schrieb
|
||||||
|
Meta und Text, aber nicht den Index-Hinweis: Ein anderswo gelöschtes und
|
||||||
|
hier durch Tippen wiederbelebtes Dokument hing bis zum nächsten Flush-Punkt
|
||||||
|
allein an seinen eigenen Schlüsseln. v3 findet es dort (`readDocs`
|
||||||
|
vereinigt Meta, Text und Index), ein **Rückbau** auf einen Build vor v3
|
||||||
|
aber nicht — der liest nur den Index und räumt bei seinem Voll-Flush jeden
|
||||||
|
Text-Schlüssel ab, den er darin nicht findet. Also stiller Verlust, der
|
||||||
|
teuerste Fehler (SPEC §4, D59). Die Funktion schreibt den Hinweis jetzt
|
||||||
|
mit; Gegenprobe per Mutation gezogen — ohne die Zeile bleibt der Index ohne
|
||||||
|
das Dokument, während Meta und Text dastehen. Sonst heilt sich der Index
|
||||||
|
von selbst, weil `indexHint()` die Speicher-Schlüssel mitliest.
|
||||||
|
|
||||||
|
**Offen bleibt die Handarbeit** (RFC §10): installierte PWA neben einem Tab,
|
||||||
|
Firefox, Safari, ein Browser ohne Locks-API. Werkzeuggrenzen wie gehabt —
|
||||||
|
die Browser-Fläche war verborgen, getippt wurde per `value` + `input`
|
||||||
|
(D91-Nachtrag 8), gelöscht über die Ablage (`confirm` ist nicht stubbar,
|
||||||
|
D91-Nachtrag 9), und der Live-Feed ruht im dauerhaft verborgenen
|
||||||
|
Automatisierungs-Tab (D76-Nachtrag 1) — dass B A's Zeile sieht, ist deshalb
|
||||||
|
über die Antwort auf den eigenen PATCH gemessen, nicht über den Feed.
|
||||||
|
|||||||
+19
-8
@@ -952,14 +952,25 @@ anderen, ohne neu zu laden.
|
|||||||
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.
|
||||||
|
|
||||||
**Ein zweites Werkbaum-Fenster desselben Browsers** — Tab, Fenster oder
|
**Ein zweites Werkbaum-Fenster desselben Browsers** — Tab, Fenster oder PWA
|
||||||
PWA — bekommt einen **modalen Dialog**, in beiden Fenstern, bis eines
|
— kollidiert nur noch am **selben nicht geteilten Dokument**: Text, Name und
|
||||||
geschlossen ist: Beide schreiben in dieselbe Dokument-Ablage, der zuletzt
|
frühere Stände liegen je Dokument unter eigenen Schlüsseln, der Index löscht
|
||||||
speichernde überschreibt den anderen. Erkannt per Herzschlag
|
nie, und gelöschte Dokumente hinterlassen einen Tombstone. Verschiedene
|
||||||
(BroadcastChannel); der Dialog schließt sich **von selbst**, sobald das
|
Dokumente in zwei Fenstern arbeiten daher still nebeneinander; ein
|
||||||
andere Fenster zu ist — es ist nichts zu bestätigen. „Trotzdem fortfahren"
|
anderswo angelegtes, umbenanntes oder gelöschtes Dokument zeigt sich im
|
||||||
ist die Notluke und gilt je Fenster und Vorfall. Siehe D89 (und D84 für die
|
laufenden Fenster ohne Neuladen (storage-Ereignis). Für den einen
|
||||||
zeilenlose Warnung, die daneben bestehen bleibt).
|
Verlustfall hält jedes Fenster, solange ein nicht-`live:`-Dokument vorn
|
||||||
|
ist, eine **Sperre** (Web Locks API) auf es — atomar, und sie fällt von
|
||||||
|
selbst, wenn das Fenster schließt. Bekommt das zweite Fenster sie nicht,
|
||||||
|
zeigt es einen Dialog mit drei Auswegen: anderes Dokument öffnen, hier nur
|
||||||
|
ansehen (Textfeld schreibgeschützt, wird von selbst beschreibbar, sobald
|
||||||
|
das andere Fenster loslässt) oder trotzdem hier bearbeiten — dann gewinnt
|
||||||
|
der letzte Tastendruck, und die Warnung nennt das Dokument. `live:`
|
||||||
|
-Dokumente werden nie gesperrt: Zwei Fenster sind zwei Live-Clients, der
|
||||||
|
Server führt zusammen. Ohne Web Locks (file://, alte Browser) bleibt der
|
||||||
|
storage-Rückfall: ein fremder Schreibzugriff am eigenen aktiven Dokument
|
||||||
|
warnt, ohne Dialog. Siehe D94 (und D89 für die lokalen Sicherungen und den
|
||||||
|
Wachhund).
|
||||||
|
|
||||||
Siehe D76 (Protokoll und Begründung) und
|
Siehe D76 (Protokoll und Begründung) und
|
||||||
`backend/docs/live-editing-proposal.md`.
|
`backend/docs/live-editing-proposal.md`.
|
||||||
|
|||||||
@@ -76,11 +76,11 @@
|
|||||||
- [^] #ed.docs.picker: Breadcrumb picker in the app header (S) %% Werkbaum › name, see D81
|
- [^] #ed.docs.picker: Breadcrumb picker in the app header (S) %% Werkbaum › name, see D81
|
||||||
- [^] #ed.docs.url: Load a document from ?sourceUrl= (S)
|
- [^] #ed.docs.url: Load a document from ?sourceUrl= (S)
|
||||||
- [^] #ed.docs.restore: Restore a shipped document from the menu (XS)
|
- [^] #ed.docs.restore: Restore a shipped document from the menu (XS)
|
||||||
- [ ] #ed.docs.windows: Two windows on one storage: app and tab side by side (M) %% docs/rfc/002-mehrfenster.md, D94
|
- [x] #ed.docs.windows: Two windows on one storage: app and tab side by side (M) %% docs/rfc/002-mehrfenster.md, D94
|
||||||
- [ ] #ed.docs.windows.keys: Every document owns its keys — text, meta, states; the index never deletes (S)
|
- [x] #ed.docs.windows.keys: Every document owns its keys — text, meta, states; the index never deletes (S)
|
||||||
- [ ] #ed.docs.windows.sync: The storage event keeps the list, names and tombstones current in every window (S)
|
- [x] #ed.docs.windows.sync: The storage event keeps the list, names and tombstones current in every window (S)
|
||||||
- [ ] #ed.docs.windows.lock: A per-document lock via the Web Locks API finds the one real loss case (S) :#ed.docs.windows.keys
|
- [x] #ed.docs.windows.lock: A per-document lock via the Web Locks API finds the one real loss case (S) :#ed.docs.windows.keys
|
||||||
- [ ] #ed.docs.windows.dialog: Three exits instead of continue anyway: open another, view only, edit anyway (XS) :#ed.docs.windows.lock
|
- [x] #ed.docs.windows.dialog: Three exits instead of continue anyway: open another, view only, edit anyway (XS) :#ed.docs.windows.lock
|
||||||
- [^] #ed.jump: Jump between diagram and text (S)
|
- [^] #ed.jump: Jump between diagram and text (S)
|
||||||
- [^] #ed.jump.dep: Ctrl+click follows a dependency to its id (XS)
|
- [^] #ed.jump.dep: Ctrl+click follows a dependency to its id (XS)
|
||||||
- [^] #ed.lineno: Line numbers in the text editor (XS) %% the warnings name them
|
- [^] #ed.lineno: Line numbers in the text editor (XS) %% the warnings name them
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|---------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| Status | **Abgestimmt** (sieben Fragen am 2026-09-02, Entscheidungen in §13 und D94) — nichts gebaut |
|
| Status | **Gebaut und im Browser nachgemessen** (2026-09-02, Branch `rfc-002-mehrfenster`; Fälle 1–8 grün, Fall 9 deckte eine Lücke auf, die geschlossen ist — §10. Offen: die Handarbeit, PWA/Firefox/Safari/ohne Locks) | |
|
||||||
| Anlass | Fehlerbericht: installierte App und Browser-Tab mit demselben geteilten Dokument ⇒ modaler Dialog in beiden Fenstern, einziger Ausgang „Trotzdem fortfahren“ |
|
| Anlass | Fehlerbericht: installierte App und Browser-Tab mit demselben geteilten Dokument ⇒ modaler Dialog in beiden Fenstern, einziger Ausgang „Trotzdem fortfahren“ |
|
||||||
| Plan-Knoten | `#ed.docs.windows` in `docs/examples/werkbaum.werkbaum` |
|
| Plan-Knoten | `#ed.docs.windows` in `docs/examples/werkbaum.werkbaum` |
|
||||||
| Entscheidung | D94 in `docs/DECISIONS.md`; revidiert **D89** (Dialog) und schreibt **D83** (Ablageschema) und **D84** (Fremd-Tab-Warnung) fort |
|
| Entscheidung | D94 in `docs/DECISIONS.md`; revidiert **D89** (Dialog) und schreibt **D83** (Ablageschema) und **D84** (Fremd-Tab-Warnung) fort |
|
||||||
@@ -497,6 +497,59 @@ Inszenieren von Speicherzuständen erst alle alten Tabs schließen (D83).
|
|||||||
stubben (D91-Nachtrag 9) — Löschen wird über die Ablage selbst inszeniert.
|
stubben (D91-Nachtrag 9) — Löschen wird über die Ablage selbst inszeniert.
|
||||||
Eine echte PWA-Installation lässt sich nicht automatisieren (D73).
|
Eine echte PWA-Installation lässt sich nicht automatisieren (D73).
|
||||||
|
|
||||||
|
### Ergebnis der Nachmessung (2026-09-02)
|
||||||
|
|
||||||
|
Zwei echte Tabs auf `localhost:8137`, Fall 6 gegen ein lokal laufendes
|
||||||
|
Backend. **Grün: 1 bis 8.** Im Einzelnen — Fall 1: A sieht B's neues Dokument
|
||||||
|
ohne Neuladen, und nach A's Dokumentwechsel samt `visibilitychange`/
|
||||||
|
`pagehide` sind dessen Text **und** Meta unangetastet; Fall 2: beide
|
||||||
|
Kamera-Stände liegen nebeneinander, B's Flush lässt A's stehen; Fall 3: Chip
|
||||||
|
„deleted elsewhere", Editor behält seine 221 Knoten, Tastendruck holt
|
||||||
|
Dokument und Tombstone zurück, der Wechsel ohne Tastendruck legt den Text
|
||||||
|
(68 791 Zeichen) in `werkbaum-snaps:werkbaum`; Fall 4: der Chip folgt der
|
||||||
|
fremden Umbenennung, der Text bleibt; Fall 5: Dialog nur im zweiten Fenster,
|
||||||
|
„nur ansehen" setzt `readOnly`, und als das erste Fenster wegwechselte, wurde
|
||||||
|
das zweite **von selbst** beschreibbar — „trotzdem" nennt Dokument und
|
||||||
|
Fensterart in beiden Fenstern; Fall 6 und 7: dasselbe `live:`-Dokument in
|
||||||
|
beiden Fenstern gibt **keinen** Dialog, auch beim Start (das gemeldete
|
||||||
|
Symptom), beide schreiben, der Server zählt von 2 auf 4 und behält beide
|
||||||
|
Zeilen; Fall 8: mit wirklich voller Quota (kein Stub) steht `storeFailed`,
|
||||||
|
die Dokumente bleiben unangetastet, die Stände weichen 4 → 0, und der nächste
|
||||||
|
gelungene Schreibvorgang räumt die Warnung.
|
||||||
|
|
||||||
|
**Gegenprobe im Vor-v3-Build gezogen** (git-Worktree am Commit davor, eigener
|
||||||
|
Dev-Server): Dort ist Fall 1 nachweislich rot — B legt ein Dokument an, A
|
||||||
|
wechselt das Dokument, und B's Text ist weg (`werkbaum-doc:… = null`, Eintrag
|
||||||
|
aus dem Index entfernt). Genau Befund 2.
|
||||||
|
|
||||||
|
**Fall 9 hat eine Lücke aufgedeckt, die jetzt geschlossen ist.** Alles, was
|
||||||
|
der Index kennt, ist im alten Build sichtbar — ein Dokument, das gerade nur
|
||||||
|
an `werkbaum-meta:<id>` und `werkbaum-doc:<id>` hängt, aber nicht im Index
|
||||||
|
steht, ist dort jedoch nicht nur unsichtbar: Dessen Voll-Flush **löscht**
|
||||||
|
seinen Text-Schlüssel. Das Fenster dafür ist schmal, weil `indexHint()` die
|
||||||
|
Speicher-Schlüssel mitliest und sich damit bei jedem Flush selbst heilt —
|
||||||
|
aber es gab eine Stelle, die es regelmäßig öffnete: `reviveGoneDoc()`
|
||||||
|
(§6.5) schrieb Meta und Text und **nicht** den Index-Hinweis. Ein anderswo
|
||||||
|
gelöschtes und hier durch Tippen wiederbelebtes Dokument hing also bis zum
|
||||||
|
nächsten Flush-Punkt allein an seinen eigenen Schlüsseln. Die Funktion
|
||||||
|
schreibt den Hinweis jetzt mit. Nachgemessen im Browser: Index nach dem
|
||||||
|
Tastendruck sofort wieder vollständig; Gegenprobe per Mutation — ohne die
|
||||||
|
Zeile bleibt der Index ohne das Dokument, während Meta und Text dastehen.
|
||||||
|
|
||||||
|
**Zwei Beobachtungen ohne Handlungsbedarf.** Fügen zwei Fenster gleichzeitig
|
||||||
|
an derselben Stelle eines geteilten Dokuments ein, kann die Zeilenreihenfolge
|
||||||
|
im Editor kurz von der des Servers abweichen; der nächste Push gleicht sie an,
|
||||||
|
verloren geht nichts. Und die **Feed-Hälfte** von Fall 6 („beide sehen beides
|
||||||
|
ohne Neuladen") ist in dieser Umgebung nicht messbar — der Automatisierungs-
|
||||||
|
Tab ist dauerhaft `hidden`, der Feed ruht dort planmäßig (D76-Nachtrag 1);
|
||||||
|
gemessen ist stattdessen, dass B A's Zeile über die Antwort auf den eigenen
|
||||||
|
PATCH bekam. Getippt wurde durchweg per `value` + `input`-Ereignis, weil die
|
||||||
|
Browser-Fläche verborgen war und echte Tastendrücke nicht ankommen
|
||||||
|
(D91-Nachtrag 8).
|
||||||
|
|
||||||
|
**Offen bleibt die Handarbeit** aus dem Absatz oben: installierte PWA + Tab,
|
||||||
|
Firefox, Safari, ein Browser ohne Locks-API.
|
||||||
|
|
||||||
## 11. Abgrenzung — was nicht gebaut wird
|
## 11. Abgrenzung — was nicht gebaut wird
|
||||||
|
|
||||||
| Nicht gebaut | Warum |
|
| Nicht gebaut | Warum |
|
||||||
@@ -540,3 +593,12 @@ zurück als heute.
|
|||||||
|
|
||||||
- 2026-09-02 — erste Fassung aus dem Fehlerbericht; sieben Fragen
|
- 2026-09-02 — erste Fassung aus dem Fehlerbericht; sieben Fragen
|
||||||
entschieden; RFC, Plan-Knoten und D94 in einem Commit. Nichts gebaut.
|
entschieden; RFC, Plan-Knoten und D94 in einem Commit. Nichts gebaut.
|
||||||
|
- 2026-09-02 — gebaut in vier Schritten (§12): Schema v3 + Tombstones +
|
||||||
|
Dirty-Flush; `docsync.js` + storage-Handler; Web Locks + Dialog + Warnung;
|
||||||
|
Dokumentation. Headless 666 Tests grün, Gegenprobe per Mutation gezogen;
|
||||||
|
die Browser-Nachmessungen (§10) standen noch aus.
|
||||||
|
- 2026-09-02 — im Browser nachgemessen (§10): Fälle 1–8 grün, Fall 1 im
|
||||||
|
Vor-v3-Build gegengeprüft und dort rot. Fall 9 deckte auf, dass
|
||||||
|
`reviveGoneDoc()` den Index-Hinweis nicht mitschrieb — behoben, Gegenprobe
|
||||||
|
per Mutation gezogen. Offen bleibt die Handarbeit: PWA neben Tab, Firefox,
|
||||||
|
Safari, ein Browser ohne Locks-API.
|
||||||
|
|||||||
+35
-20
@@ -150,31 +150,46 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
|
|||||||
auseinander und die PATCH-Antwort gewinnt — wer das prüfen will, muss die
|
auseinander und die PATCH-Antwort gewinnt — wer das prüfen will, muss die
|
||||||
PATCH-Antwort im Client verzögern, sonst meldet die Messung „geht doch".
|
PATCH-Antwort im Client verzögern, sonst meldet die Messung „geht doch".
|
||||||
- Zustand wird im `localStorage` gehalten (Server-Dokumente ausgenommen): `werkbaum-lang`
|
- Zustand wird im `localStorage` gehalten (Server-Dokumente ausgenommen): `werkbaum-lang`
|
||||||
(Sprache), `werkbaum-docs` (JSON-Array der Dokumente `[{id,name,text}]`),
|
(Sprache), `werkbaum-active` (id des aktiven Dokuments), `werkbaum-src` (Spiegel des
|
||||||
`werkbaum-active` (id des aktiven Dokuments), `werkbaum-src` (Spiegel des
|
|
||||||
aktiven Texts), `werkbaum-ui` (JSON: Modus, verworfene,
|
aktiven Texts), `werkbaum-ui` (JSON: Modus, verworfene,
|
||||||
günstigster Pfad, Split-Zustand inkl. `--col`/`--drow`, Zoom, Vollbild). Neue
|
günstigster Pfad, Split-Zustand inkl. `--col`/`--drow`, Zoom, Vollbild). Neue
|
||||||
GUI-Einstellungen in `saveUI()`/`restoreState()` mitführen; `saveUI` liefert
|
GUI-Einstellungen in `saveUI()`/`restoreState()` mitführen; `saveUI` liefert
|
||||||
während `restoring===true` nichts, damit das Wiederherstellen nicht sofort
|
während `restoring===true` nichts, damit das Wiederherstellen nicht sofort
|
||||||
zurückschreibt.
|
zurückschreibt.
|
||||||
**Persistenz ist geteilt (D82) und je Dokument abgelegt (D83):** Das
|
**Persistenz ist geteilt (D82) und je Dokument abgelegt (D83, v3 nach
|
||||||
Schema liegt headless in `docstore.js` — `werkbaum-docs` ist nur der
|
RFC 002/D94):** Das Schema liegt headless in `docstore.js` — der Text
|
||||||
INDEX `[{id,name,source?}]`, der Text jedes Dokuments ein eigener
|
jedes Dokuments unter `werkbaum-doc:<id>`, sein Name/Quelle/Geburtsdatum
|
||||||
Schlüssel `werkbaum-doc:<id>`. Der Tastendruck schreibt über
|
unter `werkbaum-meta:<id>`, seine früheren Stände unter
|
||||||
`persistActiveText()`/`storeDocText()` NUR den aktiven Text (+ Spiegel
|
`werkbaum-snaps:<id>`, eine Löschung als Tombstone `werkbaum-gone:<id>`
|
||||||
`werkbaum-src` als Rollback-Fallback); den Voll-Flush macht
|
(Verfall 7 Tage). `werkbaum-docs` ist nur der Reihenfolge-Hinweis
|
||||||
`persistDocs()`/`storeDocs()` an Flush-Punkten (Wechseln/Anlegen/Löschen/
|
`[{id,name,source?}]` — er **entfernt nichts mehr** und wird aus
|
||||||
Umbenennen, `pagehide`, verborgener Tab) — mit Vergleich vor jedem
|
`Speicher-ids ∪ eigene Liste` geschrieben (`writeIndexHint`). Der
|
||||||
Schreiben und Abräumen verwaister Text-Schlüssel; nie wieder eine
|
Tastendruck schreibt über `persistActiveText()`/`storeDocText()` NUR den
|
||||||
Voll-Serialisierung in einen Tastendruck-Pfad hängen. Die „Spiegel
|
aktiven Text (+ Spiegel `werkbaum-src`); den **Dirty-Flush** macht
|
||||||
gewinnt"-Regel in `loadDocs` gilt NUR noch der einmaligen Migration aus
|
`persistDocs()` an Flush-Punkten (Wechseln/Anlegen/Löschen/Umbenennen,
|
||||||
dem Altformat (Texte im Array) und muss **vor** `seedShippedDocs()`
|
`pagehide`, verborgener Tab) — er schreibt NUR, was dieses Fenster
|
||||||
laufen — danach drehte der ältere Spiegel eine frisch nachgezogene
|
angelegt, umbenannt oder getippt hat (`dirtyDocs`, docsync.js), **nie**
|
||||||
Fassung zurück und das Dokument gälte für immer als bearbeitet. Ein
|
den Schlüssel eines anderen Fensters (der alte Orphan-Sweep des
|
||||||
fehlender Text-Schlüssel ergibt ein leeres Dokument, nie den Verlust der
|
Voll-Flushes hat fremde Dokumente samt Text gelöscht — genau dagegen ist
|
||||||
Liste. Scheitert ein Write (Quota), meldet die persistente Warnung
|
die Gegenprobe im Test). Die „Spiegel gewinnt"-Regel in `loadDocs` gilt
|
||||||
`storeFailed` (`noteStore()`, rendert nur an der Flanke) — nie wieder
|
NUR noch der einmaligen Migration aus dem Altformat (Texte im Array) und
|
||||||
still schlucken.
|
muss **vor** `seedShippedDocs()` laufen — danach drehte der ältere Spiegel
|
||||||
|
eine frisch nachgezogene Fassung zurück und das Dokument gälte für immer
|
||||||
|
als bearbeitet. Ein fehlender Text-Schlüssel ergibt ein leeres Dokument,
|
||||||
|
nie den Verlust der Liste. Scheitert ein Write (Quota), meldet die
|
||||||
|
persistente Warnung `storeFailed` (`noteStore()`, rendert nur an der
|
||||||
|
Flanke) — nie wieder still schlucken.
|
||||||
|
**Zwei Fenster (RFC 002/D94):** Das `storage`-Ereignis zieht Liste, Namen
|
||||||
|
und Tombstones per `applyStorageEvent()` (docsync.js, headless getestet)
|
||||||
|
im laufenden Fenster nach; das aktive anderswo gelöschte Dokument bleibt
|
||||||
|
warnfarben stehen, bis getippt wird (Tippen belebt es — `storeDocText`
|
||||||
|
hebt den Tombstone selbst). Ein zweites Fenster am selben **nicht
|
||||||
|
geteilten** Dokument wird per Web-Locks-Sperre je Dokument erkannt
|
||||||
|
(`syncDocLock()`/`acquireDocLock()`); der Dialog über dem Editor hat drei
|
||||||
|
Auswege (anderes öffnen / nur ansehen `viewOnly` / trotzdem bearbeiten);
|
||||||
|
ohne Locks-API warnt der `foreignWrite`-Rückfall. `live:`-Dokumente
|
||||||
|
werden nie gesperrt — der Server führt zusammen. Der D89-Präsenz-Kanal
|
||||||
|
(BroadcastChannel, Herzschlag, modaler Dialog) ist ersatzlos weg.
|
||||||
- Dokumente (D22): mehrere umschaltbare Notationstexte. `loadDocs()` migriert bei
|
- Dokumente (D22): mehrere umschaltbare Notationstexte. `loadDocs()` migriert bei
|
||||||
fehlendem `werkbaum-docs` den bestehenden `werkbaum-src` (oder `INITIAL`) in
|
fehlendem `werkbaum-docs` den bestehenden `werkbaum-src` (oder `INITIAL`) in
|
||||||
**ein** Dokument; `initDocs()` (Aufruf **nach** `applyLang`) holt den aktiven
|
**ein** Dokument; `initDocs()` (Aufruf **nach** `applyLang`) holt den aktiven
|
||||||
|
|||||||
+480
-136
@@ -6,11 +6,17 @@ import { esc, renderTreeHtml, TIP_RULE } from './render.js';
|
|||||||
import { formatWarning, warningText } from './warnings.js';
|
import { formatWarning, warningText } from './warnings.js';
|
||||||
import * as live from './live.js';
|
import * as live from './live.js';
|
||||||
import { depFragment, collectIds, matchIds, depIdAt, idLine } from './autocomplete.js';
|
import { depFragment, collectIds, matchIds, depIdAt, idLine } from './autocomplete.js';
|
||||||
import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel }
|
import { SNAP_EVERY, addSnapshot, persistSnaps, snapLabel,
|
||||||
|
readAllSnaps, readSnapList }
|
||||||
from './snapshots.js';
|
from './snapshots.js';
|
||||||
import { FILE_ACCEPT, FILE_TYPES, saveFileName } from './localfile.js';
|
import { FILE_ACCEPT, FILE_TYPES, saveFileName } from './localfile.js';
|
||||||
import { LIVE_PARAM, SOURCE_PARAM, ETHERPAD_PARAM, docSearch, docKind } from './docurl.js';
|
import { LIVE_PARAM, SOURCE_PARAM, ETHERPAD_PARAM, docSearch, docKind } from './docurl.js';
|
||||||
import { readDocs, storeDocs, storeDocText, isDocKey, LS_DOCS, LS_ACTIVE, LS_SRC } from './docstore.js';
|
import { readDocs, writeDoc, writeIndexHint, removeDoc, expireTombstones, migrateV3,
|
||||||
|
storeDocText, readMeta, hasTombstone, isDocKey,
|
||||||
|
LS_ACTIVE, LS_SRC,
|
||||||
|
DOC_TEXT_PREFIX, DOC_META_PREFIX, DOC_GONE_PREFIX }
|
||||||
|
from './docstore.js';
|
||||||
|
import { applyStorageEvent, lockDecision, lockName, createDirtySet } from './docsync.js';
|
||||||
/* Neuigkeiten (D58): die git-Historie, zur BAUZEIT eingelesen (Vite-Plugin in
|
/* Neuigkeiten (D58): die git-Historie, zur BAUZEIT eingelesen (Vite-Plugin in
|
||||||
vite.config.js). Zur Laufzeit gibt es kein git — und keinen Server, der
|
vite.config.js). Zur Laufzeit gibt es kein git — und keinen Server, der
|
||||||
nachliefern könnte (D11/D19). Leer, wo git nicht erreichbar war. */
|
nachliefern könnte (D11/D19). Leer, wo git nicht erreichbar war. */
|
||||||
@@ -89,79 +95,222 @@ let sourceWarning = null;
|
|||||||
Verlust fiel erst beim Neuladen auf. Persistent, bis ein Schreiben wieder
|
Verlust fiel erst beim Neuladen auf. Persistent, bis ein Schreiben wieder
|
||||||
gelingt; zeilenlos und zuoberst. */
|
gelingt; zeilenlos und zuoberst. */
|
||||||
let storeWarning = null;
|
let storeWarning = null;
|
||||||
/* Ein ANDERER Tab schreibt in dieselbe Dokument-Ablage (D84): Das
|
/* Ein ANDERES Fenster schreibt in die Dokument-Ablage (D84, nach RFC 002
|
||||||
storage-Ereignis feuert nur in fremden Tabs desselben Ursprungs — genau
|
§6.3/§6.6): Das storage-Ereignis feuert nur in fremden Fenstern desselben
|
||||||
das richtige Signal. Kein Sync (der letzte Flush gewinnt weiterhin, wie
|
Ursprungs — genau das richtige Signal. Die Warnung `tabConflict` erscheint
|
||||||
immer), aber kein STILLER Verlust mehr: Die Warnung bleibt stehen,
|
aber NUR noch im Restfall (dasselbe nicht-`live:`-Dokument in beiden
|
||||||
solange dieser Tab lebt — die Lage ändert sich ja nicht dadurch, dass
|
Fenstern — `foreignWrite` aus docsync.js); verschiedene Dokumente
|
||||||
der andere Tab gerade nichts schreibt. */
|
kollidieren nicht mehr und bleiben still. Sie bleibt stehen, solange das
|
||||||
|
Dokument aktiv ist, und nennt es; die Lage ändert sich nicht dadurch, dass
|
||||||
|
der andere gerade nichts tippt. */
|
||||||
let tabWarning = null;
|
let tabWarning = null;
|
||||||
|
/* Ereignisse, die während des Starts ankommen (das andere Fenster tippt,
|
||||||
|
während dieses hochläuft): sammeln und nach dem Boot nachziehen — der
|
||||||
|
Handler ist damit registriert, bevor der erste Flush schreibt (§6.10). */
|
||||||
|
const storageBacklog = [];
|
||||||
window.addEventListener('storage', e => {
|
window.addEventListener('storage', e => {
|
||||||
if(tabWarning || !e.key || !isDocKey(e.key)) return;
|
if(!e.key || !isDocKey(e.key)) return;
|
||||||
tabWarning = {type: 'tabConflict'};
|
const ev = {key: e.key, oldValue: e.oldValue, newValue: e.newValue};
|
||||||
render();
|
if(bootDone) applyForeignStorageEvent(ev);
|
||||||
|
else storageBacklog.push(ev);
|
||||||
});
|
});
|
||||||
/* Zwei lebende Fenster sind mehr als eine Fußnote (D89): Die zeilenlose
|
|
||||||
Warnung oben meldet nur, dass ein anderer Tab GESCHRIEBEN hat — ein
|
|
||||||
zweites offenes Fenster bekommt zusätzlich einen MODALEN Dialog, in beiden
|
|
||||||
Fenstern, bis das Problem behoben ist. Herzschlag über BroadcastChannel:
|
|
||||||
jedes Fenster meldet sich sekündlich; verstummt das andere (Abmeldung beim
|
|
||||||
Schließen, sonst 3 s Stille), schließt der Dialog sich von selbst — die
|
|
||||||
App läuft ohne weiteren Klick weiter. „Trotzdem fortfahren" ist die
|
|
||||||
Notluke und gilt nur, solange DIESES zweite Fenster lebt. */
|
|
||||||
/* Wachhund-Warnung (D89): Ungesendete Live-Änderungen bzw. tote Sitzung.
|
/* Wachhund-Warnung (D89): Ungesendete Live-Änderungen bzw. tote Sitzung.
|
||||||
Gesetzt vom 5-s-Takt im Live-Abschnitt, gelesen von render(). */
|
Gesetzt vom 5-s-Takt im Live-Abschnitt, gelesen von render(). */
|
||||||
let liveWarning = null;
|
let liveWarning = null;
|
||||||
const WIN_ID = Math.random().toString(36).slice(2);
|
/* ---------- Sperre je Dokument (RFC 002 §6.4/§6.5, D94) ----------
|
||||||
/* 75 s statt weniger Sekunden: Chrome drosselt die Timer verborgener Fenster
|
Der einzige Restfall — dasselbe nicht-`live:`-Dokument in beiden Fenstern
|
||||||
nach fünf Minuten auf einen Tick je MINUTE (dieselbe Umgebungsgrenze wie in
|
vorn — wird über die Web Locks API erkannt: atomar, sie fällt von selbst,
|
||||||
D79 gemessen) — ein kürzerer Timeout ließe den Dialog im sichtbaren Fenster
|
wenn das Fenster schließt oder abstürzt, und ein wartender Request weckt
|
||||||
flackern, weil das verborgene nur noch minütlich schlägt. Das saubere
|
das zweite Fenster, sobald der Halter loslässt. Der Präsenz-Kanal aus D89
|
||||||
Schließen meldet sich ohnehin sofort ab (bye bei pagehide); der Timeout
|
(Herzschlag, Timeout, Notluke, modaler Dialog) ist damit ersatzlos weg.
|
||||||
fängt nur hart gestorbene Fenster. */
|
|
||||||
const FOREIGN_DEAD_MS = 75000;
|
Ohne Locks-API (file://, alte Browser) bleibt der storage-Rückfall: Ein
|
||||||
let presenceChannel = null, tabModalEl = null;
|
`foreignWrite` am eigenen aktiven Dokument ist der Beweis, dass ein
|
||||||
const foreignBeats = new Map(); /* fremde Fenster-Id -> letzter Herzschlag */
|
zweites Fenster darin schreibt — dann Warnung, ohne Dialog. */
|
||||||
const tabModalDismissed = new Set(); /* Notluke gilt je fremder Id, nicht je Sitzung:
|
const hasLocks = typeof navigator !== 'undefined' && !!navigator.locks;
|
||||||
ein NEUES zweites Fenster bekommt wieder den Dialog */
|
let lockHeldId = null; /* id, für die die Sperre gehalten wird */
|
||||||
try{ presenceChannel = new BroadcastChannel('werkbaum-presence'); }catch(_){}
|
let lockTargetId = null; /* id der letzten Anfrage (syncDocLock vergleicht) */
|
||||||
if(presenceChannel){
|
let lockRelease = null; /* löst die gehaltene Sperre (Callback-Promise) */
|
||||||
presenceChannel.onmessage = e => {
|
let lockGen = 0; /* Generation: ungültigt ausstehende Anfragen */
|
||||||
const m = e.data || {};
|
let viewOnly = false; /* Ausweg 2: „hier nur ansehen" (§6.5) */
|
||||||
if(!m.id || m.id === WIN_ID) return;
|
let docGoneElsewhere = false; /* aktives Dokument anderswo gelöscht (§6.5) */
|
||||||
if(m.type === 'hello') presenceChannel.postMessage({type: 'beat', id: WIN_ID});
|
let lockDialogEl = null; /* Dialog über dem Editor (§6.5) */
|
||||||
if(m.type === 'hello' || m.type === 'beat') foreignBeats.set(m.id, Date.now());
|
function clearTabConflict(){
|
||||||
if(m.type === 'bye'){ foreignBeats.delete(m.id); tabModalDismissed.delete(m.id); }
|
if(!tabWarning) return;
|
||||||
updateTabModal();
|
tabWarning = null;
|
||||||
};
|
render();
|
||||||
presenceChannel.postMessage({type: 'hello', id: WIN_ID});
|
|
||||||
setInterval(() => {
|
|
||||||
presenceChannel.postMessage({type: 'beat', id: WIN_ID});
|
|
||||||
for(const [id, wann] of foreignBeats)
|
|
||||||
if(Date.now() - wann > FOREIGN_DEAD_MS){ foreignBeats.delete(id); tabModalDismissed.delete(id); }
|
|
||||||
updateTabModal();
|
|
||||||
}, 1000);
|
|
||||||
addEventListener('pagehide', () => { try{ presenceChannel.postMessage({type: 'bye', id: WIN_ID}); }catch(_){} });
|
|
||||||
}
|
}
|
||||||
function updateTabModal(){
|
function releaseDocLock(){
|
||||||
const offen = [...foreignBeats.keys()].some(id => !tabModalDismissed.has(id));
|
if(lockRelease){
|
||||||
if(offen && !tabModalEl){
|
const r = lockRelease;
|
||||||
|
lockRelease = null; lockHeldId = null;
|
||||||
|
r();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Die Sperre folgt dem aktiven Dokument: Alte fallen, neue werden angefordert
|
||||||
|
(ifAvailable). Sie wird gehalten, solange das Dokument aktiv ist — die
|
||||||
|
Callback-Promise bleibt offen; der nächste Wechsel löst sie. */
|
||||||
|
function syncDocLock(){
|
||||||
|
const d = activeDoc();
|
||||||
|
const id = d ? d.id : null;
|
||||||
|
if(id === lockTargetId) return; /* schon gehalten oder angefragt */
|
||||||
|
acquireDocLock(id);
|
||||||
|
}
|
||||||
|
function acquireDocLock(id){
|
||||||
|
releaseDocLock();
|
||||||
|
lockTargetId = id;
|
||||||
|
docGoneElsewhere = false;
|
||||||
|
viewOnly = false;
|
||||||
|
src.readOnly = false; /* im Zweifel beschreibbar; der Dialog setzt es zurück */
|
||||||
|
closeLockDialog();
|
||||||
|
clearTabConflict();
|
||||||
|
/* live: nie, ohne Locks-API nie — dort warnt der storage-Rückfall (§6.4). */
|
||||||
|
if(!id || !lockDecision(id) || !hasLocks) return;
|
||||||
|
const gen = ++lockGen;
|
||||||
|
navigator.locks.request(lockName(id), {ifAvailable: true}, lock => {
|
||||||
|
if(gen !== lockGen) return; /* inzwischen gewechselt */
|
||||||
|
if(!lock){ enterLockDialog(id); return; } /* ein anderes Fenster hält es */
|
||||||
|
lockHeldId = id;
|
||||||
|
return new Promise(res => { lockRelease = res; lockArrived(id); });
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
/* Die Sperre ist da — vom ersten Anfordern oder weil das andere Fenster
|
||||||
|
losgelassen hat: beschreibbar, Label normal, Warnung räumt sich (§6.6). */
|
||||||
|
function lockArrived(id){
|
||||||
|
if(id !== activeId) return; /* inzwischen weggewechselt */
|
||||||
|
lockHeldId = id;
|
||||||
|
viewOnly = false;
|
||||||
|
src.readOnly = false;
|
||||||
|
closeLockDialog();
|
||||||
|
clearTabConflict();
|
||||||
|
updateDocName();
|
||||||
|
}
|
||||||
|
function waitInBackground(id){
|
||||||
|
if(!hasLocks || id == null) return;
|
||||||
|
const gen = lockGen; /* ungültig, sobald das Dokument wechselt */
|
||||||
|
navigator.locks.request(lockName(id), lock => {
|
||||||
|
if(gen !== lockGen || !lock) return;
|
||||||
|
lockHeldId = id;
|
||||||
|
return new Promise(res => { lockRelease = res; lockArrived(id); });
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
/* Die Sperre wurde NICHT bekommen — der Restfall. Dialog über dem Editor,
|
||||||
|
drei Auswege (§6.5); das Textfeld ist bis zur Wahl schreibgeschützt,
|
||||||
|
Diagramm und Menü bleiben bedienbar. */
|
||||||
|
function enterLockDialog(id){
|
||||||
|
if(id !== activeId) return;
|
||||||
|
src.readOnly = true;
|
||||||
|
closeLockDialog();
|
||||||
|
const d = docs.find(x => x.id === id);
|
||||||
|
if(!d) return; /* inzwischen weg — nichts tun */
|
||||||
const ov = document.createElement('div');
|
const ov = document.createElement('div');
|
||||||
ov.className = 'tabmodal-overlay';
|
ov.className = 'lockdlg-overlay';
|
||||||
ov.innerHTML = '<div class="tabmodal" role="alertdialog" aria-modal="true">' +
|
ov.innerHTML = '<div class="lockdlg" role="alertdialog" aria-modal="false">' +
|
||||||
'<h2></h2><p></p><button type="button" class="tabmodal-force"></button></div>';
|
'<h2></h2><p></p>' +
|
||||||
ov.querySelector('h2').textContent = t('tabModalTitle');
|
'<div class="lockdlg-listhead"></div><div class="lockdlg-list" role="listbox"></div>' +
|
||||||
ov.querySelector('p').textContent = t('tabModalText');
|
'<div class="lockdlg-btns">' +
|
||||||
const force = ov.querySelector('.tabmodal-force');
|
'<button type="button" class="lockdlg-view"></button>' +
|
||||||
force.textContent = t('tabModalForce');
|
'<button type="button" class="lockdlg-edit"></button></div></div>';
|
||||||
force.addEventListener('click', () => {
|
ov.querySelector('h2').textContent = t('docLockTitle', {name: d.name});
|
||||||
for(const id of foreignBeats.keys()) tabModalDismissed.add(id);
|
ov.querySelector('p').textContent = t('docLockText');
|
||||||
updateTabModal();
|
const kopf = ov.querySelector('.lockdlg-listhead');
|
||||||
});
|
const liste = ov.querySelector('.lockdlg-list');
|
||||||
document.body.appendChild(ov);
|
const andere = docs.filter(x => x.id !== id);
|
||||||
tabModalEl = ov;
|
if(andere.length){
|
||||||
|
kopf.textContent = t('docLockOpenOther');
|
||||||
|
for(const x of andere){
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'lockdlg-doc';
|
||||||
|
b.setAttribute('role', 'option');
|
||||||
|
b.textContent = x.name;
|
||||||
|
b.addEventListener('click', () => switchDoc(x.id));
|
||||||
|
liste.appendChild(b);
|
||||||
|
}
|
||||||
|
} else { kopf.hidden = true; liste.hidden = true; }
|
||||||
|
const ansehen = ov.querySelector('.lockdlg-view');
|
||||||
|
const trotzdem = ov.querySelector('.lockdlg-edit');
|
||||||
|
ansehen.textContent = t('docLockViewOnly');
|
||||||
|
trotzdem.textContent = t('docLockEditAnyway');
|
||||||
|
ansehen.addEventListener('click', () => {
|
||||||
|
closeLockDialog();
|
||||||
|
viewOnly = true;
|
||||||
|
src.readOnly = true;
|
||||||
|
updateDocName();
|
||||||
|
waitInBackground(lockTargetId); /* wird von selbst beschreibbar (§6.5) */
|
||||||
|
});
|
||||||
|
trotzdem.addEventListener('click', () => {
|
||||||
|
closeLockDialog();
|
||||||
|
src.readOnly = false;
|
||||||
|
viewOnly = false;
|
||||||
|
lockGen++; /* ausstehende Warteanfragen ungültig (§6.5) */
|
||||||
|
const jetzt = activeDoc();
|
||||||
|
tabWarning = {type: 'tabConflict', name: jetzt ? jetzt.name : '',
|
||||||
|
where: ownWindowKind()};
|
||||||
|
updateDocName(); render();
|
||||||
|
});
|
||||||
|
srcWrap.appendChild(ov);
|
||||||
|
lockDialogEl = ov;
|
||||||
|
}
|
||||||
|
function closeLockDialog(){
|
||||||
|
if(!lockDialogEl) return;
|
||||||
|
lockDialogEl.remove();
|
||||||
|
lockDialogEl = null;
|
||||||
|
}
|
||||||
|
/* „In der App" oder „im Browser" — aus der EIGENEN Sicht (§6.6): Das eigene
|
||||||
|
Fenster kennt seine Art, das andere Fenster wird daraus gefolgert. */
|
||||||
|
function ownWindowKind(){
|
||||||
|
try{
|
||||||
|
return matchMedia('(display-mode: standalone)').matches
|
||||||
|
? 'windowKindBrowser' : 'windowKindApp';
|
||||||
|
}catch(_){ return 'windowKindBrowser'; }
|
||||||
|
}
|
||||||
|
/* Der storage-Handler: docsync entscheidet (headless), hier wird angewendet. */
|
||||||
|
function applyForeignStorageEvent(ev){
|
||||||
|
const erg = applyStorageEvent(docs, activeId, ev);
|
||||||
|
if(erg.docs !== docs){
|
||||||
|
docs = erg.docs;
|
||||||
|
if(!docMenu.hidden) renderDocMenu();
|
||||||
|
}
|
||||||
|
const d = erg.id != null ? docs.find(x => x.id === erg.id) : null;
|
||||||
|
switch(erg.action){
|
||||||
|
case 'created':
|
||||||
|
/* Text lazy aus dem Speicher (§6.3) — der Aufrufer reicht nichts herein. */
|
||||||
|
if(d){ try{ const tx = localStorage.getItem(DOC_TEXT_PREFIX + erg.id);
|
||||||
|
if(tx != null) d.text = String(tx); }catch(_){} }
|
||||||
|
/* Handle der id lazy nachladen (§6.9): „dieselbe Datei landet im selben
|
||||||
|
Dokument" (D73) soll auch über Fenstergrenzen gelten. */
|
||||||
|
if(hasFsAccess) idbLoadHandle(erg.id);
|
||||||
|
break;
|
||||||
|
case 'renamed':
|
||||||
|
updateDocName();
|
||||||
|
break;
|
||||||
|
case 'deleted':
|
||||||
|
docGoneElsewhere = true; /* behalten, warnfarben, bis getippt wird (§6.5) */
|
||||||
|
/* Mit dem Dokument geht auch hier das gemerkte Handle (§6.9) — das
|
||||||
|
löschende Fenster hat den IDB-Eintrag bereits entfernt. */
|
||||||
|
fileHandles.delete(erg.id);
|
||||||
|
updateDocName();
|
||||||
|
render();
|
||||||
|
break;
|
||||||
|
case 'foreignWrite':
|
||||||
|
if(!tabWarning){
|
||||||
|
const ad = activeDoc();
|
||||||
|
tabWarning = {type: 'tabConflict', name: ad ? ad.name : '', where: ownWindowKind()};
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'snaps':
|
||||||
|
/* Stände-Cache der id verwerfen — frisch beim nächsten Öffnen (§6.3). */
|
||||||
|
try{
|
||||||
|
const l = readSnapList(localStorage, erg.id);
|
||||||
|
if(l.length) snaps[erg.id] = l; else delete snaps[erg.id];
|
||||||
|
}catch(_){}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if(erg.docs !== docs || erg.action === 'renamed' || erg.action === 'created'){
|
||||||
|
if(!docMenu.hidden) renderDocMenu();
|
||||||
}
|
}
|
||||||
if(!offen && tabModalEl){ tabModalEl.remove(); tabModalEl = null; }
|
|
||||||
}
|
}
|
||||||
function noteStore(ok){
|
function noteStore(ok){
|
||||||
const neu = ok ? null : {type: 'storeFailed'};
|
const neu = ok ? null : {type: 'storeFailed'};
|
||||||
@@ -3393,12 +3542,18 @@ const I18N = {
|
|||||||
sourceLoadWarn:"„{url}“ konnte nicht geladen werden ({error}). Die Datei muss per http(s) erreichbar sein und CORS erlauben (Access-Control-Allow-Origin).",
|
sourceLoadWarn:"„{url}“ konnte nicht geladen werden ({error}). Die Datei muss per http(s) erreichbar sein und CORS erlauben (Access-Control-Allow-Origin).",
|
||||||
padGoneWarn:"Dieser Link zeigt auf ein Etherpad-Pad. Die Etherpad-Anbindung gibt es nicht mehr — gemeinsames Arbeiten läuft jetzt über ein Werkbaum-Backend (?live=…). Der Text des Pads lässt sich dort einmal einfügen und dann zu zweit bearbeiten.",
|
padGoneWarn:"Dieser Link zeigt auf ein Etherpad-Pad. Die Etherpad-Anbindung gibt es nicht mehr — gemeinsames Arbeiten läuft jetzt über ein Werkbaum-Backend (?live=…). Der Text des Pads lässt sich dort einmal einfügen und dann zu zweit bearbeiten.",
|
||||||
storeFailedWarn:"Speichern im Browser fehlgeschlagen — vermutlich ist der Speicher voll. Änderungen können beim Neuladen verloren gehen; Platz schaffen: nicht mehr gebrauchte Dokumente oder frühere Stände löschen.",
|
storeFailedWarn:"Speichern im Browser fehlgeschlagen — vermutlich ist der Speicher voll. Änderungen können beim Neuladen verloren gehen; Platz schaffen: nicht mehr gebrauchte Dokumente oder frühere Stände löschen.",
|
||||||
tabConflictWarn:"Werkbaum ist in einem weiteren Browser-Tab geöffnet — beide schreiben in dieselbe Dokumentenliste, und der zuletzt speichernde gewinnt. Am besten nur in einem Tab arbeiten.",
|
tabConflictWarn:"„{name}“ wird in einem anderen Fenster ({where}) bearbeitet — der letzte Tastendruck gewinnt.",
|
||||||
liveUnsentWarn:"Deine Änderungen sind seit {min} Minuten NICHT auf dem Server angekommen — sie existieren nur in diesem Fenster. Vor dem Schließen sichern (Strg+S oder Kamera-Knopf).",
|
liveUnsentWarn:"Deine Änderungen sind seit {min} Minuten NICHT auf dem Server angekommen — sie existieren nur in diesem Fenster. Vor dem Schließen sichern (Strg+S oder Kamera-Knopf).",
|
||||||
liveEndedWarn:"Die Live-Verbindung zu diesem Server-Dokument ist getrennt — Werkbaum verbindet automatisch neu. Bis dahin bleiben Änderungen nur in diesem Fenster.",
|
liveEndedWarn:"Die Live-Verbindung zu diesem Server-Dokument ist getrennt — Werkbaum verbindet automatisch neu. Bis dahin bleiben Änderungen nur in diesem Fenster.",
|
||||||
tabModalTitle:"Werkbaum ist mehrfach geöffnet",
|
docLockTitle:"„{name}“ ist in einem anderen Fenster geöffnet",
|
||||||
tabModalText:"Diese App ist gerade in einem weiteren Fenster oder Tab geöffnet. Beide schreiben in dieselbe Dokument-Ablage — der zuletzt speichernde überschreibt den anderen. Bitte das andere Fenster schließen; dieser Hinweis verschwindet dann von selbst.",
|
docLockText:"Ein anderes Fenster bearbeitet dieses Dokument gerade — der letzte Tastendruck gewinnt. Was willst du tun?",
|
||||||
tabModalForce:"Trotzdem fortfahren (nicht empfohlen)",
|
docLockOpenOther:"Anderes Dokument öffnen:",
|
||||||
|
docLockViewOnly:"Hier nur ansehen",
|
||||||
|
docLockEditAnyway:"Trotzdem hier bearbeiten",
|
||||||
|
docKindViewOnly:"nur ansehen — in einem anderen Fenster geöffnet",
|
||||||
|
docKindGoneElsewhere:"anderswo gelöscht",
|
||||||
|
windowKindApp:"in der App",
|
||||||
|
windowKindBrowser:"im Browser",
|
||||||
snapLocalHead:"Lokale Sicherungen (dieses Fenster)",
|
snapLocalHead:"Lokale Sicherungen (dieses Fenster)",
|
||||||
kindLiveDead:"getrennt — verbindet neu …",
|
kindLiveDead:"getrennt — verbindet neu …",
|
||||||
kindLiveUnsent:"ungesendete Änderungen!",
|
kindLiveUnsent:"ungesendete Änderungen!",
|
||||||
@@ -3529,12 +3684,18 @@ const I18N = {
|
|||||||
sourceLoadWarn:"Could not load “{url}” ({error}). The file must be reachable via http(s) and allow CORS (Access-Control-Allow-Origin).",
|
sourceLoadWarn:"Could not load “{url}” ({error}). The file must be reachable via http(s) and allow CORS (Access-Control-Allow-Origin).",
|
||||||
padGoneWarn:"This link points at an Etherpad pad. The Etherpad connection is gone — collaboration now runs through a Werkbaum backend (?live=…). Paste the pad’s text there once, then edit it together.",
|
padGoneWarn:"This link points at an Etherpad pad. The Etherpad connection is gone — collaboration now runs through a Werkbaum backend (?live=…). Paste the pad’s text there once, then edit it together.",
|
||||||
storeFailedWarn:"Saving in the browser failed — its storage is probably full. Changes may be lost on reload; free space by deleting unused documents or earlier states.",
|
storeFailedWarn:"Saving in the browser failed — its storage is probably full. Changes may be lost on reload; free space by deleting unused documents or earlier states.",
|
||||||
tabConflictWarn:"Werkbaum is open in another browser tab — both write to the same document list, and the last one to save wins. Best work in a single tab.",
|
tabConflictWarn:"“{name}” is being edited in another window ({where}) — the last keystroke wins.",
|
||||||
liveUnsentWarn:"Your changes have NOT reached the server for {min} minutes — they exist only in this window. Save before closing (Ctrl+S or the camera button).",
|
liveUnsentWarn:"Your changes have NOT reached the server for {min} minutes — they exist only in this window. Save before closing (Ctrl+S or the camera button).",
|
||||||
liveEndedWarn:"The live connection to this server document is down — Werkbaum reconnects automatically. Until then, changes stay in this window only.",
|
liveEndedWarn:"The live connection to this server document is down — Werkbaum reconnects automatically. Until then, changes stay in this window only.",
|
||||||
tabModalTitle:"Werkbaum is open more than once",
|
docLockTitle:"“{name}” is open in another window",
|
||||||
tabModalText:"This app is currently open in another window or tab. Both write to the same document storage — whichever saves last overwrites the other. Please close the other window; this notice then disappears by itself.",
|
docLockText:"Another window is editing this document right now — the last keystroke wins. What would you like to do?",
|
||||||
tabModalForce:"Continue anyway (not recommended)",
|
docLockOpenOther:"Open another document:",
|
||||||
|
docLockViewOnly:"View only here",
|
||||||
|
docLockEditAnyway:"Edit here anyway",
|
||||||
|
docKindViewOnly:"view only — open in another window",
|
||||||
|
docKindGoneElsewhere:"deleted elsewhere",
|
||||||
|
windowKindApp:"in the installed app",
|
||||||
|
windowKindBrowser:"in the browser",
|
||||||
snapLocalHead:"Local backups (this window)",
|
snapLocalHead:"Local backups (this window)",
|
||||||
kindLiveDead:"disconnected — reconnecting …",
|
kindLiveDead:"disconnected — reconnecting …",
|
||||||
kindLiveUnsent:"unsent changes!",
|
kindLiveUnsent:"unsent changes!",
|
||||||
@@ -3665,12 +3826,18 @@ const I18N = {
|
|||||||
sourceLoadWarn:"No se pudo cargar «{url}» ({error}). El archivo debe ser accesible por http(s) y permitir CORS (Access-Control-Allow-Origin).",
|
sourceLoadWarn:"No se pudo cargar «{url}» ({error}). El archivo debe ser accesible por http(s) y permitir CORS (Access-Control-Allow-Origin).",
|
||||||
padGoneWarn:"Este enlace apunta a un pad de Etherpad. La conexión con Etherpad ya no existe: ahora se colabora a través de un backend de Werkbaum (?live=…). Pega allí el texto del pad una vez y editadlo juntos.",
|
padGoneWarn:"Este enlace apunta a un pad de Etherpad. La conexión con Etherpad ya no existe: ahora se colabora a través de un backend de Werkbaum (?live=…). Pega allí el texto del pad una vez y editadlo juntos.",
|
||||||
storeFailedWarn:"No se pudo guardar en el navegador: su almacenamiento probablemente está lleno. Los cambios pueden perderse al recargar; libera espacio borrando documentos sin uso o estados anteriores.",
|
storeFailedWarn:"No se pudo guardar en el navegador: su almacenamiento probablemente está lleno. Los cambios pueden perderse al recargar; libera espacio borrando documentos sin uso o estados anteriores.",
|
||||||
tabConflictWarn:"Werkbaum está abierto en otra pestaña — ambas escriben en la misma lista de documentos y gana la última en guardar. Mejor trabaja en una sola pestaña.",
|
tabConflictWarn:"«{name}» se está editando en otra ventana ({where}) — gana la última tecla.",
|
||||||
liveUnsentWarn:"Tus cambios NO han llegado al servidor desde hace {min} minutos — solo existen en esta ventana. Guarda antes de cerrar (Ctrl+S o el botón de cámara).",
|
liveUnsentWarn:"Tus cambios NO han llegado al servidor desde hace {min} minutos — solo existen en esta ventana. Guarda antes de cerrar (Ctrl+S o el botón de cámara).",
|
||||||
liveEndedWarn:"La conexión en vivo con este documento del servidor se ha perdido — Werkbaum se reconecta automáticamente. Hasta entonces, los cambios quedan solo en esta ventana.",
|
liveEndedWarn:"La conexión en vivo con este documento del servidor se ha perdido — Werkbaum se reconecta automáticamente. Hasta entonces, los cambios quedan solo en esta ventana.",
|
||||||
tabModalTitle:"Werkbaum está abierto más de una vez",
|
docLockTitle:"«{name}» está abierto en otra ventana",
|
||||||
tabModalText:"Esta aplicación está abierta en otra ventana o pestaña. Ambas escriben en el mismo almacén de documentos — la última en guardar sobrescribe a la otra. Cierra la otra ventana; este aviso desaparecerá solo.",
|
docLockText:"Otra ventana está editando este documento ahora mismo — la última tecla gana. ¿Qué quieres hacer?",
|
||||||
tabModalForce:"Continuar de todos modos (no recomendado)",
|
docLockOpenOther:"Abrir otro documento:",
|
||||||
|
docLockViewOnly:"Solo ver aquí",
|
||||||
|
docLockEditAnyway:"Editar aquí de todos modos",
|
||||||
|
docKindViewOnly:"solo lectura — abierto en otra ventana",
|
||||||
|
docKindGoneElsewhere:"eliminado en otra ventana",
|
||||||
|
windowKindApp:"en la app instalada",
|
||||||
|
windowKindBrowser:"en el navegador",
|
||||||
snapLocalHead:"Copias locales (esta ventana)",
|
snapLocalHead:"Copias locales (esta ventana)",
|
||||||
kindLiveDead:"desconectado — reconectando …",
|
kindLiveDead:"desconectado — reconectando …",
|
||||||
kindLiveUnsent:"¡cambios sin enviar!",
|
kindLiveUnsent:"¡cambios sin enviar!",
|
||||||
@@ -3801,12 +3968,18 @@ const I18N = {
|
|||||||
sourceLoadWarn:"Impossible de charger « {url} » ({error}). Le fichier doit être accessible en http(s) et autoriser CORS (Access-Control-Allow-Origin).",
|
sourceLoadWarn:"Impossible de charger « {url} » ({error}). Le fichier doit être accessible en http(s) et autoriser CORS (Access-Control-Allow-Origin).",
|
||||||
padGoneWarn:"Ce lien pointe vers un pad Etherpad. La connexion Etherpad n’existe plus — la collaboration passe désormais par un backend Werkbaum (?live=…). Collez-y une fois le texte du pad, puis modifiez-le à plusieurs.",
|
padGoneWarn:"Ce lien pointe vers un pad Etherpad. La connexion Etherpad n’existe plus — la collaboration passe désormais par un backend Werkbaum (?live=…). Collez-y une fois le texte du pad, puis modifiez-le à plusieurs.",
|
||||||
storeFailedWarn:"L'enregistrement dans le navigateur a échoué — son stockage est probablement plein. Les modifications peuvent être perdues au rechargement ; libérez de l'espace en supprimant des documents inutilisés ou des états antérieurs.",
|
storeFailedWarn:"L'enregistrement dans le navigateur a échoué — son stockage est probablement plein. Les modifications peuvent être perdues au rechargement ; libérez de l'espace en supprimant des documents inutilisés ou des états antérieurs.",
|
||||||
tabConflictWarn:"Werkbaum est ouvert dans un autre onglet — les deux écrivent dans la même liste de documents et le dernier à enregistrer l'emporte. Mieux vaut travailler dans un seul onglet.",
|
tabConflictWarn:"« {name} » est modifié dans une autre fenêtre ({where}) — le dernier à taper gagne.",
|
||||||
liveUnsentWarn:"Tes modifications ne sont PAS arrivées au serveur depuis {min} minutes — elles n'existent que dans cette fenêtre. Enregistre avant de fermer (Ctrl+S ou le bouton appareil photo).",
|
liveUnsentWarn:"Tes modifications ne sont PAS arrivées au serveur depuis {min} minutes — elles n'existent que dans cette fenêtre. Enregistre avant de fermer (Ctrl+S ou le bouton appareil photo).",
|
||||||
liveEndedWarn:"La connexion en direct à ce document serveur est interrompue — Werkbaum se reconnecte automatiquement. D'ici là, les modifications restent dans cette fenêtre.",
|
liveEndedWarn:"La connexion en direct à ce document serveur est interrompue — Werkbaum se reconnecte automatiquement. D'ici là, les modifications restent dans cette fenêtre.",
|
||||||
tabModalTitle:"Werkbaum est ouvert plusieurs fois",
|
docLockTitle:"« {name} » est ouvert dans une autre fenêtre",
|
||||||
tabModalText:"Cette application est ouverte dans une autre fenêtre ou un autre onglet. Les deux écrivent dans le même stockage de documents — le dernier à enregistrer écrase l'autre. Ferme l'autre fenêtre ; cet avis disparaîtra de lui-même.",
|
docLockText:"Une autre fenêtre modifie ce document en ce moment — le dernier à taper gagne. Que veux-tu faire ?",
|
||||||
tabModalForce:"Continuer quand même (déconseillé)",
|
docLockOpenOther:"Ouvrir un autre document :",
|
||||||
|
docLockViewOnly:"Consulter seulement ici",
|
||||||
|
docLockEditAnyway:"Modifier quand même ici",
|
||||||
|
docKindViewOnly:"consultation — ouvert dans une autre fenêtre",
|
||||||
|
docKindGoneElsewhere:"supprimé ailleurs",
|
||||||
|
windowKindApp:"dans l'app installée",
|
||||||
|
windowKindBrowser:"dans le navigateur",
|
||||||
snapLocalHead:"Sauvegardes locales (cette fenêtre)",
|
snapLocalHead:"Sauvegardes locales (cette fenêtre)",
|
||||||
kindLiveDead:"déconnecté — reconnexion …",
|
kindLiveDead:"déconnecté — reconnexion …",
|
||||||
kindLiveUnsent:"modifications non envoyées !",
|
kindLiveUnsent:"modifications non envoyées !",
|
||||||
@@ -3937,12 +4110,18 @@ const I18N = {
|
|||||||
sourceLoadWarn:"Nie udało się wczytać „{url}” ({error}). Plik musi być dostępny przez http(s) i zezwalać na CORS (Access-Control-Allow-Origin).",
|
sourceLoadWarn:"Nie udało się wczytać „{url}” ({error}). Plik musi być dostępny przez http(s) i zezwalać na CORS (Access-Control-Allow-Origin).",
|
||||||
padGoneWarn:"Ten link prowadzi do pada Etherpad. Połączenia z Etherpadem już nie ma — wspólna praca odbywa się teraz przez backend Werkbaum (?live=…). Wklej tam raz tekst pada i edytujcie go razem.",
|
padGoneWarn:"Ten link prowadzi do pada Etherpad. Połączenia z Etherpadem już nie ma — wspólna praca odbywa się teraz przez backend Werkbaum (?live=…). Wklej tam raz tekst pada i edytujcie go razem.",
|
||||||
storeFailedWarn:"Zapis w przeglądarce nie powiódł się — jego pamięć jest zapewne pełna. Zmiany mogą przepaść przy przeładowaniu; zwolnij miejsce, usuwając nieużywane dokumenty lub wcześniejsze stany.",
|
storeFailedWarn:"Zapis w przeglądarce nie powiódł się — jego pamięć jest zapewne pełna. Zmiany mogą przepaść przy przeładowaniu; zwolnij miejsce, usuwając nieużywane dokumenty lub wcześniejsze stany.",
|
||||||
tabConflictWarn:"Werkbaum jest otwarty w innej karcie — obie zapisują tę samą listę dokumentów i wygrywa ta, która zapisze ostatnia. Najlepiej pracować w jednej karcie.",
|
tabConflictWarn:"„{name}” jest edytowany w innym oknie ({where}) — wygrywa ostatni klawisz.",
|
||||||
liveUnsentWarn:"Twoje zmiany NIE dotarły na serwer od {min} minut — istnieją tylko w tym oknie. Zapisz przed zamknięciem (Ctrl+S lub przycisk aparatu).",
|
liveUnsentWarn:"Twoje zmiany NIE dotarły na serwer od {min} minut — istnieją tylko w tym oknie. Zapisz przed zamknięciem (Ctrl+S lub przycisk aparatu).",
|
||||||
liveEndedWarn:"Połączenie na żywo z tym dokumentem serwera zostało przerwane — Werkbaum łączy się ponownie automatycznie. Do tego czasu zmiany pozostają tylko w tym oknie.",
|
liveEndedWarn:"Połączenie na żywo z tym dokumentem serwera zostało przerwane — Werkbaum łączy się ponownie automatycznie. Do tego czasu zmiany pozostają tylko w tym oknie.",
|
||||||
tabModalTitle:"Werkbaum jest otwarty więcej niż raz",
|
docLockTitle:"„{name}” jest otwarte w innym oknie",
|
||||||
tabModalText:"Ta aplikacja jest otwarta w innym oknie lub karcie. Obie zapisują do tego samego magazynu dokumentów — ostatni zapis nadpisuje drugi. Zamknij drugie okno; ten komunikat zniknie sam.",
|
docLockText:"Inne okno właśnie edytuje ten dokument — wygrywa ostatni klawisz. Co chcesz zrobić?",
|
||||||
tabModalForce:"Kontynuuj mimo to (niezalecane)",
|
docLockOpenOther:"Otwórz inny dokument:",
|
||||||
|
docLockViewOnly:"Tylko podgląd",
|
||||||
|
docLockEditAnyway:"Edytuj mimo to",
|
||||||
|
docKindViewOnly:"tylko podgląd — otwarte w innym oknie",
|
||||||
|
docKindGoneElsewhere:"usunięte gdzie indziej",
|
||||||
|
windowKindApp:"w zainstalowanej aplikacji",
|
||||||
|
windowKindBrowser:"w przeglądarce",
|
||||||
snapLocalHead:"Lokalne kopie (to okno)",
|
snapLocalHead:"Lokalne kopie (to okno)",
|
||||||
kindLiveDead:"rozłączono — ponowne łączenie …",
|
kindLiveDead:"rozłączono — ponowne łączenie …",
|
||||||
kindLiveUnsent:"niewysłane zmiany!",
|
kindLiveUnsent:"niewysłane zmiany!",
|
||||||
@@ -4073,12 +4252,18 @@ const I18N = {
|
|||||||
sourceLoadWarn:"Не удалось загрузить «{url}» ({error}). Файл должен быть доступен по http(s) и разрешать CORS (Access-Control-Allow-Origin).",
|
sourceLoadWarn:"Не удалось загрузить «{url}» ({error}). Файл должен быть доступен по http(s) и разрешать CORS (Access-Control-Allow-Origin).",
|
||||||
padGoneWarn:"Эта ссылка ведёт на пад Etherpad. Подключения к Etherpad больше нет — совместная работа теперь идёт через бэкенд Werkbaum (?live=…). Вставьте туда текст пада один раз и редактируйте вместе.",
|
padGoneWarn:"Эта ссылка ведёт на пад Etherpad. Подключения к Etherpad больше нет — совместная работа теперь идёт через бэкенд Werkbaum (?live=…). Вставьте туда текст пада один раз и редактируйте вместе.",
|
||||||
storeFailedWarn:"Не удалось сохранить в браузере — его хранилище, вероятно, заполнено. Изменения могут потеряться при перезагрузке; освободите место, удалив ненужные документы или прежние состояния.",
|
storeFailedWarn:"Не удалось сохранить в браузере — его хранилище, вероятно, заполнено. Изменения могут потеряться при перезагрузке; освободите место, удалив ненужные документы или прежние состояния.",
|
||||||
tabConflictWarn:"Werkbaum открыт в другой вкладке — обе пишут в один список документов, и побеждает та, что сохранит последней. Лучше работать в одной вкладке.",
|
tabConflictWarn:"«{name}» редактируется в другом окне ({where}) — побеждает последнее нажатие.",
|
||||||
liveUnsentWarn:"Ваши изменения НЕ доходят до сервера уже {min} мин — они существуют только в этом окне. Сохраните перед закрытием (Ctrl+S или кнопка камеры).",
|
liveUnsentWarn:"Ваши изменения НЕ доходят до сервера уже {min} мин — они существуют только в этом окне. Сохраните перед закрытием (Ctrl+S или кнопка камеры).",
|
||||||
liveEndedWarn:"Живое соединение с этим серверным документом прервано — Werkbaum переподключается автоматически. До тех пор изменения остаются только в этом окне.",
|
liveEndedWarn:"Живое соединение с этим серверным документом прервано — Werkbaum переподключается автоматически. До тех пор изменения остаются только в этом окне.",
|
||||||
tabModalTitle:"Werkbaum открыт несколько раз",
|
docLockTitle:"«{name}» открыт в другом окне",
|
||||||
tabModalText:"Приложение открыто в другом окне или вкладке. Оба пишут в одно хранилище документов — последний сохранивший перезаписывает другого. Закройте другое окно; это сообщение исчезнет само.",
|
docLockText:"Другое окно сейчас редактирует этот документ — побеждает последнее нажатие. Что вы хотите сделать?",
|
||||||
tabModalForce:"Продолжить всё равно (не рекомендуется)",
|
docLockOpenOther:"Открыть другой документ:",
|
||||||
|
docLockViewOnly:"Только просмотр",
|
||||||
|
docLockEditAnyway:"Всё равно редактировать здесь",
|
||||||
|
docKindViewOnly:"только просмотр — открыт в другом окне",
|
||||||
|
docKindGoneElsewhere:"удалён в другом окне",
|
||||||
|
windowKindApp:"в установленном приложении",
|
||||||
|
windowKindBrowser:"в браузере",
|
||||||
snapLocalHead:"Локальные копии (это окно)",
|
snapLocalHead:"Локальные копии (это окно)",
|
||||||
kindLiveDead:"нет связи — переподключение …",
|
kindLiveDead:"нет связи — переподключение …",
|
||||||
kindLiveUnsent:"неотправленные изменения!",
|
kindLiveUnsent:"неотправленные изменения!",
|
||||||
@@ -4209,12 +4394,18 @@ const I18N = {
|
|||||||
sourceLoadWarn:"„{url}“ लोड नहीं हो सका ({error})। फ़ाइल http(s) से उपलब्ध होनी चाहिए और CORS की अनुमति देनी चाहिए (Access-Control-Allow-Origin)।",
|
sourceLoadWarn:"„{url}“ लोड नहीं हो सका ({error})। फ़ाइल http(s) से उपलब्ध होनी चाहिए और CORS की अनुमति देनी चाहिए (Access-Control-Allow-Origin)।",
|
||||||
padGoneWarn:"यह लिंक एक Etherpad पैड की ओर इशारा करता है। Etherpad कनेक्शन अब नहीं है — साझा काम अब Werkbaum बैकएंड (?live=…) से होता है। पैड का टेक्स्ट वहाँ एक बार चिपकाएँ और मिलकर संपादित करें।",
|
padGoneWarn:"यह लिंक एक Etherpad पैड की ओर इशारा करता है। Etherpad कनेक्शन अब नहीं है — साझा काम अब Werkbaum बैकएंड (?live=…) से होता है। पैड का टेक्स्ट वहाँ एक बार चिपकाएँ और मिलकर संपादित करें।",
|
||||||
storeFailedWarn:"ब्राउज़र में सहेजना विफल रहा — संभवतः उसका संग्रहण भर गया है। पुनः लोड करने पर बदलाव खो सकते हैं; अनुपयोगी दस्तावेज़ या पिछली स्थितियाँ हटाकर जगह बनाएँ।",
|
storeFailedWarn:"ब्राउज़र में सहेजना विफल रहा — संभवतः उसका संग्रहण भर गया है। पुनः लोड करने पर बदलाव खो सकते हैं; अनुपयोगी दस्तावेज़ या पिछली स्थितियाँ हटाकर जगह बनाएँ।",
|
||||||
tabConflictWarn:"Werkbaum एक और ब्राउज़र टैब में खुला है — दोनों एक ही दस्तावेज़ सूची में लिखते हैं, और आख़िर में सहेजने वाला जीतता है। बेहतर है कि एक ही टैब में काम करें।",
|
tabConflictWarn:"„{name}“ दूसरी विंडो में संपादित हो रहा है ({where}) — अंतिम कीस्ट्रोक जीतता है।",
|
||||||
liveUnsentWarn:"आपके बदलाव {min} मिनट से सर्वर तक नहीं पहुँचे — वे केवल इस विंडो में मौजूद हैं। बंद करने से पहले सहेजें (Ctrl+S या कैमरा बटन)।",
|
liveUnsentWarn:"आपके बदलाव {min} मिनट से सर्वर तक नहीं पहुँचे — वे केवल इस विंडो में मौजूद हैं। बंद करने से पहले सहेजें (Ctrl+S या कैमरा बटन)।",
|
||||||
liveEndedWarn:"इस सर्वर दस्तावेज़ से लाइव कनेक्शन टूट गया है — Werkbaum अपने आप फिर जुड़ता है। तब तक बदलाव केवल इसी विंडो में रहते हैं।",
|
liveEndedWarn:"इस सर्वर दस्तावेज़ से लाइव कनेक्शन टूट गया है — Werkbaum अपने आप फिर जुड़ता है। तब तक बदलाव केवल इसी विंडो में रहते हैं।",
|
||||||
tabModalTitle:"Werkbaum एक से अधिक बार खुला है",
|
docLockTitle:"„{name}“ दूसरी विंडो में खुला है",
|
||||||
tabModalText:"यह ऐप किसी और विंडो या टैब में भी खुला है। दोनों एक ही दस्तावेज़ भंडार में लिखते हैं — आख़िर में सहेजने वाला दूसरे को मिटा देता है। कृपया दूसरी विंडो बंद करें; यह सूचना अपने आप हट जाएगी।",
|
docLockText:"दूसरी विंडो इस दस्तावेज़ को अभी संपादित कर रही है — अंतिम कीस्ट्रोक जीतता है। आप क्या करना चाहेंगे?",
|
||||||
tabModalForce:"फिर भी जारी रखें (अनुशंसित नहीं)",
|
docLockOpenOther:"दूसरा दस्तावेज़ खोलें:",
|
||||||
|
docLockViewOnly:"यहाँ केवल देखें",
|
||||||
|
docLockEditAnyway:"फिर भी यहाँ संपादित करें",
|
||||||
|
docKindViewOnly:"केवल देखना — दूसरी विंडो में खुला",
|
||||||
|
docKindGoneElsewhere:"दूसरी जगह हटाया गया",
|
||||||
|
windowKindApp:"इंस्टॉल किए गए ऐप में",
|
||||||
|
windowKindBrowser:"ब्राउज़र में",
|
||||||
snapLocalHead:"स्थानीय प्रतियाँ (यह विंडो)",
|
snapLocalHead:"स्थानीय प्रतियाँ (यह विंडो)",
|
||||||
kindLiveDead:"संपर्क टूटा — फिर जोड़ रहा है …",
|
kindLiveDead:"संपर्क टूटा — फिर जोड़ रहा है …",
|
||||||
kindLiveUnsent:"बिना भेजे बदलाव!",
|
kindLiveUnsent:"बिना भेजे बदलाव!",
|
||||||
@@ -4345,12 +4536,18 @@ const I18N = {
|
|||||||
sourceLoadWarn:"无法加载“{url}”({error})。该文件必须可通过 http(s) 访问并允许 CORS(Access-Control-Allow-Origin)。",
|
sourceLoadWarn:"无法加载“{url}”({error})。该文件必须可通过 http(s) 访问并允许 CORS(Access-Control-Allow-Origin)。",
|
||||||
padGoneWarn:"此链接指向一个 Etherpad pad。Etherpad 连接已移除——协作现在通过 Werkbaum 后端(?live=…)进行。把 pad 的文本粘贴过去一次,然后一起编辑。",
|
padGoneWarn:"此链接指向一个 Etherpad pad。Etherpad 连接已移除——协作现在通过 Werkbaum 后端(?live=…)进行。把 pad 的文本粘贴过去一次,然后一起编辑。",
|
||||||
storeFailedWarn:"无法保存到浏览器——其存储空间可能已满。重新加载时更改可能丢失;请删除不再使用的文档或以前的状态以腾出空间。",
|
storeFailedWarn:"无法保存到浏览器——其存储空间可能已满。重新加载时更改可能丢失;请删除不再使用的文档或以前的状态以腾出空间。",
|
||||||
tabConflictWarn:"Werkbaum 已在另一个浏览器标签页中打开——两者写入同一份文档列表,最后保存者生效。最好只在一个标签页中工作。",
|
tabConflictWarn:"“{name}”正在另一个窗口中编辑({where})——最后按键者生效。",
|
||||||
liveUnsentWarn:"你的更改已有 {min} 分钟未到达服务器——它们只存在于此窗口中。关闭前请先保存(Ctrl+S 或相机按钮)。",
|
liveUnsentWarn:"你的更改已有 {min} 分钟未到达服务器——它们只存在于此窗口中。关闭前请先保存(Ctrl+S 或相机按钮)。",
|
||||||
liveEndedWarn:"与此服务器文档的实时连接已断开——Werkbaum 会自动重连。在此之前,更改只保留在此窗口中。",
|
liveEndedWarn:"与此服务器文档的实时连接已断开——Werkbaum 会自动重连。在此之前,更改只保留在此窗口中。",
|
||||||
tabModalTitle:"Werkbaum 已多处打开",
|
docLockTitle:"“{name}”已在另一个窗口中打开",
|
||||||
tabModalText:"此应用正同时在另一个窗口或标签页中打开。两者写入同一份文档存储——后保存者会覆盖对方。请关闭另一个窗口;此提示会自行消失。",
|
docLockText:"另一个窗口正在编辑此文档——最后按键者生效。您想怎么做?",
|
||||||
tabModalForce:"仍要继续(不推荐)",
|
docLockOpenOther:"打开其他文档:",
|
||||||
|
docLockViewOnly:"仅在此查看",
|
||||||
|
docLockEditAnyway:"仍要在此编辑",
|
||||||
|
docKindViewOnly:"仅查看——已在其他窗口打开",
|
||||||
|
docKindGoneElsewhere:"已在其他窗口删除",
|
||||||
|
windowKindApp:"在安装的应用中",
|
||||||
|
windowKindBrowser:"在浏览器中",
|
||||||
snapLocalHead:"本地备份(此窗口)",
|
snapLocalHead:"本地备份(此窗口)",
|
||||||
kindLiveDead:"已断开——正在重连 …",
|
kindLiveDead:"已断开——正在重连 …",
|
||||||
kindLiveUnsent:"有未发送的更改!",
|
kindLiveUnsent:"有未发送的更改!",
|
||||||
@@ -4481,12 +4678,18 @@ const I18N = {
|
|||||||
sourceLoadWarn:"「{url}」を読み込めませんでした({error})。ファイルは http(s) でアクセス可能で、CORS(Access-Control-Allow-Origin)を許可する必要があります。",
|
sourceLoadWarn:"「{url}」を読み込めませんでした({error})。ファイルは http(s) でアクセス可能で、CORS(Access-Control-Allow-Origin)を許可する必要があります。",
|
||||||
padGoneWarn:"このリンクは Etherpad のパッドを指しています。Etherpad 連携は廃止されました。共同編集は Werkbaum バックエンド(?live=…)で行います。パッドの本文を一度貼り付ければ、複数人で編集できます。",
|
padGoneWarn:"このリンクは Etherpad のパッドを指しています。Etherpad 連携は廃止されました。共同編集は Werkbaum バックエンド(?live=…)で行います。パッドの本文を一度貼り付ければ、複数人で編集できます。",
|
||||||
storeFailedWarn:"ブラウザーへの保存に失敗しました — ストレージが満杯の可能性があります。再読み込みで変更が失われることがあります。不要なドキュメントや以前の状態を削除して空きを作ってください。",
|
storeFailedWarn:"ブラウザーへの保存に失敗しました — ストレージが満杯の可能性があります。再読み込みで変更が失われることがあります。不要なドキュメントや以前の状態を削除して空きを作ってください。",
|
||||||
tabConflictWarn:"Werkbaum が別のタブでも開いています — 両方が同じドキュメント一覧に書き込み、最後に保存した方が勝ちます。1 つのタブでの作業をおすすめします。",
|
tabConflictWarn:"「{name}」は別のウィンドウで編集中です({where})— 最後のキー操作が優先されます。",
|
||||||
liveUnsentWarn:"変更が {min} 分間サーバーに届いていません — この変更はこのウィンドウにしか存在しません。閉じる前に保存してください(Ctrl+S またはカメラボタン)。",
|
liveUnsentWarn:"変更が {min} 分間サーバーに届いていません — この変更はこのウィンドウにしか存在しません。閉じる前に保存してください(Ctrl+S またはカメラボタン)。",
|
||||||
liveEndedWarn:"このサーバー文書とのライブ接続が切れています — Werkbaum は自動的に再接続します。それまで変更はこのウィンドウにのみ残ります。",
|
liveEndedWarn:"このサーバー文書とのライブ接続が切れています — Werkbaum は自動的に再接続します。それまで変更はこのウィンドウにのみ残ります。",
|
||||||
tabModalTitle:"Werkbaum が複数開かれています",
|
docLockTitle:"「{name}」は別のウィンドウで開かれています",
|
||||||
tabModalText:"このアプリは別のウィンドウまたはタブでも開かれています。両方が同じ文書ストレージに書き込むため、最後に保存した側が他方を上書きします。もう一方のウィンドウを閉じてください。この通知は自動的に消えます。",
|
docLockText:"別のウィンドウがこのドキュメントを編集中です — 最後のキー操作が優先されます。どうしますか?",
|
||||||
tabModalForce:"それでも続行(非推奨)",
|
docLockOpenOther:"別のドキュメントを開く:",
|
||||||
|
docLockViewOnly:"ここでは閲覧のみ",
|
||||||
|
docLockEditAnyway:"それでもここで編集する",
|
||||||
|
docKindViewOnly:"閲覧のみ — 別のウィンドウで開かれています",
|
||||||
|
docKindGoneElsewhere:"別のウィンドウで削除済み",
|
||||||
|
windowKindApp:"インストールしたアプリで",
|
||||||
|
windowKindBrowser:"ブラウザーで",
|
||||||
snapLocalHead:"ローカルの控え(このウィンドウ)",
|
snapLocalHead:"ローカルの控え(このウィンドウ)",
|
||||||
kindLiveDead:"切断 — 再接続中 …",
|
kindLiveDead:"切断 — 再接続中 …",
|
||||||
kindLiveUnsent:"未送信の変更あり!",
|
kindLiveUnsent:"未送信の変更あり!",
|
||||||
@@ -4665,19 +4868,51 @@ function uniqueName(base){
|
|||||||
while(taken.has(base + ' ' + i)) i++;
|
while(taken.has(base + ' ' + i)) i++;
|
||||||
return base + ' ' + i;
|
return base + ' ' + i;
|
||||||
}
|
}
|
||||||
/* Die volle Persistenz (Flush-Punkte: Wechseln/Anlegen/Löschen/Umbenennen,
|
/* Die Dirty-Hälfte des Schemas v3 (RFC 002 §6.1): An den Flush-Punkten
|
||||||
Verlassen der Seite — nicht der Tastendruck, D82): Index + Texte über das
|
(Wechseln/Anlegen/Löschen/Umbenennen, Verlassen der Seite — nicht der
|
||||||
Ablageschema (docstore.js, D83). Unveränderte Schlüssel werden dort nicht
|
Tastendruck, D82) wird nur geschrieben, was DIESES Fenster angelegt,
|
||||||
angefasst; ein Fehlschlag (Quota voll) wird gemeldet statt geschluckt. */
|
umbenannt oder getippt hat, plus der Index-Hinweis. Kein Voll-Flush mehr,
|
||||||
|
der fremde Schlüssel schriebe oder entfernte — verschiedene Dokumente in
|
||||||
|
zwei Fenstern kollidieren damit gar nicht mehr. Unveränderte Schlüssel
|
||||||
|
werden nicht angefasst; ein Fehlschlag (Quota voll) wird gemeldet statt
|
||||||
|
geschluckt. */
|
||||||
|
const dirtyDocs = createDirtySet();
|
||||||
function persistDocs(){
|
function persistDocs(){
|
||||||
try{
|
try{
|
||||||
storeDocs(localStorage, docs, Object.keys(localStorage));
|
for(const id of dirtyDocs.entries()){
|
||||||
|
const d = docs.find(x => x.id === id);
|
||||||
|
if(d) writeDoc(localStorage, d);
|
||||||
|
}
|
||||||
|
dirtyDocs.clear();
|
||||||
|
writeIndexHint(localStorage, indexHint());
|
||||||
localStorage.setItem(LS_ACTIVE, activeId || '');
|
localStorage.setItem(LS_ACTIVE, activeId || '');
|
||||||
const d = activeDoc();
|
const d = activeDoc();
|
||||||
if(d) localStorage.setItem(LS_SRC, d.text); /* Spiegel: Rollback-Fallback */
|
if(d) localStorage.setItem(LS_SRC, d.text); /* Spiegel: Rollback-Fallback */
|
||||||
noteStore(true);
|
noteStore(true);
|
||||||
}catch(_){ noteStore(false); }
|
}catch(_){ noteStore(false); }
|
||||||
}
|
}
|
||||||
|
/* Die Speicher-ids ∪ eigene Liste — der Index-Hinweis sagt, was es gibt; er
|
||||||
|
entfernt NIE etwas (§6.1). Die eigene Liste bestimmt die Reihenfolge,
|
||||||
|
Nachzügler aus dem Speicher hängen hinten an. */
|
||||||
|
function indexHint(){
|
||||||
|
const eintraege = new Map();
|
||||||
|
for(const d of docs){
|
||||||
|
const e = {id: d.id, name: d.name};
|
||||||
|
if(d.source) e.source = d.source;
|
||||||
|
eintraege.set(d.id, e);
|
||||||
|
}
|
||||||
|
for(const k of Object.keys(localStorage)){
|
||||||
|
let id = null;
|
||||||
|
if(k.startsWith(DOC_META_PREFIX)) id = k.slice(DOC_META_PREFIX.length);
|
||||||
|
else if(k.startsWith(DOC_TEXT_PREFIX)) id = k.slice(DOC_TEXT_PREFIX.length);
|
||||||
|
if(!id || eintraege.has(id) || hasTombstone(localStorage, id)) continue;
|
||||||
|
const meta = readMeta(localStorage, id);
|
||||||
|
const e = {id, name: (meta && meta.name) || id};
|
||||||
|
if(meta && meta.source) e.source = meta.source;
|
||||||
|
eintraege.set(id, e);
|
||||||
|
}
|
||||||
|
return [...eintraege.values()];
|
||||||
|
}
|
||||||
/* Die Tastendruck-Hälfte (D82/D83): der Text des AKTIVEN Dokuments unter
|
/* Die Tastendruck-Hälfte (D82/D83): der Text des AKTIVEN Dokuments unter
|
||||||
seinem eigenen Schlüssel plus der Spiegel — der Tastendruck schreibt damit
|
seinem eigenen Schlüssel plus der Spiegel — der Tastendruck schreibt damit
|
||||||
direkt in die echte Ablage, nicht mehr in eine Zwischenstation. */
|
direkt in die echte Ablage, nicht mehr in eine Zwischenstation. */
|
||||||
@@ -4710,9 +4945,10 @@ function seedShippedDocs(){
|
|||||||
try{ seen = localStorage.getItem(LS_SEEDED); }catch(_){}
|
try{ seen = localStorage.getItem(LS_SEEDED); }catch(_){}
|
||||||
const doc = docs.find(d => d.id === WERKBAUM_ID);
|
const doc = docs.find(d => d.id === WERKBAUM_ID);
|
||||||
if(!seen){
|
if(!seen){
|
||||||
if(!doc) docs.push({ id: WERKBAUM_ID, name: WERKBAUM_NAME, text: WERKBAUM_DOC });
|
if(!doc){ docs.push({ id: WERKBAUM_ID, name: WERKBAUM_NAME, text: WERKBAUM_DOC }); dirtyDocs.add(WERKBAUM_ID); }
|
||||||
} else if(seen !== '1' && seen !== fp && doc && fingerprint(doc.text) === seen){
|
} else if(seen !== '1' && seen !== fp && doc && fingerprint(doc.text) === seen){
|
||||||
doc.text = WERKBAUM_DOC;
|
doc.text = WERKBAUM_DOC;
|
||||||
|
dirtyDocs.add(WERKBAUM_ID);
|
||||||
}
|
}
|
||||||
try{ localStorage.setItem(LS_SEEDED, fp); }catch(_){}
|
try{ localStorage.setItem(LS_SEEDED, fp); }catch(_){}
|
||||||
/* Dasselbe Nachziehen für das Beispiel-Dokument (D27-Nachtrag): Ohne den
|
/* Dasselbe Nachziehen für das Beispiel-Dokument (D27-Nachtrag): Ohne den
|
||||||
@@ -4726,6 +4962,7 @@ function seedShippedDocs(){
|
|||||||
const ex = docs.find(d => d.id === EXAMPLE_ID);
|
const ex = docs.find(d => d.id === EXAMPLE_ID);
|
||||||
if(exSeen && exSeen !== exFp && ex && fingerprint(ex.text) === exSeen){
|
if(exSeen && exSeen !== exFp && ex && fingerprint(ex.text) === exSeen){
|
||||||
ex.text = INITIAL;
|
ex.text = INITIAL;
|
||||||
|
dirtyDocs.add(EXAMPLE_ID);
|
||||||
}
|
}
|
||||||
try{ localStorage.setItem(LS_SEEDED_EXAMPLE, exFp); }catch(_){}
|
try{ localStorage.setItem(LS_SEEDED_EXAMPLE, exFp); }catch(_){}
|
||||||
/* Nur vergleichen, solange der Text der ausgelieferte ist — hat der Nutzer ihn
|
/* Nur vergleichen, solange der Text der ausgelieferte ist — hat der Nutzer ihn
|
||||||
@@ -4734,29 +4971,43 @@ function seedShippedDocs(){
|
|||||||
if(shipped && shipped.text === WERKBAUM_DOC) computeFresh(WERKBAUM_ID, WERKBAUM_DOC);
|
if(shipped && shipped.text === WERKBAUM_DOC) computeFresh(WERKBAUM_ID, WERKBAUM_DOC);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Aus dem localStorage laden (Schema: docstore.js, D83); bei fehlender oder
|
/* Aus dem localStorage laden (Schema v3: docstore.js, RFC 002 §6.1/§6.10
|
||||||
unbrauchbarer Dokumentenliste den bestehenden Einzeltext (oder INITIAL)
|
Schritt 1); bei fehlender oder unbrauchbarer Dokumentenliste den
|
||||||
als erstes Dokument migrieren. */
|
bestehenden Einzeltext (oder INITIAL) als erstes Dokument migrieren.
|
||||||
|
Reihenfolge: Tombstones altern → Union-Lesen → Altformat-Migration →
|
||||||
|
Seeding → der Dirty-Flush schreibt nur, was fehlt. */
|
||||||
function loadDocs(){
|
function loadDocs(){
|
||||||
|
try{ expireTombstones(localStorage, Object.keys(localStorage), Date.now()); }catch(_){}
|
||||||
let gelesen = null;
|
let gelesen = null;
|
||||||
try{ gelesen = readDocs(localStorage); }catch(_){}
|
try{ gelesen = readDocs(localStorage, Object.keys(localStorage)); }catch(_){}
|
||||||
if(gelesen){
|
if(gelesen){
|
||||||
docs = gelesen.docs;
|
docs = gelesen.docs;
|
||||||
} else {
|
} else {
|
||||||
let legacy = null;
|
let legacy = null;
|
||||||
try{ legacy = localStorage.getItem(LS_SRC); }catch(_){}
|
try{ legacy = localStorage.getItem(LS_SRC); }catch(_){}
|
||||||
docs = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: (legacy !== null) ? legacy : INITIAL }];
|
docs = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: (legacy !== null) ? legacy : INITIAL }];
|
||||||
|
/* Erstkontakt oder geräumte Ablage: das Beispiel neu anlegen — auch gegen
|
||||||
|
einen alten Tombstone des Beispiels (typed, siehe D22-Neu-Satz). */
|
||||||
|
try{ writeDoc(localStorage, docs[0], {typed: true}); }catch(_){}
|
||||||
}
|
}
|
||||||
let a = null;
|
let a = null;
|
||||||
try{ a = localStorage.getItem(LS_ACTIVE); }catch(_){}
|
try{ a = localStorage.getItem(LS_ACTIVE); }catch(_){}
|
||||||
/* Alt-Zustand (erste Version: zufällige id, lokalisierter Name): ein noch
|
/* Alt-Zustand (erste Version: zufällige id, lokalisierter Name): ein noch
|
||||||
unverändertes Beispiel-Dokument bekommt nachträglich die reservierte id und
|
unverändertes Beispiel-Dokument bekommt nachträglich die reservierte id und
|
||||||
den englischen Namen, damit Namensfix und Reset auch dort greifen. Nur bei
|
den englischen Namen, damit Namensfix und Reset auch dort greifen. Nur bei
|
||||||
unverändertem Text (=== INITIAL), um echte Nutzerinhalte nie zu adoptieren. */
|
unverändertem Text (=== INITIAL), um echte Nutzerinhalte nie zu adoptieren.
|
||||||
|
Der alte Text-Schlüssel wird mit Tombstone abgeräumt — der v3-Sweep
|
||||||
|
existiert nicht mehr, es bleibt sonst als namenloses Nachzügler-Dokument
|
||||||
|
stehen. */
|
||||||
if(!docs.some(d => d.id === EXAMPLE_ID) && docs[0] && docs[0].text === INITIAL){
|
if(!docs.some(d => d.id === EXAMPLE_ID) && docs[0] && docs[0].text === INITIAL){
|
||||||
if(a === docs[0].id) a = EXAMPLE_ID;
|
const altId = docs[0].id;
|
||||||
|
if(a === altId) a = EXAMPLE_ID;
|
||||||
docs[0].id = EXAMPLE_ID;
|
docs[0].id = EXAMPLE_ID;
|
||||||
docs[0].name = EXAMPLE_NAME;
|
docs[0].name = EXAMPLE_NAME;
|
||||||
|
if(altId !== EXAMPLE_ID){
|
||||||
|
removeDoc(localStorage, altId, Date.now());
|
||||||
|
dirtyDocs.add(EXAMPLE_ID);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
activeId = docs.some(d => d.id === a) ? a : docs[0].id;
|
activeId = docs.some(d => d.id === a) ? a : docs[0].id;
|
||||||
/* „Der Spiegel gewinnt" gilt nur noch der EINMALIGEN Migration aus dem
|
/* „Der Spiegel gewinnt" gilt nur noch der EINMALIGEN Migration aus dem
|
||||||
@@ -4772,21 +5023,46 @@ function loadDocs(){
|
|||||||
try{
|
try{
|
||||||
const spiegel = localStorage.getItem(LS_SRC);
|
const spiegel = localStorage.getItem(LS_SRC);
|
||||||
const d = docs.find(x => x.id === activeId);
|
const d = docs.find(x => x.id === activeId);
|
||||||
if(d && spiegel !== null && spiegel !== d.text) d.text = spiegel;
|
if(d && spiegel !== null && spiegel !== d.text){ d.text = spiegel; dirtyDocs.add(d.id); }
|
||||||
}catch(_){}
|
}catch(_){}
|
||||||
}
|
}
|
||||||
|
/* Schema v3: Alt-Stände verteilen, Meta für Index-Dokumente schreiben —
|
||||||
|
idempotent (§6.1). */
|
||||||
|
try{ migrateV3(localStorage, Date.now()); }catch(_){}
|
||||||
seedShippedDocs();
|
seedShippedDocs();
|
||||||
/* Namensfix für die kurzlebige Fassung mit dem Tippfehler — nur, solange der
|
/* Namensfix für die kurzlebige Fassung mit dem Tippfehler — nur, solange der
|
||||||
ausgelieferte Name unverändert ist; eine eigene Umbenennung bleibt stehen
|
ausgelieferte Name unverändert ist; eine eigene Umbenennung bleibt stehen
|
||||||
(Dokumentnamen sind Nutzerdaten, D22). */
|
(Dokumentnamen sind Nutzerdaten, D22). */
|
||||||
const wb = docs.find(d => d.id === WERKBAUM_ID);
|
const wb = docs.find(d => d.id === WERKBAUM_ID);
|
||||||
if(wb && wb.name === WERKBAUM_NAME_ALT) wb.name = WERKBAUM_NAME;
|
if(wb && wb.name === WERKBAUM_NAME_ALT){ wb.name = WERKBAUM_NAME; dirtyDocs.add(WERKBAUM_ID); }
|
||||||
|
/* §6.10 Schritt 5: kein persistDocs() nach dem Laden — der Dirty-Flush
|
||||||
|
schreibt nur die Migration/Seeding-Ergebnisse und den Index-Hinweis. */
|
||||||
|
persistDocs();
|
||||||
}
|
}
|
||||||
function saveSrc(){
|
function saveSrc(){
|
||||||
if(restoring) return;
|
if(restoring) return;
|
||||||
const d = activeDoc();
|
const d = activeDoc();
|
||||||
if(d) d.text = src.value;
|
if(d){
|
||||||
persistActiveText(); /* nicht persistDocs: das serialisierte ALLE Dokumente je Tastendruck (D82) */
|
d.text = src.value;
|
||||||
|
if(docGoneElsewhere) reviveGoneDoc(); /* Tippen ist Absicht (§6.5) */
|
||||||
|
}
|
||||||
|
persistActiveText(); /* nur der aktive Text plus Spiegel — kein Voll-Write (D82) */
|
||||||
|
}
|
||||||
|
/* Das anderswo gelöschte aktive Dokument wird durch Tippen wieder angelegt:
|
||||||
|
der Tombstone fällt, Meta und Text kommen neu (§6.5). */
|
||||||
|
function reviveGoneDoc(){
|
||||||
|
const d = activeDoc();
|
||||||
|
if(!d) return;
|
||||||
|
docGoneElsewhere = false;
|
||||||
|
/* Der Index-Hinweis kommt gleich mit — bis zum nächsten Flush hinge das
|
||||||
|
Dokument sonst allein an seinen eigenen Schlüsseln. v3 findet es dort
|
||||||
|
(readDocs vereinigt Meta, Text und Index), ein Rückbau auf einen Build
|
||||||
|
VOR v3 aber nicht: Der liest nur den Index und räumt bei seinem
|
||||||
|
Voll-Flush jeden Text-Schlüssel ab, den er darin nicht findet. Der
|
||||||
|
stille Verlust ist der teuerste Fehler (SPEC §4, D59), und die Zeile
|
||||||
|
schließt genau das Fenster (RFC 002 §9, Nachmessung Fall 9). */
|
||||||
|
try{ writeDoc(localStorage, d, {typed: true}); writeIndexHint(localStorage, indexHint()); }catch(_){}
|
||||||
|
updateDocName();
|
||||||
}
|
}
|
||||||
function saveUI(){
|
function saveUI(){
|
||||||
if(restoring) return;
|
if(restoring) return;
|
||||||
@@ -4887,11 +5163,16 @@ function updateDocName(){
|
|||||||
/* Verbindungs-Status am Chip (D90-Nachtrag): Die zeilenlose Warnung
|
/* Verbindungs-Status am Chip (D90-Nachtrag): Die zeilenlose Warnung
|
||||||
unten übersieht man leicht (Nutzer-Befund) — getrennt bzw.
|
unten übersieht man leicht (Nutzer-Befund) — getrennt bzw.
|
||||||
ungesendet steht deshalb warnfarben OBEN neben dem Namen, kurz.
|
ungesendet steht deshalb warnfarben OBEN neben dem Namen, kurz.
|
||||||
Getrennt heißt zugleich: die Wiederverbindung läuft schon. */
|
Getrennt heißt zugleich: die Wiederverbindung läuft schon.
|
||||||
|
Daneben die beiden Zustände aus dem Mehr-Fenster-Umbau (RFC 002
|
||||||
|
§6.5): „nur ansehen" (Sperre hält ein anderes Fenster) und
|
||||||
|
„anderswo gelöscht" (behalten, bis getippt wird). */
|
||||||
const tot = art === 'server' && !liveState;
|
const tot = art === 'server' && !liveState;
|
||||||
const unsent = !tot && liveWarning && liveWarning.type === 'liveUnsent';
|
const unsent = !tot && liveWarning && liveWarning.type === 'liveUnsent';
|
||||||
kindEl.classList.toggle('warn', tot || !!unsent);
|
kindEl.classList.toggle('warn', tot || !!unsent || viewOnly || docGoneElsewhere);
|
||||||
kindEl.textContent =
|
kindEl.textContent =
|
||||||
|
docGoneElsewhere ? t(label) + ' · ' + t('docKindGoneElsewhere') :
|
||||||
|
viewOnly ? t(label) + ' · ' + t('docKindViewOnly') :
|
||||||
tot ? t(label) + ' · ' + t('kindLiveDead') :
|
tot ? t(label) + ' · ' + t('kindLiveDead') :
|
||||||
unsent ? t(label) + ' · ' + t('kindLiveUnsent') :
|
unsent ? t(label) + ' · ' + t('kindLiveUnsent') :
|
||||||
t(label) + (host ? ' · ' + host : '');
|
t(label) + (host ? ' · ' + host : '');
|
||||||
@@ -5058,6 +5339,7 @@ function restoreDoc(id){
|
|||||||
if(!window.confirm(t('docRestoreConfirm', {name: d.name}))) return;
|
if(!window.confirm(t('docRestoreConfirm', {name: d.name}))) return;
|
||||||
d.text = shipped.text;
|
d.text = shipped.text;
|
||||||
d.name = shipped.name;
|
d.name = shipped.name;
|
||||||
|
dirtyDocs.add(d.id);
|
||||||
foldOverrides.clear();
|
foldOverrides.clear();
|
||||||
loadActiveIntoEditor();
|
loadActiveIntoEditor();
|
||||||
persistDocs();
|
persistDocs();
|
||||||
@@ -5079,6 +5361,7 @@ async function reloadDoc(){
|
|||||||
try{
|
try{
|
||||||
d.text = await fetchRemote(d.source);
|
d.text = await fetchRemote(d.source);
|
||||||
sourceWarning = null;
|
sourceWarning = null;
|
||||||
|
dirtyDocs.add(d.id);
|
||||||
loadActiveIntoEditor();
|
loadActiveIntoEditor();
|
||||||
persistDocs();
|
persistDocs();
|
||||||
}catch(err){
|
}catch(err){
|
||||||
@@ -5095,6 +5378,7 @@ async function reloadDoc(){
|
|||||||
if(perm === 'prompt') perm = await h.requestPermission({mode: 'read'});
|
if(perm === 'prompt') perm = await h.requestPermission({mode: 'read'});
|
||||||
if(perm !== 'granted') return;
|
if(perm !== 'granted') return;
|
||||||
d.text = await (await h.getFile()).text();
|
d.text = await (await h.getFile()).text();
|
||||||
|
dirtyDocs.add(d.id);
|
||||||
loadActiveIntoEditor();
|
loadActiveIntoEditor();
|
||||||
persistDocs();
|
persistDocs();
|
||||||
}catch(_){}
|
}catch(_){}
|
||||||
@@ -5122,7 +5406,7 @@ function flushActive(){ const d = activeDoc(); if(d) d.text = src.value; }
|
|||||||
let snaps = {}; /* {docId: [{t, text}, …]} — ältester zuerst */
|
let snaps = {}; /* {docId: [{t, text}, …]} — ältester zuerst */
|
||||||
let snapBase = ''; /* Text bei Dokumentwechsel; Vergleich, solange es keinen Stand gibt */
|
let snapBase = ''; /* Text bei Dokumentwechsel; Vergleich, solange es keinen Stand gibt */
|
||||||
|
|
||||||
function loadSnaps(){ snaps = parseSnaps(localStorage.getItem(LS_SNAPS)); }
|
function loadSnaps(){ snaps = readAllSnaps(localStorage, Object.keys(localStorage)); }
|
||||||
|
|
||||||
function snapshotNow(manuell){
|
function snapshotNow(manuell){
|
||||||
const d = activeDoc();
|
const d = activeDoc();
|
||||||
@@ -5135,7 +5419,7 @@ function snapshotNow(manuell){
|
|||||||
const text = src.value;
|
const text = src.value;
|
||||||
if(!addSnapshot(snaps, d.id, text, Date.now(), {base: snapBase, manual: manuell})) return false;
|
if(!addSnapshot(snaps, d.id, text, Date.now(), {base: snapBase, manual: manuell})) return false;
|
||||||
snapBase = text;
|
snapBase = text;
|
||||||
persistSnaps(snaps, localStorage);
|
persistSnaps(localStorage, snaps, d.id, Object.keys(localStorage));
|
||||||
if(!snapMenu.hidden) renderSnapMenu();
|
if(!snapMenu.hidden) renderSnapMenu();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -5475,6 +5759,7 @@ function loadActiveIntoEditor(){ const d = activeDoc(); src.value = d ? d.text :
|
|||||||
/* 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. */
|
||||||
snapBase = src.value; closeSnapMenu();
|
snapBase = src.value; closeSnapMenu();
|
||||||
|
syncDocLock(); /* die Sperre folgt dem aktiven Dokument (RFC 002 §6.4) */
|
||||||
render(); updateDocName(); updateFreshBtn();
|
render(); updateDocName(); updateFreshBtn();
|
||||||
/* Ein Dokumentwechsel ist mehr als neuer Text im Feld: Adresszeile und
|
/* Ein Dokumentwechsel ist mehr als neuer Text im Feld: Adresszeile und
|
||||||
Live-Sitzung gehören dem, was man vor sich hat (D80). Hier, weil jeder
|
Live-Sitzung gehören dem, was man vor sich hat (D80). Hier, weil jeder
|
||||||
@@ -5507,6 +5792,16 @@ function followActiveDoc(){
|
|||||||
|
|
||||||
function switchDoc(id){
|
function switchDoc(id){
|
||||||
if(id === activeId) return;
|
if(id === activeId) return;
|
||||||
|
/* Anderswo gelöscht (§6.5): Der Wechsel ohne Tastendruck lässt das
|
||||||
|
Dokument gehen — der Text wandert in die lokalen Sicherungen, sichtbar
|
||||||
|
im Uhr-Menü, solange die Sitzung lebt. */
|
||||||
|
if(docGoneElsewhere){
|
||||||
|
const alt = activeDoc();
|
||||||
|
if(alt) rescueSnapshot(alt.id, src.value);
|
||||||
|
docs = docs.filter(x => !alt || x.id !== alt.id);
|
||||||
|
docGoneElsewhere = false;
|
||||||
|
if(!docs.length) docs = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: INITIAL }];
|
||||||
|
}
|
||||||
/* Was noch im Debounce steckt, ist getippt und gemeint: erst loswerden,
|
/* Was noch im Debounce steckt, ist getippt und gemeint: erst loswerden,
|
||||||
solange das Textfeld noch den Text dieses Dokuments zeigt (D80). */
|
solange das Textfeld noch den Text dieses Dokuments zeigt (D80). */
|
||||||
if(liveActive() && liveState.pushTimer){
|
if(liveActive() && liveState.pushTimer){
|
||||||
@@ -5534,6 +5829,7 @@ function newDoc(){
|
|||||||
const d = { id: uid(), name: uniqueName(t('docNewName')), text: '' };
|
const d = { id: uid(), name: uniqueName(t('docNewName')), text: '' };
|
||||||
docs.push(d);
|
docs.push(d);
|
||||||
activeId = d.id;
|
activeId = d.id;
|
||||||
|
dirtyDocs.add(d.id);
|
||||||
foldOverrides.clear();
|
foldOverrides.clear();
|
||||||
loadActiveIntoEditor();
|
loadActiveIntoEditor();
|
||||||
persistDocs();
|
persistDocs();
|
||||||
@@ -5570,7 +5866,7 @@ function commitRename(){
|
|||||||
/* Server-Dokumente: Der Titel gehört dem Server — alle sehen denselben
|
/* Server-Dokumente: Der Titel gehört dem Server — alle sehen denselben
|
||||||
(D76). Der Weg dorthin ist PATCH /title (D85), nicht der lokale Name. */
|
(D76). Der Weg dorthin ist PATCH /title (D85), nicht der lokale Name. */
|
||||||
if(String(d.id).startsWith('live:')) renameOnServer(d, val);
|
if(String(d.id).startsWith('live:')) renameOnServer(d, val);
|
||||||
else { d.name = val; persistDocs(); updateDocName(); }
|
else { d.name = val; dirtyDocs.add(d.id); persistDocs(); updateDocName(); }
|
||||||
}
|
}
|
||||||
renderDocMenu();
|
renderDocMenu();
|
||||||
finishNewDoc();
|
finishNewDoc();
|
||||||
@@ -5585,7 +5881,7 @@ function cancelRename(){ renamingId = null; renderDocMenu(); finishNewDoc(); }
|
|||||||
async function renameOnServer(d, titel){
|
async function renameOnServer(d, titel){
|
||||||
const url = String(d.id).slice(5);
|
const url = String(d.id).slice(5);
|
||||||
const alt = d.name;
|
const alt = d.name;
|
||||||
const zeige = () => { persistDocs(); updateDocName(); if(!docMenu.hidden) renderDocMenu(); };
|
const zeige = () => { dirtyDocs.add(d.id); persistDocs(); updateDocName(); if(!docMenu.hidden) renderDocMenu(); };
|
||||||
d.name = titel; zeige();
|
d.name = titel; zeige();
|
||||||
const patch = async () => {
|
const patch = async () => {
|
||||||
const doc = await fetchJson(url);
|
const doc = await fetchJson(url);
|
||||||
@@ -5612,13 +5908,27 @@ async function renameOnServer(d, titel){
|
|||||||
}
|
}
|
||||||
/* Der gemeinsame Kern von Löschen und Verlassen: den Eintrag samt lokaler
|
/* Der gemeinsame Kern von Löschen und Verlassen: den Eintrag samt lokaler
|
||||||
Anhängsel entfernen. Die beiden Aktionen unterscheiden sich für den
|
Anhängsel entfernen. Die beiden Aktionen unterscheiden sich für den
|
||||||
Benutzer (Wort, Icon, Rückfrage — D81-Nachtrag 5), lokal tun sie dasselbe. */
|
Benutzer (Wort, Icon, Rückfrage — D81-Nachtrag 5), lokal tun sie dasselbe.
|
||||||
|
Seit RFC 002 hinterlässt beides einen Tombstone (§6.2) — er hindert die
|
||||||
|
anderen Fenster daran, das Dokument still wieder anzulegen; verfällt nach
|
||||||
|
sieben Tagen. */
|
||||||
function removeDocLocally(d){
|
function removeDocLocally(d){
|
||||||
if(liveState && liveState.id === d.id) stopLive(); /* dito fürs Server-Dokument (D76) */
|
if(liveState && liveState.id === d.id) stopLive(); /* dito fürs Server-Dokument (D76) */
|
||||||
if(fileHandles.has(d.id)){ fileHandles.delete(d.id); idbDeleteHandle(d.id); } /* mit dem Dokument geht sein Datei-Handle (D72) */
|
if(fileHandles.has(d.id)){ fileHandles.delete(d.id); idbDeleteHandle(d.id); } /* mit dem Dokument geht sein Datei-Handle (D72) */
|
||||||
|
/* War das aktive Dokument ohnehin anderswo gelöscht, geht der Text sicher
|
||||||
|
in die lokalen Sicherungen (§6.5), bevor die Spur verschwindet. */
|
||||||
|
if(docGoneElsewhere && d.id === activeId) rescueSnapshot(d.id, src.value);
|
||||||
docs = docs.filter(x => x.id !== d.id);
|
docs = docs.filter(x => x.id !== d.id);
|
||||||
if(snaps[d.id]){ delete snaps[d.id]; persistSnaps(snaps, localStorage); } /* mit dem Dokument gehen seine Stände (D54) */
|
try{ removeDoc(localStorage, d.id, Date.now()); }catch(_){ noteStore(false); }
|
||||||
if(!docs.length) docs = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: INITIAL }];
|
docGoneElsewhere = false;
|
||||||
|
if(snaps[d.id]){ delete snaps[d.id]; } /* mit dem Dokument gehen seine Stände (D54); den Schlüssel räumt removeDoc */
|
||||||
|
if(!docs.length){
|
||||||
|
docs = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: INITIAL }];
|
||||||
|
/* Das letzte gelöschte Dokument wird als Beispiel neu gesät (D22) —
|
||||||
|
selbst wenn eine alte Löschung des Beispiels noch als Tombstone steht:
|
||||||
|
der Neu-Satz ist Absicht, der Tombstone fällt (typed, §6.2). */
|
||||||
|
try{ writeDoc(localStorage, docs[0], {typed: true}); }catch(_){ noteStore(false); }
|
||||||
|
}
|
||||||
/* Zeilen-Aktion (D81): Nur wenn das AKTIVE Dokument geht, wechselt der
|
/* Zeilen-Aktion (D81): Nur wenn das AKTIVE Dokument geht, wechselt der
|
||||||
Editor; das Menü bleibt offen — wer aufräumt, räumt meist weiter. */
|
Editor; das Menü bleibt offen — wer aufräumt, räumt meist weiter. */
|
||||||
if(d.id === activeId){
|
if(d.id === activeId){
|
||||||
@@ -5713,6 +6023,15 @@ async function idbHandleOp(mode, fn){
|
|||||||
}
|
}
|
||||||
function idbPutHandle(docId, handle){ return idbHandleOp('readwrite', s => { s.put(handle, docId); }); }
|
function idbPutHandle(docId, handle){ return idbHandleOp('readwrite', s => { s.put(handle, docId); }); }
|
||||||
function idbDeleteHandle(docId){ return idbHandleOp('readwrite', s => { s.delete(docId); }); }
|
function idbDeleteHandle(docId){ return idbHandleOp('readwrite', s => { s.delete(docId); }); }
|
||||||
|
/* Ein einzelnes Handle nachladen (RFC 002 §6.9): Ein anderes Fenster kennt
|
||||||
|
neue Dokumente nur aus dem storage-Ereignis — sein Handle wäre sonst nicht
|
||||||
|
in der Map, und ein Speichern hier käme mit dem Dialog und überschriebe
|
||||||
|
das gemerkte. */
|
||||||
|
function idbLoadHandle(docId){
|
||||||
|
return idbHandleOp('readonly', s => s.get(docId)).then(h => {
|
||||||
|
if(h && typeof h.createWritable === 'function') fileHandles.set(docId, h);
|
||||||
|
});
|
||||||
|
}
|
||||||
/* Beim Start die gemerkten Handles zurückholen — nur für Dokumente, die es
|
/* Beim Start die gemerkten Handles zurückholen — nur für Dokumente, die es
|
||||||
noch gibt (verwaiste Einträge räumen sich dabei weg), und nur, was sich wie
|
noch gibt (verwaiste Einträge räumen sich dabei weg), und nur, was sich wie
|
||||||
ein Handle verhält (defensiv gegen fremden Speicherinhalt). */
|
ein Handle verhält (defensiv gegen fremden Speicherinhalt). */
|
||||||
@@ -5750,6 +6069,7 @@ async function adoptFile(handle, name, text){
|
|||||||
activeId = d.id;
|
activeId = d.id;
|
||||||
}
|
}
|
||||||
if(handle){ fileHandles.set(activeId, handle); idbPutHandle(activeId, handle); }
|
if(handle){ fileHandles.set(activeId, handle); idbPutHandle(activeId, handle); }
|
||||||
|
dirtyDocs.add(activeId);
|
||||||
foldOverrides.clear();
|
foldOverrides.clear();
|
||||||
loadActiveIntoEditor();
|
loadActiveIntoEditor();
|
||||||
persistDocs();
|
persistDocs();
|
||||||
@@ -5895,7 +6215,10 @@ document.addEventListener('keydown', e => {
|
|||||||
if(!e.repeat) saveLocalFile();
|
if(!e.repeat) saveLocalFile();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
/* Dokumente laden + aktiven Text in den Editor holen (nach applyLang). */
|
/* Dokumente laden + aktiven Text in den Editor holen (nach applyLang).
|
||||||
|
§6.10 Schritt 1/5: lesen, Migration, Tombstones altern — schreiben tut nur
|
||||||
|
die Migration selbst (im Dirty-Flush von loadDocs); danach holt die Sperre
|
||||||
|
(beim Boot) die Entscheidung, beschreibbar oder Dialog. */
|
||||||
function initDocs(){
|
function initDocs(){
|
||||||
restoring = true;
|
restoring = true;
|
||||||
loadDocs();
|
loadDocs();
|
||||||
@@ -5904,7 +6227,6 @@ function initDocs(){
|
|||||||
src.value = d ? d.text : '';
|
src.value = d ? d.text : '';
|
||||||
snapBase = src.value;
|
snapBase = src.value;
|
||||||
restoring = false;
|
restoring = false;
|
||||||
persistDocs(); /* migrierte/geladene Liste festschreiben (stabil über Reload) */
|
|
||||||
updateDocName();
|
updateDocName();
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
@@ -5959,6 +6281,7 @@ function adoptRemote(s, text){
|
|||||||
else { d = {id: s.id, name: s.name, text}; docs.push(d); }
|
else { d = {id: s.id, name: s.name, text}; docs.push(d); }
|
||||||
d.source = s.source;
|
d.source = s.source;
|
||||||
activeId = s.id;
|
activeId = s.id;
|
||||||
|
dirtyDocs.add(s.id);
|
||||||
sourceWarning = null;
|
sourceWarning = null;
|
||||||
computeFresh(s.id, text); /* was ist seit dem letzten Ansehen in Produktion? (D28) */
|
computeFresh(s.id, text); /* was ist seit dem letzten Ansehen in Produktion? (D28) */
|
||||||
loadActiveIntoEditor();
|
loadActiveIntoEditor();
|
||||||
@@ -6115,6 +6438,7 @@ function adoptLive(doc){
|
|||||||
else { d = {id: liveState.id, name: doc.title || liveState.urls.doc, text: content}; docs.push(d); }
|
else { d = {id: liveState.id, name: doc.title || liveState.urls.doc, text: content}; docs.push(d); }
|
||||||
d.source = liveState.urls.doc;
|
d.source = liveState.urls.doc;
|
||||||
activeId = liveState.id;
|
activeId = liveState.id;
|
||||||
|
dirtyDocs.add(liveState.id);
|
||||||
sourceWarning = null;
|
sourceWarning = null;
|
||||||
computeFresh(liveState.id, content);
|
computeFresh(liveState.id, content);
|
||||||
loadActiveIntoEditor();
|
loadActiveIntoEditor();
|
||||||
@@ -6158,7 +6482,7 @@ function livePendingChanges(){
|
|||||||
function rescueSnapshot(docId, text){
|
function rescueSnapshot(docId, text){
|
||||||
if(!text || !text.trim()) return;
|
if(!text || !text.trim()) return;
|
||||||
if(addSnapshot(snaps, docId, text, Date.now(), {base: null, manual: true}))
|
if(addSnapshot(snaps, docId, text, Date.now(), {base: null, manual: true}))
|
||||||
persistSnaps(snaps, localStorage);
|
persistSnaps(localStorage, snaps, docId, Object.keys(localStorage));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Eine tote Sitzung heilt sich SELBST (Nutzer-Einwand zu D89: „Ohne Reload
|
/* Eine tote Sitzung heilt sich SELBST (Nutzer-Einwand zu D89: „Ohne Reload
|
||||||
@@ -6398,6 +6722,7 @@ function applyRenameEvents(feed){
|
|||||||
const d = docs.find(x => x.id === liveState.id);
|
const d = docs.find(x => x.id === liveState.id);
|
||||||
if(!d || d.name === ev.title) return;
|
if(!d || d.name === ev.title) return;
|
||||||
d.name = ev.title;
|
d.name = ev.title;
|
||||||
|
dirtyDocs.add(d.id);
|
||||||
persistDocs();
|
persistDocs();
|
||||||
updateDocName();
|
updateDocName();
|
||||||
if(!docMenu.hidden) renderDocMenu();
|
if(!docMenu.hidden) renderDocMenu();
|
||||||
@@ -6839,11 +7164,19 @@ let startLang = 'de';
|
|||||||
try{ startLang = localStorage.getItem('werkbaum-lang') || detectLang(); }catch(_){ startLang = detectLang(); }
|
try{ startLang = localStorage.getItem('werkbaum-lang') || detectLang(); }catch(_){ startLang = detectLang(); }
|
||||||
applyLang(I18N[startLang] ? startLang : 'de'); /* setzt Texte + rendert */
|
applyLang(I18N[startLang] ? startLang : 'de'); /* setzt Texte + rendert */
|
||||||
initDocs(); /* Dokumente laden + aktiven Text in den Editor (nach Sprache) */
|
initDocs(); /* Dokumente laden + aktiven Text in den Editor (nach Sprache) */
|
||||||
/* Flush-Punkte beim Verlassen (D82): Das Dokument-Array wird beim Tippen
|
/* Sperre für das wiederhergestellte aktive Dokument (RFC 002 §6.10 Schritt
|
||||||
nicht mehr geschrieben (nur der Spiegel) — hier holt es den Stand nach.
|
2/3): Ein nicht geteiltes Dokument, das ein anderes Fenster hält, öffnet
|
||||||
`pagehide` statt `beforeunload` (greift auch beim bfcache), dazu der
|
nicht still — der Dialog mit drei Auswegen steht vor dem ersten
|
||||||
verborgene Tab: Auf Mobil räumt der Browser Tabs oft ohne pagehide ab. */
|
Tastendruck; `live:` wird nie gesperrt (Server führt zusammen). */
|
||||||
addEventListener('pagehide', () => persistDocs());
|
acquireDocLock(activeId);
|
||||||
|
/* Flush-Punkte beim Verlassen (D82): Der Tastendruck schreibt nur den aktiven
|
||||||
|
Text — hier holt der Dirty-Flush den Rest nach. `pagehide` statt
|
||||||
|
`beforeunload` (greift auch beim bfcache), dazu der verborgene Tab: Auf
|
||||||
|
Mobil räumt der Browser Tabs oft ohne pagehide ab. */
|
||||||
|
addEventListener('pagehide', () => {
|
||||||
|
if(docGoneElsewhere && activeDoc()) rescueSnapshot(activeId, src.value);
|
||||||
|
persistDocs();
|
||||||
|
});
|
||||||
document.addEventListener('visibilitychange', () => { if(document.hidden) persistDocs(); });
|
document.addEventListener('visibilitychange', () => { if(document.hidden) persistDocs(); });
|
||||||
if(hasFsAccess) handlesReady = idbLoadHandles(); /* gemerkte Datei-Handles zurückholen (D72, Stufe 2) */
|
if(hasFsAccess) handlesReady = idbLoadHandles(); /* gemerkte Datei-Handles zurückholen (D72, Stufe 2) */
|
||||||
/* Erst mit den Handles wissen Speichern-Tooltip und Neu-laden-Knopf, ob das
|
/* Erst mit den Handles wissen Speichern-Tooltip und Neu-laden-Knopf, ob das
|
||||||
@@ -6873,6 +7206,11 @@ loadLive(); /* ?live= — Server-Dokument samt Feed (asynchron, D76)
|
|||||||
/* Beide haben ihren Parameter jetzt gelesen (synchron, vor dem ersten
|
/* Beide haben ihren Parameter jetzt gelesen (synchron, vor dem ersten
|
||||||
`await`). Ab hier folgt die Adresszeile dem aktiven Dokument (D80). */
|
`await`). Ab hier folgt die Adresszeile dem aktiven Dokument (D80). */
|
||||||
bootDone = true;
|
bootDone = true;
|
||||||
|
/* Nachziehen, was andere Fenster während des Starts geschrieben haben (§6.10
|
||||||
|
Schritt 4): Der Handler war registriert, hat aber nur gesammelt. */
|
||||||
|
for(const ev of storageBacklog.splice(0)){
|
||||||
|
try{ applyForeignStorageEvent(ev); }catch(_){}
|
||||||
|
}
|
||||||
refreshTaiga(); /* kann das Backend Taiga? (asynchron, D91) */
|
refreshTaiga(); /* kann das Backend Taiga? (asynchron, D91) */
|
||||||
|
|
||||||
/* ---------- PWA: Service Worker (D73) ----------
|
/* ---------- PWA: Service Worker (D73) ----------
|
||||||
@@ -7216,7 +7554,12 @@ function resetToDefaults(){
|
|||||||
/* Nicht-Dokument-Zustand auf Defaults (UI, Sprache, Update-Log) — die
|
/* Nicht-Dokument-Zustand auf Defaults (UI, Sprache, Update-Log) — die
|
||||||
Dokumentenliste (werkbaum-docs) bleibt erhalten (D22). Die beiden
|
Dokumentenliste (werkbaum-docs) bleibt erhalten (D22). Die beiden
|
||||||
Update-Schlüssel schreibt niemand mehr (D45); sie werden nur noch
|
Update-Schlüssel schreibt niemand mehr (D45); sie werden nur noch
|
||||||
aufgeräumt, falls sie aus einer früheren Fassung herumliegen. */
|
aufgeräumt, falls sie aus einer früheren Fassung herumliegen. Die
|
||||||
|
Tombstones fallen mit (RFC 002 §6.2): der Reset macht gelöschte
|
||||||
|
Dokumente wieder anlegbar. */
|
||||||
|
Object.keys(localStorage)
|
||||||
|
.filter(k => k.startsWith(DOC_GONE_PREFIX))
|
||||||
|
.forEach(k => { try{ localStorage.removeItem(k); }catch(_){} });
|
||||||
['werkbaum-ui','werkbaum-lang','werkbaum-html-hash','werkbaum-update-available','werkbaum-update-log','werkbaum-fs-notice']
|
['werkbaum-ui','werkbaum-lang','werkbaum-html-hash','werkbaum-update-available','werkbaum-update-log','werkbaum-fs-notice']
|
||||||
.forEach(k => { try{ localStorage.removeItem(k); }catch(_){} });
|
.forEach(k => { try{ localStorage.removeItem(k); }catch(_){} });
|
||||||
|
|
||||||
@@ -7228,6 +7571,7 @@ function resetToDefaults(){
|
|||||||
if(d){ d.text = text; d.name = name; }
|
if(d){ d.text = text; d.name = name; }
|
||||||
else if(vorn) docs.unshift({ id, name, text });
|
else if(vorn) docs.unshift({ id, name, text });
|
||||||
else docs.push({ id, name, text });
|
else docs.push({ id, name, text });
|
||||||
|
dirtyDocs.add(id);
|
||||||
};
|
};
|
||||||
reseed(EXAMPLE_ID, EXAMPLE_NAME, INITIAL, true);
|
reseed(EXAMPLE_ID, EXAMPLE_NAME, INITIAL, true);
|
||||||
reseed(WERKBAUM_ID, WERKBAUM_NAME, WERKBAUM_DOC, false);
|
reseed(WERKBAUM_ID, WERKBAUM_NAME, WERKBAUM_DOC, false);
|
||||||
|
|||||||
+216
-51
@@ -1,49 +1,133 @@
|
|||||||
/* Werkbaum — Ablageschema der Dokumente im localStorage (D83, headless).
|
/* Werkbaum — Ablageschema der Dokumente im localStorage (D83, v3 nach RFC 002,
|
||||||
|
headless).
|
||||||
|
|
||||||
Bis D82 lagen alle Dokumente als EIN JSON-Array unter einem Schlüssel —
|
Bis D82 lagen alle Dokumente als EIN JSON-Array unter einem Schlüssel —
|
||||||
mit zwei strukturellen Folgen: Bei voller Quota scheiterte jeder Flush
|
mit zwei strukturellen Folgen: Bei voller Quota scheiterte jeder Flush
|
||||||
ganz (auch die Änderung an einem winzigen Dokument muss das Ganze
|
ganz, und ein einziger kaputter Schlüssel kostete die Sicht auf ALLE
|
||||||
mitschreiben), und ein einziger kaputter Schlüssel kostete die Sicht auf
|
Dokumente. D83 teilte auf (Index + Text je Dokument) — behielt aber den
|
||||||
ALLE Dokumente. Deshalb die Aufteilung:
|
Voll-Flush, der den Index aus der EIGENEN Liste schrieb und jeden
|
||||||
|
fremden Text-Schlüssel entfernte. Zwei Fenster löschten sich damit
|
||||||
|
gegenseitig Dokumente und Stände weg (RFC 002, Befund 2 und 3). Schema
|
||||||
|
v3 zieht die Konsequenz — **kein Fenster schreibt je den Schlüssel eines
|
||||||
|
anderen**:
|
||||||
|
|
||||||
- `werkbaum-docs` — der INDEX: [{id, name, source?}], ohne Text.
|
- `werkbaum-doc:<id>` — der Text je Dokument (wie D83).
|
||||||
- `werkbaum-doc:<id>` — der Text je Dokument, ein eigener Schlüssel.
|
- `werkbaum-meta:<id>` — `{name, source?, born}` je Dokument (NEU):
|
||||||
- `werkbaum-src` — Spiegel des aktiven Texts (Rollback-Fallback:
|
Name und Quelle ohne den Index; `born` ordnet
|
||||||
ein älterer Build fällt auf ihn zurück).
|
Dokumente, die der Index nicht kennt.
|
||||||
|
- `werkbaum-snaps:<id>` — frühere Stände je Dokument (snapshots.js).
|
||||||
|
- `werkbaum-gone:<id>` — Tombstone der Löschung, Zeitstempel, Verfall
|
||||||
|
nach GONE_TTL (§6.2). „Gelöscht" ist damit von
|
||||||
|
„nie gesehen" zu unterscheiden.
|
||||||
|
- `werkbaum-docs` — der INDEX bleibt als **Reihenfolge-Hinweis**
|
||||||
|
und Rollback-Brücke; er löscht NIE mehr und
|
||||||
|
wird nur aus `Speicher-ids ∪ eigene Liste`
|
||||||
|
geschrieben.
|
||||||
|
- `werkbaum-src` — Spiegel des aktiven Texts (Rollback-Fallback).
|
||||||
|
|
||||||
Das ALTFORMAT (Texte im Array) wird beim Lesen erkannt und einmalig
|
|
||||||
aufgeteilt — der nächste Voll-Flush schreibt den Index ohne Texte.
|
|
||||||
Hausregel (D54-Nachtrag 3): Der Storage wird als {getItem, setItem,
|
Hausregel (D54-Nachtrag 3): Der Storage wird als {getItem, setItem,
|
||||||
removeItem} hereingereicht, damit die Tests ihn stellen können. */
|
removeItem} hereingereicht, damit die Tests ihn stellen können. Schlüssel-
|
||||||
|
Aufzählungen (Union-Lesen, Tombstone-Verfall) bekommen die Schlüsselliste
|
||||||
|
als Argument — im Browser `Object.keys(localStorage)`. */
|
||||||
|
|
||||||
export const LS_DOCS = 'werkbaum-docs';
|
export const LS_DOCS = 'werkbaum-docs';
|
||||||
export const LS_ACTIVE = 'werkbaum-active';
|
export const LS_ACTIVE = 'werkbaum-active';
|
||||||
export const LS_SRC = 'werkbaum-src';
|
export const LS_SRC = 'werkbaum-src';
|
||||||
export const DOC_TEXT_PREFIX = 'werkbaum-doc:';
|
export const DOC_TEXT_PREFIX = 'werkbaum-doc:';
|
||||||
|
export const DOC_META_PREFIX = 'werkbaum-meta:';
|
||||||
|
export const DOC_GONE_PREFIX = 'werkbaum-gone:';
|
||||||
|
export const DOC_SNAPS_PREFIX = 'werkbaum-snaps:';
|
||||||
|
/* Der alte Sammel-Schlüssel der Stände (D54–D83) — nur noch Migration. */
|
||||||
|
export const LEGACY_SNAPS = 'werkbaum-snaps';
|
||||||
|
|
||||||
|
/* Tombstones verfallen nach sieben Tagen (RFC 002 §6.2). */
|
||||||
|
export const GONE_TTL = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
export function docTextKey(id){ return DOC_TEXT_PREFIX + id; }
|
export function docTextKey(id){ return DOC_TEXT_PREFIX + id; }
|
||||||
|
export function docMetaKey(id){ return DOC_META_PREFIX + id; }
|
||||||
|
export function docGoneKey(id){ return DOC_GONE_PREFIX + id; }
|
||||||
|
|
||||||
/* Dokumentenliste lesen. Rückgabe {docs, legacy} — `legacy` heißt: Das
|
/* Meta eines Dokuments lesen — null, wenn kein brauchbares Objekt liegt. */
|
||||||
Altformat lag (ganz oder teilweise) vor, der nächste Voll-Flush schließt
|
export function readMeta(storage, id){
|
||||||
die Migration ab. `null`, wenn nichts Brauchbares da ist — dann greift der
|
let o = null;
|
||||||
Beispiel-/Migrations-Pfad des Aufrufers.
|
try{ o = JSON.parse(storage.getItem(docMetaKey(id)) || 'null'); }catch(_){ return null; }
|
||||||
|
if(!o || typeof o !== 'object' || Array.isArray(o)) return null;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasTombstone(storage, id){
|
||||||
|
return storage.getItem(docGoneKey(id)) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dokumentenliste lesen (v3). Rückgabe {docs, legacy} — `legacy` heißt: Das
|
||||||
|
Altformat (Texte im Index-Array) lag vor, der Spiegel-Vergleich des
|
||||||
|
Aufrufers entscheidet noch über das aktive Dokument. `null`, wenn nichts
|
||||||
|
Brauchbares da ist — dann greift der Beispiel-/Migrations-Pfad des
|
||||||
|
Aufrufers.
|
||||||
|
|
||||||
|
Menge der Dokumente = ids mit Meta- oder Text-Schlüssel, vereinigt mit dem
|
||||||
|
Index, minus ids mit Tombstone. Name aus Meta, sonst Index, sonst die id.
|
||||||
|
Reihenfolge: Index zuerst, danach Nachzügler nach `born`. Ein Index-Eintrag
|
||||||
|
ohne Text und ohne Meta ist ein Rest und wird ignoriert (§6.1).
|
||||||
|
|
||||||
Ein fehlender Text-Schlüssel ergibt einen LEEREN Text, kein Verwerfen des
|
Ein fehlender Text-Schlüssel ergibt einen LEEREN Text, kein Verwerfen des
|
||||||
Dokuments: Der Schaden bleibt auf das eine Dokument begrenzt — genau der
|
Dokuments: Der Schaden bleibt auf das eine Dokument begrenzt — genau der
|
||||||
Punkt der Aufteilung. */
|
Punkt der Aufteilung. */
|
||||||
export function readDocs(storage){
|
export function readDocs(storage, allKeys){
|
||||||
let arr = null;
|
const keys = Array.isArray(allKeys) ? allKeys : [];
|
||||||
try{ arr = JSON.parse(storage.getItem(LS_DOCS) || 'null'); }catch(_){ return null; }
|
/* Tombstones zuerst: gelöschte Dokumente tauchen nicht wieder auf. */
|
||||||
if(!Array.isArray(arr) || !arr.length) return null;
|
const weg = new Set();
|
||||||
if(!arr.every(d => d && typeof d.id === 'string')) return null;
|
for(const k of keys){
|
||||||
const legacy = arr.some(d => typeof d.text === 'string');
|
if(typeof k === 'string' && k.startsWith(DOC_GONE_PREFIX)) weg.add(k.slice(DOC_GONE_PREFIX.length));
|
||||||
const docs = arr.map(d => {
|
}
|
||||||
const out = { id: d.id, name: typeof d.name === 'string' ? d.name : d.id };
|
/* Der Index ist nur noch ein Hinweis — beschädigt oder leer heißt nicht
|
||||||
if(typeof d.source === 'string') out.source = d.source;
|
„nichts da", die Dokumente stehen an ihren eigenen Schlüsseln. */
|
||||||
if(typeof d.text === 'string'){
|
let index = null;
|
||||||
out.text = d.text; /* Altformat: Text im Array */
|
try{ index = JSON.parse(storage.getItem(LS_DOCS) || 'null'); }catch(_){}
|
||||||
|
const eintraege = Array.isArray(index)
|
||||||
|
? index.filter(d => d && typeof d.id === 'string' && !weg.has(d.id))
|
||||||
|
: [];
|
||||||
|
const legacy = eintraege.some(d => typeof d.text === 'string');
|
||||||
|
const ids = [];
|
||||||
|
const gesehen = new Set();
|
||||||
|
for(const e of eintraege){
|
||||||
|
/* Rest: weder Text noch Meta — ein Eintrag, der nichts mehr sagt. */
|
||||||
|
if(storage.getItem(docTextKey(e.id)) == null && typeof e.text !== 'string'
|
||||||
|
&& storage.getItem(docMetaKey(e.id)) == null) continue;
|
||||||
|
if(!gesehen.has(e.id)){ gesehen.add(e.id); ids.push(e.id); }
|
||||||
|
}
|
||||||
|
for(const k of keys){
|
||||||
|
if(typeof k !== 'string') continue;
|
||||||
|
let id = null;
|
||||||
|
if(k.startsWith(DOC_META_PREFIX)) id = k.slice(DOC_META_PREFIX.length);
|
||||||
|
else if(k.startsWith(DOC_TEXT_PREFIX)) id = k.slice(DOC_TEXT_PREFIX.length);
|
||||||
|
if(id && !gesehen.has(id) && !weg.has(id)){ gesehen.add(id); ids.push(id); }
|
||||||
|
}
|
||||||
|
if(!ids.length) return null;
|
||||||
|
const pos = new Map(eintraege.map((e, i) => [e.id, i]));
|
||||||
|
const metaFuer = new Map(ids.map(id => [id, readMeta(storage, id)]));
|
||||||
|
ids.sort((a, b) => {
|
||||||
|
const rang = id => {
|
||||||
|
if(pos.has(id)) return [0, pos.get(id), ''];
|
||||||
|
const meta = metaFuer.get(id);
|
||||||
|
return [1, meta && typeof meta.born === 'number' ? meta.born : Number.MAX_SAFE_INTEGER, id];
|
||||||
|
};
|
||||||
|
const ra = rang(a), rb = rang(b);
|
||||||
|
return ra[0] - rb[0] || ra[1] - rb[1] || (ra[2] < rb[2] ? -1 : ra[2] > rb[2] ? 1 : 0);
|
||||||
|
});
|
||||||
|
const indexNachId = new Map(eintraege.map(e => [e.id, e]));
|
||||||
|
const docs = ids.map(id => {
|
||||||
|
const e = indexNachId.get(id), meta = metaFuer.get(id);
|
||||||
|
const out = { id };
|
||||||
|
out.name = (meta && typeof meta.name === 'string' && meta.name)
|
||||||
|
|| (e && typeof e.name === 'string' && e.name) || id;
|
||||||
|
const source = (meta && typeof meta.source === 'string' && meta.source)
|
||||||
|
|| (e && typeof e.source === 'string' && e.source) || null;
|
||||||
|
if(source) out.source = source;
|
||||||
|
if(e && typeof e.text === 'string'){
|
||||||
|
out.text = e.text; /* Altformat: Text im Array */
|
||||||
} else {
|
} else {
|
||||||
const t = storage.getItem(docTextKey(d.id));
|
const t = storage.getItem(docTextKey(id));
|
||||||
out.text = t == null ? '' : String(t);
|
out.text = t == null ? '' : String(t);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -51,45 +135,126 @@ export function readDocs(storage){
|
|||||||
return {docs, legacy};
|
return {docs, legacy};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Voll-Flush: Index + Texte + verwaiste Text-Schlüssel entfernen.
|
/* Ein EINZELNES Dokument schreiben: Meta + Text, je unter eigenem Schlüssel.
|
||||||
|
Steht ein Tombstone, wird NICHTS geschrieben — außer `typed`: Der
|
||||||
|
Benutzer hat ausdrücklich getippt, der Tombstone fällt und das Dokument
|
||||||
|
wird neu angelegt (§6.2/§6.5). `born` bleibt erhalten: die bestehende Meta
|
||||||
|
gewinnt, beim ersten Anlegen zählt die Uhr.
|
||||||
|
|
||||||
Vor jedem Schreiben wird verglichen — unveränderte Schlüssel werden nicht
|
|
||||||
angefasst (sonst wäre die Write-Amplification des Ein-Schlüssel-Designs
|
|
||||||
nur verteilt statt behoben). `allKeys` sind die vorhandenen Schlüssel des
|
|
||||||
Storage (der Aufrufer reicht `Object.keys(localStorage)`): Texte
|
|
||||||
gelöschter Dokumente werden darüber abgeräumt, fremde Schlüssel bleiben.
|
|
||||||
Quota-Fehler werden NICHT gefangen — der Aufrufer meldet sie (D82). */
|
Quota-Fehler werden NICHT gefangen — der Aufrufer meldet sie (D82). */
|
||||||
export function storeDocs(storage, docs, allKeys){
|
export function writeDoc(storage, doc, opts){
|
||||||
const index = JSON.stringify(docs.map(d => {
|
if(!doc || typeof doc.id !== 'string' || !doc.id) return false;
|
||||||
const e = { id: d.id, name: d.name };
|
const {typed = false, now = Date.now()} = opts || {};
|
||||||
if(d.source) e.source = d.source;
|
const gk = docGoneKey(doc.id);
|
||||||
return e;
|
if(storage.getItem(gk) != null){
|
||||||
|
if(!typed) return false; /* gelöscht bleibt gelöscht (§6.2) */
|
||||||
|
storage.removeItem(gk);
|
||||||
|
}
|
||||||
|
const alt = readMeta(storage, doc.id) || {};
|
||||||
|
const meta = {name: typeof doc.name === 'string' ? doc.name : doc.id};
|
||||||
|
if(doc.source) meta.source = doc.source;
|
||||||
|
meta.born = typeof alt.born === 'number' ? alt.born
|
||||||
|
: typeof doc.born === 'number' ? doc.born : now;
|
||||||
|
const mk = docMetaKey(doc.id), metaJson = JSON.stringify(meta);
|
||||||
|
if(storage.getItem(mk) !== metaJson) storage.setItem(mk, metaJson);
|
||||||
|
const tk = docTextKey(doc.id);
|
||||||
|
if(storage.getItem(tk) !== doc.text) storage.setItem(tk, doc.text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Index-Hinweis: Reihenfolge fürs Menü und Rollback-Brücke für ältere
|
||||||
|
Builds. Er wird NIE aus einer eigenen In-Memory-Liste „aufgeräumt" —
|
||||||
|
schreiben, was da ist, entfernen, was nicht (§6.1). */
|
||||||
|
export function writeIndexHint(storage, eintraege){
|
||||||
|
const index = JSON.stringify((eintraege || []).map(e => {
|
||||||
|
const out = {id: e.id, name: e.name};
|
||||||
|
if(e.source) out.source = e.source;
|
||||||
|
return out;
|
||||||
}));
|
}));
|
||||||
if(storage.getItem(LS_DOCS) !== index) storage.setItem(LS_DOCS, index);
|
if(storage.getItem(LS_DOCS) !== index) storage.setItem(LS_DOCS, index);
|
||||||
for(const d of docs){
|
|
||||||
const k = docTextKey(d.id);
|
|
||||||
if(storage.getItem(k) !== d.text) storage.setItem(k, d.text);
|
|
||||||
}
|
}
|
||||||
const bleibt = new Set(docs.map(d => docTextKey(d.id)));
|
|
||||||
|
/* Löschen (und Verlassen): Text, Meta und Stände der id gehen, der Tombstone
|
||||||
|
kommt (§6.2). Er schützt die Sitzung des anderen Fensters vor dem stillen
|
||||||
|
Wiederanlegen; verfallen tut er nach GONE_TTL. */
|
||||||
|
export function removeDoc(storage, id, now){
|
||||||
|
storage.removeItem(docTextKey(id));
|
||||||
|
storage.removeItem(docMetaKey(id));
|
||||||
|
storage.removeItem(DOC_SNAPS_PREFIX + id);
|
||||||
|
storage.setItem(docGoneKey(id), String(now));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tombstones altern lassen (beim Laden abgeräumt, §6.2): abgelaufene — und
|
||||||
|
defekte — fallen weg, junge bleiben. */
|
||||||
|
export function expireTombstones(storage, allKeys, now){
|
||||||
for(const k of (allKeys || [])){
|
for(const k of (allKeys || [])){
|
||||||
if(k.startsWith(DOC_TEXT_PREFIX) && !bleibt.has(k)) storage.removeItem(k);
|
if(typeof k !== 'string' || !k.startsWith(DOC_GONE_PREFIX)) continue;
|
||||||
|
const ts = Number(storage.getItem(k));
|
||||||
|
if(!(ts > 0) || now - ts > GONE_TTL) storage.removeItem(k);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Gehört ein localStorage-Schlüssel zur Dokument-Ablage? Für die
|
/* Migration (einmalig beim Laden, idempotent, §6.1):
|
||||||
Fremd-Tab-Erkennung (D84): Das storage-Ereignis anderer Tabs nennt den
|
1. `werkbaum-snaps` (Sammel-Schlüssel, D54–D83) auf `werkbaum-snaps:<id>`
|
||||||
Schlüssel — nur Dokument-Schlüssel heißen „ein anderer Tab schreibt in
|
verteilen und entfernen — sonst wirft der Flush des einen Fensters die
|
||||||
dieselbe Liste". */
|
Stände des anderen weg (Befund 3).
|
||||||
|
2. Für Dokumente, die der Index kennt, aber keine Meta haben, die Meta aus
|
||||||
|
dem Index schreiben. Ein Rest ohne Text bleibt ohne Meta.
|
||||||
|
3. Altformat: Texte, die nur noch im Index-Array stehen, auf eigene
|
||||||
|
Schlüssel (der nächste flushDocs schreibt den Index ohne Texte).
|
||||||
|
Zwei Fenster, die das gleichzeitig tun, schreiben identische Werte —
|
||||||
|
harmlos. */
|
||||||
|
export function migrateV3(storage, now){
|
||||||
|
const alt = storage.getItem(LEGACY_SNAPS);
|
||||||
|
if(alt != null){
|
||||||
|
let o = null;
|
||||||
|
try{ o = JSON.parse(alt); }catch(_){ o = null; }
|
||||||
|
if(o && typeof o === 'object' && !Array.isArray(o)){
|
||||||
|
for(const id of Object.keys(o)){
|
||||||
|
const liste = Array.isArray(o[id])
|
||||||
|
? o[id].filter(s => s && typeof s.text === 'string' && typeof s.t === 'number')
|
||||||
|
: [];
|
||||||
|
const k = DOC_SNAPS_PREFIX + id;
|
||||||
|
if(liste.length && storage.getItem(k) == null) storage.setItem(k, JSON.stringify(liste));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
storage.removeItem(LEGACY_SNAPS);
|
||||||
|
}
|
||||||
|
let index = null;
|
||||||
|
try{ index = JSON.parse(storage.getItem(LS_DOCS) || 'null'); }catch(_){}
|
||||||
|
if(!Array.isArray(index)) return;
|
||||||
|
for(const e of index){
|
||||||
|
if(!e || typeof e.id !== 'string' || !e.id) continue;
|
||||||
|
const hatText = storage.getItem(docTextKey(e.id)) != null || typeof e.text === 'string';
|
||||||
|
if(!hatText) continue; /* Rest ohne Text ignorieren */
|
||||||
|
if(storage.getItem(docMetaKey(e.id)) == null){
|
||||||
|
const meta = {name: typeof e.name === 'string' ? e.name : e.id, born: now};
|
||||||
|
if(typeof e.source === 'string') meta.source = e.source;
|
||||||
|
storage.setItem(docMetaKey(e.id), JSON.stringify(meta));
|
||||||
|
}
|
||||||
|
if(typeof e.text === 'string' && storage.getItem(docTextKey(e.id)) == null){
|
||||||
|
storage.setItem(docTextKey(e.id), e.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gehört ein localStorage-Schlüssel zur Dokument-Ablage? Das storage-Ereignis
|
||||||
|
anderer Fenster nennt den Schlüssel — nur diese gehören zur Synchronisation
|
||||||
|
(docsync.js); die übrigen (Ansicht, Sprache, Merker) sind harmlos. */
|
||||||
export function isDocKey(k){
|
export function isDocKey(k){
|
||||||
const s = String(k == null ? '' : k);
|
const s = String(k == null ? '' : k);
|
||||||
return s === LS_DOCS || s === LS_ACTIVE || s === LS_SRC || s.startsWith(DOC_TEXT_PREFIX);
|
return s === LS_DOCS || s === LS_ACTIVE || s === LS_SRC || s === LEGACY_SNAPS
|
||||||
|
|| s.startsWith(DOC_TEXT_PREFIX) || s.startsWith(DOC_META_PREFIX)
|
||||||
|
|| s.startsWith(DOC_GONE_PREFIX) || s.startsWith(DOC_SNAPS_PREFIX);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Die Tastendruck-Hälfte: nur der Text EINES Dokuments plus der Spiegel.
|
/* Die Tastendruck-Hälfte: nur der Text EINES Dokuments plus der Spiegel.
|
||||||
Anders als bis D82 schreibt der Tastendruck damit direkt in die echte
|
Tippen ist Absicht (D55-Linie): Steht ein Tombstone, hebt dieser
|
||||||
Ablage — eine „Spiegel gewinnt"-Regel beim Laden braucht das neue Schema
|
Tastendruck ihn auf — das Dokument wird wieder angelegt (§6.5); die Meta
|
||||||
nur noch für die einmalige Migration aus dem Altformat. */
|
schreibt der Dirty-Flush des Aufrufers. */
|
||||||
export function storeDocText(storage, id, text){
|
export function storeDocText(storage, id, text){
|
||||||
|
const gk = docGoneKey(id);
|
||||||
|
if(storage.getItem(gk) != null) storage.removeItem(gk);
|
||||||
const k = docTextKey(id);
|
const k = docTextKey(id);
|
||||||
if(storage.getItem(k) !== text) storage.setItem(k, text);
|
if(storage.getItem(k) !== text) storage.setItem(k, text);
|
||||||
if(storage.getItem(LS_SRC) !== text) storage.setItem(LS_SRC, text);
|
if(storage.getItem(LS_SRC) !== text) storage.setItem(LS_SRC, text);
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/* docsync — Nachziehen im laufenden Fenster (RFC 002 §6.3, headless).
|
||||||
|
|
||||||
|
Zwei Fenster teilen sich einen Ursprung und damit den Speicher; das
|
||||||
|
`storage`-Ereignis feuert nur in den FREMDEN Fenstern und nennt Schlüssel,
|
||||||
|
alten und neuen Wert — für Liste, Namen und Tombstones die vollständige
|
||||||
|
Information. Diese Funktion entscheidet rein, was ein Ereignis bedeutet;
|
||||||
|
app.js wendet das Ergebnis an (Menü zeichnen, Chip, Warnung).
|
||||||
|
|
||||||
|
Heikel sind die Fälle am AKTIVEN Dokument (§5.3):
|
||||||
|
- anderswo umbenannt → übernehmen, der Chip folgt (Text unberührt);
|
||||||
|
- anderswo gelöscht → behalten, bis getippt wird — Tippen ist Absicht
|
||||||
|
(D55-Linie); sofortiges Wegschalten zöge Text unter der Schreibmarke weg;
|
||||||
|
- anderswo Text geschrieben → nicht nachziehen (es überschriebe das
|
||||||
|
Getippte), sondern warnen (§6.6) — der Restfall „dasselbe nicht geteilte
|
||||||
|
Dokument in beiden Fenstern". Bei `live:` ist der Feed die Quelle:
|
||||||
|
nichts tun.
|
||||||
|
|
||||||
|
headless (D54-Nachtrag 3): keine DOM-, keine Speicher-Zugriffe — das
|
||||||
|
Ereignis kommt als Datenobjekt herein, die Liste als Datenarray. */
|
||||||
|
|
||||||
|
import { LS_DOCS, DOC_TEXT_PREFIX, DOC_META_PREFIX, DOC_GONE_PREFIX,
|
||||||
|
DOC_SNAPS_PREFIX, GONE_TTL } from './docstore.js';
|
||||||
|
|
||||||
|
export { GONE_TTL };
|
||||||
|
|
||||||
|
/* Geteilte Dokumente (`live:`) werden NIE gesperrt: Zwei Fenster sind zwei
|
||||||
|
Live-Clients mit eigener Kennung, der Server führt zusammen (D76) — genau
|
||||||
|
der Fall, den der alte D89-Dialog verbot, obwohl er funktioniert. Nicht
|
||||||
|
„lokal", sondern „nicht `live:`" ist das Kriterium (§5.4): URL- und
|
||||||
|
Datei-Dokumente sind ebenso im Browser wahr. */
|
||||||
|
export function lockDecision(id){
|
||||||
|
return !String(id == null ? '' : id).startsWith('live:');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Name der Sperre je Dokument (§6.4). */
|
||||||
|
export function lockName(id){ return 'werkbaum-doc:' + String(id); }
|
||||||
|
|
||||||
|
/* Ist ein Tombstone verfallen? Ohne brauchbaren Zeitstempel gilt er als
|
||||||
|
verfallen — ein Sicherheitsnetz darf nicht zum Datenfriedhof werden. */
|
||||||
|
export function tombstoneExpired(ts, now){
|
||||||
|
return !(typeof ts === 'number' && ts > 0) || (now - ts) > GONE_TTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dirty-Menge (§6.1): was DIESSES Fenster angelegt, umbenannt, getippt hat.
|
||||||
|
Die Flush-Punkte schreiben nur diese Dokumente plus den Index-Hinweis —
|
||||||
|
kein Voll-Flush mehr, der fremde Schlüssel anfassen könnte. */
|
||||||
|
export function createDirtySet(){
|
||||||
|
const s = new Set();
|
||||||
|
return {
|
||||||
|
add(id){ if(id) s.add(String(id)); },
|
||||||
|
has(id){ return s.has(String(id)); },
|
||||||
|
entries(){ return [...s]; },
|
||||||
|
clear(){ s.clear(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ein storage-Ereignis auf die Dokumentenliste anwenden.
|
||||||
|
Rückgabe `{docs, action, id}`: `docs` ist DASSELBE Array, wenn nichts an
|
||||||
|
der Liste geändert hat (Mutationen am Eintrag — Umbenennen — laufen auf dem
|
||||||
|
übergebenen Objekt), sonst ein neues. `action` sagt, was am AKTIVEN
|
||||||
|
Dokument zu tun ist:
|
||||||
|
|
||||||
|
- 'created' — meta neu: Dokument hängt in der Liste, Text vom
|
||||||
|
Aufrufer lazy aus dem Speicher lesen
|
||||||
|
- 'renamed' — Name/Quelle des aktiven Dokuments übernommen (Chip)
|
||||||
|
- 'deleted' — aktives Dokument wurde anderswo gelöscht: BEHALTEN,
|
||||||
|
warnfarben markieren (§6.5), bis getippt wird
|
||||||
|
- 'foreignWrite' — das aktive nicht-`live:`-Dokument wurde anderswo
|
||||||
|
geschrieben: der Restfall, Warnung (§6.6)
|
||||||
|
- 'snaps' — Stände-Cache der id verwerfen (§6.3)
|
||||||
|
- 'order' — Reihenfolge-Hinweis übernommen
|
||||||
|
- null — nichts am aktiven Dokument */
|
||||||
|
export function applyStorageEvent(docs, activeId, ev){
|
||||||
|
const key = String((ev && ev.key) || '');
|
||||||
|
const neu = ev ? ev.newValue : null;
|
||||||
|
if(key.startsWith(DOC_META_PREFIX)){
|
||||||
|
const id = key.slice(DOC_META_PREFIX.length);
|
||||||
|
if(!id || neu == null) return {docs, action: null, id: null};
|
||||||
|
let meta = null;
|
||||||
|
try{ meta = JSON.parse(neu); }catch(_){ meta = null; }
|
||||||
|
if(!meta || typeof meta !== 'object' || Array.isArray(meta)) return {docs, action: null, id};
|
||||||
|
const d = docs.find(x => x.id === id);
|
||||||
|
if(!d){
|
||||||
|
const aus = {id, name: typeof meta.name === 'string' && meta.name ? meta.name : id, text: ''};
|
||||||
|
if(typeof meta.source === 'string' && meta.source) aus.source = meta.source;
|
||||||
|
return {docs: docs.concat([aus]), action: 'created', id};
|
||||||
|
}
|
||||||
|
let geaendert = false;
|
||||||
|
if(typeof meta.name === 'string' && meta.name && d.name !== meta.name){ d.name = meta.name; geaendert = true; }
|
||||||
|
if(typeof meta.source === 'string' && meta.source && d.source !== meta.source){ d.source = meta.source; geaendert = true; }
|
||||||
|
return {docs, action: geaendert && id === activeId ? 'renamed' : null, id};
|
||||||
|
}
|
||||||
|
if(key.startsWith(DOC_GONE_PREFIX)){
|
||||||
|
const id = key.slice(DOC_GONE_PREFIX.length);
|
||||||
|
if(!id || neu == null) return {docs, action: null, id}; /* Tombstone aufgehoben — das meta-Ereignis folgt */
|
||||||
|
if(!docs.some(x => x.id === id)) return {docs, action: null, id};
|
||||||
|
if(id === activeId) return {docs, action: 'deleted', id}; /* behalten, warnfarben (§6.5) */
|
||||||
|
return {docs: docs.filter(x => x.id !== id), action: null, id};
|
||||||
|
}
|
||||||
|
if(key.startsWith(DOC_TEXT_PREFIX)){
|
||||||
|
const id = key.slice(DOC_TEXT_PREFIX.length);
|
||||||
|
if(!id || neu == null) return {docs, action: null, id}; /* Entfernen kommt als gone-Ereignis */
|
||||||
|
const d = docs.find(x => x.id === id);
|
||||||
|
if(!d) return {docs, action: null, id}; /* das meta-Ereignis legt ihn an */
|
||||||
|
if(id === activeId){
|
||||||
|
return lockDecision(id) ? {docs, action: 'foreignWrite', id}
|
||||||
|
: {docs, action: null, id}; /* der Feed ist die Quelle */
|
||||||
|
}
|
||||||
|
d.text = neu; /* Vorschau nachziehen */
|
||||||
|
return {docs, action: null, id};
|
||||||
|
}
|
||||||
|
if(key.startsWith(DOC_SNAPS_PREFIX)){
|
||||||
|
return {docs, action: 'snaps', id: key.slice(DOC_SNAPS_PREFIX.length)};
|
||||||
|
}
|
||||||
|
if(key === LS_DOCS){
|
||||||
|
/* Reihenfolge-Hinweis (§6.1): übernehmen, wo er etwas sagt. Bekannte ids
|
||||||
|
rücken in seine Reihenfolge, unbekannte bleiben hinten — in ihrer
|
||||||
|
bisherigen Ordnung. */
|
||||||
|
let arr = null;
|
||||||
|
try{ arr = JSON.parse(neu || 'null'); }catch(_){ arr = null; }
|
||||||
|
if(!Array.isArray(arr)) return {docs, action: null, id: null};
|
||||||
|
const pos = new Map();
|
||||||
|
arr.forEach((e, i) => { if(e && typeof e.id === 'string') pos.set(e.id, i); });
|
||||||
|
if(!pos.size) return {docs, action: null, id: null};
|
||||||
|
const geordnet = docs.slice().sort((a, b) => {
|
||||||
|
const ra = pos.has(a.id) ? pos.get(a.id) : arr.length + docs.indexOf(a);
|
||||||
|
const rb = pos.has(b.id) ? pos.get(b.id) : arr.length + docs.indexOf(b);
|
||||||
|
return ra - rb;
|
||||||
|
});
|
||||||
|
const gleich = geordnet.every((d, i) => d === docs[i]);
|
||||||
|
return {docs: gleich ? docs : geordnet, action: gleich ? null : 'order', id: null};
|
||||||
|
}
|
||||||
|
/* werkbaum-active, werkbaum-src, Ansicht, Merker — letzter Schreiber
|
||||||
|
gewinnt, harmlos (§3). */
|
||||||
|
return {docs, action: null, id: null};
|
||||||
|
}
|
||||||
+67
-24
@@ -6,18 +6,29 @@
|
|||||||
dafür ist reine Logik — sie hing nur an `localStorage`, `Date.now()` und
|
dafür ist reine Logik — sie hing nur an `localStorage`, `Date.now()` und
|
||||||
dem `<textarea>` fest und war deshalb nicht prüfbar.
|
dem `<textarea>` fest und war deshalb nicht prüfbar.
|
||||||
|
|
||||||
|
Seit RFC 002 (Schema v3) liegen die Stände je Dokument unter einem eigenen
|
||||||
|
Schlüssel `werkbaum-snaps:<id>` — der alte Sammel-Schlüssel ließ jeden
|
||||||
|
Voll-Flush die Stände des anderen Fensters wegwerfen, die Rettungs-
|
||||||
|
Sicherungen eingeschlossen (Befund 3). Nur der Quota-Notfall fasst noch
|
||||||
|
einen fremden Schlüssel an (§6.8, bewusst so belassen: Dokumente gehen
|
||||||
|
vor Ständen, D54).
|
||||||
|
|
||||||
Aufteilung wie bei den übrigen Modulen: Hier steht, WAS gilt; in
|
Aufteilung wie bei den übrigen Modulen: Hier steht, WAS gilt; in
|
||||||
app.js bleibt, WOHER die Werte kommen (aktives Dokument, Schreibschutz,
|
app.js bleibt, WOHER die Werte kommen (aktives Dokument, Schreibschutz,
|
||||||
Menü) und WOHIN sie gehen (`localStorage`). Der Speicher wird als
|
Menü) und WOHIN sie gehen (`localStorage`). Der Speicher wird als
|
||||||
`{setItem, removeItem}` hereingereicht, die Uhr als Zahl — beides lässt sich
|
`{getItem, setItem, removeItem}` hereingereicht, die Schlüsselliste dazu
|
||||||
im Test stellen.
|
(im Browser `Object.keys(localStorage)`) — beides lässt sich im Test
|
||||||
|
stellen.
|
||||||
|
|
||||||
Datenform: `{docId: [{t, text}, …]}`, ältester Stand zuerst. */
|
Datenform: je Schlüssel eine Liste `[{t, text}, …]`, ältester Stand zuerst. */
|
||||||
|
|
||||||
|
import { DOC_SNAPS_PREFIX } from './docstore.js';
|
||||||
|
|
||||||
export const LS_SNAPS = 'werkbaum-snaps';
|
|
||||||
export const SNAP_EVERY = 10 * 60 * 1000;
|
export const SNAP_EVERY = 10 * 60 * 1000;
|
||||||
export const SNAP_KEEP = 20;
|
export const SNAP_KEEP = 20;
|
||||||
|
|
||||||
|
export function snapKey(id){ return DOC_SNAPS_PREFIX + id; }
|
||||||
|
|
||||||
/* Was aus dem Speicher kommt, ist fremder Text: Es kann von einer älteren
|
/* Was aus dem Speicher kommt, ist fremder Text: Es kann von einer älteren
|
||||||
Fassung stammen, von Hand bearbeitet oder halb geschrieben sein. Alles, was
|
Fassung stammen, von Hand bearbeitet oder halb geschrieben sein. Alles, was
|
||||||
nicht die erwartete Form hat, fällt weg statt später beim Lesen zu
|
nicht die erwartete Form hat, fällt weg statt später beim Lesen zu
|
||||||
@@ -25,15 +36,25 @@ export const SNAP_KEEP = 20;
|
|||||||
umbringen. */
|
umbringen. */
|
||||||
export function parseSnaps(raw){
|
export function parseSnaps(raw){
|
||||||
let o;
|
let o;
|
||||||
try{ o = JSON.parse(raw || '{}'); }
|
try{ o = JSON.parse(raw || 'null'); }
|
||||||
catch(_){ return {}; }
|
catch(_){ return []; }
|
||||||
if(!o || typeof o !== 'object' || Array.isArray(o)) return {};
|
if(!Array.isArray(o)) return [];
|
||||||
|
return o.filter(s => s && typeof s.text === 'string' && typeof s.t === 'number');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die Stände EINES Dokuments aus dem Speicher lesen. */
|
||||||
|
export function readSnapList(storage, id){
|
||||||
|
return parseSnaps(storage.getItem(snapKey(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alle Stände des Speichers lesen — beim Laden, aus den Schlüsseln. Ein
|
||||||
|
Schlüssel ohne brauchbare Liste fällt still weg. */
|
||||||
|
export function readAllSnaps(storage, allKeys){
|
||||||
const out = {};
|
const out = {};
|
||||||
for(const id of Object.keys(o)){
|
for(const k of (allKeys || [])){
|
||||||
const list = o[id];
|
if(typeof k !== 'string' || !k.startsWith(DOC_SNAPS_PREFIX)) continue;
|
||||||
if(!Array.isArray(list)) continue;
|
const liste = parseSnaps(storage.getItem(k));
|
||||||
const rein = list.filter(s => s && typeof s.text === 'string' && typeof s.t === 'number');
|
if(liste.length) out[k.slice(DOC_SNAPS_PREFIX.length)] = liste;
|
||||||
if(rein.length) out[id] = rein;
|
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -60,32 +81,54 @@ export function addSnapshot(snaps, id, text, now, opts){
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Wirft den ältesten Stand **über alle Dokumente hinweg** weg und sagt, ob
|
/* Wirft den ältesten Stand **über alle Dokumente hinweg** weg und sagt, ob
|
||||||
noch einer da war. Über alle, nicht nur im aktiven Dokument: Wenn der Platz
|
noch einer da war — im GEDÄCHTNIS; den Speicher pflegt persistSnaps.
|
||||||
knapp wird, ist das Älteste das Entbehrlichste, gleich zu welchem Dokument
|
Über alle, nicht nur im aktiven Dokument: Wenn der Platz knapp wird, ist
|
||||||
es gehört. */
|
das Älteste das Entbehrlichste, gleich zu welchem Dokument es gehört. */
|
||||||
export function dropOldestSnap(snaps){
|
export function dropOldestSnap(snaps){
|
||||||
let id = null, t = Infinity;
|
let id = null, t = Infinity;
|
||||||
for(const k in snaps){
|
for(const k in snaps){
|
||||||
const l = snaps[k];
|
const l = snaps[k];
|
||||||
if(l && l.length && l[0].t < t){ t = l[0].t; id = k; }
|
if(l && l.length && l[0].t < t){ t = l[0].t; id = k; }
|
||||||
}
|
}
|
||||||
if(id === null) return false;
|
if(id === null) return null;
|
||||||
snaps[id].shift();
|
snaps[id].shift();
|
||||||
if(!snaps[id].length) delete snaps[id];
|
if(!snaps[id].length) delete snaps[id];
|
||||||
return true;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Der Platz im Speicher ist geteilt. Läuft er über, sollen die **Dokumente**
|
/* Die Stände eines Dokuments schreiben. Der Platz im Speicher ist geteilt —
|
||||||
überleben, nicht ihre Stände — deshalb wirft der Fehlerfall Stände weg, bis
|
läuft er über, sollen die **Dokumente** überleben, nicht ihre Stände (D54):
|
||||||
es passt, notfalls alle. Gibt zurück, ob am Ende etwas gespeichert wurde. */
|
Der älteste Stand geht zuerst, dokumentübergreifend. Dabei wird ein fremder
|
||||||
export function persistSnaps(snaps, store){
|
Schlüssel nur im Notfall angefasst — und dann lesend geändert, nie aus dem
|
||||||
|
eigenen Gedächtnis überschrieben (§6.8). Gibt zurück, ob etwas gespeichert
|
||||||
|
wurde. */
|
||||||
|
export function persistSnaps(storage, snaps, id, allKeys){
|
||||||
for(;;){
|
for(;;){
|
||||||
try{ store.setItem(LS_SNAPS, JSON.stringify(snaps)); return true; }
|
try{ storage.setItem(snapKey(id), JSON.stringify(snaps[id] || [])); return true; }
|
||||||
catch(_){
|
catch(_){
|
||||||
if(!dropOldestSnap(snaps)){
|
const opfer = dropOldestSnap(snaps);
|
||||||
try{ store.removeItem(LS_SNAPS); }catch(_){}
|
if(opfer === null){
|
||||||
|
/* Nichts mehr da — den eigenen Schlüssel frei geben. */
|
||||||
|
delete snaps[id];
|
||||||
|
try{ storage.removeItem(snapKey(id)); }catch(_){}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if(opfer !== id){
|
||||||
|
/* Der älteste Stand lag bei einem fremden Dokument — dessen Schlüssel
|
||||||
|
im Speicher nachziehen (lesen, kürzen, schreiben), den Cache hier
|
||||||
|
verwerfen; beim nächsten Öffnen des Menüs frisch gelesen. */
|
||||||
|
const fremd = parseSnaps(storage.getItem(snapKey(opfer)));
|
||||||
|
fremd.shift();
|
||||||
|
try{
|
||||||
|
if(fremd.length) storage.setItem(snapKey(opfer), JSON.stringify(fremd));
|
||||||
|
else storage.removeItem(snapKey(opfer));
|
||||||
|
}catch(__){
|
||||||
|
try{ storage.removeItem(snapKey(opfer)); }catch(___){}
|
||||||
|
}
|
||||||
|
delete snaps[opfer];
|
||||||
|
}
|
||||||
|
/* Eigener Verlust: die Liste schrumpfte im Gedächtnis mit — der nächste
|
||||||
|
Versuch schreibt weniger. */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-7
@@ -1701,9 +1701,10 @@
|
|||||||
.docpick[aria-checked="true"] .docitem-where{ color: inherit; opacity: .75; }
|
.docpick[aria-checked="true"] .docitem-where{ color: inherit; opacity: .75; }
|
||||||
|
|
||||||
/* ---------- Zwei-Fenster-Wächter (D89) ----------
|
/* ---------- Zwei-Fenster-Wächter (D89) ----------
|
||||||
Modaler Dialog, solange ein zweites lebendes Werkbaum-Fenster desselben
|
Der modale Dialog von D89 ist weg (RFC 002/D94): Er verbot ausgerechnet
|
||||||
Browsers nachweisbar ist (Herzschlag); schließt sich von selbst, sobald
|
den Fall, der funktioniert, und schützte nicht vor dem, wovor er warnte.
|
||||||
das andere Fenster zu ist. Über allem (Konflikt-Band liegt bei 1200). */
|
Übrig bleibt der Sperren-Dialog (unten) — die tabmodal-Klassen tragen
|
||||||
|
weiter die Taiga-Dialoge. */
|
||||||
.tabmodal-overlay{
|
.tabmodal-overlay{
|
||||||
position:fixed;inset:0;z-index:1300;
|
position:fixed;inset:0;z-index:1300;
|
||||||
background:rgba(36,52,71,.55);
|
background:rgba(36,52,71,.55);
|
||||||
@@ -1715,12 +1716,42 @@
|
|||||||
}
|
}
|
||||||
.tabmodal h2{margin:0 0 10px;font-size:1.05rem;color:var(--line)}
|
.tabmodal h2{margin:0 0 10px;font-size:1.05rem;color:var(--line)}
|
||||||
.tabmodal p{margin:0 0 14px;line-height:1.45}
|
.tabmodal p{margin:0 0 14px;line-height:1.45}
|
||||||
.tabmodal-force{
|
|
||||||
background:none;border:none;padding:0;cursor:pointer;
|
|
||||||
color:var(--muted);text-decoration:underline;font:inherit;font-size:.85em;
|
|
||||||
}
|
|
||||||
@media print{ .tabmodal-overlay{ display:none; } }
|
@media print{ .tabmodal-overlay{ display:none; } }
|
||||||
|
|
||||||
|
/* ---------- Sperren-Dialog (RFC 002/D94, §6.5) ----------
|
||||||
|
Erscheint nur im Restfall — dasselbe nicht geteilte Dokument in beiden
|
||||||
|
Fenstern. Er liegt über dem Editor, nicht über allem: Diagramm und
|
||||||
|
Dokumenten-Menü bleiben bedienbar. */
|
||||||
|
.src-wrap{position:relative}
|
||||||
|
.lockdlg-overlay{
|
||||||
|
position:absolute;inset:0;z-index:70;
|
||||||
|
display:flex;align-items:center;justify-content:center;padding:16px;
|
||||||
|
background:rgba(36,52,71,.35);
|
||||||
|
}
|
||||||
|
.lockdlg{
|
||||||
|
background:var(--paper);border-radius:10px;max-width:420px;width:100%;
|
||||||
|
padding:18px 20px;box-shadow:0 12px 40px rgba(0,0,0,.35);
|
||||||
|
}
|
||||||
|
.lockdlg h2{margin:0 0 10px;font-size:1.05rem;color:var(--line)}
|
||||||
|
.lockdlg p{margin:0 0 12px;line-height:1.45}
|
||||||
|
.lockdlg-listhead{font-size:.78rem;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin:0 0 6px}
|
||||||
|
.lockdlg-list{display:flex;flex-direction:column;gap:4px;max-height:9rem;overflow-y:auto;margin:0 0 14px}
|
||||||
|
.lockdlg-doc{
|
||||||
|
text-align:left;padding:6px 10px;font:inherit;cursor:pointer;
|
||||||
|
border:1px solid rgba(36,52,71,.3);border-radius:7px;background:none;color:var(--ink);
|
||||||
|
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||||||
|
}
|
||||||
|
.lockdlg-doc:hover{border-color:var(--or)}
|
||||||
|
.lockdlg-btns{display:flex;justify-content:flex-end;gap:10px;flex-wrap:wrap}
|
||||||
|
.lockdlg-btns button{
|
||||||
|
padding:7px 14px;border-radius:7px;font:inherit;cursor:pointer;
|
||||||
|
border:1px solid rgba(36,52,71,.3);background:none;color:var(--ink);
|
||||||
|
}
|
||||||
|
.lockdlg-edit{border-color:var(--warn)!important;color:var(--warn)!important}
|
||||||
|
@media print{ .lockdlg-overlay{ display:none; } }
|
||||||
|
/* „Nur ansehen" (§6.5 Ausweg 2): das Textfeld trägt seinen Zustand. */
|
||||||
|
#src[readonly]{background:rgba(107,122,140,.08);color:var(--muted);cursor:default}
|
||||||
|
|
||||||
/* ---------- Taiga-Anlege- und Anmeldedialog (D91) ----------
|
/* ---------- Taiga-Anlege- und Anmeldedialog (D91) ----------
|
||||||
Im tabmodal-Idiom (Overlay + Karte); nur die Innereien sind eigen. */
|
Im tabmodal-Idiom (Overlay + Karte); nur die Innereien sind eigen. */
|
||||||
.taigadlg .taiga-subject{margin:0 0 12px;font-weight:600}
|
.taigadlg .taiga-subject{margin:0 0 12px;font-weight:600}
|
||||||
|
|||||||
@@ -52,10 +52,14 @@
|
|||||||
droht beim Neuladen; zeilenlos, steht
|
droht beim Neuladen; zeilenlos, steht
|
||||||
zuoberst und verschwindet, sobald ein
|
zuoberst und verschwindet, sobald ein
|
||||||
Schreiben wieder gelingt
|
Schreiben wieder gelingt
|
||||||
- tabConflict { } — ein weiterer Tab schreibt in dieselbe
|
- tabConflict { name, where } — dasselbe nicht geteilte Dokument wird
|
||||||
Dokument-Ablage (D84): der letzte Flush
|
in einem anderen Fenster bearbeitet
|
||||||
gewinnt; zeilenlos, bleibt bis zum
|
(RFC 002/D94, der Restfall): der letzte
|
||||||
Neuladen stehen
|
Tastendruck gewinnt; zeilenlos, bleibt
|
||||||
|
stehen, solange das Dokument aktiv ist.
|
||||||
|
`where` ist ein i18n-Schlüssel für
|
||||||
|
„in der App" / „im Browser" — aus der
|
||||||
|
Sicht des eigenen Fensters gefolgert
|
||||||
- liveUnsent { min } — Änderungen an einem Server-Dokument seit
|
- liveUnsent { min } — Änderungen an einem Server-Dokument seit
|
||||||
{min} Minuten nicht angekommen (D89):
|
{min} Minuten nicht angekommen (D89):
|
||||||
sie existieren nur in diesem Fenster;
|
sie existieren nur in diesem Fenster;
|
||||||
@@ -117,7 +121,7 @@ function build(w, t, esc){
|
|||||||
case 'storeFailed':
|
case 'storeFailed':
|
||||||
return t('storeFailedWarn');
|
return t('storeFailedWarn');
|
||||||
case 'tabConflict':
|
case 'tabConflict':
|
||||||
return t('tabConflictWarn');
|
return t('tabConflictWarn', {name: esc(w.name || ''), where: t(w.where || 'windowKindBrowser')});
|
||||||
case 'liveUnsent':
|
case 'liveUnsent':
|
||||||
return t('liveUnsentWarn', {min: w.min});
|
return t('liveUnsentWarn', {min: w.min});
|
||||||
case 'liveEnded':
|
case 'liveEnded':
|
||||||
|
|||||||
+231
-66
@@ -1,119 +1,284 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { readDocs, storeDocs, storeDocText, docTextKey, isDocKey,
|
import { readDocs, writeDoc, writeIndexHint, removeDoc, expireTombstones,
|
||||||
LS_DOCS, LS_SRC, DOC_TEXT_PREFIX } from '../src/docstore.js';
|
migrateV3, storeDocText, readMeta, hasTombstone, docTextKey, docMetaKey,
|
||||||
|
docGoneKey, isDocKey, GONE_TTL,
|
||||||
|
LS_DOCS, LS_SRC, DOC_TEXT_PREFIX, DOC_META_PREFIX, DOC_GONE_PREFIX,
|
||||||
|
DOC_SNAPS_PREFIX, LEGACY_SNAPS } from '../src/docstore.js';
|
||||||
|
|
||||||
/* Storage-Attrappe mit Zähler — die Vergleich-vor-Schreiben-Regel ist nur
|
/* Storage-Attrappe mit ZÄHLERN JE SCHLÜSSEL — die Hausregel des Schemas v3
|
||||||
über die Zahl der setItem-Aufrufe zu beweisen. */
|
(„kein Fenster schreibt je den Schlüssel eines anderen", RFC 002 §6.1) ist
|
||||||
|
nur über die Zahl der Zugriffe je Schlüssel zu beweisen. Fiele der alte
|
||||||
|
Orphan-Sweep des D83-Voll-Flushes zurück, fiele GENAU dieser Test. */
|
||||||
function mem(init){
|
function mem(init){
|
||||||
const m = new Map(Object.entries(init || {}));
|
const m = new Map(Object.entries(init || {}));
|
||||||
let writes = 0;
|
const schreib = new Map(), loesch = new Map();
|
||||||
return {
|
return {
|
||||||
getItem: k => (m.has(k) ? m.get(k) : null),
|
getItem: k => (m.has(k) ? m.get(k) : null),
|
||||||
setItem: (k, v) => { writes++; m.set(k, String(v)); },
|
setItem: (k, v) => { schreib.set(k, (schreib.get(k) || 0) + 1); m.set(k, String(v)); },
|
||||||
removeItem: k => { m.delete(k); },
|
removeItem: k => { schreib.set(k, (schreib.get(k) || 0) - 1); m.delete(k); },
|
||||||
keys: () => [...m.keys()],
|
keys: () => [...m.keys()],
|
||||||
writes: () => writes,
|
schreibFuer: k => schreib.get(k) || 0,
|
||||||
|
schreibGesamt: () => [...schreib.values()].reduce((a, b) => a + Math.abs(b), 0),
|
||||||
|
map: m,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const A = {id: 'example', name: 'Example', text: '- a'};
|
const A = {id: 'example', name: 'Example', text: '- a'};
|
||||||
const B = {id: 'k1', name: 'Sprint 15', text: '- b', source: 'https://x.example/p'};
|
const B = {id: 'k1', name: 'Sprint 15', text: '- b', source: 'https://x.example/p'};
|
||||||
|
const JETZT = 1750000000000;
|
||||||
|
|
||||||
describe('docstore — Index + Text je Dokument (D83)', () => {
|
function schreiben(s, doc, opts){ return writeDoc(s, doc, {now: JETZT, ...opts}); }
|
||||||
it('Rundreise: storeDocs schreibt, readDocs liest dasselbe zurück', () => {
|
function dokumente(s, keys){ return readDocs(s, keys ?? s.keys()); }
|
||||||
|
|
||||||
|
describe('docstore v3 — Union-Lesen (§6.1)', () => {
|
||||||
|
it('Rundreise: writeDoc schreibt, readDocs liest dasselbe zurück', () => {
|
||||||
const s = mem();
|
const s = mem();
|
||||||
storeDocs(s, [A, B], s.keys());
|
schreiben(s, A);
|
||||||
expect(readDocs(s)).toEqual({docs: [A, B], legacy: false});
|
schreiben(s, B);
|
||||||
|
expect(dokumente(s)).toEqual({docs: [A, B], legacy: false});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('der Index trägt KEINEN Text — der liegt je Dokument unter eigenem Schlüssel', () => {
|
it('Meta trägt Name, Quelle und born — der Text liegt je Dokument unter eigenem Schlüssel', () => {
|
||||||
const s = mem();
|
const s = mem();
|
||||||
storeDocs(s, [A], s.keys());
|
schreiben(s, B);
|
||||||
expect(s.getItem(LS_DOCS)).not.toContain('- a');
|
expect(readMeta(s, B.id)).toEqual({name: B.name, source: B.source, born: JETZT});
|
||||||
expect(s.getItem(docTextKey('example'))).toBe('- a');
|
expect(s.getItem(LS_DOCS)).toBe(null);
|
||||||
|
expect(s.getItem(docTextKey(B.id))).toBe('- b');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Altformat (Texte im Array) wird gelesen und als legacy gemeldet', () => {
|
it('Altformat (Texte im Index-Array) wird gelesen und als legacy gemeldet', () => {
|
||||||
const s = mem({[LS_DOCS]: JSON.stringify([A, B])});
|
const s = mem({[LS_DOCS]: JSON.stringify([A, B])});
|
||||||
const r = readDocs(s);
|
const r = dokumente(s);
|
||||||
expect(r.legacy).toBe(true);
|
expect(r.legacy).toBe(true);
|
||||||
expect(r.docs).toEqual([A, B]);
|
expect(r.docs).toEqual([A, B]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Union: ein Dokument mit Text-Schlüssel, aber ohne Meta und ohne Index, wird gefunden', () => {
|
||||||
|
const s = mem();
|
||||||
|
schreiben(s, A);
|
||||||
|
s.setItem(DOC_TEXT_PREFIX + 'fremd', '- ein Nachzügler');
|
||||||
|
const r = dokumente(s);
|
||||||
|
expect(r.docs.map(d => d.id)).toEqual(['example', 'fremd']);
|
||||||
|
expect(r.docs[1].name).toBe('fremd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Reihenfolge: Index zuerst, Nachzügler nach born', () => {
|
||||||
|
const s = mem();
|
||||||
|
schreiben(s, {...A, born: 100}, {now: 100});
|
||||||
|
schreiben(s, B, {now: 50});
|
||||||
|
writeIndexHint(s, [{id: 'k1', name: B.name}]);
|
||||||
|
const r = dokumente(s);
|
||||||
|
expect(r.docs.map(d => d.id)).toEqual(['k1', 'example']); /* Index ('k1') vor dem Nachzügler */
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Name aus Meta gewinnt vor dem Index-Hinweis', () => {
|
||||||
|
const s = mem();
|
||||||
|
schreiben(s, {...A, name: 'Neuer Name'});
|
||||||
|
writeIndexHint(s, [{id: A.id, name: 'Alter Indexname'}]);
|
||||||
|
expect(dokumente(s).docs[0].name).toBe('Neuer Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Index-Eintrag ohne Text und ohne Meta ist ein Rest und wird ignoriert', () => {
|
||||||
|
const s = mem({[LS_DOCS]: JSON.stringify([{id: 'ghost', name: 'Gespenst'}, {id: 'd1', name: 'Da'}])});
|
||||||
|
s.setItem(docTextKey('d1'), '- x');
|
||||||
|
expect(dokumente(s).docs.map(d => d.id)).toEqual(['d1']);
|
||||||
|
});
|
||||||
|
|
||||||
it('ein fehlender Text-Schlüssel kostet nur diesen einen Text, nicht die Liste', () => {
|
it('ein fehlender Text-Schlüssel kostet nur diesen einen Text, nicht die Liste', () => {
|
||||||
const s = mem();
|
const s = mem();
|
||||||
storeDocs(s, [A, B], s.keys());
|
schreiben(s, A);
|
||||||
s.removeItem(docTextKey('k1'));
|
schreiben(s, B);
|
||||||
const r = readDocs(s);
|
s.removeItem(docTextKey(B.id));
|
||||||
|
const r = dokumente(s);
|
||||||
expect(r.docs.map(d => d.id)).toEqual(['example', 'k1']);
|
expect(r.docs.map(d => d.id)).toEqual(['example', 'k1']);
|
||||||
expect(r.docs[1].text).toBe('');
|
expect(r.docs[1].text).toBe('');
|
||||||
expect(r.docs[0].text).toBe('- a');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('kaputter oder unbrauchbarer Index -> null (Beispiel-Pfad des Aufrufers)', () => {
|
it('kaputter oder fehlender Index ist nur ein Hinweis — die eigenen Schlüssel zählen weiter', () => {
|
||||||
expect(readDocs(mem({[LS_DOCS]: '{kaputt'}))).toBe(null);
|
const s = mem({[LS_DOCS]: '{kaputt'});
|
||||||
expect(readDocs(mem({[LS_DOCS]: '"kein Array"'}))).toBe(null);
|
schreiben(s, A);
|
||||||
expect(readDocs(mem({[LS_DOCS]: '[]'}))).toBe(null);
|
expect(dokumente(s).docs.map(d => d.id)).toEqual(['example']);
|
||||||
expect(readDocs(mem({[LS_DOCS]: JSON.stringify([{name: 'ohne id'}])}))).toBe(null);
|
expect(dokumente(mem())).toBe(null);
|
||||||
expect(readDocs(mem())).toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('unveränderte Schlüssel werden nicht neu geschrieben', () => {
|
|
||||||
const s = mem();
|
|
||||||
storeDocs(s, [A, B], s.keys());
|
|
||||||
const vorher = s.writes();
|
|
||||||
storeDocs(s, [A, B], s.keys()); /* nichts geändert */
|
|
||||||
expect(s.writes()).toBe(vorher);
|
|
||||||
storeDocs(s, [A, {...B, text: '- b2'}], s.keys()); /* ein Text geändert */
|
|
||||||
expect(s.writes()).toBe(vorher + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Texte gelöschter Dokumente werden abgeräumt, fremde Schlüssel bleiben', () => {
|
|
||||||
const s = mem({'werkbaum-ui': '{}'});
|
|
||||||
storeDocs(s, [A, B], s.keys());
|
|
||||||
storeDocs(s, [A], s.keys()); /* B gelöscht */
|
|
||||||
expect(s.getItem(docTextKey('k1'))).toBe(null);
|
|
||||||
expect(s.getItem('werkbaum-ui')).toBe('{}');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('storeDocText schreibt Text-Schlüssel UND Spiegel — und nur bei Änderung', () => {
|
|
||||||
const s = mem();
|
|
||||||
storeDocText(s, 'example', '- neu');
|
|
||||||
expect(s.getItem(docTextKey('example'))).toBe('- neu');
|
|
||||||
expect(s.getItem(LS_SRC)).toBe('- neu');
|
|
||||||
const vorher = s.writes();
|
|
||||||
storeDocText(s, 'example', '- neu');
|
|
||||||
expect(s.writes()).toBe(vorher);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ids mit Doppelpunkten und URLs (live:/url:) tragen als Schlüssel', () => {
|
it('ids mit Doppelpunkten und URLs (live:/url:) tragen als Schlüssel', () => {
|
||||||
const live = {id: 'live:https://w.example/api/v1/documents/abc', name: 'Plan', text: '- x'};
|
const live = {id: 'live:https://w.example/api/v1/documents/abc', name: 'Plan', text: '- x'};
|
||||||
const s = mem();
|
const s = mem();
|
||||||
storeDocs(s, [live], s.keys());
|
schreiben(s, live);
|
||||||
expect(readDocs(s).docs).toEqual([live]);
|
expect(dokumente(s).docs).toEqual([live]);
|
||||||
expect(s.getItem(DOC_TEXT_PREFIX + live.id)).toBe('- x');
|
expect(s.getItem(DOC_TEXT_PREFIX + live.id)).toBe('- x');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('fehlender Name fällt auf die id zurück, Quota-Fehler laufen zum Aufrufer', () => {
|
describe('docstore v3 — Tombstones (§6.2)', () => {
|
||||||
const s = mem({[LS_DOCS]: JSON.stringify([{id: 'x'}])});
|
it('gelöscht bleibt gelöscht: der Tombstone nimmt die id aus dem Lesen', () => {
|
||||||
expect(readDocs(s).docs[0].name).toBe('x');
|
const s = mem();
|
||||||
|
schreiben(s, A);
|
||||||
|
removeDoc(s, A.id, JETZT);
|
||||||
|
expect(hasTombstone(s, A.id)).toBe(true);
|
||||||
|
expect(s.getItem(docTextKey(A.id))).toBe(null);
|
||||||
|
expect(s.getItem(docMetaKey(A.id))).toBe(null);
|
||||||
|
expect(s.getItem(DOC_SNAPS_PREFIX + A.id)).toBe(null);
|
||||||
|
expect(dokumente(s)).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Tombstone blockiert das stille Wiederanlegen — Tippen hebt ihn auf', () => {
|
||||||
|
const s = mem();
|
||||||
|
schreiben(s, A);
|
||||||
|
removeDoc(s, A.id, JETZT);
|
||||||
|
expect(writeDoc(s, {...A, text: '- neu'}, {now: JETZT})).toBe(false); /* Flush schreibt nicht */
|
||||||
|
expect(hasTombstone(s, A.id)).toBe(true);
|
||||||
|
expect(writeDoc(s, {...A, text: '- neu'}, {now: JETZT, typed: true})).toBe(true); /* Tippen ist Absicht */
|
||||||
|
expect(hasTombstone(s, A.id)).toBe(false);
|
||||||
|
expect(s.getItem(docTextKey(A.id))).toBe('- neu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('storeDocText (der Tastendruck) hebt den Tombstone selbst', () => {
|
||||||
|
const s = mem();
|
||||||
|
removeDoc(s, A.id, JETZT);
|
||||||
|
storeDocText(s, A.id, '- getippt');
|
||||||
|
expect(hasTombstone(s, A.id)).toBe(false);
|
||||||
|
expect(s.getItem(docTextKey(A.id))).toBe('- getippt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Tombstones verfallen nach 7 Tagen — junge bleiben, alte und defekte fallen', () => {
|
||||||
|
const s = mem();
|
||||||
|
s.setItem(docGoneKey('alt'), String(JETZT - GONE_TTL - 1));
|
||||||
|
s.setItem(docGoneKey('jung'), String(JETZT - GONE_TTL + 1));
|
||||||
|
s.setItem(docGoneKey('kaputt'), 'gar kein Datum');
|
||||||
|
expireTombstones(s, s.keys(), JETZT);
|
||||||
|
expect(hasTombstone(s, 'alt')).toBe(false);
|
||||||
|
expect(hasTombstone(s, 'jung')).toBe(true);
|
||||||
|
expect(hasTombstone(s, 'kaputt')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('docstore v3 — die Flush-Grenze: fremde Schlüssel werden nie angefasst', () => {
|
||||||
|
/* DIE Gegenprobe des RFC (§10, Mutation): Baut jemand den Orphan-Sweep des
|
||||||
|
D83-Voll-Flushes zurück, fällt genau dieser Test — der Schaden von damals
|
||||||
|
(Befund 2: B's Dokument samt Text weg) ist hier als Zähler beweisbar. */
|
||||||
|
it('persistDocs (writeDoc + writeIndexHint) schreibt nur EIGENE Schlüssel, entfernt KEINEN', () => {
|
||||||
|
const s = mem();
|
||||||
|
schreiben(s, A);
|
||||||
|
/* Der Zustand, der Befund 2 war: das andere Fenster hat ein Dokument
|
||||||
|
angelegt, das diese Liste nicht kennt. */
|
||||||
|
const fremd = {id: 'fremdes-plan', name: 'Plan aus Fenster B', text: '- b'};
|
||||||
|
schreiben(s, fremd);
|
||||||
|
/* Der Flush dieses Fensters kennt nur A: Dirty-Flush + Index-Hinweis. */
|
||||||
|
writeDoc(s, A);
|
||||||
|
writeIndexHint(s, [{id: A.id, name: A.name}]);
|
||||||
|
expect(s.getItem(docTextKey('fremdes-plan'))).toBe('- b'); /* unberührt … */
|
||||||
|
expect(s.getItem(docMetaKey('fremdes-plan'))).not.toBe(null); /* … und nicht entfernt */
|
||||||
|
/* und nichts von B ist verloren: */
|
||||||
|
expect(dokumente(s).docs.map(d => d.id).sort()).toEqual(['example', 'fremdes-plan']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writeDoc schreibt nur Meta und Text des einen Dokuments — Zähler je Schlüssel', () => {
|
||||||
|
const s = mem();
|
||||||
|
schreiben(s, A);
|
||||||
|
const zugriffe = k => s.schreibFuer(k);
|
||||||
|
const vorA = zugriffe('werkbaum-meta:example') + zugriffe('werkbaum-doc:example');
|
||||||
|
schreiben(s, A); /* dieselben Inhalte — Vergleich greift */
|
||||||
|
expect(zugriffe('werkbaum-meta:example') + zugriffe('werkbaum-doc:example')).toBe(vorA);
|
||||||
|
expect(zugriffe(DOC_META_PREFIX + 'k1')).toBe(0);
|
||||||
|
expect(zugriffe(DOC_TEXT_PREFIX + 'k1')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('born bleibt erhalten: Umbenennen dreht die Ablage-Reihenfolge nicht um', () => {
|
||||||
|
const s = mem();
|
||||||
|
schreiben(s, {...A, born: 5}, {now: JETZT});
|
||||||
|
writeDoc(s, {...A, name: 'Umbenannt'}, {now: JETZT + 1000}); /* ohne born im Objekt */
|
||||||
|
expect(readMeta(s, A.id).born).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writeIndexHint schreibt den Hinweis — und löscht nichts', () => {
|
||||||
|
const s = mem({[LS_DOCS]: JSON.stringify([{id: 'a', name: 'x'}])});
|
||||||
|
writeIndexHint(s, []);
|
||||||
|
expect(s.getItem(LS_DOCS)).toBe('[]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('docstore v3 — Migration (§6.1, idempotent)', () => {
|
||||||
|
it('teilt den alten Stände-Sammel-Schlüssel auf und entfernt ihn', () => {
|
||||||
|
const s = mem({
|
||||||
|
[LEGACY_SNAPS]: JSON.stringify({a: [{t: 1, text: 'x'}], b: [{t: 2, text: 'y'}, {t: 'kaputt'}]}),
|
||||||
|
[DOC_TEXT_PREFIX + 'a']: '- a',
|
||||||
|
});
|
||||||
|
migrateV3(s, JETZT);
|
||||||
|
expect(s.getItem(LEGACY_SNAPS)).toBe(null);
|
||||||
|
expect(s.getItem(DOC_SNAPS_PREFIX + 'a')).toBe(JSON.stringify([{t: 1, text: 'x'}]));
|
||||||
|
/* b's Liste bleibt — nur die defekten Einträge fallen. */
|
||||||
|
expect(s.getItem(DOC_SNAPS_PREFIX + 'b')).toBe(JSON.stringify([{t: 2, text: 'y'}]));
|
||||||
|
/* Ohne Index gibt es keine Meta-Quelle — 'a' bleibt ein Nachzügler. */
|
||||||
|
expect(readMeta(s, 'a')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zweimal laufen = einmal laufen (gleicher Inhalt, keine zusätzlichen Schreibvorgänge)', () => {
|
||||||
|
const s = mem({
|
||||||
|
[LEGACY_SNAPS]: JSON.stringify({a: [{t: 1, text: 'x'}]}),
|
||||||
|
[LS_DOCS]: JSON.stringify([{id: 'a', name: 'A'}]),
|
||||||
|
});
|
||||||
|
migrateV3(s, JETZT);
|
||||||
|
const snapshot = new Map(s.map);
|
||||||
|
const n = s.schreibGesamt();
|
||||||
|
migrateV3(s, JETZT + 1000); /* zweimal: schreibt nichts mehr */
|
||||||
|
expect(s.schreibGesamt()).toBe(n);
|
||||||
|
expect([...s.map.entries()]).toEqual([...snapshot.entries()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schreibt Meta nur für Dokumente MIT Text — Reste ohne Text bleiben ohne Meta', () => {
|
||||||
|
const s = mem({[LS_DOCS]: JSON.stringify([{id: 'da', name: 'Da', text: '- x'}, {id: 'rest', name: 'Rest'}])});
|
||||||
|
migrateV3(s, JETZT);
|
||||||
|
expect(readMeta(s, 'da')).toEqual({name: 'Da', born: JETZT});
|
||||||
|
expect(s.getItem(DOC_TEXT_PREFIX + 'da')).toBe('- x');
|
||||||
|
expect(readMeta(s, 'rest')).toBe(null);
|
||||||
|
expect(s.getItem(DOC_TEXT_PREFIX + 'rest')).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bestehende Meta bleibt stehen (born kommt nicht unter die Migration)', () => {
|
||||||
|
const s = mem({
|
||||||
|
[LS_DOCS]: JSON.stringify([{id: 'a', name: 'Alt'}]),
|
||||||
|
[docMetaKey('a')]: JSON.stringify({name: 'Eigenname', born: 1}),
|
||||||
|
[DOC_TEXT_PREFIX + 'a']: '- x',
|
||||||
|
});
|
||||||
|
migrateV3(s, JETZT);
|
||||||
|
expect(readMeta(s, 'a')).toEqual({name: 'Eigenname', born: 1});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('storeDocText — Tastendruck-Hälfte (unverändert, plus Tombstone)', () => {
|
||||||
|
it('schreibt Text-Schlüssel UND Spiegel — und nur bei Änderung', () => {
|
||||||
|
const s = mem();
|
||||||
|
storeDocText(s, 'example', '- neu');
|
||||||
|
expect(s.getItem(docTextKey('example'))).toBe('- neu');
|
||||||
|
expect(s.getItem(LS_SRC)).toBe('- neu');
|
||||||
|
const vorher = s.schreibGesamt();
|
||||||
|
storeDocText(s, 'example', '- neu');
|
||||||
|
expect(s.schreibGesamt()).toBe(vorher);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Quota-Fehler laufen zum Aufrufer', () => {
|
||||||
const voll = {getItem: () => null, setItem: () => { throw new Error('QuotaExceeded'); }, removeItem: () => {}};
|
const voll = {getItem: () => null, setItem: () => { throw new Error('QuotaExceeded'); }, removeItem: () => {}};
|
||||||
expect(() => storeDocs(voll, [A], [])).toThrow();
|
expect(() => writeDoc(voll, A)).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isDocKey — Fremd-Tab-Erkennung (D84)', () => {
|
describe('isDocKey — die Schlüssel der Synchronisation (D84, RFC 002 §6.3)', () => {
|
||||||
it('erkennt Index, aktives Dokument, Spiegel und Text-Schlüssel', () => {
|
it('erkennt Index, aktives Dokument, Spiegel, alte Stände und alle per-Dokument-Schlüssel', () => {
|
||||||
expect(isDocKey(LS_DOCS)).toBe(true);
|
expect(isDocKey(LS_DOCS)).toBe(true);
|
||||||
expect(isDocKey('werkbaum-active')).toBe(true);
|
expect(isDocKey('werkbaum-active')).toBe(true);
|
||||||
expect(isDocKey(LS_SRC)).toBe(true);
|
expect(isDocKey(LS_SRC)).toBe(true);
|
||||||
|
expect(isDocKey(LEGACY_SNAPS)).toBe(true);
|
||||||
expect(isDocKey(DOC_TEXT_PREFIX + 'live:https://x/api/v1/documents/a')).toBe(true);
|
expect(isDocKey(DOC_TEXT_PREFIX + 'live:https://x/api/v1/documents/a')).toBe(true);
|
||||||
|
expect(isDocKey(DOC_META_PREFIX + 'example')).toBe(true);
|
||||||
|
expect(isDocKey(DOC_GONE_PREFIX + 'example')).toBe(true);
|
||||||
|
expect(isDocKey(DOC_SNAPS_PREFIX + 'example')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fremde Schlüssel und Ansichts-Zustand sind keine Dokument-Schlüssel', () => {
|
it('fremde Schlüssel und Ansicht-Zustand sind keine Dokument-Schlüssel', () => {
|
||||||
expect(isDocKey('werkbaum-ui')).toBe(false);
|
expect(isDocKey('werkbaum-ui')).toBe(false);
|
||||||
expect(isDocKey('werkbaum-lang')).toBe(false);
|
expect(isDocKey('werkbaum-lang')).toBe(false);
|
||||||
expect(isDocKey('werkbaum-snaps')).toBe(false);
|
expect(isDocKey('werkbaum-seeded')).toBe(false);
|
||||||
expect(isDocKey(null)).toBe(false);
|
expect(isDocKey(null)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { applyStorageEvent, lockDecision, lockName, tombstoneExpired,
|
||||||
|
createDirtySet, GONE_TTL } from '../src/docsync.js';
|
||||||
|
|
||||||
|
/* docsync (RFC 002 §6.3): Die Ereignis-Matrix — was ein storage-Ereignis im
|
||||||
|
laufenden Fenster bedeutet. Headless: Liste und Ereignis sind Daten.
|
||||||
|
|
||||||
|
Fixture: A ist dieses Fenster. Das Ereignis kommt von Fenster B. */
|
||||||
|
function apply(docs, activeId, key, newValue, oldValue){
|
||||||
|
return applyStorageEvent(docs, activeId, {key, newValue, oldValue});
|
||||||
|
}
|
||||||
|
const D = (id, name, text) => ({id, name, text: text ?? ''});
|
||||||
|
|
||||||
|
describe('applyStorageEvent — die Matrix aus §6.3', () => {
|
||||||
|
it('meta neu: Dokument hängt in der Liste, ohne Text (lazy)', () => {
|
||||||
|
const docs = [D('a', 'A', '- a')];
|
||||||
|
const erg = apply(docs, 'a', 'werkbaum-meta:neu', '{"name":"Neu","born":1}');
|
||||||
|
expect(erg.docs).toEqual([{id: 'a', name: 'A', text: '- a'}, {id: 'neu', name: 'Neu', text: ''}]);
|
||||||
|
expect(erg.action).toBe('created');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meta geändert am FREMDEN Dokument: Name/Quelle übernehmen, kein action', () => {
|
||||||
|
const docs = [D('a', 'A', '- a'), D('b', 'Alt')];
|
||||||
|
const erg = apply(docs, 'a', 'werkbaum-meta:b', '{"name":"Neu","born":1}');
|
||||||
|
expect(erg.docs[0]).toBe(docs[0]); /* dasselbe Array — die Mutation trägt */
|
||||||
|
expect(erg.docs[1].name).toBe('Neu');
|
||||||
|
expect(erg.action).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meta geändert am AKTIVEN Dokument: action „renamed" — der Chip folgt', () => {
|
||||||
|
const docs = [D('a', 'Alt')];
|
||||||
|
const erg = apply(docs, 'a', 'werkbaum-meta:a', '{"name":"Neu","born":1}');
|
||||||
|
expect(erg.action).toBe('renamed');
|
||||||
|
expect(erg.docs[0].name).toBe('Neu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gone neu, id NICHT aktiv: aus der Liste', () => {
|
||||||
|
const docs = [D('a', 'A', '- a'), D('b', 'B', '- b')];
|
||||||
|
const erg = apply(docs, 'a', 'werkbaum-gone:b', '123');
|
||||||
|
expect(erg.docs.map(d => d.id)).toEqual(['a']);
|
||||||
|
expect(erg.action).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gone neu, id AKTIV: behalten, action „deleted" — warnfarben, bis getippt wird (§6.5)', () => {
|
||||||
|
const docs = [D('a', 'A', '- a')];
|
||||||
|
const erg = apply(docs, 'a', 'werkbaum-gone:a', '123');
|
||||||
|
expect(erg.docs).toBe(docs); /* behalten */
|
||||||
|
expect(erg.action).toBe('deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gone aufgehoben (newValue null): nichts tun — das meta-Ereignis legt an', () => {
|
||||||
|
const erg = apply([D('a', 'A')], 'a', 'werkbaum-gone:a', null);
|
||||||
|
expect(erg.action).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doc geändert, id NICHT aktiv: Text für die Vorschau nachziehen', () => {
|
||||||
|
const docs = [D('a', 'A', '- a'), D('b', 'B', '- alt')];
|
||||||
|
const erg = apply(docs, 'a', 'werkbaum-doc:b', '- neu');
|
||||||
|
expect(erg.docs[1].text).toBe('- neu');
|
||||||
|
expect(erg.action).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doc geändert, id AKTIV, kein live: — NICHTS am Text, action „foreignWrite" (§6.6)', () => {
|
||||||
|
const docs = [D('a', 'A', '- mein Text')];
|
||||||
|
const erg = apply(docs, 'a', 'werkbaum-doc:a', '- fremder Text');
|
||||||
|
expect(erg.docs[0].text).toBe('- mein Text'); /* das Getippte bleibt */
|
||||||
|
expect(erg.action).toBe('foreignWrite');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doc geändert, id aktiv, live: — nichts, der Feed ist die Quelle', () => {
|
||||||
|
const docs = [D('live:https://x/d1', 'Geteilt', '- gemeinsam')];
|
||||||
|
const erg = apply(docs, 'live:https://x/d1', 'werkbaum-doc:live:https://x/d1', '- server');
|
||||||
|
expect(erg.docs[0].text).toBe('- gemeinsam');
|
||||||
|
expect(erg.action).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('doc geändert, id unbekannt: ignorieren — das meta-Ereignis legt das Dokument an', () => {
|
||||||
|
const docs = [D('a', 'A')];
|
||||||
|
const erg = apply(docs, 'a', 'werkbaum-doc:fremd', '- x');
|
||||||
|
expect(erg.docs).toBe(docs);
|
||||||
|
expect(erg.action).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('snaps: action „snaps" — der Stände-Cache der id wird verworfen', () => {
|
||||||
|
const erg = apply([D('a', 'A')], 'a', 'werkbaum-snaps:b', '[{"t":1,"text":"x"}]');
|
||||||
|
expect(erg.action).toBe('snaps');
|
||||||
|
expect(erg.id).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('docs (Index-Hinweis): Reihenfolge übernehmen, unbekannte ids bleiben hinten', () => {
|
||||||
|
const docs = [D('a', 'A'), D('b', 'B'), D('fremd', 'F')];
|
||||||
|
const erg = apply(docs, 'a', 'werkbaum-docs', '[{"id":"b","name":"B"},{"id":"a","name":"A"}]');
|
||||||
|
expect(erg.docs.map(d => d.id)).toEqual(['b', 'a', 'fremd']);
|
||||||
|
expect(erg.action).toBe('order');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('docs unlesbar oder bereits gleich: nichts', () => {
|
||||||
|
const docs = [D('a', 'A')];
|
||||||
|
expect(apply(docs, 'a', 'werkbaum-docs', '{kaputt').docs).toBe(docs);
|
||||||
|
expect(apply(docs, 'a', 'werkbaum-docs', null).docs).toBe(docs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Harmlose Schlüssel (active, Spiegel, Ansicht, Merker): nichts', () => {
|
||||||
|
const docs = [D('a', 'A')];
|
||||||
|
for(const k of ['werkbaum-active', 'werkbaum-src', 'werkbaum-ui', 'werkbaum-lang', 'werkbaum-seeded']){
|
||||||
|
const erg = apply(docs, 'a', k, '1');
|
||||||
|
expect(erg.docs).toBe(docs);
|
||||||
|
expect(erg.action).toBe(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lockDecision — gesperrt wird nie ein geteiltes Dokument (§6.4)', () => {
|
||||||
|
it('nicht „live:" wird gesperrt — eigene, URL- und Datei-Dokumente ebenso', () => {
|
||||||
|
expect(lockDecision('example')).toBe(true);
|
||||||
|
expect(lockDecision('d12345')).toBe(true);
|
||||||
|
expect(lockDecision('url:https://x.example/p.werkbaum')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('live: nie — zwei Fenster sind zwei Live-Clients, der Server führt zusammen', () => {
|
||||||
|
expect(lockDecision('live:https://w.example/api/v1/documents/abc')).toBe(false);
|
||||||
|
expect(lockDecision(null)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tombstoneExpired — Verfall nach 7 Tagen (§6.2)', () => {
|
||||||
|
const jetzt = 1750000000000;
|
||||||
|
it('junge Tombstones bleiben', () => {
|
||||||
|
expect(tombstoneExpired(jetzt - GONE_TTL + 1, jetzt)).toBe(false);
|
||||||
|
expect(tombstoneExpired(jetzt, jetzt)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nach sieben Tagen fallen sie', () => {
|
||||||
|
expect(tombstoneExpired(jetzt - GONE_TTL - 1, jetzt)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne brauchbaren Zeitstempel gilt der Tombstone als verfallen', () => {
|
||||||
|
expect(tombstoneExpired(undefined, jetzt)).toBe(true);
|
||||||
|
expect(tombstoneExpired('kaputt', jetzt)).toBe(true);
|
||||||
|
expect(tombstoneExpired(0, jetzt)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createDirtySet — die Flush-Punkte schreiben nur Eigenes (§6.1)', () => {
|
||||||
|
it('sammelt ids, take räumt auf', () => {
|
||||||
|
const dirty = createDirtySet();
|
||||||
|
expect(dirty.entries()).toEqual([]);
|
||||||
|
dirty.add('a'); dirty.add('b'); dirty.add('a');
|
||||||
|
expect(dirty.entries()).toEqual(['a', 'b']);
|
||||||
|
dirty.clear();
|
||||||
|
expect(dirty.entries()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignoriert fehlende ids', () => {
|
||||||
|
const dirty = createDirtySet();
|
||||||
|
dirty.add(null); dirty.add(undefined); dirty.add('');
|
||||||
|
expect(dirty.entries()).toEqual([]);
|
||||||
|
dirty.add('x');
|
||||||
|
expect(dirty.has('x')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { LS_SNAPS, SNAP_KEEP, parseSnaps, addSnapshot, dropOldestSnap,
|
import { SNAP_EVERY, SNAP_KEEP, parseSnaps, addSnapshot, dropOldestSnap,
|
||||||
persistSnaps, snapLabel } from '../src/snapshots.js';
|
persistSnaps, readSnapList, readAllSnaps, snapLabel, snapKey } from '../src/snapshots.js';
|
||||||
|
import { DOC_SNAPS_PREFIX } from '../src/docstore.js';
|
||||||
|
|
||||||
/* Frühere Stände (D54). Herausgezogen aus app.js, damit genau das prüfbar
|
/* Frühere Stände (D54). Herausgezogen aus app.js, damit genau das prüfbar
|
||||||
wird, was dort dreimal daneben lag: die Regel, wann ein Stand entsteht. */
|
wird, was dort dreimal daneben lag: die Regel, wann ein Stand entsteht.
|
||||||
|
Seit RFC 002 liegt JEDES Dokument unter eigenem Schlüssel — der alte
|
||||||
|
Sammel-Schlüssel ließ den Flush des einen Fensters die Stände des anderen
|
||||||
|
wegwerfen, die Rettungs-Sicherungen eingeschlossen (Befund 3). */
|
||||||
|
|
||||||
describe('addSnapshot — Takt und Knopf unterscheiden sich in einer Sache', () => {
|
describe('addSnapshot — Takt und Knopf unterscheiden sich in einer Sache', () => {
|
||||||
/* DER gemeldete Fehler (D54-Nachtrag 2): Dokument geöffnet, nichts geändert,
|
/* DER gemeldete Fehler (D54-Nachtrag 2): Dokument geöffnet, nichts geändert,
|
||||||
@@ -71,10 +75,10 @@ describe('addSnapshot — es bleiben die letzten 20', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('dropOldestSnap — das Älteste geht zuerst, dokumentübergreifend', () => {
|
describe('dropOldestSnap — das Älteste geht zuerst, dokumentübergreifend (im Gedächtnis)', () => {
|
||||||
it('trifft das älteste über alle Dokumente hinweg', () => {
|
it('trifft das älteste über alle Dokumente hinweg und sagt, welches es war', () => {
|
||||||
const snaps = {a: [{t: 50, text: 'a1'}], b: [{t: 10, text: 'b1'}, {t: 60, text: 'b2'}]};
|
const snaps = {a: [{t: 50, text: 'a1'}], b: [{t: 10, text: 'b1'}, {t: 60, text: 'b2'}]};
|
||||||
expect(dropOldestSnap(snaps)).toBe(true);
|
expect(dropOldestSnap(snaps)).toBe('b');
|
||||||
expect(snaps.b.map(s => s.text)).toEqual(['b2']);
|
expect(snaps.b.map(s => s.text)).toEqual(['b2']);
|
||||||
expect(snaps.a).toHaveLength(1);
|
expect(snaps.a).toHaveLength(1);
|
||||||
});
|
});
|
||||||
@@ -85,86 +89,117 @@ describe('dropOldestSnap — das Älteste geht zuerst, dokumentübergreifend', (
|
|||||||
expect(snaps).toEqual({});
|
expect(snaps).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('meldet false, wenn nichts mehr da ist', () => {
|
it('meldet null, wenn nichts mehr da ist', () => {
|
||||||
expect(dropOldestSnap({})).toBe(false);
|
expect(dropOldestSnap({})).toBe(null);
|
||||||
expect(dropOldestSnap({a: []})).toBe(false);
|
expect(dropOldestSnap({a: []})).toBe(null);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/* Der Speicher ist mit den Dokumenten geteilt. Läuft er über, sollen die
|
/* Der Speicher ist mit den Dokumenten geteilt. Läuft er über, sollen die
|
||||||
Dokumente überleben — Stände sind das Nachgeben-Bare. */
|
Dokumente überleben — Stände sind das Nachgeben-Bare. */
|
||||||
describe('persistSnaps — Dokumente gehen vor Ständen', () => {
|
describe('persistSnaps — je Dokument geschrieben, Verdrängung über die Schlüssel', () => {
|
||||||
function speicher(limit){
|
function speicher(limit){
|
||||||
const s = {daten: null, entfernt: false, versuche: 0};
|
const m = new Map();
|
||||||
return Object.assign(s, {
|
return {
|
||||||
|
getItem: k => (m.has(k) ? m.get(k) : null),
|
||||||
setItem(k, v){
|
setItem(k, v){
|
||||||
s.versuche++;
|
if(String(v).length > limit) throw new Error('QuotaExceededError');
|
||||||
if(v.length > limit) throw new Error('QuotaExceededError');
|
m.set(k, String(v));
|
||||||
s.daten = v;
|
|
||||||
},
|
},
|
||||||
removeItem(){ s.entfernt = true; }
|
removeItem: k => m.delete(k),
|
||||||
});
|
keys: () => [...m.keys()],
|
||||||
|
map: m,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it('speichert, wenn es passt', () => {
|
it('speichert unter dem Schlüssel des Dokuments, wenn es passt', () => {
|
||||||
const store = speicher(1e6), snaps = {a: [{t: 1, text: 'kurz'}]};
|
const store = speicher(1e6), snaps = {a: [{t: 1, text: 'kurz'}]};
|
||||||
expect(persistSnaps(snaps, store)).toBe(true);
|
expect(persistSnaps(store, snaps, 'a', store.keys())).toBe(true);
|
||||||
expect(JSON.parse(store.daten)).toEqual(snaps);
|
expect(JSON.parse(store.getItem(snapKey('a')))).toEqual(snaps.a);
|
||||||
expect(store.versuche).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wirft die ältesten Stände weg, bis es passt', () => {
|
it('wirft den ältesten Stand des EIGENEN Dokuments weg, bis der eigene Schlüssel passt', () => {
|
||||||
const snaps = {a: [{t: 1, text: 'x'.repeat(200)}, {t: 3, text: 'y'.repeat(200)}],
|
const snaps = {a: [{t: 1, text: 'x'.repeat(200)}, {t: 3, text: 'y'.repeat(200)}]};
|
||||||
b: [{t: 2, text: 'z'.repeat(200)}]};
|
const store = speicher(220); /* eine Liste (217 Zeichen) passt, beide nicht */
|
||||||
const store = speicher(300);
|
expect(persistSnaps(store, snaps, 'a', store.keys())).toBe(true);
|
||||||
expect(persistSnaps(snaps, store)).toBe(true);
|
|
||||||
/* Übrig bleibt der jüngste — t:1 und t:2 fielen in dieser Reihenfolge. */
|
|
||||||
expect(Object.keys(snaps)).toEqual(['a']);
|
|
||||||
expect(snaps.a.map(s => s.t)).toEqual([3]);
|
expect(snaps.a.map(s => s.t)).toEqual([3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gibt auf und räumt den Schlüssel weg, wenn selbst leer nicht passt', () => {
|
it('greift im Notfall auf den ältesten Stand EINES FREMDEN Dokuments zurück — lesend, §6.8', () => {
|
||||||
|
const snaps = {a: [{t: 9, text: 'x'.repeat(200)}, {t: 20, text: 'y'.repeat(200)}],
|
||||||
|
b: [{t: 1, text: 'p'.repeat(100)}, {t: 4, text: 'q'.repeat(100)}]};
|
||||||
|
const store = speicher(220);
|
||||||
|
store.map.set(snapKey('b'), JSON.stringify(snaps.b)); /* der Zustand des anderen Fensters */
|
||||||
|
expect(persistSnaps(store, snaps, 'a', store.keys())).toBe(true);
|
||||||
|
/* Der älteste Stand (t:1) war b's — sein Schlüssel im Speicher wurde
|
||||||
|
gekürzt, nicht aus dem Gedächtnis überschrieben; dann räumte der
|
||||||
|
eigene älteste, bis a passte. */
|
||||||
|
expect(snaps.a.map(s => s.t)).toEqual([20]);
|
||||||
|
expect(JSON.parse(store.getItem(snapKey('b'))).map(s => s.t)).toEqual([4]);
|
||||||
|
expect(snaps.b).toBeUndefined(); /* Cache verworfen — beim nächsten Öffnen frisch gelesen */
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt den fremden Schlüssel ganz, wenn sein letzter Stand gefallen ist', () => {
|
||||||
|
const snaps = {a: [{t: 9, text: 'x'.repeat(200)}, {t: 20, text: 'y'.repeat(200)}],
|
||||||
|
b: [{t: 1, text: 'p'.repeat(100)}]};
|
||||||
|
const store = speicher(220);
|
||||||
|
store.map.set(snapKey('b'), JSON.stringify(snaps.b));
|
||||||
|
expect(persistSnaps(store, snaps, 'a', store.keys())).toBe(true);
|
||||||
|
expect(store.getItem(snapKey('b'))).toBe(null); /* sein letzter Stand ging */
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt auf und räumt den eigenen Schlüssel weg, wenn selbst leer nicht passt', () => {
|
||||||
const snaps = {a: [{t: 1, text: 'x'}]};
|
const snaps = {a: [{t: 1, text: 'x'}]};
|
||||||
const store = speicher(1); /* nicht einmal "{}" passt */
|
const store = speicher(1); /* nicht einmal "[]" passt */
|
||||||
expect(persistSnaps(snaps, store)).toBe(false);
|
expect(persistSnaps(store, snaps, 'a', store.keys())).toBe(false);
|
||||||
expect(store.entfernt).toBe(true);
|
expect(store.getItem(snapKey('a'))).toBe(null);
|
||||||
expect(snaps).toEqual({});
|
expect(snaps).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('speichert unter dem vereinbarten Schlüssel', () => {
|
|
||||||
let key = null;
|
|
||||||
persistSnaps({}, {setItem(k){ key = k; }, removeItem(){}});
|
|
||||||
expect(key).toBe(LS_SNAPS);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/* Was aus dem Speicher kommt, ist fremder Text: alte Fassung, von Hand
|
/* Beim Laden werden die Schlüssel gelesen — ein Schlüssel ohne brauchbare
|
||||||
bearbeitet, halb geschrieben. Ein Sicherheitsnetz darf daran nicht die App
|
Liste fällt still weg. */
|
||||||
aufhängen. */
|
describe('readAllSnaps / readSnapList — die Stände liegen je Dokument', () => {
|
||||||
describe('parseSnaps — beschädigter Speicher bringt die App nicht um', () => {
|
const store = {
|
||||||
it('liest die erwartete Form', () => {
|
getItem: k => ({[snapKey('a')]: '[{"t":1,"text":"x"}]',
|
||||||
const o = {a: [{t: 1, text: 'x'}]};
|
[snapKey('b')]: '{kaputt',
|
||||||
expect(parseSnaps(JSON.stringify(o))).toEqual(o);
|
[snapKey('c')]: '[]'}[k] ?? null),
|
||||||
|
};
|
||||||
|
const keys = [snapKey('a'), snapKey('b'), snapKey('c'), 'werkbaum-ui'];
|
||||||
|
|
||||||
|
it('readAllSnaps liest alle Dokumente aus den Schlüsseln', () => {
|
||||||
|
expect(readAllSnaps(store, Object.keys({})).a).toBeUndefined(); /* ohne Schlüsselliste: nichts */
|
||||||
|
expect(Object.keys(readAllSnaps(store, [snapKey('a'), snapKey('b'), snapKey('c'), 'werkbaum-ui'])))
|
||||||
|
.toEqual(['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('readSnapList liest eine Liste, beschädigter Inhalt wird eine leere Liste', () => {
|
||||||
|
expect(readSnapList(store, 'a')).toEqual([{t: 1, text: 'x'}]);
|
||||||
|
expect(readSnapList(store, 'b')).toEqual([]);
|
||||||
|
expect(readSnapList(store, 'gibtsnicht')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parseSnaps wirft Einträge weg, die nicht die erwartete Form haben', () => {
|
||||||
|
const raw = JSON.stringify([{t: 1, text: 'gut'}, {t: 'später', text: 'x'}, {t: 2}, null, {text: 'ohne t'}]);
|
||||||
|
expect(parseSnaps(raw)).toEqual([{t: 1, text: 'gut'}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['leer', null],
|
['leer', null],
|
||||||
['leerer String', ''],
|
['leerer String', ''],
|
||||||
['kaputtes JSON', '{nicht json'],
|
['kaputtes JSON', '{nicht json'],
|
||||||
['kein Objekt', '"text"'],
|
['keine Liste', '"text"'],
|
||||||
['Array statt Objekt', '[1,2,3]'],
|
['Objekt statt Liste', '{"a":1}'],
|
||||||
['null', 'null'],
|
['null', 'null'],
|
||||||
])('gibt bei %s ein leeres Objekt zurück', (_name, raw) => {
|
])('gibt bei %s eine leere Liste zurück', (_name, raw) => {
|
||||||
expect(parseSnaps(raw)).toEqual({});
|
expect(parseSnaps(raw)).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wirft Einträge weg, die nicht die erwartete Form haben', () => {
|
describe('snapKey — der Schlüssel trägt das Präfix des Ablageschemas', () => {
|
||||||
const raw = JSON.stringify({
|
it('werkbaum-snaps:<id>', () => {
|
||||||
a: [{t: 1, text: 'gut'}, {t: 'späth', text: 'x'}, {t: 2}, null, {text: 'ohne t'}],
|
expect(snapKey('example')).toBe(DOC_SNAPS_PREFIX + 'example');
|
||||||
b: 'keine Liste',
|
expect(snapKey('live:https://x/api/v1/documents/a')).toBe(DOC_SNAPS_PREFIX + 'live:https://x/api/v1/documents/a');
|
||||||
c: []
|
|
||||||
});
|
|
||||||
expect(parseSnaps(raw)).toEqual({a: [{t: 1, text: 'gut'}]});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,3 +228,4 @@ describe('snapLabel — heute die Uhrzeit, sonst mit Datum', () => {
|
|||||||
expect(snapLabel(ts, 'xx-!', ts)).toBe('2026-03-05 14:37');
|
expect(snapLabel(ts, 'xx-!', ts)).toBe('2026-03-05 14:37');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user