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:
mhoennig
2026-07-30 12:59:14 +02:00
parent 3310cab7be
commit 793561c873
11 changed files with 357 additions and 111 deletions
+16 -13
View File
@@ -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
+3
View File
@@ -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>
+177 -56
View File
@@ -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 nautorise que quelques récupérations par fenêtre de temps (10 par 90 s par défaut), une synchronisation aussi fréquente nest 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} » na pas répondu en {seconds} s — la requête a été interrompue. Etherpad limite la fréquence de récupération de lexport (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) 访问并允许 CORSAccess-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) でアクセス可能で、CORSAccess-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;
padLink.hidden = !isPad;
if(!isPad) return;
padLink.href = padSource.source;
const tip = t('padEdit');
padLink.title = tip;
padLink.setAttribute('aria-label', tip);
if(padLink){
padLink.hidden = !isPad;
if(isPad){
padLink.href = padSource.source;
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
View File
@@ -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.
+12 -1
View File
@@ -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 ?? '?'})`;
}
+19
View File
@@ -16,6 +16,25 @@ describe('formatWarning — vereinheitlichtes Warnungs-Modell', () => {
expect(out).toBe('unknownStatusWarn|{"line":3,"code":"&lt;"}');
});
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&amp;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&amp;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 (?)');