feat(paket b): Umbenennen erreicht alle — PATCH /title und RENAMED im Feed (D85)
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c438a88344
commit
fbe9b1fd22
+6
-2
@@ -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.
|
||||
|
||||
Binary file not shown.
@@ -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]
|
||||
@@ -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<ApiDocument> {
|
||||
val renamed = liveEditing.renameDocument(
|
||||
documentId,
|
||||
titlePatchRequest.title,
|
||||
titlePatchRequest.expectedVersion,
|
||||
)
|
||||
return ResponseEntity.ok(renamed.toApi())
|
||||
}
|
||||
|
||||
override fun getDocumentHistory(documentId: UUID): ResponseEntity<List<ApiHistoryEntry>> =
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ChangeFeed>().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<ChangeFeed>().events.map { it.changeType.value } shouldContain typ
|
||||
|
||||
@@ -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<Document>()
|
||||
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<DocumentConflictException> {
|
||||
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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+64
-5
@@ -3714,7 +3714,12 @@ function docRowHtml(d){
|
||||
`<span class="doccheck" aria-hidden="true">✓</span>` +
|
||||
`<span class="docitem-name">${esc(d.name)}</span>${zusatz}</button>` +
|
||||
`<span class="docacts">` +
|
||||
(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
|
||||
|
||||
Reference in New Issue
Block a user