diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index c42e503..813f70c 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -10,8 +10,12 @@ Kommandos in README.md hier. Live-Editing (D76, `docs/live-editing-proposal.md`) ist in Arbeit: Schritte 1–5 der Reihenfolge dort sind gebaut (Zeilen-Diff in `de.werkbaum.diff`, Historie in zwei Ebenen, `PATCH /content` und der Änderungsfeed im `LiveEditingService`, -Master-Passwort für die Dokumentenliste); offen sind `PATCH /title` und der -Client im Frontend. +Master-Passwort für die Dokumentenliste, `PATCH /title` samt RENAMED-Ereignis +im Feed — D85), der Client im Frontend ist verdrahtet (D76-Nachtrag 7). +Verwaltungs-Aktionen (Umbenennen, künftig Löschen) perspektivisch an das +geplante Owner-Passwort binden (`#col.live.owner` im Plan) — Endpunkte so +schneiden, dass die Berechtigungsprüfung dazukommen kann, ohne die Signatur +zu brechen. ## Konventionen - Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21. diff --git a/backend/data/editor.mv.db b/backend/data/editor.mv.db new file mode 100644 index 0000000..fd26b73 Binary files /dev/null and b/backend/data/editor.mv.db differ diff --git a/backend/data/editor.trace.db b/backend/data/editor.trace.db new file mode 100644 index 0000000..b025db8 --- /dev/null +++ b/backend/data/editor.trace.db @@ -0,0 +1,3 @@ +2026-08-27 08:10:36.668320+02:00 jdbc[3]: exception +org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "DATABASECHANGELOGLOCK" not found; SQL statement: +SELECT COUNT(*) FROM PUBLIC.DATABASECHANGELOGLOCK [42102-240] diff --git a/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt b/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt index ce924ff..f7a4b8e 100644 --- a/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt +++ b/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt @@ -11,6 +11,7 @@ import de.werkbaum.generated.model.DocumentHistoryEntry as ApiHistoryEntry import de.werkbaum.generated.model.DocumentUpdateRequest import de.werkbaum.generated.model.RestoreRequest import de.werkbaum.generated.model.ServiceInfo +import de.werkbaum.generated.model.TitlePatchRequest import de.werkbaum.domain.ChangeAuthor import de.werkbaum.domain.ChangeEvent import de.werkbaum.domain.ChangeFeed @@ -137,6 +138,18 @@ class DocumentsController( .body(feed?.toApi()) } + override fun patchDocumentTitle( + documentId: UUID, + titlePatchRequest: TitlePatchRequest, + ): ResponseEntity { + val renamed = liveEditing.renameDocument( + documentId, + titlePatchRequest.title, + titlePatchRequest.expectedVersion, + ) + return ResponseEntity.ok(renamed.toApi()) + } + override fun getDocumentHistory(documentId: UUID): ResponseEntity> = ResponseEntity.ok(service.history(documentId).map { it.toApi() }) @@ -170,6 +183,7 @@ class DocumentsController( changeType = ApiChangeEvent.ChangeType.valueOf(changeType.name), clientId = author?.clientId, displayName = author?.displayName, + title = title, ) private fun DocumentHistoryEntry.toApi(): ApiHistoryEntry = ApiHistoryEntry( diff --git a/backend/src/main/kotlin/de/werkbaum/domain/ChangeFeed.kt b/backend/src/main/kotlin/de/werkbaum/domain/ChangeFeed.kt index b7de326..708315b 100644 --- a/backend/src/main/kotlin/de/werkbaum/domain/ChangeFeed.kt +++ b/backend/src/main/kotlin/de/werkbaum/domain/ChangeFeed.kt @@ -2,11 +2,16 @@ package de.werkbaum.domain import de.werkbaum.diff.LineOp -/** Was an einer Version geschehen ist und wer sie eingereicht hat. */ +/** + * Was an einer Version geschehen ist und wer sie eingereicht hat. + * [title] nur bei [ChangeType.RENAMED]: der neue Titel im Klartext, damit der + * Client ihn ohne weiteren Abruf übernehmen kann (D76/D85). + */ data class ChangeEvent( val version: Long, val changeType: ChangeType, val author: ChangeAuthor? = null, + val title: String? = null, ) /** diff --git a/backend/src/main/kotlin/de/werkbaum/domain/DocumentHistoryEntry.kt b/backend/src/main/kotlin/de/werkbaum/domain/DocumentHistoryEntry.kt index 57beeb7..2459906 100644 --- a/backend/src/main/kotlin/de/werkbaum/domain/DocumentHistoryEntry.kt +++ b/backend/src/main/kotlin/de/werkbaum/domain/DocumentHistoryEntry.kt @@ -11,8 +11,11 @@ import java.util.UUID * eines **lebenden** Dokuments auf eine alte Version; für den Client ein * gewöhnlicher Inhaltswechsel. Beide trugen früher denselben Typ; ein Typ, der * zwei Dinge bedeutet, ist die Unschärfe, aus der später Fehler werden (D76). + * + * [RENAMED] ändert nur den Titel — ein Metadatum, kein Zeileninhalt (D76/D85); + * der Feed stellt den neuen Titel im Klartext zu, der Inhalt bleibt. */ -enum class ChangeType { CREATED, UPDATED, DELETED, RESTORED, ROLLED_BACK } +enum class ChangeType { CREATED, UPDATED, RENAMED, DELETED, RESTORED, ROLLED_BACK } /** * Ein Eintrag der Dokumenthistorie. Die Historie wird getrennt vom Dokument diff --git a/backend/src/main/kotlin/de/werkbaum/service/DocumentService.kt b/backend/src/main/kotlin/de/werkbaum/service/DocumentService.kt index b36bdd4..db1b7bc 100644 --- a/backend/src/main/kotlin/de/werkbaum/service/DocumentService.kt +++ b/backend/src/main/kotlin/de/werkbaum/service/DocumentService.kt @@ -76,6 +76,30 @@ class DocumentService( return updated } + /** + * Ändert nur den Titel (D76/D85): ein Metadatum mit eigener + * Versionsprüfung — weicht [expectedVersion] von der aktuellen Version + * ab, gibt es 409, und der Client setzt frisch auf. Die Umbenennung ist + * strukturell (Meilenstein) und erreicht alle über den Feed als + * [ChangeType.RENAMED] samt neuem Titel. + */ + fun rename(id: UUID, title: String, expectedVersion: Long): Document { + val existing = findById(id) + if (existing.version != expectedVersion) { + throw DocumentConflictException( + "Version $expectedVersion ist nicht mehr aktuell (jetzt: ${existing.version})" + ) + } + val renamed = existing.copy( + title = title, + version = existing.version + 1, + updatedAt = OffsetDateTime.now(clock), + ) + repository.save(renamed) + recordHistory(renamed, ChangeType.RENAMED) + return renamed + } + fun delete(id: UUID) { val existing = findById(id) repository.deleteById(id) diff --git a/backend/src/main/kotlin/de/werkbaum/service/LiveEditingService.kt b/backend/src/main/kotlin/de/werkbaum/service/LiveEditingService.kt index 1c810bb..f286296 100644 --- a/backend/src/main/kotlin/de/werkbaum/service/LiveEditingService.kt +++ b/backend/src/main/kotlin/de/werkbaum/service/LiveEditingService.kt @@ -53,6 +53,21 @@ class LiveEditingService( fun patchContent(documentId: UUID, patch: ContentPatch): ContentPatchOutcome = lockFor(documentId).withLock { applyPatch(documentId, patch) } + /** + * Umbenennen unter derselben Sperre wie die Inhalts-Patches (D85): Auch + * der Titel bumpt die Version, und prüfen und schreiben gehören zusammen. + * Titel-Regeln liegen hier (400), die Versionsprüfung im [DocumentService]. + */ + fun renameDocument(documentId: UUID, title: String, expectedVersion: Long) = + lockFor(documentId).withLock { + val bereinigt = title.trim() + if (bereinigt.isEmpty()) throw InvalidPatchException("Titel darf nicht leer sein") + if (bereinigt.length > 255) { + throw InvalidPatchException("Titel zu lang: ${bereinigt.length} (erlaubt: 255)") + } + documents.rename(documentId, bereinigt, expectedVersion) + } + private fun applyPatch(documentId: UUID, patch: ContentPatch): ContentPatchOutcome { if (patch.ops.size > properties.maxOps) { throw InvalidPatchException( @@ -185,7 +200,10 @@ class LiveEditingService( } } - private fun DocumentHistoryEntry.toEvent() = ChangeEvent(version, changeType, author) + private fun DocumentHistoryEntry.toEvent() = ChangeEvent( + version, changeType, author, + title = title.takeIf { changeType == de.werkbaum.domain.ChangeType.RENAMED }, + ) private fun lockFor(documentId: UUID): ReentrantLock = stripes[Math.floorMod(documentId.hashCode(), stripes.size)] diff --git a/backend/src/main/resources/openapi/api.yaml b/backend/src/main/resources/openapi/api.yaml index 7cd4789..eb83802 100644 --- a/backend/src/main/resources/openapi/api.yaml +++ b/backend/src/main/resources/openapi/api.yaml @@ -255,6 +255,56 @@ paths: schema: $ref: "#/components/schemas/ProblemDetail" + /documents/{documentId}/title: + parameters: + - name: documentId + in: path + required: true + schema: + type: string + format: uuid + patch: + tags: [Documents] + operationId: patchDocumentTitle + summary: Dokument umbenennen (Live-Editing) + description: > + Aendert nur den Titel - er ist ein Metadatum, kein Zeileninhalt, und + bekommt deshalb seinen eigenen Weg mit Versionspruefung (D76). Die + Umbenennung erzeugt eine neue Version vom Typ RENAMED, die der + Aenderungsfeed mit dem neuen Titel im Klartext zustellt - alle sehen + denselben Namen. + + + Verwaltungs-Aktion: Sie wird kuenftig an das geplante Owner-Passwort + gebunden (die Bindung kommt als Berechtigungspruefung dazu, die + Signatur bleibt). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TitlePatchRequest" + responses: + "200": + description: Umbenanntes Dokument + content: + application/json: + schema: + $ref: "#/components/schemas/Document" + "400": + $ref: "#/components/responses/BadRequest" + "404": + $ref: "#/components/responses/NotFound" + "409": + description: > + Versionskonflikt - `expectedVersion` ist nicht mehr die aktuelle + Version. Der Client holt das Dokument frisch und versucht es mit + dessen Version erneut. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + /documents/{documentId}/changes: parameters: - name: documentId @@ -427,11 +477,26 @@ components: RESTORED heisst: ein geloeschtes Dokument ist wieder da. ROLLED_BACK ist der Rueckfall eines lebenden Dokuments auf eine aeltere Version. - enum: [CREATED, UPDATED, DELETED, RESTORED, ROLLED_BACK] + enum: [CREATED, UPDATED, RENAMED, DELETED, RESTORED, ROLLED_BACK] timestamp: type: string format: date-time + TitlePatchRequest: + type: object + required: [title, expectedVersion] + properties: + title: + type: string + minLength: 1 + maxLength: 255 + expectedVersion: + type: integer + format: int64 + description: > + Die Version, auf der die Umbenennung aufsetzt - weicht sie von der + aktuellen ab, antwortet der Server mit 409. + RestoreRequest: type: object properties: @@ -546,7 +611,7 @@ components: format: int64 changeType: type: string - enum: [CREATED, UPDATED, DELETED, RESTORED, ROLLED_BACK] + enum: [CREATED, UPDATED, RENAMED, DELETED, RESTORED, ROLLED_BACK] clientId: type: string description: Fehlt bei Aenderungen ohne Absender (etwa ueber PUT). @@ -555,6 +620,11 @@ components: description: > Selbstgewaehlt und ohne Anmeldung eine Behauptung - in der Oberflaeche nicht wie ein Nachweis darstellen. + title: + type: string + description: > + Nur bei RENAMED - der neue Titel im Klartext, damit der Client + ihn ohne weiteren Abruf uebernehmen kann (D76). ChangeFeed: type: object diff --git a/backend/src/test/kotlin/de/werkbaum/bdd/LiveEditingStepDefinitions.kt b/backend/src/test/kotlin/de/werkbaum/bdd/LiveEditingStepDefinitions.kt index f9f7cc2..737b1b9 100644 --- a/backend/src/test/kotlin/de/werkbaum/bdd/LiveEditingStepDefinitions.kt +++ b/backend/src/test/kotlin/de/werkbaum/bdd/LiveEditingStepDefinitions.kt @@ -205,6 +205,45 @@ class LiveEditingStepDefinitions { feed.content shouldBe erwartet } + @Wenn("das Dokument auf {string} umbenannt wird") + fun `das Dokument wird umbenannt`(titel: String) = + sendRename(titel, currentDocument().version) + + @Wenn("das Dokument mit veralteter Version auf {string} umbenannt wird") + fun `veraltete Umbenennung`(titel: String) = + sendRename(titel, currentDocument().version - 1) + + private fun sendRename(titel: String, version: Long) { + lastResponse = client.patch() + .uri("/api/v1/documents/$documentId/title") + .contentType(MediaType.APPLICATION_JSON) + .body("""{"title":${json(titel)},"expectedVersion":$version}""") + .exchange() + .returnResult(String::class.java) + } + + @Dann("erhalte ich für das Umbenennen den Status {int}") + fun `status des Umbenennens`(erwartet: Int) { + status() shouldBe erwartet + } + + @Und("das Dokument trägt den Titel {string}") + fun `dokument traegt den Titel`(titel: String) { + currentDocument().title shouldBe titel + } + + @Und("das Dokument steht auf Version {long}") + fun `dokument steht auf Version`(version: Long) { + currentDocument().version shouldBe version + } + + @Und("das RENAMED-Ereignis nennt den Titel {string}") + fun `renamed nennt den Titel`(titel: String) { + lastBody().events + .first { it.changeType.value == "RENAMED" } + .title shouldBe titel + } + @Und("der Feed meldet das Ereignis {string}") fun `der Feed meldet das Ereignis`(typ: String) { lastBody().events.map { it.changeType.value } shouldContain typ diff --git a/backend/src/test/kotlin/de/werkbaum/service/DocumentServiceTest.kt b/backend/src/test/kotlin/de/werkbaum/service/DocumentServiceTest.kt index d038502..98652ad 100644 --- a/backend/src/test/kotlin/de/werkbaum/service/DocumentServiceTest.kt +++ b/backend/src/test/kotlin/de/werkbaum/service/DocumentServiceTest.kt @@ -112,6 +112,35 @@ class DocumentServiceTest { entry.captured.version shouldBe 4 } + @Test + fun `rename inkrementiert Version und schreibt RENAMED-Meilenstein`() { + val doc = sampleDocument(version = 3) + every { repository.findById(doc.id) } returns doc + val saved = slot() + every { repository.save(capture(saved)) } answers { saved.captured } + val entry = captureAppended() + + val result = service.rename(doc.id, title = "Team-Plan", expectedVersion = 3) + + result.version shouldBe 4 + result.title shouldBe "Team-Plan" + result.content shouldBe doc.content /* der Inhalt bleibt */ + entry.captured.changeType shouldBe ChangeType.RENAMED + entry.captured.milestone shouldBe true /* strukturell, nie bloss Sync */ + entry.captured.title shouldBe "Team-Plan" + } + + @Test + fun `rename mit veralteter Version wirft Konflikt und schreibt nichts`() { + val doc = sampleDocument(version = 3) + every { repository.findById(doc.id) } returns doc + + shouldThrow { + service.rename(doc.id, title = "Zu spaet", expectedVersion = 2) + } + verify(exactly = 0) { repository.save(any()) } + } + @Test fun `delete entfernt Dokument und schreibt DELETED-Tombstone`() { val doc = sampleDocument(version = 2) diff --git a/backend/src/test/resources/features/umbenennen.feature b/backend/src/test/resources/features/umbenennen.feature new file mode 100644 index 0000000..1035c5f --- /dev/null +++ b/backend/src/test/resources/features/umbenennen.feature @@ -0,0 +1,51 @@ +# language: de +Funktionalität: Ein geteiltes Dokument umbenennen + Als Bearbeiter eines geteilten Plans + möchte ich den Titel für alle ändern, + damit jeder im Wähler denselben Namen sieht. + + Grundlage: Der Titel ist ein Metadatum und bekommt seinen eigenen Weg mit + Versionsprüfung (D76/D85); der Änderungsfeed stellt die Umbenennung samt + neuem Titel zu. + + Szenario: Umbenennen mit aktueller Version gilt und lässt den Inhalt stehen + Angenommen es existiert ein Dokument "Plan" mit den Zeilen: + """ + - [ ] Eins + """ + Wenn das Dokument auf "Team-Plan" umbenannt wird + Dann erhalte ich für das Umbenennen den Status 200 + Und das Dokument trägt den Titel "Team-Plan" + Und das Dokument steht auf Version 2 + Und das Dokument hat die Zeilen: + """ + - [ ] Eins + """ + + Szenario: Der Feed stellt die Umbenennung samt neuem Titel zu + Angenommen es existiert ein Dokument "Plan" mit den Zeilen: + """ + - [ ] Eins + """ + Wenn das Dokument auf "Team-Plan" umbenannt wird + Und ich die Änderungen seit Version 1 abrufe + Dann der Feed meldet das Ereignis "RENAMED" + Und das RENAMED-Ereignis nennt den Titel "Team-Plan" + + Szenario: Eine veraltete Version wird abgelehnt + Angenommen es existiert ein Dokument "Plan" mit den Zeilen: + """ + - [ ] Eins + """ + Wenn das Dokument mit veralteter Version auf "Zu spät" umbenannt wird + Dann erhalte ich für das Umbenennen den Status 409 + Und das Dokument trägt den Titel "Plan" + + Szenario: Ein leerer Titel wird abgelehnt + Angenommen es existiert ein Dokument "Plan" mit den Zeilen: + """ + - [ ] Eins + """ + Wenn das Dokument auf " " umbenannt wird + Dann erhalte ich für das Umbenennen den Status 400 + Und das Dokument trägt den Titel "Plan" diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 85ce950..0b1c94e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,8 @@ reverse. ## 2026-08-27 +- Renaming a shared document now renames it for everyone: the pencil patches the server title, and the change feed hands the new name to every open editor +- URL documents lost their pencil — their name is the URL, and a local name would not survive the next load - If Werkbaum is open in a second browser tab, a warning says so — both tabs write the same document list, and the last one to save wins - Reloading a URL document no longer asks — a page reload discards local changes silently anyway, so the question promised a protection that did not exist - Deleting your last document now says that a fresh example will take its place diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 7fe8a02..ee81640 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -7290,3 +7290,44 @@ richtigen Texte, Abbruch lässt alles stehen; und die Tab-Warnung erschien durch **echtes** Tippen in einem zweiten Tab (kein synthetisches Ereignis — das storage-Ereignis kommt nur aus fremden Fenstern). 531 Tests, davon 2 neue für `isDocKey`. + +## D85 — Umbenennen erreicht alle: PATCH /title und das RENAMED-Ereignis +Paket B der Ungereimtheiten-Liste (D84). Der Befund: Umbenennen eines +geteilten Dokuments wirkte nur lokal und war flüchtig — der Server-Titel gilt +für alle (D76), und `adoptLive()` überschrieb den lokalen Namen beim nächsten +Laden. Dasselbe bei URL-Dokumenten, deren Name die URL ist (D23). Entschieden +(Multiple-Choice): der in D76 vorgesehene eigene Weg wird gebaut. + +**Backend:** `PATCH /documents/{id}/title` mit `expectedVersion` — +abweichende Version ⇒ 409, der Client setzt frisch auf; leerer oder zu langer +Titel ⇒ 400 (die Regeln liegen im `LiveEditingService`, unter derselben +Stripe-Sperre wie die Inhalts-Patches: auch der Titel bumpt die Version, und +prüfen und schreiben gehören zusammen). Die Umbenennung ist ein neuer +`ChangeType.RENAMED` — strukturell, also immer Meilenstein — und der +Änderungsfeed stellt den **neuen Titel im Klartext** zu (`ChangeEvent.title`, +nur bei RENAMED gefüllt): Kein Client braucht einen weiteren Abruf. In der +API-Beschreibung steht die Owner-Vormerkung: Die Aktion wird künftig an das +geplante Owner-Passwort gebunden (`#col.live.owner`), die Bindung kommt als +Berechtigungsprüfung dazu, die Signatur bleibt. + +**Frontend:** Der Zeilen-Stift eines Server-Dokuments ruft `renameOnServer()` — +optimistisch sofort anzeigen, dann GET (Version) + PATCH; bei 409 einmal mit +frischer Version erneut, bei endgültigem Scheitern kommt der alte Name zurück +und eine Warnung sagt warum. Die Version bumpt ohne Inhaltsänderung, die +Schattenkopie bleibt also gültig. Die übrigen Mitschreiber bekommen die +Umbenennung über `applyRenameEvents()` aus dem Feed — Chip und Menü folgen +ohne Neuladen. **URL-Dokumente verlieren den Stift** (wie Mitgelieferte, +D81-Nachtrag 3): Ihr Name ist die URL; ein lokaler Name würde beim nächsten +Laden überschrieben, und ein Server, den man umbenennen könnte, steht dort +nicht dahinter. + +**Nachgemessen** Ende-zu-Ende gegen das lokal laufende Backend: Der Stift am +Server-Dokument benennt um (Server-Titel „Team-Plan X", Version 2, keine +Warnung); eine Umbenennung **von außen** (curl) erreicht den offenen Editor +über den Feed — Chip wechselt auf „Von aussen umbenannt", der Inhalt bleibt +unangetastet. Backend: vier neue Cucumber-Szenarien (gilt für alle und lässt +den Inhalt stehen; Feed samt Titel; veraltete Version ⇒ 409; leerer Titel ⇒ +400) und zwei Unit-Tests (RENAMED-Meilenstein; Konflikt schreibt nichts), +BUILD SUCCESSFUL samt Coverage. Werkzeuggrenze wie in D76-Nachtrag 7: Der +Automations-Tab meldet sich dauerhaft als verborgen — der Feed lief nur mit +gestellter Sichtbarkeit. diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index 3919834..8834dfe 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -154,6 +154,7 @@ - [^] #col.live.upload: Share: put a local plan on the server (S) %% a button in the editor title bar, see D81 - [?] #col.live.cursors: Cursors and who else is here (S) - [ ] #col.live.owner: An owner password guards managing a shared plan (M) %% returned once on creation, admin can reclaim + - [x] #col.live.rename: Renaming reaches everyone (S) %% PATCH /title + RENAMED in the feed, see D85 - [-] #col.live.ws: Transport over a websocket (S) %% plain HTTP turned out to be enough - [-] #col.live.own: A merge algorithm of our own (XL) %% solved problem, do not reinvent - [?] #mmd: Mermaid plugin (XL) @@ -918,6 +919,11 @@ another browser. Management actions bind to it; an admin with the master password can still reclaim a document. +#col.live.rename + The title is server metadata, so renaming patches it with a version check + and the change feed hands the new title to every client in plain text. URL + documents lost their pencil instead - their name is the URL. + #col.live.cursors Seeing where the others are. A by-product of the merge layer rather than a feature in its own right. diff --git a/frontend/src/app.js b/frontend/src/app.js index 4b5ac04..b06a08a 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -3714,7 +3714,12 @@ function docRowHtml(d){ `` + `${esc(d.name)}${zusatz}` + `` + - (shippedStateOf(d.id) ? '' : iconBtn('rename', t('docRename'), IC_RENAME)) + + /* Umbenennen: bei Mitgelieferten nicht (der Name ist Auslieferungsstand, + D81-Nachtrag 3), bei URL-Dokumenten nicht (der Name IST die URL, D23 — + ein lokaler Name würde beim nächsten Laden überschrieben, D85). Bei + Server-Dokumenten wirkt es über PATCH /title für ALLE (D85). */ + (['shipped', 'url'].includes(docKind(d.id, SHIPPED_IDS)) + ? '' : iconBtn('rename', t('docRename'), IC_RENAME)) + /* Geteilte (Server- wie URL-Dokumente) werden VERLASSEN, nicht gelöscht (D81-Nachtrag 5): Die Aktion tut lokal dasselbe, ist für den Benutzer aber eine andere — dem Dokument selbst geschieht nichts, man gibt nur @@ -4213,9 +4218,9 @@ function finishNewDoc(){ } function renameDoc(id){ if(!docs.some(x => x.id === id)) return; - /* Mitgelieferte nicht (D81-Nachtrag 3): Ihr Name ist Auslieferungsstand. - Die Prüfung liegt hier, nicht nur am ausgeblendeten Stift. */ - if(shippedStateOf(id)) return; + /* Mitgelieferte nicht (D81-Nachtrag 3) und URL-Dokumente nicht (D85) — + die Prüfung liegt hier, nicht nur am ausgeblendeten Stift. */ + if(['shipped', 'url'].includes(docKind(id, SHIPPED_IDS))) return; renamingId = id; /* Zeilen-Aktion (D81): jedes Dokument, nicht nur das aktive */ renameIsNew = false; renderDocMenu(); @@ -4226,11 +4231,50 @@ function commitRename(){ const d = docs.find(x => x.id === renamingId); const val = inp ? inp.value.trim() : ''; renamingId = null; - if(d && val){ d.name = val; persistDocs(); updateDocName(); } + if(d && val){ + /* Server-Dokumente: Der Titel gehört dem Server — alle sehen denselben + (D76). Der Weg dorthin ist PATCH /title (D85), nicht der lokale Name. */ + if(String(d.id).startsWith('live:')) renameOnServer(d, val); + else { d.name = val; persistDocs(); updateDocName(); } + } renderDocMenu(); finishNewDoc(); } function cancelRename(){ renamingId = null; renderDocMenu(); finishNewDoc(); } +/* Umbenennen eines Server-Dokuments (D85): optimistisch sofort anzeigen; + scheitert der PATCH, kommt der alte Name zurück und die Warnung sagt + warum. Bei 409 — jemand war zwischen Abruf und PATCH schneller — einmal + mit frischer Version erneut. Die Version bumpt ohne Inhaltsänderung, die + Schattenkopie bleibt also gültig; die übrigen Mitschreiber bekommen die + Umbenennung als RENAMED-Ereignis über ihren Feed. */ +async function renameOnServer(d, titel){ + const url = String(d.id).slice(5); + const alt = d.name; + const zeige = () => { persistDocs(); updateDocName(); if(!docMenu.hidden) renderDocMenu(); }; + d.name = titel; zeige(); + const patch = async () => { + const doc = await fetchJson(url); + return fetchJson(url + '/title', { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({title: titel, expectedVersion: doc.version}), + }); + }; + try{ + let neu; + try{ neu = await patch(); } + catch(err){ if(err && err.status === 409) neu = await patch(); else throw err; } + d.name = neu.title; + if(liveState && liveState.id === d.id) liveState.version = neu.version; + zeige(); + }catch(err){ + d.name = alt; + sourceWarning = {type: 'liveLoad', url: url + '/title', + error: (err && err.message) || String(err)}; + zeige(); + render(); + } +} /* Der gemeinsame Kern von Löschen und Verlassen: den Eintrag samt lokaler Anhängsel entfernen. Die beiden Aktionen unterscheiden sich für den Benutzer (Wort, Icon, Rückfrage — D81-Nachtrag 5), lokal tun sie dasselbe. */ @@ -4878,6 +4922,7 @@ document.addEventListener('visibilitychange', () => { function applyFeed(feed){ const what = live.feedAction(feed, liveState.version, liveState.busy); if(what === 'skip') return; + applyRenameEvents(feed); /* Umbenennung durch andere (RENAMED, D85) */ if(what === 'replace'){ /* Volltext: die Basis ist verdichtet, ein Diff gibt es nicht mehr. */ liveState.version = feed.currentVersion; @@ -4891,6 +4936,20 @@ function applyFeed(feed){ applyForeign(alt, feed.ops, liveState.shadow, liveState.version); } +/* Hat jemand umbenannt, trägt der Feed den neuen Titel im Klartext (D85) — + der letzte gewinnt. Der Name ist Index-Metadatum: persistieren ist ein + Flush-Ereignis, und Umbenennen ist selten. */ +function applyRenameEvents(feed){ + const ev = (feed.events || []).filter(e => e && e.changeType === 'RENAMED' && e.title).pop(); + if(!ev) return; + const d = docs.find(x => x.id === liveState.id); + if(!d || d.name === ev.title) return; + d.name = ev.title; + persistDocs(); + updateDocName(); + if(!docMenu.hidden) renderDocMenu(); +} + /* Fremde Operationen auf den **sichtbaren** Text anwenden. Hat der Nutzer inzwischen selbst getippt, ist sein Text nicht mehr die alte Schattenkopie: Dann wird die fremde Änderung auf seinen Stand angewendet und