feat: Etherpad-Anbindung ausgebaut (D78)
D31 lieh sich fuer die Echtzeit-Zusammenarbeit ein Etherpad: Das Pad war die Schreibflaeche, Werkbaum die Ansicht. Richtig fuer den Zeitpunkt - die schwere Arbeit war dort getan, und Werkbaum hatte kein Backend. Jetzt hat es eins (D76), das dasselbe besser und im Editor selbst kann. D31 bleibt stehen: Was dort gemessen wurde, ist der Grund, warum die Anbindung nie gut wurde - Export-Drosselung (10 je 90 s), SameSite=Lax beim Autoren-Cookie, abgelehnter Socket-Upgrade. Uebrig blieb ein Neu-laden-Knopf und ein schreibgeschuetztes Textfeld. Ein alter ?etherpad=-Link meldet sich, statt still nichts zu tun: Der Parameter bleibt erkannt und ergibt die zeilenlose Warnung padGone, die auf ?live= zeigt (neun Sprachen). Geholt wird nichts - nachgemessen geht keine Anfrage mehr an den Pad-Host. Vorhandene Pad-Dokumente bleiben liegen und werden dabei besser: ohne Schreibschutz sind sie bearbeitbar und sammeln fruehere Staende. Mit ausgebaut, weil sie nur der Anbindung dienten: remote.js samt Tests, der Ansichts-Waehler und sein Splitter, der Neu-laden-Knopf samt Drosselungs-Zaehler, die Warnungen sourceTimeout/padRateLimit, elf i18n- Schluessel x 9 Sprachen - und src.readOnly ganz, denn nur Pad-Dokumente haben es gesetzt (mit ihm updateSnapBtn und sieben tote Waechter). Auch #srcArea faellt weg: den Kasten gab es nur, damit Pad-Rahmen und Spiegel sich einen Bereich teilen. ?sourceUrl= bleibt unangetastet - beide teilten sich einen Fetch-Pfad, das war die Stelle, an der nichts verrutschen durfte. Im Plan wird aus dem #col.pad-Zweig (14 Knoten samt der ganzen "Update by itself"-Gruppe) ein verworfener Knoten mit Begruendung; der gemeinsame Zeigefinger wandert als #col.point heraus, denn !!! gilt jedem geteilten Dokument. 196 Knoten, 0 Warnungen. Nebengewinn: Der README bekommt endlich den Abschnitt zu ?live= - den gab es nie, die Einstiegs-Beschreibung stand weiter beim Pad. Nachgemessen im Browser: ?etherpad= zeigt die Warnung ohne Netz-Anfrage, ?sourceUrl= laedt unveraendert und ist beschreibbar, Textfeld und Zahlenstreifen liegen buendig, Legenden-Splitter teilt wie zuvor (954+10+300), Falten und Staende funktionieren. 501 Tests, 946 Zeilen weniger.
This commit is contained in:
+29
-43
@@ -88,60 +88,46 @@ GitLab-Raw-Links tun das; ein beliebiger Webserver oft nicht. Scheitert das
|
||||
Laden, bleibt der bisherige Stand stehen und eine Warnung nennt die Ursache.
|
||||
Zugelassen sind nur `http`/`https`.
|
||||
|
||||
### Zusammen an einem Plan arbeiten (Etherpad)
|
||||
### Zusammen an einem Plan arbeiten (`?live=`)
|
||||
|
||||
Für Zusammenarbeit in Echtzeit braucht Werkbaum kein eigenes Backend — es leiht
|
||||
sich ein **Etherpad**. Angegeben wird die Pad-Adresse, wie sie im Browser steht
|
||||
(ohne Export-Pfad, den hängt Werkbaum selbst an):
|
||||
Liegt der Plan auf einem Werkbaum-Backend, bearbeiten ihn alle **im Editor
|
||||
selbst** und sehen die Änderungen der anderen, ohne neu zu laden:
|
||||
|
||||
```
|
||||
https://werkbaum.javagil.de/?etherpad=https://pad.hostsharing.net/p/mein-plan
|
||||
https://werkbaum.javagil.de/?live=https://werkbaum.javagil.de/api/v1/documents/<uuid>
|
||||
```
|
||||
|
||||
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.
|
||||
Angelegt wird so ein Dokument über den Eintrag **„Auf den Server legen"** im
|
||||
Dokumenten-Menü: Er lädt den aktiven Plan hoch, schaltet dorthin um und schreibt
|
||||
den Link in die Adresszeile und in die Zwischenablage. Diesen Link weitergeben —
|
||||
die Adresse ist die Einladung, und wer sie kennt, kommt hinein.
|
||||
|
||||
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.
|
||||
**Das Textfeld bleibt beschreibbar.** Nach 1,5 s Ruhe schickt der Editor die
|
||||
Änderung als Zeilen-Diff; ein offener Abruf hält die Gegenrichtung bereit und
|
||||
spielt fremde Änderungen ein. **Die Schreibmarke wandert mit** — fügt jemand
|
||||
oberhalb Zeilen ein, bleibt sie an ihrer Stelle im Text.
|
||||
|
||||
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
|
||||
verschwände getippter Text beim nächsten Abruf.
|
||||
Der **Name** ist der Titel des Dokuments (alle sehen denselben), die vollständige
|
||||
Adresse steht im Tooltip.
|
||||
|
||||
Getestet gegen Etherpad: Der Klartext-Export sendet
|
||||
`Access-Control-Allow-Origin: *`, und die Notation kommt **byte-identisch**
|
||||
zurück — führende Leerzeichen, `-`/`+`/`|`, Statusboxen und `%%` überleben
|
||||
Etherpads Speichermodell, das `-` wird nicht zur Aufzählung umgedeutet.
|
||||
**Überschneiden sich zwei Änderungen wirklich** — dieselben Zeilen —, fragt ein
|
||||
Band oben, wessen Fassung gelten soll: *Fremde übernehmen* oder *Eigene
|
||||
durchsetzen*. Alles andere führt der Server selbst zusammen, ohne zu fragen.
|
||||
Verloren geht nichts: Der verworfene Stand bleibt in den früheren Ständen, jede
|
||||
Version in der Historie des Servers.
|
||||
|
||||
Das Pad lässt sich auch **im Editor-Panel einbetten**: Ein Wähler in der
|
||||
Titelzeile schaltet reihum zwischen *Pad und Text* (durch einen Splitter frei
|
||||
geteilt), *nur Pad* und *nur Text*. Der schmal gezogene Textspiegel behält seinen
|
||||
Zweck — der Sprung zwischen Diagramm und Text arbeitet auf ihm, und in *nur Pad*
|
||||
holt ein Sprung ihn selbst zurück. Der Rahmen wird nur geladen, solange er
|
||||
sichtbar ist, denn ein geladenes Pad verbindet sich und zeigt dich in dessen
|
||||
Anwesenden-Liste.
|
||||
**Ein gemeinsamer Zeigefinger:** `!!!` auf einer Zeile hebt diesen Knoten hervor
|
||||
und holt ihn ins Bild — bei **allen**, die das Dokument ansehen. Das kann ein
|
||||
Cursor nicht. Erkannt nur als alleinstehendes Token, `Achtung!!!` bleibt also ein
|
||||
gewöhnliches Label. Die Marke bleibt stehen, bis jemand sie löscht.
|
||||
|
||||
**Der gemeinsame Zeigefinger:** Schreib `!!!` in eine Zeile, und dieser Knoten
|
||||
wird hervorgehoben und ins Bild geholt — bei **allen**, die auf das Pad schauen.
|
||||
Das kann ein Cursor nicht. Erkannt nur als alleinstehendes Token, `Achtung!!!`
|
||||
bleibt also ein gewöhnliches Label. Die Marke bleibt stehen, bis jemand sie
|
||||
löscht.
|
||||
Das Backend einrichten: siehe [backend/README.md](backend/README.md) und
|
||||
`docs/DECISIONS.md` D76.
|
||||
|
||||
**Bedenke:** Der Plantext liegt damit auf fremder Infrastruktur, und ein Pad ist
|
||||
für jeden lesbar, der die Adresse kennt. Im eingebetteten Rahmen wird Etherpads
|
||||
Autoren-Cookie (`SameSite=Lax`) nicht mitgesendet — man gilt bei jedem Laden als
|
||||
neuer Autor, was nur serverseitig zu beheben ist (`cookie.sameSite: "None"`).
|
||||
|
||||
Aus demselben Grund ist der eingebettete Rahmen vor allem zum **Mitlesen** gut:
|
||||
Sollte das Bearbeiten darin einmal aufhören zu funktionieren, den Ansichts-Wähler
|
||||
einmal durchschalten (das lädt den Rahmen neu) oder über „im Pad bearbeiten" im
|
||||
eigenen Tab weiterarbeiten, wo das Cookie gilt. Siehe `docs/DECISIONS.md` D31
|
||||
und D32.
|
||||
> Dafür lieh sich Werkbaum früher ein **Etherpad** (`?etherpad=`). Das ist
|
||||
> ausgebaut — das Backend kann dasselbe besser und im Editor selbst. Ein alter
|
||||
> `?etherpad=`-Link zeigt heute einen Hinweis hierher, statt still nichts zu
|
||||
> tun; siehe `docs/DECISIONS.md` D78.
|
||||
|
||||
### Lokal ausführen
|
||||
|
||||
|
||||
@@ -83,57 +83,46 @@ http(s) regardless of extension or content type.
|
||||
do; an arbitrary web server often does not. If loading fails the previous
|
||||
content stays and a warning explains why. Only `http`/`https` are allowed.
|
||||
|
||||
### Working on one plan together (Etherpad)
|
||||
### Working on one plan together (`?live=`)
|
||||
|
||||
For real-time collaboration Werkbaum needs no backend of its own — it borrows an
|
||||
**Etherpad**. Pass the pad address as it appears in your browser (no export path,
|
||||
Werkbaum appends that itself):
|
||||
If the plan lives on a Werkbaum backend, everyone edits it **in the editor
|
||||
itself** and sees the others' changes without reloading:
|
||||
|
||||
```
|
||||
https://werkbaum.javagil.de/?etherpad=https://pad.hostsharing.net/p/my-plan
|
||||
https://werkbaum.javagil.de/?live=https://werkbaum.javagil.de/api/v1/documents/<uuid>
|
||||
```
|
||||
|
||||
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.
|
||||
To create such a document, open the document menu and pick **"Put on the
|
||||
server"**: it uploads the active plan, switches to it, and puts the link in the
|
||||
address bar and on your clipboard. Share that link — the address is the
|
||||
invitation, and knowing it is what grants access.
|
||||
|
||||
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.
|
||||
The text area stays **writable**. After 1.5 s of quiet the editor sends the
|
||||
change as a line diff; an open request holds the other direction ready and plays
|
||||
foreign changes in. **The caret travels with them** — if someone inserts lines
|
||||
above you, it stays where it was in the text.
|
||||
|
||||
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
|
||||
protection, anything you typed would vanish on the next fetch.
|
||||
The document's **title** is its name (everyone sees the same one), the full
|
||||
address sits in the tooltip.
|
||||
|
||||
Verified against Etherpad: the plain-text export sends
|
||||
`Access-Control-Allow-Origin: *`, and the notation comes back **byte-identical** —
|
||||
leading spaces, `-`/`+`/`|`, status boxes and `%%` survive Etherpad's storage
|
||||
model, and `-` is not turned into a bullet list.
|
||||
|
||||
The pad can also be **embedded** in the editor panel: a selector in the title bar
|
||||
cycles through *pad and text* (split by a draggable divider), *pad only* and *text
|
||||
only*. The narrow text mirror keeps its purpose — the jump between diagram and
|
||||
text works on it, and in *pad only* a jump brings it back by itself. The frame is
|
||||
only loaded while it is visible, because a loaded pad connects and shows you in
|
||||
the pad's list of people.
|
||||
When two changes really overlap — the same lines — a bar at the top asks whose
|
||||
version should win: *take theirs* or *keep mine*. Everything else the server
|
||||
merges without asking. Nothing is lost either way: the discarded state stays in
|
||||
the earlier states, and every version is in the server's history.
|
||||
|
||||
**A shared pointer:** write `!!!` on a line and that node is highlighted and
|
||||
scrolled into view — for **everyone** looking at the pad, which is something a
|
||||
cursor cannot do. Recognised only as a standalone token, so `Careful!!!` stays an
|
||||
ordinary label. It stays in the text until someone deletes it.
|
||||
scrolled into view — for **everyone** looking at the document, which is
|
||||
something a cursor cannot do. Recognised only as a standalone token, so
|
||||
`Careful!!!` stays an ordinary label. It stays in the text until someone
|
||||
deletes it.
|
||||
|
||||
**Be aware:** your plan text then lives on third-party infrastructure, and a pad
|
||||
is readable by anyone who knows its address. In an embedded frame Etherpad's
|
||||
author cookie (`SameSite=Lax`) is not sent, so you count as a new author on every
|
||||
load — fixable only on the server (`cookie.sameSite: "None"`).
|
||||
Setting up the backend: see [backend/README.md](backend/README.md) and
|
||||
`docs/DECISIONS.md` D76.
|
||||
|
||||
For the same reason the **embedded frame is best for reading along**: if editing
|
||||
in it ever stops working, cycle the view selector once (that reloads the frame),
|
||||
or use the "edit in the pad" button and work in the pad's own tab, where the
|
||||
cookie does apply. See `docs/DECISIONS.md` D31 and D32.
|
||||
> Werkbaum used to borrow an **Etherpad** for this (`?etherpad=`). That is
|
||||
> removed — the backend does the same job better and in the editor itself. An
|
||||
> old `?etherpad=` link now shows a note pointing here rather than silently
|
||||
> doing nothing; see `docs/DECISIONS.md` D78.
|
||||
|
||||
### Running it locally
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ reverse.
|
||||
|
||||
## 2026-08-26
|
||||
|
||||
- The Etherpad integration is gone: collaboration now runs through a Werkbaum backend, and an old `?etherpad=` link says so instead of doing nothing
|
||||
- Plans can live on a Werkbaum backend now: open `?live=<document URL>` and everyone edits the same text, seeing each other's changes without reloading
|
||||
- When two people change the same lines, a bar asks whose version should win — everything else the server merges by itself
|
||||
- The caret stays where you put it when someone else inserts lines above you
|
||||
|
||||
@@ -6660,3 +6660,87 @@ Version), `frontend info` (1.1.162, Commit = HEAD), `backend status`,
|
||||
`backend restart`, `backend backup` samt lokalem Wiederanlauf,
|
||||
`frontend preview` (nichts geschrieben, HEAD unverändert) und die
|
||||
Fehlerpfade (Exit-Code 2 bei unbekanntem Ziel und fehlender Aktion).
|
||||
|
||||
## D78 — Die Etherpad-Anbindung ist ausgebaut
|
||||
D31 lieh sich für die Echtzeit-Zusammenarbeit ein **Etherpad**: Das Pad war die
|
||||
Schreibfläche, Werkbaum die Ansicht. Das war die richtige Entscheidung für den
|
||||
Zeitpunkt — die schwere Arbeit (gleichzeitige Änderungen zusammenführen) war
|
||||
dort seit Jahren getan, und Werkbaum hatte kein Backend. **Jetzt hat es eins**
|
||||
(D76), und das kann dasselbe besser und im Editor selbst. Also raus damit,
|
||||
statt zwei Wege zur selben Sache zu pflegen.
|
||||
|
||||
**D31 bleibt stehen** — Entscheidungen werden nicht gelöscht, sondern
|
||||
fortgeschrieben. Was dort gemessen wurde, gilt weiter und ist der Grund, warum
|
||||
die Anbindung nie gut wurde: Etherpad **drosselt den Export** (serienmäßig 10
|
||||
Abrufe je 90 s und IP), ein Hintergrund-Takt erzeugt die Drosselung, statt sie
|
||||
zu umgehen; das Autoren-Cookie ist `SameSite=Lax` und kommt im eingebetteten
|
||||
Rahmen nicht an, also ist man bei jedem Laden ein neuer Autor; und ein eigener
|
||||
Socket zum Pad wurde von der Gegenseite abgelehnt (D31-Nachtrag, Code 1006).
|
||||
Übrig blieb ein Neu-laden-Knopf und ein schreibgeschütztes Textfeld — die
|
||||
Rückmeldung „funktioniert sowieso nicht gut" ist die ehrliche Zusammenfassung.
|
||||
|
||||
**Was `?live=` besser macht**, in derselben Reihenfolge: Es wird **im Editor
|
||||
geschrieben** statt in einem fremden Rahmen; die Gegenrichtung ist ein offener
|
||||
Abruf statt eines Takts gegen ein fremdes Limit; der Konflikt wird benannt
|
||||
statt versteckt; und der Plantext liegt auf **eigener** Infrastruktur statt auf
|
||||
einem Pad, das jeder lesen kann, der die Adresse kennt.
|
||||
|
||||
**Ein alter Link meldet sich, statt still nichts zu tun.** `?etherpad=` bleibt
|
||||
als **erkannter** Parameter stehen und ergibt die zeilenlose Warnung `padGone`,
|
||||
die auf `?live=` zeigt — in allen neun Sprachen. Der Parameter war geteilt: Wer
|
||||
ihn irgendwo stehen hat (in einer Mail, einem Wiki, einem Lesezeichen), bekäme
|
||||
sonst ein leeres Werkbaum ohne Erklärung. Genau der stille Fehler, den dieses
|
||||
Projekt sonst überall ablehnt (SPEC §4, D59). Es ist bewusst **kein**
|
||||
Rest-Feature: Geholt wird nichts, der Pad-Host sieht keine Anfrage mehr
|
||||
(nachgemessen).
|
||||
|
||||
**Vorhandene Pad-Dokumente bleiben liegen — und werden dabei besser.** Sie sind
|
||||
im localStorage gewöhnliche Dokumente (`{id, name, text, source}`); ohne den
|
||||
Schreibschutz sind sie ab jetzt **bearbeitbar**, sammeln frühere Stände (D54)
|
||||
und lassen sich falten. Ihr Text ist der zuletzt geholte. Nichts wird gelöscht,
|
||||
niemand verliert seinen Plan.
|
||||
|
||||
**Der Schreibschutz verschwindet ganz, nicht nur seine Ursache.** `src.readOnly`
|
||||
wurde ausschließlich von Pad-Dokumenten gesetzt; danach hätten sieben Wächter
|
||||
in Falten, Kurz-IDs, Autovervollständigung und Ständen auf eine Bedingung
|
||||
geprüft, die nie mehr wahr wird — mit Kommentaren, die auf D31 zeigen. Tote
|
||||
Wächter mit veralteter Begründung sind schlechter als keine; ein künftiger
|
||||
Lesemodus braucht ohnehin seine eigene Entscheidung. Mit ihm fällt
|
||||
`updateSnapBtn()` weg: Der Knopf ist jetzt immer da.
|
||||
|
||||
**Eine Layout-Ebene weniger.** `#srcArea` gab es nur, damit Pad-Rahmen und
|
||||
Textspiegel sich einen Bereich teilen konnten, ohne die Legenden-Aufteilung
|
||||
(D26) anzufassen. Ohne Rahmen ist es ein Kasten mit einem Kind. Nachgemessen
|
||||
nach dem Entfernen: `.editor-body` trägt jetzt direkt `srcWrap` · `hintGutter` ·
|
||||
`agenda`, und die drei kacheln die Breite exakt (954 + 10 + 300 px) — der
|
||||
Legenden-Splitter arbeitet unverändert.
|
||||
|
||||
**Mit ausgebaut**, weil sie nur der Anbindung dienten: `remote.js` samt seinen
|
||||
Tests (die Pad-Adressen normalisieren), der Ansichts-Wähler und sein Splitter
|
||||
(`--pcol`/`--prow`), der Neu-laden-Knopf samt Drosselungs-Zähler, die Warnungen
|
||||
`sourceTimeout` und `padRateLimit`, elf i18n-Schlüssel × 9 Sprachen und der
|
||||
Timeout-Parameter von `fetchRemote()`. **`?sourceUrl=` bleibt unangetastet**
|
||||
(D23) — beide teilten sich einen Fetch-Pfad, und das war die eine Stelle, an der
|
||||
beim Schneiden nichts verrutschen durfte.
|
||||
|
||||
**Der Plan sagt es auch.** Im mitgelieferten `werkbaum.werkbaum` wird aus dem
|
||||
`#col.pad`-Zweig (14 Knoten samt der ganzen „Update by itself"-Gruppe, die nur
|
||||
Etherpads Grenze umgehen wollte) **ein** verworfener Knoten `[-] #col.pad` mit
|
||||
Begründung — dieselbe Form, in der dort schon `#bld.ghpages` und `#not.store`
|
||||
stehen. Der eine Knoten, der weiterlebt, wandert heraus: `#col.pad.point` wird
|
||||
`#col.point`, denn der gemeinsame Zeigefinger `!!!` gilt jedem geteilten
|
||||
Dokument, nicht nur einem Pad. Danach 196 Knoten, 0 Warnungen.
|
||||
|
||||
**Nebengewinn: Der README bekommt endlich den Abschnitt zu `?live=`.** Den gab
|
||||
es nie — D76 ist gebaut und dokumentiert (SPEC §9, DECISIONS), aber die
|
||||
Einstiegs-Beschreibung stand weiter beim Pad. Das Ausbauen hätte sonst ein Loch
|
||||
hinterlassen; jetzt steht dort in beiden Sprachen, wie man ein Server-Dokument
|
||||
anlegt, teilt und was bei einem Konflikt passiert.
|
||||
|
||||
**Nachgemessen** im Browser: `?etherpad=…` zeigt die Warnung, und im
|
||||
Netzwerk-Mitschnitt geht **keine** Anfrage an den Pad-Host; `?sourceUrl=` lädt
|
||||
unverändert und das Dokument ist beschreibbar (früher: schreibgeschützt); das
|
||||
Textfeld liegt mit dem Zahlenstreifen bündig (1151 + 20 px), die Zeilennummern
|
||||
sitzen auf ihren Höhen, Pfad und Stationen werden gezeichnet; der
|
||||
Legenden-Splitter teilt wie zuvor. 501 Tests (die 24 Pad-Adress-Tests sind mit
|
||||
`remote.js` gegangen, `padGone` ist dazugekommen).
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ Tastendruck ein Commit); und wer bei einem Backend eigentlich was darf
|
||||
Zu klären ist das Zusammenspiel mit der Update-Anzeige (D45): Sitzt ein
|
||||
Worker vor der Seite, entscheidet **er**, was ausgeliefert wird — „der
|
||||
laufende Build gegen den, den der Server schickt" ist dann nicht mehr
|
||||
dasselbe. Dokumente aus `?sourceUrl=`/`?etherpad=` bleiben naturgemäß
|
||||
dasselbe. Dokumente aus `?sourceUrl=`/`?live=` bleiben naturgemäß
|
||||
online. Im Plan: `#bld.pwa`.
|
||||
- Schlagworte `&tag` mit einer Filter-/Hervorheben-Linse im Diagramm: Der Baum
|
||||
ist genau eine Zerlegung — ein Tag benennt eine Menge von Knoten quer dazu
|
||||
|
||||
+17
-61
@@ -155,7 +155,7 @@ ab, auch außerhalb seines eigenen Teilbaums.
|
||||
|
||||
**Fokusmarke `!!!`** — „schau hier hin": Der Knoten wird im Diagramm
|
||||
hervorgehoben und ins Bild geholt (§9). Gedacht für das gemeinsame Arbeiten an
|
||||
einem Pad (§9, `?etherpad=`): Weil dort niemand den Cursor der anderen sieht,
|
||||
einem Dokument (§9, `?live=`): Weil dort niemand den Cursor der anderen sieht,
|
||||
ist eine Marke **im Text** der einzige Weg, auf eine Stelle zu zeigen — und sie
|
||||
hat etwas, das ein Cursor nicht hat: **alle** sehen dieselbe Stelle.
|
||||
|
||||
@@ -770,59 +770,18 @@ Scheitert das Laden (häufigster Fall: das Ziel sendet keinen
|
||||
`Access-Control-Allow-Origin`-Header, außerdem 404/Netzfehler), bleibt der
|
||||
bisherige Stand stehen und es erscheint eine **Warnung**. Siehe D23.
|
||||
|
||||
### Gemeinsam an einem Pad arbeiten (`?etherpad=`)
|
||||
Für Zusammenarbeit in Echtzeit nimmt der Editor die Adresse eines
|
||||
**Etherpad-Pads** — die Adresse, die im Browser steht, ohne Export-Pfad:
|
||||
`…?etherpad=https://pad.example.org/p/mein-plan`. Werkbaum hängt den
|
||||
Klartext-Export (`/export/txt`) selbst an; ein versehentlich mitgegebener
|
||||
Export- oder `/timeslider`-Pfad wird abgeschnitten.
|
||||
|
||||
- **Das Pad ist die Schreibfläche, Werkbaum die Ansicht.** Alle bearbeiten den
|
||||
Notationstext im Pad, jeder Betrachter sieht das Diagramm mitwachsen. Das
|
||||
Zusammenführen gleichzeitiger Änderungen macht Etherpad; Werkbaum tut es
|
||||
nicht.
|
||||
- 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.
|
||||
- Das Pad kann **im Editor-Panel eingebettet** werden. Ein Wähler in der
|
||||
Titelzeile schaltet reihum zwischen drei Ansichten: **Pad und Text** (beide,
|
||||
durch einen eigenen **Splitter** frei geteilt — Doppelklick setzt zurück),
|
||||
**nur Pad** und **nur Text**. Die Aufteilung wird für nebeneinander und
|
||||
gestapelt getrennt gehalten und bleibt erhalten.
|
||||
- Der schmal gezogene Textspiegel behält seinen Zweck: Der Sprung zwischen
|
||||
Diagramm und Text (§9) arbeitet auf ihm. In „nur Pad" ist er ausgeblendet —
|
||||
ein Sprung holt ihn dann selbst zurück, so wie er ein zugeklapptes
|
||||
Editor-Panel aufklappt.
|
||||
- Der Rahmen wird **nur geladen, wenn er sichtbar ist**. Ein geladenes Pad
|
||||
verbindet sich und zeigt dich in dessen Anwesenden-Liste; „nur Text" ist
|
||||
damit die Ansicht, die nichts von dir verrät.
|
||||
- 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
|
||||
ab, nicht von der Export-Adresse — derselbe Pad ergibt damit genau ein
|
||||
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 **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.
|
||||
### Kein Etherpad mehr (`?etherpad=`, entfallen)
|
||||
Der Editor konnte den Notationstext aus einem **Etherpad-Pad** beziehen: Alle
|
||||
schrieben im Pad, Werkbaum zeigte das Diagramm dazu. Das ist **ausgebaut** —
|
||||
`?live=` (unten) kann dasselbe besser, und zwar im Editor selbst. Ein alter
|
||||
`?etherpad=`-Link ergibt heute eine **Warnung**, die auf `?live=` zeigt, statt
|
||||
still nichts zu tun. Siehe D78; die frühere Ausführung steht in D31.
|
||||
|
||||
### Gemeinsam an einem Server-Dokument arbeiten (`?live=`)
|
||||
Liegt der Plan auf einem Werkbaum-Backend, nimmt der Editor dessen
|
||||
Dokument-Adresse: `…?live=https://example.org/api/v1/documents/<uuid>`.
|
||||
Anders als beim Pad (`?etherpad=`) wird hier **im Editor selbst geschrieben**,
|
||||
und alle sehen die Änderungen der anderen, ohne neu zu laden.
|
||||
Geschrieben wird **im Editor selbst**, und alle sehen die Änderungen der
|
||||
anderen, ohne neu zu laden.
|
||||
|
||||
- **Das Textfeld bleibt beschreibbar.** Nach kurzer Ruhe (1,5 s) schickt der
|
||||
Editor die Änderung als Zeilen-Diff; ein offener Abruf hält die Gegenrichtung
|
||||
@@ -852,8 +811,7 @@ Ausrichtungen getrennt erhalten. Die Legende belegt höchstens 85 % des Panels,
|
||||
damit das Textfeld nie ganz verschwindet. Siehe D26.
|
||||
|
||||
### Was ist neu? (Dokumente von außen)
|
||||
Bei Dokumenten, die von außen kommen (mitgeliefert, per `?sourceUrl=` oder
|
||||
`?etherpad=`), wird
|
||||
Bei Dokumenten, die von außen kommen (mitgeliefert oder per `?sourceUrl=`), wird
|
||||
gezeigt, was sich seit dem letzten Besuch getan hat. **„Neu" heißt: neu in
|
||||
Produktion** — ein Knoten trägt jetzt `[^]` und tat es in der zuletzt gesehenen
|
||||
Fassung nicht. Solche Knoten bekommen einen **gelben Strahlenkranz** nach außen
|
||||
@@ -1018,8 +976,7 @@ eigene ID der Zeile.
|
||||
weiter: Sie fängt keine Taste ab, solange sie nichts anzeigt, und Tab rückt
|
||||
bei geschlossener Liste unverändert ein.
|
||||
- **Kein Vorschlag** bei bloßem `#` (das *definiert* eine ID), im Kommentar
|
||||
(`%%`), im Beschreibungsteil hinter `---` und in schreibgeschützten
|
||||
Pad-Dokumenten (§9).
|
||||
(`%%`) und im Beschreibungsteil hinter `---`.
|
||||
- Der Parser sieht nie etwas davon; `llms.md` (§13) bleibt unberührt. Für
|
||||
Screenreader meldet eine höfliche Live-Region die Trefferzahl und den
|
||||
gewählten Eintrag; das Popup selbst ist `aria-hidden`, normales Tippen
|
||||
@@ -1099,13 +1056,12 @@ fokussierten Knoten (WAI-ARIA-Baum-Idiom).
|
||||
angefasst, solange die übrigen Marken den Zustand noch richtig beschreiben —
|
||||
ein von Hand gesetztes `<` bleibt also stehen. Trifft es nicht mehr zu, werden
|
||||
alle Marken neu gesetzt und das `<` dabei aufgelöst.
|
||||
- Wo der Text **nicht beschreibbar** ist — bei einem Pad-Dokument (§9) —, gilt
|
||||
der Eingriff **je Knoten** (Identität = Label-Pfad, wie bei „Was ist neu?“)
|
||||
und **nur für die Sitzung**; ein Dokumentwechsel setzt ihn zurück. Dasselbe
|
||||
gilt für einen Zustand, der sich in Marken gar nicht ausdrücken lässt (etwa
|
||||
weil eine Fokusmarke `!!!` ihren Knoten immer wieder hervorholt): Dann wird
|
||||
lieber nichts geschrieben, als einen Text zu hinterlassen, der etwas anderes
|
||||
sagt als das Bild.
|
||||
- Lässt sich ein Zustand in Marken **gar nicht ausdrücken** (etwa weil eine
|
||||
Fokusmarke `!!!` ihren Knoten immer wieder hervorholt), wird lieber nichts
|
||||
geschrieben, als einen Text zu hinterlassen, der etwas anderes sagt als das
|
||||
Bild. Der Eingriff gilt dann **je Knoten** (Identität = Label-Pfad, wie bei
|
||||
„Was ist neu?“) und **nur für die Sitzung**; ein Dokumentwechsel setzt ihn
|
||||
zurück.
|
||||
- **Für den ganzen Baum** gibt es im Diagramm-Kopf einen **Durchschalter**:
|
||||
Jeder Druck stellt die **nächste** von vier Voreinstellungen her, reihum —
|
||||
1. **ab Größe M abwärts zugeklappt**: jeder Knoten mit Kindern, dessen
|
||||
|
||||
+16
-115
@@ -131,30 +131,10 @@
|
||||
| [?] #be.auth.token: Single user, one token (S)
|
||||
| [?] #be.auth.oidc: Log in with OIDC (L)
|
||||
- [~] #col: Collaborating on a Diagram (XXL)
|
||||
" The pad merges concurrent edits, Werkbaum renders the plan.
|
||||
- [^] #col.pad: Watch a shared Etherpad — it merges, we render (L)
|
||||
- [^] #col.pad.export: Load the pad's plain-text export (XS)
|
||||
- [^] #col.pad.reload: Reload on demand (XS) %% a timer only trips Etherpad's rate limit
|
||||
- [^] #col.pad.embed: Embed the pad, keep the text mirror beside it (M)
|
||||
- [^] #col.pad.views: Three views: pad and text, pad only, text only (S)
|
||||
- [^] #col.pad.split: Splitter between pad and mirror (XS)
|
||||
- [?] #col.pad.cookie: Editing in the frame stops after a while (S) %% SameSite=Lax, see D31
|
||||
| [?] #col.pad.samesite: Ask the admin for cookie.sameSite = None (XS)
|
||||
| [ ] #col.pad.owntab: Live with it: edit in the pad's own tab (XS)
|
||||
- [^] #col.pad.point: A shared pointer: !!! focuses a node for everyone (XS)
|
||||
- [?] > #col.auto: Update by itself, within about 2 s (M)
|
||||
| [?] #col.own: Our own Etherpad beside Werkbaum (L) %% then every setting below is ours
|
||||
- [ ] #col.own.db: Node and a real database in userland (S) %% the flatfile store is not for production
|
||||
- [ ] #col.own.ram: Book the RAM quota for an own daemon (XS)
|
||||
- [ ] #col.own.proxy: Reverse proxy in .htaccess, websockets included (S) %% Mattermost does it, wiki shows how
|
||||
- [ ] #col.own.unit: systemd user unit so it survives a reboot (XS)
|
||||
- [!] #col.own.patch: Keeping a public service patched (S) %% indefinitely, that is the real cost
|
||||
| [?] #col.auto.limit: Admin raises the export rate limit (XS) %% default is 10 per 90 s
|
||||
| [?] #col.auto.plugin: Admin installs a plugin that pushes the text (S) %% postMessage to the page
|
||||
| [?] #col.auto.relay: Our own backend holds one socket and fans out (XXL) :#be.scaffold
|
||||
| [ ] #col.auto.poll: Poll inside the budget, at best every 9 s (S) %% legal, but not 2 s
|
||||
| [-] #col.auto.socket: Our own socket straight to the pad (L) %% measured: upgrade refused
|
||||
| [-] #col.auto.html: Read the pad's HTML instead of the export (M) %% measured: no CORS header
|
||||
" Reading together is solved, changing together too — typing together at the
|
||||
" same instant is the expensive one.
|
||||
- [^] #col.point: A shared pointer: !!! focuses a node for everyone (XS)
|
||||
- [-] #col.pad: Borrow an Etherpad — it merges, we render (L) %% built, then removed, see D78
|
||||
- [ ] #col.git: Git as the shared store (L)
|
||||
| [ ] #col.git.pr: A file in a repository, changed by pull request (S) %% works today, no code
|
||||
| [?] #col.git.auto: The backend commits every change (L) :#be.scaffold
|
||||
@@ -714,8 +694,8 @@
|
||||
#bld.pwa.sw
|
||||
A worker lets the editor start without a network. It is deliberately dumb:
|
||||
navigations go network-first, and the cache only keeps the last version it
|
||||
has seen of the one self-contained file for the offline case. Texts pulled from a URL
|
||||
or a pad pass through untouched and stay online by their nature.
|
||||
has seen of the one self-contained file for the offline case. Texts pulled from
|
||||
a URL or a server pass through untouched and stay online by their nature.
|
||||
|
||||
#bld.pwa.update
|
||||
The feared migration turned out unnecessary: because the worker only
|
||||
@@ -838,96 +818,17 @@
|
||||
machinery. Reading together is solved, changing together mostly is, and
|
||||
typing together at the same instant is the expensive one.
|
||||
|
||||
#col.point
|
||||
Nobody sees anyone else's caret, so a mark in the text is the only way to
|
||||
point at a place — and it has something a caret has not: everyone sees the
|
||||
same spot.
|
||||
|
||||
#col.pad
|
||||
Let an Etherpad do the hard part: it merges concurrent edits, and Werkbaum
|
||||
only renders the result. Borrowed rather than rebuilt.
|
||||
|
||||
#col.pad.export
|
||||
Every pad offers its content as plain text, with the header that lets a
|
||||
foreign page read it. That one endpoint is the entire integration.
|
||||
|
||||
#col.pad.reload
|
||||
Fetching happens on a button press, not on a timer. The export is rate
|
||||
limited, and a timer does nothing but trip it.
|
||||
|
||||
#col.pad.embed
|
||||
The pad in a frame inside the editor, with our own text mirror beside it.
|
||||
The mirror keeps the jumps between diagram and text working, because a
|
||||
foreign frame gives up no access to its content.
|
||||
|
||||
#col.pad.views
|
||||
Pad and text, pad only, text only. Text only is the view that tells the pad
|
||||
nothing about you — a loaded pad announces you in its list of people.
|
||||
|
||||
#col.pad.split
|
||||
A splitter of its own between frame and mirror, remembered separately for
|
||||
side by side and stacked.
|
||||
|
||||
#col.pad.cookie
|
||||
In a foreign frame the pad's author cookie is not sent, so a session can
|
||||
turn read-only after a while. Not fixable from this side.
|
||||
|
||||
#col.pad.samesite
|
||||
One server setting resolves it. Presumes somebody who can change that
|
||||
setting.
|
||||
|
||||
#col.pad.owntab
|
||||
Or live with it: read along in the frame, and write in the pad's own tab
|
||||
where the cookie works.
|
||||
|
||||
#col.pad.point
|
||||
Nobody sees anybody else's cursor across two applications, so a mark in the
|
||||
text is the only way to point at something. It has one thing a cursor
|
||||
hasn't: everyone sees the same spot.
|
||||
|
||||
#col.auto
|
||||
The view should follow the pad by itself, within a couple of seconds. Every
|
||||
route below was measured rather than guessed at.
|
||||
|
||||
#col.own
|
||||
Running the pad ourselves makes every setting below it ours. It also makes
|
||||
every one of them our job.
|
||||
|
||||
#col.own.db
|
||||
The default file store is not meant for production, so a real database and a
|
||||
runtime to host it are the price of admission.
|
||||
|
||||
#col.own.ram
|
||||
A daemon of one's own needs memory booked for it.
|
||||
|
||||
#col.own.proxy
|
||||
Reaching it needs a reverse proxy that passes websockets through as well.
|
||||
|
||||
#col.own.unit
|
||||
And something that starts it again after a reboot.
|
||||
|
||||
#col.own.patch
|
||||
The real cost is not the setup but keeping a public service patched,
|
||||
indefinitely.
|
||||
|
||||
#col.auto.limit
|
||||
The cheapest effective change: raise the export limit on the pad server. Ten
|
||||
fetches per ninety seconds is the default, and the reason a timer fails.
|
||||
|
||||
#col.auto.plugin
|
||||
A pad plugin can push the text to the embedding page. Live, no polling, no
|
||||
limit touched — and nothing to install on our side.
|
||||
|
||||
#col.auto.relay
|
||||
Our own backend holds one connection to the pad and fans out to every
|
||||
viewer. Solves it for everybody at once, and costs a backend.
|
||||
|
||||
#col.auto.poll
|
||||
Staying inside the budget means one fetch every nine seconds at best, shared
|
||||
by everyone behind the same address. Legal, but not two seconds.
|
||||
|
||||
#col.auto.socket
|
||||
Measured and rejected: the pad server refuses the websocket upgrade when it
|
||||
comes from another origin.
|
||||
|
||||
#col.auto.html
|
||||
Measured and rejected: the pad page carries the full text, but sends no
|
||||
header that would let a foreign page read it.
|
||||
Removed. An Etherpad did the hard part for a while: it merged concurrent
|
||||
edits and Werkbaum only rendered the result, borrowed rather than rebuilt.
|
||||
The borrowing is what it cost — a rate-limited export, no writing from here,
|
||||
and an author cookie that a foreign frame never gets. The backend does it
|
||||
better and in our own editor, so this went out again.
|
||||
|
||||
#col.git
|
||||
Keep the plan in a repository. Because it is text, diff, blame, review and
|
||||
|
||||
+21
-53
@@ -76,7 +76,7 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
|
||||
(verworfene einblenden, Pfad an/aus) als **Parameter** — keine Globals; nur
|
||||
`cheapPathOn` lebt als UI-State in `app.js`. Tests: `tests/*.test.js`.
|
||||
- **Was entscheidbar ist, gehört in ein eigenes Modul** — auch bei Features, die
|
||||
wie reine UI aussehen: `remote.js` (Pad-URLs normalisieren, D31),
|
||||
wie reine UI aussehen:
|
||||
`live.js` (Server-Dokumente: Adressen, Zeilen-Diff, Cursor-Rechnung, wann eine
|
||||
Feed-Antwort angewendet werden darf — D76),
|
||||
`warnings.js` (Warnung → Text), `snapshots.js` (frühere Stände: wann entsteht
|
||||
@@ -201,47 +201,18 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
|
||||
Endung `.werkbaum` ist reine Konvention (D24, SPEC §12). Beispieldateien zum
|
||||
Ausprobieren: `docs/examples/*.werkbaum` (nacheinander geöffnet ergeben sie
|
||||
mehrere Dokumente im Wähler).
|
||||
- `?etherpad=` (D31): `remoteSource()` liefert **einen** Beschreiber für beide
|
||||
Eingänge (`?sourceUrl=` und `?etherpad=`), `loadRemoteSource()` holt und legt
|
||||
das Dokument an — ein Fetch-Pfad, ein Warnkanal. Die Normalisierung der
|
||||
Pad-Adresse steht headless in **`remote.js`** (`padUrls`, Tests in
|
||||
`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** 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.
|
||||
- Pad einbetten, drei Ansichten (D31): `padView` ('both'|'pad'|'text') +
|
||||
`applyPadView()`. Der Rahmen bekommt **nur sichtbar** ein `src`, sonst
|
||||
`about:blank` — ein geladenes Pad verbindet sich per Socket und zeigt dich in
|
||||
dessen Anwesenden-Liste. `PAD_VIEWS`/`padView` stehen **oben** bei
|
||||
`cheapPathOn`, nicht beim übrigen Pad-Code: `saveUI()` liest sie und kann schon
|
||||
aus `applySplit()` heraus laufen, weiter unten stünden sie noch in der
|
||||
temporalen Todeszone. Der Splitter Pad|Spiegel (`#padGutter`, `--pcol`/`--prow`)
|
||||
ist eine Kopie des Legenden-Splitters (D26) samt getrennter Persistenz.
|
||||
`revealEditor()` schaltet 'pad' → 'both', sonst zeigte der Sprung (D25) ins
|
||||
Nichts. Das Markup: `#srcArea` umschließt Rahmen + Splitter + `#src`, damit die
|
||||
Legenden-Aufteilung unberührt bleibt — `.editor-body textarea` ist ein
|
||||
Nachfahren-Selektor und greift weiter. Nebenwirkung, die als Behelf taugt:
|
||||
Einmal durch die Ansichten schalten **lädt den Rahmen neu** (über
|
||||
`about:blank`) — das ist der Ausweg, wenn ein eingebettetes Pad nicht mehr
|
||||
beschreibbar ist. Nicht in den Neu-laden-Knopf legen: der zieht nach dem Tippen
|
||||
Spiegel und Diagramm nach, und ein Rahmen-Reload dabei kostete jedes Mal die
|
||||
Schreibmarke im Pad. **Umhängen im DOM lädt jeden `<iframe>` neu** — wer
|
||||
Container um `#srcArea` herum neu aufbaut, wirft die Pad-Sitzung weg.
|
||||
- **Etherpad ist ausgebaut (D78).** `?etherpad=` bleibt als **erkannter**
|
||||
Parameter stehen und ergibt die zeilenlose Warnung `padGone`, die auf `?live=`
|
||||
zeigt — ein geteilter Link soll nicht still ins Leere laufen. Geholt wird
|
||||
nichts. Mit gegangen sind `remote.js`, der Ansichts-Wähler samt Splitter
|
||||
(`--pcol`/`--prow`), der Neu-laden-Knopf, die Warnungen `sourceTimeout`/
|
||||
`padRateLimit` und **der Schreibschutz**: `src.readOnly` wurde nur von
|
||||
Pad-Dokumenten gesetzt, also sind die Wächter in Falten, Kurz-IDs,
|
||||
Autovervollständigung und Ständen weg (und mit ihnen `updateSnapBtn()`).
|
||||
Wer einen Lesemodus wieder einführt, braucht dafür eine eigene Entscheidung.
|
||||
Ebenfalls weg: `#srcArea` — es gab den Kasten nur, damit sich Pad-Rahmen und
|
||||
Spiegel einen Bereich teilen; `.editor-body` trägt jetzt direkt
|
||||
`srcWrap` · `hintGutter` · `agenda`.
|
||||
- Fokusmarke `!!!` (D32, SPEC §1): Parser setzt `focus`, Renderer die Klasse
|
||||
`focusmark`, CSS gibt ihr einen **eigenen Strahlenkranz in Petrol** (Nachtrag zu
|
||||
D32; der geteilte Ring mit `.current` war zu leise). Schein in **hellem** Teal
|
||||
@@ -253,7 +224,7 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
|
||||
Lookbehind (Safari erst ab 16.4), sonst verlöre `Achtung!!!` seine
|
||||
Ausrufezeichen. Extraktion **nach** den Tags; der Kommentar fällt vorher weg,
|
||||
eine Marke hinter `%%` wirkt also nicht. `revealFocusMark()` scrollt **nur bei
|
||||
Änderung** (Schlüssel = Label-Text, nicht Zeilennummer — Umsortieren im Pad soll
|
||||
Änderung** (Schlüssel = Label-Text, nicht Zeilennummer — Umsortieren soll
|
||||
nicht neu scrollen), sonst zöge jeder Neubau den Blick zurück. Die Regel braucht
|
||||
den `#out`-Präfix wie `.current` (`ul.or .node{box-shadow:none}` ist
|
||||
spezifischer) **und** die Ausnahme
|
||||
@@ -317,10 +288,9 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
|
||||
diesen Versatz (`drop`) nach oben, sonst stehen sie durchgehend zu tief.
|
||||
Aufgerufen wird aus `render()` (dort kommt auch die Warnungsmenge her) und aus
|
||||
einem `ResizeObserver` auf `#src` (Umbruch hängt an der Breite). Markup:
|
||||
`#srcWrap` umschließt Streifen + Textfeld; die Pad-Regeln (D31) teilen gegen
|
||||
**diese Spalte** (`.src-area.pv-both>.src-wrap`), nicht gegen das nackte
|
||||
Textfeld. `caretLine` steht deshalb **oben** bei `padView` — der Streifen liest
|
||||
sie und hängt an `render()` (temporale Todeszone, siehe D31).
|
||||
`#srcWrap` umschließt Streifen + Textfeld. `caretLine` steht **oben** bei den
|
||||
übrigen Ansichts-Variablen — der Streifen liest sie und hängt an `render()`
|
||||
(temporale Todeszone).
|
||||
- Touch-Langdruck (D25): Der Timer (500 ms) setzt **nur** die Klasse `armed`
|
||||
(Petrol-Ring); `jumpToLine()` läuft im `touchend`-Handler. Nicht zurück in den
|
||||
Timer verlegen — **`focus()` aus einem Timer gilt in mobilen Browsern nicht als
|
||||
@@ -396,10 +366,8 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
|
||||
`setRangeText` zerstören die Undo-Historie (gemessen). Zwei Fallen: Ein
|
||||
Textfeld mit `display:none`-Vorfahr nimmt **kein** `execCommand` an (Klasse
|
||||
`writing-fold` schaltet es kurz sichtbar), und der nötige Fokus zieht auf
|
||||
Mobil die Tastatur hoch (`keyboardOnJump(true)`). Bei `src.readOnly` (Pad,
|
||||
D31) wird nicht geschrieben, dort trägt weiter `foldOverrides` — die
|
||||
Überlagerungen werden nach erfolgreichem Schreiben geleert, sonst maskieren
|
||||
sie den Text. Der **Umschalter** im Diagramm-Kopf (`applyFoldPreset`, D44)
|
||||
Mobil die Tastatur hoch (`keyboardOnJump(true)`). Die Überlagerungen werden
|
||||
nach erfolgreichem Schreiben geleert, sonst maskieren sie den Text. Der **Umschalter** im Diagramm-Kopf (`applyFoldPreset`, D44)
|
||||
geht denselben Weg: Überlagerungen für ALLE faltbaren Knoten setzen
|
||||
(`atMostM(n)` bzw. `false`), dann `writeAllFoldMarks()` — der aus
|
||||
`writeFoldToText()` herausgelöste Voll-Rewrite; einen minimalen Patch gibt es
|
||||
@@ -551,7 +519,7 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
|
||||
im Textbereich die Pfad-Linie weg (gemessen). Die Sprünge (D25) schalten den
|
||||
Bereich selbst um: `revealEditor()` auf Text, `focusNodeOfCaret()` aufs
|
||||
Diagramm — **vor** dem Zentrieren. Zustand in `werkbaum-ui` (`mobilePane`),
|
||||
Variable oben bei `padView` deklariert (saveUI liest sie).
|
||||
Variable oben deklariert (saveUI liest sie).
|
||||
`applyLayout` ruft auf Mobil **kein** `applySplit`.
|
||||
**Inhalte ~25 % kleiner** (D17-Nachtrag 2): `MOBILE_ZOOM = 0.75` als **Faktor**
|
||||
auf den Nutzer-Zoom — `effZoom()` ist die Wahrheit, die `applyZoom()` setzt und
|
||||
@@ -647,7 +615,7 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
|
||||
**aktuellen** Dokuments geht deshalb über `execCommand('insertText')`
|
||||
(`replaceTextUndoable()` für ganze Texte, `writeAt()` für Bereiche).
|
||||
`src.value =` ist nur beim **Laden eines anderen** Dokuments richtig
|
||||
(`loadActiveIntoEditor`, Dokumentwechsel, Pad-Abruf) — dorthin gibt es nichts
|
||||
(`loadActiveIntoEditor`, Dokumentwechsel, URL-Abruf) — dorthin gibt es nichts
|
||||
zurückzunehmen.
|
||||
- **ID-Vorschläge `:#` (D63):** Die Regeln stehen headless in `autocomplete.js`
|
||||
(`depFragment`/`collectIds`/`matchIds`, Tests); app.js verdrahtet nur Popup,
|
||||
|
||||
+1
-18
@@ -103,15 +103,6 @@
|
||||
<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>
|
||||
</button>
|
||||
</span>
|
||||
<button type="button" class="copybtn padview" id="padViewBtn" data-view="text" hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect class="frame" x="2.5" y="5" width="19" height="14" rx="2"/><line class="ic-both" x1="12" y1="5" x2="12" y2="19"/><rect class="ic-pad" x="3.5" y="6" width="8" height="12" rx="1" fill="currentColor" stroke="none"/><rect class="ic-text" x="12.5" y="6" width="8" height="12" rx="1" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<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>
|
||||
@@ -126,14 +117,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-body">
|
||||
<!-- Pad-Rahmen + interner Spiegel teilen sich einen eigenen Bereich, damit die
|
||||
Legenden-Aufteilung (D26) unberührt bleibt. Reihenfolge: Pad, Splitter,
|
||||
Spiegel — gezogen wird von der Spiegelseite aus, wie bei der Legende. -->
|
||||
<div class="src-area pv-text" id="srcArea">
|
||||
<iframe class="padframe" id="padFrame" title="Etherpad" hidden></iframe>
|
||||
<div class="pad-gutter" id="padGutter" role="separator" data-i18n-title="gutterTooltip" data-i18n-aria="padGutterAria" title="Ziehen zum Verschieben, Doppelklick setzt zurück" aria-label="Pad und Texteditor größenverändern" hidden></div>
|
||||
<!-- Zeilennummern + Textfeld sind eine Spalte: der Splitter oben teilt den
|
||||
Rahmen gegen diese Spalte, nicht gegen das nackte Textfeld. -->
|
||||
<!-- Zeilennummern + Textfeld sind eine Spalte (D33). -->
|
||||
<div class="src-wrap" id="srcWrap">
|
||||
<div class="lineno" id="lineNos" aria-hidden="true"><div class="lineno-inner"></div></div>
|
||||
<!-- `wrap="off"`: Zeilen werden nicht umbrochen, lange Zeilen bekommen
|
||||
@@ -142,7 +126,6 @@
|
||||
Rand und lässt die Ebene verschwinden. -->
|
||||
<textarea id="src" wrap="off" spellcheck="false" data-i18n-aria="editorTitle" aria-label="Struktur (Text)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint-gutter" id="hintGutter" role="separator" data-i18n-title="gutterTooltip" data-i18n-aria="hintGutterAria" title="Ziehen zum Verschieben, Doppelklick setzt zurück" aria-label="Editor und Legende größenverändern" hidden></div>
|
||||
<div class="agenda" id="agenda">
|
||||
<button type="button" class="agenda-summary" id="agendaToggle" aria-expanded="false" aria-controls="hint" data-i18n="agenda">Agenda</button>
|
||||
|
||||
@@ -10,7 +10,8 @@ Files are UTF-8 with LF line endings; the extension `.werkbaum` is a
|
||||
convention (plain `.txt` works too). The editor at
|
||||
https://werkbaum.javagil.de renders the notation as a diagram;
|
||||
`?sourceUrl=<url>` loads any CORS-readable http(s) text file, and
|
||||
`?etherpad=<pad-url>` follows a live Etherpad.
|
||||
`?live=<document-url>` opens a document on a Werkbaum backend that several
|
||||
people edit at once.
|
||||
|
||||
## Line format
|
||||
|
||||
|
||||
+52
-427
@@ -3,7 +3,6 @@ import { parse, setFoldMark, expandShortIds, shortIdClosed } from './parser.js';
|
||||
import { computeCheapPlan, overloadedAssignee, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, presetFoldSet, lineTargets } from './model.js';
|
||||
import { esc, renderTreeHtml, TIP_RULE } from './render.js';
|
||||
import { formatWarning, warningText } from './warnings.js';
|
||||
import { padUrls } from './remote.js';
|
||||
import * as live from './live.js';
|
||||
import { depFragment, collectIds, matchIds, depIdAt, idLine } from './autocomplete.js';
|
||||
import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel }
|
||||
@@ -69,18 +68,14 @@ let showIds = false; /* Knoten-IDs im Diagramm einblenden (D56) */
|
||||
Der Faltzustand steht im Text (D38), die Position ist nur der Zeiger darin. */
|
||||
const FOLD_CYCLE = ['small', 'path', 'closed', 'open'];
|
||||
let foldCycleNext = 0, foldCycleApplied = -1;
|
||||
/* Ansicht bei einem Pad-Dokument (D31): beide | nur Pad | nur Spiegel. Hier oben
|
||||
deklariert, weil saveUI() sie liest und schon aus applySplit() heraus laufen
|
||||
kann — weiter unten stünde sie dann noch in der temporalen Todeszone. */
|
||||
const PAD_VIEWS = ['both', 'pad', 'text'];
|
||||
let padView = 'both';
|
||||
/* Sichtbarer Bereich auf kleinem Bildschirm (D17-Nachtrag): dort ist immer
|
||||
genau einer zu sehen. Aus demselben Grund hier oben deklariert wie `padView`
|
||||
— saveUI() liest ihn und läuft schon aus applySplit() heraus. */
|
||||
genau einer zu sehen. Hier oben deklariert, weil saveUI() ihn liest und schon
|
||||
aus applySplit() heraus laufen kann — weiter unten stünde er dann noch in der
|
||||
temporalen Todeszone. */
|
||||
let mobilePane = 'diagram';
|
||||
/* Cursor-Zeile (D25) — bleibt `null`, bis der Cursor das erste Mal bewegt wurde,
|
||||
sonst wäre nach dem Laden ungefragt die Wurzel markiert. Hier oben deklariert
|
||||
wie `padView`: der Zeilennummern-Streifen (D33) liest sie, und der hängt an
|
||||
sonst wäre nach dem Laden ungefragt die Wurzel markiert. Aus demselben Grund
|
||||
hier oben: der Zeilennummern-Streifen (D33) liest sie, und der hängt an
|
||||
render(). */
|
||||
let caretLine = null, currentNodeEl = null;
|
||||
/* Warnung des ?sourceUrl-Ladens (D23) — zeilenlos und persistent, siehe render(). */
|
||||
@@ -242,7 +237,7 @@ function render(){
|
||||
/* Fokusmarke `!!!` (SPEC §1): Der erste markierte Knoten wird ins Bild geholt —
|
||||
aber nur, wenn sich die Marke **geändert** hat. Sonst zöge jeder Neubau des
|
||||
Baums den Blick zurück und man käme nicht weg. Verglichen wird das Label des
|
||||
Knotens, nicht die Zeilennummer: Umsortieren im Pad soll nicht als neue Marke
|
||||
Knotens, nicht die Zeilennummer: Umsortieren soll nicht als neue Marke
|
||||
gelten. Die Hervorhebung selbst macht die Klasse `focusmark` aus dem Renderer,
|
||||
hier geht es nur ums Scrollen. */
|
||||
let lastFocusMark = null;
|
||||
@@ -1074,14 +1069,6 @@ function scrollEditorToOffset(offset){
|
||||
|
||||
/* Ist das Editor-Panel zugeklappt, muss der Sprung es erst öffnen. */
|
||||
function revealEditor(){
|
||||
/* In der Ansicht „nur Pad" (D31) ist der Spiegel ausgeblendet — dann gibt es
|
||||
keine Zeile zum Markieren. Der Sprung holt ihn deshalb zurück, genau wie er
|
||||
ein zugeklapptes Editor-Panel aufklappt (D25). */
|
||||
if(padView === 'pad' && padSource && activeId === padSource.id){
|
||||
padView = 'both';
|
||||
applyPadView();
|
||||
saveUI();
|
||||
}
|
||||
if(isMobile()){
|
||||
/* Auf Mobil ist der Text womöglich gar nicht vorn — dann holt der Sprung
|
||||
ihn nach vorn, so wie er auf dem Desktop ein zugeklapptes Panel aufklappt
|
||||
@@ -1133,7 +1120,7 @@ function nodeFromEvent(e){
|
||||
Umklappen im Diagramm ändert den Notationstext. Der Text ist damit auch für
|
||||
die Faltung die eine Quelle der Wahrheit (D14) — die Sitzungs-Überlagerung
|
||||
`foldOverrides` bleibt nur für Dokumente, in die nicht geschrieben werden
|
||||
kann (Pad, schreibgeschützt nach D31). */
|
||||
kann. */
|
||||
|
||||
/* Der Sollzustand als Menge von Label-Pfad-Schlüsseln: heutiger Anfangszustand
|
||||
aus dem Text, überlagert von den Eingriffen — dieselbe Rechnung wie in
|
||||
@@ -1222,7 +1209,6 @@ function withEditorWritable(fn){
|
||||
/* Schreibt den Faltzustand in den Text. Liefert false, wenn nicht geschrieben
|
||||
werden konnte — dann bleibt die Sitzungs-Überlagerung stehen. */
|
||||
function writeFoldToText(line, collapsed){
|
||||
if(src.readOnly) return false; /* Pad-Dokument (D31) */
|
||||
const roots = parse(src.value).roots;
|
||||
if(!roots.length) return false;
|
||||
const want = desiredFoldKeys(roots);
|
||||
@@ -1273,8 +1259,8 @@ function applyFoldPreset(mode){
|
||||
if(!n.children.length) return; /* nur faltbare Knoten */
|
||||
foldOverrides.set(key, want.has(n));
|
||||
});
|
||||
if(!src.readOnly && writeAllFoldMarks(roots, desiredFoldKeys(roots))) foldOverrides.clear();
|
||||
else render(); /* Pad o. nicht ausdrückbar: Überlagerung trägt */
|
||||
if(writeAllFoldMarks(roots, desiredFoldKeys(roots))) foldOverrides.clear();
|
||||
else render(); /* nicht ausdrückbar: Überlagerung trägt */
|
||||
}
|
||||
|
||||
/* Faltung umklappen (SPEC §9, D38). Gelingt das Zurückschreiben, ist der Text
|
||||
@@ -1620,7 +1606,7 @@ function resolveShortId(line){
|
||||
writeShortId(line);
|
||||
}
|
||||
function writeShortId(line){
|
||||
if(src.readOnly || line == null) return;
|
||||
if(line == null) return;
|
||||
/* **Nicht** sofort schreiben: Beide Wege hierher hängen am
|
||||
`input`-Ereignis — der Doppelpunkt unmittelbar, der Zeilenwechsel über die
|
||||
Enter-Taste —, und `execCommand` verweigert den Dienst, wenn es
|
||||
@@ -1751,7 +1737,7 @@ function closeAc(){
|
||||
acCtx = null;
|
||||
}
|
||||
function updateAc(){
|
||||
if(src.readOnly || document.activeElement !== src ||
|
||||
if(document.activeElement !== src ||
|
||||
src.selectionStart !== src.selectionEnd){ closeAc(); return; }
|
||||
const ctx = depFragment(src.value, src.selectionStart);
|
||||
if(!ctx){ acSuppress = null; closeAc(); return; }
|
||||
@@ -2221,21 +2207,11 @@ const I18N = {
|
||||
acHint:"{n} ID-Vorschläge – ↑/↓ wählt, Enter übernimmt",
|
||||
tipClose:"Schließen",
|
||||
tipOpenLink:"Link öffnen",
|
||||
padReadonly:"Wird im Pad bearbeitet — hier nur lesen.",
|
||||
padEdit:"Pad zum Bearbeiten öffnen",
|
||||
padRefresh:"Vom Pad neu laden",
|
||||
liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/<uuid>)?",
|
||||
liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.",
|
||||
liveConflictText:"Jemand hat dieselben Zeilen geändert. Wessen Fassung soll gelten?",
|
||||
liveConflictTheirs:"Fremde übernehmen",
|
||||
liveConflictMine:"Eigene durchsetzen",
|
||||
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.",
|
||||
padViewTooltip:"Ansicht: {state} — klicken zum Wechseln",
|
||||
padView_both:"Pad und Text",
|
||||
padView_pad:"nur Pad",
|
||||
padView_text:"nur Text",
|
||||
padGutterAria:"Pad und Texteditor größenverändern",
|
||||
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",
|
||||
@@ -2307,7 +2283,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.",
|
||||
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.",
|
||||
a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: mindestens {size} (angenommen)", a11yTags:"Zuständig: {names}", a11yId:"ID: #{id}", a11yDeps:"hängt ab von: {ids}", a11yFolded:"eingeklappt, {n} verborgen", a11yEffective:"effektiv: {status}", heldTooltip:"effektiv {eff} — selbst schon {own}, wartet auf Abhängigkeiten", a11yOptional:"optional", a11yFocusMark:"hierhin schauen", a11yLink:"verlinkt",
|
||||
hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.",
|
||||
hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen",
|
||||
@@ -2343,21 +2319,11 @@ const I18N = {
|
||||
acHint:"{n} id suggestions – ↑/↓ to choose, Enter to insert",
|
||||
tipClose:"Close",
|
||||
tipOpenLink:"Open link",
|
||||
padReadonly:"Edited in the pad — read-only here.",
|
||||
padEdit:"Open the pad to edit",
|
||||
padRefresh:"Reload from the pad",
|
||||
liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/<uuid>)?",
|
||||
liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.",
|
||||
liveConflictText:"Someone changed the same lines. Whose version should win?",
|
||||
liveConflictTheirs:"Take theirs",
|
||||
liveConflictMine:"Keep mine",
|
||||
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.",
|
||||
padViewTooltip:"View: {state} — click to switch",
|
||||
padView_both:"pad and text",
|
||||
padView_pad:"pad only",
|
||||
padView_text:"text only",
|
||||
padGutterAria:"Resize pad and text editor",
|
||||
riskTooltip:"High risk – effort still unclear.",
|
||||
discardedTooltip:"Show/hide discarded nodes and their subtree",
|
||||
cheapTooltip:"Highlight the cheapest path – unneeded alternatives recede",
|
||||
@@ -2428,7 +2394,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.",
|
||||
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.",
|
||||
a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: at least {size} (assumed)", a11yTags:"Assigned: {names}", a11yId:"ID: #{id}", a11yDeps:"depends on: {ids}", a11yFolded:"collapsed, {n} hidden", a11yEffective:"effective: {status}", heldTooltip:"effectively {eff} — itself already {own}, waiting on dependencies", a11yOptional:"optional", a11yFocusMark:"look here", 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",
|
||||
@@ -2464,21 +2430,11 @@ const I18N = {
|
||||
acHint:"{n} sugerencias de ID – ↑/↓ elige, Intro inserta",
|
||||
tipClose:"Cerrar",
|
||||
tipOpenLink:"Abrir enlace",
|
||||
padReadonly:"Se edita en el pad — aquí solo lectura.",
|
||||
padEdit:"Abrir el pad para editar",
|
||||
padRefresh:"Recargar desde el pad",
|
||||
liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/<uuid>)?",
|
||||
liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.",
|
||||
liveConflictText:"Alguien cambió las mismas líneas. ¿Qué versión debe prevalecer?",
|
||||
liveConflictTheirs:"Tomar la ajena",
|
||||
liveConflictMine:"Mantener la mía",
|
||||
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.",
|
||||
padViewTooltip:"Vista: {state} — clic para cambiar",
|
||||
padView_both:"pad y texto",
|
||||
padView_pad:"solo pad",
|
||||
padView_text:"solo texto",
|
||||
padGutterAria:"Redimensionar pad y editor de texto",
|
||||
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",
|
||||
@@ -2549,7 +2505,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.",
|
||||
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.",
|
||||
a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: al menos {size} (asumido)", a11yTags:"Responsable: {names}", a11yId:"ID: #{id}", a11yDeps:"depende de: {ids}", a11yFolded:"plegado, {n} ocultos", a11yEffective:"efectivo: {status}", heldTooltip:"efectivamente {eff} — por sí mismo ya {own}, espera dependencias", a11yOptional:"opcional", a11yFocusMark:"mirar aquí", 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",
|
||||
@@ -2585,21 +2541,11 @@ const I18N = {
|
||||
acHint:"{n} suggestions d'ID – ↑/↓ pour choisir, Entrée pour insérer",
|
||||
tipClose:"Fermer",
|
||||
tipOpenLink:"Ouvrir le lien",
|
||||
padReadonly:"Modifié dans le pad — lecture seule ici.",
|
||||
padEdit:"Ouvrir le pad pour modifier",
|
||||
padRefresh:"Recharger depuis le pad",
|
||||
liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/<uuid>) ?",
|
||||
liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.",
|
||||
liveConflictText:"Quelqu'un a modifié les mêmes lignes. Quelle version doit l'emporter ?",
|
||||
liveConflictTheirs:"Prendre la sienne",
|
||||
liveConflictMine:"Garder la mienne",
|
||||
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.",
|
||||
padViewTooltip:"Vue : {state} — cliquer pour changer",
|
||||
padView_both:"pad et texte",
|
||||
padView_pad:"pad seul",
|
||||
padView_text:"texte seul",
|
||||
padGutterAria:"Redimensionner le pad et l’éditeur de texte",
|
||||
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",
|
||||
@@ -2670,7 +2616,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.",
|
||||
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.",
|
||||
a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : au moins {size} (supposé)", a11yTags:"Responsable : {names}", a11yId:"ID : #{id}", a11yDeps:"dépend de : {ids}", a11yFolded:"replié, {n} masqués", a11yEffective:"effectif : {status}", heldTooltip:"effectivement {eff} — lui-même déjà {own}, en attente de dépendances", a11yOptional:"facultatif", a11yFocusMark:"regarder ici", 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",
|
||||
@@ -2706,21 +2652,11 @@ const I18N = {
|
||||
acHint:"{n} podpowiedzi ID – ↑/↓ wybiera, Enter wstawia",
|
||||
tipClose:"Zamknij",
|
||||
tipOpenLink:"Otwórz link",
|
||||
padReadonly:"Edytowane w padzie — tu tylko do czytania.",
|
||||
padEdit:"Otwórz pad do edycji",
|
||||
padRefresh:"Wczytaj ponownie z padu",
|
||||
liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/<uuid>)?",
|
||||
liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.",
|
||||
liveConflictText:"Ktoś zmienił te same wiersze. Która wersja ma obowiązywać?",
|
||||
liveConflictTheirs:"Przyjmij cudzą",
|
||||
liveConflictMine:"Zachowaj własną",
|
||||
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.",
|
||||
padViewTooltip:"Widok: {state} — kliknij, aby zmienić",
|
||||
padView_both:"pad i tekst",
|
||||
padView_pad:"tylko pad",
|
||||
padView_text:"tylko tekst",
|
||||
padGutterAria:"Zmień rozmiar padu i edytora tekstu",
|
||||
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",
|
||||
@@ -2791,7 +2727,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.",
|
||||
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.",
|
||||
a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: co najmniej {size} (założony)", a11yTags:"Przypisano: {names}", a11yId:"ID: #{id}", a11yDeps:"zależy od: {ids}", a11yFolded:"zwinięte, ukrytych: {n}", a11yEffective:"efektywnie: {status}", heldTooltip:"efektywnie {eff} — sam już {own}, czeka na zależności", a11yOptional:"opcjonalny", a11yFocusMark:"spójrz tutaj", a11yLink:"z linkiem",
|
||||
hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.",
|
||||
hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną",
|
||||
@@ -2827,21 +2763,11 @@ const I18N = {
|
||||
acHint:"{n} подсказок ID – ↑/↓ выбирает, Enter вставляет",
|
||||
tipClose:"Закрыть",
|
||||
tipOpenLink:"Открыть ссылку",
|
||||
padReadonly:"Редактируется в паде — здесь только чтение.",
|
||||
padEdit:"Открыть пад для редактирования",
|
||||
padRefresh:"Обновить из пада",
|
||||
liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/<uuid>)?",
|
||||
liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.",
|
||||
liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?",
|
||||
liveConflictTheirs:"Принять чужую",
|
||||
liveConflictMine:"Оставить свою",
|
||||
padWait:"Ещё {seconds} с — Etherpad ограничивает частоту",
|
||||
padRateLimitWarn:"Пока не обновлено: Etherpad разрешает лишь несколько загрузок за окно времени (по умолчанию 10 за 90 с), поэтому столь частая синхронизация невозможна. Повторите через {seconds} с.",
|
||||
padViewTooltip:"Вид: {state} — нажмите для переключения",
|
||||
padView_both:"пад и текст",
|
||||
padView_pad:"только пад",
|
||||
padView_text:"только текст",
|
||||
padGutterAria:"Изменить размер пада и текстового редактора",
|
||||
riskTooltip:"Высокий риск – оценка ещё не ясна.",
|
||||
discardedTooltip:"Показать/скрыть отклонённые узлы вместе с поддеревом",
|
||||
cheapTooltip:"Выделить самый дешёвый путь — ненужные альтернативы приглушаются",
|
||||
@@ -2912,7 +2838,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 с); подождите немного и обновите снова.",
|
||||
padGoneWarn:"Эта ссылка ведёт на пад Etherpad. Подключения к Etherpad больше нет — совместная работа теперь идёт через бэкенд Werkbaum (?live=…). Вставьте туда текст пада один раз и редактируйте вместе.",
|
||||
a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: не меньше {size} (предполагается)", a11yTags:"Ответственные: {names}", a11yId:"ID: #{id}", a11yDeps:"зависит от: {ids}", a11yFolded:"свёрнуто, скрыто: {n}", a11yEffective:"фактически: {status}", heldTooltip:"фактически {eff} — сам уже {own}, ждёт зависимости", a11yOptional:"необязательно", a11yFocusMark:"смотрите здесь", a11yLink:"со ссылкой",
|
||||
hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.",
|
||||
hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну",
|
||||
@@ -2948,21 +2874,11 @@ const I18N = {
|
||||
acHint:"{n} आईडी सुझाव – ↑/↓ से चुनें, Enter से डालें",
|
||||
tipClose:"बंद करें",
|
||||
tipOpenLink:"लिंक खोलें",
|
||||
padReadonly:"पैड में संपादित होता है — यहाँ केवल पढ़ें।",
|
||||
padEdit:"संपादित करने के लिए पैड खोलें",
|
||||
padRefresh:"पैड से फिर लोड करें",
|
||||
liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/<uuid>)?",
|
||||
liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।",
|
||||
liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?",
|
||||
liveConflictTheirs:"दूसरे का लें",
|
||||
liveConflictMine:"अपना रखें",
|
||||
padWait:"{seconds} स॰ बाकी — Etherpad बार-बार लेने की सीमा रखता है",
|
||||
padRateLimitWarn:"अभी दोबारा लोड नहीं किया: Etherpad प्रति समय-खिड़की केवल कुछ ही बार लेने देता है (डिफ़ॉल्ट रूप से 90 स॰ में 10 बार), इसलिए इतनी बार सिंक करना संभव नहीं है। {seconds} स॰ में फिर कोशिश करें।",
|
||||
padViewTooltip:"दृश्य: {state} — बदलने के लिए क्लिक करें",
|
||||
padView_both:"पैड और टेक्स्ट",
|
||||
padView_pad:"केवल पैड",
|
||||
padView_text:"केवल टेक्स्ट",
|
||||
padGutterAria:"पैड और टेक्स्ट संपादक का आकार बदलें",
|
||||
riskTooltip:"उच्च जोखिम – प्रयास अभी अस्पष्ट।",
|
||||
discardedTooltip:"अस्वीकृत नोड्स और उनके उप-वृक्ष दिखाएँ/छिपाएँ",
|
||||
cheapTooltip:"सबसे किफ़ायती पथ को उजागर करें – अनावश्यक विकल्प मंद हो जाते हैं",
|
||||
@@ -3033,7 +2949,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 बार); कुछ क्षण रुकें, फिर दोबारा लोड करें।",
|
||||
padGoneWarn:"यह लिंक एक Etherpad पैड की ओर इशारा करता है। Etherpad कनेक्शन अब नहीं है — साझा काम अब Werkbaum बैकएंड (?live=…) से होता है। पैड का टेक्स्ट वहाँ एक बार चिपकाएँ और मिलकर संपादित करें।",
|
||||
a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: कम से कम {size} (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yId:"आईडी: #{id}", a11yDeps:"निर्भर: {ids}", a11yFolded:"समेटा हुआ, {n} छिपे", a11yEffective:"प्रभावी: {status}", heldTooltip:"प्रभावी रूप से {eff} — स्वयं {own} है, निर्भरताओं की प्रतीक्षा में", a11yOptional:"वैकल्पिक", a11yFocusMark:"यहाँ देखें", a11yLink:"लिंक सहित",
|
||||
hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।",
|
||||
hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें",
|
||||
@@ -3080,21 +2996,11 @@ const I18N = {
|
||||
acHint:"{n} 个 ID 建议 – ↑/↓ 选择,Enter 插入",
|
||||
tipClose:"关闭",
|
||||
tipOpenLink:"打开链接",
|
||||
padReadonly:"在 Pad 中编辑 — 此处只读。",
|
||||
padEdit:"打开 Pad 进行编辑",
|
||||
padRefresh:"从 Pad 重新加载",
|
||||
liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/<uuid>)吗?",
|
||||
liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。",
|
||||
liveConflictText:"有人改动了同样的行。以谁的版本为准?",
|
||||
liveConflictTheirs:"采用对方的",
|
||||
liveConflictMine:"保留我的",
|
||||
padWait:"还需 {seconds} 秒 — Etherpad 限制获取频率",
|
||||
padRateLimitWarn:"尚未重新加载:Etherpad 每个时间窗口只允许少量获取(默认每 90 秒 10 次),因此无法如此频繁地同步。请在 {seconds} 秒后再试。",
|
||||
padViewTooltip:"视图:{state} — 点击切换",
|
||||
padView_both:"Pad 和文本",
|
||||
padView_pad:"仅 Pad",
|
||||
padView_text:"仅文本",
|
||||
padGutterAria:"调整 Pad 与文本编辑器大小",
|
||||
riskTooltip:"高风险 – 工作量尚不明确。",
|
||||
editorTitle:"结构(文本)", diagramTitle:"图表",
|
||||
docSwitchTooltip:"选择或管理文档", docMenuAria:"文档",
|
||||
@@ -3154,7 +3060,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 次);请稍候再重新加载。",
|
||||
padGoneWarn:"此链接指向一个 Etherpad pad。Etherpad 连接已移除——协作现在通过 Werkbaum 后端(?live=…)进行。把 pad 的文本粘贴过去一次,然后一起编辑。",
|
||||
a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:至少 {size}(假定)", a11yTags:"负责人:{names}", a11yId:"ID:#{id}", a11yDeps:"依赖:{ids}", a11yFolded:"已折叠,隐藏 {n} 项", a11yEffective:"实际:{status}", heldTooltip:"实际为 {eff}——自身已是 {own},等待依赖完成", a11yOptional:"可选", a11yFocusMark:"看这里", a11yLink:"含链接",
|
||||
hint_indent:"缩进(2 个空格或制表符)定义层级。",
|
||||
hint_all:"子任务,全部必需", hint_any:"备选项,择其一",
|
||||
@@ -3201,21 +3107,11 @@ const I18N = {
|
||||
acHint:"ID候補 {n} 件 – ↑/↓で選択、Enterで挿入",
|
||||
tipClose:"閉じる",
|
||||
tipOpenLink:"リンクを開く",
|
||||
padReadonly:"パッドで編集します — ここでは読み取り専用です。",
|
||||
padEdit:"編集するにはパッドを開く",
|
||||
padRefresh:"パッドから再読み込み",
|
||||
liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/<uuid>)ですか。",
|
||||
liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。",
|
||||
liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。",
|
||||
liveConflictTheirs:"相手の版",
|
||||
liveConflictMine:"自分の版",
|
||||
padWait:"あと {seconds} 秒 — Etherpad は取得頻度を制限します",
|
||||
padRateLimitWarn:"まだ再読み込みしていません: Etherpad は一定時間内の取得回数を制限します(既定で 90 秒あたり 10 回)。これほど頻繁な同期はできません。{seconds} 秒後にもう一度お試しください。",
|
||||
padViewTooltip:"表示: {state} — クリックで切り替え",
|
||||
padView_both:"パッドとテキスト",
|
||||
padView_pad:"パッドのみ",
|
||||
padView_text:"テキストのみ",
|
||||
padGutterAria:"パッドとテキストエディターのサイズを変更",
|
||||
riskTooltip:"高リスク – 規模はまだ不明。",
|
||||
editorTitle:"構造(テキスト)", diagramTitle:"ダイアグラム",
|
||||
docSwitchTooltip:"ドキュメントを選択・管理", docMenuAria:"ドキュメント",
|
||||
@@ -3275,7 +3171,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 回)。少し待ってから再読み込みしてください。",
|
||||
padGoneWarn:"このリンクは Etherpad のパッドを指しています。Etherpad 連携は廃止されました。共同編集は Werkbaum バックエンド(?live=…)で行います。パッドの本文を一度貼り付ければ、複数人で編集できます。",
|
||||
a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: 少なくとも {size}(想定)", a11yTags:"担当: {names}", a11yId:"ID: #{id}", a11yDeps:"依存先: {ids}", a11yFolded:"折りたたみ中、{n} 件非表示", a11yEffective:"実効: {status}", heldTooltip:"実効では {eff} — 自身は既に {own}、依存待ち", a11yOptional:"任意", a11yFocusMark:"ここを見る", a11yLink:"リンクあり",
|
||||
hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。",
|
||||
hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ",
|
||||
@@ -3555,11 +3451,6 @@ function saveUI(){
|
||||
agenda: !!document.querySelector('.agenda.open'),
|
||||
hcol: app.style.getPropertyValue('--hcol') || null,
|
||||
hrow: app.style.getPropertyValue('--hrow') || null,
|
||||
/* Pad-Ansicht + Aufteilung Pad|Spiegel (D31). Global über alle Dokumente,
|
||||
wie der übrige Ansichts-Zustand (D22). */
|
||||
padView: padView,
|
||||
pcol: app.style.getPropertyValue('--pcol') || null,
|
||||
prow: app.style.getPropertyValue('--prow') || null,
|
||||
/* Sichtbarer Bereich auf kleinem Bildschirm (D17-Nachtrag) — ebenfalls
|
||||
global über alle Dokumente wie der übrige Ansichts-Zustand. */
|
||||
mobilePane: mobilePane,
|
||||
@@ -3609,9 +3500,6 @@ function restoreState(){
|
||||
if(ui && ui.hcol) app.style.setProperty('--hcol', ui.hcol);
|
||||
if(ui && ui.hrow) app.style.setProperty('--hrow', ui.hrow);
|
||||
if(ui && (ui.mobilePane === 'diagram' || ui.mobilePane === 'text')) mobilePane = ui.mobilePane;
|
||||
if(ui && PAD_VIEWS.indexOf(ui.padView) >= 0) padView = ui.padView;
|
||||
if(ui && ui.pcol) app.style.setProperty('--pcol', ui.pcol);
|
||||
if(ui && ui.prow) app.style.setProperty('--prow', ui.prow);
|
||||
setAgendaOpen(!!(ui && ui.agenda));
|
||||
applyZoom();
|
||||
restoring = false;
|
||||
@@ -3633,7 +3521,6 @@ function updateDocName(){
|
||||
? d.source + '\n' + t('docSwitchTooltip')
|
||||
: t('docSwitchTooltip');
|
||||
}
|
||||
updatePadLink(); /* Schreibschutz + Pad-Knopf hängen am aktiven Dokument (D31) */
|
||||
}
|
||||
let renamingId = null; /* id des gerade inline umbenannten Dokuments (oder null) */
|
||||
/* Kam das Umbenennen von einem frisch angelegten Dokument? Dann geht es danach
|
||||
@@ -3741,9 +3628,8 @@ function flushActive(){ const d = activeDoc(); if(d) d.text = src.value; }
|
||||
wer weiter zurück will, hat Git.
|
||||
|
||||
Die Regeln stehen in snapshots.js und sind dort getestet; hier bleibt nur,
|
||||
was DOM oder Speicher berührt: welches Dokument aktiv ist, ob sein Text
|
||||
beschreibbar ist (Pad-Dokumente, D31, bleiben außen vor — ein alter Stand
|
||||
ließe sich dort gar nicht einsetzen), und das Nachzeichnen des Menüs. */
|
||||
was DOM oder Speicher berührt: welches Dokument aktiv ist und das
|
||||
Nachzeichnen des Menüs. */
|
||||
let snaps = {}; /* {docId: [{t, text}, …]} — ältester zuerst */
|
||||
let snapBase = ''; /* Text bei Dokumentwechsel; Vergleich, solange es keinen Stand gibt */
|
||||
|
||||
@@ -3751,7 +3637,7 @@ function loadSnaps(){ snaps = parseSnaps(localStorage.getItem(LS_SNAPS)); }
|
||||
|
||||
function snapshotNow(manuell){
|
||||
const d = activeDoc();
|
||||
if(!d || src.readOnly) return false;
|
||||
if(!d) return false;
|
||||
const text = src.value;
|
||||
if(!addSnapshot(snaps, d.id, text, Date.now(), {base: snapBase, manual: manuell})) return false;
|
||||
snapBase = text;
|
||||
@@ -3796,7 +3682,6 @@ function renderSnapMenu(){
|
||||
keine Rückfrage. Der aktuelle Stand wird vorher weggelegt, falls er noch
|
||||
nicht drin ist — sonst wäre er das Einzige, was der Griff verlöre. */
|
||||
function loadSnapshot(s){
|
||||
if(src.readOnly) return;
|
||||
snapshotNow(true); /* bewusstes Wegleg-Ereignis wie der Knopf, nicht der Takt */
|
||||
closeSnapMenu();
|
||||
if(!replaceTextUndoable(s.text)) render();
|
||||
@@ -3811,10 +3696,6 @@ function closeSnapMenu(){
|
||||
snapMenu.hidden = true;
|
||||
snapBtn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
function updateSnapBtn(){
|
||||
snapBtn.hidden = snapAddBtn.hidden = src.readOnly;
|
||||
if(src.readOnly) closeSnapMenu();
|
||||
}
|
||||
snapBtn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
snapMenu.hidden ? openSnapMenu() : closeSnapMenu();
|
||||
@@ -4010,7 +3891,7 @@ document.addEventListener('pointerdown', e => {
|
||||
function loadActiveIntoEditor(){ const d = activeDoc(); src.value = d ? d.text : '';
|
||||
/* Vergleichsstand für den nächsten Snapshot (D54): Ohne ihn legte der
|
||||
erste Takt nach dem Öffnen auch ein unverändertes Dokument weg. */
|
||||
snapBase = src.value; closeSnapMenu(); updateSnapBtn();
|
||||
snapBase = src.value; closeSnapMenu();
|
||||
render(); updateDocName(); updateFreshBtn(); }
|
||||
function switchDoc(id){
|
||||
if(id === activeId) return;
|
||||
@@ -4072,7 +3953,6 @@ 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) */
|
||||
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) */
|
||||
docs = docs.filter(x => x.id !== d.id);
|
||||
@@ -4336,24 +4216,21 @@ function initDocs(){
|
||||
src.value = d ? d.text : '';
|
||||
snapBase = src.value;
|
||||
restoring = false;
|
||||
updateSnapBtn();
|
||||
persistDocs(); /* migrierte/geladene Liste festschreiben (stabil über Reload) */
|
||||
updateDocName();
|
||||
render();
|
||||
}
|
||||
|
||||
/* ---------- Text von außen: ?sourceUrl= (D23) und ?etherpad= (D31) ----------
|
||||
Beide holen einen Notationstext über http(s) und führen ihn als eigenes
|
||||
Dokument. Die id leitet sich aus der URL ab: derselbe Link aktualisiert dieses
|
||||
Dokument, statt bei jedem Aufruf ein neues anzulegen. Eigene Dokumente des
|
||||
Nutzers bleiben unberührt. Scheitert das Laden (häufigster Fall: das Ziel
|
||||
sendet keinen CORS-Header), bleibt der bisherige Stand stehen und es
|
||||
erscheint eine Warnung.
|
||||
/* ---------- Text von außen: ?sourceUrl= (D23) ----------
|
||||
Holt einen Notationstext über http(s) und führt ihn als eigenes Dokument. Die
|
||||
id leitet sich aus der URL ab: derselbe Link aktualisiert dieses Dokument,
|
||||
statt bei jedem Aufruf ein neues anzulegen. Eigene Dokumente des Nutzers
|
||||
bleiben unberührt. Scheitert das Laden (häufigster Fall: das Ziel sendet
|
||||
keinen CORS-Header), bleibt der bisherige Stand stehen und es erscheint eine
|
||||
Warnung. Eine statische Datei, einmal pro Laden geholt.
|
||||
|
||||
Unterschied: `sourceUrl` ist eine statische Datei, einmal pro Laden geholt
|
||||
(D23, unverändert). `etherpad` ist ein lebendes Pad — wiederholt geholt und
|
||||
hier schreibgeschützt, weil das Zusammenführen gleichzeitiger Änderungen
|
||||
Etherpads Aufgabe ist. Ein Fetch-Pfad, zwei Eingänge. */
|
||||
`?etherpad=` gab es hier einmal daneben (D31) und ist ausgebaut (D78) — ein
|
||||
alter Link meldet sich, statt still nichts zu tun. */
|
||||
const SOURCE_PARAM = 'sourceUrl';
|
||||
const ETHERPAD_PARAM = 'etherpad';
|
||||
function urlParam(name){
|
||||
@@ -4361,16 +4238,14 @@ function urlParam(name){
|
||||
}
|
||||
function sourceUrlParam(){ return urlParam(SOURCE_PARAM); }
|
||||
|
||||
/* Woher kommt der Text? null = kein Parameter, {bad,error} = unbrauchbare
|
||||
Angabe, sonst der Beschreiber für loadRemoteSource(). Die Normalisierung der
|
||||
Pad-Adresse steht headless in remote.js — dort auch ihre Begründung. */
|
||||
/* Woher kommt der Text? null = kein Parameter, {gone} = ausgebauter Eingang,
|
||||
{bad,error} = unbrauchbare Angabe, sonst der Beschreiber für
|
||||
loadRemoteSource(). */
|
||||
function remoteSource(){
|
||||
const padRaw = urlParam(ETHERPAD_PARAM);
|
||||
if(padRaw){
|
||||
const p = padUrls(padRaw, location.href);
|
||||
if(!p) return {bad: padRaw, error: 'not an Etherpad URL'};
|
||||
return {fetchUrl: p.text, id: 'url:' + p.pad, name: p.pad, source: p.pad, live: true};
|
||||
}
|
||||
/* Ein alter `?etherpad=`-Link soll nicht stillschweigend ein leeres
|
||||
Werkbaum zeigen: Wer ihn irgendwo stehen hat, erfährt hier, wohin die
|
||||
Zusammenarbeit gezogen ist (D78). */
|
||||
if(urlParam(ETHERPAD_PARAM)) return {gone: true};
|
||||
const raw = sourceUrlParam();
|
||||
if(!raw) return null;
|
||||
let url;
|
||||
@@ -4380,25 +4255,15 @@ function remoteSource(){
|
||||
(„Failed to fetch", „HTTP 404") — der Rahmentext ist lokalisiert. */
|
||||
try{ url = new URL(raw, location.href); }catch(_){ return {bad: raw, error: 'invalid URL'}; }
|
||||
if(url.protocol !== 'http:' && url.protocol !== 'https:') return {bad: url.href, error: url.protocol};
|
||||
return {fetchUrl: url.href, id: 'url:' + url.href, name: url.href, source: url.href, live: false};
|
||||
return {fetchUrl: url.href, id: 'url:' + url.href, name: url.href, source: url.href};
|
||||
}
|
||||
|
||||
/* `timeoutMs` nur beim Pad-Takt (D31): Hängt die Gegenseite, muss der Abruf
|
||||
abbrechen, sonst bliebe der Riegel `padBusy` für immer zu und es käme nie
|
||||
wieder etwas. Der erste Ladeversuch wartet unbegrenzt wie bisher (D23). */
|
||||
async function fetchRemote(url, timeoutMs){
|
||||
const ctl = timeoutMs ? new AbortController() : null;
|
||||
const timer = ctl ? setTimeout(() => ctl.abort(), timeoutMs) : null;
|
||||
try{
|
||||
const resp = await fetch(url, {cache:'no-store', credentials:'omit',
|
||||
signal: ctl ? ctl.signal : undefined});
|
||||
if(!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return await resp.text();
|
||||
} finally { if(timer) clearTimeout(timer); }
|
||||
async function fetchRemote(url){
|
||||
const resp = await fetch(url, {cache:'no-store', credentials:'omit'});
|
||||
if(!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return await resp.text();
|
||||
}
|
||||
/* 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. */
|
||||
/* Geholten Text als Dokument übernehmen und aktivieren. */
|
||||
function adoptRemote(s, text){
|
||||
flushActive(); /* laufende Bearbeitung nicht verlieren */
|
||||
let d = docs.find(x => x.id === s.id);
|
||||
@@ -4414,28 +4279,23 @@ function adoptRemote(s, text){
|
||||
async function loadRemoteSource(){
|
||||
const s = remoteSource();
|
||||
if(!s) return;
|
||||
if(s.gone){
|
||||
sourceWarning = {type:'padGone'};
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if(s.bad !== undefined){
|
||||
sourceWarning = {type:'sourceLoad', url: s.bad, error: s.error};
|
||||
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{
|
||||
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.
|
||||
Bei einem Pad ist das nicht das Ende: der Knopf holt es nach und räumt die
|
||||
Warnung weg, sobald ein Abruf gelingt. */
|
||||
der Warntext nennt CORS daher ausdrücklich als wahrscheinliche Ursache. */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4886,204 +4746,6 @@ async function putOnServer(){
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 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 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);
|
||||
}
|
||||
/* 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;
|
||||
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(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 */
|
||||
/* Auswahl und Scrollstand erhalten — der Sprung aus dem Diagramm (D25) und
|
||||
die Cursor-Zeile sollen einen Abruf überleben. */
|
||||
const top = src.scrollTop, a = src.selectionStart, b = src.selectionEnd;
|
||||
src.value = text;
|
||||
try{ src.setSelectionRange(a, b); }catch(_){}
|
||||
src.scrollTop = top;
|
||||
computeFresh(d.id, text); /* Basis bleibt die zuletzt bestätigte Fassung */
|
||||
render();
|
||||
updateFreshBtn();
|
||||
}
|
||||
|
||||
/* ---------- Ansicht bei einem Pad-Dokument: beide | nur Pad | nur Spiegel ----
|
||||
Das Pad lässt sich einbetten (nachgemessen: kein `X-Frame-Options`, keine CSP
|
||||
mit `frame-ancestors`), doch es ersetzt nicht einfach das Textfeld: Alt+Klick
|
||||
und die Cursor-Zeile (D25) arbeiten auf **unserem** `<textarea>`, und in einen
|
||||
fremdstämmigen Rahmen kommt kein DOM-Zugriff. Deshalb drei Ansichten statt
|
||||
einer Entscheidung — in „beide" bleibt der Spiegel schmal ziehbar und trägt
|
||||
weiter die Sprünge.
|
||||
|
||||
Ein **Wähler** statt dreier Knöpfe: Die Editor-Titelzeile ist schon voll
|
||||
(Dokument, Pad, Neu laden, Kopieren, Legende, Fenster), und auf kleinem
|
||||
Bildschirm (D17) ist sie es dreifach. Derselbe Reihum-Griff wie beim
|
||||
Modus-Wähler dort.
|
||||
|
||||
Der Rahmen wird **nur geladen, wenn er sichtbar ist** (`about:blank` sonst).
|
||||
Das ist kein Geiz: Ein geladenes Pad verbindet sich per Socket und macht dich
|
||||
in der Anwesenden-Liste sichtbar. „Nur Spiegel" ist damit die Ansicht, die
|
||||
nichts von dir verrät. */
|
||||
const padFrame = document.getElementById('padFrame');
|
||||
const srcArea = document.getElementById('srcArea');
|
||||
const padGutter = document.getElementById('padGutter');
|
||||
const padViewBtn = document.getElementById('padViewBtn');
|
||||
function applyPadView(){
|
||||
const d = activeDoc();
|
||||
const isPad = !!(d && padSource && d.id === padSource.id);
|
||||
const show = isPad ? padView : 'text';
|
||||
if(srcArea) srcArea.className = 'src-area pv-' + show;
|
||||
if(padViewBtn){
|
||||
padViewBtn.hidden = !isPad;
|
||||
padViewBtn.dataset.view = show;
|
||||
const tip = t('padViewTooltip', {state: t('padView_' + show)});
|
||||
padViewBtn.title = tip;
|
||||
padViewBtn.setAttribute('aria-label', tip);
|
||||
}
|
||||
if(padGutter) padGutter.hidden = (show !== 'both');
|
||||
if(!padFrame) return;
|
||||
const wantFrame = isPad && show !== 'text';
|
||||
padFrame.hidden = !wantFrame;
|
||||
const wantSrc = wantFrame ? padViewUrl(padSource.source) : 'about:blank';
|
||||
if(padFrame.getAttribute('src') !== wantSrc) padFrame.setAttribute('src', wantSrc);
|
||||
}
|
||||
/* Zeilennummern und Monospace sind für diese Notation die richtige Darstellung;
|
||||
der Chat kostet in einem schmalen Rahmen nur Platz. */
|
||||
function padViewUrl(padUrl){
|
||||
return padUrl + '?showChat=false&showLineNumbers=true&useMonospaceFont=true';
|
||||
}
|
||||
function cyclePadView(){
|
||||
padView = PAD_VIEWS[(PAD_VIEWS.indexOf(padView) + 1) % PAD_VIEWS.length];
|
||||
applyPadView();
|
||||
saveUI();
|
||||
}
|
||||
if(padViewBtn) padViewBtn.addEventListener('click', cyclePadView);
|
||||
|
||||
/* Ein Pad-Dokument wird im Pad bearbeitet, nicht hier (D31): Textfeld
|
||||
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;
|
||||
updateSnapBtn(); /* Pad-Dokumente sammeln keine Stände (D54) */
|
||||
src.classList.toggle('readonly', isPad);
|
||||
if(srcWrap) srcWrap.classList.toggle('readonly', isPad); /* Streifen mit abtönen */
|
||||
if(isPad) src.title = t('padReadonly'); else src.removeAttribute('title');
|
||||
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 */
|
||||
}
|
||||
applyPadView();
|
||||
}
|
||||
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. */
|
||||
@@ -5228,43 +4890,6 @@ hintGutter.addEventListener('dblclick', () => {
|
||||
(D26): gezogen wird von der Spiegelseite aus, Doppelklick setzt zurück,
|
||||
Ausrichtung folgt der Panel-Anordnung. Beide Werte werden getrennt gehalten,
|
||||
damit ein Moduswechsel die jeweils andere Aufteilung nicht zerstört. */
|
||||
const PAD_MIN = 120; /* schmal darf er werden — aber lesbar bleiben */
|
||||
const PAD_MAX_SHARE = 0.85;
|
||||
let padDragging = false;
|
||||
padGutter.addEventListener('pointerdown', e => {
|
||||
padDragging = true;
|
||||
padGutter.classList.add('dragging');
|
||||
padGutter.setPointerCapture(e.pointerId);
|
||||
document.body.style.userSelect = 'none';
|
||||
e.preventDefault();
|
||||
});
|
||||
padGutter.addEventListener('pointermove', e => {
|
||||
if(!padDragging) return;
|
||||
const b = srcArea.getBoundingClientRect();
|
||||
if(editorStacked()){
|
||||
const h = Math.min(Math.max(b.bottom - e.clientY, PAD_MIN), b.height * PAD_MAX_SHARE);
|
||||
app.style.setProperty('--prow', Math.round(h) + 'px');
|
||||
} else {
|
||||
const w = Math.min(Math.max(b.right - e.clientX, PAD_MIN), b.width * PAD_MAX_SHARE);
|
||||
app.style.setProperty('--pcol', Math.round(w) + 'px');
|
||||
}
|
||||
});
|
||||
function endPadDrag(e){
|
||||
if(!padDragging) return;
|
||||
padDragging = false;
|
||||
padGutter.classList.remove('dragging');
|
||||
document.body.style.userSelect = '';
|
||||
try{ padGutter.releasePointerCapture(e.pointerId); }catch(_){}
|
||||
saveUI();
|
||||
}
|
||||
padGutter.addEventListener('pointerup', endPadDrag);
|
||||
padGutter.addEventListener('pointercancel', endPadDrag);
|
||||
padGutter.addEventListener('dblclick', () => {
|
||||
app.style.removeProperty('--pcol');
|
||||
app.style.removeProperty('--prow');
|
||||
saveUI();
|
||||
});
|
||||
|
||||
/* Modus-Wähler auf kleinem Bildschirm: es ist nur das aktive Icon sichtbar,
|
||||
Tippen schaltet zum nächsten Modus (reihum). Auf normaler Größe bleibt es
|
||||
der Dreier-Umschalter — dort kehrt der Handler sofort zurück. */
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
/* Werkbaum — Adressen für Dokumente von außen (headless, D31).
|
||||
Hier steht nur reine Logik, damit sie testbar ist; das Holen selbst und der
|
||||
ganze DOM-Kram bleiben in app.js. */
|
||||
|
||||
/* Pad-Adresse normalisieren (D31). Eingabe ist die Adresse aus der Browser-
|
||||
Adresszeile — ohne Export-Pfad, den hängt Werkbaum selbst an. Abgeschnitten
|
||||
werden ein versehentlich mitgegebener Export- oder /timeslider-Pfad, Query,
|
||||
Fragment und Schrägstriche am Ende: derselbe Pad soll genau ein Dokument
|
||||
ergeben, gleich in welcher Schreibweise der Link kam.
|
||||
|
||||
Verlangt wird `/p/<name>` am Ende (auch unter einem Unterpfad montiert) — das
|
||||
ist die Prüfung, ob überhaupt eine Pad-Adresse vorliegt. Erlaubt sind nur
|
||||
http(s), wie bei ?sourceUrl= (D23).
|
||||
|
||||
Rückgabe {pad, text} oder null. `pad` ist zugleich Name und Identität des
|
||||
Dokuments — die **vollständige** URL, nicht der bloße Pad-Name: Pad-Namen sind
|
||||
nur pro Instanz eindeutig, zwei Hosts mit je einem Pad `plan` wären sonst im
|
||||
Wähler nicht zu unterscheiden. */
|
||||
export function padUrls(raw, base){
|
||||
let u;
|
||||
try{ u = base ? new URL(raw, base) : new URL(raw); }catch(_){ return null; }
|
||||
if(u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
||||
const path = u.pathname.replace(/\/+$/, '').replace(/\/(export\/[^/]+|timeslider)$/, '');
|
||||
if(!/\/p\/[^/]+$/.test(path)) return null;
|
||||
u.pathname = path; u.search = ''; u.hash = '';
|
||||
const pad = u.href;
|
||||
return {pad, text: pad + '/export/txt'};
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
dafür ist reine Logik — sie hing nur an `localStorage`, `Date.now()` und
|
||||
dem `<textarea>` fest und war deshalb nicht prüfbar.
|
||||
|
||||
Aufteilung nach dem Vorbild von remote.js (D31): 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,
|
||||
Menü) und WOHIN sie gehen (`localStorage`). Der Speicher wird als
|
||||
`{setItem, removeItem}` hereingereicht, die Uhr als Zahl — beides lässt sich
|
||||
|
||||
+3
-54
@@ -270,29 +270,8 @@
|
||||
Horizontal nebeneinander, vertikal (.side) untereinander. */
|
||||
.editor-body{display:flex;align-items:stretch;flex:1 1 auto;min-height:0}
|
||||
.editor-body textarea{flex:1 1 auto;min-width:0;min-height:0}
|
||||
/* Pad-Rahmen | interner Spiegel (D31). Eigener Bereich, damit die
|
||||
Legenden-Aufteilung (D26) unangetastet bleibt. Nur in der Ansicht „beide"
|
||||
bekommt der Spiegel eine feste Größe (`--pcol`/`--prow`) — sonst füllt er
|
||||
wie bisher den Bereich. Die 85 %-Grenze steht zusätzlich hier im CSS, weil
|
||||
der gespeicherte Wert ein px-Wert ist und der Bereich später schrumpfen
|
||||
kann (dieselbe Falle wie bei der Legende, D26). */
|
||||
.src-area{display:flex;align-items:stretch;flex:1 1 auto;min-width:0;min-height:0}
|
||||
.padframe{flex:1 1 auto;min-width:0;min-height:0;border:0;background:var(--card)}
|
||||
.padframe[hidden]{display:none}
|
||||
/* Zeilennummern + Textfeld sind eine Spalte (`.src-wrap`). Der Pad-Splitter
|
||||
teilt gegen diese Spalte — nicht gegen das nackte Textfeld, sonst stünde der
|
||||
Zahlenstreifen außerhalb der geteilten Breite. */
|
||||
/* Zeilennummern + Textfeld sind eine Spalte (`.src-wrap`, D33). */
|
||||
.src-wrap{display:flex;align-items:stretch;flex:1 1 auto;min-width:0;min-height:0}
|
||||
.src-area.pv-both>.src-wrap{
|
||||
flex:0 0 var(--pcol,300px);max-width:85%;
|
||||
border-left:1px dashed rgba(36,52,71,.18);
|
||||
}
|
||||
.src-area.pv-pad>.src-wrap{display:none}
|
||||
.side .src-area,body.mobile .src-area{flex-direction:column}
|
||||
.side .src-area.pv-both>.src-wrap,body.mobile .src-area.pv-both>.src-wrap{
|
||||
flex:0 0 var(--prow,180px);max-width:none;max-height:85%;
|
||||
border-left:0;border-top:1px dashed rgba(36,52,71,.18);
|
||||
}
|
||||
/* Legende: bewusst KEIN <details> mehr. Chrome legt den Details-Inhalt in
|
||||
`::details-content`; dadurch war `.hint` kein Flex-Kind von `.agenda` mehr,
|
||||
`flex:1/min-height:0` liefen ins Leere und der Inhalt wurde geclippt statt
|
||||
@@ -343,17 +322,6 @@
|
||||
.side .agenda.open{width:auto;max-width:none;height:var(--hrow,220px);max-height:85%}
|
||||
.side .hint-gutter{cursor:row-resize}
|
||||
.side .hint-gutter::before{width:38px;height:3px}
|
||||
/* Splitter Pad|Spiegel — gleiches Idiom wie der Legenden-Splitter. */
|
||||
.pad-gutter{
|
||||
flex:0 0 10px;align-self:stretch;
|
||||
cursor:col-resize;touch-action:none;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
}
|
||||
.pad-gutter[hidden]{display:none}
|
||||
.pad-gutter::before{content:'';width:3px;height:38px;border-radius:2px;background:rgba(36,52,71,.18)}
|
||||
.pad-gutter:hover::before,.pad-gutter.dragging::before{background:var(--or)}
|
||||
.side .pad-gutter,body.mobile .pad-gutter{cursor:row-resize}
|
||||
.side .pad-gutter::before,body.mobile .pad-gutter::before{width:38px;height:3px}
|
||||
|
||||
.panel{
|
||||
background:var(--card);
|
||||
@@ -431,22 +399,9 @@
|
||||
kopieren, aber dieselbe Erfolgs-Rückmeldung geben (Stand sichern, D54). */
|
||||
.copybtn.done .ic-copy,.copybtn.done .ic-main{display:none}
|
||||
.copybtn.done .ic-done{display:block}
|
||||
/* „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],.padrefresh[hidden],.padview[hidden]{display:none}
|
||||
/* Der Ansichts-Wähler zeigt seinen Zustand als Position im Rahmen: geteilt,
|
||||
links gefüllt (Pad) oder rechts gefüllt (Spiegel). Zwei Klassen, damit die
|
||||
Regel die allgemeinere `.copybtn svg{display:block}` sicher schlägt. */
|
||||
.copybtn.padview .ic-both,
|
||||
.copybtn.padview .ic-pad,
|
||||
.copybtn.padview .ic-text{display:none}
|
||||
.copybtn.padview[data-view="both"] .ic-both,
|
||||
.copybtn.padview[data-view="pad"] .ic-pad,
|
||||
.copybtn.padview[data-view="text"] .ic-text{display:block}
|
||||
/* Falt-Durchschalter (SPEC §9/D75): vier Icons, sichtbar ist das des
|
||||
NÄCHSTEN Schritts — `data-next` setzt updateFoldBtn(). Zwei Klassen wie
|
||||
beim Pad-Wähler, damit die Regel `.copybtn svg{display:block}` schlägt. */
|
||||
NÄCHSTEN Schritts — `data-next` setzt updateFoldBtn(). Zwei Klassen, damit
|
||||
die Regel `.copybtn svg{display:block}` schlägt. */
|
||||
.copybtn.foldbtn .fc-small,
|
||||
.copybtn.foldbtn .fc-path,
|
||||
.copybtn.foldbtn .fc-closed,
|
||||
@@ -596,11 +551,6 @@
|
||||
color:var(--ink);background:var(--card);
|
||||
tab-size:2;
|
||||
}
|
||||
/* Pad-Dokument (D31): hier nur lesen, geschrieben wird im Pad. Abgesetzt über
|
||||
den Hintergrund, damit der Schreibschutz nicht als hängendes Textfeld
|
||||
erscheint — die Schrift bleibt aber Tinte: hier wird gelesen, der Plantext
|
||||
ist der Hauptinhalt und kein ausgegrautes Formularfeld. */
|
||||
textarea.readonly{background:#F1F4F7;cursor:default}
|
||||
/* Zeilennummern (D33). Die Warnungen nennen Zeilennummern — ohne Streifen
|
||||
muss man sie im Textfeld abzählen. Jede Zahl steht auf einer **gemessenen**
|
||||
Höhe (`renderLineNos()` in app.js), nicht auf „Zeilenhöhe × n"; seit D49
|
||||
@@ -617,7 +567,6 @@
|
||||
color:var(--muted);
|
||||
-webkit-user-select:none;user-select:none;
|
||||
}
|
||||
.src-wrap.readonly .lineno{background:#F1F4F7}
|
||||
.lineno-inner{position:absolute;top:0;left:0;right:0}
|
||||
.lineno-inner span{
|
||||
position:absolute;right:6px;
|
||||
|
||||
@@ -39,18 +39,14 @@
|
||||
exakte Suche (D42); zeilenlos
|
||||
- sourceLoad { url, error } — ?sourceUrl= nicht ladbar (D23); ohne
|
||||
Zeilennummer, erscheint dadurch zuoberst
|
||||
- padRateLimit { seconds } — zu früh nachgeladen; Werkbaum hat gar nicht
|
||||
erst geholt, um Etherpads Grenze nicht
|
||||
auszulösen (D31)
|
||||
- padGone { } — ein alter ?etherpad=-Link: die Anbindung
|
||||
ist ausgebaut (D78), der Text zeigt auf
|
||||
?live=; zeilenlos
|
||||
- liveLoad { url, error } — Server-Dokument (?live=, D76) nicht
|
||||
erreichbar oder keine Dokument-Adresse
|
||||
- liveStale { error } — eigene Änderung nicht anwendbar
|
||||
(Prüfsumme, Index, Basis verdichtet);
|
||||
der Client hat einmal neu geladen (D76)
|
||||
- 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 */
|
||||
der Client hat einmal neu geladen (D76) */
|
||||
|
||||
import { esc } from './render.js';
|
||||
|
||||
@@ -95,14 +91,12 @@ function build(w, t, esc){
|
||||
return t('cheapApproxWarn');
|
||||
case 'sourceLoad':
|
||||
return t('sourceLoadWarn', {url: esc(w.url), error: esc(w.error)});
|
||||
case 'padRateLimit':
|
||||
return t('padRateLimitWarn', {seconds: w.seconds});
|
||||
case 'padGone':
|
||||
return t('padGoneWarn');
|
||||
case 'liveLoad':
|
||||
return t('liveLoadWarn', {url: esc(w.url), error: esc(w.error)});
|
||||
case 'liveStale':
|
||||
return t('liveStaleWarn', {error: esc(w.error)});
|
||||
case 'sourceTimeout':
|
||||
return t('sourceTimeoutWarn', {url: esc(w.url), seconds: w.seconds});
|
||||
default:
|
||||
return `${esc(String(w.type))} (${w.line ?? '?'})`;
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { padUrls } from '../src/remote.js';
|
||||
|
||||
const PAD = 'https://pad.example.org/p/mein-plan';
|
||||
|
||||
describe('padUrls — Pad-Adresse normalisieren (D31)', () => {
|
||||
it('hängt den Klartext-Export an', () => {
|
||||
expect(padUrls(PAD)).toEqual({ pad: PAD, text: PAD + '/export/txt' });
|
||||
});
|
||||
|
||||
/* Derselbe Pad soll genau EIN Dokument ergeben — die Identität leitet sich
|
||||
aus `pad` ab, also müssen alle Schreibweisen darauf zusammenfallen. */
|
||||
it.each([
|
||||
['Schrägstrich am Ende', PAD + '/'],
|
||||
['mehrere Schrägstriche', PAD + '///'],
|
||||
['Export-Pfad mitgegeben', PAD + '/export/txt'],
|
||||
['anderer Export', PAD + '/export/html'],
|
||||
['Timeslider', PAD + '/timeslider'],
|
||||
['Query dran', PAD + '?showChat=false'],
|
||||
['Fragment dran', PAD + '#anker'],
|
||||
['Query und Schrägstrich', PAD + '/?showControls=false'],
|
||||
])('fällt auf dieselbe Pad-URL zusammen: %s', (_name, input) => {
|
||||
expect(padUrls(input)).toEqual({ pad: PAD, text: PAD + '/export/txt' });
|
||||
});
|
||||
|
||||
it('erlaubt eine Montage unter einem Unterpfad', () => {
|
||||
const sub = 'https://example.org/etherpad/p/plan';
|
||||
expect(padUrls(sub)).toEqual({ pad: sub, text: sub + '/export/txt' });
|
||||
});
|
||||
|
||||
it('erlaubt http neben https', () => {
|
||||
const h = 'http://pad.example.org/p/plan';
|
||||
expect(padUrls(h).pad).toBe(h);
|
||||
});
|
||||
|
||||
it('behält den Port', () => {
|
||||
const h = 'https://pad.example.org:9001/p/plan';
|
||||
expect(padUrls(h)).toEqual({ pad: h, text: h + '/export/txt' });
|
||||
});
|
||||
|
||||
/* Zwei Pads gleichen Namens auf verschiedenen Hosts müssen unterscheidbar
|
||||
bleiben — deshalb ist der Name die vollständige URL, nicht der Pad-Name. */
|
||||
it('unterscheidet gleichnamige Pads verschiedener Hosts', () => {
|
||||
expect(padUrls('https://a.example/p/plan').pad)
|
||||
.not.toBe(padUrls('https://b.example/p/plan').pad);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['kein /p/-Pfad', 'https://pad.example.org/mein-plan'],
|
||||
['nur der Host', 'https://pad.example.org'],
|
||||
['/p/ ohne Namen', 'https://pad.example.org/p/'],
|
||||
['fremdes Schema', 'file:///tmp/plan.txt'],
|
||||
['javascript:', 'javascript:alert(1)'],
|
||||
['data:', 'data:text/plain,foo'],
|
||||
['gar keine URL', 'nicht mal eine URL'],
|
||||
['leer', ''],
|
||||
])('weist ab: %s', (_name, input) => {
|
||||
expect(padUrls(input)).toBeNull();
|
||||
});
|
||||
|
||||
it('löst relative Angaben gegen die Seite auf, wenn eine Basis da ist', () => {
|
||||
expect(padUrls('/p/plan', 'https://pad.example.org/x/y').pad)
|
||||
.toBe('https://pad.example.org/p/plan');
|
||||
});
|
||||
|
||||
it('ohne Basis bleibt eine relative Angabe unbrauchbar', () => {
|
||||
expect(padUrls('/p/plan')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -21,18 +21,11 @@ describe('formatWarning — vereinheitlichtes Warnungs-Modell', () => {
|
||||
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}');
|
||||
/* Ein alter ?etherpad=-Link soll nicht still ins Leere laufen: Die Anbindung
|
||||
ist ausgebaut (D78), der Text zeigt auf ?live=. Ohne Platzhalter — die
|
||||
Meldung hat nichts einzusetzen. */
|
||||
it('padGone meldet den ausgebauten Eingang ohne Platzhalter', () => {
|
||||
expect(formatWarning({type: 'padGone'}, t)).toBe('padGoneWarn|undefined');
|
||||
});
|
||||
|
||||
it('unbekannter Typ fällt auf eine generische, escapte Meldung zurück', () => {
|
||||
|
||||
Reference in New Issue
Block a user