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