Compare commits

...

14 commits

Author SHA1 Message Date
1fba43cf90 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.
2026-02-02 12:34:15 +01:00
8063341150 feat: add GetTaskByReference function
Look up tasks by external reference within a project, following
the same pattern as GetTask with ErrTaskNotFound on missing results.
2026-02-02 11:25:57 +01:00
c4caf4b876 Added badges to README 2026-01-30 12:30:52 +01:00
4e856cd206 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
2026-01-29 09:20:13 +01:00
56da2e0fdc chore: update beads export state 2026-01-28 12:09:27 +01:00
12c1660d94 chore: update beads export state 2026-01-28 12:09:21 +01:00
a381c0dc51 chore: update beads export state 2026-01-28 12:09:14 +01:00
bc478b44e0 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.
2026-01-28 12:09:06 +01:00
5fd38721b5 chore: update beads export state 2026-01-27 17:01:48 +01:00
280fff21a3 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
2026-01-27 17:01:21 +01:00
c15d52633f feat: add GetColorList for available task colors
Adds GetColorList() to query the available task colors from the
Kanboard instance. Returns a map of color_id to display name
(e.g., "yellow" -> "Yellow").

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:13:35 +01:00
449cd2626c feat: add OperationFailedError with actionable hints
The Kanboard API returns only true/false for many operations without
explaining why they failed. Added OperationFailedError type that
includes operation details and hints about possible causes.

Updated MoveTaskPosition and MoveTaskToProject to use this new error
type, providing users with actionable debugging information instead
of generic "failed to move task" messages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:12:25 +01:00
508c3ac6d2 fix: remove Basic prefix from custom auth headers
Custom headers like X-API-Auth expect raw base64 credentials without
the "Basic " prefix. The standard Authorization header continues to
use the proper Basic auth format via SetBasicAuth().

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:01:41 +01:00
96601980c3 fix: handle URLs already ending in /jsonrpc.php
NewClient() now detects when the provided URL already ends with
/jsonrpc.php and avoids appending it again. This prevents double-path
issues like /jsonrpc.php/jsonrpc.php.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 10:27:34 +01:00
23 changed files with 1348 additions and 14 deletions

View file

@ -1,6 +1,6 @@
{ {
"worktree_root": "/home/oli/Dev/kanboard-api", "worktree_root": "/home/oli/Dev/kanboard-api",
"last_export_commit": "169f81c4c49391498652aa3534bdc4d8fde7e7c2", "last_export_commit": "c4caf4b8761660f52e465c37cbcefa80e11aec01",
"last_export_time": "2026-01-27T10:21:24.788588574+01:00", "last_export_time": "2026-02-02T11:25:57.259799495+01:00",
"jsonl_hash": "75e9b1d700551c2a9307c7c3996c3a005f64dde4fe45621a6e7e94b709b7212e" "jsonl_hash": "ccd7fae0d9d72dc744a6028c3d1573e1df0b43bb6fb6b28d433a4a8c3c0d5eb6"
} }

View file

@ -1,5 +1,12 @@
{"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-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-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-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-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-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"} {"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"}
@ -27,4 +34,7 @@
{"id":"kanboard-api-xhf","title":"Create example programs","description":"Create example programs demonstrating library usage.\n\n## Examples to create\n```\nexamples/\n├── basic/\n│ └── main.go # Basic client setup and simple operations\n├── fluent/\n│ └── main.go # Fluent API demonstration\n└── search/\n └── main.go # Search functionality demo\n```\n\n## basic/main.go\n- Client creation with API token\n- Get all projects\n- Get tasks from a project\n- Create a simple task\n\n## fluent/main.go\n- Client configuration with all options\n- Task creation with TaskParams\n- Task updates with TaskUpdateParams\n- Tag operations\n\n## search/main.go\n- Project-specific search\n- Global search across all projects\n\n## Files to create\n- examples/basic/main.go\n- examples/fluent/main.go\n- examples/search/main.go\n\n## Acceptance criteria\n- Examples compile and are well-commented\n- Cover main use cases\n- Show both fluent and direct API styles","status":"closed","priority":3,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:53.604889443+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T19:25:02.384085528+01:00","closed_at":"2026-01-15T19:25:02.384085528+01:00","close_reason":"Created basic, fluent, and search example programs","dependencies":[{"issue_id":"kanboard-api-xhf","depends_on_id":"kanboard-api-2ze","type":"blocks","created_at":"2026-01-15T17:46:55.571585285+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-xhf","depends_on_id":"kanboard-api-una","type":"blocks","created_at":"2026-01-15T17:46:55.63515762+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-xhf","title":"Create example programs","description":"Create example programs demonstrating library usage.\n\n## Examples to create\n```\nexamples/\n├── basic/\n│ └── main.go # Basic client setup and simple operations\n├── fluent/\n│ └── main.go # Fluent API demonstration\n└── search/\n └── main.go # Search functionality demo\n```\n\n## basic/main.go\n- Client creation with API token\n- Get all projects\n- Get tasks from a project\n- Create a simple task\n\n## fluent/main.go\n- Client configuration with all options\n- Task creation with TaskParams\n- Task updates with TaskUpdateParams\n- Tag operations\n\n## search/main.go\n- Project-specific search\n- Global search across all projects\n\n## Files to create\n- examples/basic/main.go\n- examples/fluent/main.go\n- examples/search/main.go\n\n## Acceptance criteria\n- Examples compile and are well-commented\n- Cover main use cases\n- Show both fluent and direct API styles","status":"closed","priority":3,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:53.604889443+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T19:25:02.384085528+01:00","closed_at":"2026-01-15T19:25:02.384085528+01:00","close_reason":"Created basic, fluent, and search example programs","dependencies":[{"issue_id":"kanboard-api-xhf","depends_on_id":"kanboard-api-2ze","type":"blocks","created_at":"2026-01-15T17:46:55.571585285+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-xhf","depends_on_id":"kanboard-api-una","type":"blocks","created_at":"2026-01-15T17:46:55.63515762+01:00","created_by":"Oliver Jakoubek"}]}
{"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-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-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"} {"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"}

View file

@ -1,5 +1,10 @@
# kanboard-api # 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. Go library for the [Kanboard](https://kanboard.org/) JSON-RPC API. Provides a fluent, chainable API for integrating Kanboard into Go applications.
## Installation ## Installation

17
application.go Normal file
View file

@ -0,0 +1,17 @@
package kanboard
import (
"context"
"fmt"
)
// GetColorList returns the available task colors.
// Returns a map of color_id to display name (e.g., "yellow" -> "Yellow").
func (c *Client) GetColorList(ctx context.Context) (map[string]string, error) {
var result map[string]string
if err := c.call(ctx, "getColorList", nil, &result); err != nil {
return nil, fmt.Errorf("getColorList: %w", err)
}
return result, nil
}

94
application_test.go Normal file
View file

@ -0,0 +1,94 @@
package kanboard
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestClient_GetColorList(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 != "getColorList" {
t.Errorf("expected method=getColorList, got %s", req.Method)
}
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{
"yellow": "Yellow",
"blue": "Blue",
"green": "Green",
"purple": "Purple",
"red": "Red",
"orange": "Orange",
"grey": "Grey",
"brown": "Brown",
"deep_orange": "Deep Orange",
"dark_grey": "Dark Grey",
"pink": "Pink",
"teal": "Teal",
"cyan": "Cyan",
"lime": "Lime",
"light_green": "Light Green",
"amber": "Amber"
}`),
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token")
colors, err := client.GetColorList(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Check that we got some colors
if len(colors) != 16 {
t.Errorf("expected 16 colors, got %d", len(colors))
}
// Check specific colors
if colors["yellow"] != "Yellow" {
t.Errorf("expected yellow='Yellow', got %s", colors["yellow"])
}
if colors["blue"] != "Blue" {
t.Errorf("expected blue='Blue', got %s", colors["blue"])
}
if colors["deep_orange"] != "Deep Orange" {
t.Errorf("expected deep_orange='Deep Orange', got %s", colors["deep_orange"])
}
}
func TestClient_GetColorList_Empty(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")
colors, err := client.GetColorList(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(colors) != 0 {
t.Errorf("expected 0 colors, got %d", len(colors))
}
}

View file

@ -24,7 +24,7 @@ func (a *apiTokenAuth) Apply(req *http.Request) {
user = "jsonrpc" user = "jsonrpc"
} }
if a.headerName != "" { if a.headerName != "" {
req.Header.Set(a.headerName, "Basic "+basicAuthValue(user, a.token)) req.Header.Set(a.headerName, basicAuthValue(user, a.token))
} else { } else {
req.SetBasicAuth(user, a.token) req.SetBasicAuth(user, a.token)
} }
@ -40,7 +40,7 @@ type basicAuth struct {
// Apply adds HTTP Basic Auth with username and password. // Apply adds HTTP Basic Auth with username and password.
func (a *basicAuth) Apply(req *http.Request) { func (a *basicAuth) Apply(req *http.Request) {
if a.headerName != "" { if a.headerName != "" {
req.Header.Set(a.headerName, "Basic "+basicAuthValue(a.username, a.password)) req.Header.Set(a.headerName, basicAuthValue(a.username, a.password))
} else { } else {
req.SetBasicAuth(a.username, a.password) req.SetBasicAuth(a.username, a.password)
} }

View file

@ -200,8 +200,8 @@ func TestAPITokenAuth_CustomHeader(t *testing.T) {
return return
} }
// Verify the custom header has the correct Basic auth value // Verify the custom header has raw base64 value (no "Basic " prefix)
expected := "Basic " + base64Encode("jsonrpc:my-api-token") expected := base64Encode("jsonrpc:my-api-token")
if customAuth != expected { if customAuth != expected {
t.Errorf("expected X-API-Auth=%s, got %s", expected, customAuth) t.Errorf("expected X-API-Auth=%s, got %s", expected, customAuth)
} }
@ -244,8 +244,8 @@ func TestBasicAuth_CustomHeader(t *testing.T) {
return return
} }
// Verify the custom header has the correct Basic auth value // Verify the custom header has raw base64 value (no "Basic " prefix)
expected := "Basic " + base64Encode("admin:secret") expected := base64Encode("admin:secret")
if customAuth != expected { if customAuth != expected {
t.Errorf("expected X-Custom-Auth=%s, got %s", expected, customAuth) t.Errorf("expected X-Custom-Auth=%s, got %s", expected, customAuth)
} }
@ -281,8 +281,8 @@ func TestCustomHeader_WithCustomUser(t *testing.T) {
return return
} }
// Verify the custom header uses the custom username // Verify the custom header uses raw base64 value with custom username
expected := "Basic " + base64Encode("custom-user:my-token") expected := base64Encode("custom-user:my-token")
if customAuth != expected { if customAuth != expected {
t.Errorf("expected X-API-Auth=%s, got %s", expected, customAuth) t.Errorf("expected X-API-Auth=%s, got %s", expected, customAuth)
} }

View file

@ -43,6 +43,16 @@ func (b *BoardScope) CreateTask(ctx context.Context, req CreateTaskRequest) (*Ta
return b.client.CreateTask(ctx, req) 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. // CreateTaskFromParams creates a new task in the project using TaskParams.
// This provides a fluent interface for task creation. // This provides a fluent interface for task creation.
func (b *BoardScope) CreateTaskFromParams(ctx context.Context, params *TaskParams) (*Task, error) { func (b *BoardScope) CreateTaskFromParams(ctx context.Context, params *TaskParams) (*Task, error) {

View file

@ -33,3 +33,80 @@ func (c *Client) GetCategory(ctx context.Context, categoryID int) (*Category, er
return result, nil 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)
}

View file

@ -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) { func TestClient_GetAllCategories_ContextCanceled(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {} select {}

View file

@ -4,6 +4,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"strings" "strings"
"sync"
"time" "time"
) )
@ -19,6 +20,9 @@ type Client struct {
auth Authenticator auth Authenticator
logger *slog.Logger logger *slog.Logger
authHeaderName string // custom auth header, empty = use "Authorization" authHeaderName string // custom auth header, empty = use "Authorization"
timezone *time.Location
tzOnce sync.Once
tzEnabled bool
} }
// NewClient creates a new Kanboard API client. // NewClient creates a new Kanboard API client.
@ -29,9 +33,15 @@ func NewClient(baseURL string) *Client {
// Ensure no trailing slash // Ensure no trailing slash
baseURL = strings.TrimSuffix(baseURL, "/") baseURL = strings.TrimSuffix(baseURL, "/")
// Handle URLs that already include /jsonrpc.php
endpoint := baseURL
if !strings.HasSuffix(baseURL, "/jsonrpc.php") {
endpoint = baseURL + "/jsonrpc.php"
}
c := &Client{ c := &Client{
baseURL: baseURL, baseURL: baseURL,
endpoint: baseURL + "/jsonrpc.php", endpoint: endpoint,
} }
c.httpClient = &http.Client{ c.httpClient = &http.Client{
@ -97,3 +107,11 @@ func (c *Client) WithLogger(logger *slog.Logger) *Client {
c.logger = logger c.logger = logger
return c 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
}

View file

@ -44,6 +44,22 @@ func TestNewClient_Subdirectory(t *testing.T) {
} }
} }
func TestNewClient_WithJsonrpcPhpSuffix(t *testing.T) {
client := NewClient("https://example.com/jsonrpc.php")
if client.endpoint != "https://example.com/jsonrpc.php" {
t.Errorf("expected endpoint='https://example.com/jsonrpc.php', got %s", client.endpoint)
}
}
func TestNewClient_SubdirectoryWithJsonrpcPhpSuffix(t *testing.T) {
client := NewClient("https://example.com/kanboard/jsonrpc.php")
if client.endpoint != "https://example.com/kanboard/jsonrpc.php" {
t.Errorf("expected endpoint='https://example.com/kanboard/jsonrpc.php', got %s", client.endpoint)
}
}
func TestDefaultTimeout(t *testing.T) { func TestDefaultTimeout(t *testing.T) {
if DefaultTimeout != 30*time.Second { if DefaultTimeout != 30*time.Second {
t.Errorf("expected DefaultTimeout=30s, got %v", DefaultTimeout) t.Errorf("expected DefaultTimeout=30s, got %v", DefaultTimeout)

View file

@ -82,6 +82,26 @@ func (e *APIError) Error() string {
return fmt.Sprintf("Kanboard API error (code %d): %s", e.Code, e.Message) return fmt.Sprintf("Kanboard API error (code %d): %s", e.Code, e.Message)
} }
// OperationFailedError represents an API operation that returned false without details.
// The Kanboard API often returns only true/false without explaining why an operation failed.
type OperationFailedError struct {
Operation string
Hints []string
}
// Error implements the error interface.
func (e *OperationFailedError) Error() string {
msg := fmt.Sprintf("%s: operation failed", e.Operation)
if len(e.Hints) > 0 {
msg += " (possible causes: " + e.Hints[0]
for _, hint := range e.Hints[1:] {
msg += ", " + hint
}
msg += ")"
}
return msg
}
// IsNotFound returns true if the error indicates a resource was not found. // IsNotFound returns true if the error indicates a resource was not found.
func IsNotFound(err error) bool { func IsNotFound(err error) bool {
return errors.Is(err, ErrNotFound) || return errors.Is(err, ErrNotFound) ||
@ -102,3 +122,9 @@ func IsAPIError(err error) bool {
var apiErr *APIError var apiErr *APIError
return errors.As(err, &apiErr) return errors.As(err, &apiErr)
} }
// IsOperationFailed returns true if the error is an OperationFailedError.
func IsOperationFailed(err error) bool {
var opErr *OperationFailedError
return errors.As(err, &opErr)
}

View file

@ -3,6 +3,7 @@ package kanboard
import ( import (
"errors" "errors"
"fmt" "fmt"
"strings"
"testing" "testing"
) )
@ -217,3 +218,71 @@ func TestErrorWrapping(t *testing.T) {
t.Errorf("unexpected error message: %s", wrappedTwice.Error()) t.Errorf("unexpected error message: %s", wrappedTwice.Error())
} }
} }
func TestOperationFailedError(t *testing.T) {
tests := []struct {
name string
err *OperationFailedError
expectedSubstr []string
}{
{
name: "with hints",
err: &OperationFailedError{
Operation: "moveTaskPosition(task=42, column=5, project=1)",
Hints: []string{"task may not exist", "column may not belong to project"},
},
expectedSubstr: []string{
"moveTaskPosition",
"operation failed",
"possible causes",
"task may not exist",
"column may not belong to project",
},
},
{
name: "without hints",
err: &OperationFailedError{
Operation: "someOperation",
Hints: nil,
},
expectedSubstr: []string{"someOperation", "operation failed"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errMsg := tt.err.Error()
for _, substr := range tt.expectedSubstr {
if !containsSubstr(errMsg, substr) {
t.Errorf("error message %q should contain %q", errMsg, substr)
}
}
})
}
}
func TestIsOperationFailed(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{"OperationFailedError", &OperationFailedError{Operation: "test"}, true},
{"wrapped OperationFailedError", fmt.Errorf("call failed: %w", &OperationFailedError{Operation: "test"}), true},
{"ErrUnauthorized", ErrUnauthorized, false},
{"generic error", errors.New("some error"), false},
{"nil", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsOperationFailed(tt.err); got != tt.expected {
t.Errorf("IsOperationFailed(%v) = %v, want %v", tt.err, got, tt.expected)
}
})
}
}
func containsSubstr(s, substr string) bool {
return strings.Contains(s, substr)
}

View file

@ -75,3 +75,35 @@ func (c *Client) RemoveTaskFile(ctx context.Context, fileID int) error {
return nil 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
}

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
@ -322,3 +323,242 @@ func TestTaskScope_UploadFile(t *testing.T) {
t.Errorf("expected fileID=10, got %d", fileID) 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)
}
}

View file

@ -47,6 +47,12 @@ func nextRequestID() int64 {
// call sends a JSON-RPC request and parses the response. // call sends a JSON-RPC request and parses the response.
// The result parameter should be a pointer to the expected result type. // 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 { 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{ req := JSONRPCRequest{
JSONRPC: "2.0", JSONRPC: "2.0",
Method: method, 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 { if err := json.Unmarshal(rpcResp.Result, result); err != nil {
return fmt.Errorf("failed to unmarshal result: %w", err) return fmt.Errorf("failed to unmarshal result: %w", err)
} }
if c.tzEnabled && c.timezone != nil {
c.convertTimestamps(result)
}
} }
return nil return nil

View file

@ -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) 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)
}

View file

@ -24,6 +24,23 @@ func (c *Client) GetTask(ctx context.Context, taskID int) (*Task, error) {
return result, nil 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. // GetAllTasks returns all tasks for a project with the specified status.
func (c *Client) GetAllTasks(ctx context.Context, projectID int, status TaskStatus) ([]Task, error) { func (c *Client) GetAllTasks(ctx context.Context, projectID int, status TaskStatus) ([]Task, error) {
params := map[string]int{ params := map[string]int{
@ -143,7 +160,15 @@ func (c *Client) MoveTaskPosition(ctx context.Context, projectID, taskID, column
} }
if !success { if !success {
return fmt.Errorf("moveTaskPosition: failed to move task %d", taskID) return &OperationFailedError{
Operation: fmt.Sprintf("moveTaskPosition(task=%d, column=%d, project=%d)", taskID, columnID, projectID),
Hints: []string{
"task may not exist",
"column may not belong to project",
"insufficient permissions",
"task may already be in target position",
},
}
} }
return nil return nil
@ -162,7 +187,14 @@ func (c *Client) MoveTaskToProject(ctx context.Context, taskID, projectID int) e
} }
if !success { if !success {
return fmt.Errorf("moveTaskToProject: failed to move task %d to project %d", taskID, projectID) return &OperationFailedError{
Operation: fmt.Sprintf("moveTaskToProject(task=%d, project=%d)", taskID, projectID),
Hints: []string{
"task may not exist",
"target project may not exist",
"insufficient permissions",
},
}
} }
return nil return nil

View file

@ -6,6 +6,7 @@ import (
"errors" "errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
) )
@ -76,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) { func TestClient_GetAllTasks(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req JSONRPCRequest var req JSONRPCRequest
@ -639,6 +707,20 @@ func TestClient_MoveTaskPosition_Failure(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error for failed move") t.Fatal("expected error for failed move")
} }
// Verify it's an OperationFailedError with helpful hints
if !IsOperationFailed(err) {
t.Errorf("expected OperationFailedError, got %T", err)
}
// Error message should contain actionable hints
errMsg := err.Error()
if !strings.Contains(errMsg, "moveTaskPosition") {
t.Error("error should mention operation name")
}
if !strings.Contains(errMsg, "possible causes") {
t.Error("error should include possible causes")
}
} }
func TestClient_MoveTaskToProject(t *testing.T) { func TestClient_MoveTaskToProject(t *testing.T) {

81
timezone.go Normal file
View file

@ -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))
}
}
}

233
timezone_test.go Normal file
View file

@ -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())
}
})
}

View file

@ -165,6 +165,7 @@ type Task struct {
DateCompleted Timestamp `json:"date_completed"` DateCompleted Timestamp `json:"date_completed"`
DateStarted Timestamp `json:"date_started"` DateStarted Timestamp `json:"date_started"`
DateDue Timestamp `json:"date_due"` DateDue Timestamp `json:"date_due"`
DateMoved Timestamp `json:"date_moved"`
ColorID string `json:"color_id"` ColorID string `json:"color_id"`
ProjectID StringInt `json:"project_id"` ProjectID StringInt `json:"project_id"`
ColumnID StringInt `json:"column_id"` ColumnID StringInt `json:"column_id"`
@ -237,6 +238,8 @@ type TaskFile struct {
DateCreation Timestamp `json:"date_creation"` DateCreation Timestamp `json:"date_creation"`
UserID StringInt `json:"user_id"` UserID StringInt `json:"user_id"`
Size StringInt64 `json:"size"` 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. // Tag represents a Kanboard tag.