From fdca934cf091749ac442b2af904e122f59e2c7e2 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Wed, 26 Aug 2026 21:19:29 +0200 Subject: [PATCH] feat: Adresszeile folgt dem Dokument, Live-Sitzung ebenso (D80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wer bei offenem `?live=` umschaltet, behielt die alte Adresse — optisch falsch, und ein Neuladen holte das falsche Dokument zurück. Der Parameter gehört jetzt zum aktiven Dokument, für `?live=` wie für `?sourceUrl=`; `?etherpad=` wird nur noch weggeräumt. Fremde Parameter (`?server=`) bleiben wörtlich stehen, damit die URL lesbar bleibt. Dabei gefunden: Die Live-Sitzung lief weiter, während ein anderes Dokument vorn stand — `setLiveText()` schrieb fremde Änderungen in dessen Text. Die Sitzung gehört jetzt dem sichtbaren Dokument: Umschalten beendet sie, Umschalten auf ein Server-Dokument nimmt sie auf. Was noch im Debounce steckt, geht vorher raus, und `pushLive()` hält seine Sitzung fest statt anzunehmen, dass sich über ein `await` hinweg nichts ändert. Co-Authored-By: Claude Opus 5 --- README.de.md | 6 +++ README.md | 5 ++ docs/CHANGELOG.md | 3 ++ docs/DECISIONS.md | 82 ++++++++++++++++++++++++++++++ docs/SPEC.md | 10 ++++ frontend/src/app.js | 95 +++++++++++++++++++++++++++-------- frontend/src/docurl.js | 50 ++++++++++++++++++ frontend/tests/docurl.test.js | 82 ++++++++++++++++++++++++++++++ 8 files changed, 313 insertions(+), 20 deletions(-) create mode 100644 frontend/src/docurl.js create mode 100644 frontend/tests/docurl.test.js diff --git a/README.de.md b/README.de.md index fc867e2..daf3722 100644 --- a/README.de.md +++ b/README.de.md @@ -110,6 +110,12 @@ oberhalb Zeilen ein, bleibt sie an ihrer Stelle im Text. Der **Name** ist der Titel des Dokuments (alle sehen denselben), die vollständige Adresse steht im Tooltip. +**Die Adresszeile folgt dem Dokument, das vorn ist.** Umschalten auf einen +lokalen Plan räumt `?live=` weg, Umschalten auf ein anderes Server-Dokument +trägt dessen Adresse ein — ein Neuladen bringt also zurück, was man vor sich +hatte. Wer ein Server-Dokument im Menü auswählt, arbeitet darin auch wieder +gemeinsam. + **Ü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. diff --git a/README.md b/README.md index 3316809..e734fe1 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,11 @@ above you, it stays where it was in the text. The document's **title** is its name (everyone sees the same one), the full address sits in the tooltip. +**The address bar follows the document you are on.** Switch to a local plan and +`?live=` goes away; switch to another server document and its address takes its +place — so a reload brings back what you were looking at. Picking a server +document from the menu also puts you back into the shared session. + 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 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 290e0db..4d5a17b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,9 @@ reverse. ## 2026-08-26 +- The address bar follows the document you switch to: `?live=` and `?sourceUrl=` name what is in front of you, so a reload brings back the same plan +- Switching to a server document in the picker now really opens it live — before it only showed its last state +- Fix: a foreign change to a server document could land in the text of a local document you had switched to - Changes in a shared document now reach the others after 0.6 s instead of 1.5 s — the wait before sending was almost the whole delay - The Etherpad integration is gone: collaboration now runs through a Werkbaum backend, and an old `?etherpad=` link says so instead of doing nothing diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a573729..201b43a 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6819,3 +6819,85 @@ die 600 ms sind gesetzt, und was daneben liegt (Server 39 ms, Rundlauf 130 ms, PATCH → sichtbar 46 ms) ist einzeln gemessen. Dieselbe Lehre wie D25 (synthetische `TouchEvent`s), D17-Nachtrag 4 (Bildschirmtastatur) und D53 (synthetisches Strg+Z): Was die Umgebung stellt, stellt der Emulator nicht. + +## D80 — Die Adresszeile beschreibt das aktive Dokument, und die Live-Sitzung folgt ihm +Gemeldet: Wer bei offenem `?live=…` auf ein anderes Dokument umschaltet, behält +die alte Adresse — „das sieht optisch falsch aus, und beim Neuladen würde wohl +auch das Dokument aus `live=` wieder geladen". Beides stimmt, und beim +Nachsehen kam ein dritter, schwererer Befund dazu. + +**Die Adresse ist kein Andenken an den Aufruf, sondern der Stand.** Sie ist der +Link, den man weitergibt, und das, was ein Neuladen wiederherstellt. Zeigt sie +auf etwas anderes als der Bildschirm, ist eines von beiden gelogen — und beim +Neuladen entscheidet die Adresse. Die Regel lautet deshalb: **Der Parameter +gehört zum aktiven Dokument.** Umschalten auf ein lokales Dokument räumt ihn +weg, Umschalten auf ein anderes Server-Dokument tauscht ihn aus. + +**Sie gilt für beide Eingänge, nicht nur für `?live=`.** `?sourceUrl=` (D23) +hatte dasselbe Problem, und eine Regel, die nur für einen der beiden gilt, ist +keine. Beide Eingänge sind ohnehin schon die **Identität** des Dokuments +(`live:`, `url:`) — der Parameter lässt sich also aus der id +zurückrechnen, statt nebenher geführt zu werden. `?etherpad=` ist ausgebaut +(D78) und wird nur noch weggeräumt. + +**Für `?sourceUrl=` ist das keine neue Gefahr**, obwohl der Parameter beim +Zurückschalten wiederkommt und ein Neuladen den Text dann erneut holt (D23: +„lokale Änderungen daran überleben ein Neuladen nicht"). Bisher stand er +**immer** da, unabhängig davon, was vorn war — es wird also nicht mehr +überschrieben als vorher, sondern weniger. + +**Fremde Parameter bleiben wörtlich stehen — auch ihre Schreibweise.** Der +naheliegende Weg über `URLSearchParams` schriebe jedes `:` und `/` als +`%3A`/`%2F` und machte damit gerade die URL unleserlich, um die es hier geht; +`?server=` (D76-Nachtrag 8) fiele bei einem Neubau der Adresse ganz weg. +Maskiert wird nur, was den Query-String sonst zerrisse (`&`, `#`). Die +entscheidbare Hälfte steht als reine Funktion in `docurl.js` +(Hausregel D54-Nachtrag 3), die `history.replaceState`-Seite in app.js. + +**Der dritte Befund: Die Live-Sitzung lief weiter, während ein anderes Dokument +vorn stand.** `switchDoc()` hat bisher nur `activeId` gewechselt; `liveState` +blieb, der Feed lief, und `setLiveText()` schreibt in `src.value` **und** in +`activeDoc().text` — eine fremde Änderung am Server-Dokument landete also im +Text des Dokuments, das man gerade ansieht. Nachgemessen war die Lücke echt: +Die Meldung des Nutzers ist die Tür dazu. + +**Also gehört die Sitzung dem sichtbaren Dokument.** Umschalten beendet sie; +Umschalten auf ein Server-Dokument nimmt sie auf (`startLive()`, aus +`loadLive()` herausgelöst — derselbe Weg, nur mit der URL aus dem Dokument +statt aus dem Parameter). Der Nebengewinn ist der eigentliche: Ein +Server-Dokument, das man im Wähler auswählt, ist danach wirklich live. Vorher +zeigte es stumm seinen letzten Stand — die Adresse hätte also nicht nur +optisch, sondern der Sache nach gelogen, wenn man sie einfach mitgeführt hätte. + +**Verdrahtet an genau einer Stelle:** `loadActiveIntoEditor()` — jeder Weg zu +einem anderen aktiven Dokument führt dort durch (Umschalten, Anlegen, Löschen, +Datei öffnen, Server-Dokument laden). Während des Starts ruht die Regel +(`bootDone`): `loadRemoteSource()` und `loadLive()` lesen ihre Parameter erst, +nachdem das zuletzt aktive Dokument wiederhergestellt ist — ein vorschnelles +Aufräumen nähme ihnen die Vorlage. + +**Was noch im Debounce steckt, wird beim Umschalten losgeschickt.** Sonst +verlöre ein Wechsel innerhalb von 600 ms nach dem letzten Tastendruck genau +diese Änderung an den Server. Gesendet wird, **bevor** `activeId` wechselt — +`pushLive()` liest `src.value` synchron, danach zeigt das Feld schon den +anderen Text. + +**Und `pushLive()` hält jetzt seine Sitzung fest, nicht nur deren Felder.** +Wer während des Sendens umschaltet, beendet sie; die Fortsetzung nach dem +`await` dürfte danach weder schreiben noch in ein `null` greifen (das +`finally` hätte es getan). Dieselbe Sorte Annahme, die D76-Nachtrag 9 schon +einmal an dieser Funktion korrigiert hat: dass sich über ein `await` hinweg +nichts ändert. + +**Nachgemessen** im Browser gegen ein lokales Backend, mit zwei +Server-Dokumenten: Umschalten auf ein lokales Dokument räumt `?live=` weg, +Umschalten auf das andere Server-Dokument tauscht die URL aus, `?sourceUrl=` +verhält sich symmetrisch und bleibt unmaskiert lesbar. Eine fremde Änderung +erreicht das per Wähler geöffnete Server-Dokument ohne Neuladen (die Sitzung +läuft also wirklich); dieselbe Änderung, während ein lokales Dokument vorn +steht, lässt dessen Text unangetastet (vorher hätte sie ihn überschrieben); +beim Zurückschalten steht der Server-Stand da. Eine Zeile, im selben Zug +getippt und umgeschaltet, kommt beim Server an. 514 Tests, davon 13 neue in +`tests/docurl.test.js`; Gegenproben: fremde Parameter mitwerfen → genau die +zwei danach benannten Zusicherungen fallen, `encodeURIComponent` statt der +sparsamen Maskierung → genau die sieben, die die Lesbarkeit festhalten. diff --git a/docs/SPEC.md b/docs/SPEC.md index 9307fac..ac34e63 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -766,6 +766,9 @@ Seite aufgelöst; zugelassen sind nur `http`/`https`). Der Text wird als Tooltip); derselbe Link aktualisiert dieses Dokument, statt ein neues anzulegen. Ist der Parameter gesetzt, wird bei **jedem** Laden neu geholt — die URL ist die Quelle der Wahrheit, lokale Änderungen daran überleben ein Neuladen nicht. +Die **Adresszeile folgt dem aktiven Dokument** (§9, `?live=`): Beim Umschalten +auf ein anderes verschwindet der Parameter, beim Zurückschalten steht er wieder +da. 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. @@ -796,6 +799,13 @@ anderen, ohne neu zu laden. jede Version in der Historie des Servers. - Der Abruf läuft **nur im sichtbaren Tab**; im Hintergrund ruht er und holt beim Zurückkommen den Rückstand. +- **Die Adresszeile beschreibt das Dokument, das vorn ist.** Umschalten auf ein + lokales Dokument räumt `?live=` weg, Umschalten auf ein anderes + Server-Dokument trägt dessen Adresse ein — ein Neuladen bringt also zurück, + was man vor sich hatte. Dasselbe gilt für `?sourceUrl=` (oben). Und die + gemeinsame Sitzung gehört dem sichtbaren Dokument: Wer ein Server-Dokument im + Wähler auswählt, arbeitet darin wieder gemeinsam; wer es verlässt, hört auf, + mitzuschreiben. Siehe D80. Siehe D76 (Protokoll und Begründung) und `backend/docs/live-editing-proposal.md`. diff --git a/frontend/src/app.js b/frontend/src/app.js index 931db8a..27d40b2 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -8,6 +8,7 @@ import { depFragment, collectIds, matchIds, depIdAt, idLine } from './autocomple import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel } from './snapshots.js'; import { FILE_ACCEPT, FILE_TYPES, saveFileName } from './localfile.js'; +import { LIVE_PARAM, SOURCE_PARAM, ETHERPAD_PARAM, docSearch } from './docurl.js'; /* Neuigkeiten (D58): die git-Historie, zur BAUZEIT eingelesen (Vite-Plugin in vite.config.js). Zur Laufzeit gibt es kein git — und keinen Server, der nachliefern könnte (D11/D19). Leer, wo git nicht erreichbar war. */ @@ -80,6 +81,13 @@ let mobilePane = 'diagram'; let caretLine = null, currentNodeEl = null; /* Warnung des ?sourceUrl-Ladens (D23) — zeilenlos und persistent, siehe render(). */ let sourceWarning = null; +/* Steht die App? Erst danach folgt die Adresszeile dem aktiven Dokument (D80) + und wird eine Live-Sitzung beim Umschalten übernommen. Während des Starts + dürfen beide nicht laufen: `loadRemoteSource()`/`loadLive()` lesen ihre + Parameter erst nach dem Wiederherstellen des zuletzt aktiven Dokuments — + ein vorschnelles Aufräumen der Adresszeile nähme ihnen die Vorlage. Hier + oben deklariert, weil `loadActiveIntoEditor()` schon beim Start läuft. */ +let bootDone = false; /* „Was ist neu?" (D28): Knoten, die gegenüber der zuletzt GESEHENEN Fassung neu in Produktion sind. Gilt immer für genau ein Dokument (`freshDocId`) — nur Dokumente von außen (mitgeliefert oder ?sourceUrl=) haben eine @@ -3892,9 +3900,44 @@ 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(); - render(); updateDocName(); updateFreshBtn(); } + render(); updateDocName(); updateFreshBtn(); + /* Ein Dokumentwechsel ist mehr als neuer Text im Feld: Adresszeile und + Live-Sitzung gehören dem, was man vor sich hat (D80). Hier, weil jeder + Weg zu einem anderen aktiven Dokument durch diese eine Stelle führt — + Umschalten, Anlegen, Löschen, Datei öffnen, Server-Dokument laden. */ + followActiveDoc(); } + +/* Die Adresszeile beschreibt das aktive Dokument (D80). Geschrieben wird nur, + wenn sich wirklich etwas ändert — sonst stünde in der Historie des Browsers + bei jedem Rendern ein Eintrag mehr. */ +function syncDocUrl(){ + let u; + try{ u = new URL(location.href); }catch(_){ return; } + const neu = docSearch(u.search, activeId); + if(neu === u.search) return; + try{ history.replaceState(null, '', u.origin + u.pathname + neu + u.hash); }catch(_){} +} + +/* Die Live-Sitzung gehört dem sichtbaren Dokument (D80). Ohne das liefe der + Feed eines Server-Dokuments weiter, während ein anderes vorn steht — und + `setLiveText()` schriebe die fremde Änderung in **dessen** Text. */ +function followActiveDoc(){ + if(!bootDone) return; + syncDocUrl(); + if(liveState && liveState.id !== activeId) stopLive(); + const d = activeDoc(); + if(!liveState && d && String(d.id).startsWith('live:')) startLive(d.id.slice(5)); +} + function switchDoc(id){ if(id === activeId) return; + /* Was noch im Debounce steckt, ist getippt und gemeint: erst loswerden, + solange das Textfeld noch den Text dieses Dokuments zeigt (D80). */ + if(liveActive() && liveState.pushTimer){ + clearTimeout(liveState.pushTimer); + liveState.pushTimer = null; + pushLive(); /* liest src.value synchron; das Ergebnis braucht niemand mehr */ + } flushActive(); activeId = id; foldOverrides.clear(); /* Falt-Eingriffe gelten je Dokument-Sitzung (D38) */ @@ -4231,8 +4274,8 @@ function initDocs(){ `?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'; +/* Die Parameternamen stehen in docurl.js — dort wird auch entschieden, welcher + von ihnen zum aktiven Dokument gehört (D80). */ function urlParam(name){ try{ return new URLSearchParams(location.search).get(name); }catch(_){ return null; } } @@ -4315,7 +4358,6 @@ async function loadRemoteSource(){ Was hier NICHT passiert: Zusammenführen. Überschneiden sich zwei Änderungen wirklich, entscheidet der Mensch (Konflikt-Band unten). Alles andere verschiebt der Server selbst. */ -const LIVE_PARAM = 'live'; const LIVE_DEBOUNCE_MS = 600; /* Ruhe vor dem Senden; D76, D79 */ const LIVE_WAIT_S = 25; /* Wartezeit des Feeds; der Server klemmt sie ohnehin */ const LIVE_RETRY_MS = 5000; /* nach einem Netzfehler, bevor der Feed erneut fragt */ @@ -4367,8 +4409,12 @@ function stopLive(){ hideConflictBanner(); } -async function loadLive(){ - const raw = urlParam(LIVE_PARAM); +function loadLive(){ startLive(urlParam(LIVE_PARAM)); } + +/* Eine Sitzung für dieses Server-Dokument aufnehmen: Stand holen, Feed öffnen. + Zwei Wege hierher — der `?live=`-Parameter beim Laden und das Umschalten auf + ein Server-Dokument im Wähler (D80). */ +async function startLive(raw){ if(!raw) return; const urls = live.liveUrls(raw, location.href); if(!urls){ @@ -4444,7 +4490,12 @@ async function pushLive(){ const ops = live.computeOps(liveState.shadow, now); if(!ops.length) return; - liveState.busy = true; + /* Die Sitzung selbst festhalten, nicht nur ihre Felder: Wer während des + Sendens auf ein anderes Dokument umschaltet, beendet sie (D80) — die + Fortsetzung unten dürfte danach weder schreiben noch in ein `null` + greifen. Der PATCH ist dann trotzdem draußen, und das ist gewollt. */ + const sitzung = liveState; + sitzung.busy = true; const seq = nextSeq(); /* Die Basis, gegen die `ops` gerechnet sind — VOR dem Warten festgehalten. Sie hinterher aus `liveState` zu lesen hieße anzunehmen, dass sich @@ -4452,21 +4503,22 @@ async function pushLive(){ Feed dazwischenfunkt (D76-Nachtrag 9). Der Feed lässt sich jetzt aus, solange wir senden — aber eine Rechnung, die nur wegen einer Sperre anderswo stimmt, schreibt man nicht auf. */ - const basis = liveState.shadow; + const basis = sitzung.shadow; try{ const body = { - baseVersion: liveState.version, - checksum: await live.checksum(live.text(liveState.shadow)), + baseVersion: sitzung.version, + checksum: await live.checksum(live.text(sitzung.shadow)), clientId: clientId(), displayName: displayName(), seq, ops, }; - const result = await fetchJson(liveState.urls.content, { + const result = await fetchJson(sitzung.urls.content, { method: 'PATCH', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), }); + if(liveState !== sitzung) return; /* inzwischen umgeschaltet (D80) */ /* Angenommen. Hat der Server verschoben, stehen die fremden Operationen in `opsSinceBase`: Die Schattenkopie zieht erst darüber nach, dann kommt unsere eigene Änderung darauf — verschoben um die fremde. Genau diese @@ -4475,14 +4527,14 @@ async function pushLive(){ const foreign = (result.opsSinceBase || []); const meine = foreign.length ? live.rebaseOps(ops, foreign) : ops; if(meine == null){ await reloadLive(); return; } /* kann nicht sein - dann lieber neu */ - liveState.shadow = live.applyOps( + sitzung.shadow = live.applyOps( foreign.length ? live.applyOps(basis, foreign) : basis, meine); - liveState.version = result.version; - if(foreign.length) applyForeign(basis, foreign, liveState.shadow, liveState.version); + sitzung.version = result.version; + if(foreign.length) applyForeign(basis, foreign, sitzung.shadow, sitzung.version); }catch(err){ - handlePushError(err); + if(liveState === sitzung) handlePushError(err); }finally{ - liveState.busy = false; + sitzung.busy = false; } } @@ -4736,10 +4788,10 @@ async function putOnServer(){ runFeed(); /* Die Adresszeile IST der Link — dort sucht man ihn, und ein Neuladen - führt zurück ins selbe Dokument. Zusätzlich in die Zwischenablage, - weil Weitergeben der eigentliche Zweck ist. */ - const teilen = location.origin + location.pathname + '?live=' + urls.doc; - try{ history.replaceState(null, '', teilen); }catch(_){} + führt zurück ins selbe Dokument. Gesetzt hat sie `adoptLive()` schon + (D80); in die Zwischenablage geht der Link ohne fremde Parameter, denn + ein `?server=` geht den Empfänger nichts an. */ + const teilen = location.origin + location.pathname + '?' + LIVE_PARAM + '=' + urls.doc; try{ await navigator.clipboard.writeText(teilen); }catch(_){} flashBtn(document.getElementById('docTrigger')); sourceWarning = null; @@ -4979,6 +5031,9 @@ if('launchQueue' in window){ applyMobile(); /* Mobil-Verhalten (nach Sprache/Restore) anwenden */ loadRemoteSource(); /* ?sourceUrl= / ?etherpad= nachladen (asynchron, D23/D31) */ loadLive(); /* ?live= — Server-Dokument samt Feed (asynchron, D76) */ +/* Beide haben ihren Parameter jetzt gelesen (synchron, vor dem ersten + `await`). Ab hier folgt die Adresszeile dem aktiven Dokument (D80). */ +bootDone = true; /* ---------- PWA: Service Worker (D73) ---------- Ein reiner Offline-Mantel (public/sw.js): Navigationen network-first, der diff --git a/frontend/src/docurl.js b/frontend/src/docurl.js new file mode 100644 index 0000000..0a296fc --- /dev/null +++ b/frontend/src/docurl.js @@ -0,0 +1,50 @@ +/* ---------- Welche Adresse beschreibt das aktive Dokument? (D80) ---------- + + Die Adresszeile ist der Link, den man weitergibt, und der Stand, den ein + Neuladen wiederherstellt. Sie muss deshalb sagen, welches Dokument **gerade + vorn** ist — nicht, mit welchem die Seite einmal aufgerufen wurde. Bleibt + ein `?live=` stehen, nachdem man auf ein anderes Dokument umgeschaltet hat, + zeigt die Adresse auf etwas anderes als der Bildschirm, und ein Neuladen + holt das falsche Dokument zurück. + + Die beiden Eingänge, die ein Dokument adressieren, sind zugleich seine + Identität: `live:` (D76) und `url:` (D23). Aus der id lässt sich + der Parameter also zurückrechnen — hier steht diese eine Regel. Eigene + Dokumente, Dateien und die mitgelieferten haben keine Adresse: Dort fällt + der Parameter weg. `?etherpad=` ist ausgebaut (D78) und wird nur noch + weggeräumt. */ + +export const LIVE_PARAM = 'live'; +export const SOURCE_PARAM = 'sourceUrl'; +export const ETHERPAD_PARAM = 'etherpad'; + +/* Die Parameter, über die diese Regel bestimmt. Alles andere ist fremd und + bleibt unangetastet. */ +const OWNED = [LIVE_PARAM, SOURCE_PARAM, ETHERPAD_PARAM]; + +/* Der Parameter, der dieses Dokument wieder öffnet — oder null. */ +export function docParam(id){ + if(typeof id !== 'string') return null; + if(id.startsWith('live:')) return {name: LIVE_PARAM, value: id.slice(5)}; + if(id.startsWith('url:')) return {name: SOURCE_PARAM, value: id.slice(4)}; + return null; +} + +/* Der neue Query-String zu `search` (mit oder ohne führendes `?`) für das + Dokument `id`. + + Fremde Parameter (etwa `?server=`, D76-Nachtrag 8) bleiben **wörtlich** + stehen, Schreibweise eingeschlossen: Der Umweg über `URLSearchParams` + schriebe jedes `:` und `/` als `%3A`/`%2F` und machte damit gerade die URL + unleserlich, um die es hier geht. Aus demselben Grund wird der eigene Wert + nur dort maskiert, wo er den Query-String sonst zerrisse (`&`, `#`). */ +export function docSearch(search, id){ + const roh = String(search == null ? '' : search).replace(/^\?/, ''); + const rest = roh ? roh.split('&').filter(p => p && OWNED.indexOf(p.split('=')[0]) < 0) : []; + const mein = docParam(id); + if(mein){ + const wert = String(mein.value).replace(/&/g, '%26').replace(/#/g, '%23'); + rest.push(mein.name + '=' + wert); + } + return rest.length ? '?' + rest.join('&') : ''; +} diff --git a/frontend/tests/docurl.test.js b/frontend/tests/docurl.test.js new file mode 100644 index 0000000..cc64573 --- /dev/null +++ b/frontend/tests/docurl.test.js @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { docParam, docSearch, LIVE_PARAM, SOURCE_PARAM } from '../src/docurl.js'; + +const LIVE = 'live:https://werkbaum.example/api/v1/documents/44753df1'; +const URLDOC = 'url:https://example.org/plan.werkbaum'; + +describe('docParam — welcher Parameter öffnet dieses Dokument wieder?', () => { + it('ein Server-Dokument wird über ?live= adressiert', () => { + expect(docParam(LIVE)).toEqual( + {name: LIVE_PARAM, value: 'https://werkbaum.example/api/v1/documents/44753df1'}); + }); + + it('ein geholtes Dokument über ?sourceUrl=', () => { + expect(docParam(URLDOC)).toEqual( + {name: SOURCE_PARAM, value: 'https://example.org/plan.werkbaum'}); + }); + + it('eigene, mitgelieferte und aus Dateien geöffnete Dokumente haben keine Adresse', () => { + expect(docParam('example')).toBe(null); + expect(docParam('werkbaum')).toBe(null); + expect(docParam('k3f9x1')).toBe(null); + expect(docParam(null)).toBe(null); + expect(docParam(undefined)).toBe(null); + }); +}); + +describe('docSearch — die Adresszeile folgt dem aktiven Dokument', () => { + it('setzt den Parameter des Server-Dokuments', () => { + expect(docSearch('', LIVE)) + .toBe('?live=https://werkbaum.example/api/v1/documents/44753df1'); + }); + + it('räumt ihn weg, sobald ein lokales Dokument vorn ist', () => { + expect(docSearch('?live=https://werkbaum.example/api/v1/documents/44753df1', 'example')) + .toBe(''); + }); + + it('tauscht ihn beim Wechsel auf ein anderes Server-Dokument', () => { + expect(docSearch('?live=https://werkbaum.example/api/v1/documents/aaa', + 'live:https://werkbaum.example/api/v1/documents/bbb')) + .toBe('?live=https://werkbaum.example/api/v1/documents/bbb'); + }); + + it('tauscht auch zwischen den beiden Eingängen', () => { + expect(docSearch('?sourceUrl=https://example.org/plan.werkbaum', LIVE)) + .toBe('?live=https://werkbaum.example/api/v1/documents/44753df1'); + expect(docSearch('?live=https://werkbaum.example/api/v1/documents/44753df1', URLDOC)) + .toBe('?sourceUrl=https://example.org/plan.werkbaum'); + }); + + it('räumt den ausgebauten ?etherpad= mit weg (D78)', () => { + expect(docSearch('?etherpad=https://pad.example/p/plan', 'example')).toBe(''); + }); + + it('lässt fremde Parameter wörtlich stehen — auch ihre Schreibweise', () => { + expect(docSearch('?server=http://localhost:8080&live=https://a/x', 'example')) + .toBe('?server=http://localhost:8080'); + expect(docSearch('?server=http://localhost:8080', LIVE)) + .toBe('?server=http://localhost:8080&live=https://werkbaum.example/api/v1/documents/44753df1'); + }); + + it('schreibt die URL unmaskiert — lesbar ist der Zweck', () => { + expect(docSearch('', LIVE)).toContain('https://werkbaum.example/api/v1/documents/44753df1'); + }); + + it('maskiert nur, was den Query-String zerrisse', () => { + expect(docSearch('', 'url:https://example.org/p?a=1&b=2#tail')) + .toBe('?sourceUrl=https://example.org/p?a=1%26b=2%23tail'); + }); + + it('nimmt den Query-String mit und ohne führendes Fragezeichen', () => { + expect(docSearch('server=x', 'example')).toBe('?server=x'); + expect(docSearch('?server=x', 'example')).toBe('?server=x'); + expect(docSearch(undefined, 'example')).toBe(''); + }); + + it('ändert nichts, wenn schon das Richtige dasteht', () => { + const s = '?live=https://werkbaum.example/api/v1/documents/44753df1'; + expect(docSearch(s, LIVE)).toBe(s); + expect(docSearch('', 'example')).toBe(''); + }); +});