diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index c30a81c..dc2d0bf 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -81,3 +81,10 @@ docs/SPEC.md §10 testen — niemals eine zweite, abweichende Grammatik pflegen. deshalb den `slug` (aus `&taiga.`, SPEC §1) und fragen erst `/projects/by_slug`, dann `by_ref` — der Slug kommt vom Client und wird **kodiert** angehängt, sonst hängte ein `&` darin einen weiteren Filter an. +- **Schreiben (D91-Nachtrag 8):** `PATCH /taiga/{userstories|tasks}/{ref}/status` + nimmt die Status-**Id** (aus `GET /taiga/{userstory|task}-statuses?slug=`) + und die zuletzt gelesene `version` — Taigas optimistische Sperre; ein + Konflikt wird durchgereicht, nie überschrieben. Die Zielspalte wählt der + Editor: Namen sind je Projekt frei, die Abbildung ist Notation (D14). Der + Test-Stub muss **chunked** Bodies lesen (der JDK-HttpClient sendet ohne + `Content-Length`) — sonst meldet er einen Versionskonflikt, den es nicht gibt. diff --git a/backend/src/main/kotlin/de/werkbaum/api/TaigaController.kt b/backend/src/main/kotlin/de/werkbaum/api/TaigaController.kt index dc7d66a..5825e31 100644 --- a/backend/src/main/kotlin/de/werkbaum/api/TaigaController.kt +++ b/backend/src/main/kotlin/de/werkbaum/api/TaigaController.kt @@ -6,9 +6,12 @@ import de.werkbaum.generated.model.TaigaProject import de.werkbaum.generated.model.TaigaSession import de.werkbaum.generated.model.TaigaStoryCreateRequest import de.werkbaum.generated.model.TaigaTaskCreateRequest +import de.werkbaum.generated.model.TaigaStatus +import de.werkbaum.generated.model.TaigaStatusPatch import de.werkbaum.generated.model.TaigaTicket import de.werkbaum.generated.model.TaigaTicketDetail import de.werkbaum.integration.taiga.TaigaClient +import de.werkbaum.integration.taiga.TaigaTicketDetailData import de.werkbaum.integration.taiga.TaigaTicketData import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity @@ -84,19 +87,60 @@ class TaigaController(private val client: TaigaClient) : TaigaApi { ticket(xTaigaToken, slug, ref, task = true) private fun ticket(token: String, slug: String, ref: Long, task: Boolean): - ResponseEntity { - val d = client.ticket(token, slug, ref, task) - return ResponseEntity.ok( - TaigaTicketDetail( - id = d.id, - ref = d.ref, - subject = d.subject, - status = d.status, - statusClosed = d.statusClosed, - assignee = d.assignee, - ) + ResponseEntity = + ResponseEntity.ok(client.ticket(token, slug, ref, task).toApi()) + + /* Schreiben (D91-Nachtrag 7/8): angestoßen wird es im Knoten-Fenster, von + selbst geschieht nichts. Die Zielspalte kommt als Id herein — welche es + ist, entscheidet der Editor über die Statusbox-Abbildung (D14). */ + override fun taigaStoryStatuses(xTaigaToken: String, slug: String) = + statuses(xTaigaToken, slug, task = false) + + override fun taigaTaskStatuses(xTaigaToken: String, slug: String) = + statuses(xTaigaToken, slug, task = true) + + private fun statuses(token: String, slug: String, task: Boolean): + ResponseEntity> = + ResponseEntity.ok( + client.statuses(token, slug, task).map { + TaigaStatus(id = it.id, name = it.name, closed = it.closed) + } ) - } + + override fun taigaSetStoryStatus( + xTaigaToken: String, + ref: Long, + slug: String, + taigaStatusPatch: TaigaStatusPatch, + ) = setStatus(xTaigaToken, slug, ref, task = false, patch = taigaStatusPatch) + + override fun taigaSetTaskStatus( + xTaigaToken: String, + ref: Long, + slug: String, + taigaStatusPatch: TaigaStatusPatch, + ) = setStatus(xTaigaToken, slug, ref, task = true, patch = taigaStatusPatch) + + private fun setStatus( + token: String, + slug: String, + ref: Long, + task: Boolean, + patch: TaigaStatusPatch, + ): ResponseEntity = + ResponseEntity.ok( + client.setStatus(token, slug, ref, task, patch.status, patch.version).toApi() + ) + + private fun TaigaTicketDetailData.toApi() = TaigaTicketDetail( + id = id, + ref = ref, + subject = subject, + status = status, + statusClosed = statusClosed, + assignee = assignee, + version = version, + ) private fun created(ticket: TaigaTicketData): ResponseEntity = ResponseEntity.status(HttpStatus.CREATED).body( diff --git a/backend/src/main/kotlin/de/werkbaum/integration/taiga/TaigaClient.kt b/backend/src/main/kotlin/de/werkbaum/integration/taiga/TaigaClient.kt index 9d94731..140ce9f 100644 --- a/backend/src/main/kotlin/de/werkbaum/integration/taiga/TaigaClient.kt +++ b/backend/src/main/kotlin/de/werkbaum/integration/taiga/TaigaClient.kt @@ -51,8 +51,13 @@ data class TaigaTicketDetailData( val status: String?, val statusClosed: Boolean?, val assignee: String?, + /** Taigas optimistische Sperre; geht beim Schreiben unverändert zurück. */ + val version: Long?, ) +/** Eine Spalte des Projekt-Workflows (D91-Nachtrag 8). */ +data class TaigaStatusData(val id: Long, val name: String, val closed: Boolean?) + /** * Schmaler, benannter Client zur konfigurierten Taiga-Instanz (D91) — kein * Durchreich-Proxy: genau die vier Aufrufe, die die Ticket-Anlage braucht. @@ -127,16 +132,65 @@ class TaigaClient(private val properties: TaigaProperties) { .header("Authorization", "Bearer $token") .retrieve().body(MAP) } ?: throw TaigaUnavailableException("Leere Antwort von Taiga ($pfad)") - return TaigaTicketDetailData( - id = num(map, "id"), - ref = num(map, "ref"), - subject = str(map, "subject"), - status = extra(map, "status_extra_info")?.get("name") as? String, - statusClosed = extra(map, "status_extra_info")?.get("is_closed") as? Boolean, - assignee = extra(map, "assigned_to_extra_info")?.get("full_name_display") as? String, - ) + return detail(map) } + /** + * Die Spalten des Projekt-Workflows (D91-Nachtrag 8) — Taiga schreibt nach + * Status-**Id**, und die Namen sind je Projekt frei. Welche Spalte gemeint + * ist, entscheidet der Editor: Er kennt die Abbildung auf die Statusbox + * (SPEC §4), das Backend parst die Notation nicht (D14). + */ + fun statuses(token: String, slug: String, task: Boolean): List { + val project = projectId(token, slug) + val pfad = if (task) "/task-statuses" else "/userstory-statuses" + val list = exchange { + rest.get().uri(url("$pfad?project=$project")) + .header("Authorization", "Bearer $token") + .retrieve().body(LIST) + } ?: emptyList() + return list.map { + TaigaStatusData(num(it, "id"), str(it, "name"), it["is_closed"] as? Boolean) + } + } + + /** + * Den Status eines Tickets setzen (D91-Nachtrag 8). Die `version` kommt + * vom Client — sie ist die, die er gelesen hat: Taigas optimistische + * Sperre lehnt das Schreiben ab, wenn jemand dazwischen geändert hat, und + * der Konflikt wird durchgereicht statt überschrieben (dieselbe Haltung + * wie beim Live-Editing, D76). + */ + fun setStatus( + token: String, + slug: String, + ref: Long, + task: Boolean, + status: Long, + version: Long, + ): TaigaTicketDetailData { + val id = ticket(token, slug, ref, task).id + val pfad = if (task) "/tasks/$id" else "/userstories/$id" + val map = exchange { + rest.patch().uri(url(pfad)) + .header("Authorization", "Bearer $token") + .contentType(MediaType.APPLICATION_JSON) + .body(mapOf("status" to status, "version" to version)) + .retrieve().body(MAP) + } ?: throw TaigaUnavailableException("Leere Antwort von Taiga ($pfad)") + return detail(map) + } + + private fun detail(map: Map) = TaigaTicketDetailData( + id = num(map, "id"), + ref = num(map, "ref"), + subject = str(map, "subject"), + status = extra(map, "status_extra_info")?.get("name") as? String, + statusClosed = extra(map, "status_extra_info")?.get("is_closed") as? Boolean, + assignee = extra(map, "assigned_to_extra_info")?.get("full_name_display") as? String, + version = (map["version"] as? Number)?.toLong(), + ) + /** * Projekt-Slug -> Id; Taigas `by_ref` filtert über die Id. Der Slug kommt * vom Client und wird deshalb **kodiert** in die Anfrage gesetzt — sonst diff --git a/backend/src/main/resources/openapi/api.yaml b/backend/src/main/resources/openapi/api.yaml index 7ed28d6..7073785 100644 --- a/backend/src/main/resources/openapi/api.yaml +++ b/backend/src/main/resources/openapi/api.yaml @@ -606,6 +606,173 @@ paths: "503": $ref: "#/components/responses/TaigaNotConfigured" + /taiga/userstories/{ref}/status: + patch: + tags: [Taiga] + operationId: taigaSetStoryStatus + summary: Status einer User Story setzen (Proxy) + description: > + Schreibt den Status zurueck (D91-Nachtrag 7/8) - die eine Haelfte des + Abgleichs, die der Benutzer im Knoten-Fenster ausdruecklich anstoesst; + von selbst geschieht nichts. `status` ist die **Id** einer Spalte aus + `GET /taiga/userstory-statuses` (die Namen sind je Projekt frei), + `version` die zuletzt gelesene: Hat jemand dazwischen geaendert, lehnt + Taigas optimistische Sperre ab und der Konflikt wird durchgereicht, + statt ihn zu ueberschreiben. + parameters: + - $ref: "#/components/parameters/TaigaToken" + - $ref: "#/components/parameters/TaigaRef" + - $ref: "#/components/parameters/TaigaSlug" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TaigaStatusPatch" + responses: + "200": + description: Der neue Stand des Tickets + content: + application/json: + schema: + $ref: "#/components/schemas/TaigaTicketDetail" + "400": + $ref: "#/components/responses/BadRequest" + "401": + description: Token fehlt oder ist abgelaufen + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "404": + description: Projekt oder Ref gibt es nicht + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "502": + $ref: "#/components/responses/TaigaUnavailable" + "503": + $ref: "#/components/responses/TaigaNotConfigured" + + /taiga/tasks/{ref}/status: + patch: + tags: [Taiga] + operationId: taigaSetTaskStatus + summary: Status einer Task setzen (Proxy) + description: > + Wie `PATCH /taiga/userstories/{ref}/status`, nur fuer Tasks; die + Spalten kommen aus `GET /taiga/task-statuses`. + parameters: + - $ref: "#/components/parameters/TaigaToken" + - $ref: "#/components/parameters/TaigaRef" + - $ref: "#/components/parameters/TaigaSlug" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TaigaStatusPatch" + responses: + "200": + description: Der neue Stand des Tickets + content: + application/json: + schema: + $ref: "#/components/schemas/TaigaTicketDetail" + "400": + $ref: "#/components/responses/BadRequest" + "401": + description: Token fehlt oder ist abgelaufen + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "404": + description: Projekt oder Ref gibt es nicht + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "502": + $ref: "#/components/responses/TaigaUnavailable" + "503": + $ref: "#/components/responses/TaigaNotConfigured" + + /taiga/userstory-statuses: + get: + tags: [Taiga] + operationId: taigaStoryStatuses + summary: Workflow-Spalten der Storys eines Projekts (Proxy) + description: > + `GET /userstory-statuses?project=`. Gebraucht zum + Schreiben: Taiga nimmt die Status-**Id**, und welche Spalte zu welcher + Statusbox gehoert, entscheidet der Editor (SPEC par. 4/9) - das + Backend parst die Notation nicht (D14). + parameters: + - $ref: "#/components/parameters/TaigaToken" + - $ref: "#/components/parameters/TaigaSlug" + responses: + "200": + description: Die Spalten des Projekts + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TaigaStatus" + "401": + description: Token fehlt oder ist abgelaufen + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "404": + description: Projekt gibt es nicht + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "502": + $ref: "#/components/responses/TaigaUnavailable" + "503": + $ref: "#/components/responses/TaigaNotConfigured" + + /taiga/task-statuses: + get: + tags: [Taiga] + operationId: taigaTaskStatuses + summary: Workflow-Spalten der Tasks eines Projekts (Proxy) + description: Wie `GET /taiga/userstory-statuses`, nur fuer Tasks. + parameters: + - $ref: "#/components/parameters/TaigaToken" + - $ref: "#/components/parameters/TaigaSlug" + responses: + "200": + description: Die Spalten des Projekts + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/TaigaStatus" + "401": + description: Token fehlt oder ist abgelaufen + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "404": + description: Projekt gibt es nicht + content: + application/problem+json: + schema: + $ref: "#/components/schemas/ProblemDetail" + "502": + $ref: "#/components/responses/TaigaUnavailable" + "503": + $ref: "#/components/responses/TaigaNotConfigured" + /info: get: tags: [Documents] @@ -1113,6 +1280,42 @@ components: Anzeigename des Zustaendigen (`assigned_to_extra_info.full_name_display`); fehlt, wenn niemand zugewiesen ist. + version: + type: integer + format: int64 + description: > + Taigas optimistische Sperre. Sie geht beim Schreiben unveraendert + zurueck (`PATCH .../status`); passt sie nicht mehr, lehnt Taiga ab + und der Konflikt wird durchgereicht. + + TaigaStatus: + type: object + description: Eine Spalte des Projekt-Workflows (D91-Nachtrag 8). + required: [id, name] + properties: + id: + type: integer + format: int64 + name: + type: string + description: > + Frei benannt, je Projekt verschieden - die Zuordnung zur Statusbox + der Notation macht der Editor. + closed: + type: boolean + + TaigaStatusPatch: + type: object + required: [status, version] + properties: + status: + type: integer + format: int64 + description: Id der Zielspalte (aus den Status-Listen), nicht ihr Name. + version: + type: integer + format: int64 + description: Die zuletzt gelesene Version des Tickets. ProblemDetail: type: object diff --git a/backend/src/test/kotlin/de/werkbaum/api/TaigaApiTest.kt b/backend/src/test/kotlin/de/werkbaum/api/TaigaApiTest.kt index 63d27c3..8743ce9 100644 --- a/backend/src/test/kotlin/de/werkbaum/api/TaigaApiTest.kt +++ b/backend/src/test/kotlin/de/werkbaum/api/TaigaApiTest.kt @@ -143,6 +143,31 @@ class TaigaApiTest { result.responseBody!! shouldContain "\"assignee\":null" } + @Test + fun `die Workflow-Spalten eines Projekts kommen als Id und Name`() { + val result = client.get() + .uri("/api/v1/taiga/userstory-statuses?slug=mi-kunde") + .header("X-Taiga-Token", "tok-abc123") + .exchange() + .returnResult(String::class.java) + result.status.value() shouldBe 200 + result.responseBody!! shouldContain """{"id":12,"name":"In progress","closed":false}""" + } + + @Test + fun `ein Status wird per Ref gesetzt und antwortet mit dem neuen Stand`() { + val result = client.patch() + .uri("/api/v1/taiga/userstories/123/status?slug=mi-kunde") + .header("X-Taiga-Token", "tok-abc123") + .header("Content-Type", "application/json") + .body("""{"status":13,"version":7}""") + .exchange() + .returnResult(String::class.java) + result.status.value() shouldBe 200 + result.responseBody!! shouldContain "\"status\":\"Done\"" + result.responseBody!! shouldContain "\"version\":8" + } + companion object { private lateinit var stub: HttpServer @Volatile private var stubStatus = 200 @@ -160,6 +185,9 @@ class TaigaApiTest { "/api/v1/projects/by_slug" -> TaigaClientTestData.PROJECT_OK "/api/v1/userstories/by_ref" -> TaigaClientTestData.STORY_DETAIL "/api/v1/tasks/by_ref" -> TaigaClientTestData.TASK_DETAIL + "/api/v1/userstory-statuses", "/api/v1/task-statuses" -> + TaigaClientTestData.STATUSES_OK + "/api/v1/userstories/1234" -> TaigaClientTestData.STORY_PATCHED else -> "{}" } val status = if (stubBody != null) stubStatus @@ -209,9 +237,16 @@ object TaigaClientTestData { const val PROJECT_OK = """{"id": 7, "name": "Kunde", "slug": "mi-kunde"}""" const val STORY_DETAIL = - """{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7, + """{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7, "version": 7, "status_extra_info": {"name": "In progress", "is_closed": false}, "assigned_to_extra_info": {"full_name_display": "Anna Beispiel"}}""" + const val STATUSES_OK = + """[{"id": 11, "name": "New", "is_closed": false}, + {"id": 12, "name": "In progress", "is_closed": false}, + {"id": 13, "name": "Done", "is_closed": true}]""" + const val STORY_PATCHED = + """{"id": 1234, "ref": 123, "subject": "Login bauen", "version": 8, + "status_extra_info": {"name": "Done", "is_closed": true}}""" const val TASK_DETAIL = """{"id": 5678, "ref": 1234, "subject": "API-Teil", "project": 7, "status_extra_info": {"name": "Done", "is_closed": true}}""" diff --git a/backend/src/test/kotlin/de/werkbaum/integration/taiga/TaigaClientTest.kt b/backend/src/test/kotlin/de/werkbaum/integration/taiga/TaigaClientTest.kt index 4458ccc..0204233 100644 --- a/backend/src/test/kotlin/de/werkbaum/integration/taiga/TaigaClientTest.kt +++ b/backend/src/test/kotlin/de/werkbaum/integration/taiga/TaigaClientTest.kt @@ -162,6 +162,64 @@ class TaigaClientTest { requests[0].query shouldBe "slug=a%26member%3D1" } + /* ---- Status zurückschreiben (D91-Nachtrag 7/8) ---- */ + + @Test + fun `statuses liefert die Spalten des Projekt-Workflows`() { + routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK + routes["/api/v1/userstory-statuses"] = 200 to STATUSES_OK + val s = client().statuses("tok-abc123", "mi-kunde", task = false) + + s.map { it.name } shouldBe listOf("New", "In progress", "Done") + s[0].id shouldBe 11L + s[2].closed shouldBe true + requests[1].query shouldBe "project=7" + } + + @Test + fun `Task-Spalten kommen vom eigenen Endpunkt`() { + routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK + routes["/api/v1/task-statuses"] = 200 to STATUSES_OK + client().statuses("tok-abc123", "mi-kunde", task = true) + requests[1].path shouldBe "/api/v1/task-statuses" + } + + @Test + fun `setStatus loest die Ref auf und patcht mit Status-Id und Version`() { + routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK + routes["/api/v1/userstories/by_ref"] = 200 to STORY_DETAIL + routes["/api/v1/userstories/1234"] = 200 to STORY_PATCHED + val d = client().setStatus("tok-abc123", "mi-kunde", 123, task = false, status = 13, version = 7) + + d.status shouldBe "Done" + d.version shouldBe 8L + + val patch = requests.last() + patch.path shouldBe "/api/v1/userstories/1234" /* per Id, nicht per Ref */ + patch.method shouldBe "PATCH" + patch.body shouldContain "\"status\":13" + patch.body shouldContain "\"version\":7" + } + + @Test + fun `eine veraltete Version wird als Konflikt durchgereicht`() { + routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK + routes["/api/v1/userstories/by_ref"] = 200 to STORY_DETAIL + routes["/api/v1/userstories/1234"] = 400 to STALE + val ex = shouldThrow { + client().setStatus("tok-abc123", "mi-kunde", 123, task = false, status = 13, version = 1) + } + ex.status shouldBe 400 + ex.message shouldContain "modified" + } + + @Test + fun `die gelesene Version kommt mit - sie ist die Sperre fuers Schreiben`() { + routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK + routes["/api/v1/userstories/by_ref"] = 200 to STORY_DETAIL + client().ticket("tok-abc123", "mi-kunde", 123, task = false).version shouldBe 7L + } + @Test fun `ohne konfigurierte Instanz gibt es kein Ziel`() { val bare = TaigaClient(TaigaProperties(apiUrl = "")) @@ -183,6 +241,7 @@ class TaigaClientTest { data class Recorded( val path: String, + val method: String, val query: String?, val auth: String?, val noPagination: String?, @@ -220,7 +279,7 @@ class TaigaClientTest { /* Form der by_ref-Antwort: die Namen stehen in den `*_extra_info`-Blöcken, die Ids daneben (Taiga-API). */ const val STORY_DETAIL = - """{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7, "status": 3, + """{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7, "status": 3, "version": 7, "status_extra_info": {"name": "In progress", "color": "#ff9900", "is_closed": false}, "assigned_to": 42, "assigned_to_extra_info": {"username": "anna", "full_name_display": "Anna Beispiel"}}""" @@ -230,6 +289,15 @@ class TaigaClientTest { "assigned_to_extra_info": null}""" const val STORY_BARE = """{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7, "status": 3}""" + const val STATUSES_OK = + """[{"id": 11, "name": "New", "is_closed": false, "order": 1}, + {"id": 12, "name": "In progress", "is_closed": false}, + {"id": 13, "name": "Done", "is_closed": true}]""" + const val STORY_PATCHED = + """{"id": 1234, "ref": 123, "subject": "Login bauen", "version": 8, + "status_extra_info": {"name": "Done", "is_closed": true}}""" + const val STALE = + """{"_error_message": "The version doesn't match the data base version. The object was modified.", "_error_type": "taiga.base.exceptions.WrongArguments"}""" const val NOT_FOUND = """{"_error_message": "Not found.", "_error_type": "taiga.base.exceptions.NotFound"}""" @@ -240,6 +308,7 @@ class TaigaClientTest { server.createContext("/") { ex -> recorded = Recorded( path = ex.requestURI.path, + method = ex.requestMethod, query = ex.requestURI.query, auth = ex.requestHeaders.getFirst("Authorization"), noPagination = ex.requestHeaders.getFirst("x-disable-pagination"), diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 75a2de6..0446470 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,10 @@ reverse. ## 2026-08-27 +- Where a ticket and the plan disagree, the node window says so and offers both directions as explicit buttons — nothing happens by itself +- "Write to Taiga" sets the ticket's column from the status box; only the five mapped states can be written, `[?]`, `[!]`, `[-]` and a box-less node leave the ticket untouched +- Writing goes against the state you were shown: if someone changed the ticket meanwhile, it is refused instead of overwritten, and ↻ fetches the new state +- "Take from Taiga" writes the status box into the text line — an ordinary, undoable change, visible to everyone in a shared document - The node window shows the state of a ticket now: subject, status and assignee, read from Taiga through the backend proxy - Taiga's status is shown next to the notation's own box (`In progress → [~]`) — a column name outside the five known ones stays unmapped and is shown as text - Reading a ticket never changes the plan: no status is written back, and the text stays untouched diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index fc40fd9..8ce54b2 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -8261,3 +8261,73 @@ den Namen — die Spaltennamen sind je Projekt frei, der Proxy braucht also Und ein `PATCH` verlangt die `version` des Tickets (optimistisches Sperren): Hat jemand dazwischen geändert, meldet sich der Konflikt, statt ihn zu überschreiben — dieselbe Haltung wie beim Live-Editing (D76). + +**Nachtrag 8 — `#trk.write` gebaut: die Entscheidungen des Baus +(2026-08-27).** Die Festlegung aus Nachtrag 7 ist umgesetzt (SPEC §9). Was +dabei zu entscheiden war: + +**Taiga schreibt nach Status-Id, die Auswahl trifft der Editor.** Die +Spaltennamen sind je Projekt frei, `PATCH` nimmt die **Id**. Der Proxy +bekommt deshalb zwei Lese-Endpunkte für die Spalten +(`GET /taiga/userstory-statuses`, `…/task-statuses`, je `?slug=`), und die +Zuordnung Statusbox → Spaltenname bleibt im Editor (`taigaStatusName`, +`pickStatus`, headless) — dieselbe Grenze wie beim Lesen: Statuscodes sind +Notation, das Backend parst sie nicht (D14). Verglichen wird mit **derselben +Normalisierung** wie beim Lesen (Groß-/Kleinschreibung und Leerraum egal), +damit nicht zwei Stellen dieselbe Regel unterschiedlich auslegen. Findet sich +die Spalte im Projekt nicht, wird **nicht geschrieben** und das Fenster nennt +sie beim Namen. + +**Geschrieben wird gegen die gelesene `version`** — Taigas optimistische +Sperre. Der Client schickt die Version, die er im Fenster **gezeigt** hat; +hat jemand dazwischen etwas geändert, lehnt Taiga ab, der Fehlertext steht im +Fenster und der ↻-Knopf holt den neuen Stand. Damit kann das Zurückschreiben +nie eine fremde Änderung überschreiben, die man gar nicht gesehen hat — die +Haltung des Live-Editings (D76), hier gegenüber einem fremden System. +Nachgemessen: Ein zweiter Schreibversuch mit der alten Version wird abgelehnt +(HTTP 400 samt Taigas Meldung), danach ↻ und derselbe Knopf gelingen. + +**Die Ref bleibt die Adresse, auch beim Schreiben.** `PATCH +/taiga/userstories/{ref}/status?slug=` löst intern erst den Slug und dann die +Ref auf (drei Umläufe je Schreibvorgang). Der Endpunkt könnte die Ticket-**Id** +nehmen — die kennt der Client aus dem Lesen —, aber dann hieße `{…}` im selben +Pfadmuster einmal Ref und einmal Id: eine Zweideutigkeit, die man später +einmal falsch liest. Schreibvorgänge sind einzelne Klicks; der Umlauf ist +billiger als die Verwechslung. + +**Die Statusbox setzt `setStatusBox()` in parser.js** — Text→Text neben +`setFoldMark` (D38) und `expandShortIds` (D55), also an der Stelle, an der die +Zeilenstruktur ohnehin bekannt ist: Angefasst wird nur die Box, Einrückung, +Zeichen, **beide** Faltmarken-Stellungen und Label bleiben zeichengenau +stehen. Geschrieben wird über `replaceTextUndoable` (D53) — ein Undo-Schritt, +nie `src.value =`. + +**Nachgemessen** im Browser gegen das lokal laufende Backend mit Taiga-Stub: +Ticket „In progress → [~]" gegen Plan `[ ]` zeigt die Abweichung in der +Warnfarbe und beide Knöpfe; *nach Taiga schreiben* setzt die Spalte „New", +danach ist die Abweichung weg **und der Notationstext unverändert**; ein +`[?]`-Knoten bekommt **nur** den Übernehmen-Knopf samt Begründung, und +*übernehmen* schreibt `[/]` in die Zeile (`- [/] API-Teil #T-1234`), worauf +der Neubau das Fenster schließt. Backend: 5 neue Client-Tests (Spaltenlisten +je Typ, PATCH per Id mit Status und Version, Konflikt-Durchreichung, gelesene +Version) und 2 Ende-zu-Ende-Tests; Frontend 596 Tests (11 neue). Gegenproben: +Normalisierung der Spaltensuche entfernt → genau die eine danach benannte +Zusicherung fällt; die Faltmarken-Gruppe aus `setStatusBox` entfernt → die +vier Statusbox-Tests. + +**Zwei Werkzeugfallen, beide schon einmal bezahlt und wieder zugeschnappt:** +Der Stub muss **chunked** Bodies lesen — der JDK-HttpClient sendet ohne +`Content-Length`, und wer nur die Länge liest, sieht `{}` und meldet einen +Versionskonflikt, den es nicht gibt (dieselbe Falle wie in Nachtrag 4). Und +`execCommand('insertText')` braucht **Fensterfokus**: Ohne dargestellte +Browser-Fläche tut es nichts, der Prüftext muss dann über `value` plus +`input`-Ereignis gesetzt werden (dieselbe Sorte Grenze wie D57). + +**Dazu eine neue, die es vorher nicht gab:** Ein **gerades** `"` in einem +deutschen i18n-Text (`„{name}"`) beendet die JS-Zeichenkette — der Bundle war +kaputt, und **`npm test` merkte davon nichts**, weil die Testsuite `app.js` +nie importiert (sie prüft die Module). Gesehen hat es erst der Dev-Server. +Wer i18n-Texte mit Anführungszeichen schreibt, nimmt die typografischen +(`„…“`, `«…»`, `“…”`) und prüft die Datei einmal mit `npx esbuild src/app.js +--outfile=…` — das ist die schnellste ehrliche Syntaxprobe für eine Datei, +die kein Test anfasst. diff --git a/docs/SPEC.md b/docs/SPEC.md index 00d0896..9420e73 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -500,9 +500,25 @@ es einen gibt, den **Zuständigen**. „In progress“ `[~]`, „Ready for test“ `[/]`, „Done“ `[x]`, „Archived“ `[^]`; Groß-/Kleinschreibung und Leerraum sind egal. Ein Name außerhalb dieser Liste bleibt **unabgebildet** und steht nur als Text — geraten wird nicht. -- **Gelesen, nie geschrieben.** Der Notationstext bleibt unangetastet; die - Statusbox des Knotens ändert sich nicht, und die Abbildung sagt nichts über - Fortschritt (§4) oder Kosten (§5). Das Zurückschreiben ist reserviert (§11). +- **Von selbst geschieht nichts.** Weder wird der Notationstext angefasst + noch das Ticket: Die Abbildung ist eine Anzeige, keine Aussage über + Fortschritt (§4) oder Kosten (§5). +- **Weicht der Ticket-Status von der Statusbox ab**, wird das **markiert** + (Warnfarbe, mit der eigenen Box daneben) und mit **zwei ausdrücklichen + Aktionen** angeboten: + - *nach Taiga schreiben* — setzt den Status des Tickets auf die Spalte, die + zur eigenen Statusbox gehört. Angeboten nur für die fünf abgebildeten + Zustände; `[?]`, `[!]`, `[-]` und der neutrale Knoten haben keine + Entsprechung und lassen das Ticket **unangetastet** — das Fenster sagt, + warum. Geschrieben wird gegen den zuletzt **gelesenen** Stand: Hat jemand + inzwischen etwas geändert, wird abgelehnt statt überschrieben, und die + Meldung steht im Fenster (↻ holt den neuen Stand). + - *aus Taiga übernehmen* — schreibt die Statusbox in die **Textzeile**, als + gewöhnliche, undo-fähige Änderung; in einem geteilten Dokument (§9, + `?live=`) sehen sie damit alle. Der Neubau schließt das Fenster; die neue + Farbe des Knotens ist die Rückmeldung. +- Beide Richtungen betreffen **einen** Knoten; eine Sammelaktion über einen + Teilbaum gibt es nicht. - Geholt wird erst, wenn das Fenster **kurz stehen bleibt** (nicht im Vorüberfahren), und je Ticket **einmal je Sitzung** — ein ↻-Knopf im Fenster holt neu. Ohne Anmeldung an der Instanz, ohne Projekt-Zuordnung oder ohne @@ -511,7 +527,7 @@ es einen gibt, den **Zuständigen**. - Reine Bedienhilfe wie das Fenster selbst: nicht im Grafikexport, nicht im Druck. -Siehe D91-Nachtrag 6. +Siehe D91-Nachträge 6, 7 und 8. **Die Knotenfarbe zeigt den effektiven Status (§4)**, nicht den intrinsischen — das Diagramm beantwortet „wie weit ist das wirklich?“. Wo der eigene Status @@ -1385,33 +1401,17 @@ Taiga-Projekte, benennt das Schlagwort `&taiga.` (unten) das Projekt je Teilbaum — eine Ref wird gegen das Projekt des nächsten Vorfahren mit so einem Tag aufgelöst. Siehe D91-Nachträge 2 und 3. Der **Stand** eines so bezeichneten Tickets (Betreff, Status, Zuständiger) -wird im Knoten-Fenster gezeigt (§9) — gelesen, nie geschrieben; das -**Zurückschreiben** des Status bleibt reserviert (unten). +wird im Knoten-Fenster gezeigt (§9); weicht er von der Statusbox ab, sind +dort beide Richtungen als ausdrückliche Aktionen zu haben — von selbst +geschieht nichts. Freie Schlagworte liegen **nicht** mehr auf `#` — siehe `&tag` unten; damit ist die frühere Dreifach-Rolle von `#` aufgelöst (D34). -### Status zurückschreiben — festgelegt, noch nicht gebaut +### Status zurückschreiben -Der Ticket-Stand wird gelesen (§9). Der Abgleich in die andere Richtung ist -entschieden, aber ungebaut; die Regeln stehen hier, bevor sie gebaut werden. - -- **Niemand gewinnt von selbst.** Weicht der gelesene Ticket-Status von der - Statusbox des Knotens ab, wird die Abweichung im Knoten-Fenster - **markiert** und mit **zwei ausdrücklichen Aktionen** angeboten: *nach - Taiga schreiben* (Statusbox → Ticket) und *aus Taiga übernehmen* - (Ticket-Status → Statusbox). Kein Takt, kein stiller Abgleich, keine - Richtung, die die andere überstimmt. -- **Übernehmen ist eine gewöhnliche Textänderung**: Die Statusbox wird - geschrieben wie beim Falten (§9) — undo-fähig, in einem geteilten Dokument - für alle sichtbar. Sonst gilt weiter, dass der Text die Quelle der Wahrheit - ist und kein Werkzeug ihn ungefragt umschreibt. -- **Geschrieben wird nur, was die Abbildung kennt** (§9, die fünf Zustände). - `[?]`, `[!]`, `[-]` und der neutrale Knoten haben keine Entsprechung und - lassen das Ticket **unangetastet**; das Fenster sagt, warum. Erfunden wird - nichts — dieselbe Haltung wie beim Lesen, wo ein unbekannter Spaltenname - unabgebildet bleibt. -- Beide Richtungen betreffen **einen** Knoten. Eine Sammelaktion über einen - Teilbaum ist offen und wird, wenn sie kommt, hier festgelegt. +**Umgesetzt** — Anzeige und Regeln in §9 (Abweichung im Knoten-Fenster, zwei +ausdrückliche Aktionen, nur die abgebildeten Zustände). Entscheidung und +verworfene Alternativen: D91-Nachträge 7 und 8. ### Schlagworte (`&tag`) diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index c647d8e..0e1ebee 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -204,7 +204,7 @@ - [/] #trk.resolve: Resolve "#US-123" over the REST API (M) - [^] #trk.resolve.read: Read title, link and status (S) - [/] #trk.resolve.map: Map the workflow onto the states (S) - - [?] #trk.write: Write the status back (M) :#trk.resolve + - [x] #trk.write: Write the status back (M) :#trk.resolve - [^] #trk.create: Create tickets from nodes (XL) - [^] #trk.create.proxy: Backend proxy with named endpoints (M) - [^] #trk.create.login: Log in to Taiga, token stays in the browser (S) :#trk.create.proxy @@ -1158,8 +1158,9 @@ shown as plain text; making the mapping configurable is still open. #trk.write - The other direction: change a status in the plan and the ticket follows. - Only worth doing once reading it is trustworthy. + The other direction. Neither side wins by itself: a difference between the + ticket and the status box is marked, and both directions are offered as + explicit buttons — writing only for the five mapped states. #trk.create One click in the node window turns a node into a Taiga ticket. The cut diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 5ca60fd..e678538 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -681,6 +681,24 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der läuft schon beim Aufbau — sonst temporale Todeszone). Der `pointerdown`-Wächter lässt `.tabmodal-overlay` durch: Der Anmelde-Dialog gehört zu einer Aktion AUS dem Fenster und darf es nicht zumachen. +- **Status zurückschreiben (D91-Nachtrag 7/8):** Weicht der Ticket-Status von + der Statusbox ab, zeigt `paintDiff()` beides und bietet **zwei** Knöpfe — + von selbst geschieht in keine Richtung etwas. `pushStatus()` sucht die + Spalte des Projekts (`statusListPath` → `pickStatus`, je Sitzung gecacht; + Taiga schreibt nach **Id**, nicht nach Namen) und patcht mit der zuletzt + GELESENEN `version` — Taigas optimistische Sperre; ein Konflikt kommt als + Fehlerzeile ins Fenster, überschrieben wird nie. `pullStatus()` schreibt + die Box über `setStatusBox()` (parser.js, Text→Text neben `setFoldMark`) + und `writeLine()`/`replaceTextUndoable` in die Zeile — der Neubau schließt + danach das Fenster. Schreibbar sind nur die fünf abgebildeten Zustände + (`taigaStatusName`); `[?]`, `[!]`, `[-]` und der neutrale Knoten bekommen + keinen Knopf, sondern die Begründung. +- **Ein gerades `"` in einem i18n-Text zerlegt den Bundle** — und `npm test` + merkt es NICHT: Die Testsuite importiert `app.js` nie (sie prüft die + Module), gesehen hat es erst der Dev-Server (500er, weißes Bild). Deutsche + Anführungszeichen also typografisch schreiben (`„…“`) und die Datei nach + einer i18n-Runde einmal mit `npx esbuild src/app.js --outfile=/tmp/x.js` + prüfen — die schnellste ehrliche Syntaxprobe für eine Datei ohne Test. - **Nie `src.value = …` während des Bearbeitens (D53).** Es löscht die Undo-Historie des Textfelds **komplett** — nicht nur den eigenen Schritt, sondern alles davor Getippte. Gemessen: nach so einem Schreiben ändert das diff --git a/frontend/src/app.js b/frontend/src/app.js index 71220b1..4eb56cb 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1,7 +1,7 @@ import './style.css'; -import { parse, setFoldMark, expandShortIds, shortIdClosed } from './parser.js'; +import { parse, setFoldMark, setStatusBox, expandShortIds, shortIdClosed } from './parser.js'; import { computeCheapPlan, overloadedAssignee, assigneeLoads, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, presetFoldSet, personFoldSet, allTags, lineTargets, taigaSlugs } from './model.js'; -import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, ticketApiPath, mapTaigaStatus } from './taiga.js'; +import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath } from './taiga.js'; import { esc, renderTreeHtml, TIP_RULE } from './render.js'; import { formatWarning, warningText } from './warnings.js'; import * as live from './live.js'; @@ -1822,7 +1822,7 @@ function appendTaigaActions(el){ entfällt nur dessen Hälfte. */ const slug = taigaSlugs(roots).get(node); const url = ticketUrl(taigaWeb, slug, ref); - if(slug) nodeTipBody.appendChild(ticketBox(el, slug, ref)); + if(slug) nodeTipBody.appendChild(ticketBox(el, slug, ref, line)); /* Als Knopf im Fenster ist der Link auch auf Touch erreichbar (dort gibt es kein Strg) und macht die Strg+Klick-Geste nebenbei auffindbar (D25-Lehre). */ @@ -1861,12 +1861,12 @@ function appendTaigaActions(el){ const taigaTickets = new Map(); /* '/' -> {kind:'load'|'ok'|'err', data, msg} */ const TICKET_DELAY = 400; /* `tipTicket`/`ticketTimer`: oben bei `tipNode` */ -function ticketBox(el, slug, ref){ +function ticketBox(el, slug, ref, line){ const box = document.createElement('div'); box.className = 'nodetip-ticket'; const key = slug + '/' + ref; - tipTicket = {key, slug, ref, box}; - paintTicket(box, key, slug, ref); + tipTicket = {key, slug, ref, line, box}; + paintTicket(box, key, slug, ref, line); clearTimeout(ticketTimer); if(!taigaTickets.has(key) && taigaSession()){ ticketTimer = setTimeout(() => { @@ -1876,7 +1876,7 @@ function ticketBox(el, slug, ref){ return box; } -function paintTicket(box, key, slug, ref){ +function paintTicket(box, key, slug, ref, lineNo){ const st = taigaTickets.get(key) || {kind: 'idle'}; box.textContent = ''; const line = (cls, text) => { @@ -1887,6 +1887,7 @@ function paintTicket(box, key, slug, ref){ return d; }; if(st.kind === 'load'){ line('tk-line', t('taigaTicketLoading')); return; } + if(st.kind === 'write'){ line('tk-line', t('taigaTicketWriting')); return; } if(st.kind === 'ok'){ const head = document.createElement('div'); head.className = 'tk-head'; @@ -1913,12 +1914,117 @@ function paintTicket(box, key, slug, ref){ box.appendChild(head); if(st.data.subject) line('tk-line', st.data.subject); if(st.data.assignee) line('tk-line', t('taigaTicketAssignee', {name: st.data.assignee})); + paintDiff(box, line, st, key, slug, ref, lineNo); return; } if(st.kind === 'err') line('tk-err', st.msg); box.appendChild(reloadBtn(key, slug, ref, st.kind === 'err')); } +/* Die Abweichung und ihre zwei Knöpfe (D91-Nachtrag 7): Sagen Ticket und + Statusbox Verschiedenes, wird das **markiert** und beide Richtungen werden + ausdrücklich angeboten — von selbst geschieht nichts, in keine Richtung. + Verglichen wird nur, was abbildbar ist: Ein unbekannter Spaltenname (oben + unabgebildet) sagt nichts über den Plan. */ +function paintDiff(box, line, st, key, slug, ref, lineNo){ + const ticket = mapTaigaStatus(st.data.status); + if(!ticket) return; + const eigen = ownStatusOfLine(lineNo); + if(eigen && eigen.code === ticket.code) return; /* einig — nichts zu tun */ + + const d = document.createElement('div'); + d.className = 'tk-diff'; + d.textContent = t('taigaTicketDiff') + ' '; + if(eigen){ + const c = document.createElement('span'); + c.className = 'chip st-' + eigen.key; + c.textContent = '[' + eigen.code + ']'; + d.appendChild(c); + } else { + d.appendChild(document.createTextNode(t('taigaTicketNoBox'))); + } + box.appendChild(d); + + const act = document.createElement('div'); + act.className = 'tk-act'; + /* Schreiben geht nur für die fünf abgebildeten Zustände (D91-Nachtrag 7); + `[?]`, `[!]`, `[-]` und der neutrale Knoten lassen das Ticket + unangetastet — und das Fenster sagt, warum. Ohne gelesene `version` + fehlt Taigas Sperre; dann wird nicht angeboten. */ + const ziel = eigen ? taigaStatusName(eigen.code) : null; + if(ziel && st.data.version != null){ + act.appendChild(mkBtn('→ ' + t('taigaTicketPush'), + () => pushStatus(key, slug, ref, ziel, st.data.version))); + } + act.appendChild(mkBtn('← ' + t('taigaTicketPull'), + () => pullStatus(lineNo, ticket.code))); + box.appendChild(act); + if(!ziel) line('tk-line', t('taigaTicketNoMap')); +} + +function mkBtn(label, onClick){ + const b = document.createElement('button'); + b.type = 'button'; b.tabIndex = -1; + b.className = 'nodetip-taigabtn'; + b.textContent = label; + b.addEventListener('click', onClick); + return b; +} + +/* Der intrinsische Status des Knotens einer Zeile — frisch geparst, denn das + Fenster kann eine Weile offen gestanden haben. */ +function ownStatusOfLine(lineNo){ + const {node} = nodeAndRootsByLine(lineNo); + return node && node.status ? node.status : null; +} + +/* „aus Taiga übernehmen": die Statusbox im TEXT setzen — eine gewöhnliche, + undo-fähige Textänderung (D53), in einem geteilten Dokument für alle + sichtbar. Der Neubau schließt danach das Fenster; die neue Farbe des + Knotens ist die Rückmeldung. */ +function pullStatus(lineNo, code){ + writeLine(lineNo, zeile => setStatusBox(zeile, code)); +} + +/* „nach Taiga schreiben": Spalte des Projekts suchen (Taiga schreibt nach Id, + die Namen sind je Projekt frei), dann mit der gelesenen `version` patchen — + hat jemand dazwischen geändert, lehnt Taiga ab und der Text steht im + Fenster, statt dass etwas überschrieben wird. */ +async function pushStatus(key, slug, ref, name, version){ + const session = await taigaEnsureSession(); + if(!session || !session.token) return; + taigaTickets.set(key, {kind: 'write'}); + repaintTicket(key); + try{ + const spalten = await taigaStatusList(slug, ref, session); + const spalte = pickStatus(spalten, name); + if(!spalte){ + taigaTickets.set(key, {kind: 'err', msg: t('taigaTicketNoColumn', {name})}); + } else { + const neu = await taigaFetch(statusApiPath(ref, slug), {method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({status: spalte.id, version})}, session.token); + taigaTickets.set(key, {kind: 'ok', data: neu}); + } + }catch(err){ + if(err && err.status === 401) storeTaigaSession(null); + taigaTickets.set(key, {kind: 'err', msg: taigaErrText(err)}); + } + repaintTicket(key); +} + +/* Die Spalten je Projekt und Typ — einmal je Sitzung, wie der Ticket-Stand + selbst (sie ändern sich nur, wenn jemand den Workflow umbaut). */ +const taigaStatusLists = new Map(); +async function taigaStatusList(slug, ref, session){ + const pfad = statusListPath(ref, slug); + if(!taigaStatusLists.has(pfad)){ + taigaStatusLists.set(pfad, taigaFetch(pfad, null, session.token) + .catch(err => { taigaStatusLists.delete(pfad); throw err; })); + } + return taigaStatusLists.get(pfad); +} + /* Der Knopf holt den Stand — und meldet dafür bei Bedarf an (`interactive`): Ein Klick ist die ausdrückliche Absicht, das Überfahren eines Knotens nicht. */ function reloadBtn(key, slug, ref, kurz){ @@ -1953,7 +2059,7 @@ async function loadTicket(key, slug, ref, interactive){ Fenster wächst dabei, also neu setzen. */ function repaintTicket(key){ if(!tipTicket || tipTicket.key !== key || !tipTicket.box.isConnected) return; - paintTicket(tipTicket.box, key, tipTicket.slug, tipTicket.ref); + paintTicket(tipTicket.box, key, tipTicket.slug, tipTicket.ref, tipTicket.line); if(tipNode) placeNodeTip(tipNode); } @@ -2066,12 +2172,18 @@ async function taigaProjects(session){ Schreibmarke; ein nacktes execCommand griffe ohne Fokus nicht und fiele auf den Historien-Killer `src.value =` zurück, D53). */ function writeLineTokens(lineNo, tokens){ + writeLine(lineNo, zeile => tokens.reduce(appendToken, zeile)); +} + +/* Eine einzelne Textzeile ersetzen (Tokens anhängen, Statusbox setzen …). + Unverändert heißt: nicht schreiben — sonst entstünde ein leerer + Undo-Schritt. */ +function writeLine(lineNo, mapper){ const zeilen = src.value.split('\n'); if(lineNo < 1 || lineNo > zeilen.length) return; - let zeile = zeilen[lineNo - 1]; - for(const tok of tokens) zeile = appendToken(zeile, tok); - if(zeile === zeilen[lineNo - 1]) return; - zeilen[lineNo - 1] = zeile; + const neu = mapper(zeilen[lineNo - 1]); + if(neu === zeilen[lineNo - 1]) return; + zeilen[lineNo - 1] = neu; withEditorWritable(() => replaceTextUndoable(zeilen.join('\n'))); } @@ -2892,7 +3004,7 @@ const I18N = { acHint:"{n} ID-Vorschläge – ↑/↓ wählt, Enter übernimmt", tipClose:"Schließen", tipOpenLink:"Link öffnen", - taigaStoryBtn:"Taiga: Story anlegen", taigaTasksBtn:"Taiga: Story + Tasks anlegen", taigaLoginTitle:"Bei Taiga anmelden", taigaUser:"Benutzername", taigaPass:"Passwort", taigaLoginDo:"Anmelden", taigaCancel:"Abbrechen", taigaProjectTitle:"Taiga-Ticket anlegen", taigaProject:"Projekt", taigaTasksPick:"Teilpakete als Tasks anlegen:", taigaCreateDo:"Anlegen", taigaError:"Fehlgeschlagen: {error}", taigaOpenBtn:"{ref} in Taiga öffnen", taigaTicketFetch:"Stand holen", taigaTicketReload:"Stand neu holen", taigaTicketLoading:"Stand wird geholt …", taigaTicketAssignee:"Zuständig: {name}", + taigaStoryBtn:"Taiga: Story anlegen", taigaTasksBtn:"Taiga: Story + Tasks anlegen", taigaLoginTitle:"Bei Taiga anmelden", taigaUser:"Benutzername", taigaPass:"Passwort", taigaLoginDo:"Anmelden", taigaCancel:"Abbrechen", taigaProjectTitle:"Taiga-Ticket anlegen", taigaProject:"Projekt", taigaTasksPick:"Teilpakete als Tasks anlegen:", taigaCreateDo:"Anlegen", taigaError:"Fehlgeschlagen: {error}", taigaOpenBtn:"{ref} in Taiga öffnen", taigaTicketDiff:"weicht vom Plan ab:", taigaTicketNoBox:"ohne Statusbox", taigaTicketPush:"nach Taiga schreiben", taigaTicketPull:"aus Taiga übernehmen", taigaTicketWriting:"wird geschrieben …", taigaTicketNoMap:"Dieser Zustand hat in Taiga keine Entsprechung — geschrieben wird nichts.", taigaTicketNoColumn:"Die Spalte „{name}“ gibt es in diesem Projekt nicht.", taigaTicketFetch:"Stand holen", taigaTicketReload:"Stand neu holen", taigaTicketLoading:"Stand wird geholt …", taigaTicketAssignee:"Zuständig: {name}", liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/<uuid>)?", liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.", liveConflictText:"Jemand hat dieselben Zeilen geändert. Wessen Fassung soll gelten?", @@ -3029,7 +3141,7 @@ const I18N = { acHint:"{n} id suggestions – ↑/↓ to choose, Enter to insert", tipClose:"Close", tipOpenLink:"Open link", - taigaStoryBtn:"Taiga: create story", taigaTasksBtn:"Taiga: create story + tasks", taigaLoginTitle:"Log in to Taiga", taigaUser:"Username", taigaPass:"Password", taigaLoginDo:"Log in", taigaCancel:"Cancel", taigaProjectTitle:"Create Taiga ticket", taigaProject:"Project", taigaTasksPick:"Create sub-packages as tasks:", taigaCreateDo:"Create", taigaError:"Failed: {error}", taigaOpenBtn:"Open {ref} in Taiga", taigaTicketFetch:"Fetch state", taigaTicketReload:"Fetch again", taigaTicketLoading:"Fetching state …", taigaTicketAssignee:"Assigned to: {name}", + taigaStoryBtn:"Taiga: create story", taigaTasksBtn:"Taiga: create story + tasks", taigaLoginTitle:"Log in to Taiga", taigaUser:"Username", taigaPass:"Password", taigaLoginDo:"Log in", taigaCancel:"Cancel", taigaProjectTitle:"Create Taiga ticket", taigaProject:"Project", taigaTasksPick:"Create sub-packages as tasks:", taigaCreateDo:"Create", taigaError:"Failed: {error}", taigaOpenBtn:"Open {ref} in Taiga", taigaTicketDiff:"differs from the plan:", taigaTicketNoBox:"no status box", taigaTicketPush:"write to Taiga", taigaTicketPull:"take from Taiga", taigaTicketWriting:"writing …", taigaTicketNoMap:"This state has no counterpart in Taiga — nothing is written.", taigaTicketNoColumn:"There is no column “{name}” in this project.", taigaTicketFetch:"Fetch state", taigaTicketReload:"Fetch again", taigaTicketLoading:"Fetching state …", taigaTicketAssignee:"Assigned to: {name}", liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/<uuid>)?", liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.", liveConflictText:"Someone changed the same lines. Whose version should win?", @@ -3165,7 +3277,7 @@ const I18N = { acHint:"{n} sugerencias de ID – ↑/↓ elige, Intro inserta", tipClose:"Cerrar", tipOpenLink:"Abrir enlace", - taigaStoryBtn:"Taiga: crear historia", taigaTasksBtn:"Taiga: crear historia + tareas", taigaLoginTitle:"Iniciar sesión en Taiga", taigaUser:"Usuario", taigaPass:"Contraseña", taigaLoginDo:"Iniciar sesión", taigaCancel:"Cancelar", taigaProjectTitle:"Crear ticket de Taiga", taigaProject:"Proyecto", taigaTasksPick:"Crear subpaquetes como tareas:", taigaCreateDo:"Crear", taigaError:"Error: {error}", taigaOpenBtn:"Abrir {ref} en Taiga", taigaTicketFetch:"Consultar estado", taigaTicketReload:"Volver a consultar", taigaTicketLoading:"Consultando estado …", taigaTicketAssignee:"Asignado a: {name}", + taigaStoryBtn:"Taiga: crear historia", taigaTasksBtn:"Taiga: crear historia + tareas", taigaLoginTitle:"Iniciar sesión en Taiga", taigaUser:"Usuario", taigaPass:"Contraseña", taigaLoginDo:"Iniciar sesión", taigaCancel:"Cancelar", taigaProjectTitle:"Crear ticket de Taiga", taigaProject:"Proyecto", taigaTasksPick:"Crear subpaquetes como tareas:", taigaCreateDo:"Crear", taigaError:"Error: {error}", taigaOpenBtn:"Abrir {ref} en Taiga", taigaTicketDiff:"difiere del plan:", taigaTicketNoBox:"sin casilla de estado", taigaTicketPush:"escribir en Taiga", taigaTicketPull:"tomar de Taiga", taigaTicketWriting:"escribiendo …", taigaTicketNoMap:"Este estado no tiene equivalente en Taiga: no se escribe nada.", taigaTicketNoColumn:"En este proyecto no existe la columna «{name}».", taigaTicketFetch:"Consultar estado", taigaTicketReload:"Volver a consultar", taigaTicketLoading:"Consultando estado …", taigaTicketAssignee:"Asignado a: {name}", liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/<uuid>)?", liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.", liveConflictText:"Alguien cambió las mismas líneas. ¿Qué versión debe prevalecer?", @@ -3301,7 +3413,7 @@ const I18N = { acHint:"{n} suggestions d'ID – ↑/↓ pour choisir, Entrée pour insérer", tipClose:"Fermer", tipOpenLink:"Ouvrir le lien", - taigaStoryBtn:"Taiga : créer la story", taigaTasksBtn:"Taiga : créer story + tâches", taigaLoginTitle:"Se connecter à Taiga", taigaUser:"Nom d'utilisateur", taigaPass:"Mot de passe", taigaLoginDo:"Se connecter", taigaCancel:"Annuler", taigaProjectTitle:"Créer un ticket Taiga", taigaProject:"Projet", taigaTasksPick:"Créer les sous-lots comme tâches :", taigaCreateDo:"Créer", taigaError:"Échec : {error}", taigaOpenBtn:"Ouvrir {ref} dans Taiga", taigaTicketFetch:"Relever l'état", taigaTicketReload:"Relever à nouveau", taigaTicketLoading:"Relevé de l'état …", taigaTicketAssignee:"Assigné à : {name}", + taigaStoryBtn:"Taiga : créer la story", taigaTasksBtn:"Taiga : créer story + tâches", taigaLoginTitle:"Se connecter à Taiga", taigaUser:"Nom d'utilisateur", taigaPass:"Mot de passe", taigaLoginDo:"Se connecter", taigaCancel:"Annuler", taigaProjectTitle:"Créer un ticket Taiga", taigaProject:"Projet", taigaTasksPick:"Créer les sous-lots comme tâches :", taigaCreateDo:"Créer", taigaError:"Échec : {error}", taigaOpenBtn:"Ouvrir {ref} dans Taiga", taigaTicketDiff:"diffère du plan :", taigaTicketNoBox:"sans case d'état", taigaTicketPush:"écrire dans Taiga", taigaTicketPull:"reprendre de Taiga", taigaTicketWriting:"écriture …", taigaTicketNoMap:"Cet état n'a pas d'équivalent dans Taiga — rien n'est écrit.", taigaTicketNoColumn:"La colonne « {name} » n'existe pas dans ce projet.", taigaTicketFetch:"Relever l'état", taigaTicketReload:"Relever à nouveau", taigaTicketLoading:"Relevé de l'état …", taigaTicketAssignee:"Assigné à : {name}", liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/<uuid>) ?", liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.", liveConflictText:"Quelqu'un a modifié les mêmes lignes. Quelle version doit l'emporter ?", @@ -3437,7 +3549,7 @@ const I18N = { acHint:"{n} podpowiedzi ID – ↑/↓ wybiera, Enter wstawia", tipClose:"Zamknij", tipOpenLink:"Otwórz link", - taigaStoryBtn:"Taiga: utwórz historyjkę", taigaTasksBtn:"Taiga: historyjka + zadania", taigaLoginTitle:"Zaloguj się do Taigi", taigaUser:"Nazwa użytkownika", taigaPass:"Hasło", taigaLoginDo:"Zaloguj", taigaCancel:"Anuluj", taigaProjectTitle:"Utwórz zgłoszenie w Taidze", taigaProject:"Projekt", taigaTasksPick:"Utwórz podpakiety jako zadania:", taigaCreateDo:"Utwórz", taigaError:"Niepowodzenie: {error}", taigaOpenBtn:"Otwórz {ref} w Taidze", taigaTicketFetch:"Pobierz stan", taigaTicketReload:"Pobierz ponownie", taigaTicketLoading:"Pobieranie stanu …", taigaTicketAssignee:"Przypisane do: {name}", + taigaStoryBtn:"Taiga: utwórz historyjkę", taigaTasksBtn:"Taiga: historyjka + zadania", taigaLoginTitle:"Zaloguj się do Taigi", taigaUser:"Nazwa użytkownika", taigaPass:"Hasło", taigaLoginDo:"Zaloguj", taigaCancel:"Anuluj", taigaProjectTitle:"Utwórz zgłoszenie w Taidze", taigaProject:"Projekt", taigaTasksPick:"Utwórz podpakiety jako zadania:", taigaCreateDo:"Utwórz", taigaError:"Niepowodzenie: {error}", taigaOpenBtn:"Otwórz {ref} w Taidze", taigaTicketDiff:"różni się od planu:", taigaTicketNoBox:"bez pola statusu", taigaTicketPush:"zapisz w Taidze", taigaTicketPull:"pobierz z Taigi", taigaTicketWriting:"zapisywanie …", taigaTicketNoMap:"Ten stan nie ma odpowiednika w Taidze — nic nie zostanie zapisane.", taigaTicketNoColumn:"W tym projekcie nie ma kolumny „{name}”.", taigaTicketFetch:"Pobierz stan", taigaTicketReload:"Pobierz ponownie", taigaTicketLoading:"Pobieranie stanu …", taigaTicketAssignee:"Przypisane do: {name}", liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/<uuid>)?", liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.", liveConflictText:"Ktoś zmienił te same wiersze. Która wersja ma obowiązywać?", @@ -3573,7 +3685,7 @@ const I18N = { acHint:"{n} подсказок ID – ↑/↓ выбирает, Enter вставляет", tipClose:"Закрыть", tipOpenLink:"Открыть ссылку", - taigaStoryBtn:"Taiga: создать историю", taigaTasksBtn:"Taiga: история + задачи", taigaLoginTitle:"Вход в Taiga", taigaUser:"Имя пользователя", taigaPass:"Пароль", taigaLoginDo:"Войти", taigaCancel:"Отмена", taigaProjectTitle:"Создать тикет в Taiga", taigaProject:"Проект", taigaTasksPick:"Создать подпакеты как задачи:", taigaCreateDo:"Создать", taigaError:"Не удалось: {error}", taigaOpenBtn:"Открыть {ref} в Taiga", taigaTicketFetch:"Получить статус", taigaTicketReload:"Обновить статус", taigaTicketLoading:"Получение статуса …", taigaTicketAssignee:"Назначено: {name}", + taigaStoryBtn:"Taiga: создать историю", taigaTasksBtn:"Taiga: история + задачи", taigaLoginTitle:"Вход в Taiga", taigaUser:"Имя пользователя", taigaPass:"Пароль", taigaLoginDo:"Войти", taigaCancel:"Отмена", taigaProjectTitle:"Создать тикет в Taiga", taigaProject:"Проект", taigaTasksPick:"Создать подпакеты как задачи:", taigaCreateDo:"Создать", taigaError:"Не удалось: {error}", taigaOpenBtn:"Открыть {ref} в Taiga", taigaTicketDiff:"расходится с планом:", taigaTicketNoBox:"без статуса", taigaTicketPush:"записать в Taiga", taigaTicketPull:"взять из Taiga", taigaTicketWriting:"запись …", taigaTicketNoMap:"У этого состояния нет соответствия в Taiga — ничего не записано.", taigaTicketNoColumn:"В этом проекте нет колонки «{name}».", taigaTicketFetch:"Получить статус", taigaTicketReload:"Обновить статус", taigaTicketLoading:"Получение статуса …", taigaTicketAssignee:"Назначено: {name}", liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/<uuid>)?", liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.", liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?", @@ -3709,7 +3821,7 @@ const I18N = { acHint:"{n} आईडी सुझाव – ↑/↓ से चुनें, Enter से डालें", tipClose:"बंद करें", tipOpenLink:"लिंक खोलें", - taigaStoryBtn:"Taiga: स्टोरी बनाएँ", taigaTasksBtn:"Taiga: स्टोरी + टास्क बनाएँ", taigaLoginTitle:"Taiga में साइन इन करें", taigaUser:"उपयोगकर्ता नाम", taigaPass:"पासवर्ड", taigaLoginDo:"साइन इन", taigaCancel:"रद्द करें", taigaProjectTitle:"Taiga टिकट बनाएँ", taigaProject:"प्रोजेक्ट", taigaTasksPick:"उप-पैकेज टास्क के रूप में बनाएँ:", taigaCreateDo:"बनाएँ", taigaError:"विफल: {error}", taigaOpenBtn:"Taiga में {ref} खोलें", taigaTicketFetch:"स्थिति लाएँ", taigaTicketReload:"फिर से लाएँ", taigaTicketLoading:"स्थिति लाई जा रही है …", taigaTicketAssignee:"ज़िम्मेदार: {name}", + taigaStoryBtn:"Taiga: स्टोरी बनाएँ", taigaTasksBtn:"Taiga: स्टोरी + टास्क बनाएँ", taigaLoginTitle:"Taiga में साइन इन करें", taigaUser:"उपयोगकर्ता नाम", taigaPass:"पासवर्ड", taigaLoginDo:"साइन इन", taigaCancel:"रद्द करें", taigaProjectTitle:"Taiga टिकट बनाएँ", taigaProject:"प्रोजेक्ट", taigaTasksPick:"उप-पैकेज टास्क के रूप में बनाएँ:", taigaCreateDo:"बनाएँ", taigaError:"विफल: {error}", taigaOpenBtn:"Taiga में {ref} खोलें", taigaTicketDiff:"योजना से भिन्न:", taigaTicketNoBox:"स्थिति-बॉक्स नहीं", taigaTicketPush:"Taiga में लिखें", taigaTicketPull:"Taiga से लें", taigaTicketWriting:"लिखा जा रहा है …", taigaTicketNoMap:"इस स्थिति का Taiga में कोई समकक्ष नहीं है — कुछ नहीं लिखा जाता।", taigaTicketNoColumn:"इस प्रोजेक्ट में “{name}” कॉलम नहीं है।", taigaTicketFetch:"स्थिति लाएँ", taigaTicketReload:"फिर से लाएँ", taigaTicketLoading:"स्थिति लाई जा रही है …", taigaTicketAssignee:"ज़िम्मेदार: {name}", liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/<uuid>)?", liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।", liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?", @@ -3856,7 +3968,7 @@ const I18N = { acHint:"{n} 个 ID 建议 – ↑/↓ 选择,Enter 插入", tipClose:"关闭", tipOpenLink:"打开链接", - taigaStoryBtn:"Taiga:创建故事", taigaTasksBtn:"Taiga:创建故事和任务", taigaLoginTitle:"登录 Taiga", taigaUser:"用户名", taigaPass:"密码", taigaLoginDo:"登录", taigaCancel:"取消", taigaProjectTitle:"创建 Taiga 工单", taigaProject:"项目", taigaTasksPick:"将子包创建为任务:", taigaCreateDo:"创建", taigaError:"失败:{error}", taigaOpenBtn:"在 Taiga 中打开 {ref}", taigaTicketFetch:"获取状态", taigaTicketReload:"重新获取", taigaTicketLoading:"正在获取状态 …", taigaTicketAssignee:"负责人:{name}", + taigaStoryBtn:"Taiga:创建故事", taigaTasksBtn:"Taiga:创建故事和任务", taigaLoginTitle:"登录 Taiga", taigaUser:"用户名", taigaPass:"密码", taigaLoginDo:"登录", taigaCancel:"取消", taigaProjectTitle:"创建 Taiga 工单", taigaProject:"项目", taigaTasksPick:"将子包创建为任务:", taigaCreateDo:"创建", taigaError:"失败:{error}", taigaOpenBtn:"在 Taiga 中打开 {ref}", taigaTicketDiff:"与计划不一致:", taigaTicketNoBox:"没有状态框", taigaTicketPush:"写入 Taiga", taigaTicketPull:"从 Taiga 取用", taigaTicketWriting:"正在写入 …", taigaTicketNoMap:"该状态在 Taiga 中没有对应项 — 不会写入任何内容。", taigaTicketNoColumn:"本项目中没有「{name}」这一列。", taigaTicketFetch:"获取状态", taigaTicketReload:"重新获取", taigaTicketLoading:"正在获取状态 …", taigaTicketAssignee:"负责人:{name}", liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/<uuid>)吗?", liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。", liveConflictText:"有人改动了同样的行。以谁的版本为准?", @@ -3992,7 +4104,7 @@ const I18N = { acHint:"ID候補 {n} 件 – ↑/↓で選択、Enterで挿入", tipClose:"閉じる", tipOpenLink:"リンクを開く", - taigaStoryBtn:"Taiga: ストーリーを作成", taigaTasksBtn:"Taiga: ストーリー+タスクを作成", taigaLoginTitle:"Taiga にログイン", taigaUser:"ユーザー名", taigaPass:"パスワード", taigaLoginDo:"ログイン", taigaCancel:"キャンセル", taigaProjectTitle:"Taiga チケットを作成", taigaProject:"プロジェクト", taigaTasksPick:"サブパッケージをタスクとして作成:", taigaCreateDo:"作成", taigaError:"失敗: {error}", taigaOpenBtn:"Taiga で {ref} を開く", taigaTicketFetch:"状態を取得", taigaTicketReload:"再取得", taigaTicketLoading:"状態を取得中 …", taigaTicketAssignee:"担当: {name}", + taigaStoryBtn:"Taiga: ストーリーを作成", taigaTasksBtn:"Taiga: ストーリー+タスクを作成", taigaLoginTitle:"Taiga にログイン", taigaUser:"ユーザー名", taigaPass:"パスワード", taigaLoginDo:"ログイン", taigaCancel:"キャンセル", taigaProjectTitle:"Taiga チケットを作成", taigaProject:"プロジェクト", taigaTasksPick:"サブパッケージをタスクとして作成:", taigaCreateDo:"作成", taigaError:"失敗: {error}", taigaOpenBtn:"Taiga で {ref} を開く", taigaTicketDiff:"計画と食い違い:", taigaTicketNoBox:"ステータス欄なし", taigaTicketPush:"Taiga に書く", taigaTicketPull:"Taiga から取る", taigaTicketWriting:"書き込み中 …", taigaTicketNoMap:"この状態に対応する列が Taiga にありません — 何も書き込みません。", taigaTicketNoColumn:"このプロジェクトに「{name}」列はありません。", taigaTicketFetch:"状態を取得", taigaTicketReload:"再取得", taigaTicketLoading:"状態を取得中 …", taigaTicketAssignee:"担当: {name}", liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/<uuid>)ですか。", liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。", liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。", diff --git a/frontend/src/parser.js b/frontend/src/parser.js index 9a813c8..b68c90f 100644 --- a/frontend/src/parser.js +++ b/frontend/src/parser.js @@ -43,6 +43,18 @@ export function setFoldMark(line, mark){ return m[1] + m[2] + (mark ? mark + ' ' : '') + line.slice(m[0].length); } +/* Setzt (`'~'`) oder entfernt (`null`) die Statusbox einer Zeile — dieselbe + Sorte Umkehrung wie `setFoldMark`, gebraucht fürs Übernehmen eines + Ticket-Status aus Taiga (D91-Nachtrag 7/8). Angefasst wird NUR die Box: + Einrückung, Zerlegungszeichen, Faltmarke (in beiden Stellungen) und Label + bleiben zeichengenau stehen. Eine Zeile ohne Zeichen (Wurzel, SPEC §2) + bekommt die Box an den Zeilenanfang. */ +export function setStatusBox(line, code){ + const m = line.match( + /^([ \t]*(?:[-|+]|=(?=[ \t]))?[ \t]*)((?:[><](?=[ \t])[ \t]*)?)(?:\[[^\]]\][ \t]*)?/); + return m[1] + m[2] + (code ? '[' + code + '] ' : '') + line.slice(m[0].length); +} + const RE_LINE = /^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:([><])(?=[ \t])\s*)?(?:\[([^\]])\]\s*)?(?:([><])(?=[ \t])\s*)?(.*)$/; const RE_ID_TOKEN = /(^|\s)#([\p{L}\p{N}._-]+)/u; /* Fortsetzungszeile (SPEC §1): Leerraum, dann `\` als letztes Zeichen. Der diff --git a/frontend/src/style.css b/frontend/src/style.css index 066db88..db92a2d 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1095,6 +1095,13 @@ .nodetip-ticket .tk-line{margin-top:4px;color:var(--muted)} .nodetip-ticket .tk-err{margin-top:2px;margin-bottom:6px;color:var(--warn)} .nodetip-ticket .tk-mini{margin-left:auto;padding:2px 7px;font-size:.8rem;line-height:1.1} + /* Abweichung zwischen Ticket und Statusbox (D91-Nachtrag 7): markiert in der + Warnfarbe, beide Richtungen als ausdrückliche Knöpfe darunter. */ + .nodetip-ticket .tk-diff{ + display:flex;align-items:center;gap:6px;flex-wrap:wrap; + margin-top:6px;color:var(--warn);font-weight:500; + } + .nodetip-ticket .tk-act{display:flex;flex-wrap:wrap;gap:6px;margin-top:7px} /* Die Spitze zeigt auf den Knoten — ohne sie wäre bei dicht stehenden Knoten nicht zu sehen, welcher gemeint ist. Ein gedrehtes Quadrat mit zwei Kanten: so erbt es Rahmen UND Schatten des Fensters. JS setzt --tipx (Spitze) und diff --git a/frontend/src/taiga.js b/frontend/src/taiga.js index 6a24b95..5797ed5 100644 --- a/frontend/src/taiga.js +++ b/frontend/src/taiga.js @@ -70,9 +70,48 @@ export const TAIGA_STATUS_CODE = { }; export function mapTaigaStatus(name){ if(typeof name !== 'string') return null; - const code = TAIGA_STATUS_CODE[name.trim().toLowerCase().replace(/\s+/g, ' ')]; + const code = TAIGA_STATUS_CODE[normName(name)]; return code ? STATUS_BY_CODE[code] : null; } +const normName = s => s.trim().toLowerCase().replace(/\s+/g, ' '); + +/* Die Gegenrichtung (D91-Nachtrag 7/8): Zu welcher Taiga-Spalte gehört eine + Statusbox? Nur die fünf abgebildeten Zustände haben eine — `[?]`, `[!]`, + `[-]` und der neutrale Knoten (code null) haben keine Entsprechung und + lassen das Ticket unangetastet; erfunden wird nichts. */ +export const TAIGA_STATUS_NAME = Object.fromEntries( + Object.entries(TAIGA_STATUS_CODE).map(([name, code]) => [code, name])); +export function taigaStatusName(code){ + const name = code ? TAIGA_STATUS_NAME[code] : null; + /* Zurück in die Schreibweise, in der Taiga die Spalten führt — gesucht wird + ohnehin normalisiert (`pickStatus`), aber gemeldet wird sie im Klartext. */ + return name ? name.replace(/^./, c => c.toUpperCase()) : null; +} + +/* Die Spalte des Projekts zu einem Namen — Taiga schreibt nach **Id**, und + die Namen sind je Projekt frei. Verglichen wird mit derselben + Normalisierung wie beim Lesen; findet sich nichts, wird nicht geschrieben + (der Aufrufer sagt, welche Spalte fehlte). */ +export function pickStatus(list, name){ + if(!Array.isArray(list) || typeof name !== 'string') return null; + const gesucht = normName(name); + return list.find(s => s && typeof s.name === 'string' && normName(s.name) === gesucht) || null; +} + +/* Die beiden Schreib-Pfade am Proxy: der Status des Tickets und die Spalten + des Projekts — je Typ ein eigener Endpunkt, wie beim Lesen. */ +export function statusApiPath(ref, slug){ + const p = ticketApiPath(ref, slug); + if(!p) return null; + const [pfad, query] = p.split('?'); + return pfad + '/status?' + query; +} +export function statusListPath(ref, slug){ + const p = refParts(ref); + if(!p || !slug) return null; + return '/' + (p.kind === 'US' ? 'userstory-statuses' : 'task-statuses') + + '?slug=' + encodeURIComponent(slug); +} /* Die Ticket-Referenz unter der Schreibmarke (Strg+Klick im Text, D91-Nachtrag 5): ein FREISTEHENDES `#US-123`/`#T-1234`-Token im Baumteil. diff --git a/frontend/tests/taiga.test.js b/frontend/tests/taiga.test.js index 1054c47..9ddfb84 100644 --- a/frontend/tests/taiga.test.js +++ b/frontend/tests/taiga.test.js @@ -1,7 +1,8 @@ import { describe, it, expect } from 'vitest'; import { parse } from '../src/parser.js'; import { taigaSlugs } from '../src/model.js'; -import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, refParts, ticketApiPath, mapTaigaStatus } from '../src/taiga.js'; +import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, refParts, ticketApiPath, mapTaigaStatus, taigaStatusName, pickStatus, statusApiPath, statusListPath } from '../src/taiga.js'; +import { setStatusBox } from '../src/parser.js'; /* Schlagworte `&tag` (SPEC §1, D91): Extraktion im Parser und die `taiga.*`-Vererbung in model.js — der erste Konsument der reservierten @@ -301,3 +302,78 @@ describe('mapTaigaStatus — Workflow auf die Statusbox (SPEC §4/§9)', () => { expect(mapTaigaStatus(undefined)).toBe(null); }); }); + +/* Status zurückschreiben (D91-Nachtrag 7/8): die Gegenrichtung der Abbildung, + die Spaltensuche im Projekt, die beiden Schreib-Pfade — und das Setzen der + Statusbox im Text für „aus Taiga übernehmen". */ + +describe('taigaStatusName / pickStatus — die Gegenrichtung', () => { + it('nennt zu den fünf Zuständen ihre Taiga-Spalte', () => { + expect(taigaStatusName(' ')).toBe('New'); + expect(taigaStatusName('~')).toBe('In progress'); + expect(taigaStatusName('/')).toBe('Ready for test'); + expect(taigaStatusName('x')).toBe('Done'); + expect(taigaStatusName('^')).toBe('Archived'); + }); + + it('die übrigen Zustände haben keine Entsprechung — es wird nichts geschrieben', () => { + expect(taigaStatusName('?')).toBe(null); /* Idee */ + expect(taigaStatusName('!')).toBe(null); /* High Risk */ + expect(taigaStatusName('-')).toBe(null); /* verworfen */ + expect(taigaStatusName(null)).toBe(null); /* neutraler Knoten */ + }); + + it('findet die Spalte des Projekts, Schreibweise egal', () => { + const spalten = [{id: 11, name: 'New'}, {id: 12, name: ' in PROGRESS '}]; + expect(pickStatus(spalten, 'In progress').id).toBe(12); + }); + + it('ohne passende Spalte gibt es keine — dann wird nicht geschrieben', () => { + expect(pickStatus([{id: 11, name: 'Backlog'}], 'Done')).toBe(null); + expect(pickStatus(null, 'Done')).toBe(null); + expect(pickStatus([{id: 1}], 'Done')).toBe(null); + }); + + it('baut die Schreib-Pfade je Typ', () => { + expect(statusApiPath('US-123', 'mi-kunde')).toBe('/userstories/123/status?slug=mi-kunde'); + expect(statusApiPath('T-9', 'mi-kunde')).toBe('/tasks/9/status?slug=mi-kunde'); + expect(statusListPath('US-1', 'mi-kunde')).toBe('/userstory-statuses?slug=mi-kunde'); + expect(statusListPath('T-1', 'a&b')).toBe('/task-statuses?slug=a%26b'); + expect(statusApiPath('ABC-1', 'mi-kunde')).toBe(null); + expect(statusListPath('US-1', null)).toBe(null); + }); +}); + +describe('setStatusBox — die Statusbox im Text setzen (SPEC §1)', () => { + it('ersetzt eine vorhandene Box', () => { + expect(setStatusBox(' - [ ] Backend (M) #US-1', '~')).toBe(' - [~] Backend (M) #US-1'); + }); + + it('setzt eine fehlende Box hinter das Zeichen', () => { + expect(setStatusBox(' - Backend', 'x')).toBe(' - [x] Backend'); + }); + + it('eine Wurzelzeile bekommt sie an den Zeilenanfang', () => { + expect(setStatusBox('Wurzel', '^')).toBe('[^] Wurzel'); + }); + + it('lässt die Faltmarke stehen — in beiden Stellungen (D34-Nachtrag 2)', () => { + expect(setStatusBox('- [ ] > Backend', 'x')).toBe('- [x] > Backend'); + expect(setStatusBox('- > [ ] Backend', 'x')).toBe('- > [x] Backend'); + }); + + it('null entfernt die Box', () => { + expect(setStatusBox(' - [x] Backend', null)).toBe(' - Backend'); + }); + + it('das Ergebnis parst mit dem gesetzten Status und unverändertem Rest', () => { + const zeile = setStatusBox(' - [ ] #auth: Backend (M) @anna #US-1', '/'); + const { roots } = parse('- Wurzel\n' + zeile); + const n = roots[0].children[0]; + expect(n.status.key).toBe('durchstich'); + expect(n.id).toBe('auth'); + expect(n.size).toBe('M'); + expect(n.tags).toEqual(['anna']); + expect(ticketRefOf(n)).toBe('US-1'); + }); +});