diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 9fe2dc1..c93605d 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,6 +1,6 @@ {"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":"open","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-15T17:35:16.6133153+01:00","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":"open","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-15T17:36:08.526810135+01:00","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":"open","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-15T17:34:55.0044989+01:00"} +{"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-2g1","title":"Implement JSON-RPC client foundation","description":"Implement the core JSON-RPC 2.0 client for Kanboard API communication.\n\n## Requirements\n- JSONRPCRequest struct with jsonrpc, method, id, params fields\n- JSONRPCResponse struct with jsonrpc, id, result, error fields \n- JSONRPCError struct with code and message\n- Generic `call` method to send requests and parse responses\n- Automatic `/jsonrpc.php` path appending to baseURL\n- Thread-safe request ID generation via atomic.Int64\n\n## Files to create\n- jsonrpc.go\n\n## Acceptance criteria\n- All JSON-RPC structs properly marshal/unmarshal\n- Request IDs increment atomically\n- Supports subdirectory installations (e.g. /kanboard/jsonrpc.php)","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:53.232007312+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:10:29.68466887+01:00","closed_at":"2026-01-15T18:10:29.68466887+01:00","close_reason":"Closed"} {"id":"kanboard-api-2ze","title":"Implement BoardScope fluent builder","description":"Implement BoardScope for fluent project-scoped operations.\n\n## Requirements\n- BoardScope struct with client and projectID\n- Client.Board(projectID int) *BoardScope method\n- BoardScope methods:\n - GetColumns(ctx) ([]Column, error)\n - GetCategories(ctx) ([]Category, error)\n - GetTasks(ctx, status TaskStatus) ([]Task, error)\n - SearchTasks(ctx, query string) ([]Task, error)\n - CreateTask(ctx, task *TaskParams) (*Task, error)\n\n## Files to create\n- board_scope.go\n\n## Example usage\n```go\ncolumns, _ := client.Board(1).GetColumns(ctx)\ntask, _ := client.Board(1).CreateTask(ctx, kanboard.NewTask(\"Title\"))\n```\n\n## Acceptance criteria\n- All methods delegate to direct Client methods\n- Proper error propagation","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:40.044649709+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:40.044649709+01:00","dependencies":[{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-apl","type":"blocks","created_at":"2026-01-15T17:43:30.81063282+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-l9b","type":"blocks","created_at":"2026-01-15T17:43:30.874964284+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-0fz","type":"blocks","created_at":"2026-01-15T17:43:30.939377116+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:43:31.005026627+01:00","created_by":"Oliver Jakoubek"}]} {"id":"kanboard-api-3dc","title":"Implement Link API methods","description":"Implement direct API methods for task link operations.\n\n## Methods to implement\n- GetAllTaskLinks(ctx, taskID int) ([]TaskLink, error) - getAllTaskLinks\n- CreateTaskLink(ctx, taskID, oppositeTaskID, linkID int) (int, error) - createTaskLink\n- RemoveTaskLink(ctx, taskLinkID int) error - removeTaskLink (Nice-to-have)\n\n## TaskScope methods to add\n- GetLinks(ctx) ([]TaskLink, error)\n- LinkTo(ctx, oppositeTaskID, linkID int) error\n\n## Files to create\n- links.go\n- task_scope.go (extend)\n\n## Acceptance criteria\n- CreateTaskLink returns the link ID\n- Links include related task information","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:09.328552773+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:09.328552773+01:00","dependencies":[{"issue_id":"kanboard-api-3dc","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.785710003+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-3dc","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.886111429+01:00","created_by":"Oliver Jakoubek"}]} diff --git a/timestamp.go b/timestamp.go new file mode 100644 index 0000000..4666b52 --- /dev/null +++ b/timestamp.go @@ -0,0 +1,64 @@ +package kanboard + +import ( + "encoding/json" + "fmt" + "time" +) + +// Timestamp wraps time.Time and handles Kanboard's Unix timestamp JSON format. +// Kanboard returns timestamps as Unix integers, with 0 or empty strings for null values. +type Timestamp struct { + time.Time +} + +// UnmarshalJSON implements json.Unmarshaler. +// Supports Unix timestamps as integers, empty strings, "0" strings, and zero values. +func (t *Timestamp) UnmarshalJSON(data []byte) error { + // Try to unmarshal as integer (Unix timestamp) + var unix int64 + if err := json.Unmarshal(data, &unix); err == nil { + if unix == 0 { + t.Time = time.Time{} + } else { + t.Time = time.Unix(unix, 0) + } + return nil + } + + // Try to unmarshal as string (empty or "0") + var str string + if err := json.Unmarshal(data, &str); err == nil { + if str == "" || str == "0" { + t.Time = time.Time{} + return nil + } + // Try to parse as numeric string + var unix int64 + if _, err := fmt.Sscanf(str, "%d", &unix); err == nil { + if unix == 0 { + t.Time = time.Time{} + } else { + t.Time = time.Unix(unix, 0) + } + return nil + } + } + + // Handle null + if string(data) == "null" { + t.Time = time.Time{} + return nil + } + + return fmt.Errorf("cannot unmarshal timestamp: %s", string(data)) +} + +// MarshalJSON implements json.Marshaler. +// Returns 0 for zero time, otherwise returns Unix timestamp. +func (t Timestamp) MarshalJSON() ([]byte, error) { + if t.IsZero() { + return []byte("0"), nil + } + return json.Marshal(t.Unix()) +} diff --git a/timestamp_test.go b/timestamp_test.go new file mode 100644 index 0000000..a931522 --- /dev/null +++ b/timestamp_test.go @@ -0,0 +1,203 @@ +package kanboard + +import ( + "encoding/json" + "testing" + "time" +) + +func TestTimestamp_UnmarshalJSON_Integer(t *testing.T) { + tests := []struct { + name string + input string + expected time.Time + }{ + { + name: "positive unix timestamp", + input: "1609459200", + expected: time.Unix(1609459200, 0), // 2021-01-01 00:00:00 UTC + }, + { + name: "zero", + input: "0", + expected: time.Time{}, + }, + { + name: "recent timestamp", + input: "1704067200", + expected: time.Unix(1704067200, 0), // 2024-01-01 00:00:00 UTC + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ts Timestamp + if err := json.Unmarshal([]byte(tt.input), &ts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ts.Time.Equal(tt.expected) { + t.Errorf("expected %v, got %v", tt.expected, ts.Time) + } + }) + } +} + +func TestTimestamp_UnmarshalJSON_String(t *testing.T) { + tests := []struct { + name string + input string + expected time.Time + }{ + { + name: "empty string", + input: `""`, + expected: time.Time{}, + }, + { + name: "zero string", + input: `"0"`, + expected: time.Time{}, + }, + { + name: "numeric string", + input: `"1609459200"`, + expected: time.Unix(1609459200, 0), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ts Timestamp + if err := json.Unmarshal([]byte(tt.input), &ts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ts.Time.Equal(tt.expected) { + t.Errorf("expected %v, got %v", tt.expected, ts.Time) + } + }) + } +} + +func TestTimestamp_UnmarshalJSON_Null(t *testing.T) { + var ts Timestamp + if err := json.Unmarshal([]byte("null"), &ts); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ts.IsZero() { + t.Errorf("expected zero time for null, got %v", ts.Time) + } +} + +func TestTimestamp_MarshalJSON(t *testing.T) { + tests := []struct { + name string + ts Timestamp + expected string + }{ + { + name: "zero time", + ts: Timestamp{}, + expected: "0", + }, + { + name: "positive timestamp", + ts: Timestamp{Time: time.Unix(1609459200, 0)}, + expected: "1609459200", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(tt.ts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(data) != tt.expected { + t.Errorf("expected %s, got %s", tt.expected, string(data)) + } + }) + } +} + +func TestTimestamp_RoundTrip(t *testing.T) { + // Test that marshal/unmarshal produces the same result + original := Timestamp{Time: time.Unix(1609459200, 0)} + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + + var parsed Timestamp + if err := json.Unmarshal(data, &parsed); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if !original.Time.Equal(parsed.Time) { + t.Errorf("round trip failed: %v != %v", original.Time, parsed.Time) + } +} + +func TestTimestamp_InStruct(t *testing.T) { + // Test Timestamp as part of a struct (simulating API response) + type Task struct { + ID int `json:"id"` + DateCreation Timestamp `json:"date_creation"` + DateDue Timestamp `json:"date_due"` + } + + jsonData := `{"id":42,"date_creation":1609459200,"date_due":0}` + + var task Task + if err := json.Unmarshal([]byte(jsonData), &task); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if task.ID != 42 { + t.Errorf("expected ID=42, got %d", task.ID) + } + if task.DateCreation.IsZero() { + t.Error("DateCreation should not be zero") + } + if !task.DateDue.IsZero() { + t.Error("DateDue should be zero") + } +} + +func TestTimestamp_InStructWithStringTimestamp(t *testing.T) { + // Test with string timestamps (Kanboard sometimes returns these) + type Task struct { + ID int `json:"id"` + DateCreation Timestamp `json:"date_creation"` + DateDue Timestamp `json:"date_due"` + } + + jsonData := `{"id":42,"date_creation":"1609459200","date_due":""}` + + var task Task + if err := json.Unmarshal([]byte(jsonData), &task); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if task.ID != 42 { + t.Errorf("expected ID=42, got %d", task.ID) + } + if task.DateCreation.IsZero() { + t.Error("DateCreation should not be zero") + } + if !task.DateDue.IsZero() { + t.Error("DateDue should be zero") + } +} + +func TestTimestamp_IsZero(t *testing.T) { + var zero Timestamp + if !zero.IsZero() { + t.Error("default Timestamp should be zero") + } + + nonZero := Timestamp{Time: time.Unix(1609459200, 0)} + if nonZero.IsZero() { + t.Error("non-zero Timestamp should not be zero") + } +}