feat(taiga): Ticket-Stand im Knoten-Fenster — gelesen, nie geschrieben (D91-Nachtrag 6, SPEC §9)

Wo eine Ref steht und ein `&taiga.<slug>` gilt, zeigt das Knoten-Fenster
Betreff, Status und Zuständigen des Tickets; Taigas Statusname steht neben
der Statusbox der Notation (`In progress → [~]`).

- Proxy: zwei benannte Lese-Endpunkte (`GET /taiga/userstories/{ref}` und
  `…/tasks/{ref}`, je `?slug=`) — das Präfix der Ref trägt den Typ, Taiga
  hat getrennte `by_ref`-Endpunkte. Erst `/projects/by_slug`, dann `by_ref`
  (eine Ref ist nur je Projekt eindeutig); der Slug wird kodiert angehängt.
- Die Abbildung Status → Statusbox liegt im Editor (`mapTaigaStatus`,
  headless): Statuscodes sind Notation, das Backend parst sie nicht (D14).
  Unbekannte Namen bleiben unabgebildet — Raten hieße, dem Knoten eine
  Aussage zu geben, die niemand gemacht hat.
- Geholt wird erst nach 400 ms Verweilen und je Ticket einmal je Sitzung
  (↻ holt neu); ohne Anmeldung gar nicht — der Knopf meldet erst an.
- Nichts wird geschrieben: kein Text, keine Statusbox (das bleibt
  `#trk.write`).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-27 20:41:21 +02:00
co-authored by Claude Fable 5
parent e58f71bf90
commit 842e89795a
15 changed files with 776 additions and 33 deletions
+11 -1
View File
@@ -18,7 +18,8 @@ schneiden, dass die Berechtigungsprüfung dazukommen kann, ohne die Signatur
zu brechen. zu brechen.
**Taiga-Proxy (D91):** schmale, benannte Endpunkte unter `/api/v1/taiga/*` **Taiga-Proxy (D91):** schmale, benannte Endpunkte unter `/api/v1/taiga/*`
(auth, projects, userstories, tasks) in `de.werkbaum.integration.taiga` (auth, projects, userstories, tasks — je POST zum Anlegen und GET
`…/{ref}?slug=` zum **Lesen**, D91-Nachtrag 6) in `de.werkbaum.integration.taiga`
(`TaigaClient` + `TaigaProperties`), Controller in `api`. Die Basis-URL der (`TaigaClient` + `TaigaProperties`), Controller in `api`. Die Basis-URL der
Taiga-**API** ist Server-Konfiguration (`werkbaum.taiga.api-url` bzw. Taiga-**API** ist Server-Konfiguration (`werkbaum.taiga.api-url` bzw.
`WERKBAUM_TAIGA_API_URL`), **nie** Request-Parameter — die SSRF-Falle `WERKBAUM_TAIGA_API_URL`), **nie** Request-Parameter — die SSRF-Falle
@@ -71,3 +72,12 @@ docs/SPEC.md §10 testen — niemals eine zweite, abweichende Grammatik pflegen.
URL, Status. Status-Mapping Taiga-Workflow → Notation konfigurierbar URL, Status. Status-Mapping Taiga-Workflow → Notation konfigurierbar
(Default: „New"→`[ ]`, „In progress"→`[~]`, „Ready for test"→`[/]`, (Default: „New"→`[ ]`, „In progress"→`[~]`, „Ready for test"→`[/]`,
„Done"→`[x]`, „Archived"→`[^]`). „Done"→`[x]`, „Archived"→`[^]`).
- **Abgebildet wird im Frontend, nicht hier** (D91-Nachtrag 6): Der Proxy
reicht Taigas Status-**Namen** durch (`status_extra_info.name`), die
Statuscodes sind Notations-Vokabular und das Backend parst die Notation
nicht (D14). Die Tabelle steht headless in `frontend/src/taiga.js`
(`mapTaigaStatus`); konfigurierbar ist sie noch nicht.
- **Eine Ref ist nur je Projekt eindeutig:** Die Lese-Endpunkte nehmen
deshalb den `slug` (aus `&taiga.<slug>`, 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.
@@ -7,6 +7,7 @@ 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.TaigaTicket import de.werkbaum.generated.model.TaigaTicket
import de.werkbaum.generated.model.TaigaTicketDetail
import de.werkbaum.integration.taiga.TaigaClient import de.werkbaum.integration.taiga.TaigaClient
import de.werkbaum.integration.taiga.TaigaTicketData import de.werkbaum.integration.taiga.TaigaTicketData
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
@@ -72,6 +73,31 @@ class TaigaController(private val client: TaigaClient) : TaigaApi {
return created(ticket) return created(ticket)
} }
/* Lesen (D91-Nachtrag 6): zwei Endpunkte statt eines mit Typ-Parameter —
das Präfix der Ref trägt den Typ, und Taiga hat getrennte
`by_ref`-Endpunkte. Die Abbildung des Status auf die Notation macht der
Editor: Das Backend parst die Notation nicht (D14). */
override fun taigaStoryByRef(xTaigaToken: String, ref: Long, slug: String) =
ticket(xTaigaToken, slug, ref, task = false)
override fun taigaTaskByRef(xTaigaToken: String, ref: Long, slug: String) =
ticket(xTaigaToken, slug, ref, task = true)
private fun ticket(token: String, slug: String, ref: Long, task: Boolean):
ResponseEntity<TaigaTicketDetail> {
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,
)
)
}
private fun created(ticket: TaigaTicketData): ResponseEntity<TaigaTicket> = private fun created(ticket: TaigaTicketData): ResponseEntity<TaigaTicket> =
ResponseEntity.status(HttpStatus.CREATED).body( ResponseEntity.status(HttpStatus.CREATED).body(
TaigaTicket(id = ticket.id, ref = ticket.ref, subject = ticket.subject) TaigaTicket(id = ticket.id, ref = ticket.ref, subject = ticket.subject)
@@ -7,7 +7,9 @@ import org.springframework.stereotype.Service
import org.springframework.web.client.ResourceAccessException import org.springframework.web.client.ResourceAccessException
import org.springframework.web.client.RestClient import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientResponseException import org.springframework.web.client.RestClientResponseException
import java.net.URLEncoder
import java.net.http.HttpClient import java.net.http.HttpClient
import java.nio.charset.StandardCharsets
import java.time.Duration import java.time.Duration
/** Keine Taiga-Instanz konfiguriert — der Proxy hat kein Ziel (503). */ /** Keine Taiga-Instanz konfiguriert — der Proxy hat kein Ziel (503). */
@@ -36,6 +38,21 @@ data class TaigaProjectData(val id: Long, val name: String, val slug: String)
data class TaigaTicketData(val id: Long, val ref: Long, val subject: String) data class TaigaTicketData(val id: Long, val ref: Long, val subject: String)
/**
* Der gelesene Stand eines Tickets (D91-Nachtrag 6). Status und Zuständiger
* kommen aus Taigas `*_extra_info`-Blöcken und sind **nullbar**: Liefert die
* Instanz sie nicht mit, fehlt die Zeile im Knoten-Fenster — geraten wird
* nicht.
*/
data class TaigaTicketDetailData(
val id: Long,
val ref: Long,
val subject: String,
val status: String?,
val statusClosed: Boolean?,
val assignee: String?,
)
/** /**
* 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.
@@ -91,6 +108,53 @@ class TaigaClient(private val properties: TaigaProperties) {
// Taigas Feldname; unsere API sagt `userStory` (camelCase wie überall). // Taigas Feldname; unsere API sagt `userStory` (camelCase wie überall).
create(token, "/tasks", mapOf("project" to project, "subject" to subject, "user_story" to userStory)) create(token, "/tasks", mapOf("project" to project, "subject" to subject, "user_story" to userStory))
/**
* Ein Ticket über seine **Ref** lesen (D91-Nachtrag 6). Zwei Schritte,
* weil eine Ref nur je Projekt eindeutig ist: erst der Projekt-Slug zur
* Id (`/projects/by_slug`), dann `by_ref` am passenden Endpunkt. Beide
* sind dokumentierte Taiga-Endpunkte — der eine gesparte Umlauf über
* `project__slug` wäre eine Wette auf eine Filter-Eigenheit gewesen.
*
* `task` unterscheidet die beiden Typen; welcher gemeint ist, weiß der
* Aufrufer aus dem Präfix der Ref (`US-`/`T-`, SPEC §11) — hier steht
* nur, wohin gefragt wird.
*/
fun ticket(token: String, slug: String, ref: Long, task: Boolean): TaigaTicketDetailData {
val project = projectId(token, slug)
val pfad = if (task) "/tasks/by_ref" else "/userstories/by_ref"
val map = exchange {
rest.get().uri(url("$pfad?project=$project&ref=$ref"))
.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,
)
}
/**
* Projekt-Slug -> Id; Taigas `by_ref` filtert über die Id. Der Slug kommt
* vom Client und wird deshalb **kodiert** in die Anfrage gesetzt — sonst
* hängte ein `&` daran einen weiteren Filter an.
*/
fun projectId(token: String, slug: String): Long {
val map = exchange {
rest.get().uri(url("/projects/by_slug?slug=" + enc(slug)))
.header("Authorization", "Bearer $token")
.retrieve().body(MAP)
} ?: throw TaigaUnavailableException("Leere Antwort von Taiga (/projects/by_slug)")
return num(map, "id")
}
@Suppress("UNCHECKED_CAST")
private fun extra(m: Map<String, Any?>, key: String): Map<String, Any?>? =
m[key] as? Map<String, Any?>
private fun create(token: String, path: String, body: Map<String, Any>): TaigaTicketData { private fun create(token: String, path: String, body: Map<String, Any>): TaigaTicketData {
val map = exchange { val map = exchange {
rest.post().uri(url(path)) rest.post().uri(url(path))
@@ -102,6 +166,8 @@ class TaigaClient(private val properties: TaigaProperties) {
return TaigaTicketData(id = num(map, "id"), ref = num(map, "ref"), subject = str(map, "subject")) return TaigaTicketData(id = num(map, "id"), ref = num(map, "ref"), subject = str(map, "subject"))
} }
private fun enc(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
private fun url(path: String): String { private fun url(path: String): String {
if (!properties.configured) throw TaigaNotConfiguredException() if (!properties.configured) throw TaigaNotConfiguredException()
return properties.apiUrl.trimEnd('/') + path return properties.apiUrl.trimEnd('/') + path
+139
View File
@@ -524,6 +524,88 @@ paths:
"503": "503":
$ref: "#/components/responses/TaigaNotConfigured" $ref: "#/components/responses/TaigaNotConfigured"
/taiga/userstories/{ref}:
get:
tags: [Taiga]
operationId: taigaStoryByRef
summary: User Story ueber ihre Ref lesen (Proxy)
description: >
Loest `#US-<ref>` auf (D91-Nachtrag 6): Betreff, Status und
Zustaendiger einer Story. Refs sind nur **je Projekt** eindeutig -
deshalb der `slug`, den der Editor aus dem Schlagwort
`&taiga.<slug>` des Teilbaums nimmt (SPEC par. 1). Der Proxy fragt
dafuer zuerst `GET <api-url>/projects/by_slug`, dann
`GET <api-url>/userstories/by_ref?project=<id>&ref=<ref>`.
Nur gelesen: Werkbaum schreibt hier nichts nach Taiga und nichts in
den Notationstext.
parameters:
- $ref: "#/components/parameters/TaigaToken"
- $ref: "#/components/parameters/TaigaRef"
- $ref: "#/components/parameters/TaigaSlug"
responses:
"200":
description: Die Story
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaTicketDetail"
"401":
description: Token fehlt oder ist abgelaufen
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"404":
description: >
Projekt oder Ref gibt es nicht - Taigas Status wird
durchgereicht (der Editor zeigt den Fehlertext im Knoten-Fenster).
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"502":
$ref: "#/components/responses/TaigaUnavailable"
"503":
$ref: "#/components/responses/TaigaNotConfigured"
/taiga/tasks/{ref}:
get:
tags: [Taiga]
operationId: taigaTaskByRef
summary: Task ueber ihre Ref lesen (Proxy)
description: >
Wie `GET /taiga/userstories/{ref}`, nur ueber
`GET <api-url>/tasks/by_ref` - loest `#T-<ref>` auf. Zwei Endpunkte
statt eines mit Typ-Parameter, weil das Praefix der Ref den Typ
traegt und Taiga getrennte `by_ref`-Endpunkte hat (SPEC par. 11).
parameters:
- $ref: "#/components/parameters/TaigaToken"
- $ref: "#/components/parameters/TaigaRef"
- $ref: "#/components/parameters/TaigaSlug"
responses:
"200":
description: Die Task
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaTicketDetail"
"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"
/info: /info:
get: get:
tags: [Documents] tags: [Documents]
@@ -559,6 +641,31 @@ components:
type: string type: string
maxLength: 512 maxLength: 512
TaigaRef:
name: ref
in: path
required: true
description: >
Die nackte Taiga-Nummer, ohne das Werkbaum-Praefix: aus `#US-123`
bzw. `#T-1234` wird `123` bzw. `1234`. Den Typ traegt der Pfad.
schema:
type: integer
format: int64
minimum: 1
TaigaSlug:
name: slug
in: query
required: true
description: >
Projekt-Slug aus dem Schlagwort `&taiga.<slug>` (SPEC par. 1). Refs
laufen je Projekt fortlaufend - ohne das Projekt ist eine Ref nicht
eindeutig.
schema:
type: string
minLength: 1
maxLength: 255
responses: responses:
TaigaNotConfigured: TaigaNotConfigured:
description: > description: >
@@ -975,6 +1082,38 @@ components:
subject: subject:
type: string type: string
TaigaTicketDetail:
type: object
description: >
Der gelesene Stand eines Tickets (D91-Nachtrag 6) - schmal wie alles
hier: genau die Felder, die das Knoten-Fenster zeigt.
required: [id, ref, subject]
properties:
id:
type: integer
format: int64
ref:
type: integer
format: int64
subject:
type: string
status:
type: string
description: >
Name des Taiga-Workflow-Status (`status_extra_info.name`), z. B.
"In progress". Die Abbildung auf die Statusbox der Notation macht
der Editor (SPEC par. 4/9) - das Backend parst die Notation nicht
(D14). Fehlt, wenn Taiga den Namen nicht mitliefert.
statusClosed:
type: boolean
description: Taigas eigene Aussage, ob der Status als erledigt gilt.
assignee:
type: string
description: >
Anzeigename des Zustaendigen
(`assigned_to_extra_info.full_name_display`); fehlt, wenn niemand
zugewiesen ist.
ProblemDetail: ProblemDetail:
type: object type: object
description: Fehlerformat nach RFC 9457 (Problem Details) description: Fehlerformat nach RFC 9457 (Problem Details)
@@ -115,6 +115,34 @@ class TaigaApiTest {
result.responseBody!! shouldContain "\"ref\":124" result.responseBody!! shouldContain "\"ref\":124"
} }
@Test
fun `eine Story-Ref wird ueber Slug und by_ref aufgeloest`() {
val result = client.get()
.uri("/api/v1/taiga/userstories/123?slug=mi-kunde")
.header("X-Taiga-Token", "tok-abc123")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 200
result.responseBody!! shouldContain "\"subject\":\"Login bauen\""
// Der Status kommt als NAME an; abgebildet wird er im Editor (D14).
result.responseBody!! shouldContain "\"status\":\"In progress\""
result.responseBody!! shouldContain "\"assignee\":\"Anna Beispiel\""
}
@Test
fun `eine Task-Ref geht an den Task-Endpunkt und darf ohne Zustaendigen kommen`() {
val result = client.get()
.uri("/api/v1/taiga/tasks/1234?slug=mi-kunde")
.header("X-Taiga-Token", "tok-abc123")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 200
result.responseBody!! shouldContain "\"status\":\"Done\""
result.responseBody!! shouldContain "\"statusClosed\":true"
// Niemand zugewiesen: ausdrücklich null, nicht geraten.
result.responseBody!! shouldContain "\"assignee\":null"
}
companion object { companion object {
private lateinit var stub: HttpServer private lateinit var stub: HttpServer
@Volatile private var stubStatus = 200 @Volatile private var stubStatus = 200
@@ -129,6 +157,9 @@ class TaigaApiTest {
"/api/v1/projects" -> TaigaClientTestData.PROJECTS_OK "/api/v1/projects" -> TaigaClientTestData.PROJECTS_OK
"/api/v1/userstories" -> TaigaClientTestData.STORY_OK "/api/v1/userstories" -> TaigaClientTestData.STORY_OK
"/api/v1/tasks" -> TaigaClientTestData.TASK_OK "/api/v1/tasks" -> TaigaClientTestData.TASK_OK
"/api/v1/projects/by_slug" -> TaigaClientTestData.PROJECT_OK
"/api/v1/userstories/by_ref" -> TaigaClientTestData.STORY_DETAIL
"/api/v1/tasks/by_ref" -> TaigaClientTestData.TASK_DETAIL
else -> "{}" else -> "{}"
} }
val status = if (stubBody != null) stubStatus val status = if (stubBody != null) stubStatus
@@ -175,4 +206,13 @@ object TaigaClientTestData {
"""{"id": 1234, "ref": 123, "subject": "Backend bauen", "project": 7}""" """{"id": 1234, "ref": 123, "subject": "Backend bauen", "project": 7}"""
const val TASK_OK = const val TASK_OK =
"""{"id": 5678, "ref": 124, "subject": "API-Teil", "project": 7, "user_story": 1234}""" """{"id": 5678, "ref": 124, "subject": "API-Teil", "project": 7, "user_story": 1234}"""
const val PROJECT_OK =
"""{"id": 7, "name": "Kunde", "slug": "mi-kunde"}"""
const val STORY_DETAIL =
"""{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7,
"status_extra_info": {"name": "In progress", "is_closed": false},
"assigned_to_extra_info": {"full_name_display": "Anna Beispiel"}}"""
const val TASK_DETAIL =
"""{"id": 5678, "ref": 1234, "subject": "API-Teil", "project": 7,
"status_extra_info": {"name": "Done", "is_closed": true}}"""
} }
@@ -22,6 +22,8 @@ class TaigaClientTest {
@BeforeEach @BeforeEach
fun reset() { fun reset() {
recorded = null recorded = null
requests.clear()
routes.clear()
responseStatus = 200 responseStatus = 200
responseBody = "{}" responseBody = "{}"
} }
@@ -96,6 +98,70 @@ class TaigaClientTest {
recorded!!.body shouldContain "\"user_story\":1234" recorded!!.body shouldContain "\"user_story\":1234"
} }
/* ---- Lesen über die Ref (D91-Nachtrag 6) ---- */
@Test
fun `ticket loest erst den Slug zur Projekt-Id auf und liest dann per by_ref`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/userstories/by_ref"] = 200 to STORY_DETAIL
val d = client().ticket("tok-abc123", "mi-kunde", 123, task = false)
d.ref shouldBe 123L
d.id shouldBe 1234L
d.subject shouldBe "Login bauen"
d.status shouldBe "In progress"
d.statusClosed shouldBe false
d.assignee shouldBe "Anna Beispiel"
requests.map { it.path } shouldBe
listOf("/api/v1/projects/by_slug", "/api/v1/userstories/by_ref")
requests[0].query shouldBe "slug=mi-kunde"
requests[1].query shouldBe "project=7&ref=123"
requests.all { it.auth == "Bearer tok-abc123" } shouldBe true
}
@Test
fun `eine Task wird ueber ihren eigenen by_ref-Endpunkt gelesen`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/tasks/by_ref"] = 200 to TASK_DETAIL
val d = client().ticket("tok-abc123", "mi-kunde", 1234, task = true)
d.ref shouldBe 1234L
d.status shouldBe "Done"
requests[1].path shouldBe "/api/v1/tasks/by_ref"
requests[1].query shouldBe "project=7&ref=1234"
}
@Test
fun `ohne extra_info bleiben Status und Zustaendiger leer statt geraten`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/userstories/by_ref"] = 200 to STORY_BARE
val d = client().ticket("tok-abc123", "mi-kunde", 123, task = false)
d.subject shouldBe "Login bauen"
d.status shouldBe null
d.statusClosed shouldBe null
d.assignee shouldBe null
}
@Test
fun `ein unbekanntes Projekt wird als 404 durchgereicht, ohne zweiten Umlauf`() {
routes["/api/v1/projects/by_slug"] = 404 to NOT_FOUND
val ex = shouldThrow<TaigaUpstreamException> {
client().ticket("tok-abc123", "gibt-es-nicht", 123, task = false)
}
ex.status shouldBe 404
requests.map { it.path } shouldBe listOf("/api/v1/projects/by_slug")
}
@Test
fun `der Slug wird kodiert - ein & haengt keinen weiteren Filter an`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/userstories/by_ref"] = 200 to STORY_DETAIL
client().ticket("tok-abc123", "a&member=1", 123, task = false)
requests[0].query shouldBe "slug=a%26member%3D1"
}
@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 = ""))
@@ -131,6 +197,12 @@ class TaigaClientTest {
@Volatile var responseStatus = 200 @Volatile var responseStatus = 200
@Volatile var responseBody = "{}" @Volatile var responseBody = "{}"
/* Das Lesen eines Tickets braucht ZWEI Umläufe (Slug -> Id, dann
by_ref) — deshalb Antworten je Pfad und alle Anfragen der Reihe
nach, statt nur der letzten. */
val routes = mutableMapOf<String, Pair<Int, String>>()
val requests = mutableListOf<Recorded>()
/* Aufgezeichnete Antwortformen (gekuerzt auf die gebrauchten Felder /* Aufgezeichnete Antwortformen (gekuerzt auf die gebrauchten Felder
plus typisches Beiwerk, damit der Client Unbekanntes ignoriert). */ plus typisches Beiwerk, damit der Client Unbekanntes ignoriert). */
const val AUTH_OK = const val AUTH_OK =
@@ -143,6 +215,23 @@ class TaigaClientTest {
"""{"id": 1234, "ref": 123, "subject": "Backend bauen", "project": 7, "status": 1}""" """{"id": 1234, "ref": 123, "subject": "Backend bauen", "project": 7, "status": 1}"""
const val TASK_OK = const val TASK_OK =
"""{"id": 5678, "ref": 124, "subject": "API-Teil", "project": 7, "user_story": 1234}""" """{"id": 5678, "ref": 124, "subject": "API-Teil", "project": 7, "user_story": 1234}"""
const val PROJECT_OK =
"""{"id": 7, "name": "Kunde", "slug": "mi-kunde"}"""
/* 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,
"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"}}"""
const val TASK_DETAIL =
"""{"id": 5678, "ref": 1234, "subject": "API-Teil", "project": 7,
"status_extra_info": {"name": "Done", "is_closed": true},
"assigned_to_extra_info": null}"""
const val STORY_BARE =
"""{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7, "status": 3}"""
const val NOT_FOUND =
"""{"_error_message": "Not found.", "_error_type": "taiga.base.exceptions.NotFound"}"""
@JvmStatic @JvmStatic
@BeforeAll @BeforeAll
@@ -156,9 +245,12 @@ class TaigaClientTest {
noPagination = ex.requestHeaders.getFirst("x-disable-pagination"), noPagination = ex.requestHeaders.getFirst("x-disable-pagination"),
body = ex.requestBody.readBytes().decodeToString(), body = ex.requestBody.readBytes().decodeToString(),
) )
val bytes = responseBody.encodeToByteArray() requests += recorded!!
val route = routes[ex.requestURI.path]
val status = route?.first ?: responseStatus
val bytes = (route?.second ?: responseBody).encodeToByteArray()
ex.responseHeaders.set("Content-Type", "application/json") ex.responseHeaders.set("Content-Type", "application/json")
ex.sendResponseHeaders(responseStatus, bytes.size.toLong()) ex.sendResponseHeaders(status, bytes.size.toLong())
ex.responseBody.use { it.write(bytes) } ex.responseBody.use { it.write(bytes) }
} }
server.start() server.start()
+5
View File
@@ -19,6 +19,11 @@ reverse.
## 2026-08-27 ## 2026-08-27
- 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
- The state is fetched once per ticket and session, and only when the node window stays open for a moment — a ↻ button fetches again
- Without being logged in nothing is fetched: the window offers a "fetch state" button that asks for the login first
- Ctrl+click a ticket ref like `#US-123` — in the text or on its node — opens the ticket in Taiga's web UI; the node window carries an open button, which is the way on touch - Ctrl+click a ticket ref like `#US-123` — in the text or on its node — opens the ticket in Taiga's web UI; the node window carries an open button, which is the way on touch
- The backend names the Taiga web address now (`WERKBAUM_TAIGA_WEB_URL`, reported by `/api/v1/info`) — without it, creating tickets still works and opening is simply absent - The backend names the Taiga web address now (`WERKBAUM_TAIGA_WEB_URL`, reported by `/api/v1/info`) — without it, creating tickets still works and opening is simply absent
- A ticket ref like `#US-123` in a node title no longer breaks across two lines at its hyphen - A ticket ref like `#US-123` in a node title no longer breaks across two lines at its hyphen
+87
View File
@@ -8123,3 +8123,90 @@ gestapelten Prüfskript setzte der Sprung aus dem Vor-Schritt die Auswahl
asynchron neu und ließ den Tastaturweg scheinbar die falsche URL öffnen — asynchron neu und ließ den Tastaturweg scheinbar die falsche URL öffnen —
isoliert wiederholt stimmt sie; dieselbe Zustandsvermischung wie beim isoliert wiederholt stimmt sie; dieselbe Zustandsvermischung wie beim
Nachtrag-4-Bau. Nachtrag-4-Bau.
**Nachtrag 6 — Ticket-Stand im Knoten-Fenster: gelesen, nie geschrieben
(2026-08-27).** `#trk.resolve` gebaut: Wo eine Ref steht und ein
`&taiga.<slug>` gilt, zeigt das Fenster Betreff, Status und Zuständigen des
Tickets (SPEC §9). Die Entscheidungen:
**Zwei benannte Lese-Endpunkte statt eines mit Typ-Parameter** —
`GET /taiga/userstories/{ref}` und `GET /taiga/tasks/{ref}`, je mit `?slug=`.
Das Präfix der Ref **trägt** den Typ (D91-Nachtrag 2), und Taiga hat für die
beiden Typen getrennte `by_ref`-Endpunkte; ein Enum-Parameter hätte dieselbe
Verzweigung nur einen Schritt später gemacht — und passt schlecht zu den
vorhandenen Namen (`/taiga/userstories` POST legt an, GET liest).
**Zwei Umläufe, nicht einer auf Verdacht.** Eine Ref ist nur **je Projekt**
eindeutig, `by_ref` filtert über die Projekt-**Id**. Der Proxy fragt deshalb
erst `/projects/by_slug`, dann `by_ref`. Der eine gesparte Umlauf über
`?project__slug=` wäre eine Wette auf eine Filter-Eigenheit gewesen, die
niemand hier gemessen hat — die beiden genommenen Endpunkte sind
dokumentiert. **Der Slug wird kodiert** in die Anfrage gesetzt: Er kommt vom
Client, und ein `&` darin hängte sonst einen weiteren Filter an (die kleine
Schwester der SSRF-Falle, wegen der die Basis-URL Server-Konfiguration ist).
**Die Abbildung Taiga-Status → Statusbox liegt im FRONTEND**, obwohl
`backend/CLAUDE.md` sie unter „Taiga-Mapping" führt: Die Statuscodes sind
Notations-Vokabular (SPEC §4), und das Backend parst die Notation nicht
(D14). Der Proxy reicht den **Namen** durch (`status_extra_info.name`), der
Editor bildet ab — headless in `taiga.js`, damit die Regel eine Zusicherung
hat. Vorgabe wie dort notiert: „New" `[ ]`, „In progress" `[~]`, „Ready for
test" `[/]`, „Done" `[x]`, „Archived" `[^]`; Groß-/Kleinschreibung und
Leerraum egal. **Unbekannte Namen bleiben unabgebildet** und stehen nur als
Text — Taiga-Workflows sind je Projekt frei benannt, und Raten wäre hier
besonders teuer: Der Knoten bekäme eine Statusaussage, die niemand gemacht
hat. Die in der Roadmap versprochene **Konfigurierbarkeit** ist damit
ausdrücklich noch offen; der Plan-Knoten `#trk.resolve.map` steht deshalb auf
`[/]`, nicht auf `[x]`.
**Gezeigt wird beides: Taigas Name UND die Box** (`In progress → [~]`). Die
Abbildung ist die Aussage — nur die Box zu zeigen verlöre, woher sie kommt,
nur den Namen zu zeigen verlöre den Bezug zur Notation.
**Gelesen, nie geschrieben.** Kein Zeichen wandert in den Text, keine
Statusbox ändert sich. Das Zurückschreiben ist ein eigener Knoten
(`#trk.write`) und braucht eigene Entscheidungen (wer gewinnt bei
Abweichung?). Wo Ticket und Knoten auseinanderlaufen, sieht man es jetzt —
die Frage stellt das Fenster, beantworten muss sie ein Mensch.
**Zwei Sparsamkeiten gegenüber der fremden Instanz.** Das Fenster öffnet beim
**Überfahren** (D57) und beim Tabben — ein Abruf je gestreiftem Knoten wäre
unhöflich. Also: geholt wird erst, wenn es **400 ms** stehen bleibt, und je
Ticket **einmal je Sitzung** (Cache); ein ↻-Knopf holt neu. Ohne Anmeldung
wird **gar nicht** automatisch geholt — dort steht der Knopf „Stand holen",
und der meldet bei Bedarf an: Ein Klick ist die ausdrückliche Absicht, ein
Zeiger über einem Knoten nicht.
**Der Anmelde-Dialog darf das Fenster nicht zumachen.** Er gehört zu einer
Aktion **aus** dem Fenster; schlösse der `pointerdown`-Wächter es (D52), fiele
die Antwort ins Leere und man müsste den Knoten erneut aufsuchen. Ausgenommen
ist deshalb `.tabmodal-overlay` — die Anlage-Aktion (D91-Nachtrag 4) schließt
das Fenster weiterhin selbst, dort ist es gewollt.
**Ohne Slug geschieht still nichts**, wie beim Öffnen (D91-Nachtrag 5) und
beim Abhängigkeits-Sprung (D67); ein Fehler dagegen steht als Zeile im
Fenster — ein Abruf, den jemand angefordert hat, darf nicht stumm scheitern.
Nicht im Grafikexport und nicht im Druck: Das Fenster ist Bedienhilfe.
**Nachgemessen** Ende-zu-Ende im Browser gegen das lokal laufende Backend mit
Taiga-Stub: Ohne Sitzung steht „Stand holen" und **kein** Abruf geht hinaus;
der Knopf meldet an (Dialog, Fenster bleibt offen) und zeigt danach
`In progress → [~]` mit Taigas eigenem Betreff und „Zuständig: Anna
Beispiel"; die Task zeigt `Ready for test → [/]`; ein Knoten ohne Ref zeigt
unverändert die Anlage-Knöpfe, eine Ref **ohne** Projekt-Zuordnung gar
nichts. Im Mitschnitt des Stubs: genau **zwei** Anfragen je Ticket
(`by_slug` + `by_ref`), **keine** beim kurzen Streifen eines Knotens,
**keine** beim zweiten Ansehen (Cache), und genau eine neue Runde auf ↻.
Die Farben der Box stammen aus §4 (gemessen: `#FADDE4`/`#D897A8` für
`arbeit`). Backend: 5 neue Client-Tests (zweistufiger Weg, eigener
Task-Endpunkt, fehlendes `extra_info` → leer statt geraten, 404 ohne zweiten
Umlauf, kodierter Slug) und 2 Ende-zu-Ende-Tests; Frontend 585 Tests
(9 neue). Gegenproben: Kodierung entfernt → genau die eine danach benannte
Zusicherung fällt, Normalisierung des Statusnamens entfernt → genau die drei.
**Werkzeuggrenze, benannt:** Die Browser-Fläche wurde in dieser Sitzung nicht
dargestellt (keine Frames, keine Screenshots, `getBoundingClientRect()`
durchweg 0) — geprüft ist deshalb über Ereignisse und **berechnete** Stile,
nicht am Bild. Dieselbe Sorte Grenze wie D57 (`focus()` ohne Fensterfokus
feuert keine Fokus-Ereignisse): Der Tastaturweg musste synthetisch angestoßen
werden.
+27
View File
@@ -489,6 +489,30 @@ durch eine echte Linie abgesetzt. Siehe D57.
Siehe D52. Siehe D52.
**Ticket-Stand im Knoten-Fenster (§11).** Trägt die Zeile eine
Ticket-Referenz (`#US-123` / `#T-1234`) und ist ihr Teilbaum per
`&taiga.<slug>` (§1) einem Projekt zugeordnet, holt das Fenster den Stand des
Tickets und zeigt ihn über dem Öffnen-Knopf: **Betreff**, **Status** und, wenn
es einen gibt, den **Zuständigen**.
- Der Taiga-Status wird auf die Statusbox der Notation (§4) abgebildet und
**neben** seinem eigenen Namen gezeigt (`In progress → [~]`): „New“ `[ ]`,
„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).
- 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
konfiguriertes Backend geschieht still nichts; ein Fehler steht als Zeile im
Fenster.
- Reine Bedienhilfe wie das Fenster selbst: nicht im Grafikexport, nicht im
Druck.
Siehe D91-Nachtrag 6.
**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
**weiter** ist als der effektive (der Knoten wird von Abhängigkeiten **weiter** ist als der effektive (der Knoten wird von Abhängigkeiten
@@ -1360,6 +1384,9 @@ wird sie von selbst die ID. Erstreckt sich ein Plan über **mehrere**
Taiga-Projekte, benennt das Schlagwort `&taiga.<slug>` (unten) das Projekt 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)
wird im Knoten-Fenster gezeigt (§9) — gelesen, nie geschrieben; das
**Zurückschreiben** des Status bleibt reserviert (unten).
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).
+7 -5
View File
@@ -201,9 +201,9 @@
| [?] #idea.drift.js: Run the one JS parser inside the IDE (M) | [?] #idea.drift.js: Run the one JS parser inside the IDE (M)
- [~] #trk: Tracker integration (XL) %% exactly one of these, hence = - [~] #trk: Tracker integration (XL) %% exactly one of these, hence =
= [~] #trk.taiga: Taiga (XL) https://taiga.io = [~] #trk.taiga: Taiga (XL) https://taiga.io
- [ ] #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) - [x] #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 - [?] #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)
@@ -1149,11 +1149,13 @@
state. Read-only, and already useful on its own. state. Read-only, and already useful on its own.
#trk.resolve.read #trk.resolve.read
Fetching the ticket and showing what it says. Fetching the ticket and showing what it says: subject, status and assignee
in the node window, read through the backend proxy.
#trk.resolve.map #trk.resolve.map
Translating the tracker's workflow onto the eight states of the notation. Translating the tracker's workflow onto the eight states of the notation.
Configurable, because no two projects use the same column names. The default five are mapped, an unknown column name stays unmapped and is
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: change a status in the plan and the ticket follows.
+14
View File
@@ -667,6 +667,20 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der
`werkbaum-taiga` im localStorage (nur Token, nie ein Passwort); ein 401 `werkbaum-taiga` im localStorage (nur Token, nie ein Passwort); ein 401
löscht es und fragt neu. Fehler erscheinen IM Dialog, nicht als löscht es und fragt neu. Fehler erscheinen IM Dialog, nicht als
`window.alert` (in manchen Kontexten unterdrückt, D22-Lehre). `window.alert` (in manchen Kontexten unterdrückt, D22-Lehre).
- **Ticket-Stand lesen (D91-Nachtrag 6):** `ticketApiPath()` baut den
Proxy-Pfad (getrennte by_ref-Endpunkte je Typ, Slug kodiert),
`mapTaigaStatus()` bildet Taigas Statusnamen auf `STATUS_BY_CODE` ab —
beides headless in `taiga.js`, denn die Statuscodes sind Notation und das
Backend parst sie nicht (D14); ein unbekannter Name bleibt **null**.
In app.js: `ticketBox()`/`paintTicket()`/`loadTicket()` samt Cache
`taigaTickets` (je Ticket EIN Abruf je Sitzung, ↻ holt neu) und
`TICKET_DELAY` (400 ms Verweilen, bevor überhaupt gefragt wird — das
Fenster öffnet beim Überfahren und beim Tabben). **Ohne Sitzung wird nichts
automatisch geholt**, der Knopf meldet erst an. `tipTicket`/`ticketTimer`
stehen oben bei `tipNode`, weil `closeNodeTip()` sie mit wegräumt (das
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.
- **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
+139 -15
View File
@@ -1,7 +1,7 @@
import './style.css'; import './style.css';
import { parse, setFoldMark, expandShortIds, shortIdClosed } from './parser.js'; import { parse, setFoldMark, 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 } from './taiga.js'; import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, ticketApiPath, mapTaigaStatus } 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';
@@ -1585,11 +1585,16 @@ out.addEventListener('contextmenu', e => { if(pressTimer || armedEl) e.preventDe
const nodeTip = document.getElementById('nodeTip'); const nodeTip = document.getElementById('nodeTip');
const nodeTipBody = document.getElementById('nodeTipBody'); const nodeTipBody = document.getElementById('nodeTipBody');
let tipNode = null; let tipNode = null;
/* Der Ticket-Stand darin (D91-Nachtrag 6) steht hier oben bei `tipNode`:
`closeNodeTip()` räumt ihn mit weg, und das läuft schon beim Aufbau. */
let tipTicket = null, ticketTimer = null;
function closeNodeTip(){ function closeNodeTip(){
if(!tipNode) return; if(!tipNode) return;
tipNode.classList.remove('tipped'); tipNode.classList.remove('tipped');
tipNode = null; tipNode = null;
tipTicket = null; /* nichts mehr zu bemalen (D91-Nachtrag 6) */
clearTimeout(ticketTimer); /* ein noch nicht gestarteter Abruf entfällt */
nodeTip.hidden = true; nodeTip.hidden = true;
} }
@@ -1701,6 +1706,10 @@ document.getElementById('nodeTipClose').addEventListener('click', closeNodeTip);
document.addEventListener('pointerdown', e => { document.addEventListener('pointerdown', e => {
if(!tipNode || nodeTip.contains(e.target)) return; if(!tipNode || nodeTip.contains(e.target)) return;
if(e.target && e.target.closest && e.target.closest('.node') === tipNode) return; if(e.target && e.target.closest && e.target.closest('.node') === tipNode) return;
/* Ein modaler Dialog gehört zu einer Aktion AUS dem Fenster (Anmelden für
den Ticket-Stand, D91-Nachtrag 6) er darf es nicht zumachen, sonst
fiele die Antwort ins Leere. Er liegt ohnehin darüber (z-index). */
if(e.target && e.target.closest && e.target.closest('.tabmodal-overlay')) return;
closeNodeTip(); closeNodeTip();
}, true); }, true);
document.addEventListener('keydown', e => { if(e.key === 'Escape'){ closeNodeTip(); closeNewsMenu(); } }); document.addEventListener('keydown', e => { if(e.key === 'Escape'){ closeNodeTip(); closeNewsMenu(); } });
@@ -1808,17 +1817,23 @@ function appendTaigaActions(el){
const ref = ticketRefOf(node); const ref = ticketRefOf(node);
if(ref){ if(ref){
/* Der Knoten IST schon angelegt (Idempotenz-Marker) statt der /* Der Knoten IST schon angelegt (Idempotenz-Marker) statt der
Anlage-Knöpfe gibt es den Weg zum Ticket. Als Knopf im Fenster ist er Anlage-Knöpfe gibt es den Stand des Tickets und den Weg dorthin. Der
auch auf Touch erreichbar (dort gibt es kein Strg) und macht die Slug (SPEC §1) trägt das Lesen, die Web-Basis das Öffnen: Fehlt eines,
Strg+Klick-Geste nebenbei auffindbar (D25-Lehre). */ entfällt nur dessen Hälfte. */
const url = ticketUrl(taigaWeb, taigaSlugs(roots).get(node), ref); const slug = taigaSlugs(roots).get(node);
if(!url) return; const url = ticketUrl(taigaWeb, slug, ref);
if(slug) nodeTipBody.appendChild(ticketBox(el, slug, ref));
/* 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). */
if(url){
const b = document.createElement('button'); const b = document.createElement('button');
b.type = 'button'; b.className = 'nodetip-taigabtn'; b.tabIndex = -1; b.type = 'button'; b.className = 'nodetip-taigabtn'; b.tabIndex = -1;
b.textContent = '↗ ' + t('taigaOpenBtn', {ref: '#' + ref}); b.textContent = '↗ ' + t('taigaOpenBtn', {ref: '#' + ref});
b.addEventListener('click', () => { closeNodeTip(); window.open(url, '_blank', 'noopener'); }); b.addEventListener('click', () => { closeNodeTip(); window.open(url, '_blank', 'noopener'); });
wrap.appendChild(b); wrap.appendChild(b);
nodeTipBody.appendChild(wrap); nodeTipBody.appendChild(wrap);
}
return; return;
} }
const mk = (label, mitTasks) => { const mk = (label, mitTasks) => {
@@ -1833,6 +1848,115 @@ function appendTaigaActions(el){
nodeTipBody.appendChild(wrap); nodeTipBody.appendChild(wrap);
} }
/* ---------- Ticket-Stand im Knoten-Fenster (D91-Nachtrag 6) ----------
Trägt die Zeile eine Ref und ist der Teilbaum per `&taiga.<slug>` zugeordnet,
zeigt das Fenster Betreff, Status und Zuständigen des Tickets. Gelesen, nie
geschrieben: Der Notationstext bleibt unangetastet, die Abbildung des
Taiga-Status auf die Statusbox (SPEC §4) ist reine Anzeige.
Zwei Sparsamkeiten, beide der fremden Instanz zuliebe: Geholt wird je Ticket
EINMAL je Sitzung (Cache; der -Knopf holt neu), und erst, wenn das Fenster
kurz stehen bleibt es öffnet schon beim Überfahren (D57) und beim Tabben,
ein Abruf je gestreiftem Knoten wäre unhöflich. */
const taigaTickets = new Map(); /* '<slug>/<ref>' -> {kind:'load'|'ok'|'err', data, msg} */
const TICKET_DELAY = 400; /* `tipTicket`/`ticketTimer`: oben bei `tipNode` */
function ticketBox(el, slug, ref){
const box = document.createElement('div');
box.className = 'nodetip-ticket';
const key = slug + '/' + ref;
tipTicket = {key, slug, ref, box};
paintTicket(box, key, slug, ref);
clearTimeout(ticketTimer);
if(!taigaTickets.has(key) && taigaSession()){
ticketTimer = setTimeout(() => {
if(tipNode === el) loadTicket(key, slug, ref, false);
}, TICKET_DELAY);
}
return box;
}
function paintTicket(box, key, slug, ref){
const st = taigaTickets.get(key) || {kind: 'idle'};
box.textContent = '';
const line = (cls, text) => {
const d = document.createElement('div');
d.className = cls;
d.textContent = text;
box.appendChild(d);
return d;
};
if(st.kind === 'load'){ line('tk-line', t('taigaTicketLoading')); return; }
if(st.kind === 'ok'){
const head = document.createElement('div');
head.className = 'tk-head';
if(st.data.status){
const s = document.createElement('span');
s.className = 'tk-status';
s.textContent = st.data.status;
head.appendChild(s);
/* Taigas Name UND die Statusbox der Notation die Abbildung ist die
Aussage, deshalb stehen beide da (SPEC §9). Ein unbekannter Name
bleibt für sich stehen. */
const m = mapTaigaStatus(st.data.status);
if(m){
const a = document.createElement('span');
a.className = 'tk-arrow';
a.textContent = '→';
const c = document.createElement('span');
c.className = 'chip st-' + m.key;
c.textContent = '[' + m.code + ']';
head.append(a, c);
}
}
head.appendChild(reloadBtn(key, slug, ref, true));
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}));
return;
}
if(st.kind === 'err') line('tk-err', st.msg);
box.appendChild(reloadBtn(key, slug, ref, st.kind === 'err'));
}
/* 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){
const b = document.createElement('button');
b.type = 'button'; b.tabIndex = -1;
b.className = 'nodetip-taigabtn' + (kurz ? ' tk-mini' : '');
b.textContent = kurz ? '↻' : t('taigaTicketFetch');
if(kurz) b.title = t('taigaTicketReload');
b.addEventListener('click', () => loadTicket(key, slug, ref, true));
return b;
}
async function loadTicket(key, slug, ref, interactive){
const session = interactive ? await taigaEnsureSession() : taigaSession();
if(!session || !session.token) return; /* nicht angemeldet: der Knopf bleibt stehen */
taigaTickets.set(key, {kind: 'load'});
repaintTicket(key);
try{
const d = await taigaFetch(ticketApiPath(ref, slug), null, session.token);
taigaTickets.set(key, {kind: 'ok', data: d});
}catch(err){
/* Abgelaufenes Token: wegräumen, damit der nächste Knopfdruck die
Anmeldung anbietet (wie bei der Projektliste). */
if(err && err.status === 401) storeTaigaSession(null);
taigaTickets.set(key, {kind: 'err', msg: taigaErrText(err)});
}
repaintTicket(key);
}
/* Gemalt wird nur, wenn genau dieses Ticket noch im offenen Fenster steht
die Antwort kann kommen, wenn längst ein anderer Knoten dran ist. Das
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);
if(tipNode) placeNodeTip(tipNode);
}
/* Strg+Klick (macOS auch Cmd) auf einen Knoten mit Ticket-Referenz öffnet /* Strg+Klick (macOS auch Cmd) auf einen Knoten mit Ticket-Referenz öffnet
das Ticket im Taiga-Frontend (D91-Nachtrag 5) dieselbe Geste wie im das Ticket im Taiga-Frontend (D91-Nachtrag 5) dieselbe Geste wie im
Text. Der einfache Klick bleibt der Link (§6), Alt der Sprung (D25); ohne Text. Der einfache Klick bleibt der Link (§6), Alt der Sprung (D25); ohne
@@ -2768,7 +2892,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", 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}",
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?",
@@ -2905,7 +3029,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", 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}",
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?",
@@ -3041,7 +3165,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", 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}",
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?",
@@ -3177,7 +3301,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", 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}",
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 ?",
@@ -3313,7 +3437,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", 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}",
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ć?",
@@ -3449,7 +3573,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", 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}",
liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.", liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.",
liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?", liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?",
@@ -3585,7 +3709,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} खोलें", 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}",
liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/&lt;uuid&gt;)?", liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/&lt;uuid&gt;)?",
liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।", liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।",
liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?", liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?",
@@ -3732,7 +3856,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}", 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}",
liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/&lt;uuid&gt;)吗?", liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/&lt;uuid&gt;)吗?",
liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。", liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。",
liveConflictText:"有人改动了同样的行。以谁的版本为准?", liveConflictText:"有人改动了同样的行。以谁的版本为准?",
@@ -3868,7 +3992,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} を開く", 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}",
liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/&lt;uuid&gt;)ですか。", liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/&lt;uuid&gt;)ですか。",
liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。", liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。",
liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。", liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。",
+12
View File
@@ -1083,6 +1083,18 @@
font:inherit;font-size:.74rem;font-weight:500; font:inherit;font-size:.74rem;font-weight:500;
} }
.nodetip-taigabtn:hover{background:rgba(15,118,110,.08)} .nodetip-taigabtn:hover{background:rgba(15,118,110,.08)}
/* Der gelesene Ticket-Stand (D91-Nachtrag 6): abgesetzt wie die Kurz-Fakten,
die Statusbox als gewöhnlicher .chip in den §4-Farben. */
.nodetip-ticket{
margin-top:9px;padding-top:8px;
border-top:1px solid rgba(36,52,71,.15);font-size:.74rem;
}
.nodetip-ticket .tk-head{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
.nodetip-ticket .tk-status{font-weight:500}
.nodetip-ticket .tk-arrow{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-mini{margin-left:auto;padding:2px 7px;font-size:.8rem;line-height:1.1}
/* 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
+43 -3
View File
@@ -9,10 +9,20 @@
zeigt nur die Nummer. */ zeigt nur die Nummer. */
import { gateOf, isDone, isRealized } from './model.js'; import { gateOf, isDone, isRealized } from './model.js';
import { STATUS_BY_CODE } from './parser.js';
/* Das Tracker-Muster (SPEC §11): `US-\d+` (Story) und `T-\d+` (Task). */ /* Das Tracker-Muster (SPEC §11): `US-\d+` (Story) und `T-\d+` (Task). */
export const TICKET_ID_RE = /^(?:US|T)-\d+$/; export const TICKET_ID_RE = /^(?:US|T)-\d+$/;
/* Zerlegt eine Ref in Typ und nackte Nummer: `US-123` -> {kind:'US', nr:123}.
Das Präfix trägt den Typ (SPEC §11) daran hängen der Frontend-Pfad
(unten), Taigas getrennte `by_ref`-Endpunkte und damit auch der Proxy-Pfad.
Ungültiges ergibt null; geraten wird nirgends. */
export function refParts(ref){
const m = /^(US|T)-(\d+)$/.exec(ref || '');
return m ? {kind: m[1], nr: m[2]} : null;
}
/* Trägt die Zeile eines Knotens schon eine Ticket-Referenz? Das ist der /* Trägt die Zeile eines Knotens schon eine Ticket-Referenz? Das ist der
Idempotenz-Marker (D91-Nachtrag 2): So ein Knoten wird nicht erneut Idempotenz-Marker (D91-Nachtrag 2): So ein Knoten wird nicht erneut
angelegt. Die Ref ist entweder die Knoten-ID selbst (erstes `#`-Token, angelegt. Die Ref ist entweder die Knoten-ID selbst (erstes `#`-Token,
@@ -29,9 +39,39 @@ export function ticketRefOf(n){
genau dafür schreibt Werkbaum es (SPEC §11). Ohne Web-Basis, Projekt-Slug genau dafür schreibt Werkbaum es (SPEC §11). Ohne Web-Basis, Projekt-Slug
oder gültige Ref gibt es keine Adresse (null). */ oder gültige Ref gibt es keine Adresse (null). */
export function ticketUrl(web, slug, ref){ export function ticketUrl(web, slug, ref){
const m = /^(US|T)-(\d+)$/.exec(ref || ''); const p = refParts(ref);
if(!m || !web || !slug) return null; if(!p || !web || !slug) return null;
return web + '/project/' + slug + '/' + (m[1] === 'US' ? 'us' : 'task') + '/' + m[2]; return web + '/project/' + slug + '/' + (p.kind === 'US' ? 'us' : 'task') + '/' + p.nr;
}
/* Der Pfad am Backend-Proxy zum LESEN eines Tickets (D91-Nachtrag 6),
relativ zu `/api/v1/taiga`: zwei benannte Endpunkte statt eines mit
Typ-Parameter, weil Taiga getrennte `by_ref`-Endpunkte hat. Der Slug
kommt als Query dazu (eine Ref ist nur je Projekt eindeutig). */
export function ticketApiPath(ref, slug){
const p = refParts(ref);
if(!p || !slug) return null;
return '/' + (p.kind === 'US' ? 'userstories' : 'tasks') + '/' + p.nr +
'?slug=' + encodeURIComponent(slug);
}
/* Taiga-Workflow -> Statusbox der Notation (SPEC §4/§9, D91-Nachtrag 6).
Die Vorgabe steht in backend/CLAUDE.md; abgebildet wird im **Editor**, denn
die Statuscodes sind Notations-Vokabular und das Backend parst die Notation
nicht (D14). Groß-/Kleinschreibung und Leerraum sind egal; ein Name
außerhalb der Liste bleibt unabgebildet (null) geraten wird nicht, und
Taigas Workflows sind je Projekt frei benennbar. */
export const TAIGA_STATUS_CODE = {
'new': ' ',
'in progress': '~',
'ready for test': '/',
'done': 'x',
'archived': '^',
};
export function mapTaigaStatus(name){
if(typeof name !== 'string') return null;
const code = TAIGA_STATUS_CODE[name.trim().toLowerCase().replace(/\s+/g, ' ')];
return code ? STATUS_BY_CODE[code] : null;
} }
/* Die Ticket-Referenz unter der Schreibmarke (Strg+Klick im Text, /* Die Ticket-Referenz unter der Schreibmarke (Strg+Klick im Text,
+60 -1
View File
@@ -1,7 +1,7 @@
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 } from '../src/taiga.js'; import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, refParts, ticketApiPath, mapTaigaStatus } from '../src/taiga.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
@@ -242,3 +242,62 @@ describe('ticketRefAt — die Ref unter der Schreibmarke', () => {
expect(ticketRefAt(text, at(text, 'US'))).toBe(null); expect(ticketRefAt(text, at(text, 'US'))).toBe(null);
}); });
}); });
/* Ticket-Stand lesen (D91-Nachtrag 6): Ref zerlegen, Proxy-Pfad bauen,
Taiga-Workflow auf die Statusbox der Notation abbilden. */
describe('refParts / ticketApiPath — das Präfix trägt den Typ', () => {
it('zerlegt Story- und Task-Refs', () => {
expect(refParts('US-123')).toEqual({kind: 'US', nr: '123'});
expect(refParts('T-9')).toEqual({kind: 'T', nr: '9'});
});
it('alles andere ergibt null — geraten wird nicht', () => {
expect(refParts('ABC-1')).toBe(null);
expect(refParts('US-1x')).toBe(null);
expect(refParts('123')).toBe(null);
expect(refParts(null)).toBe(null);
});
it('baut die getrennten by_ref-Pfade des Proxys samt Slug', () => {
expect(ticketApiPath('US-123', 'mi-kunde')).toBe('/userstories/123?slug=mi-kunde');
expect(ticketApiPath('T-1234', 'mi-kunde')).toBe('/tasks/1234?slug=mi-kunde');
});
it('kodiert den Slug — er hängt keinen zweiten Parameter an', () => {
expect(ticketApiPath('US-1', 'a&b=2')).toBe('/userstories/1?slug=a%26b%3D2');
});
it('ohne Slug oder mit unbrauchbarer Ref gibt es keinen Pfad', () => {
expect(ticketApiPath('US-1', null)).toBe(null);
expect(ticketApiPath('ABC-1', 'mi-kunde')).toBe(null);
});
});
describe('mapTaigaStatus — Workflow auf die Statusbox (SPEC §4/§9)', () => {
const code = name => (mapTaigaStatus(name) || {}).code;
it('bildet die fünf vorgegebenen Zustände ab', () => {
expect(code('New')).toBe(' ');
expect(code('In progress')).toBe('~');
expect(code('Ready for test')).toBe('/');
expect(code('Done')).toBe('x');
expect(code('Archived')).toBe('^');
});
it('liefert den vollen Status samt Schlüssel für die Anzeige', () => {
expect(mapTaigaStatus('in progress').key).toBe('arbeit');
expect(mapTaigaStatus('DONE').key).toBe('fertig');
});
it('Groß-/Kleinschreibung und Leerraum sind egal', () => {
expect(code(' ready FOR test ')).toBe('/');
});
it('ein unbekannter Name bleibt unabgebildet', () => {
expect(mapTaigaStatus('Blocked')).toBe(null);
expect(mapTaigaStatus('')).toBe(null);
expect(mapTaigaStatus(null)).toBe(null);
expect(mapTaigaStatus(undefined)).toBe(null);
});
});