From 280fff21a3f7074b4bd718e2ce0da00c1c2234fb Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Tue, 27 Jan 2026 17:01:21 +0100 Subject: [PATCH 01/10] feat: add GetTaskFile and RemoveAllTaskFiles methods Complete the Task File API implementation with: - GetTaskFile: retrieve single file metadata by ID - RemoveAllTaskFiles: remove all files attached to a task - Add Username/UserName fields to TaskFile struct - Add TaskScope convenience methods: GetFile, RemoveFile, DownloadFile, RemoveAllFiles - Comprehensive tests for all new methods Closes: kanboard-amh --- .beads/export-state/fbbdf412d0fd5173.json | 4 +- .beads/issues.jsonl | 1 + files.go | 32 +++ files_test.go | 240 ++++++++++++++++++++++ task_scope.go | 20 ++ types.go | 2 + 6 files changed, 297 insertions(+), 2 deletions(-) diff --git a/.beads/export-state/fbbdf412d0fd5173.json b/.beads/export-state/fbbdf412d0fd5173.json index 80ee16a..dc11523 100644 --- a/.beads/export-state/fbbdf412d0fd5173.json +++ b/.beads/export-state/fbbdf412d0fd5173.json @@ -1,6 +1,6 @@ { "worktree_root": "/home/oli/Dev/kanboard-api", - "last_export_commit": "449cd2626c2990199ccad4b08041c0e438858272", - "last_export_time": "2026-01-27T12:13:18.925035335+01:00", + "last_export_commit": "6f2f0a10fc517c5bd882dcd188e15b47affeaca1", + "last_export_time": "2026-01-27T12:13:35.241832799+01:00", "jsonl_hash": "a60a9c8ba481e1eb8a3879ae02ed3fadda20082c4f0a618fe05f2c1c58b02e43" } \ No newline at end of file diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index e0bb016..5f20bf0 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -3,6 +3,7 @@ {"id":"kanboard-7es","title":"JSON-RPC Request-ID: Zufälligen Wert statt fester 1 verwenden","description":"## Kontext\n\nIn jedem JSON-RPC Request an die Kanboard-API wird im Root-Objekt ein Feld `id` mitgeliefert. Dieses dient dazu, bei asynchroner Kommunikation Request und Response einander zuordnen zu können – die API liefert diese ID in der Antwort zurück.\n\n**Aktuell:** Die ID ist fest auf `1` gesetzt.\n\n## Anforderung\n\n1. **Wenn keine ID von außen gesetzt wird:** Die Library soll intern einen zufälligen Wert generieren\n2. **API-Dokumentation prüfen:** Welche Werte sind erlaubt? Welche Größenordnung? (vermutlich Integer)\n3. **Signatur beibehalten:** Die öffentliche API der Library-Funktionen soll unverändert bleiben\n4. **Interne Generierung:** Die Library bestimmt selbst einen zufälligen Wert\n\n## Implementierungshinweise\n\n- Prüfen: Kanboard JSON-RPC Dokumentation bezüglich erlaubter ID-Werte\n- Vermutlich: `int64` oder `int32` Range\n- Zufallsgenerator: `math/rand` mit Seed oder `crypto/rand` für bessere Verteilung\n- Ggf. bestehende `requestIDCounter` in `jsonrpc.go` (Zeile 40) anpassen oder ersetzen\n\n## Beispiel\n\n**Vorher (immer gleich):**\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"getTask\", \"id\": 1, \"params\": {...}}\n```\n\n**Nachher (zufällig):**\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"getTask\", \"id\": 847291536, \"params\": {...}}\n```\n\n## Referenz\n\n- Datei: `jsonrpc.go`\n- Zeile 17: `ID int64 \\`json:\"id\"\\``\n- Zeile 40: `requestIDCounter` (existiert bereits)","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-23T17:44:51.566737509+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T10:21:06.415129879+01:00","closed_at":"2026-01-27T10:21:06.415129879+01:00","close_reason":"Closed"} {"id":"kanboard-9wa","title":"Support custom authentication header name","description":"## Description\n\nKanboard supports using an alternative HTTP header for authentication when the server has specific configuration requirements.\n\nCurrently, authentication uses the standard `Authorization` header via Go's `SetBasicAuth()`. This needs to be configurable so users can specify a custom header name (e.g., `X-API-Auth`).\n\n## Requirements\n\n- Add an optional configuration parameter for custom auth header name\n- Default to standard `Authorization` header if not specified\n- When custom header is set, use that header name instead of `Authorization`\n- The header value format should remain the same (Basic Auth base64-encoded credentials)\n\n## Acceptance Criteria\n\n- [ ] New client configuration method (e.g., `WithAuthHeader(headerName string)`)\n- [ ] Default behavior unchanged when no custom header specified\n- [ ] Custom header name is used when configured\n- [ ] Works with both API token and basic auth\n- [ ] Tests cover default and custom header scenarios\n\n## Reference\n\nKanboard API documentation: \"You can use an alternative HTTP header for authentication if your server has a very specific configuration.\"","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-23T18:08:31.507616093+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-23T18:26:50.40804952+01:00","closed_at":"2026-01-23T18:26:50.40804952+01:00","close_reason":"Closed"} {"id":"kanboard-a7x","title":"Handle URLs already ending in /jsonrpc.php","description":"## Context\n\nThe `NewClient()` function in `client.go` always appends `/jsonrpc.php` to the base URL. This causes issues when users pass a URL that already includes the endpoint path.\n\n## Problem\n\nIf a user calls:\n```go\nclient := kanboard.NewClient(\"https://example.com/jsonrpc.php\")\n```\n\nThe resulting endpoint becomes `https://example.com/jsonrpc.php/jsonrpc.php`, which fails.\n\n## Solution\n\nModify `NewClient()` to detect and handle URLs that already end in `/jsonrpc.php`:\n\n```go\nfunc NewClient(baseURL string) *Client {\n // Ensure no trailing slash\n baseURL = strings.TrimSuffix(baseURL, \"/\")\n\n // Handle URLs that already include /jsonrpc.php\n endpoint := baseURL\n if !strings.HasSuffix(baseURL, \"/jsonrpc.php\") {\n endpoint = baseURL + \"/jsonrpc.php\"\n }\n\n c := \u0026Client{\n baseURL: baseURL,\n endpoint: endpoint,\n }\n // ... rest unchanged\n}\n```\n\n## Files to Modify\n\n- `client.go` - Update `NewClient()` to check for existing `/jsonrpc.php` suffix\n\n## Acceptance Criteria\n\n- [ ] `NewClient(\"https://example.com\")` → endpoint `https://example.com/jsonrpc.php`\n- [ ] `NewClient(\"https://example.com/\")` → endpoint `https://example.com/jsonrpc.php`\n- [ ] `NewClient(\"https://example.com/jsonrpc.php\")` → endpoint `https://example.com/jsonrpc.php`\n- [ ] `NewClient(\"https://example.com/kanboard/jsonrpc.php\")` → endpoint `https://example.com/kanboard/jsonrpc.php`\n- [ ] Tests written and passing","status":"closed","priority":2,"issue_type":"bug","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T10:25:51.077352962+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T10:27:15.189568683+01:00","closed_at":"2026-01-27T10:27:15.189568683+01:00","close_reason":"Closed"} +{"id":"kanboard-amh","title":"Implement Attachment API methods","description":"Add support for Kanboard's task file/attachment API methods.\n\n## API Methods to Implement\n\nBased on [Kanboard Task File Procedures](https://docs.kanboard.org/v1/api/task_file_procedures/):\n\n### 1. createTaskFile\n- **Purpose:** Create and upload a new task attachment\n- **Parameters:** `project_id` (int), `task_id` (int), `filename` (string), `blob` (base64-encoded content)\n- **Returns:** `file_id` on success, `false` on failure\n- **Note:** Limited by PHP config; unsuitable for large files\n\n### 2. getAllTaskFiles\n- **Purpose:** Get all files attached to a task\n- **Parameters:** `task_id` (int)\n- **Returns:** Array of file objects with properties: id, name, path, is_image, task_id, date, user_id, size, username, user_name\n\n### 3. getTaskFile\n- **Purpose:** Get single file metadata\n- **Parameters:** `file_id` (int)\n- **Returns:** File object (id, name, path, is_image, task_id, date, user_id, size)\n\n### 4. downloadTaskFile\n- **Purpose:** Download file contents\n- **Parameters:** `file_id` (int)\n- **Returns:** Base64-encoded string\n\n### 5. removeTaskFile\n- **Purpose:** Delete a file attachment\n- **Parameters:** `file_id` (int)\n- **Returns:** `true` on success, `false` on failure\n\n### 6. removeAllTaskFiles (bonus)\n- **Purpose:** Remove all files from a task\n- **Parameters:** `task_id` (int)\n- **Returns:** `true` on success, `false` on failure\n\n## Acceptance Criteria\n\n- [ ] Implement `CreateTaskFile` method with base64 blob upload\n- [ ] Implement `GetAllTaskFiles` method returning typed file slice\n- [ ] Implement `GetTaskFile` method returning file metadata struct\n- [ ] Implement `DownloadTaskFile` method returning decoded bytes\n- [ ] Implement `RemoveTaskFile` method\n- [ ] Implement `RemoveAllTaskFiles` method\n- [ ] Define `TaskFile` struct with all documented fields\n- [ ] Add comprehensive tests for all methods\n- [ ] Follow existing code patterns in the repository","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T16:06:53.739050479+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T17:01:10.618378834+01:00","closed_at":"2026-01-27T17:01:10.618378834+01:00","close_reason":"Closed"} {"id":"kanboard-api-0fz","title":"Implement Category API methods","description":"Implement direct API methods for category operations.\n\n## Methods to implement\n- GetAllCategories(ctx, projectID int) ([]Category, error) - getAllCategories\n- GetCategory(ctx, categoryID int) (*Category, error) - getCategory (Nice-to-have)\n\n## Files to create\n- categories.go\n\n## Acceptance criteria\n- Proper error handling\n- Returns empty slice when no categories exist","status":"closed","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:16.6133153+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:25:07.250066801+01:00","closed_at":"2026-01-15T18:25:07.250066801+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-0fz","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:53.161416595+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-0fz","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:53.226963473+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-16r","title":"Implement Tag API methods (CRITICAL)","description":"Implement direct API methods for tag operations. Tags are CRITICAL - heavily used.\n\n## Direct Client methods (Must-have)\n- GetTaskTags(ctx, taskID int) (map[int]string, error) - getTaskTags\n- SetTaskTags(ctx, projectID, taskID int, tags []string) error - setTaskTags\n\n## Direct Client methods (Nice-to-have)\n- GetAllTags(ctx) ([]Tag, error) - getAllTags\n- GetTagsByProject(ctx, projectID int) ([]Tag, error) - getTagsByProject\n- CreateTag(ctx, projectID int, name, colorID string) (int, error) - createTag\n- UpdateTag(ctx, tagID int, name, colorID string) error - updateTag\n- RemoveTag(ctx, tagID int) error - removeTag\n\n## Files to create\n- tags.go\n\n## IMPORTANT NOTE\nsetTaskTags REPLACES ALL tags. Individual add/remove requires read-modify-write pattern (implemented in TaskScope).\n\n## Acceptance criteria\n- GetTaskTags returns map[tagID]tagName\n- SetTaskTags accepts tag names (auto-creates if needed)","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:08.526810135+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:20:17.392248254+01:00","closed_at":"2026-01-15T18:20:17.392248254+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-16r","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.223137796+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-16r","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.402237867+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-25y","title":"Implement Timestamp type with JSON handling","description":"Implement custom Timestamp type that handles Kanboard's Unix timestamp format.\n\n## Requirements\n- Timestamp struct wrapping time.Time\n- UnmarshalJSON supporting:\n - Unix timestamps as integers\n - Empty strings and \"0\" as zero time\n - Zero value (0) as zero time\n- MarshalJSON returning Unix timestamp or 0 for zero time\n\n## Files to create\n- timestamp.go\n\n## Acceptance criteria\n- Correctly parses integer Unix timestamps\n- Handles empty strings and \"0\" strings\n- Zero time marshals to 0\n- Non-zero time marshals to Unix timestamp","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:55.0044989+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:15:27.299644047+01:00","closed_at":"2026-01-15T18:15:27.299644047+01:00","close_reason":"Closed"} diff --git a/files.go b/files.go index 5ed3d8f..e419afc 100644 --- a/files.go +++ b/files.go @@ -75,3 +75,35 @@ func (c *Client) RemoveTaskFile(ctx context.Context, fileID int) error { return nil } + +// GetTaskFile returns a single file's metadata by its ID. +func (c *Client) GetTaskFile(ctx context.Context, fileID int) (*TaskFile, error) { + params := map[string]int{"file_id": fileID} + + var result *TaskFile + if err := c.call(ctx, "getTaskFile", params, &result); err != nil { + return nil, fmt.Errorf("getTaskFile: %w", err) + } + + if result == nil { + return nil, fmt.Errorf("%w: file %d", ErrNotFound, fileID) + } + + return result, nil +} + +// RemoveAllTaskFiles removes all files attached to a task. +func (c *Client) RemoveAllTaskFiles(ctx context.Context, taskID int) error { + params := map[string]int{"task_id": taskID} + + var success bool + if err := c.call(ctx, "removeAllTaskFiles", params, &success); err != nil { + return fmt.Errorf("removeAllTaskFiles: %w", err) + } + + if !success { + return fmt.Errorf("removeAllTaskFiles: delete failed") + } + + return nil +} diff --git a/files_test.go b/files_test.go index 027c655..7d6b285 100644 --- a/files_test.go +++ b/files_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -322,3 +323,242 @@ func TestTaskScope_UploadFile(t *testing.T) { t.Errorf("expected fileID=10, got %d", fileID) } } + +func TestClient_GetTaskFile(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method != "getTaskFile" { + t.Errorf("expected method=getTaskFile, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["file_id"].(float64) != 5 { + t.Errorf("expected file_id=5, got %v", params["file_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id": "5", "name": "document.pdf", "path": "files/5", "is_image": "0", "task_id": "42", "user_id": "1", "size": "1024"}`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + file, err := client.GetTaskFile(context.Background(), 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if int(file.ID) != 5 { + t.Errorf("expected id=5, got %d", file.ID) + } + if file.Name != "document.pdf" { + t.Errorf("expected name='document.pdf', got %s", file.Name) + } + if int(file.TaskID) != 42 { + t.Errorf("expected task_id=42, got %d", file.TaskID) + } +} + +func TestClient_GetTaskFile_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`null`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + _, err := client.GetTaskFile(context.Background(), 999) + if err == nil { + t.Fatal("expected error for not found file") + } + if !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound, got %v", err) + } +} + +func TestClient_RemoveAllTaskFiles(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method != "removeAllTaskFiles" { + t.Errorf("expected method=removeAllTaskFiles, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["task_id"].(float64) != 42 { + t.Errorf("expected task_id=42, got %v", params["task_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`true`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + err := client.RemoveAllTaskFiles(context.Background(), 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_RemoveAllTaskFiles_Failure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`false`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + err := client.RemoveAllTaskFiles(context.Background(), 42) + if err == nil { + t.Fatal("expected error for failed delete") + } +} + +func TestTaskScope_GetFile(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method != "getTaskFile" { + t.Errorf("expected method=getTaskFile, got %s", req.Method) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id": "5", "name": "file.txt", "task_id": "42", "is_image": "0"}`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + file, err := client.Task(42).GetFile(context.Background(), 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if int(file.ID) != 5 { + t.Errorf("expected id=5, got %d", file.ID) + } +} + +func TestTaskScope_DownloadFile(t *testing.T) { + testContent := []byte("file content") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method != "downloadTaskFile" { + t.Errorf("expected method=downloadTaskFile, got %s", req.Method) + } + + encoded := base64.StdEncoding.EncodeToString(testContent) + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`"` + encoded + `"`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + content, err := client.Task(42).DownloadFile(context.Background(), 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if string(content) != string(testContent) { + t.Errorf("expected content='%s', got '%s'", testContent, content) + } +} + +func TestTaskScope_RemoveFile(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method != "removeTaskFile" { + t.Errorf("expected method=removeTaskFile, got %s", req.Method) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`true`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + err := client.Task(42).RemoveFile(context.Background(), 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestTaskScope_RemoveAllFiles(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method != "removeAllTaskFiles" { + t.Errorf("expected method=removeAllTaskFiles, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["task_id"].(float64) != 42 { + t.Errorf("expected task_id=42, got %v", params["task_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`true`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + err := client.Task(42).RemoveAllFiles(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/task_scope.go b/task_scope.go index 446c8db..32e150a 100644 --- a/task_scope.go +++ b/task_scope.go @@ -281,3 +281,23 @@ func (t *TaskScope) UploadFile(ctx context.Context, filename string, content []b } return t.client.CreateTaskFile(ctx, int(task.ProjectID), t.taskID, filename, content) } + +// GetFile returns a file's metadata by ID. +func (t *TaskScope) GetFile(ctx context.Context, fileID int) (*TaskFile, error) { + return t.client.GetTaskFile(ctx, fileID) +} + +// RemoveFile removes a file by ID. +func (t *TaskScope) RemoveFile(ctx context.Context, fileID int) error { + return t.client.RemoveTaskFile(ctx, fileID) +} + +// DownloadFile downloads a file's content by ID. +func (t *TaskScope) DownloadFile(ctx context.Context, fileID int) ([]byte, error) { + return t.client.DownloadTaskFile(ctx, fileID) +} + +// RemoveAllFiles removes all files from this task. +func (t *TaskScope) RemoveAllFiles(ctx context.Context) error { + return t.client.RemoveAllTaskFiles(ctx, t.taskID) +} diff --git a/types.go b/types.go index c729dae..7c330f0 100644 --- a/types.go +++ b/types.go @@ -237,6 +237,8 @@ type TaskFile struct { DateCreation Timestamp `json:"date_creation"` UserID StringInt `json:"user_id"` Size StringInt64 `json:"size"` + Username string `json:"username"` // Only returned by getAllTaskFiles + UserName string `json:"user_name"` // Only returned by getAllTaskFiles } // Tag represents a Kanboard tag. From 5fd38721b54abc66f72f4e0060621e4b753b5f1b Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Tue, 27 Jan 2026 17:01:37 +0100 Subject: [PATCH 02/10] chore: update beads export state --- .beads/export-state/fbbdf412d0fd5173.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.beads/export-state/fbbdf412d0fd5173.json b/.beads/export-state/fbbdf412d0fd5173.json index dc11523..1bfd4a5 100644 --- a/.beads/export-state/fbbdf412d0fd5173.json +++ b/.beads/export-state/fbbdf412d0fd5173.json @@ -1,6 +1,6 @@ { "worktree_root": "/home/oli/Dev/kanboard-api", - "last_export_commit": "6f2f0a10fc517c5bd882dcd188e15b47affeaca1", - "last_export_time": "2026-01-27T12:13:35.241832799+01:00", - "jsonl_hash": "a60a9c8ba481e1eb8a3879ae02ed3fadda20082c4f0a618fe05f2c1c58b02e43" + "last_export_commit": "280fff21a3f7074b4bd718e2ce0da00c1c2234fb", + "last_export_time": "2026-01-27T17:01:38.094910929+01:00", + "jsonl_hash": "62f8bcd2856e254f557b1b30985e77a4e3d8ab80968fa1dec1f5d5dbd1ea6ce8" } \ No newline at end of file From bc478b44e04e538e3b2cc214bd14e158bfd86b81 Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Wed, 28 Jan 2026 12:09:06 +0100 Subject: [PATCH 03/10] feat: add DateMoved timestamp field to Task struct Adds the date_moved field to capture when a task was last moved between columns or swimlanes in Kanboard. --- .beads/issues.jsonl | 1 + types.go | 1 + 2 files changed, 2 insertions(+) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 5f20bf0..4725efc 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,6 @@ {"id":"kanboard-1cb","title":"Investigate MoveTaskPosition generic error message","description":"## Context\n\nThis is an investigation task to determine if a problem in the `hqcli` tool (which uses this library) has its root cause here.\n\nThe `kb ticket move` command in hqcli fails with an unhelpful error:\n\n```\n❯ dist/hqcli kb ticket move 3765\n2026/01/27 11:02:43 Fehler beim Verschieben des Tickets: moveTaskPosition: failed to move task 3765\n```\n\n## Library Behavior\n\nLooking at `tasks.go:131-150`, the `MoveTaskPosition` function:\n\n```go\nfunc (c *Client) MoveTaskPosition(...) error {\n // ...\n var success bool\n if err := c.call(ctx, \"moveTaskPosition\", params, \u0026success); err != nil {\n return fmt.Errorf(\"moveTaskPosition: %w\", err)\n }\n\n if !success {\n return fmt.Errorf(\"moveTaskPosition: failed to move task %d\", taskID) // \u003c-- generic error\n }\n return nil\n}\n```\n\nWhen the API returns `false`, the error message is completely generic with no actionable information.\n\n## Investigation Needed\n\n1. **API Response Analysis**: What does Kanboard actually return when `moveTaskPosition` fails?\n - Does it include an error message in the JSON-RPC response?\n - Is the `false` result the only indication of failure?\n\n2. **Possible Causes for API returning false**:\n - Invalid column/project combination\n - Permission issues\n - Task doesn't exist\n - Position 0 might have different semantics than documented\n\n3. **Documentation Check**: [Kanboard API docs](https://docs.kanboard.org/v1/api/task_procedures/#movetaskposition)\n\n## Potential Improvements\n\nIf the API provides no additional details, consider:\n- Adding debug logging to show the full API response\n- Checking if the task/column/project exist before calling\n- Providing a more descriptive error: \"moveTaskPosition returned false - verify task exists, column belongs to project, and user has permission\"\n\n## Acceptance Criteria\n\n- [ ] Root cause identified (is it library issue or hqcli usage issue?)\n- [ ] If library issue: improve error message or error handling\n- [ ] Document findings","status":"closed","priority":2,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T11:05:16.089428488+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T11:11:58.26724838+01:00","closed_at":"2026-01-27T11:11:58.26724838+01:00","close_reason":"Closed"} {"id":"kanboard-1ov","title":"Add GetColorList for available task colors","description":"## Context\n\nTask colors are already partially supported in the library:\n- `ColorID` field exists in the `Task` type\n- `WithColor()` works for task creation via `TaskParams`\n- `SetColor()` works for task updates via `TaskUpdateParams`\n\nHowever, there's no way to query the available colors from the Kanboard instance.\n\n## Missing Feature\n\nThe Kanboard API provides `getColorList` in the [Application Procedures](https://docs.kanboard.org/v1/api/application_procedures/):\n\n```\nRequest:\n{\"jsonrpc\": \"2.0\", \"method\": \"getColorList\", \"id\": 1}\n\nResponse:\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"result\": {\n \"yellow\": \"Yellow\",\n \"blue\": \"Blue\",\n \"green\": \"Green\",\n ...\n }\n}\n```\n\n## Implementation\n\nCreate a new file `application.go` with:\n\n```go\n// GetColorList returns the available task colors.\n// Returns a map of color_id to display name.\nfunc (c *Client) GetColorList(ctx context.Context) (map[string]string, error) {\n var result map[string]string\n if err := c.call(ctx, \"getColorList\", nil, \u0026result); err != nil {\n return nil, fmt.Errorf(\"getColorList: %w\", err)\n }\n return result, nil\n}\n```\n\n## Files to Create/Modify\n\n- `application.go` - New file with `GetColorList()`\n- `application_test.go` - Tests for the new function\n\n## Acceptance Criteria\n\n- [ ] `GetColorList()` returns map of color_id to display name\n- [ ] Works with standard Kanboard color set\n- [ ] Tests written and passing","status":"closed","priority":3,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T12:11:00.218309278+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T12:13:06.411067375+01:00","closed_at":"2026-01-27T12:13:06.411067375+01:00","close_reason":"Closed"} +{"id":"kanboard-3pe","title":"Add DateMoved timestamp field to Task struct","description":"The Task struct in `types.go` is missing the `date_moved` timestamp that Kanboard returns when a task is moved between columns or swimlanes.\n\n## Background\n\nKanboard tracks when a task was last moved with the `date_moved` field. This timestamp is returned in API responses but is currently not captured by the Go client's Task struct.\n\n## Implementation\n\nAdd the following field to the Task struct in `types.go`:\n\n```go\nDateMoved Timestamp `json:\"date_moved\"`\n```\n\nThis should be added alongside the other date fields (after `DateDue` for consistency with the grouping of timestamp fields).\n\n## Acceptance Criteria\n\n- [ ] `DateMoved` field added to `Task` struct in `types.go`\n- [ ] Field uses `Timestamp` type with JSON tag `json:\"date_moved\"`\n- [ ] All existing tests pass\n- [ ] Field is properly handled in all task-related operations (read/unmarshal)","status":"closed","priority":2,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T12:05:50.949698791+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T12:09:01.530912406+01:00","closed_at":"2026-01-28T12:09:01.530912406+01:00","close_reason":"Closed"} {"id":"kanboard-7es","title":"JSON-RPC Request-ID: Zufälligen Wert statt fester 1 verwenden","description":"## Kontext\n\nIn jedem JSON-RPC Request an die Kanboard-API wird im Root-Objekt ein Feld `id` mitgeliefert. Dieses dient dazu, bei asynchroner Kommunikation Request und Response einander zuordnen zu können – die API liefert diese ID in der Antwort zurück.\n\n**Aktuell:** Die ID ist fest auf `1` gesetzt.\n\n## Anforderung\n\n1. **Wenn keine ID von außen gesetzt wird:** Die Library soll intern einen zufälligen Wert generieren\n2. **API-Dokumentation prüfen:** Welche Werte sind erlaubt? Welche Größenordnung? (vermutlich Integer)\n3. **Signatur beibehalten:** Die öffentliche API der Library-Funktionen soll unverändert bleiben\n4. **Interne Generierung:** Die Library bestimmt selbst einen zufälligen Wert\n\n## Implementierungshinweise\n\n- Prüfen: Kanboard JSON-RPC Dokumentation bezüglich erlaubter ID-Werte\n- Vermutlich: `int64` oder `int32` Range\n- Zufallsgenerator: `math/rand` mit Seed oder `crypto/rand` für bessere Verteilung\n- Ggf. bestehende `requestIDCounter` in `jsonrpc.go` (Zeile 40) anpassen oder ersetzen\n\n## Beispiel\n\n**Vorher (immer gleich):**\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"getTask\", \"id\": 1, \"params\": {...}}\n```\n\n**Nachher (zufällig):**\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"getTask\", \"id\": 847291536, \"params\": {...}}\n```\n\n## Referenz\n\n- Datei: `jsonrpc.go`\n- Zeile 17: `ID int64 \\`json:\"id\"\\``\n- Zeile 40: `requestIDCounter` (existiert bereits)","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-23T17:44:51.566737509+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T10:21:06.415129879+01:00","closed_at":"2026-01-27T10:21:06.415129879+01:00","close_reason":"Closed"} {"id":"kanboard-9wa","title":"Support custom authentication header name","description":"## Description\n\nKanboard supports using an alternative HTTP header for authentication when the server has specific configuration requirements.\n\nCurrently, authentication uses the standard `Authorization` header via Go's `SetBasicAuth()`. This needs to be configurable so users can specify a custom header name (e.g., `X-API-Auth`).\n\n## Requirements\n\n- Add an optional configuration parameter for custom auth header name\n- Default to standard `Authorization` header if not specified\n- When custom header is set, use that header name instead of `Authorization`\n- The header value format should remain the same (Basic Auth base64-encoded credentials)\n\n## Acceptance Criteria\n\n- [ ] New client configuration method (e.g., `WithAuthHeader(headerName string)`)\n- [ ] Default behavior unchanged when no custom header specified\n- [ ] Custom header name is used when configured\n- [ ] Works with both API token and basic auth\n- [ ] Tests cover default and custom header scenarios\n\n## Reference\n\nKanboard API documentation: \"You can use an alternative HTTP header for authentication if your server has a very specific configuration.\"","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-23T18:08:31.507616093+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-23T18:26:50.40804952+01:00","closed_at":"2026-01-23T18:26:50.40804952+01:00","close_reason":"Closed"} {"id":"kanboard-a7x","title":"Handle URLs already ending in /jsonrpc.php","description":"## Context\n\nThe `NewClient()` function in `client.go` always appends `/jsonrpc.php` to the base URL. This causes issues when users pass a URL that already includes the endpoint path.\n\n## Problem\n\nIf a user calls:\n```go\nclient := kanboard.NewClient(\"https://example.com/jsonrpc.php\")\n```\n\nThe resulting endpoint becomes `https://example.com/jsonrpc.php/jsonrpc.php`, which fails.\n\n## Solution\n\nModify `NewClient()` to detect and handle URLs that already end in `/jsonrpc.php`:\n\n```go\nfunc NewClient(baseURL string) *Client {\n // Ensure no trailing slash\n baseURL = strings.TrimSuffix(baseURL, \"/\")\n\n // Handle URLs that already include /jsonrpc.php\n endpoint := baseURL\n if !strings.HasSuffix(baseURL, \"/jsonrpc.php\") {\n endpoint = baseURL + \"/jsonrpc.php\"\n }\n\n c := \u0026Client{\n baseURL: baseURL,\n endpoint: endpoint,\n }\n // ... rest unchanged\n}\n```\n\n## Files to Modify\n\n- `client.go` - Update `NewClient()` to check for existing `/jsonrpc.php` suffix\n\n## Acceptance Criteria\n\n- [ ] `NewClient(\"https://example.com\")` → endpoint `https://example.com/jsonrpc.php`\n- [ ] `NewClient(\"https://example.com/\")` → endpoint `https://example.com/jsonrpc.php`\n- [ ] `NewClient(\"https://example.com/jsonrpc.php\")` → endpoint `https://example.com/jsonrpc.php`\n- [ ] `NewClient(\"https://example.com/kanboard/jsonrpc.php\")` → endpoint `https://example.com/kanboard/jsonrpc.php`\n- [ ] Tests written and passing","status":"closed","priority":2,"issue_type":"bug","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T10:25:51.077352962+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T10:27:15.189568683+01:00","closed_at":"2026-01-27T10:27:15.189568683+01:00","close_reason":"Closed"} diff --git a/types.go b/types.go index 7c330f0..72d7c80 100644 --- a/types.go +++ b/types.go @@ -165,6 +165,7 @@ type Task struct { DateCompleted Timestamp `json:"date_completed"` DateStarted Timestamp `json:"date_started"` DateDue Timestamp `json:"date_due"` + DateMoved Timestamp `json:"date_moved"` ColorID string `json:"color_id"` ProjectID StringInt `json:"project_id"` ColumnID StringInt `json:"column_id"` From a381c0dc51d778382edd71d5a406199e0a5a532b Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Wed, 28 Jan 2026 12:09:14 +0100 Subject: [PATCH 04/10] chore: update beads export state --- .beads/export-state/fbbdf412d0fd5173.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.beads/export-state/fbbdf412d0fd5173.json b/.beads/export-state/fbbdf412d0fd5173.json index 1bfd4a5..fe53c34 100644 --- a/.beads/export-state/fbbdf412d0fd5173.json +++ b/.beads/export-state/fbbdf412d0fd5173.json @@ -1,6 +1,6 @@ { "worktree_root": "/home/oli/Dev/kanboard-api", - "last_export_commit": "280fff21a3f7074b4bd718e2ce0da00c1c2234fb", - "last_export_time": "2026-01-27T17:01:38.094910929+01:00", - "jsonl_hash": "62f8bcd2856e254f557b1b30985e77a4e3d8ab80968fa1dec1f5d5dbd1ea6ce8" + "last_export_commit": "5fd38721b54abc66f72f4e0060621e4b753b5f1b", + "last_export_time": "2026-01-28T12:09:06.494497056+01:00", + "jsonl_hash": "e0d7c3244033c584f1bf6c013c22b66723e2595dcbe1bf20da7deb9ed6f3e68d" } \ No newline at end of file From 12c1660d94b1daa7d0e4407480a0d3d75f5816a8 Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Wed, 28 Jan 2026 12:09:21 +0100 Subject: [PATCH 05/10] chore: update beads export state --- .beads/export-state/fbbdf412d0fd5173.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.beads/export-state/fbbdf412d0fd5173.json b/.beads/export-state/fbbdf412d0fd5173.json index fe53c34..484555e 100644 --- a/.beads/export-state/fbbdf412d0fd5173.json +++ b/.beads/export-state/fbbdf412d0fd5173.json @@ -1,6 +1,6 @@ { "worktree_root": "/home/oli/Dev/kanboard-api", - "last_export_commit": "5fd38721b54abc66f72f4e0060621e4b753b5f1b", - "last_export_time": "2026-01-28T12:09:06.494497056+01:00", + "last_export_commit": "bc478b44e04e538e3b2cc214bd14e158bfd86b81", + "last_export_time": "2026-01-28T12:09:14.966945595+01:00", "jsonl_hash": "e0d7c3244033c584f1bf6c013c22b66723e2595dcbe1bf20da7deb9ed6f3e68d" } \ No newline at end of file From 56da2e0fdce53822c22eafe039443aa15cd6e9c7 Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Wed, 28 Jan 2026 12:09:27 +0100 Subject: [PATCH 06/10] chore: update beads export state --- .beads/export-state/fbbdf412d0fd5173.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.beads/export-state/fbbdf412d0fd5173.json b/.beads/export-state/fbbdf412d0fd5173.json index 484555e..e366f3e 100644 --- a/.beads/export-state/fbbdf412d0fd5173.json +++ b/.beads/export-state/fbbdf412d0fd5173.json @@ -1,6 +1,6 @@ { "worktree_root": "/home/oli/Dev/kanboard-api", - "last_export_commit": "bc478b44e04e538e3b2cc214bd14e158bfd86b81", - "last_export_time": "2026-01-28T12:09:14.966945595+01:00", + "last_export_commit": "a381c0dc51d778382edd71d5a406199e0a5a532b", + "last_export_time": "2026-01-28T12:09:21.47967978+01:00", "jsonl_hash": "e0d7c3244033c584f1bf6c013c22b66723e2595dcbe1bf20da7deb9ed6f3e68d" } \ No newline at end of file From 4e856cd206ba4e7e09d30d0f4892972df87db4a6 Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Thu, 29 Jan 2026 09:20:13 +0100 Subject: [PATCH 07/10] feat: add category write operations (create, update, remove, get-by-name) Add CreateCategory, UpdateCategory, RemoveCategory, and GetCategoryByName methods to the client, with BoardScope wrappers for project-scoped operations. Includes comprehensive tests for all new methods. Closes kanboard-4n3 --- .beads/export-state/fbbdf412d0fd5173.json | 4 +- .beads/issues.jsonl | 1 + board_scope.go | 10 + categories.go | 77 +++++++ categories_test.go | 260 ++++++++++++++++++++++ 5 files changed, 350 insertions(+), 2 deletions(-) diff --git a/.beads/export-state/fbbdf412d0fd5173.json b/.beads/export-state/fbbdf412d0fd5173.json index e366f3e..d7e5028 100644 --- a/.beads/export-state/fbbdf412d0fd5173.json +++ b/.beads/export-state/fbbdf412d0fd5173.json @@ -1,6 +1,6 @@ { "worktree_root": "/home/oli/Dev/kanboard-api", - "last_export_commit": "a381c0dc51d778382edd71d5a406199e0a5a532b", - "last_export_time": "2026-01-28T12:09:21.47967978+01:00", + "last_export_commit": "12c1660d94b1daa7d0e4407480a0d3d75f5816a8", + "last_export_time": "2026-01-28T12:09:27.757995829+01:00", "jsonl_hash": "e0d7c3244033c584f1bf6c013c22b66723e2595dcbe1bf20da7deb9ed6f3e68d" } \ No newline at end of file diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 4725efc..7579470 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,6 +1,7 @@ {"id":"kanboard-1cb","title":"Investigate MoveTaskPosition generic error message","description":"## Context\n\nThis is an investigation task to determine if a problem in the `hqcli` tool (which uses this library) has its root cause here.\n\nThe `kb ticket move` command in hqcli fails with an unhelpful error:\n\n```\n❯ dist/hqcli kb ticket move 3765\n2026/01/27 11:02:43 Fehler beim Verschieben des Tickets: moveTaskPosition: failed to move task 3765\n```\n\n## Library Behavior\n\nLooking at `tasks.go:131-150`, the `MoveTaskPosition` function:\n\n```go\nfunc (c *Client) MoveTaskPosition(...) error {\n // ...\n var success bool\n if err := c.call(ctx, \"moveTaskPosition\", params, \u0026success); err != nil {\n return fmt.Errorf(\"moveTaskPosition: %w\", err)\n }\n\n if !success {\n return fmt.Errorf(\"moveTaskPosition: failed to move task %d\", taskID) // \u003c-- generic error\n }\n return nil\n}\n```\n\nWhen the API returns `false`, the error message is completely generic with no actionable information.\n\n## Investigation Needed\n\n1. **API Response Analysis**: What does Kanboard actually return when `moveTaskPosition` fails?\n - Does it include an error message in the JSON-RPC response?\n - Is the `false` result the only indication of failure?\n\n2. **Possible Causes for API returning false**:\n - Invalid column/project combination\n - Permission issues\n - Task doesn't exist\n - Position 0 might have different semantics than documented\n\n3. **Documentation Check**: [Kanboard API docs](https://docs.kanboard.org/v1/api/task_procedures/#movetaskposition)\n\n## Potential Improvements\n\nIf the API provides no additional details, consider:\n- Adding debug logging to show the full API response\n- Checking if the task/column/project exist before calling\n- Providing a more descriptive error: \"moveTaskPosition returned false - verify task exists, column belongs to project, and user has permission\"\n\n## Acceptance Criteria\n\n- [ ] Root cause identified (is it library issue or hqcli usage issue?)\n- [ ] If library issue: improve error message or error handling\n- [ ] Document findings","status":"closed","priority":2,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T11:05:16.089428488+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T11:11:58.26724838+01:00","closed_at":"2026-01-27T11:11:58.26724838+01:00","close_reason":"Closed"} {"id":"kanboard-1ov","title":"Add GetColorList for available task colors","description":"## Context\n\nTask colors are already partially supported in the library:\n- `ColorID` field exists in the `Task` type\n- `WithColor()` works for task creation via `TaskParams`\n- `SetColor()` works for task updates via `TaskUpdateParams`\n\nHowever, there's no way to query the available colors from the Kanboard instance.\n\n## Missing Feature\n\nThe Kanboard API provides `getColorList` in the [Application Procedures](https://docs.kanboard.org/v1/api/application_procedures/):\n\n```\nRequest:\n{\"jsonrpc\": \"2.0\", \"method\": \"getColorList\", \"id\": 1}\n\nResponse:\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"result\": {\n \"yellow\": \"Yellow\",\n \"blue\": \"Blue\",\n \"green\": \"Green\",\n ...\n }\n}\n```\n\n## Implementation\n\nCreate a new file `application.go` with:\n\n```go\n// GetColorList returns the available task colors.\n// Returns a map of color_id to display name.\nfunc (c *Client) GetColorList(ctx context.Context) (map[string]string, error) {\n var result map[string]string\n if err := c.call(ctx, \"getColorList\", nil, \u0026result); err != nil {\n return nil, fmt.Errorf(\"getColorList: %w\", err)\n }\n return result, nil\n}\n```\n\n## Files to Create/Modify\n\n- `application.go` - New file with `GetColorList()`\n- `application_test.go` - Tests for the new function\n\n## Acceptance Criteria\n\n- [ ] `GetColorList()` returns map of color_id to display name\n- [ ] Works with standard Kanboard color set\n- [ ] Tests written and passing","status":"closed","priority":3,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T12:11:00.218309278+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T12:13:06.411067375+01:00","closed_at":"2026-01-27T12:13:06.411067375+01:00","close_reason":"Closed"} {"id":"kanboard-3pe","title":"Add DateMoved timestamp field to Task struct","description":"The Task struct in `types.go` is missing the `date_moved` timestamp that Kanboard returns when a task is moved between columns or swimlanes.\n\n## Background\n\nKanboard tracks when a task was last moved with the `date_moved` field. This timestamp is returned in API responses but is currently not captured by the Go client's Task struct.\n\n## Implementation\n\nAdd the following field to the Task struct in `types.go`:\n\n```go\nDateMoved Timestamp `json:\"date_moved\"`\n```\n\nThis should be added alongside the other date fields (after `DateDue` for consistency with the grouping of timestamp fields).\n\n## Acceptance Criteria\n\n- [ ] `DateMoved` field added to `Task` struct in `types.go`\n- [ ] Field uses `Timestamp` type with JSON tag `json:\"date_moved\"`\n- [ ] All existing tests pass\n- [ ] Field is properly handled in all task-related operations (read/unmarshal)","status":"closed","priority":2,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T12:05:50.949698791+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T12:09:01.530912406+01:00","closed_at":"2026-01-28T12:09:01.530912406+01:00","close_reason":"Closed"} +{"id":"kanboard-4n3","title":"Implement category write operations","description":"Implement the missing category write operations from the Kanboard API: `createCategory`, `updateCategory`, and `removeCategory`. Additionally, add a client-side `getCategoryByName` helper.\n\nReference: https://docs.kanboard.org/v1/api/category_procedures/\n\n## Details\n\nCurrently only read operations exist (`GetAllCategories`, `GetCategory` in `categories.go`). The following need to be added:\n\n### createCategory\n- Parameters: `project_id` (int), `name` (string), optional `color_id` (string)\n- Returns: category ID on success\n\n### updateCategory\n- Parameters: `id` (int), `name` (string), optional `color_id` (string)\n- Returns: bool\n\n### removeCategory\n- Parameters: `category_id` (int)\n- Returns: bool\n\n### getCategoryByName (client-side helper)\n- Since `getAllCategories` returns the full array, implement `GetCategoryByName(projectID, name)` as a client-side filter over `GetAllCategories` rather than a separate RPC call (unless a server-side procedure exists).\n- Return `ErrCategoryNotFound` if no match.\n\n## Acceptance Criteria\n- [ ] `CreateCategory(projectID, name, opts...)` implemented with functional options for color_id\n- [ ] `UpdateCategory(id, name, opts...)` implemented with functional options for color_id\n- [ ] `RemoveCategory(categoryID)` implemented\n- [ ] `GetCategoryByName(projectID, name)` implemented (client-side filter)\n- [ ] All new methods have corresponding unit tests in `categories_test.go`\n- [ ] BoardScope wrappers added where appropriate\n- [ ] Follow existing code patterns and conventions from the project","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-29T09:12:59.169475651+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-29T09:20:09.394074185+01:00","closed_at":"2026-01-29T09:20:09.394074185+01:00","close_reason":"Closed"} {"id":"kanboard-7es","title":"JSON-RPC Request-ID: Zufälligen Wert statt fester 1 verwenden","description":"## Kontext\n\nIn jedem JSON-RPC Request an die Kanboard-API wird im Root-Objekt ein Feld `id` mitgeliefert. Dieses dient dazu, bei asynchroner Kommunikation Request und Response einander zuordnen zu können – die API liefert diese ID in der Antwort zurück.\n\n**Aktuell:** Die ID ist fest auf `1` gesetzt.\n\n## Anforderung\n\n1. **Wenn keine ID von außen gesetzt wird:** Die Library soll intern einen zufälligen Wert generieren\n2. **API-Dokumentation prüfen:** Welche Werte sind erlaubt? Welche Größenordnung? (vermutlich Integer)\n3. **Signatur beibehalten:** Die öffentliche API der Library-Funktionen soll unverändert bleiben\n4. **Interne Generierung:** Die Library bestimmt selbst einen zufälligen Wert\n\n## Implementierungshinweise\n\n- Prüfen: Kanboard JSON-RPC Dokumentation bezüglich erlaubter ID-Werte\n- Vermutlich: `int64` oder `int32` Range\n- Zufallsgenerator: `math/rand` mit Seed oder `crypto/rand` für bessere Verteilung\n- Ggf. bestehende `requestIDCounter` in `jsonrpc.go` (Zeile 40) anpassen oder ersetzen\n\n## Beispiel\n\n**Vorher (immer gleich):**\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"getTask\", \"id\": 1, \"params\": {...}}\n```\n\n**Nachher (zufällig):**\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"getTask\", \"id\": 847291536, \"params\": {...}}\n```\n\n## Referenz\n\n- Datei: `jsonrpc.go`\n- Zeile 17: `ID int64 \\`json:\"id\"\\``\n- Zeile 40: `requestIDCounter` (existiert bereits)","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-23T17:44:51.566737509+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T10:21:06.415129879+01:00","closed_at":"2026-01-27T10:21:06.415129879+01:00","close_reason":"Closed"} {"id":"kanboard-9wa","title":"Support custom authentication header name","description":"## Description\n\nKanboard supports using an alternative HTTP header for authentication when the server has specific configuration requirements.\n\nCurrently, authentication uses the standard `Authorization` header via Go's `SetBasicAuth()`. This needs to be configurable so users can specify a custom header name (e.g., `X-API-Auth`).\n\n## Requirements\n\n- Add an optional configuration parameter for custom auth header name\n- Default to standard `Authorization` header if not specified\n- When custom header is set, use that header name instead of `Authorization`\n- The header value format should remain the same (Basic Auth base64-encoded credentials)\n\n## Acceptance Criteria\n\n- [ ] New client configuration method (e.g., `WithAuthHeader(headerName string)`)\n- [ ] Default behavior unchanged when no custom header specified\n- [ ] Custom header name is used when configured\n- [ ] Works with both API token and basic auth\n- [ ] Tests cover default and custom header scenarios\n\n## Reference\n\nKanboard API documentation: \"You can use an alternative HTTP header for authentication if your server has a very specific configuration.\"","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-23T18:08:31.507616093+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-23T18:26:50.40804952+01:00","closed_at":"2026-01-23T18:26:50.40804952+01:00","close_reason":"Closed"} {"id":"kanboard-a7x","title":"Handle URLs already ending in /jsonrpc.php","description":"## Context\n\nThe `NewClient()` function in `client.go` always appends `/jsonrpc.php` to the base URL. This causes issues when users pass a URL that already includes the endpoint path.\n\n## Problem\n\nIf a user calls:\n```go\nclient := kanboard.NewClient(\"https://example.com/jsonrpc.php\")\n```\n\nThe resulting endpoint becomes `https://example.com/jsonrpc.php/jsonrpc.php`, which fails.\n\n## Solution\n\nModify `NewClient()` to detect and handle URLs that already end in `/jsonrpc.php`:\n\n```go\nfunc NewClient(baseURL string) *Client {\n // Ensure no trailing slash\n baseURL = strings.TrimSuffix(baseURL, \"/\")\n\n // Handle URLs that already include /jsonrpc.php\n endpoint := baseURL\n if !strings.HasSuffix(baseURL, \"/jsonrpc.php\") {\n endpoint = baseURL + \"/jsonrpc.php\"\n }\n\n c := \u0026Client{\n baseURL: baseURL,\n endpoint: endpoint,\n }\n // ... rest unchanged\n}\n```\n\n## Files to Modify\n\n- `client.go` - Update `NewClient()` to check for existing `/jsonrpc.php` suffix\n\n## Acceptance Criteria\n\n- [ ] `NewClient(\"https://example.com\")` → endpoint `https://example.com/jsonrpc.php`\n- [ ] `NewClient(\"https://example.com/\")` → endpoint `https://example.com/jsonrpc.php`\n- [ ] `NewClient(\"https://example.com/jsonrpc.php\")` → endpoint `https://example.com/jsonrpc.php`\n- [ ] `NewClient(\"https://example.com/kanboard/jsonrpc.php\")` → endpoint `https://example.com/kanboard/jsonrpc.php`\n- [ ] Tests written and passing","status":"closed","priority":2,"issue_type":"bug","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T10:25:51.077352962+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T10:27:15.189568683+01:00","closed_at":"2026-01-27T10:27:15.189568683+01:00","close_reason":"Closed"} diff --git a/board_scope.go b/board_scope.go index 64c88e0..eced5b9 100644 --- a/board_scope.go +++ b/board_scope.go @@ -43,6 +43,16 @@ func (b *BoardScope) CreateTask(ctx context.Context, req CreateTaskRequest) (*Ta return b.client.CreateTask(ctx, req) } +// CreateCategory creates a new category in the project and returns its ID. +func (b *BoardScope) CreateCategory(ctx context.Context, name string, colorID string) (int, error) { + return b.client.CreateCategory(ctx, b.projectID, name, colorID) +} + +// GetCategoryByName returns a category by name within the project. +func (b *BoardScope) GetCategoryByName(ctx context.Context, name string) (*Category, error) { + return b.client.GetCategoryByName(ctx, b.projectID, name) +} + // CreateTaskFromParams creates a new task in the project using TaskParams. // This provides a fluent interface for task creation. func (b *BoardScope) CreateTaskFromParams(ctx context.Context, params *TaskParams) (*Task, error) { diff --git a/categories.go b/categories.go index dab2f91..a664cb6 100644 --- a/categories.go +++ b/categories.go @@ -33,3 +33,80 @@ func (c *Client) GetCategory(ctx context.Context, categoryID int) (*Category, er return result, nil } + +// CreateCategory creates a new category and returns its ID. +func (c *Client) CreateCategory(ctx context.Context, projectID int, name string, colorID string) (int, error) { + params := map[string]interface{}{ + "project_id": projectID, + "name": name, + } + if colorID != "" { + params["color_id"] = colorID + } + + var result IntOrFalse + if err := c.call(ctx, "createCategory", params, &result); err != nil { + return 0, fmt.Errorf("createCategory: %w", err) + } + + if int(result) == 0 { + return 0, fmt.Errorf("createCategory: failed to create category %q", name) + } + + return int(result), nil +} + +// UpdateCategory updates a category's name and optionally its color. +func (c *Client) UpdateCategory(ctx context.Context, categoryID int, name string, colorID string) error { + params := map[string]interface{}{ + "id": categoryID, + "name": name, + } + if colorID != "" { + params["color_id"] = colorID + } + + var result bool + if err := c.call(ctx, "updateCategory", params, &result); err != nil { + return fmt.Errorf("updateCategory: %w", err) + } + + if !result { + return fmt.Errorf("updateCategory: failed to update category %d", categoryID) + } + + return nil +} + +// RemoveCategory deletes a category. +func (c *Client) RemoveCategory(ctx context.Context, categoryID int) error { + params := map[string]int{"category_id": categoryID} + + var result bool + if err := c.call(ctx, "removeCategory", params, &result); err != nil { + return fmt.Errorf("removeCategory: %w", err) + } + + if !result { + return fmt.Errorf("removeCategory: failed to remove category %d", categoryID) + } + + return nil +} + +// GetCategoryByName returns a category by name within a project. +// Returns ErrCategoryNotFound if no category matches. +func (c *Client) GetCategoryByName(ctx context.Context, projectID int, name string) (*Category, error) { + categories, err := c.GetAllCategories(ctx, projectID) + if err != nil { + return nil, err + } + + for i := range categories { + if categories[i].Name == name { + return &categories[i], nil + } + } + + return nil, fmt.Errorf("%w: category %q in project %d", ErrCategoryNotFound, name, projectID) +} diff --git a/categories_test.go b/categories_test.go index 2565836..ab3737b 100644 --- a/categories_test.go +++ b/categories_test.go @@ -148,6 +148,266 @@ func TestClient_GetCategory_NotFound(t *testing.T) { } } +func TestClient_CreateCategory(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method != "createCategory" { + t.Errorf("expected method=createCategory, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["project_id"].(float64) != 1 { + t.Errorf("expected project_id=1, got %v", params["project_id"]) + } + if params["name"].(string) != "Bug" { + t.Errorf("expected name=Bug, got %v", params["name"]) + } + if params["color_id"].(string) != "red" { + t.Errorf("expected color_id=red, got %v", params["color_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`42`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + id, err := client.CreateCategory(context.Background(), 1, "Bug", "red") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != 42 { + t.Errorf("expected id=42, got %d", id) + } +} + +func TestClient_CreateCategory_NoColor(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + params := req.Params.(map[string]any) + if _, ok := params["color_id"]; ok { + t.Error("expected color_id to be absent") + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`10`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + id, err := client.CreateCategory(context.Background(), 1, "Bug", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != 10 { + t.Errorf("expected id=10, got %d", id) + } +} + +func TestClient_CreateCategory_Failure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`false`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + _, err := client.CreateCategory(context.Background(), 1, "Bug", "") + if err == nil { + t.Fatal("expected error on failure") + } +} + +func TestClient_UpdateCategory(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method != "updateCategory" { + t.Errorf("expected method=updateCategory, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["id"].(float64) != 5 { + t.Errorf("expected id=5, got %v", params["id"]) + } + if params["name"].(string) != "Feature" { + t.Errorf("expected name=Feature, got %v", params["name"]) + } + if params["color_id"].(string) != "blue" { + t.Errorf("expected color_id=blue, got %v", params["color_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`true`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + err := client.UpdateCategory(context.Background(), 5, "Feature", "blue") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_UpdateCategory_Failure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`false`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + err := client.UpdateCategory(context.Background(), 5, "Feature", "") + if err == nil { + t.Fatal("expected error on failure") + } +} + +func TestClient_RemoveCategory(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method != "removeCategory" { + t.Errorf("expected method=removeCategory, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["category_id"].(float64) != 3 { + t.Errorf("expected category_id=3, got %v", params["category_id"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`true`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + err := client.RemoveCategory(context.Background(), 3) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_RemoveCategory_Failure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`false`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + err := client.RemoveCategory(context.Background(), 3) + if err == nil { + t.Fatal("expected error on failure") + } +} + +func TestClient_GetCategoryByName(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`[ + {"id": "1", "name": "Bug", "project_id": "1", "color_id": "red"}, + {"id": "2", "name": "Feature", "project_id": "1", "color_id": "blue"} + ]`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + cat, err := client.GetCategoryByName(context.Background(), 1, "Feature") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cat.Name != "Feature" { + t.Errorf("expected name=Feature, got %s", cat.Name) + } + if int(cat.ID) != 2 { + t.Errorf("expected id=2, got %d", cat.ID) + } +} + +func TestClient_GetCategoryByName_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`[]`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + _, err := client.GetCategoryByName(context.Background(), 1, "Nonexistent") + if err == nil { + t.Fatal("expected error for non-existent category") + } + if !errors.Is(err, ErrCategoryNotFound) { + t.Errorf("expected ErrCategoryNotFound, got %v", err) + } +} + func TestClient_GetAllCategories_ContextCanceled(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { select {} From c4caf4b8761660f52e465c37cbcefa80e11aec01 Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Fri, 30 Jan 2026 12:30:52 +0100 Subject: [PATCH 08/10] Added badges to README --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 206d52c..8dadba8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # kanboard-api +[![Mirror on GitHub](https://img.shields.io/badge/mirror-GitHub-blue)](https://github.com/jakoubek/kanboard-api) +[![Go Reference](https://pkg.go.dev/badge/code.beautifulmachines.dev/jakoubek/kanboard-api.svg)](https://pkg.go.dev/code.beautifulmachines.dev/jakoubek/kanboard-api) +[![Go Report Card](https://goreportcard.com/badge/code.beautifulmachines.dev/jakoubek/kanboard-api)](https://goreportcard.com/report/code.beautifulmachines.dev/jakoubek/kanboard-api) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) + Go library for the [Kanboard](https://kanboard.org/) JSON-RPC API. Provides a fluent, chainable API for integrating Kanboard into Go applications. ## Installation From 806334115063652123dcf75e507e29298d401e30 Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Mon, 2 Feb 2026 11:25:57 +0100 Subject: [PATCH 09/10] feat: add GetTaskByReference function Look up tasks by external reference within a project, following the same pattern as GetTask with ErrTaskNotFound on missing results. --- .beads/export-state/fbbdf412d0fd5173.json | 6 +- .beads/issues.jsonl | 2 + tasks.go | 17 ++++++ tasks_test.go | 67 +++++++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) diff --git a/.beads/export-state/fbbdf412d0fd5173.json b/.beads/export-state/fbbdf412d0fd5173.json index d7e5028..6b9418d 100644 --- a/.beads/export-state/fbbdf412d0fd5173.json +++ b/.beads/export-state/fbbdf412d0fd5173.json @@ -1,6 +1,6 @@ { "worktree_root": "/home/oli/Dev/kanboard-api", - "last_export_commit": "12c1660d94b1daa7d0e4407480a0d3d75f5816a8", - "last_export_time": "2026-01-28T12:09:27.757995829+01:00", - "jsonl_hash": "e0d7c3244033c584f1bf6c013c22b66723e2595dcbe1bf20da7deb9ed6f3e68d" + "last_export_commit": "4e856cd206ba4e7e09d30d0f4892972df87db4a6", + "last_export_time": "2026-01-30T12:30:52.561186642+01:00", + "jsonl_hash": "46173e2c776a1bcf136d8903a3c384211ff17e44a6e7ba080445e5e0e0435749" } \ No newline at end of file diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 7579470..619c55d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -2,6 +2,7 @@ {"id":"kanboard-1ov","title":"Add GetColorList for available task colors","description":"## Context\n\nTask colors are already partially supported in the library:\n- `ColorID` field exists in the `Task` type\n- `WithColor()` works for task creation via `TaskParams`\n- `SetColor()` works for task updates via `TaskUpdateParams`\n\nHowever, there's no way to query the available colors from the Kanboard instance.\n\n## Missing Feature\n\nThe Kanboard API provides `getColorList` in the [Application Procedures](https://docs.kanboard.org/v1/api/application_procedures/):\n\n```\nRequest:\n{\"jsonrpc\": \"2.0\", \"method\": \"getColorList\", \"id\": 1}\n\nResponse:\n{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"result\": {\n \"yellow\": \"Yellow\",\n \"blue\": \"Blue\",\n \"green\": \"Green\",\n ...\n }\n}\n```\n\n## Implementation\n\nCreate a new file `application.go` with:\n\n```go\n// GetColorList returns the available task colors.\n// Returns a map of color_id to display name.\nfunc (c *Client) GetColorList(ctx context.Context) (map[string]string, error) {\n var result map[string]string\n if err := c.call(ctx, \"getColorList\", nil, \u0026result); err != nil {\n return nil, fmt.Errorf(\"getColorList: %w\", err)\n }\n return result, nil\n}\n```\n\n## Files to Create/Modify\n\n- `application.go` - New file with `GetColorList()`\n- `application_test.go` - Tests for the new function\n\n## Acceptance Criteria\n\n- [ ] `GetColorList()` returns map of color_id to display name\n- [ ] Works with standard Kanboard color set\n- [ ] Tests written and passing","status":"closed","priority":3,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T12:11:00.218309278+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T12:13:06.411067375+01:00","closed_at":"2026-01-27T12:13:06.411067375+01:00","close_reason":"Closed"} {"id":"kanboard-3pe","title":"Add DateMoved timestamp field to Task struct","description":"The Task struct in `types.go` is missing the `date_moved` timestamp that Kanboard returns when a task is moved between columns or swimlanes.\n\n## Background\n\nKanboard tracks when a task was last moved with the `date_moved` field. This timestamp is returned in API responses but is currently not captured by the Go client's Task struct.\n\n## Implementation\n\nAdd the following field to the Task struct in `types.go`:\n\n```go\nDateMoved Timestamp `json:\"date_moved\"`\n```\n\nThis should be added alongside the other date fields (after `DateDue` for consistency with the grouping of timestamp fields).\n\n## Acceptance Criteria\n\n- [ ] `DateMoved` field added to `Task` struct in `types.go`\n- [ ] Field uses `Timestamp` type with JSON tag `json:\"date_moved\"`\n- [ ] All existing tests pass\n- [ ] Field is properly handled in all task-related operations (read/unmarshal)","status":"closed","priority":2,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-28T12:05:50.949698791+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-28T12:09:01.530912406+01:00","closed_at":"2026-01-28T12:09:01.530912406+01:00","close_reason":"Closed"} {"id":"kanboard-4n3","title":"Implement category write operations","description":"Implement the missing category write operations from the Kanboard API: `createCategory`, `updateCategory`, and `removeCategory`. Additionally, add a client-side `getCategoryByName` helper.\n\nReference: https://docs.kanboard.org/v1/api/category_procedures/\n\n## Details\n\nCurrently only read operations exist (`GetAllCategories`, `GetCategory` in `categories.go`). The following need to be added:\n\n### createCategory\n- Parameters: `project_id` (int), `name` (string), optional `color_id` (string)\n- Returns: category ID on success\n\n### updateCategory\n- Parameters: `id` (int), `name` (string), optional `color_id` (string)\n- Returns: bool\n\n### removeCategory\n- Parameters: `category_id` (int)\n- Returns: bool\n\n### getCategoryByName (client-side helper)\n- Since `getAllCategories` returns the full array, implement `GetCategoryByName(projectID, name)` as a client-side filter over `GetAllCategories` rather than a separate RPC call (unless a server-side procedure exists).\n- Return `ErrCategoryNotFound` if no match.\n\n## Acceptance Criteria\n- [ ] `CreateCategory(projectID, name, opts...)` implemented with functional options for color_id\n- [ ] `UpdateCategory(id, name, opts...)` implemented with functional options for color_id\n- [ ] `RemoveCategory(categoryID)` implemented\n- [ ] `GetCategoryByName(projectID, name)` implemented (client-side filter)\n- [ ] All new methods have corresponding unit tests in `categories_test.go`\n- [ ] BoardScope wrappers added where appropriate\n- [ ] Follow existing code patterns and conventions from the project","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-29T09:12:59.169475651+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-29T09:20:09.394074185+01:00","closed_at":"2026-01-29T09:20:09.394074185+01:00","close_reason":"Closed"} +{"id":"kanboard-521","title":"Add GetTaskByReference function","description":"Add a `GetTaskByReference` function that wraps the Kanboard JSON-RPC `getTaskByReference` endpoint.\n\n**Signature:** `GetTaskByReference(ctx context.Context, projectID int, reference string) (*Task, error)`\n\n- Calls the `getTaskByReference` JSON-RPC method with `project_id` and `reference` parameters\n- Returns the matching `*Task` on success\n- Returns `ErrTaskNotFound` if no task matches the given reference\n\n**Context:** Needed by hqcli for the `find-ref` command (hqcli-a57).\n\n## Acceptance Criteria\n- [ ] `GetTaskByReference` implemented with correct JSON-RPC call\n- [ ] Returns `ErrTaskNotFound` when no task matches\n- [ ] Tests written and passing","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-02T11:22:16.674849553+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-02T11:25:53.151693032+01:00","closed_at":"2026-02-02T11:25:53.151693032+01:00","close_reason":"Closed"} {"id":"kanboard-7es","title":"JSON-RPC Request-ID: Zufälligen Wert statt fester 1 verwenden","description":"## Kontext\n\nIn jedem JSON-RPC Request an die Kanboard-API wird im Root-Objekt ein Feld `id` mitgeliefert. Dieses dient dazu, bei asynchroner Kommunikation Request und Response einander zuordnen zu können – die API liefert diese ID in der Antwort zurück.\n\n**Aktuell:** Die ID ist fest auf `1` gesetzt.\n\n## Anforderung\n\n1. **Wenn keine ID von außen gesetzt wird:** Die Library soll intern einen zufälligen Wert generieren\n2. **API-Dokumentation prüfen:** Welche Werte sind erlaubt? Welche Größenordnung? (vermutlich Integer)\n3. **Signatur beibehalten:** Die öffentliche API der Library-Funktionen soll unverändert bleiben\n4. **Interne Generierung:** Die Library bestimmt selbst einen zufälligen Wert\n\n## Implementierungshinweise\n\n- Prüfen: Kanboard JSON-RPC Dokumentation bezüglich erlaubter ID-Werte\n- Vermutlich: `int64` oder `int32` Range\n- Zufallsgenerator: `math/rand` mit Seed oder `crypto/rand` für bessere Verteilung\n- Ggf. bestehende `requestIDCounter` in `jsonrpc.go` (Zeile 40) anpassen oder ersetzen\n\n## Beispiel\n\n**Vorher (immer gleich):**\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"getTask\", \"id\": 1, \"params\": {...}}\n```\n\n**Nachher (zufällig):**\n```json\n{\"jsonrpc\": \"2.0\", \"method\": \"getTask\", \"id\": 847291536, \"params\": {...}}\n```\n\n## Referenz\n\n- Datei: `jsonrpc.go`\n- Zeile 17: `ID int64 \\`json:\"id\"\\``\n- Zeile 40: `requestIDCounter` (existiert bereits)","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-23T17:44:51.566737509+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T10:21:06.415129879+01:00","closed_at":"2026-01-27T10:21:06.415129879+01:00","close_reason":"Closed"} {"id":"kanboard-9wa","title":"Support custom authentication header name","description":"## Description\n\nKanboard supports using an alternative HTTP header for authentication when the server has specific configuration requirements.\n\nCurrently, authentication uses the standard `Authorization` header via Go's `SetBasicAuth()`. This needs to be configurable so users can specify a custom header name (e.g., `X-API-Auth`).\n\n## Requirements\n\n- Add an optional configuration parameter for custom auth header name\n- Default to standard `Authorization` header if not specified\n- When custom header is set, use that header name instead of `Authorization`\n- The header value format should remain the same (Basic Auth base64-encoded credentials)\n\n## Acceptance Criteria\n\n- [ ] New client configuration method (e.g., `WithAuthHeader(headerName string)`)\n- [ ] Default behavior unchanged when no custom header specified\n- [ ] Custom header name is used when configured\n- [ ] Works with both API token and basic auth\n- [ ] Tests cover default and custom header scenarios\n\n## Reference\n\nKanboard API documentation: \"You can use an alternative HTTP header for authentication if your server has a very specific configuration.\"","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-23T18:08:31.507616093+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-23T18:26:50.40804952+01:00","closed_at":"2026-01-23T18:26:50.40804952+01:00","close_reason":"Closed"} {"id":"kanboard-a7x","title":"Handle URLs already ending in /jsonrpc.php","description":"## Context\n\nThe `NewClient()` function in `client.go` always appends `/jsonrpc.php` to the base URL. This causes issues when users pass a URL that already includes the endpoint path.\n\n## Problem\n\nIf a user calls:\n```go\nclient := kanboard.NewClient(\"https://example.com/jsonrpc.php\")\n```\n\nThe resulting endpoint becomes `https://example.com/jsonrpc.php/jsonrpc.php`, which fails.\n\n## Solution\n\nModify `NewClient()` to detect and handle URLs that already end in `/jsonrpc.php`:\n\n```go\nfunc NewClient(baseURL string) *Client {\n // Ensure no trailing slash\n baseURL = strings.TrimSuffix(baseURL, \"/\")\n\n // Handle URLs that already include /jsonrpc.php\n endpoint := baseURL\n if !strings.HasSuffix(baseURL, \"/jsonrpc.php\") {\n endpoint = baseURL + \"/jsonrpc.php\"\n }\n\n c := \u0026Client{\n baseURL: baseURL,\n endpoint: endpoint,\n }\n // ... rest unchanged\n}\n```\n\n## Files to Modify\n\n- `client.go` - Update `NewClient()` to check for existing `/jsonrpc.php` suffix\n\n## Acceptance Criteria\n\n- [ ] `NewClient(\"https://example.com\")` → endpoint `https://example.com/jsonrpc.php`\n- [ ] `NewClient(\"https://example.com/\")` → endpoint `https://example.com/jsonrpc.php`\n- [ ] `NewClient(\"https://example.com/jsonrpc.php\")` → endpoint `https://example.com/jsonrpc.php`\n- [ ] `NewClient(\"https://example.com/kanboard/jsonrpc.php\")` → endpoint `https://example.com/kanboard/jsonrpc.php`\n- [ ] Tests written and passing","status":"closed","priority":2,"issue_type":"bug","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T10:25:51.077352962+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T10:27:15.189568683+01:00","closed_at":"2026-01-27T10:27:15.189568683+01:00","close_reason":"Closed"} @@ -34,4 +35,5 @@ {"id":"kanboard-api-zfc","title":"Implement TaskScope tag methods with read-modify-write (CRITICAL)","description":"Implement TaskScope tag methods with read-modify-write pattern. CRITICAL feature.\n\n## TaskScope methods to implement\n- GetTags(ctx) (map[int]string, error)\n- SetTags(ctx, tags ...string) error - replaces ALL tags\n- ClearTags(ctx) error - removes ALL tags\n- AddTag(ctx, tag string) error - read-modify-write\n- RemoveTag(ctx, tag string) error - read-modify-write\n- HasTag(ctx, tag string) (bool, error)\n\n## Read-Modify-Write Workflow for AddTag\n1. Get task via getTask (need project_id)\n2. Get current tags via getTaskTags\n3. Check if tag already exists\n4. If not: add tag to list\n5. Call setTaskTags with updated list\n\n## Read-Modify-Write Workflow for RemoveTag\n1. Get task via getTask (need project_id)\n2. Get current tags via getTaskTags\n3. Filter out the tag to remove\n4. Call setTaskTags with filtered list\n5. If tag didn't exist: no error (idempotent)\n\n## Files to modify\n- task_scope.go\n\n## IMPORTANT WARNING\nThis is NOT atomic. Concurrent tag modifications may cause data loss. Document this limitation.\n\n## Acceptance criteria\n- AddTag is idempotent (no error if tag exists)\n- RemoveTag is idempotent (no error if tag doesn't exist)\n- HasTag correctly checks tag existence","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:08.911429864+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:36:32.533937315+01:00","closed_at":"2026-01-15T18:36:32.533937315+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-zfc","depends_on_id":"kanboard-api-16r","type":"blocks","created_at":"2026-01-15T17:43:49.517064988+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-zfc","depends_on_id":"kanboard-api-una","type":"blocks","created_at":"2026-01-15T17:43:49.593313748+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-zfc","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:43:49.690872321+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-zg2","title":"Implement File API methods","description":"Implement direct API methods for task file operations.\n\n## Methods to implement (Must-have)\n- GetAllTaskFiles(ctx, taskID int) ([]TaskFile, error) - getAllTaskFiles\n- CreateTaskFile(ctx, projectID, taskID int, filename string, content []byte) (int, error) - createTaskFile\n\n## Methods to implement (Nice-to-have)\n- DownloadTaskFile(ctx, fileID int) ([]byte, error) - downloadTaskFile\n- RemoveTaskFile(ctx, fileID int) error - removeTaskFile\n\n## TaskScope methods to add\n- GetFiles(ctx) ([]TaskFile, error)\n- UploadFile(ctx, filename string, content []byte) (*TaskFile, error)\n\n## Files to create\n- files.go\n- task_scope.go (extend)\n\n## Acceptance criteria\n- File content base64 encoded for upload\n- CreateTaskFile returns file ID","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:09.748005313+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:45:05.499871045+01:00","closed_at":"2026-01-15T18:45:05.499871045+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-zg2","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.984099418+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-zg2","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:50.049331328+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-iuf","title":"Remove Basic prefix from custom auth headers","description":"## Problem\n\nWhen using a custom header name (e.g., `X-API-Auth` for Kanboard), the library incorrectly prepends `\"Basic \"` to the base64-encoded credentials.\n\n**Current behavior** (`auth.go:27` and `auth.go:43`):\n```go\nreq.Header.Set(a.headerName, \"Basic \"+basicAuthValue(user, a.token))\n```\n\nSends: `X-API-Auth: Basic dXNlcjpwYXNzd29yZA==`\n\n**Expected behavior** per [Kanboard API docs](https://docs.kanboard.org/v1/api/authentication/):\n\n- Standard `Authorization` header: `Basic base64(username:password)` ✓\n- Custom `X-API-Auth` header: `base64(username:password)` (no \"Basic \" prefix!)\n\nShould send: `X-API-Auth: dXNlcjpwYXNzd29yZA==`\n\n## Impact\n\nAuthentication fails when using the custom header option. The server returns HTML (login page) instead of JSON because authentication is rejected.\n\n## Suggested Fix\n\nIn both `apiTokenAuth.Apply()` and `basicAuth.Apply()`, remove the \"Basic \" prefix when using a custom header name:\n\n```go\nif a.headerName != \"\" {\n req.Header.Set(a.headerName, basicAuthValue(user, a.token)) // no \"Basic \" prefix\n} else {\n req.SetBasicAuth(user, a.token) // uses standard Authorization header with \"Basic \"\n}\n```\n\n## Affected Code\n\n- `auth.go:27` - `apiTokenAuth.Apply()`\n- `auth.go:43` - `basicAuth.Apply()`\n\n## Acceptance Criteria\n\n- [ ] Custom header (`X-API-Auth`) sends raw base64 value without \"Basic \" prefix\n- [ ] Standard `Authorization` header still works with \"Basic \" prefix\n- [ ] Tests updated to verify both behaviors\n- [ ] All tests passing","status":"closed","priority":1,"issue_type":"bug","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T10:43:21.717917986+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T11:01:19.467981335+01:00","closed_at":"2026-01-27T11:01:19.467981335+01:00","close_reason":"Closed"} +{"id":"kanboard-n03","title":"Implement Swimlane CRUD methods","description":"The kanboard-api client already supports swimlane_id on tasks (InSwimlane builder, MoveTaskPosition, Task.SwimlaneID field) but lacks dedicated Swimlane management methods.\n\nImplement the following Kanboard JSON-RPC API methods:\n\n**Read operations:**\n- getActiveSwimlanes(project_id)\n- getAllSwimlanes(project_id)\n- getSwimlane(swimlane_id)\n- getSwimlaneById(swimlane_id)\n- getSwimlaneByName(project_id, name)\n\n**Write operations:**\n- addSwimlane(project_id, name, description)\n- updateSwimlane(swimlane_id, name, description)\n- removeSwimlane(project_id, swimlane_id)\n- changeSwimlanePosition(project_id, swimlane_id, position)\n- enableSwimlane(project_id, swimlane_id)\n- disableSwimlane(project_id, swimlane_id)\n\nCreate a new file `swimlanes.go` with a Swimlane struct:\n```go\ntype Swimlane struct {\n ID int\n Name string\n Description string\n Position int\n IsActive bool\n ProjectID int\n}\n```\n\nAnd corresponding test file `swimlanes_test.go`.\n\n## Acceptance Criteria\n- [ ] Swimlane struct defined with ID, Name, Description, Position, IsActive, ProjectID\n- [ ] All 11 JSON-RPC methods implemented\n- [ ] Read methods: getActiveSwimlanes, getAllSwimlanes, getSwimlane, getSwimlaneById, getSwimlaneByName\n- [ ] Write methods: addSwimlane, updateSwimlane, removeSwimlane, changeSwimlanePosition, enableSwimlane, disableSwimlane\n- [ ] Code in swimlanes.go follows existing patterns (e.g., categories.go)\n- [ ] Tests written in swimlanes_test.go and passing","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-01T10:07:54.192295081+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-01T10:07:54.192295081+01:00"} {"id":"kanboard-r3p","title":"Support optional API user in token authentication","description":"## Description\n\nBei der API-Token-Authentifizierung soll neben dem API-Key optional auch ein API-User konfigurierbar sein. Wenn kein User angegeben wird, soll weiterhin der Standard-User \"jsonrpc\" verwendet werden.\n\n## Current Behavior\n\nIn `auth.go`, the `apiTokenAuth` struct hardcodes the username \"jsonrpc\":\n\n```go\nfunc (a *apiTokenAuth) Apply(req *http.Request) {\n req.SetBasicAuth(\"jsonrpc\", a.token)\n}\n```\n\n## Expected Behavior\n\n- Add an optional `user` field to `apiTokenAuth`\n- If user is empty/not provided, default to \"jsonrpc\"\n- If user is provided, use that value for HTTP Basic Auth\n\n## Acceptance Criteria\n\n- [ ] `apiTokenAuth` struct has an optional user field\n- [ ] Default behavior unchanged when no user specified (uses \"jsonrpc\")\n- [ ] Custom user is used when explicitly provided\n- [ ] Client configuration supports setting the API user\n- [ ] Tests cover both default and custom user scenarios","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-23T17:39:37.745294723+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-23T17:55:23.860864584+01:00","closed_at":"2026-01-23T17:55:23.860864584+01:00","close_reason":"Closed"} diff --git a/tasks.go b/tasks.go index 57e928a..66d7f8b 100644 --- a/tasks.go +++ b/tasks.go @@ -24,6 +24,23 @@ func (c *Client) GetTask(ctx context.Context, taskID int) (*Task, error) { return result, nil } +// GetTaskByReference returns a task by its external reference within a project. +// Returns ErrTaskNotFound if no task matches the reference. +func (c *Client) GetTaskByReference(ctx context.Context, projectID int, reference string) (*Task, error) { + params := map[string]any{"project_id": projectID, "reference": reference} + + var result *Task + if err := c.call(ctx, "getTaskByReference", params, &result); err != nil { + return nil, fmt.Errorf("getTaskByReference: %w", err) + } + + if result == nil { + return nil, fmt.Errorf("%w: reference %q in project %d", ErrTaskNotFound, reference, projectID) + } + + return result, nil +} + // GetAllTasks returns all tasks for a project with the specified status. func (c *Client) GetAllTasks(ctx context.Context, projectID int, status TaskStatus) ([]Task, error) { params := map[string]int{ diff --git a/tasks_test.go b/tasks_test.go index c179571..49c1249 100644 --- a/tasks_test.go +++ b/tasks_test.go @@ -77,6 +77,73 @@ func TestClient_GetTask_NotFound(t *testing.T) { } } +func TestClient_GetTaskByReference(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method != "getTaskByReference" { + t.Errorf("expected method=getTaskByReference, got %s", req.Method) + } + + params := req.Params.(map[string]any) + if params["project_id"].(float64) != 1 { + t.Errorf("expected project_id=1, got %v", params["project_id"]) + } + if params["reference"] != "EXT-123" { + t.Errorf("expected reference='EXT-123', got %v", params["reference"]) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id": "42", "title": "Test Task", "project_id": "1", "column_id": "1", "is_active": "1", "reference": "EXT-123"}`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + task, err := client.GetTaskByReference(context.Background(), 1, "EXT-123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if int(task.ID) != 42 { + t.Errorf("expected ID=42, got %d", task.ID) + } + if task.Title != "Test Task" { + t.Errorf("expected title='Test Task', got %s", task.Title) + } +} + +func TestClient_GetTaskByReference_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`null`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + _, err := client.GetTaskByReference(context.Background(), 1, "NONEXISTENT") + if err == nil { + t.Fatal("expected error for non-existent task") + } + + if !errors.Is(err, ErrTaskNotFound) { + t.Errorf("expected ErrTaskNotFound, got %v", err) + } +} + func TestClient_GetAllTasks(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req JSONRPCRequest From 1fba43cf902be67852d7d875930b3f13af228874 Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Mon, 2 Feb 2026 12:34:15 +0100 Subject: [PATCH 10/10] feat: add timezone support with automatic timestamp conversion Add GetTimezone() API method and WithTimezone() client option. When enabled, the client lazily fetches the server timezone on first API call and converts all Timestamp fields in responses using reflection-based struct walking. --- .beads/export-state/fbbdf412d0fd5173.json | 6 +- .beads/issues.jsonl | 1 + client.go | 12 ++ jsonrpc.go | 9 + timezone.go | 81 ++++++++ timezone_test.go | 233 ++++++++++++++++++++++ 6 files changed, 339 insertions(+), 3 deletions(-) create mode 100644 timezone.go create mode 100644 timezone_test.go diff --git a/.beads/export-state/fbbdf412d0fd5173.json b/.beads/export-state/fbbdf412d0fd5173.json index 6b9418d..3e8e777 100644 --- a/.beads/export-state/fbbdf412d0fd5173.json +++ b/.beads/export-state/fbbdf412d0fd5173.json @@ -1,6 +1,6 @@ { "worktree_root": "/home/oli/Dev/kanboard-api", - "last_export_commit": "4e856cd206ba4e7e09d30d0f4892972df87db4a6", - "last_export_time": "2026-01-30T12:30:52.561186642+01:00", - "jsonl_hash": "46173e2c776a1bcf136d8903a3c384211ff17e44a6e7ba080445e5e0e0435749" + "last_export_commit": "c4caf4b8761660f52e465c37cbcefa80e11aec01", + "last_export_time": "2026-02-02T11:25:57.259799495+01:00", + "jsonl_hash": "ccd7fae0d9d72dc744a6028c3d1573e1df0b43bb6fb6b28d433a4a8c3c0d5eb6" } \ No newline at end of file diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 619c55d..96b9fde 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -36,4 +36,5 @@ {"id":"kanboard-api-zg2","title":"Implement File API methods","description":"Implement direct API methods for task file operations.\n\n## Methods to implement (Must-have)\n- GetAllTaskFiles(ctx, taskID int) ([]TaskFile, error) - getAllTaskFiles\n- CreateTaskFile(ctx, projectID, taskID int, filename string, content []byte) (int, error) - createTaskFile\n\n## Methods to implement (Nice-to-have)\n- DownloadTaskFile(ctx, fileID int) ([]byte, error) - downloadTaskFile\n- RemoveTaskFile(ctx, fileID int) error - removeTaskFile\n\n## TaskScope methods to add\n- GetFiles(ctx) ([]TaskFile, error)\n- UploadFile(ctx, filename string, content []byte) (*TaskFile, error)\n\n## Files to create\n- files.go\n- task_scope.go (extend)\n\n## Acceptance criteria\n- File content base64 encoded for upload\n- CreateTaskFile returns file ID","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:09.748005313+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:45:05.499871045+01:00","closed_at":"2026-01-15T18:45:05.499871045+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-zg2","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.984099418+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-zg2","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:50.049331328+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-iuf","title":"Remove Basic prefix from custom auth headers","description":"## Problem\n\nWhen using a custom header name (e.g., `X-API-Auth` for Kanboard), the library incorrectly prepends `\"Basic \"` to the base64-encoded credentials.\n\n**Current behavior** (`auth.go:27` and `auth.go:43`):\n```go\nreq.Header.Set(a.headerName, \"Basic \"+basicAuthValue(user, a.token))\n```\n\nSends: `X-API-Auth: Basic dXNlcjpwYXNzd29yZA==`\n\n**Expected behavior** per [Kanboard API docs](https://docs.kanboard.org/v1/api/authentication/):\n\n- Standard `Authorization` header: `Basic base64(username:password)` ✓\n- Custom `X-API-Auth` header: `base64(username:password)` (no \"Basic \" prefix!)\n\nShould send: `X-API-Auth: dXNlcjpwYXNzd29yZA==`\n\n## Impact\n\nAuthentication fails when using the custom header option. The server returns HTML (login page) instead of JSON because authentication is rejected.\n\n## Suggested Fix\n\nIn both `apiTokenAuth.Apply()` and `basicAuth.Apply()`, remove the \"Basic \" prefix when using a custom header name:\n\n```go\nif a.headerName != \"\" {\n req.Header.Set(a.headerName, basicAuthValue(user, a.token)) // no \"Basic \" prefix\n} else {\n req.SetBasicAuth(user, a.token) // uses standard Authorization header with \"Basic \"\n}\n```\n\n## Affected Code\n\n- `auth.go:27` - `apiTokenAuth.Apply()`\n- `auth.go:43` - `basicAuth.Apply()`\n\n## Acceptance Criteria\n\n- [ ] Custom header (`X-API-Auth`) sends raw base64 value without \"Basic \" prefix\n- [ ] Standard `Authorization` header still works with \"Basic \" prefix\n- [ ] Tests updated to verify both behaviors\n- [ ] All tests passing","status":"closed","priority":1,"issue_type":"bug","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T10:43:21.717917986+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T11:01:19.467981335+01:00","closed_at":"2026-01-27T11:01:19.467981335+01:00","close_reason":"Closed"} {"id":"kanboard-n03","title":"Implement Swimlane CRUD methods","description":"The kanboard-api client already supports swimlane_id on tasks (InSwimlane builder, MoveTaskPosition, Task.SwimlaneID field) but lacks dedicated Swimlane management methods.\n\nImplement the following Kanboard JSON-RPC API methods:\n\n**Read operations:**\n- getActiveSwimlanes(project_id)\n- getAllSwimlanes(project_id)\n- getSwimlane(swimlane_id)\n- getSwimlaneById(swimlane_id)\n- getSwimlaneByName(project_id, name)\n\n**Write operations:**\n- addSwimlane(project_id, name, description)\n- updateSwimlane(swimlane_id, name, description)\n- removeSwimlane(project_id, swimlane_id)\n- changeSwimlanePosition(project_id, swimlane_id, position)\n- enableSwimlane(project_id, swimlane_id)\n- disableSwimlane(project_id, swimlane_id)\n\nCreate a new file `swimlanes.go` with a Swimlane struct:\n```go\ntype Swimlane struct {\n ID int\n Name string\n Description string\n Position int\n IsActive bool\n ProjectID int\n}\n```\n\nAnd corresponding test file `swimlanes_test.go`.\n\n## Acceptance Criteria\n- [ ] Swimlane struct defined with ID, Name, Description, Position, IsActive, ProjectID\n- [ ] All 11 JSON-RPC methods implemented\n- [ ] Read methods: getActiveSwimlanes, getAllSwimlanes, getSwimlane, getSwimlaneById, getSwimlaneByName\n- [ ] Write methods: addSwimlane, updateSwimlane, removeSwimlane, changeSwimlanePosition, enableSwimlane, disableSwimlane\n- [ ] Code in swimlanes.go follows existing patterns (e.g., categories.go)\n- [ ] Tests written in swimlanes_test.go and passing","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-01T10:07:54.192295081+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-01T10:07:54.192295081+01:00"} +{"id":"kanboard-qle","title":"Add timezone support with automatic timestamp conversion","description":"## Description\n\nImplement timezone support by calling the Kanboard `getTimezone` API endpoint and converting all UTC timestamps to the configured timezone.\n\nThe Kanboard API provides `getTimezone` which returns a timezone string (e.g. `Europe/Berlin`). The client should:\n\n1. Query the timezone on initialization (or lazily on first use)\n2. Automatically convert all timestamp fields to local time, including:\n - `date_creation`, `date_started`, `date_moved`, `date_due`\n - Comment dates (`date_creation`)\n - Any other timestamp fields returned by the API\n\n### Design Option\n\nProvide a `WithTimezone()` client option so callers can control whether conversion happens. When enabled, the client fetches the timezone from the API and converts timestamps transparently. When disabled (default for backward compatibility), timestamps remain as-is.\n\n## Acceptance Criteria\n\n- [ ] Implement `GetTimezone()` API method that calls `getTimezone`\n- [ ] Add `WithTimezone()` client option to enable automatic conversion\n- [ ] When enabled, all timestamp fields in responses are converted to the configured timezone\n- [ ] Timezone is fetched once and cached for the client lifetime\n- [ ] Tests written and passing for timezone conversion logic\n- [ ] Backward compatible: no behavior change without `WithTimezone()` option","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-02T12:25:32.830875466+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-02T12:34:10.955810869+01:00","closed_at":"2026-02-02T12:34:10.955810869+01:00","close_reason":"Closed"} {"id":"kanboard-r3p","title":"Support optional API user in token authentication","description":"## Description\n\nBei der API-Token-Authentifizierung soll neben dem API-Key optional auch ein API-User konfigurierbar sein. Wenn kein User angegeben wird, soll weiterhin der Standard-User \"jsonrpc\" verwendet werden.\n\n## Current Behavior\n\nIn `auth.go`, the `apiTokenAuth` struct hardcodes the username \"jsonrpc\":\n\n```go\nfunc (a *apiTokenAuth) Apply(req *http.Request) {\n req.SetBasicAuth(\"jsonrpc\", a.token)\n}\n```\n\n## Expected Behavior\n\n- Add an optional `user` field to `apiTokenAuth`\n- If user is empty/not provided, default to \"jsonrpc\"\n- If user is provided, use that value for HTTP Basic Auth\n\n## Acceptance Criteria\n\n- [ ] `apiTokenAuth` struct has an optional user field\n- [ ] Default behavior unchanged when no user specified (uses \"jsonrpc\")\n- [ ] Custom user is used when explicitly provided\n- [ ] Client configuration supports setting the API user\n- [ ] Tests cover both default and custom user scenarios","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-23T17:39:37.745294723+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-23T17:55:23.860864584+01:00","closed_at":"2026-01-23T17:55:23.860864584+01:00","close_reason":"Closed"} diff --git a/client.go b/client.go index fe41906..60ebb2d 100644 --- a/client.go +++ b/client.go @@ -4,6 +4,7 @@ import ( "log/slog" "net/http" "strings" + "sync" "time" ) @@ -19,6 +20,9 @@ type Client struct { auth Authenticator logger *slog.Logger authHeaderName string // custom auth header, empty = use "Authorization" + timezone *time.Location + tzOnce sync.Once + tzEnabled bool } // NewClient creates a new Kanboard API client. @@ -103,3 +107,11 @@ func (c *Client) WithLogger(logger *slog.Logger) *Client { c.logger = logger return c } + +// WithTimezone enables automatic timestamp conversion. On the first API call, +// the client fetches the server's timezone via getTimezone and converts all +// Timestamp fields in responses to that timezone. +func (c *Client) WithTimezone() *Client { + c.tzEnabled = true + return c +} diff --git a/jsonrpc.go b/jsonrpc.go index 290de89..9e49b11 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -47,6 +47,12 @@ func nextRequestID() int64 { // call sends a JSON-RPC request and parses the response. // The result parameter should be a pointer to the expected result type. func (c *Client) call(ctx context.Context, method string, params interface{}, result interface{}) error { + if method != "getTimezone" { + if err := c.ensureTimezone(ctx); err != nil { + return fmt.Errorf("failed to load timezone: %w", err) + } + } + req := JSONRPCRequest{ JSONRPC: "2.0", Method: method, @@ -138,6 +144,9 @@ func (c *Client) call(ctx context.Context, method string, params interface{}, re if err := json.Unmarshal(rpcResp.Result, result); err != nil { return fmt.Errorf("failed to unmarshal result: %w", err) } + if c.tzEnabled && c.timezone != nil { + c.convertTimestamps(result) + } } return nil diff --git a/timezone.go b/timezone.go new file mode 100644 index 0000000..96353e2 --- /dev/null +++ b/timezone.go @@ -0,0 +1,81 @@ +package kanboard + +import ( + "context" + "fmt" + "reflect" + "time" +) + +// GetTimezone returns the server's configured timezone string (e.g., "UTC", "Europe/Berlin"). +func (c *Client) GetTimezone(ctx context.Context) (string, error) { + var tz string + if err := c.call(ctx, "getTimezone", nil, &tz); err != nil { + return "", fmt.Errorf("getTimezone: %w", err) + } + return tz, nil +} + +// loadTimezone fetches and caches the timezone location from the server. +func (c *Client) loadTimezone(ctx context.Context) error { + tz, err := c.GetTimezone(ctx) + if err != nil { + return err + } + loc, err := time.LoadLocation(tz) + if err != nil { + return fmt.Errorf("invalid timezone %q: %w", tz, err) + } + c.timezone = loc + return nil +} + +// ensureTimezone loads the timezone if tzEnabled and not yet loaded. +func (c *Client) ensureTimezone(ctx context.Context) error { + if !c.tzEnabled { + return nil + } + var err error + c.tzOnce.Do(func() { + err = c.loadTimezone(ctx) + }) + return err +} + +// convertTimestamps converts all Timestamp fields in v to the client's timezone. +// v must be a pointer. Handles structs, pointers to structs, and slices of structs. +func (c *Client) convertTimestamps(v any) { + if c.timezone == nil { + return + } + rv := reflect.ValueOf(v) + c.walkAndConvert(rv) +} + +var timestampType = reflect.TypeOf(Timestamp{}) + +func (c *Client) walkAndConvert(rv reflect.Value) { + switch rv.Kind() { + case reflect.Ptr: + if !rv.IsNil() { + c.walkAndConvert(rv.Elem()) + } + case reflect.Struct: + if rv.Type() == timestampType { + if rv.CanSet() { + ts := rv.Addr().Interface().(*Timestamp) + if !ts.IsZero() { + ts.Time = ts.Time.In(c.timezone) + } + } + return + } + for i := 0; i < rv.NumField(); i++ { + c.walkAndConvert(rv.Field(i)) + } + case reflect.Slice: + for i := 0; i < rv.Len(); i++ { + c.walkAndConvert(rv.Index(i)) + } + } +} diff --git a/timezone_test.go b/timezone_test.go new file mode 100644 index 0000000..8c6df87 --- /dev/null +++ b/timezone_test.go @@ -0,0 +1,233 @@ +package kanboard + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestClient_GetTimezone(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method != "getTimezone" { + t.Errorf("expected method=getTimezone, got %s", req.Method) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`"Europe/Berlin"`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + tz, err := client.GetTimezone(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tz != "Europe/Berlin" { + t.Errorf("expected Europe/Berlin, got %s", tz) + } +} + +func TestClient_WithTimezone_ConvertsTaskTimestamps(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + var result json.RawMessage + switch req.Method { + case "getTimezone": + callCount++ + result = json.RawMessage(`"America/New_York"`) + case "getTask": + result = json.RawMessage(`{ + "id": "1", + "title": "Test", + "description": "", + "date_creation": 1609459200, + "date_modification": 1609459200, + "date_completed": 0, + "date_started": 0, + "date_due": 0, + "date_moved": 0, + "color_id": "yellow", + "project_id": "1", + "column_id": "1", + "owner_id": "0", + "creator_id": "1", + "position": "1", + "is_active": "1", + "score": "0", + "category_id": "0", + "swimlane_id": "0", + "priority": "0", + "reference": "", + "recurrence_status": "0", + "recurrence_trigger": "0", + "recurrence_factor": "0", + "recurrence_timeframe": "0", + "recurrence_basedate": "0", + "recurrence_parent": "0", + "recurrence_child": "0" + }`) + default: + t.Errorf("unexpected method: %s", req.Method) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: result, + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token").WithTimezone() + + task, err := client.GetTask(context.Background(), 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + loc, _ := time.LoadLocation("America/New_York") + expected := time.Unix(1609459200, 0).In(loc) + if !task.DateCreation.Time.Equal(expected) { + t.Errorf("expected time %v, got %v", expected, task.DateCreation.Time) + } + if task.DateCreation.Time.Location().String() != "America/New_York" { + t.Errorf("expected location America/New_York, got %s", task.DateCreation.Time.Location()) + } + + // Verify getTimezone was called exactly once + if callCount != 1 { + t.Errorf("expected getTimezone called once, got %d", callCount) + } + + // Make a second call — should NOT call getTimezone again + _, err = client.GetTask(context.Background(), 1) + if err != nil { + t.Fatalf("unexpected error on second call: %v", err) + } + if callCount != 1 { + t.Errorf("expected getTimezone still called once, got %d", callCount) + } +} + +func TestClient_WithTimezone_Disabled(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req JSONRPCRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.Method == "getTimezone" { + t.Error("getTimezone should not be called when timezone is disabled") + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{ + "id": "1", + "title": "Test", + "description": "", + "date_creation": 1609459200, + "date_modification": 0, + "date_completed": 0, + "date_started": 0, + "date_due": 0, + "date_moved": 0, + "color_id": "yellow", + "project_id": "1", + "column_id": "1", + "owner_id": "0", + "creator_id": "1", + "position": "1", + "is_active": "1", + "score": "0", + "category_id": "0", + "swimlane_id": "0", + "priority": "0", + "reference": "", + "recurrence_status": "0", + "recurrence_trigger": "0", + "recurrence_factor": "0", + "recurrence_timeframe": "0", + "recurrence_basedate": "0", + "recurrence_parent": "0", + "recurrence_child": "0" + }`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + task, err := client.GetTask(context.Background(), 1) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Without WithTimezone, timestamps stay as unmarshalled (Local from time.Unix) + if task.DateCreation.Time.Location() != time.Local { + t.Errorf("expected Local, got %s", task.DateCreation.Time.Location()) + } +} + +func TestConvertTimestamps(t *testing.T) { + loc, _ := time.LoadLocation("Asia/Tokyo") + client := &Client{timezone: loc} + + t.Run("struct with Timestamp fields", func(t *testing.T) { + task := &Task{ + DateCreation: Timestamp{Time: time.Unix(1609459200, 0)}, + DateModification: Timestamp{Time: time.Unix(1609459200, 0)}, + DateCompleted: Timestamp{}, // zero — should stay zero + } + client.convertTimestamps(task) + + if task.DateCreation.Time.Location().String() != "Asia/Tokyo" { + t.Errorf("expected Asia/Tokyo, got %s", task.DateCreation.Time.Location()) + } + if task.DateModification.Time.Location().String() != "Asia/Tokyo" { + t.Errorf("expected Asia/Tokyo, got %s", task.DateModification.Time.Location()) + } + if !task.DateCompleted.IsZero() { + t.Error("zero timestamp should remain zero") + } + }) + + t.Run("slice of structs", func(t *testing.T) { + tasks := &[]Task{ + {DateCreation: Timestamp{Time: time.Unix(1609459200, 0)}}, + {DateCreation: Timestamp{Time: time.Unix(1609459200, 0)}}, + } + client.convertTimestamps(tasks) + + for i, task := range *tasks { + if task.DateCreation.Time.Location().String() != "Asia/Tokyo" { + t.Errorf("task[%d]: expected Asia/Tokyo, got %s", i, task.DateCreation.Time.Location()) + } + } + }) + + t.Run("nil timezone is no-op", func(t *testing.T) { + noTzClient := &Client{} + task := &Task{DateCreation: Timestamp{Time: time.Unix(1609459200, 0)}} + noTzClient.convertTimestamps(task) + // Should not panic or change anything + if task.DateCreation.Time.Location() != time.Local { + t.Errorf("expected Local, got %s", task.DateCreation.Time.Location()) + } + }) +}