feat(taiga): Status zurückschreiben — zwei Knöpfe, niemand gewinnt von selbst (D91-Nachtrag 8, SPEC §9)

Weicht der Ticket-Status von der Statusbox ab, markiert das Knoten-Fenster
die Abweichung und bietet beide Richtungen ausdrücklich an.

- „nach Taiga schreiben": Spalte des Projekts suchen (Taiga schreibt nach Id,
  die Namen sind je Projekt frei) und mit der zuletzt GELESENEN `version`
  patchen — hat jemand dazwischen geändert, lehnt Taiga ab und der Text steht
  im Fenster, statt dass etwas überschrieben wird.
- „aus Taiga übernehmen": `setStatusBox()` schreibt die Box in die Textzeile,
  undo-fähig wie jede andere Änderung.
- Schreibbar sind nur die fünf abgebildeten Zustände; `[?]`, `[!]`, `[-]` und
  der neutrale Knoten lassen das Ticket unangetastet — mit Begründung im
  Fenster.
- Proxy: zwei Spaltenlisten (`/taiga/{userstory,task}-statuses?slug=`) und
  zwei Schreib-Endpunkte (`PATCH …/{ref}/status?slug=`); die Zielspalte wählt
  der Editor, das Backend parst die Notation nicht (D14).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-27 22:18:52 +02:00
co-authored by Claude Fable 5
parent 4e6f98bbee
commit 323e8fe0ba
16 changed files with 826 additions and 75 deletions
+7
View File
@@ -81,3 +81,10 @@ docs/SPEC.md §10 testen — niemals eine zweite, abweichende Grammatik pflegen.
deshalb den `slug` (aus `&taiga.<slug>`, SPEC §1) und fragen erst deshalb den `slug` (aus `&taiga.<slug>`, SPEC §1) und fragen erst
`/projects/by_slug`, dann `by_ref` — der Slug kommt vom Client und wird `/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. **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.
@@ -6,9 +6,12 @@ import de.werkbaum.generated.model.TaigaProject
import de.werkbaum.generated.model.TaigaSession import de.werkbaum.generated.model.TaigaSession
import de.werkbaum.generated.model.TaigaStoryCreateRequest import de.werkbaum.generated.model.TaigaStoryCreateRequest
import de.werkbaum.generated.model.TaigaTaskCreateRequest 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.TaigaTicket
import de.werkbaum.generated.model.TaigaTicketDetail import de.werkbaum.generated.model.TaigaTicketDetail
import de.werkbaum.integration.taiga.TaigaClient import de.werkbaum.integration.taiga.TaigaClient
import de.werkbaum.integration.taiga.TaigaTicketDetailData
import de.werkbaum.integration.taiga.TaigaTicketData import de.werkbaum.integration.taiga.TaigaTicketData
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
@@ -84,19 +87,60 @@ class TaigaController(private val client: TaigaClient) : TaigaApi {
ticket(xTaigaToken, slug, ref, task = true) ticket(xTaigaToken, slug, ref, task = true)
private fun ticket(token: String, slug: String, ref: Long, task: Boolean): private fun ticket(token: String, slug: String, ref: Long, task: Boolean):
ResponseEntity<TaigaTicketDetail> { ResponseEntity<TaigaTicketDetail> =
val d = client.ticket(token, slug, ref, task) ResponseEntity.ok(client.ticket(token, slug, ref, task).toApi())
return ResponseEntity.ok(
TaigaTicketDetail( /* Schreiben (D91-Nachtrag 7/8): angestoßen wird es im Knoten-Fenster, von
id = d.id, selbst geschieht nichts. Die Zielspalte kommt als Id herein — welche es
ref = d.ref, ist, entscheidet der Editor über die Statusbox-Abbildung (D14). */
subject = d.subject, override fun taigaStoryStatuses(xTaigaToken: String, slug: String) =
status = d.status, statuses(xTaigaToken, slug, task = false)
statusClosed = d.statusClosed,
assignee = d.assignee, override fun taigaTaskStatuses(xTaigaToken: String, slug: String) =
) statuses(xTaigaToken, slug, task = true)
)
private fun statuses(token: String, slug: String, task: Boolean):
ResponseEntity<List<TaigaStatus>> =
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<TaigaTicketDetail> =
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<TaigaTicket> = private fun created(ticket: TaigaTicketData): ResponseEntity<TaigaTicket> =
ResponseEntity.status(HttpStatus.CREATED).body( ResponseEntity.status(HttpStatus.CREATED).body(
@@ -51,8 +51,13 @@ data class TaigaTicketDetailData(
val status: String?, val status: String?,
val statusClosed: Boolean?, val statusClosed: Boolean?,
val assignee: String?, 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 * Schmaler, benannter Client zur konfigurierten Taiga-Instanz (D91) — kein
* Durchreich-Proxy: genau die vier Aufrufe, die die Ticket-Anlage braucht. * Durchreich-Proxy: genau die vier Aufrufe, die die Ticket-Anlage braucht.
@@ -127,15 +132,64 @@ class TaigaClient(private val properties: TaigaProperties) {
.header("Authorization", "Bearer $token") .header("Authorization", "Bearer $token")
.retrieve().body(MAP) .retrieve().body(MAP)
} ?: throw TaigaUnavailableException("Leere Antwort von Taiga ($pfad)") } ?: throw TaigaUnavailableException("Leere Antwort von Taiga ($pfad)")
return TaigaTicketDetailData( 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<TaigaStatusData> {
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<String, Any?>) = TaigaTicketDetailData(
id = num(map, "id"), id = num(map, "id"),
ref = num(map, "ref"), ref = num(map, "ref"),
subject = str(map, "subject"), subject = str(map, "subject"),
status = extra(map, "status_extra_info")?.get("name") as? String, status = extra(map, "status_extra_info")?.get("name") as? String,
statusClosed = extra(map, "status_extra_info")?.get("is_closed") as? Boolean, statusClosed = extra(map, "status_extra_info")?.get("is_closed") as? Boolean,
assignee = extra(map, "assigned_to_extra_info")?.get("full_name_display") as? String, 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 * Projekt-Slug -> Id; Taigas `by_ref` filtert über die Id. Der Slug kommt
+203
View File
@@ -606,6 +606,173 @@ paths:
"503": "503":
$ref: "#/components/responses/TaigaNotConfigured" $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 <api-url>/userstory-statuses?project=<id>`. 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: /info:
get: get:
tags: [Documents] tags: [Documents]
@@ -1113,6 +1280,42 @@ components:
Anzeigename des Zustaendigen Anzeigename des Zustaendigen
(`assigned_to_extra_info.full_name_display`); fehlt, wenn niemand (`assigned_to_extra_info.full_name_display`); fehlt, wenn niemand
zugewiesen ist. 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: ProblemDetail:
type: object type: object
@@ -143,6 +143,31 @@ class TaigaApiTest {
result.responseBody!! shouldContain "\"assignee\":null" 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 { companion object {
private lateinit var stub: HttpServer private lateinit var stub: HttpServer
@Volatile private var stubStatus = 200 @Volatile private var stubStatus = 200
@@ -160,6 +185,9 @@ class TaigaApiTest {
"/api/v1/projects/by_slug" -> TaigaClientTestData.PROJECT_OK "/api/v1/projects/by_slug" -> TaigaClientTestData.PROJECT_OK
"/api/v1/userstories/by_ref" -> TaigaClientTestData.STORY_DETAIL "/api/v1/userstories/by_ref" -> TaigaClientTestData.STORY_DETAIL
"/api/v1/tasks/by_ref" -> TaigaClientTestData.TASK_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 -> "{}" else -> "{}"
} }
val status = if (stubBody != null) stubStatus val status = if (stubBody != null) stubStatus
@@ -209,9 +237,16 @@ object TaigaClientTestData {
const val PROJECT_OK = const val PROJECT_OK =
"""{"id": 7, "name": "Kunde", "slug": "mi-kunde"}""" """{"id": 7, "name": "Kunde", "slug": "mi-kunde"}"""
const val STORY_DETAIL = 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}, "status_extra_info": {"name": "In progress", "is_closed": false},
"assigned_to_extra_info": {"full_name_display": "Anna Beispiel"}}""" "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 = const val TASK_DETAIL =
"""{"id": 5678, "ref": 1234, "subject": "API-Teil", "project": 7, """{"id": 5678, "ref": 1234, "subject": "API-Teil", "project": 7,
"status_extra_info": {"name": "Done", "is_closed": true}}""" "status_extra_info": {"name": "Done", "is_closed": true}}"""
@@ -162,6 +162,64 @@ class TaigaClientTest {
requests[0].query shouldBe "slug=a%26member%3D1" 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<TaigaUpstreamException> {
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 @Test
fun `ohne konfigurierte Instanz gibt es kein Ziel`() { fun `ohne konfigurierte Instanz gibt es kein Ziel`() {
val bare = TaigaClient(TaigaProperties(apiUrl = "")) val bare = TaigaClient(TaigaProperties(apiUrl = ""))
@@ -183,6 +241,7 @@ class TaigaClientTest {
data class Recorded( data class Recorded(
val path: String, val path: String,
val method: String,
val query: String?, val query: String?,
val auth: String?, val auth: String?,
val noPagination: String?, val noPagination: String?,
@@ -220,7 +279,7 @@ class TaigaClientTest {
/* Form der by_ref-Antwort: die Namen stehen in den /* Form der by_ref-Antwort: die Namen stehen in den
`*_extra_info`-Blöcken, die Ids daneben (Taiga-API). */ `*_extra_info`-Blöcken, die Ids daneben (Taiga-API). */
const val STORY_DETAIL = 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}, "status_extra_info": {"name": "In progress", "color": "#ff9900", "is_closed": false},
"assigned_to": 42, "assigned_to": 42,
"assigned_to_extra_info": {"username": "anna", "full_name_display": "Anna Beispiel"}}""" "assigned_to_extra_info": {"username": "anna", "full_name_display": "Anna Beispiel"}}"""
@@ -230,6 +289,15 @@ class TaigaClientTest {
"assigned_to_extra_info": null}""" "assigned_to_extra_info": null}"""
const val STORY_BARE = const val STORY_BARE =
"""{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7, "status": 3}""" """{"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 = const val NOT_FOUND =
"""{"_error_message": "Not found.", "_error_type": "taiga.base.exceptions.NotFound"}""" """{"_error_message": "Not found.", "_error_type": "taiga.base.exceptions.NotFound"}"""
@@ -240,6 +308,7 @@ class TaigaClientTest {
server.createContext("/") { ex -> server.createContext("/") { ex ->
recorded = Recorded( recorded = Recorded(
path = ex.requestURI.path, path = ex.requestURI.path,
method = ex.requestMethod,
query = ex.requestURI.query, query = ex.requestURI.query,
auth = ex.requestHeaders.getFirst("Authorization"), auth = ex.requestHeaders.getFirst("Authorization"),
noPagination = ex.requestHeaders.getFirst("x-disable-pagination"), noPagination = ex.requestHeaders.getFirst("x-disable-pagination"),
+4
View File
@@ -19,6 +19,10 @@ reverse.
## 2026-08-27 ## 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 - 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 - 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 - Reading a ticket never changes the plan: no status is written back, and the text stays untouched
+70
View File
@@ -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): Und ein `PATCH` verlangt die `version` des Tickets (optimistisches Sperren):
Hat jemand dazwischen geändert, meldet sich der Konflikt, statt ihn zu Hat jemand dazwischen geändert, meldet sich der Konflikt, statt ihn zu
überschreiben — dieselbe Haltung wie beim Live-Editing (D76). ü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.
+27 -27
View File
@@ -500,9 +500,25 @@ es einen gibt, den **Zuständigen**.
„In progress“ `[~]`, „Ready for test“ `[/]`, „Done“ `[x]`, „Archived“ `[^]`; „In progress“ `[~]`, „Ready for test“ `[/]`, „Done“ `[x]`, „Archived“ `[^]`;
Groß-/Kleinschreibung und Leerraum sind egal. Ein Name außerhalb dieser Groß-/Kleinschreibung und Leerraum sind egal. Ein Name außerhalb dieser
Liste bleibt **unabgebildet** und steht nur als Text — geraten wird nicht. Liste bleibt **unabgebildet** und steht nur als Text — geraten wird nicht.
- **Gelesen, nie geschrieben.** Der Notationstext bleibt unangetastet; die - **Von selbst geschieht nichts.** Weder wird der Notationstext angefasst
Statusbox des Knotens ändert sich nicht, und die Abbildung sagt nichts über noch das Ticket: Die Abbildung ist eine Anzeige, keine Aussage über
Fortschritt (§4) oder Kosten (§5). Das Zurückschreiben ist reserviert (§11). 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 - Geholt wird erst, wenn das Fenster **kurz stehen bleibt** (nicht im
Vorüberfahren), und je Ticket **einmal je Sitzung** — ein ↻-Knopf im Fenster Vorüberfahren), und je Ticket **einmal je Sitzung** — ein ↻-Knopf im Fenster
holt neu. Ohne Anmeldung an der Instanz, ohne Projekt-Zuordnung oder ohne 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 - Reine Bedienhilfe wie das Fenster selbst: nicht im Grafikexport, nicht im
Druck. Druck.
Siehe D91-Nachtrag 6. Siehe D91-Nachträge 6, 7 und 8.
**Die Knotenfarbe zeigt den effektiven Status (§4)**, nicht den intrinsischen — **Die Knotenfarbe zeigt den effektiven Status (§4)**, nicht den intrinsischen —
das Diagramm beantwortet „wie weit ist das wirklich?“. Wo der eigene Status das Diagramm beantwortet „wie weit ist das wirklich?“. Wo der eigene Status
@@ -1385,33 +1401,17 @@ Taiga-Projekte, benennt das Schlagwort `&taiga.<slug>` (unten) das Projekt
je Teilbaum — eine Ref wird gegen das Projekt des nächsten Vorfahren mit so 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. einem Tag aufgelöst. Siehe D91-Nachträge 2 und 3.
Der **Stand** eines so bezeichneten Tickets (Betreff, Status, Zuständiger) Der **Stand** eines so bezeichneten Tickets (Betreff, Status, Zuständiger)
wird im Knoten-Fenster gezeigt (§9) — gelesen, nie geschrieben; das wird im Knoten-Fenster gezeigt (§9); weicht er von der Statusbox ab, sind
**Zurückschreiben** des Status bleibt reserviert (unten). dort beide Richtungen als ausdrückliche Aktionen zu haben — von selbst
geschieht nichts.
Freie Schlagworte liegen **nicht** mehr auf `#` — siehe `&tag` unten; damit Freie Schlagworte liegen **nicht** mehr auf `#` — siehe `&tag` unten; damit
ist die frühere Dreifach-Rolle von `#` aufgelöst (D34). 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 **Umgesetzt** — Anzeige und Regeln in §9 (Abweichung im Knoten-Fenster, zwei
entschieden, aber ungebaut; die Regeln stehen hier, bevor sie gebaut werden. ausdrückliche Aktionen, nur die abgebildeten Zustände). Entscheidung und
verworfene Alternativen: D91-Nachträge 7 und 8.
- **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.
### Schlagworte (`&tag`) ### Schlagworte (`&tag`)
+4 -3
View File
@@ -204,7 +204,7 @@
- [/] #trk.resolve: Resolve "#US-123" over the REST API (M) - [/] #trk.resolve: Resolve "#US-123" over the REST API (M)
- [^] #trk.resolve.read: Read title, link and status (S) - [^] #trk.resolve.read: Read title, link and status (S)
- [/] #trk.resolve.map: Map the workflow onto the states (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: Create tickets from nodes (XL)
- [^] #trk.create.proxy: Backend proxy with named endpoints (M) - [^] #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 - [^] #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. shown as plain text; making the mapping configurable is still open.
#trk.write #trk.write
The other direction: change a status in the plan and the ticket follows. The other direction. Neither side wins by itself: a difference between the
Only worth doing once reading it is trustworthy. ticket and the status box is marked, and both directions are offered as
explicit buttons — writing only for the five mapped states.
#trk.create #trk.create
One click in the node window turns a node into a Taiga ticket. The cut One click in the node window turns a node into a Taiga ticket. The cut
+18
View File
@@ -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 läuft schon beim Aufbau — sonst temporale Todeszone). Der
`pointerdown`-Wächter lässt `.tabmodal-overlay` durch: Der Anmelde-Dialog `pointerdown`-Wächter lässt `.tabmodal-overlay` durch: Der Anmelde-Dialog
gehört zu einer Aktion AUS dem Fenster und darf es nicht zumachen. 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 - **Nie `src.value = …` während des Bearbeitens (D53).** Es löscht die
Undo-Historie des Textfelds **komplett** — nicht nur den eigenen Schritt, Undo-Historie des Textfelds **komplett** — nicht nur den eigenen Schritt,
sondern alles davor Getippte. Gemessen: nach so einem Schreiben ändert das sondern alles davor Getippte. Gemessen: nach so einem Schreiben ändert das
+133 -21
View File
@@ -1,7 +1,7 @@
import './style.css'; 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 { 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 { esc, renderTreeHtml, TIP_RULE } from './render.js';
import { formatWarning, warningText } from './warnings.js'; import { formatWarning, warningText } from './warnings.js';
import * as live from './live.js'; import * as live from './live.js';
@@ -1822,7 +1822,7 @@ function appendTaigaActions(el){
entfällt nur dessen Hälfte. */ entfällt nur dessen Hälfte. */
const slug = taigaSlugs(roots).get(node); const slug = taigaSlugs(roots).get(node);
const url = ticketUrl(taigaWeb, slug, ref); 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 /* Als Knopf im Fenster ist der Link auch auf Touch erreichbar (dort gibt
es kein Strg) und macht die Strg+Klick-Geste nebenbei auffindbar es kein Strg) und macht die Strg+Klick-Geste nebenbei auffindbar
(D25-Lehre). */ (D25-Lehre). */
@@ -1861,12 +1861,12 @@ function appendTaigaActions(el){
const taigaTickets = new Map(); /* '<slug>/<ref>' -> {kind:'load'|'ok'|'err', data, msg} */ const taigaTickets = new Map(); /* '<slug>/<ref>' -> {kind:'load'|'ok'|'err', data, msg} */
const TICKET_DELAY = 400; /* `tipTicket`/`ticketTimer`: oben bei `tipNode` */ 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'); const box = document.createElement('div');
box.className = 'nodetip-ticket'; box.className = 'nodetip-ticket';
const key = slug + '/' + ref; const key = slug + '/' + ref;
tipTicket = {key, slug, ref, box}; tipTicket = {key, slug, ref, line, box};
paintTicket(box, key, slug, ref); paintTicket(box, key, slug, ref, line);
clearTimeout(ticketTimer); clearTimeout(ticketTimer);
if(!taigaTickets.has(key) && taigaSession()){ if(!taigaTickets.has(key) && taigaSession()){
ticketTimer = setTimeout(() => { ticketTimer = setTimeout(() => {
@@ -1876,7 +1876,7 @@ function ticketBox(el, slug, ref){
return box; return box;
} }
function paintTicket(box, key, slug, ref){ function paintTicket(box, key, slug, ref, lineNo){
const st = taigaTickets.get(key) || {kind: 'idle'}; const st = taigaTickets.get(key) || {kind: 'idle'};
box.textContent = ''; box.textContent = '';
const line = (cls, text) => { const line = (cls, text) => {
@@ -1887,6 +1887,7 @@ function paintTicket(box, key, slug, ref){
return d; return d;
}; };
if(st.kind === 'load'){ line('tk-line', t('taigaTicketLoading')); return; } if(st.kind === 'load'){ line('tk-line', t('taigaTicketLoading')); return; }
if(st.kind === 'write'){ line('tk-line', t('taigaTicketWriting')); return; }
if(st.kind === 'ok'){ if(st.kind === 'ok'){
const head = document.createElement('div'); const head = document.createElement('div');
head.className = 'tk-head'; head.className = 'tk-head';
@@ -1913,12 +1914,117 @@ function paintTicket(box, key, slug, ref){
box.appendChild(head); box.appendChild(head);
if(st.data.subject) line('tk-line', st.data.subject); if(st.data.subject) line('tk-line', st.data.subject);
if(st.data.assignee) line('tk-line', t('taigaTicketAssignee', {name: st.data.assignee})); if(st.data.assignee) line('tk-line', t('taigaTicketAssignee', {name: st.data.assignee}));
paintDiff(box, line, st, key, slug, ref, lineNo);
return; return;
} }
if(st.kind === 'err') line('tk-err', st.msg); if(st.kind === 'err') line('tk-err', st.msg);
box.appendChild(reloadBtn(key, slug, ref, st.kind === 'err')); 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`): /* 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. */ Ein Klick ist die ausdrückliche Absicht, das Überfahren eines Knotens nicht. */
function reloadBtn(key, slug, ref, kurz){ function reloadBtn(key, slug, ref, kurz){
@@ -1953,7 +2059,7 @@ async function loadTicket(key, slug, ref, interactive){
Fenster wächst dabei, also neu setzen. */ Fenster wächst dabei, also neu setzen. */
function repaintTicket(key){ function repaintTicket(key){
if(!tipTicket || tipTicket.key !== key || !tipTicket.box.isConnected) return; 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); if(tipNode) placeNodeTip(tipNode);
} }
@@ -2066,12 +2172,18 @@ async function taigaProjects(session){
Schreibmarke; ein nacktes execCommand griffe ohne Fokus nicht und fiele Schreibmarke; ein nacktes execCommand griffe ohne Fokus nicht und fiele
auf den Historien-Killer `src.value =` zurück, D53). */ auf den Historien-Killer `src.value =` zurück, D53). */
function writeLineTokens(lineNo, tokens){ 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'); const zeilen = src.value.split('\n');
if(lineNo < 1 || lineNo > zeilen.length) return; if(lineNo < 1 || lineNo > zeilen.length) return;
let zeile = zeilen[lineNo - 1]; const neu = mapper(zeilen[lineNo - 1]);
for(const tok of tokens) zeile = appendToken(zeile, tok); if(neu === zeilen[lineNo - 1]) return;
if(zeile === zeilen[lineNo - 1]) return; zeilen[lineNo - 1] = neu;
zeilen[lineNo - 1] = zeile;
withEditorWritable(() => replaceTextUndoable(zeilen.join('\n'))); withEditorWritable(() => replaceTextUndoable(zeilen.join('\n')));
} }
@@ -2892,7 +3004,7 @@ const I18N = {
acHint:"{n} ID-Vorschläge ↑/↓ wählt, Enter übernimmt", acHint:"{n} ID-Vorschläge ↑/↓ wählt, Enter übernimmt",
tipClose:"Schließen", tipClose:"Schließen",
tipOpenLink:"Link öffnen", 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/&lt;uuid&gt;)?", liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.", 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?", 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", acHint:"{n} id suggestions ↑/↓ to choose, Enter to insert",
tipClose:"Close", tipClose:"Close",
tipOpenLink:"Open link", 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/&lt;uuid&gt;)?", liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.", liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.",
liveConflictText:"Someone changed the same lines. Whose version should win?", liveConflictText:"Someone changed the same lines. Whose version should win?",
@@ -3165,7 +3277,7 @@ const I18N = {
acHint:"{n} sugerencias de ID ↑/↓ elige, Intro inserta", acHint:"{n} sugerencias de ID ↑/↓ elige, Intro inserta",
tipClose:"Cerrar", tipClose:"Cerrar",
tipOpenLink:"Abrir enlace", 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/&lt;uuid&gt;)?", liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.", 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?", 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", acHint:"{n} suggestions d'ID ↑/↓ pour choisir, Entrée pour insérer",
tipClose:"Fermer", tipClose:"Fermer",
tipOpenLink:"Ouvrir le lien", 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/&lt;uuid&gt;) ?", liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/&lt;uuid&gt;) ?",
liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.", 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 ?", 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", acHint:"{n} podpowiedzi ID ↑/↓ wybiera, Enter wstawia",
tipClose:"Zamknij", tipClose:"Zamknij",
tipOpenLink:"Otwórz link", 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/&lt;uuid&gt;)?", liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.", 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ć?", liveConflictText:"Ktoś zmienił te same wiersze. Która wersja ma obowiązywać?",
@@ -3573,7 +3685,7 @@ const I18N = {
acHint:"{n} подсказок ID – ↑/↓ выбирает, Enter вставляет", acHint:"{n} подсказок ID – ↑/↓ выбирает, Enter вставляет",
tipClose:"Закрыть", tipClose:"Закрыть",
tipOpenLink:"Открыть ссылку", 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/&lt;uuid&gt;)?", liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.", liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.",
liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?", liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?",
@@ -3709,7 +3821,7 @@ const I18N = {
acHint:"{n} आईडी सुझाव – ↑/↓ से चुनें, Enter से डालें", acHint:"{n} आईडी सुझाव – ↑/↓ से चुनें, Enter से डालें",
tipClose:"बंद करें", tipClose:"बंद करें",
tipOpenLink:"लिंक खोलें", 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/&lt;uuid&gt;)?", liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।", liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।",
liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?", liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?",
@@ -3856,7 +3968,7 @@ const I18N = {
acHint:"{n} 个 ID 建议 ↑/↓ 选择,Enter 插入", acHint:"{n} 个 ID 建议 ↑/↓ 选择,Enter 插入",
tipClose:"关闭", tipClose:"关闭",
tipOpenLink:"打开链接", 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/&lt;uuid&gt;)吗?", liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/&lt;uuid&gt;)吗?",
liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。", liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。",
liveConflictText:"有人改动了同样的行。以谁的版本为准?", liveConflictText:"有人改动了同样的行。以谁的版本为准?",
@@ -3992,7 +4104,7 @@ const I18N = {
acHint:"ID候補 {n} 件 ↑/↓で選択、Enterで挿入", acHint:"ID候補 {n} 件 ↑/↓で選択、Enterで挿入",
tipClose:"閉じる", tipClose:"閉じる",
tipOpenLink:"リンクを開く", 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/&lt;uuid&gt;)ですか。", liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/&lt;uuid&gt;)ですか。",
liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。", liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。",
liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。", liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。",
+12
View File
@@ -43,6 +43,18 @@ export function setFoldMark(line, mark){
return m[1] + m[2] + (mark ? mark + ' ' : '') + line.slice(m[0].length); 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_LINE = /^([ \t]*)([-|+]|=(?=[ \t]))?\s*(?:([><])(?=[ \t])\s*)?(?:\[([^\]])\]\s*)?(?:([><])(?=[ \t])\s*)?(.*)$/;
const RE_ID_TOKEN = /(^|\s)#([\p{L}\p{N}._-]+)/u; const RE_ID_TOKEN = /(^|\s)#([\p{L}\p{N}._-]+)/u;
/* Fortsetzungszeile (SPEC §1): Leerraum, dann `\` als letztes Zeichen. Der /* Fortsetzungszeile (SPEC §1): Leerraum, dann `\` als letztes Zeichen. Der
+7
View File
@@ -1095,6 +1095,13 @@
.nodetip-ticket .tk-line{margin-top:4px;color:var(--muted)} .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-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} .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 /* 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: 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 so erbt es Rahmen UND Schatten des Fensters. JS setzt --tipx (Spitze) und
+40 -1
View File
@@ -70,9 +70,48 @@ export const TAIGA_STATUS_CODE = {
}; };
export function mapTaigaStatus(name){ export function mapTaigaStatus(name){
if(typeof name !== 'string') return null; 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; 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, /* Die Ticket-Referenz unter der Schreibmarke (Strg+Klick im Text,
D91-Nachtrag 5): ein FREISTEHENDES `#US-123`/`#T-1234`-Token im Baumteil. D91-Nachtrag 5): ein FREISTEHENDES `#US-123`/`#T-1234`-Token im Baumteil.
+77 -1
View File
@@ -1,7 +1,8 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { parse } from '../src/parser.js'; import { parse } from '../src/parser.js';
import { taigaSlugs } from '../src/model.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 /* Schlagworte `&tag` (SPEC §1, D91): Extraktion im Parser und die
`taiga.*`-Vererbung in model.js der erste Konsument der reservierten `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); 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');
});
});