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
|
`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,
|
dort sind gebaut (Zeilen-Diff in `de.werkbaum.diff`, Historie in zwei Ebenen,
|
||||||
`PATCH /content` und der Änderungsfeed im `LiveEditingService`,
|
`PATCH /content` und der Änderungsfeed im `LiveEditingService`,
|
||||||
Master-Passwort für die Dokumentenliste); offen sind `PATCH /title` und der
|
Master-Passwort für die Dokumentenliste, `PATCH /title` samt RENAMED-Ereignis
|
||||||
Client im Frontend.
|
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
|
## Konventionen
|
||||||
- Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21.
|
- 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.DocumentUpdateRequest
|
||||||
import de.werkbaum.generated.model.RestoreRequest
|
import de.werkbaum.generated.model.RestoreRequest
|
||||||
import de.werkbaum.generated.model.ServiceInfo
|
import de.werkbaum.generated.model.ServiceInfo
|
||||||
|
import de.werkbaum.generated.model.TitlePatchRequest
|
||||||
import de.werkbaum.domain.ChangeAuthor
|
import de.werkbaum.domain.ChangeAuthor
|
||||||
import de.werkbaum.domain.ChangeEvent
|
import de.werkbaum.domain.ChangeEvent
|
||||||
import de.werkbaum.domain.ChangeFeed
|
import de.werkbaum.domain.ChangeFeed
|
||||||
@@ -137,6 +138,18 @@ class DocumentsController(
|
|||||||
.body(feed?.toApi())
|
.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>> =
|
override fun getDocumentHistory(documentId: UUID): ResponseEntity<List<ApiHistoryEntry>> =
|
||||||
ResponseEntity.ok(service.history(documentId).map { it.toApi() })
|
ResponseEntity.ok(service.history(documentId).map { it.toApi() })
|
||||||
|
|
||||||
@@ -170,6 +183,7 @@ class DocumentsController(
|
|||||||
changeType = ApiChangeEvent.ChangeType.valueOf(changeType.name),
|
changeType = ApiChangeEvent.ChangeType.valueOf(changeType.name),
|
||||||
clientId = author?.clientId,
|
clientId = author?.clientId,
|
||||||
displayName = author?.displayName,
|
displayName = author?.displayName,
|
||||||
|
title = title,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun DocumentHistoryEntry.toApi(): ApiHistoryEntry = ApiHistoryEntry(
|
private fun DocumentHistoryEntry.toApi(): ApiHistoryEntry = ApiHistoryEntry(
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ package de.werkbaum.domain
|
|||||||
|
|
||||||
import de.werkbaum.diff.LineOp
|
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(
|
data class ChangeEvent(
|
||||||
val version: Long,
|
val version: Long,
|
||||||
val changeType: ChangeType,
|
val changeType: ChangeType,
|
||||||
val author: ChangeAuthor? = null,
|
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
|
* eines **lebenden** Dokuments auf eine alte Version; für den Client ein
|
||||||
* gewöhnlicher Inhaltswechsel. Beide trugen früher denselben Typ; ein Typ, der
|
* 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).
|
* 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
|
* Ein Eintrag der Dokumenthistorie. Die Historie wird getrennt vom Dokument
|
||||||
|
|||||||
@@ -76,6 +76,30 @@ class DocumentService(
|
|||||||
return updated
|
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) {
|
fun delete(id: UUID) {
|
||||||
val existing = findById(id)
|
val existing = findById(id)
|
||||||
repository.deleteById(id)
|
repository.deleteById(id)
|
||||||
|
|||||||
@@ -53,6 +53,21 @@ class LiveEditingService(
|
|||||||
fun patchContent(documentId: UUID, patch: ContentPatch): ContentPatchOutcome =
|
fun patchContent(documentId: UUID, patch: ContentPatch): ContentPatchOutcome =
|
||||||
lockFor(documentId).withLock { applyPatch(documentId, patch) }
|
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 {
|
private fun applyPatch(documentId: UUID, patch: ContentPatch): ContentPatchOutcome {
|
||||||
if (patch.ops.size > properties.maxOps) {
|
if (patch.ops.size > properties.maxOps) {
|
||||||
throw InvalidPatchException(
|
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 =
|
private fun lockFor(documentId: UUID): ReentrantLock =
|
||||||
stripes[Math.floorMod(documentId.hashCode(), stripes.size)]
|
stripes[Math.floorMod(documentId.hashCode(), stripes.size)]
|
||||||
|
|||||||
@@ -255,6 +255,56 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ProblemDetail"
|
$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:
|
/documents/{documentId}/changes:
|
||||||
parameters:
|
parameters:
|
||||||
- name: documentId
|
- name: documentId
|
||||||
@@ -427,11 +477,26 @@ components:
|
|||||||
RESTORED heisst: ein geloeschtes Dokument ist wieder da.
|
RESTORED heisst: ein geloeschtes Dokument ist wieder da.
|
||||||
ROLLED_BACK ist der Rueckfall eines lebenden Dokuments auf eine
|
ROLLED_BACK ist der Rueckfall eines lebenden Dokuments auf eine
|
||||||
aeltere Version.
|
aeltere Version.
|
||||||
enum: [CREATED, UPDATED, DELETED, RESTORED, ROLLED_BACK]
|
enum: [CREATED, UPDATED, RENAMED, DELETED, RESTORED, ROLLED_BACK]
|
||||||
timestamp:
|
timestamp:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
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:
|
RestoreRequest:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@@ -546,7 +611,7 @@ components:
|
|||||||
format: int64
|
format: int64
|
||||||
changeType:
|
changeType:
|
||||||
type: string
|
type: string
|
||||||
enum: [CREATED, UPDATED, DELETED, RESTORED, ROLLED_BACK]
|
enum: [CREATED, UPDATED, RENAMED, DELETED, RESTORED, ROLLED_BACK]
|
||||||
clientId:
|
clientId:
|
||||||
type: string
|
type: string
|
||||||
description: Fehlt bei Aenderungen ohne Absender (etwa ueber PUT).
|
description: Fehlt bei Aenderungen ohne Absender (etwa ueber PUT).
|
||||||
@@ -555,6 +620,11 @@ components:
|
|||||||
description: >
|
description: >
|
||||||
Selbstgewaehlt und ohne Anmeldung eine Behauptung - in der
|
Selbstgewaehlt und ohne Anmeldung eine Behauptung - in der
|
||||||
Oberflaeche nicht wie ein Nachweis darstellen.
|
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:
|
ChangeFeed:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -205,6 +205,45 @@ class LiveEditingStepDefinitions {
|
|||||||
feed.content shouldBe erwartet
|
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}")
|
@Und("der Feed meldet das Ereignis {string}")
|
||||||
fun `der Feed meldet das Ereignis`(typ: String) {
|
fun `der Feed meldet das Ereignis`(typ: String) {
|
||||||
lastBody<ChangeFeed>().events.map { it.changeType.value } shouldContain typ
|
lastBody<ChangeFeed>().events.map { it.changeType.value } shouldContain typ
|
||||||
|
|||||||
@@ -112,6 +112,35 @@ class DocumentServiceTest {
|
|||||||
entry.captured.version shouldBe 4
|
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
|
@Test
|
||||||
fun `delete entfernt Dokument und schreibt DELETED-Tombstone`() {
|
fun `delete entfernt Dokument und schreibt DELETED-Tombstone`() {
|
||||||
val doc = sampleDocument(version = 2)
|
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
|
## 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
|
- 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
|
- 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
|
- 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 —
|
durch **echtes** Tippen in einem zweiten Tab (kein synthetisches Ereignis —
|
||||||
das storage-Ereignis kommt nur aus fremden Fenstern). 531 Tests, davon 2 neue
|
das storage-Ereignis kommt nur aus fremden Fenstern). 531 Tests, davon 2 neue
|
||||||
für `isDocKey`.
|
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.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.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
|
- [ ] #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.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
|
- [-] #col.live.own: A merge algorithm of our own (XL) %% solved problem, do not reinvent
|
||||||
- [?] #mmd: Mermaid plugin (XL)
|
- [?] #mmd: Mermaid plugin (XL)
|
||||||
@@ -918,6 +919,11 @@
|
|||||||
another browser. Management actions bind to it; an admin with the master
|
another browser. Management actions bind to it; an admin with the master
|
||||||
password can still reclaim a document.
|
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
|
#col.live.cursors
|
||||||
Seeing where the others are. A by-product of the merge layer rather than a
|
Seeing where the others are. A by-product of the merge layer rather than a
|
||||||
feature in its own right.
|
feature in its own right.
|
||||||
|
|||||||
+64
-5
@@ -3714,7 +3714,12 @@ function docRowHtml(d){
|
|||||||
`<span class="doccheck" aria-hidden="true">✓</span>` +
|
`<span class="doccheck" aria-hidden="true">✓</span>` +
|
||||||
`<span class="docitem-name">${esc(d.name)}</span>${zusatz}</button>` +
|
`<span class="docitem-name">${esc(d.name)}</span>${zusatz}</button>` +
|
||||||
`<span class="docacts">` +
|
`<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
|
/* Geteilte (Server- wie URL-Dokumente) werden VERLASSEN, nicht gelöscht
|
||||||
(D81-Nachtrag 5): Die Aktion tut lokal dasselbe, ist für den Benutzer
|
(D81-Nachtrag 5): Die Aktion tut lokal dasselbe, ist für den Benutzer
|
||||||
aber eine andere — dem Dokument selbst geschieht nichts, man gibt nur
|
aber eine andere — dem Dokument selbst geschieht nichts, man gibt nur
|
||||||
@@ -4213,9 +4218,9 @@ function finishNewDoc(){
|
|||||||
}
|
}
|
||||||
function renameDoc(id){
|
function renameDoc(id){
|
||||||
if(!docs.some(x => x.id === id)) return;
|
if(!docs.some(x => x.id === id)) return;
|
||||||
/* Mitgelieferte nicht (D81-Nachtrag 3): Ihr Name ist Auslieferungsstand.
|
/* Mitgelieferte nicht (D81-Nachtrag 3) und URL-Dokumente nicht (D85) —
|
||||||
Die Prüfung liegt hier, nicht nur am ausgeblendeten Stift. */
|
die Prüfung liegt hier, nicht nur am ausgeblendeten Stift. */
|
||||||
if(shippedStateOf(id)) return;
|
if(['shipped', 'url'].includes(docKind(id, SHIPPED_IDS))) return;
|
||||||
renamingId = id; /* Zeilen-Aktion (D81): jedes Dokument, nicht nur das aktive */
|
renamingId = id; /* Zeilen-Aktion (D81): jedes Dokument, nicht nur das aktive */
|
||||||
renameIsNew = false;
|
renameIsNew = false;
|
||||||
renderDocMenu();
|
renderDocMenu();
|
||||||
@@ -4226,11 +4231,50 @@ function commitRename(){
|
|||||||
const d = docs.find(x => x.id === renamingId);
|
const d = docs.find(x => x.id === renamingId);
|
||||||
const val = inp ? inp.value.trim() : '';
|
const val = inp ? inp.value.trim() : '';
|
||||||
renamingId = null;
|
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();
|
renderDocMenu();
|
||||||
finishNewDoc();
|
finishNewDoc();
|
||||||
}
|
}
|
||||||
function cancelRename(){ renamingId = null; 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
|
/* Der gemeinsame Kern von Löschen und Verlassen: den Eintrag samt lokaler
|
||||||
Anhängsel entfernen. Die beiden Aktionen unterscheiden sich für den
|
Anhängsel entfernen. Die beiden Aktionen unterscheiden sich für den
|
||||||
Benutzer (Wort, Icon, Rückfrage — D81-Nachtrag 5), lokal tun sie dasselbe. */
|
Benutzer (Wort, Icon, Rückfrage — D81-Nachtrag 5), lokal tun sie dasselbe. */
|
||||||
@@ -4878,6 +4922,7 @@ document.addEventListener('visibilitychange', () => {
|
|||||||
function applyFeed(feed){
|
function applyFeed(feed){
|
||||||
const what = live.feedAction(feed, liveState.version, liveState.busy);
|
const what = live.feedAction(feed, liveState.version, liveState.busy);
|
||||||
if(what === 'skip') return;
|
if(what === 'skip') return;
|
||||||
|
applyRenameEvents(feed); /* Umbenennung durch andere (RENAMED, D85) */
|
||||||
if(what === 'replace'){
|
if(what === 'replace'){
|
||||||
/* Volltext: die Basis ist verdichtet, ein Diff gibt es nicht mehr. */
|
/* Volltext: die Basis ist verdichtet, ein Diff gibt es nicht mehr. */
|
||||||
liveState.version = feed.currentVersion;
|
liveState.version = feed.currentVersion;
|
||||||
@@ -4891,6 +4936,20 @@ function applyFeed(feed){
|
|||||||
applyForeign(alt, feed.ops, liveState.shadow, liveState.version);
|
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.
|
/* Fremde Operationen auf den **sichtbaren** Text anwenden.
|
||||||
Hat der Nutzer inzwischen selbst getippt, ist sein Text nicht mehr die alte
|
Hat der Nutzer inzwischen selbst getippt, ist sein Text nicht mehr die alte
|
||||||
Schattenkopie: Dann wird die fremde Änderung auf seinen Stand angewendet und
|
Schattenkopie: Dann wird die fremde Änderung auf seinen Stand angewendet und
|
||||||
|
|||||||
Reference in New Issue
Block a user