From fbe9b1fd229d3555cecc060ee57ecf0592dd1671 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Thu, 27 Aug 2026 08:13:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(paket=20b):=20Umbenennen=20erreicht=20alle?= =?UTF-8?q?=20=E2=80=94=20PATCH=20/title=20und=20RENAMED=20im=20Feed=20(D8?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: PATCH /documents/{id}/title mit expectedVersion (409 bei veralteter Version, 400 bei leerem/zu langem Titel), neuer ChangeType RENAMED (strukturell, immer Meilenstein), der Feed stellt den neuen Titel im Klartext zu (ChangeEvent.title). Unter derselben Stripe-Sperre wie die Inhalts-Patches; Owner-Vormerkung in der API-Beschreibung. Vier neue Cucumber-Szenarien, zwei Unit-Tests. Frontend: Der Zeilen-Stift eines Server-Dokuments benennt über den Server um (optimistisch, 409-Retry, Rücknahme + Warnung bei Fehlschlag); fremde Umbenennungen kommen als RENAMED über den Feed in Chip und Menü. URL-Dokumente verlieren den Stift — ihr Name ist die URL. Co-Authored-By: Claude Fable 5 --- backend/CLAUDE.md | 8 +- backend/data/editor.mv.db | Bin 0 -> 36864 bytes backend/data/editor.trace.db | 3 + .../de/werkbaum/api/DocumentsController.kt | 14 ++++ .../kotlin/de/werkbaum/domain/ChangeFeed.kt | 7 +- .../werkbaum/domain/DocumentHistoryEntry.kt | 5 +- .../de/werkbaum/service/DocumentService.kt | 24 ++++++ .../de/werkbaum/service/LiveEditingService.kt | 20 ++++- backend/src/main/resources/openapi/api.yaml | 74 +++++++++++++++++- .../bdd/LiveEditingStepDefinitions.kt | 39 +++++++++ .../werkbaum/service/DocumentServiceTest.kt | 29 +++++++ .../resources/features/umbenennen.feature | 51 ++++++++++++ docs/CHANGELOG.md | 2 + docs/DECISIONS.md | 41 ++++++++++ docs/examples/werkbaum.werkbaum | 6 ++ frontend/src/app.js | 69 ++++++++++++++-- 16 files changed, 380 insertions(+), 12 deletions(-) create mode 100644 backend/data/editor.mv.db create mode 100644 backend/data/editor.trace.db create mode 100644 backend/src/test/resources/features/umbenennen.feature 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 0000000000000000000000000000000000000000..fd26b73e5d4132f58befe52e876988190ba58c98 GIT binary patch literal 36864 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 Z?*I>-JNT#!!O~m_0u-lM;zx3s|6ljQl-d9Q literal 0 HcmV?d00001 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