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:
co-authored by
Claude Fable 5
parent
e58f71bf90
commit
842e89795a
+11
-1
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+145
-21
@@ -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);
|
||||||
const b = document.createElement('button');
|
if(slug) nodeTipBody.appendChild(ticketBox(el, slug, ref));
|
||||||
b.type = 'button'; b.className = 'nodetip-taigabtn'; b.tabIndex = -1;
|
/* Als Knopf im Fenster ist der Link auch auf Touch erreichbar (dort gibt
|
||||||
b.textContent = '↗ ' + t('taigaOpenBtn', {ref: '#' + ref});
|
es kein Strg) und macht die Strg+Klick-Geste nebenbei auffindbar
|
||||||
b.addEventListener('click', () => { closeNodeTip(); window.open(url, '_blank', 'noopener'); });
|
(D25-Lehre). */
|
||||||
wrap.appendChild(b);
|
if(url){
|
||||||
nodeTipBody.appendChild(wrap);
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button'; b.className = 'nodetip-taigabtn'; b.tabIndex = -1;
|
||||||
|
b.textContent = '↗ ' + t('taigaOpenBtn', {ref: '#' + ref});
|
||||||
|
b.addEventListener('click', () => { closeNodeTip(); window.open(url, '_blank', 'noopener'); });
|
||||||
|
wrap.appendChild(b);
|
||||||
|
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/<uuid>)?",
|
liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/<uuid>)?",
|
||||||
liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.",
|
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/<uuid>)?",
|
liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/<uuid>)?",
|
||||||
liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.",
|
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/<uuid>)?",
|
liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/<uuid>)?",
|
||||||
liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.",
|
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/<uuid>) ?",
|
liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/<uuid>) ?",
|
||||||
liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.",
|
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/<uuid>)?",
|
liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/<uuid>)?",
|
||||||
liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.",
|
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/<uuid>)?",
|
liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/<uuid>)?",
|
||||||
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/<uuid>)?",
|
liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/<uuid>)?",
|
||||||
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/<uuid>)吗?",
|
liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/<uuid>)吗?",
|
||||||
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/<uuid>)ですか。",
|
liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/<uuid>)ですか。",
|
||||||
liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。",
|
liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。",
|
||||||
liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。",
|
liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。",
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user