diff --git a/.beads/export-state/fbbdf412d0fd5173.json b/.beads/export-state/fbbdf412d0fd5173.json index 3e8e777..6b9418d 100644 --- a/.beads/export-state/fbbdf412d0fd5173.json +++ b/.beads/export-state/fbbdf412d0fd5173.json @@ -1,6 +1,6 @@ { "worktree_root": "/home/oli/Dev/kanboard-api", - "last_export_commit": "c4caf4b8761660f52e465c37cbcefa80e11aec01", - "last_export_time": "2026-02-02T11:25:57.259799495+01:00", - "jsonl_hash": "ccd7fae0d9d72dc744a6028c3d1573e1df0b43bb6fb6b28d433a4a8c3c0d5eb6" + "last_export_commit": "4e856cd206ba4e7e09d30d0f4892972df87db4a6", + "last_export_time": "2026-01-30T12:30:52.561186642+01:00", + "jsonl_hash": "46173e2c776a1bcf136d8903a3c384211ff17e44a6e7ba080445e5e0e0435749" } \ No newline at end of file diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 96b9fde..619c55d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -36,5 +36,4 @@ {"id":"kanboard-api-zg2","title":"Implement File API methods","description":"Implement direct API methods for task file operations.\n\n## Methods to implement (Must-have)\n- GetAllTaskFiles(ctx, taskID int) ([]TaskFile, error) - getAllTaskFiles\n- CreateTaskFile(ctx, projectID, taskID int, filename string, content []byte) (int, error) - createTaskFile\n\n## Methods to implement (Nice-to-have)\n- DownloadTaskFile(ctx, fileID int) ([]byte, error) - downloadTaskFile\n- RemoveTaskFile(ctx, fileID int) error - removeTaskFile\n\n## TaskScope methods to add\n- GetFiles(ctx) ([]TaskFile, error)\n- UploadFile(ctx, filename string, content []byte) (*TaskFile, error)\n\n## Files to create\n- files.go\n- task_scope.go (extend)\n\n## Acceptance criteria\n- File content base64 encoded for upload\n- CreateTaskFile returns file ID","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:09.748005313+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:45:05.499871045+01:00","closed_at":"2026-01-15T18:45:05.499871045+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-zg2","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.984099418+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-zg2","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:50.049331328+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-iuf","title":"Remove Basic prefix from custom auth headers","description":"## Problem\n\nWhen using a custom header name (e.g., `X-API-Auth` for Kanboard), the library incorrectly prepends `\"Basic \"` to the base64-encoded credentials.\n\n**Current behavior** (`auth.go:27` and `auth.go:43`):\n```go\nreq.Header.Set(a.headerName, \"Basic \"+basicAuthValue(user, a.token))\n```\n\nSends: `X-API-Auth: Basic dXNlcjpwYXNzd29yZA==`\n\n**Expected behavior** per [Kanboard API docs](https://docs.kanboard.org/v1/api/authentication/):\n\n- Standard `Authorization` header: `Basic base64(username:password)` ✓\n- Custom `X-API-Auth` header: `base64(username:password)` (no \"Basic \" prefix!)\n\nShould send: `X-API-Auth: dXNlcjpwYXNzd29yZA==`\n\n## Impact\n\nAuthentication fails when using the custom header option. The server returns HTML (login page) instead of JSON because authentication is rejected.\n\n## Suggested Fix\n\nIn both `apiTokenAuth.Apply()` and `basicAuth.Apply()`, remove the \"Basic \" prefix when using a custom header name:\n\n```go\nif a.headerName != \"\" {\n req.Header.Set(a.headerName, basicAuthValue(user, a.token)) // no \"Basic \" prefix\n} else {\n req.SetBasicAuth(user, a.token) // uses standard Authorization header with \"Basic \"\n}\n```\n\n## Affected Code\n\n- `auth.go:27` - `apiTokenAuth.Apply()`\n- `auth.go:43` - `basicAuth.Apply()`\n\n## Acceptance Criteria\n\n- [ ] Custom header (`X-API-Auth`) sends raw base64 value without \"Basic \" prefix\n- [ ] Standard `Authorization` header still works with \"Basic \" prefix\n- [ ] Tests updated to verify both behaviors\n- [ ] All tests passing","status":"closed","priority":1,"issue_type":"bug","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T10:43:21.717917986+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T11:01:19.467981335+01:00","closed_at":"2026-01-27T11:01:19.467981335+01:00","close_reason":"Closed"} {"id":"kanboard-n03","title":"Implement Swimlane CRUD methods","description":"The kanboard-api client already supports swimlane_id on tasks (InSwimlane builder, MoveTaskPosition, Task.SwimlaneID field) but lacks dedicated Swimlane management methods.\n\nImplement the following Kanboard JSON-RPC API methods:\n\n**Read operations:**\n- getActiveSwimlanes(project_id)\n- getAllSwimlanes(project_id)\n- getSwimlane(swimlane_id)\n- getSwimlaneById(swimlane_id)\n- getSwimlaneByName(project_id, name)\n\n**Write operations:**\n- addSwimlane(project_id, name, description)\n- updateSwimlane(swimlane_id, name, description)\n- removeSwimlane(project_id, swimlane_id)\n- changeSwimlanePosition(project_id, swimlane_id, position)\n- enableSwimlane(project_id, swimlane_id)\n- disableSwimlane(project_id, swimlane_id)\n\nCreate a new file `swimlanes.go` with a Swimlane struct:\n```go\ntype Swimlane struct {\n ID int\n Name string\n Description string\n Position int\n IsActive bool\n ProjectID int\n}\n```\n\nAnd corresponding test file `swimlanes_test.go`.\n\n## Acceptance Criteria\n- [ ] Swimlane struct defined with ID, Name, Description, Position, IsActive, ProjectID\n- [ ] All 11 JSON-RPC methods implemented\n- [ ] Read methods: getActiveSwimlanes, getAllSwimlanes, getSwimlane, getSwimlaneById, getSwimlaneByName\n- [ ] Write methods: addSwimlane, updateSwimlane, removeSwimlane, changeSwimlanePosition, enableSwimlane, disableSwimlane\n- [ ] Code in swimlanes.go follows existing patterns (e.g., categories.go)\n- [ ] Tests written in swimlanes_test.go and passing","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-01T10:07:54.192295081+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-01T10:07:54.192295081+01:00"} -{"id":"kanboard-qle","title":"Add timezone support with automatic timestamp conversion","description":"## Description\n\nImplement timezone support by calling the Kanboard `getTimezone` API endpoint and converting all UTC timestamps to the configured timezone.\n\nThe Kanboard API provides `getTimezone` which returns a timezone string (e.g. `Europe/Berlin`). The client should:\n\n1. Query the timezone on initialization (or lazily on first use)\n2. Automatically convert all timestamp fields to local time, including:\n - `date_creation`, `date_started`, `date_moved`, `date_due`\n - Comment dates (`date_creation`)\n - Any other timestamp fields returned by the API\n\n### Design Option\n\nProvide a `WithTimezone()` client option so callers can control whether conversion happens. When enabled, the client fetches the timezone from the API and converts timestamps transparently. When disabled (default for backward compatibility), timestamps remain as-is.\n\n## Acceptance Criteria\n\n- [ ] Implement `GetTimezone()` API method that calls `getTimezone`\n- [ ] Add `WithTimezone()` client option to enable automatic conversion\n- [ ] When enabled, all timestamp fields in responses are converted to the configured timezone\n- [ ] Timezone is fetched once and cached for the client lifetime\n- [ ] Tests written and passing for timezone conversion logic\n- [ ] Backward compatible: no behavior change without `WithTimezone()` option","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-02T12:25:32.830875466+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-02T12:34:10.955810869+01:00","closed_at":"2026-02-02T12:34:10.955810869+01:00","close_reason":"Closed"} {"id":"kanboard-r3p","title":"Support optional API user in token authentication","description":"## Description\n\nBei der API-Token-Authentifizierung soll neben dem API-Key optional auch ein API-User konfigurierbar sein. Wenn kein User angegeben wird, soll weiterhin der Standard-User \"jsonrpc\" verwendet werden.\n\n## Current Behavior\n\nIn `auth.go`, the `apiTokenAuth` struct hardcodes the username \"jsonrpc\":\n\n```go\nfunc (a *apiTokenAuth) Apply(req *http.Request) {\n req.SetBasicAuth(\"jsonrpc\", a.token)\n}\n```\n\n## Expected Behavior\n\n- Add an optional `user` field to `apiTokenAuth`\n- If user is empty/not provided, default to \"jsonrpc\"\n- If user is provided, use that value for HTTP Basic Auth\n\n## Acceptance Criteria\n\n- [ ] `apiTokenAuth` struct has an optional user field\n- [ ] Default behavior unchanged when no user specified (uses \"jsonrpc\")\n- [ ] Custom user is used when explicitly provided\n- [ ] Client configuration supports setting the API user\n- [ ] Tests cover both default and custom user scenarios","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-23T17:39:37.745294723+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-23T17:55:23.860864584+01:00","closed_at":"2026-01-23T17:55:23.860864584+01:00","close_reason":"Closed"} diff --git a/client.go b/client.go index 60ebb2d..fe41906 100644 --- a/client.go +++ b/client.go @@ -4,7 +4,6 @@ import ( "log/slog" "net/http" "strings" - "sync" "time" ) @@ -20,9 +19,6 @@ type Client struct { auth Authenticator logger *slog.Logger authHeaderName string // custom auth header, empty = use "Authorization" - timezone *time.Location - tzOnce sync.Once - tzEnabled bool } // NewClient creates a new Kanboard API client. @@ -107,11 +103,3 @@ func (c *Client) WithLogger(logger *slog.Logger) *Client { c.logger = logger return c } - -// WithTimezone enables automatic timestamp conversion. On the first API call, -// the client fetches the server's timezone via getTimezone and converts all -// Timestamp fields in responses to that timezone. -func (c *Client) WithTimezone() *Client { - c.tzEnabled = true - return c -} diff --git a/jsonrpc.go b/jsonrpc.go index 9e49b11..290de89 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -47,12 +47,6 @@ func nextRequestID() int64 { // call sends a JSON-RPC request and parses the response. // The result parameter should be a pointer to the expected result type. func (c *Client) call(ctx context.Context, method string, params interface{}, result interface{}) error { - if method != "getTimezone" { - if err := c.ensureTimezone(ctx); err != nil { - return fmt.Errorf("failed to load timezone: %w", err) - } - } - req := JSONRPCRequest{ JSONRPC: "2.0", Method: method, @@ -144,9 +138,6 @@ func (c *Client) call(ctx context.Context, method string, params interface{}, re if err := json.Unmarshal(rpcResp.Result, result); err != nil { return fmt.Errorf("failed to unmarshal result: %w", err) } - if c.tzEnabled && c.timezone != nil { - c.convertTimestamps(result) - } } return nil diff --git a/timezone.go b/timezone.go deleted file mode 100644 index 96353e2..0000000 --- a/timezone.go +++ /dev/null @@ -1,81 +0,0 @@ -package kanboard - -import ( - "context" - "fmt" - "reflect" - "time" -) - -// GetTimezone returns the server's configured timezone string (e.g., "UTC", "Europe/Berlin"). -func (c *Client) GetTimezone(ctx context.Context) (string, error) { - var tz string - if err := c.call(ctx, "getTimezone", nil, &tz); err != nil { - return "", fmt.Errorf("getTimezone: %w", err) - } - return tz, nil -} - -// loadTimezone fetches and caches the timezone location from the server. -func (c *Client) loadTimezone(ctx context.Context) error { - tz, err := c.GetTimezone(ctx) - if err != nil { - return err - } - loc, err := time.LoadLocation(tz) - if err != nil { - return fmt.Errorf("invalid timezone %q: %w", tz, err) - } - c.timezone = loc - return nil -} - -// ensureTimezone loads the timezone if tzEnabled and not yet loaded. -func (c *Client) ensureTimezone(ctx context.Context) error { - if !c.tzEnabled { - return nil - } - var err error - c.tzOnce.Do(func() { - err = c.loadTimezone(ctx) - }) - return err -} - -// convertTimestamps converts all Timestamp fields in v to the client's timezone. -// v must be a pointer. Handles structs, pointers to structs, and slices of structs. -func (c *Client) convertTimestamps(v any) { - if c.timezone == nil { - return - } - rv := reflect.ValueOf(v) - c.walkAndConvert(rv) -} - -var timestampType = reflect.TypeOf(Timestamp{}) - -func (c *Client) walkAndConvert(rv reflect.Value) { - switch rv.Kind() { - case reflect.Ptr: - if !rv.IsNil() { - c.walkAndConvert(rv.Elem()) - } - case reflect.Struct: - if rv.Type() == timestampType { - if rv.CanSet() { - ts := rv.Addr().Interface().(*Timestamp) - if !ts.IsZero() { - ts.Time = ts.Time.In(c.timezone) - } - } - return - } - for i := 0; i < rv.NumField(); i++ { - c.walkAndConvert(rv.Field(i)) - } - case reflect.Slice: - for i := 0; i < rv.Len(); i++ { - c.walkAndConvert(rv.Index(i)) - } - } -} diff --git a/timezone_test.go b/timezone_test.go deleted file mode 100644 index 8c6df87..0000000 --- a/timezone_test.go +++ /dev/null @@ -1,233 +0,0 @@ -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()) - } - }) -}