frontend: Pad auf Knopfdruck holen statt zu takten — Etherpad drosselt
Der Hintergrund-Takt aus dem letzten Commit funktionierte in der Praxis nicht:
im Netzwerk-Mitschnitt stapelten sich Anfragen und wurden abgebrochen
("cancelled"). Die Ursache ist nicht Langsamkeit, sondern ein Rate Limit —
Etherpads `importExportRateLimiting` ist serienmäßig an und lässt 10 Abrufe je
90 s und IP zu; der Takt wollte 36. Jenseits davon antwortet die Gegenseite
nicht mit 429, sondern hält die Verbindung ohne Antwort offen. Nachgemessen:
12:48:12 Abbruch nach 25 s (0 Bytes)
Abbruch nach 25 s (0 Bytes)
12:50:23 HTTP 200 nach 0,436 s <- nach ~40 s Pause
Zwei Minuten totgestellt, dann sofort in 0,4 s da. Gegen eine Drosselung kann
ein Takt nicht gewinnen, er erzeugt sie. Also ein Knopf. Damit entfallen
Stabilitätstakt, Sichtbarkeits-Wächter, Anti-Stapel-Riegel und Wiederanlauf.
Der Knopf greift zudem gut mit "Was ist neu?" (D28) zusammen: drücken, und was
seither in Produktion ging, leuchtet auf.
Verhindern UND melden, beides:
- Mindestabstand 10 s zwischen zwei Abrufen. Das ergibt höchstens 9 je 90 s und
bleibt damit beweisbar unter Etherpads Budget — die Drosselung wird gar nicht
erst ausgelöst. Nach einem Abbruch sind wir schon drüber: dann das ganze
Fenster (90 s) abwarten statt weiter dagegen zu rennen.
- Der Knopf bleibt in der Sperre **klickbar** (nur `aria-disabled`, nicht
`disabled`): Ein Klick meldet den Grund und nennt die Grenze samt Restzeit.
Die Meldung landet in `#warn`, das eine Live-Region ist, wird also angesagt.
Ein `disabled` erklärt nichts.
- Eigener Warnungstyp `sourceTimeout` für den Abbruch: `sourceLoad` zeigt auf
CORS und schickte hier auf die falsche Fährte.
- `setPadBusy()` dreht das Symbol während des Abrufs — das können bis zu 20 s
sein, und ohne Zeichen wirkt der Knopf kaputt.
Zwei Fehler des Vorgänger-Commits fielen dabei auf:
- `padSource` und der Takt standen HINTER dem `await` des ersten Abrufs. Ein
einziger Fehlschlag — bei dieser Gegenseite der Normalfall — ließ das Dokument
tot liegen, ohne Knopf und ohne Wiederversuch, bis zum Neuladen der Seite.
Genau das war die vom Nutzer gemeldete Fehlermeldung.
- Kein Erfolg räumte die Warnung des ersten Versuchs weg; sie hätte gestanden,
während der Text längst da war.
Dazu: `deleteDoc()` beendet die Pad-Quelle, sonst legte ein späterer Abruf das
gelöschte Dokument wieder an und aktivierte es auch gleich.
Nebenbefunde, beide nachgemessen und in D31 festgehalten:
- Das *Tippen* im Etherpad-Editor war der letzte offene Punkt. Der Nutzer hat
` - [ ] Layout` eingerückt eingetippt; der Export gibt es zeichengenau
zurück (vier echte Leerzeichen, kein Tab, kein Listen-Markup).
- Einbetten verweigert der Server nicht: kein `X-Frame-Options`, keine CSP mit
`frame-ancestors`, und im Versuch baute das Pad in einem fremdstämmigen Rahmen
seine Editorframes auf. Preis ist `SameSite=Lax` am Autoren-Cookie (jedes
Laden ein neuer Autor; nur serverseitig zu beheben) und der Verlust beider
Richtungen von D25, falls das Textpanel ersetzt wird.
86 Tests (85 -> 86); Schreibschutz zeigt den Text jetzt in Tinte statt grau —
hier wird gelesen, der Plantext ist der Hauptinhalt.
This commit is contained in:
+10
-3
@@ -101,9 +101,16 @@ sich ein **Etherpad**. Angegeben wird die Pad-Adresse, wie sie im Browser steht
|
||||
https://werkbaum.javagil.de/?etherpad=https://pad.hostsharing.net/p/mein-plan
|
||||
```
|
||||
|
||||
Alle bearbeiten den Notationstext **im Pad**, jeder Betrachter sieht das Diagramm
|
||||
mitwachsen (Abruf alle 2,5 s; nicht, während der Tab im Hintergrund liegt). Das
|
||||
Zusammenführen gleichzeitiger Änderungen macht Etherpad — genau darum geht es.
|
||||
Alle bearbeiten den Notationstext **im Pad**; ein Neu-laden-Knopf neben dem
|
||||
Pad-Knopf holt den aktuellen Stand. Das Zusammenführen gleichzeitiger Änderungen
|
||||
macht Etherpad — genau darum geht es.
|
||||
|
||||
Bewusst **kein** Abrufen im Hintergrund: Etherpad drosselt den Export
|
||||
(serienmäßig 10 Abrufe je 90 s und IP), ein Takt gewinnt dagegen nicht — er
|
||||
*erzeugt* die Drosselung. Nachgemessen: Nach Überschreiten des Budgets hält der
|
||||
Server die Verbindung rund zwei Minuten ohne Antwort offen und liefert dann in
|
||||
0,4 s. Der Knopf greift gut mit „Was ist neu?" zusammen: drücken, und was seither
|
||||
in Produktion ging, leuchtet auf.
|
||||
|
||||
Weil das Pad die Schreibfläche ist, ist das Textfeld hier **schreibgeschützt**;
|
||||
ein Knopf in der Editor-Titelzeile öffnet das Pad im neuen Tab. Ohne den Schutz
|
||||
|
||||
@@ -96,9 +96,16 @@ Werkbaum appends that itself):
|
||||
https://werkbaum.javagil.de/?etherpad=https://pad.hostsharing.net/p/my-plan
|
||||
```
|
||||
|
||||
Everyone edits the notation text **in the pad**, and every viewer watches the
|
||||
diagram grow (fetched every 2.5 s; not while the tab is in the background).
|
||||
Merging simultaneous edits is Etherpad's job — that is the whole point.
|
||||
Everyone edits the notation text **in the pad**; a reload button next to the pad
|
||||
button fetches the current state. Merging simultaneous edits is Etherpad's job —
|
||||
that is the whole point.
|
||||
|
||||
Deliberately **no background polling**: Etherpad rate-limits the export (10
|
||||
fetches per 90 s per IP by default), so a timer does not win against it — it
|
||||
*causes* the throttling. Measured: after exceeding the budget the server holds
|
||||
the connection open with no reply for about two minutes, then answers in 0.4 s.
|
||||
The button pairs well with "what's new": press it, and whatever went into
|
||||
production since your last look lights up.
|
||||
|
||||
Because the pad is the writing surface, the text area here is **read-only**; a
|
||||
button in the editor title bar opens the pad in a new tab. Without that
|
||||
|
||||
+83
-26
@@ -1000,10 +1000,14 @@ http(s), ohne Endung oder `Content-Type` zu prüfen (D24). Nachgemessen an
|
||||
Einrückung als echte Leerzeichen und deutet das `-` nicht zur Aufzählung um.
|
||||
Das war das Risiko, das die Idee hätte erledigen können.
|
||||
|
||||
(Gemessen wurde Import → Speicher → Export. Das *Tippen* im Etherpad-Editor —
|
||||
Tab-Einrückung, mögliches Auto-Bullet — ist damit **nicht** geprüft; dafür
|
||||
braucht es einen echten Browser. Vgl. die Lehre aus D25: synthetische Ereignisse
|
||||
beweisen nur die eigene Logik.)
|
||||
Das war zunächst nur Import → Speicher → Export, also **nicht** das Tippen — und
|
||||
genau das war der offene Punkt (Tab-Einrückung, mögliches Auto-Bullet), im Geist
|
||||
der Lehre aus D25: synthetische Ereignisse beweisen nur die eigene Logik.
|
||||
**Nachgeholt an echter Eingabe:** Der Nutzer hat im Pad ` - [ ] Layout`
|
||||
eingerückt eingetippt und eine Kommentarzeile geändert; der Export gibt beides
|
||||
zeichengenau zurück (`> - [ ] Layout$` — vier echte Leerzeichen, kein Tab,
|
||||
kein Listen-Markup). Der Etherpad-Editor fasst die Notation beim Tippen also
|
||||
nicht an.
|
||||
|
||||
**Eigener Parameter statt `?sourceUrl=`.** Drei Gründe, der erste ist der
|
||||
schwächste:
|
||||
@@ -1013,9 +1017,10 @@ schwächste:
|
||||
nicht in die Schnittstelle; Werkbaum hängt sie selbst an.
|
||||
2. Der Parameter **lizenziert anderes Verhalten**. `sourceUrl` heißt „statische
|
||||
Datei, einmal pro Laden geholt" — das ist D23 wörtlich und bleibt
|
||||
unangetastet. `etherpad` heißt „lebendes Pad", und daran hängt das
|
||||
regelmäßige Abrufen. Ohne die Trennung müsste D23 seine Semantik ändern und
|
||||
bestehende Links bekämen ungefragt Polling.
|
||||
unangetastet. `etherpad` heißt „lebendes Pad", und daran hängen der
|
||||
Schreibschutz, der „im Pad bearbeiten"-Knopf und der Neu-laden-Knopf. Ohne
|
||||
die Trennung müsste D23 seine Semantik ändern und bestehende Links bekämen
|
||||
Verhalten, um das niemand gebeten hat.
|
||||
3. Die **Pad**-URL ist mehr wert als die Export-URL: nur mit ihr sind der
|
||||
„im Pad bearbeiten"-Knopf und ein späteres Einbetten (siehe unten) ohne
|
||||
weiteren Parameter erreichbar, und Identität/Name des Dokuments werden aus
|
||||
@@ -1042,19 +1047,51 @@ gleichzeitige Änderungen nicht zusammenführen, das Pad kann es. Deshalb
|
||||
erscheint in der Editor-Titelzeile ein Knopf, der das Pad im neuen Tab öffnet
|
||||
(nur bei solchen Dokumenten sichtbar).
|
||||
|
||||
**Stabilitäts-Verzögerung statt rohem Polling.** Beim Abrufen sieht man die
|
||||
anderen mitten im Tippen; eine halb geschriebene Zeile ist eine kaputte Zeile,
|
||||
das Diagramm zuckt und die Warnungen flackern. Übernommen wird ein neuer Text
|
||||
deshalb erst, wenn **zwei** Abrufe hintereinander denselben liefern. Das kostet
|
||||
im Mittel einen Takt Verzögerung und macht die Ansicht ruhig. Bei `?sourceUrl=`
|
||||
gibt es das Problem nicht (man lädt bewusst neu) — es ist also spezifisch für
|
||||
diesen Parameter, wie das Polling selbst.
|
||||
**Geholt wird auf Knopfdruck, nicht selbsttätig — Etherpad drosselt.** Die erste
|
||||
Fassung holte alle 2,5 s im Hintergrund, mit einem Stabilitätstakt (erst
|
||||
übernehmen, wenn zwei Abrufe denselben Text liefern) gegen das Mitlesen halb
|
||||
getippter Zeilen. In der Praxis kam damit fast nichts an: im Netzwerk-Mitschnitt
|
||||
stapelten sich Anfragen und wurden abgebrochen („cancelled").
|
||||
|
||||
Getaktet wird mit 2,5 s und **nicht**, solange der Tab im Hintergrund liegt: Der
|
||||
Export rendert bei jedem Abruf das ganze Pad, und die Gegenseite ist fremde
|
||||
Infrastruktur. Ein Fehlschlag beim Abrufen bleibt **stumm** und lässt den
|
||||
letzten Stand stehen (nur der erste Ladeversuch warnt) — sonst flutete ein
|
||||
Netzaussetzer den Warnbereich.
|
||||
Die Ursache ist nicht Langsamkeit, sondern ein **Rate Limit**:
|
||||
`importExportRateLimiting` ist in Etherpad serienmäßig an und lässt **10 Abrufe
|
||||
je 90 s und IP** zu — der Takt wollte 36. Danach antwortet die Gegenseite nicht
|
||||
mit `429`, sondern **hält die Verbindung offen** (keine Kopfzeilen, keine
|
||||
Antwort), bis der eigene Abbruch sie abreißt. Nachgemessen:
|
||||
|
||||
```
|
||||
12:48:12 Abbruch nach 25 s (0 Bytes)
|
||||
Abbruch nach 25 s (0 Bytes)
|
||||
12:50:23 HTTP 200 nach 0,436 s <- nach ~40 s Pause
|
||||
```
|
||||
|
||||
Zwei Minuten totgestellt, dann sofort in 0,4 s da. Gegen eine Drosselung kann ein
|
||||
Takt nicht gewinnen — er *erzeugt* sie. Also: **ein Knopf**. Damit entfallen
|
||||
Stabilitätstakt, Sichtbarkeits-Wächter, Anti-Stapel-Riegel und Wiederanlauf; was
|
||||
bleibt, ist ein Abruf, wenn jemand ihn will. Das ist auch die ehrlichere Haltung
|
||||
gegenüber fremder Infrastruktur, und es greift gut mit „Was ist neu?" (D28)
|
||||
zusammen: drücken, und was seither in Produktion ging, leuchtet auf.
|
||||
|
||||
Zwei Dinge, die der Knopf braucht und ein stiller Takt nicht:
|
||||
|
||||
- **Rückmeldung während des Abrufs** (das Symbol dreht). Bei gedrosselter
|
||||
Gegenseite sind das bis zu 20 s; ohne Zeichen wirkt der Knopf kaputt.
|
||||
- **Eine Antwort im Fehlerfall.** Ein Hintergrund-Takt durfte stumm scheitern,
|
||||
eine bewusste Handlung nicht. Der Abbruch bekommt dafür einen **eigenen**
|
||||
Warnungstyp `sourceTimeout`: Die `sourceLoad`-Meldung zeigt auf CORS und
|
||||
schickte hier auf die falsche Fährte — richtig ist „warte einen Moment".
|
||||
|
||||
**Der erste Abruf darf scheitern, ohne alles zu verlieren.** In der ersten Fassung
|
||||
standen `padSource` und der Takt *hinter* dem `await` des ersten Abrufs: Ein
|
||||
einziger Fehlschlag — bei dieser Gegenseite der Normalfall — ließ das Dokument
|
||||
tot liegen, ohne Knopf und ohne Wiederversuch, bis zum Neuladen der Seite.
|
||||
Jetzt wird `padSource` **vor** dem Abruf gesetzt; der Knopf erscheint auch, wenn
|
||||
es das Dokument noch nicht gibt, und legt es beim ersten Erfolg an.
|
||||
|
||||
**Wer das Pad-Dokument löscht, meint es.** `deleteDoc()` beendet die Pad-Quelle.
|
||||
Ohne das hätte der (damalige) Takt es wieder angelegt — und beim Anlegen auch
|
||||
gleich aktiviert, den Nutzer also aus dem Dokument gerissen, in das er gewechselt
|
||||
war.
|
||||
|
||||
**Verhältnis zu D20 („keine externen Requests").** Unverändert wie bei D23: Die
|
||||
App lädt von sich aus nichts; der Request entsteht nur, weil der Nutzer eine
|
||||
@@ -1066,13 +1103,33 @@ anderes als bei einer Schriftart. Es bleibt die Entscheidung dessen, der den
|
||||
Link baut — Werkbaum legt von sich aus kein Pad an.
|
||||
|
||||
**Verworfene und aufgeschobene Alternativen:**
|
||||
- **`?sourceUrl=` um Polling erweitern** — hätte bestehenden Links ungefragt
|
||||
wiederholte Requests verpasst und D23 seine klare Semantik gekostet.
|
||||
- **Das Pad als `<iframe>` einbetten** (aufgeschoben, nicht verworfen): Bringt
|
||||
Cursor, Namen, Farben und Chat mit. Kostet aber **beide** Richtungen von D25
|
||||
— in einem cross-origin-iframe gibt es keinen DOM-Zugriff, also kein
|
||||
Alt+Klick → Zeile und keine Cursor-Zeile → Knoten. Bei 75 Knoten ist das die
|
||||
Orientierung. Der Knopf „im Pad bearbeiten" ist die kleine Lösung desselben
|
||||
- **`?sourceUrl=` um wiederholtes Abrufen erweitern** — hätte bestehenden Links
|
||||
ungefragt Requests verpasst und D23 seine klare Semantik gekostet.
|
||||
- **Das Pad als `<iframe>` einbetten** (aufgeschoben, nicht verworfen). Ob der
|
||||
Server das überhaupt zulässt, ist **nachgemessen**: `pad.hostsharing.net`
|
||||
sendet **kein** `X-Frame-Options` und keine CSP mit `frame-ancestors`, und im
|
||||
Versuch hat das Pad in einem fremdstämmigen Rahmen seine inneren Editorframes
|
||||
aufgebaut (`contentWindow.length === 2`, das sind `ace_outer`/`ace_inner`),
|
||||
samt Werkzeugleiste, heiler Einrückung und lebender Socket-Verbindung
|
||||
(Anwesenden-Zähler). Technisch geht es also.
|
||||
|
||||
Zwei Preise. Erstens **`Set-Cookie: token=…; SameSite=Lax`**: Dieses Cookie ist
|
||||
Etherpads Autoren-Identität und wird in einem fremdstämmigen Rahmen **nicht
|
||||
mitgesendet** (Safari blockt Dritt-Cookies grundsätzlich, Chrome je nach
|
||||
Einstellung). Bearbeiten geht, aber man ist bei jedem Laden ein neuer Autor —
|
||||
Name und Farbe halten nicht. Reparieren lässt sich das nur serverseitig
|
||||
(`cookie.sameSite: "None"` in Etherpads `settings.json`), nicht in Werkbaum.
|
||||
Zweitens: Einbetten heißt das **Textpanel ersetzen**, und damit fallen **beide**
|
||||
Richtungen von D25 weg — kein Alt+Klick → Zeile, keine Cursor-Zeile → Knoten,
|
||||
weil ein cross-origin-iframe keinen DOM-Zugriff erlaubt. Bei 75 Knoten ist das
|
||||
die Orientierung.
|
||||
|
||||
Der Ausweg, der sich beim Messen zeigte: das schreibgeschützte Textfeld
|
||||
**behalten** und das Pad daneben stellen. Dann bleibt der Spiegel die Fläche
|
||||
zum Hinschauen (Alt+Klick und Cursor-Synchronisierung arbeiten auf unserem
|
||||
eigenen `<textarea>`), das Pad ist die Fläche zum Schreiben. Preis ist
|
||||
Bildschirmfläche; auf kleinem Bildschirm (D17) sind es dann drei Bereiche.
|
||||
Der Knopf „im Pad bearbeiten" ist bis dahin die kleine Lösung desselben
|
||||
Bedürfnisses; das Einbetten bleibt möglich, weil die Pad-URL vorliegt.
|
||||
- **Echter Etherpad-Client** (socket.io + Easysync-Changesets): Rettet D25 und
|
||||
erlaubt Schreiben aus Werkbaum heraus, kostet aber zwei **Laufzeit**-
|
||||
|
||||
+12
-7
@@ -290,11 +290,13 @@ Export- oder `/timeslider`-Pfad wird abgeschnitten.
|
||||
- Deshalb ist das Textfeld für ein solches Dokument **schreibgeschützt** — ein
|
||||
Knopf in der Editor-Titelzeile öffnet das Pad im neuen Tab. Ohne den Schutz
|
||||
verschwände getippter Text beim nächsten Abruf.
|
||||
- Geholt wird **regelmäßig** (Voreinstellung 2,5 s; nicht, solange der Tab im
|
||||
Hintergrund liegt). Übernommen wird eine Änderung erst, wenn zwei Abrufe
|
||||
hintereinander **denselben** neuen Text liefern — sonst sähe man die anderen
|
||||
mitten im Tippen, und halb geschriebene Zeilen ließen Diagramm und Warnungen
|
||||
flackern.
|
||||
- Geholt wird **auf Knopfdruck**, nicht selbsttätig: Ein Neu-laden-Knopf neben
|
||||
dem Pad-Knopf holt den aktuellen Stand. Etherpad **drosselt** den Export
|
||||
(serienmäßig 10 Abrufe je 90 s und IP); ein Hintergrund-Takt läuft dagegen an
|
||||
und bekommt am Ende gar nichts mehr. Läuft ein Abruf, dreht das Symbol — bei
|
||||
gedrosselter Gegenseite kann das bis zum Abbruch (20 s) dauern.
|
||||
- Der Knopf greift gut mit „Was ist neu?" (§9) zusammen: drücken, und was seither
|
||||
in Produktion ging, leuchtet auf.
|
||||
- **Name ist die vollständige Pad-URL** (nicht der bloße Pad-Name — zwei Pads
|
||||
gleichen Namens auf verschiedenen Hosts wären sonst nicht zu unterscheiden),
|
||||
wie bei `?sourceUrl=`. Identität und Name leiten sich von der **Pad**-Adresse
|
||||
@@ -302,8 +304,11 @@ Export- oder `/timeslider`-Pfad wird abgeschnitten.
|
||||
Dokument, gleich in welcher Schreibweise der Link kam.
|
||||
- `?sourceUrl=` bleibt unverändert: statische Datei, einmal pro Laden geholt.
|
||||
Der eigene Parameter trägt gerade den Unterschied.
|
||||
- Fehler (CORS, 404, Netz) melden sich wie bei `?sourceUrl=`; ein einzelner
|
||||
Aussetzer beim Abrufen bleibt stumm und lässt den letzten Stand stehen.
|
||||
- Fehler (CORS, 404, Netz) melden sich wie bei `?sourceUrl=`. Ein **Abbruch**
|
||||
wegen Zeitablauf bekommt eine eigene Meldung, die die Drosselung benennt —
|
||||
die `?sourceUrl=`-Meldung zeigt auf CORS und schickte hier auf die falsche
|
||||
Fährte. Scheitert schon der erste Abruf, bleibt der Neu-laden-Knopf sichtbar
|
||||
und holt es nach; ein Neuladen der Seite ist nicht nötig.
|
||||
|
||||
Siehe D31.
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
- [~] Working together on one plan (XL)
|
||||
- [x] Watch a shared Etherpad — it merges, we render (S)
|
||||
- [x] Load the pad's plain-text export (XS)
|
||||
- [x] Keep up while the others type (XS)
|
||||
- [x] Reload on demand (XS) %% a timer only trips Etherpad's rate limit
|
||||
+ [?] Embed the pad beside the diagram (M) %% costs the jump to the line
|
||||
- [ ] Git as the shared store (L)
|
||||
| [ ] A file in a repository, changed by pull request (S) %% works today, no code
|
||||
|
||||
+16
-13
@@ -142,19 +142,22 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
|
||||
`tests/remote.test.js`): Export-/Timeslider-Pfad, Query, Fragment und
|
||||
Schrägstriche fallen weg, verlangt wird `/p/<name>` am Ende. Name und id sind
|
||||
die **vollständige** Pad-URL (Pad-Namen sind nur pro Instanz eindeutig).
|
||||
`padSource` muss **vor** `loadActiveIntoEditor()` gesetzt werden — daran hängt
|
||||
der Schreibschutz. `pollPad()` hat drei Riegel, jeder aus einem echten Fehler:
|
||||
`padBusy` (höchstens ein Abruf unterwegs — sonst stapeln sich Anfragen und eine
|
||||
spät eintreffende alte Antwort überschreibt neueren Text, im
|
||||
Netzwerk-Mitschnitt beobachtet), `PAD_FETCH_TIMEOUT_MS` (ohne Abbruch bliebe
|
||||
`padBusy` bei hängender Gegenseite für immer zu) und `visibilityState`
|
||||
(+ `visibilitychange`-Handler, der bei Rückkehr sofort holt). Fehlschläge im
|
||||
Takt bleiben **stumm**; nur der erste Ladeversuch warnt. Der Stabilitätstakt
|
||||
(`padPending`) übernimmt erst beim zweiten gleichen Abruf — sonst sieht man die
|
||||
anderen mitten im Tippen. Beim Prüfen im Vorschau-Browser: `visibilityState`
|
||||
ist dort `hidden` (Polling also aus) und HMR lädt bei jeder Quelländerung neu —
|
||||
ein Reload sieht wie eine geglückte Übernahme aus. Marker auf `window` setzen
|
||||
und hinterher prüfen, sonst beweist der Test nichts.
|
||||
`padSource` muss **vor** dem ersten Abruf und vor `loadActiveIntoEditor()`
|
||||
gesetzt werden: Es trägt den Schreibschutz, und scheitert der erste Abruf
|
||||
(Normalfall bei dieser Gegenseite), bleibt so der Neu-laden-Knopf erreichbar
|
||||
statt das Dokument tot liegen zu lassen. Geholt wird **nur auf Knopfdruck**
|
||||
(`refreshPad`) — **kein Hintergrund-Takt wieder einbauen**: Etherpads
|
||||
`importExportRateLimiting` lässt serienmäßig 10 Abrufe je 90 s und IP zu, und
|
||||
jenseits davon hält die Gegenseite die Verbindung ohne Antwort offen (kein
|
||||
`429`), bis der eigene Abbruch sie abreißt — gemessen: zwei Minuten tot, danach
|
||||
0,4 s. Ein Takt gewinnt dagegen nicht, er erzeugt die Drosselung. Deshalb auch
|
||||
`setPadBusy()` (Symbol dreht, bis zu 20 s) und ein **eigener** Warnungstyp
|
||||
`sourceTimeout` für den Abbruch — `sourceLoad` zeigt auf CORS und schickte hier
|
||||
auf die falsche Fährte. `deleteDoc()` ruft `stopPad()`, sonst legte ein späterer
|
||||
Abruf das gelöschte Dokument wieder an und aktivierte es auch gleich.
|
||||
Beim Prüfen im Vorschau-Browser: HMR lädt bei jeder Quelländerung neu, und ein
|
||||
Reload holt den Text beim Erstabruf — sieht wie eine geglückte Übernahme aus.
|
||||
Marker auf `window` setzen und hinterher prüfen, sonst beweist der Test nichts.
|
||||
- Sprung Diagramm ↔ Text (D25): `render.js` schreibt die Parser-Zeilennummer als
|
||||
`data-line` an jeden Knoten (Geister-Knoten bekommen keine). `jumpToLine()` in
|
||||
`app.js` klappt bei Bedarf das Editor-Panel auf (`revealEditor()`), markiert die
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
<a class="copybtn padlink" id="padLink" href="#" target="_blank" rel="noopener" hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9.5" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
</a>
|
||||
<button type="button" class="copybtn padrefresh" id="padRefresh" hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>
|
||||
</button>
|
||||
<button type="button" class="copybtn" id="copy" data-i18n-title="copyTooltip" data-i18n-aria="copyTooltip" title="Text in die Zwischenablage kopieren" aria-label="Text in die Zwischenablage kopieren">
|
||||
<svg class="ic-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
<svg class="ic-done" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
|
||||
+175
-54
@@ -894,6 +894,9 @@ const I18N = {
|
||||
jumpHint:"Alt+Klick: zur Zeile im Text",
|
||||
padReadonly:"Wird im Pad bearbeitet — hier nur lesen.",
|
||||
padEdit:"Pad zum Bearbeiten öffnen",
|
||||
padRefresh:"Vom Pad neu laden",
|
||||
padWait:"Noch {seconds} s — Etherpad begrenzt die Abrufe",
|
||||
padRateLimitWarn:"Noch nicht neu geladen: Etherpad erlaubt nur wenige Abrufe je Zeitfenster (serienmäßig 10 pro 90 s), so häufiges Nachladen ist leider nicht möglich. In {seconds} s geht es wieder.",
|
||||
riskTooltip:"High Risk – Aufwand noch unklar.",
|
||||
discardedTooltip:"Verworfene Knoten samt Teilbaum ein-/ausblenden",
|
||||
cheapTooltip:"Günstigsten Pfad hervorheben – nicht benötigte Alternativen treten zurück",
|
||||
@@ -926,6 +929,7 @@ const I18N = {
|
||||
st_fertig:"fertig", st_prod:"in Produktion", st_highrisk:"High Risk", st_verworfen:"verworfen",
|
||||
unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.",
|
||||
sourceLoadWarn:"„{url}“ konnte nicht geladen werden ({error}). Die Datei muss per http(s) erreichbar sein und CORS erlauben (Access-Control-Allow-Origin).",
|
||||
sourceTimeoutWarn:"„{url}“ hat innerhalb von {seconds} s nicht geantwortet — der Abruf wurde abgebrochen. Etherpad begrenzt, wie oft der Export geholt werden darf (serienmäßig 10-mal pro 90 s); warte einen Moment und lade dann erneut.",
|
||||
a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yOptional:"optional", a11yLink:"verlinkt",
|
||||
hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.",
|
||||
hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen",
|
||||
@@ -948,6 +952,9 @@ const I18N = {
|
||||
jumpHint:"Alt+click: jump to the line in the text",
|
||||
padReadonly:"Edited in the pad — read-only here.",
|
||||
padEdit:"Open the pad to edit",
|
||||
padRefresh:"Reload from the pad",
|
||||
padWait:"{seconds} s to go — Etherpad limits how often we may fetch",
|
||||
padRateLimitWarn:"Not reloaded yet: Etherpad only allows a few fetches per time window (10 per 90 s by default), so syncing this often is unfortunately not possible. Try again in {seconds} s.",
|
||||
riskTooltip:"High risk – effort still unclear.",
|
||||
discardedTooltip:"Show/hide discarded nodes and their subtree",
|
||||
cheapTooltip:"Highlight the cheapest path – unneeded alternatives recede",
|
||||
@@ -980,6 +987,7 @@ const I18N = {
|
||||
st_fertig:"done", st_prod:"in production", st_highrisk:"high risk", st_verworfen:"discarded",
|
||||
unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.",
|
||||
sourceLoadWarn:"Could not load “{url}” ({error}). The file must be reachable via http(s) and allow CORS (Access-Control-Allow-Origin).",
|
||||
sourceTimeoutWarn:"“{url}” did not answer within {seconds} s — the request was aborted. Etherpad limits how often the export may be fetched (10 times per 90 s by default); wait a moment, then reload.",
|
||||
a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yOptional:"optional", a11yLink:"has link",
|
||||
hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.",
|
||||
hint_all:"sub-task, all required", hint_any:"alternative, choose one",
|
||||
@@ -1002,6 +1010,9 @@ const I18N = {
|
||||
jumpHint:"Alt+clic: ir a la línea en el texto",
|
||||
padReadonly:"Se edita en el pad — aquí solo lectura.",
|
||||
padEdit:"Abrir el pad para editar",
|
||||
padRefresh:"Recargar desde el pad",
|
||||
padWait:"Faltan {seconds} s — Etherpad limita la frecuencia",
|
||||
padRateLimitWarn:"Aún no se ha recargado: Etherpad solo permite unas pocas descargas por ventana de tiempo (10 por 90 s de forma predeterminada), así que sincronizar tan a menudo no es posible. Vuelve a intentarlo en {seconds} s.",
|
||||
riskTooltip:"Alto riesgo – esfuerzo aún incierto.",
|
||||
discardedTooltip:"Mostrar u ocultar los nodos descartados y su subárbol",
|
||||
cheapTooltip:"Resaltar la ruta más económica: las alternativas no necesarias se atenúan",
|
||||
@@ -1034,6 +1045,7 @@ const I18N = {
|
||||
st_fertig:"terminado", st_prod:"en producción", st_highrisk:"alto riesgo", st_verworfen:"descartado",
|
||||
unknownStatusWarn:"Línea {line}: código de estado desconocido «{code}» — mostrado como neutral.",
|
||||
sourceLoadWarn:"No se pudo cargar «{url}» ({error}). El archivo debe ser accesible por http(s) y permitir CORS (Access-Control-Allow-Origin).",
|
||||
sourceTimeoutWarn:"«{url}» no respondió en {seconds} s — se canceló la petición. Etherpad limita la frecuencia de descarga del export (10 veces por 90 s de forma predeterminada); espera un momento y vuelve a cargar.",
|
||||
a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yOptional:"opcional", a11yLink:"con enlace",
|
||||
hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.",
|
||||
hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una",
|
||||
@@ -1056,6 +1068,9 @@ const I18N = {
|
||||
jumpHint:"Alt+clic : aller à la ligne dans le texte",
|
||||
padReadonly:"Modifié dans le pad — lecture seule ici.",
|
||||
padEdit:"Ouvrir le pad pour modifier",
|
||||
padRefresh:"Recharger depuis le pad",
|
||||
padWait:"Encore {seconds} s — Etherpad limite la fréquence",
|
||||
padRateLimitWarn:"Pas encore rechargé : Etherpad n’autorise que quelques récupérations par fenêtre de temps (10 par 90 s par défaut), une synchronisation aussi fréquente n’est donc pas possible. Réessaie dans {seconds} s.",
|
||||
riskTooltip:"Risque élevé – effort encore incertain.",
|
||||
discardedTooltip:"Afficher/masquer les nœuds abandonnés et leur sous-arbre",
|
||||
cheapTooltip:"Mettre en évidence le chemin le moins coûteux – les alternatives inutiles s'estompent",
|
||||
@@ -1088,6 +1103,7 @@ const I18N = {
|
||||
st_fertig:"terminé", st_prod:"en production", st_highrisk:"risque élevé", st_verworfen:"abandonné",
|
||||
unknownStatusWarn:"Ligne {line} : code de statut inconnu « {code} » — affiché comme neutre.",
|
||||
sourceLoadWarn:"Impossible de charger « {url} » ({error}). Le fichier doit être accessible en http(s) et autoriser CORS (Access-Control-Allow-Origin).",
|
||||
sourceTimeoutWarn:"« {url} » n’a pas répondu en {seconds} s — la requête a été interrompue. Etherpad limite la fréquence de récupération de l’export (10 fois par 90 s par défaut) ; attends un instant, puis recharge.",
|
||||
a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yOptional:"facultatif", a11yLink:"avec lien",
|
||||
hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.",
|
||||
hint_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une",
|
||||
@@ -1110,6 +1126,9 @@ const I18N = {
|
||||
jumpHint:"Alt+kliknięcie: przejdź do wiersza w tekście",
|
||||
padReadonly:"Edytowane w padzie — tu tylko do czytania.",
|
||||
padEdit:"Otwórz pad do edycji",
|
||||
padRefresh:"Wczytaj ponownie z padu",
|
||||
padWait:"Jeszcze {seconds} s — Etherpad ogranicza częstość",
|
||||
padRateLimitWarn:"Jeszcze nie wczytano ponownie: Etherpad dopuszcza tylko kilka pobrań w okresie (domyślnie 10 na 90 s), więc tak częsta synchronizacja nie jest możliwa. Spróbuj za {seconds} s.",
|
||||
riskTooltip:"Wysokie ryzyko – nakład jeszcze niejasny.",
|
||||
discardedTooltip:"Pokaż/ukryj odrzucone węzły wraz z poddrzewem",
|
||||
cheapTooltip:"Wyróżnij najtańszą ścieżkę – niepotrzebne alternatywy są przygaszone",
|
||||
@@ -1142,6 +1161,7 @@ const I18N = {
|
||||
st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone",
|
||||
unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.",
|
||||
sourceLoadWarn:"Nie udało się wczytać „{url}” ({error}). Plik musi być dostępny przez http(s) i zezwalać na CORS (Access-Control-Allow-Origin).",
|
||||
sourceTimeoutWarn:"„{url}” nie odpowiedział w ciągu {seconds} s — żądanie przerwano. Etherpad ogranicza częstość pobierania eksportu (domyślnie 10 razy na 90 s); odczekaj chwilę i wczytaj ponownie.",
|
||||
a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yOptional:"opcjonalny", a11yLink:"z linkiem",
|
||||
hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.",
|
||||
hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną",
|
||||
@@ -1164,6 +1184,9 @@ const I18N = {
|
||||
jumpHint:"Alt+клик: перейти к строке в тексте",
|
||||
padReadonly:"Редактируется в паде — здесь только чтение.",
|
||||
padEdit:"Открыть пад для редактирования",
|
||||
padRefresh:"Обновить из пада",
|
||||
padWait:"Ещё {seconds} с — Etherpad ограничивает частоту",
|
||||
padRateLimitWarn:"Пока не обновлено: Etherpad разрешает лишь несколько загрузок за окно времени (по умолчанию 10 за 90 с), поэтому столь частая синхронизация невозможна. Повторите через {seconds} с.",
|
||||
riskTooltip:"Высокий риск – оценка ещё не ясна.",
|
||||
discardedTooltip:"Показать/скрыть отклонённые узлы вместе с поддеревом",
|
||||
cheapTooltip:"Выделить самый дешёвый путь — ненужные альтернативы приглушаются",
|
||||
@@ -1196,6 +1219,7 @@ const I18N = {
|
||||
st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено",
|
||||
unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.",
|
||||
sourceLoadWarn:"Не удалось загрузить «{url}» ({error}). Файл должен быть доступен по http(s) и разрешать CORS (Access-Control-Allow-Origin).",
|
||||
sourceTimeoutWarn:"«{url}» не ответил за {seconds} с — запрос прерван. Etherpad ограничивает частоту загрузки экспорта (по умолчанию 10 раз за 90 с); подождите немного и обновите снова.",
|
||||
a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yOptional:"необязательно", a11yLink:"со ссылкой",
|
||||
hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.",
|
||||
hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну",
|
||||
@@ -1218,6 +1242,9 @@ const I18N = {
|
||||
jumpHint:"Alt+क्लिक: टेक्स्ट में उस पंक्ति पर जाएँ",
|
||||
padReadonly:"पैड में संपादित होता है — यहाँ केवल पढ़ें।",
|
||||
padEdit:"संपादित करने के लिए पैड खोलें",
|
||||
padRefresh:"पैड से फिर लोड करें",
|
||||
padWait:"{seconds} स॰ बाकी — Etherpad बार-बार लेने की सीमा रखता है",
|
||||
padRateLimitWarn:"अभी दोबारा लोड नहीं किया: Etherpad प्रति समय-खिड़की केवल कुछ ही बार लेने देता है (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार), इसलिए इतनी बार सिंक करना संभव नहीं है। {seconds} स॰ में फिर कोशिश करें।",
|
||||
riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।",
|
||||
discardedTooltip:"अस्वीकृत नोड्स और उनके उप-वृक्ष दिखाएँ/छिपाएँ",
|
||||
cheapTooltip:"सबसे किफ़ायती पथ को उजागर करें – अनावश्यक विकल्प मंद हो जाते हैं",
|
||||
@@ -1250,6 +1277,7 @@ const I18N = {
|
||||
st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत",
|
||||
unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।",
|
||||
sourceLoadWarn:"„{url}“ लोड नहीं हो सका ({error})। फ़ाइल http(s) से उपलब्ध होनी चाहिए और CORS की अनुमति देनी चाहिए (Access-Control-Allow-Origin)।",
|
||||
sourceTimeoutWarn:"„{url}“ ने {seconds} स॰ में उत्तर नहीं दिया — अनुरोध रद्द कर दिया गया। Etherpad सीमित करता है कि एक्सपोर्ट कितनी बार लिया जा सके (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार); कुछ क्षण रुकें, फिर दोबारा लोड करें।",
|
||||
a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yOptional:"वैकल्पिक", a11yLink:"लिंक सहित",
|
||||
hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।",
|
||||
hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें",
|
||||
@@ -1277,6 +1305,9 @@ const I18N = {
|
||||
jumpHint:"Alt+点击:跳转到文本中的该行",
|
||||
padReadonly:"在 Pad 中编辑 — 此处只读。",
|
||||
padEdit:"打开 Pad 进行编辑",
|
||||
padRefresh:"从 Pad 重新加载",
|
||||
padWait:"还需 {seconds} 秒 — Etherpad 限制获取频率",
|
||||
padRateLimitWarn:"尚未重新加载:Etherpad 每个时间窗口只允许少量获取(默认每 90 秒 10 次),因此无法如此频繁地同步。请在 {seconds} 秒后再试。",
|
||||
riskTooltip:"高风险 – 工作量尚不明确。",
|
||||
editorTitle:"结构(文本)", diagramTitle:"图表",
|
||||
docSwitchTooltip:"选择或管理文档", docMenuAria:"文档",
|
||||
@@ -1304,6 +1335,7 @@ const I18N = {
|
||||
st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃",
|
||||
unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。",
|
||||
sourceLoadWarn:"无法加载“{url}”({error})。该文件必须可通过 http(s) 访问并允许 CORS(Access-Control-Allow-Origin)。",
|
||||
sourceTimeoutWarn:"“{url}” 在 {seconds} 秒内没有响应 — 请求已中止。Etherpad 会限制导出的获取频率(默认每 90 秒 10 次);请稍候再重新加载。",
|
||||
a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yOptional:"可选", a11yLink:"含链接",
|
||||
hint_indent:"缩进(2 个空格或制表符)定义层级。",
|
||||
hint_all:"子任务,全部必需", hint_any:"备选项,择其一",
|
||||
@@ -1331,6 +1363,9 @@ const I18N = {
|
||||
jumpHint:"Alt+クリック:テキストの該当行へ移動",
|
||||
padReadonly:"パッドで編集します — ここでは読み取り専用です。",
|
||||
padEdit:"編集するにはパッドを開く",
|
||||
padRefresh:"パッドから再読み込み",
|
||||
padWait:"あと {seconds} 秒 — Etherpad は取得頻度を制限します",
|
||||
padRateLimitWarn:"まだ再読み込みしていません: Etherpad は一定時間内の取得回数を制限します(既定で 90 秒あたり 10 回)。これほど頻繁な同期はできません。{seconds} 秒後にもう一度お試しください。",
|
||||
riskTooltip:"高リスク – 規模はまだ不明。",
|
||||
editorTitle:"構造(テキスト)", diagramTitle:"ダイアグラム",
|
||||
docSwitchTooltip:"ドキュメントを選択・管理", docMenuAria:"ドキュメント",
|
||||
@@ -1358,6 +1393,7 @@ const I18N = {
|
||||
st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄",
|
||||
unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。",
|
||||
sourceLoadWarn:"「{url}」を読み込めませんでした({error})。ファイルは http(s) でアクセス可能で、CORS(Access-Control-Allow-Origin)を許可する必要があります。",
|
||||
sourceTimeoutWarn:"「{url}」が {seconds} 秒以内に応答しませんでした — 要求を中止しました。Etherpad はエクスポートの取得回数を制限します(既定で 90 秒あたり 10 回)。少し待ってから再読み込みしてください。",
|
||||
a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yOptional:"任意", a11yLink:"リンクあり",
|
||||
hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。",
|
||||
hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ",
|
||||
@@ -1781,6 +1817,7 @@ function deleteDoc(){
|
||||
const d = activeDoc();
|
||||
if(!d) return;
|
||||
if(!window.confirm(t('docDeleteConfirm', {name: d.name}))) return;
|
||||
if(padSource && padSource.id === d.id) stopPad(); /* danach gibt es nichts mehr zu holen (D31) */
|
||||
docs = docs.filter(x => x.id !== d.id);
|
||||
if(!docs.length) docs = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: INITIAL }];
|
||||
activeId = docs[0].id;
|
||||
@@ -1814,7 +1851,6 @@ function initDocs(){
|
||||
Etherpads Aufgabe ist. Ein Fetch-Pfad, zwei Eingänge. */
|
||||
const SOURCE_PARAM = 'sourceUrl';
|
||||
const ETHERPAD_PARAM = 'etherpad';
|
||||
const PAD_POLL_MS = 2500;
|
||||
function urlParam(name){
|
||||
try{ return new URLSearchParams(location.search).get(name); }catch(_){ return null; }
|
||||
}
|
||||
@@ -1855,6 +1891,21 @@ async function fetchRemote(url, timeoutMs){
|
||||
return await resp.text();
|
||||
} finally { if(timer) clearTimeout(timer); }
|
||||
}
|
||||
/* Geholten Text als Dokument übernehmen und aktivieren. Gemeinsam genutzt vom
|
||||
ersten Ladeversuch und vom Pad-Takt, wenn dieser einen gescheiterten ersten
|
||||
Versuch nachholt — sonst gäbe es zwei Fassungen derselben sechs Zeilen. */
|
||||
function adoptRemote(s, text){
|
||||
flushActive(); /* laufende Bearbeitung nicht verlieren */
|
||||
let d = docs.find(x => x.id === s.id);
|
||||
if(d){ d.text = text; d.name = s.name; }
|
||||
else { d = {id: s.id, name: s.name, text}; docs.push(d); }
|
||||
d.source = s.source;
|
||||
activeId = s.id;
|
||||
sourceWarning = null;
|
||||
computeFresh(s.id, text); /* was ist seit dem letzten Ansehen in Produktion? (D28) */
|
||||
loadActiveIntoEditor();
|
||||
persistDocs();
|
||||
}
|
||||
async function loadRemoteSource(){
|
||||
const s = remoteSource();
|
||||
if(!s) return;
|
||||
@@ -1863,67 +1914,126 @@ async function loadRemoteSource(){
|
||||
render();
|
||||
return;
|
||||
}
|
||||
/* `padSource` **vor** dem ersten Abruf setzen: Es trägt den Schreibschutz
|
||||
(muss also vor loadActiveIntoEditor() stehen) und macht den Neu-laden-Knopf
|
||||
erreichbar. Scheitert der erste Abruf — die Gegenseite fällt beobachtbar mal
|
||||
aus —, bleibt der Knopf also da und man kommt ohne Neuladen weiter. */
|
||||
if(s.live){ padSource = s; padBusy = true; setPadCooldown(PAD_MIN_GAP_MS); }
|
||||
try{
|
||||
const text = await fetchRemote(s.fetchUrl);
|
||||
flushActive(); /* laufende Bearbeitung nicht verlieren */
|
||||
let d = docs.find(x => x.id === s.id);
|
||||
if(d){ d.text = text; d.name = s.name; }
|
||||
else { d = {id: s.id, name: s.name, text}; docs.push(d); }
|
||||
d.source = s.source;
|
||||
activeId = s.id;
|
||||
sourceWarning = null;
|
||||
if(s.live) padSource = s; /* vor loadActiveIntoEditor: setzt den Schreibschutz */
|
||||
computeFresh(s.id, text); /* was ist seit dem letzten Ansehen in Produktion? (D28) */
|
||||
loadActiveIntoEditor();
|
||||
persistDocs();
|
||||
if(s.live) startPadPolling();
|
||||
adoptRemote(s, await fetchRemote(s.fetchUrl));
|
||||
}catch(err){
|
||||
/* CORS-Fehler melden sich als „TypeError: Failed to fetch" ohne Details —
|
||||
der Warntext nennt CORS daher ausdrücklich als wahrscheinliche Ursache. */
|
||||
der Warntext nennt CORS daher ausdrücklich als wahrscheinliche Ursache.
|
||||
Bei einem Pad ist das nicht das Ende: der Knopf holt es nach und räumt die
|
||||
Warnung weg, sobald ein Abruf gelingt. */
|
||||
sourceWarning = {type:'sourceLoad', url: s.fetchUrl, error: (err && err.message) || String(err)};
|
||||
updatePadLink(); /* Knopf zeigen, obwohl kein Dokument entstanden ist */
|
||||
render();
|
||||
}finally{
|
||||
if(s.live) padBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Pad: regelmäßig neu holen (D31) ----------
|
||||
Stumm bei Fehlschlägen — der erste Ladeversuch hat bereits gewarnt, ein
|
||||
Netzaussetzer soll den Warnbereich nicht fluten. Nicht abrufen, solange der
|
||||
Tab im Hintergrund liegt: der Export rendert jedes Mal das ganze Pad, und die
|
||||
Gegenseite ist fremde Infrastruktur. */
|
||||
const PAD_FETCH_TIMEOUT_MS = 10000;
|
||||
/* ---------- Pad: auf Knopfdruck neu holen (D31) ----------
|
||||
Kein Hintergrund-Takt. Der erste Entwurf holte alle 2,5 s selbsttätig — und
|
||||
lief damit in Etherpads **Drosselung**: `importExportRateLimiting` ist
|
||||
serienmäßig an und lässt 10 Abrufe je 90 s und IP zu, der Takt wollte 36.
|
||||
Danach hält die Gegenseite die Verbindung einfach offen (kein `429`, keine
|
||||
Kopfzeilen), der Abbruch reißt sie ab — im Netzwerk-Mitschnitt „cancelled",
|
||||
und es kommt kaum je etwas an. Ein Knopf ist ohnehin ehrlicher gegenüber
|
||||
fremder Infrastruktur: geholt wird, wenn jemand es will. Zusammen mit
|
||||
„Was ist neu?" (D28) ergibt das die bessere Geschichte — Knopf drücken, und
|
||||
was seither in Produktion ging, leuchtet auf. */
|
||||
const PAD_FETCH_TIMEOUT_MS = 20000;
|
||||
/* Mindestabstand zwischen zwei Abrufen. 10 s ergibt höchstens **9** Abrufe je
|
||||
90 s und bleibt damit beweisbar unter Etherpads Voreinstellung (10 je 90 s) —
|
||||
die Drosselung wird also gar nicht erst ausgelöst. Nach einem Abbruch sind wir
|
||||
schon drüber: dann das ganze Fenster abwarten, statt weiter dagegen zu rennen. */
|
||||
const PAD_MIN_GAP_MS = 10000;
|
||||
const PAD_BACKOFF_MS = 90000;
|
||||
let padSource = null; /* Beschreiber der aktiven Pad-Quelle, oder null */
|
||||
let padTimer = null;
|
||||
let padPending = null; /* geholter, noch nicht übernommener Text (Stabilitätstakt) */
|
||||
let padBusy = false; /* höchstens ein Abruf unterwegs (siehe pollPad) */
|
||||
function startPadPolling(){
|
||||
if(padTimer) clearInterval(padTimer);
|
||||
padTimer = setInterval(pollPad, PAD_POLL_MS);
|
||||
let padBusy = false; /* ein Abruf unterwegs — schützt vor Doppelklick */
|
||||
let padNextAllowed = 0; /* Zeitpunkt (ms), ab dem wieder geholt werden darf */
|
||||
let padCoolTimer = null;
|
||||
/* Wer das Pad-Dokument löscht, meint es: danach gibt es nichts mehr zu holen. */
|
||||
function stopPad(){ padSource = null; }
|
||||
function padCoolLeft(){ return Math.max(0, padNextAllowed - Date.now()); }
|
||||
function setPadCooldown(ms){ padNextAllowed = Date.now() + ms; tickPadCooldown(); }
|
||||
/* Der Sekundenzähler läuft **nur** während der Sperre und hält sich selbst an. */
|
||||
function tickPadCooldown(){
|
||||
if(padCoolTimer){ clearInterval(padCoolTimer); padCoolTimer = null; }
|
||||
updatePadRefreshLabel();
|
||||
if(padCoolLeft() <= 0) return;
|
||||
padCoolTimer = setInterval(() => {
|
||||
updatePadRefreshLabel();
|
||||
if(padCoolLeft() <= 0){ clearInterval(padCoolTimer); padCoolTimer = null; }
|
||||
}, 1000);
|
||||
}
|
||||
/* Bei Rückkehr in den sichtbaren Zustand sofort holen: nach einer langen Pause
|
||||
stünde sonst bis zum nächsten Takt ein veralteter Stand da. */
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if(document.visibilityState === 'visible' && padSource) pollPad();
|
||||
});
|
||||
async function pollPad(){
|
||||
/* Sperre sichtbar machen, aber den Knopf **klickbar** lassen: Ein `disabled`
|
||||
erklärt nichts. So kann der Klick den Grund melden (Live-Region `#warn`). */
|
||||
function updatePadRefreshLabel(){
|
||||
if(!padRefreshBtn || padRefreshBtn.hidden) return;
|
||||
const left = Math.ceil(padCoolLeft()/1000);
|
||||
padRefreshBtn.classList.toggle('cooling', left > 0);
|
||||
padRefreshBtn.setAttribute('aria-disabled', left > 0 ? 'true' : 'false');
|
||||
const tip = left > 0 ? t('padWait', {seconds: left}) : t('padRefresh');
|
||||
padRefreshBtn.title = tip;
|
||||
padRefreshBtn.setAttribute('aria-label', tip);
|
||||
}
|
||||
/* Rückmeldung während des Abrufs: Bei einer gedrosselten Gegenseite können das
|
||||
die vollen 20 s sein — ohne Zeichen wirkt der Knopf kaputt. */
|
||||
function setPadBusy(on){
|
||||
if(!padRefreshBtn) return;
|
||||
padRefreshBtn.classList.toggle('busy', on);
|
||||
padRefreshBtn.setAttribute('aria-busy', on ? 'true' : 'false');
|
||||
}
|
||||
async function refreshPad(){
|
||||
const s = padSource;
|
||||
/* `padBusy`: höchstens ein Abruf unterwegs. Ist die Gegenseite langsamer als
|
||||
der Takt, stapelten sich sonst die Anfragen — und eine spät eintreffende
|
||||
alte Antwort überschriebe neueren Text (im Netzwerk-Mitschnitt beobachtet). */
|
||||
if(!s || padBusy || document.visibilityState === 'hidden') return;
|
||||
const d = docs.find(x => x.id === s.id);
|
||||
if(!d) return; /* Dokument gelöscht — nichts nachtragen */
|
||||
let text;
|
||||
if(!s || padBusy) return;
|
||||
if(padCoolLeft() > 0){
|
||||
/* Nicht heimlich nichts tun: Der Grund ist eine fremde Grenze, und die
|
||||
gehört gesagt — sonst wirkt der Knopf kaputt. Die Meldung landet im
|
||||
Warnbereich, der eine Live-Region ist und deshalb angesagt wird. */
|
||||
sourceWarning = {type:'padRateLimit', seconds: Math.ceil(padCoolLeft()/1000)};
|
||||
render();
|
||||
return;
|
||||
}
|
||||
padBusy = true;
|
||||
setPadBusy(true);
|
||||
setPadCooldown(PAD_MIN_GAP_MS);
|
||||
let text;
|
||||
try{ text = await fetchRemote(s.fetchUrl, PAD_FETCH_TIMEOUT_MS); }
|
||||
catch(_){ return; } /* stumm; `finally` gibt den Riegel frei */
|
||||
finally{ padBusy = false; }
|
||||
if(text === d.text){ padPending = null; return; }
|
||||
/* Stabilitätstakt: erst übernehmen, wenn zwei Abrufe hintereinander denselben
|
||||
neuen Text liefern. Sonst sieht man die anderen mitten im Tippen, und eine
|
||||
halb geschriebene Zeile ist eine kaputte Zeile — Diagramm und Warnungen
|
||||
flackerten. Kostet im Mittel einen Takt. */
|
||||
if(padPending !== text){ padPending = text; return; }
|
||||
padPending = null;
|
||||
catch(err){
|
||||
/* Anders als ein Hintergrund-Takt ist das eine bewusste Handlung — sie
|
||||
braucht eine Antwort, auch wenn sie schiefgeht. Ein Abbruch bekommt einen
|
||||
**eigenen** Warnungstyp: `sourceLoad` zeigt auf CORS, und bei einer
|
||||
Drosselung schickte das den Leser auf die falsche Fährte. */
|
||||
if(err && err.name === 'AbortError'){
|
||||
/* Abbruch heißt: Wir sind schon in der Drosselung. Weiterklicken hilft
|
||||
nicht, also das ganze Fenster abwarten — und das dem Nutzer sagen. */
|
||||
setPadCooldown(PAD_BACKOFF_MS);
|
||||
sourceWarning = {type:'sourceTimeout', url: s.fetchUrl,
|
||||
seconds: Math.round(PAD_FETCH_TIMEOUT_MS/1000)};
|
||||
} else {
|
||||
sourceWarning = {type:'sourceLoad', url: s.fetchUrl, error: (err && err.message) || String(err)};
|
||||
}
|
||||
render();
|
||||
return;
|
||||
}
|
||||
finally{ padBusy = false; setPadBusy(false); }
|
||||
if(padRefreshBtn) flashBtn(padRefreshBtn);
|
||||
const d = docs.find(x => x.id === s.id);
|
||||
if(!d){
|
||||
/* Kein Dokument: der erste Ladeversuch ist gescheitert. Jetzt nachholen —
|
||||
anlegen und aktivieren wie beim Laden. */
|
||||
adoptRemote(s, text);
|
||||
return;
|
||||
}
|
||||
/* Ein geglückter Abruf räumt die Warnung des ersten Versuchs weg — sonst
|
||||
stünde „konnte nicht geladen werden", während der Text längst da ist. */
|
||||
const hadWarning = !!sourceWarning;
|
||||
sourceWarning = null;
|
||||
if(text === d.text){ if(hadWarning) render(); return; }
|
||||
d.text = text;
|
||||
persistDocs();
|
||||
if(activeId !== d.id) return; /* im Hintergrund still aktualisiert */
|
||||
@@ -1942,20 +2052,31 @@ async function pollPad(){
|
||||
schreibgeschützt, Knopf in der Titelzeile öffnet das Pad im neuen Tab. Ohne
|
||||
den Schutz verschwände getippter Text beim nächsten Abruf. */
|
||||
const padLink = document.getElementById('padLink');
|
||||
const padRefreshBtn = document.getElementById('padRefresh');
|
||||
function updatePadLink(){
|
||||
const d = activeDoc();
|
||||
const isPad = !!(d && padSource && d.id === padSource.id);
|
||||
src.readOnly = isPad;
|
||||
src.classList.toggle('readonly', isPad);
|
||||
if(isPad) src.title = t('padReadonly'); else src.removeAttribute('title');
|
||||
if(!padLink) return;
|
||||
if(padLink){
|
||||
padLink.hidden = !isPad;
|
||||
if(!isPad) return;
|
||||
if(isPad){
|
||||
padLink.href = padSource.source;
|
||||
const tip = t('padEdit');
|
||||
padLink.title = tip;
|
||||
padLink.setAttribute('aria-label', tip);
|
||||
padLink.title = t('padEdit');
|
||||
padLink.setAttribute('aria-label', t('padEdit'));
|
||||
}
|
||||
}
|
||||
/* Der Neu-laden-Knopf erscheint auch, wenn es das Pad-Dokument noch **nicht**
|
||||
gibt: Dann ist der erste Abruf gescheitert, und genau dieser Knopf ist der
|
||||
Weg zurück — ohne ihn bliebe nur Neuladen der Seite. */
|
||||
if(padRefreshBtn){
|
||||
const pending = !!(padSource && !docs.some(x => x.id === padSource.id));
|
||||
padRefreshBtn.hidden = !(isPad || pending);
|
||||
updatePadRefreshLabel(); /* trägt auch den laufenden Sperr-Zähler */
|
||||
}
|
||||
}
|
||||
if(padRefreshBtn) padRefreshBtn.addEventListener('click', refreshPad);
|
||||
docTrigger.addEventListener('click', e => {
|
||||
/* Desktop: ist der Editor minimiert, stellt ein Klick ihn wieder her
|
||||
(Bubbling zur Titelzeile) statt das Menü zu öffnen. */
|
||||
|
||||
+14
-1
@@ -332,7 +332,20 @@
|
||||
/* „im Pad bearbeiten" (D31) — nur bei einem ?etherpad=-Dokument sichtbar.
|
||||
Das [hidden] braucht die eigene Regel, weil .copybtn ein display setzt. */
|
||||
.padlink{text-decoration:none}
|
||||
.padlink[hidden]{display:none}
|
||||
.padlink[hidden],.padrefresh[hidden]{display:none}
|
||||
/* Läuft ein Abruf, dreht das Symbol: bei gedrosselter Gegenseite (D31) können
|
||||
das die vollen 20 s sein, und ein Knopf ohne Rückmeldung wirkt kaputt. */
|
||||
/* Sperre nach einem Abruf (D31): sichtbar gedämpft, aber **klickbar** — der
|
||||
Klick meldet den Grund, ein `disabled` erklärte nichts. */
|
||||
.padrefresh.cooling{opacity:.45;cursor:not-allowed}
|
||||
.padrefresh.busy{color:var(--or);border-color:var(--or);pointer-events:none}
|
||||
.padrefresh.busy.cooling{opacity:1;cursor:default}
|
||||
.padrefresh.busy svg{animation:padspin 1.1s linear infinite}
|
||||
@keyframes padspin{to{transform:rotate(360deg)}}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.padrefresh.busy svg{animation:none}
|
||||
.padrefresh.busy{opacity:.55}
|
||||
}
|
||||
.dlbtn{gap:3px}
|
||||
.dl-label{font-family:'IBM Plex Mono',monospace;font-size:.62rem;font-weight:600;letter-spacing:.02em}
|
||||
/* Download-Gruppe: Desktop zeigt SVG/PNG inline, der Trigger ist versteckt.
|
||||
|
||||
@@ -10,7 +10,14 @@
|
||||
- mixedGate { line, label } — Geschwister mit gemischtem Gate (SPEC §3)
|
||||
- unknownStatus { line, code } — unbekanntes Statuszeichen (Phase 2)
|
||||
- sourceLoad { url, error } — ?sourceUrl= nicht ladbar (D23); ohne
|
||||
Zeilennummer, erscheint dadurch zuoberst */
|
||||
Zeilennummer, erscheint dadurch zuoberst
|
||||
- padRateLimit { seconds } — zu früh nachgeladen; Werkbaum hat gar nicht
|
||||
erst geholt, um Etherpads Grenze nicht
|
||||
auszulösen (D31)
|
||||
- sourceTimeout { url, seconds } — Abruf abgebrochen, Gegenseite zu langsam
|
||||
(D31). Eigener Typ, weil `sourceLoad` auf
|
||||
CORS zeigt — bei einem Zeitablauf schickt
|
||||
das den Leser auf die falsche Fährte */
|
||||
|
||||
import { esc } from './render.js';
|
||||
|
||||
@@ -24,6 +31,10 @@ export function formatWarning(w, t){
|
||||
return t('unknownStatusWarn', {line: w.line, code: esc(w.code)});
|
||||
case 'sourceLoad':
|
||||
return t('sourceLoadWarn', {url: esc(w.url), error: esc(w.error)});
|
||||
case 'padRateLimit':
|
||||
return t('padRateLimitWarn', {seconds: w.seconds});
|
||||
case 'sourceTimeout':
|
||||
return t('sourceTimeoutWarn', {url: esc(w.url), seconds: w.seconds});
|
||||
default:
|
||||
return `${esc(String(w.type))} (${w.line ?? '?'})`;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,25 @@ describe('formatWarning — vereinheitlichtes Warnungs-Modell', () => {
|
||||
expect(out).toBe('unknownStatusWarn|{"line":3,"code":"<"}');
|
||||
});
|
||||
|
||||
it('sourceLoad escaped die URL und reicht das technische Detail durch', () => {
|
||||
const out = formatWarning({type: 'sourceLoad', url: 'https://a/?x=1&y=2', error: 'HTTP 404'}, t);
|
||||
expect(out).toBe('sourceLoadWarn|{"url":"https://a/?x=1&y=2","error":"HTTP 404"}');
|
||||
});
|
||||
|
||||
/* Eigener Typ, weil sourceLoadWarn auf CORS zeigt: Bei Etherpads Drosselung
|
||||
(10 Abrufe je 90 s) schickte das den Leser auf die falsche Fährte (D31). */
|
||||
it('sourceTimeout ist ein eigener Typ und nennt die Sekunden', () => {
|
||||
const out = formatWarning({type: 'sourceTimeout', url: 'https://p/x&y', seconds: 20}, t);
|
||||
expect(out).toBe('sourceTimeoutWarn|{"url":"https://p/x&y","seconds":20}');
|
||||
});
|
||||
|
||||
/* Kein `url` im Objekt: Der Nutzer hat gar nichts angefordert, was scheitern
|
||||
konnte — Werkbaum hat den Abruf verhindert (D31). */
|
||||
it('padRateLimit nennt die Restzeit und braucht keine URL', () => {
|
||||
expect(formatWarning({type: 'padRateLimit', seconds: 7}, t))
|
||||
.toBe('padRateLimitWarn|{"seconds":7}');
|
||||
});
|
||||
|
||||
it('unbekannter Typ fällt auf eine generische, escapte Meldung zurück', () => {
|
||||
expect(formatWarning({type: 'was?', line: 9}, t)).toBe('was? (9)');
|
||||
expect(formatWarning({type: 'x'}, t)).toBe('x (?)');
|
||||
|
||||
Reference in New Issue
Block a user