From 610998283ba7640c9168b8554b2f41504519ca0b Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Thu, 15 Jan 2026 18:45:11 +0100 Subject: [PATCH] Implement File API methods - GetAllTaskFiles: retrieve all files attached to a task - CreateTaskFile: upload file with base64 encoding - DownloadTaskFile: download file with base64 decoding - RemoveTaskFile: delete a task file - TaskScope.GetFiles and UploadFile for fluent API - Automatic base64 encoding/decoding for file content - Comprehensive test coverage --- .beads/issues.jsonl | 2 +- files.go | 77 +++++++++++ files_test.go | 324 ++++++++++++++++++++++++++++++++++++++++++++ task_scope.go | 15 ++ 4 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 files.go create mode 100644 files_test.go diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 1c2269e..ba52331 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -24,4 +24,4 @@ {"id":"kanboard-api-una","title":"Implement TaskScope fluent builder (core methods)","description":"Implement TaskScope for fluent task-scoped operations (core methods).\n\n## Requirements\n- TaskScope struct with client and taskID\n- Client.Task(taskID int) *TaskScope method\n- Core TaskScope methods:\n - Get(ctx) (*Task, error)\n - Close(ctx) error\n - Open(ctx) error\n - MoveToColumn(ctx, columnID int) error\n - MoveToProject(ctx, projectID int) error\n - Update(ctx, params *TaskUpdateParams) error\n\n## Files to create\n- task_scope.go\n\n## Example usage\n```go\ntask, _ := client.Task(123).Get(ctx)\nclient.Task(123).Close(ctx)\nclient.Task(123).Update(ctx, kanboard.NewTaskUpdate().SetTitle(\"New\"))\n```\n\n## Acceptance criteria\n- All methods delegate to direct Client methods\n- Proper error propagation","status":"closed","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:41.173930396+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:34:32.369975567+01:00","closed_at":"2026-01-15T18:34:32.369975567+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-una","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:43:31.198935686+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-una","depends_on_id":"kanboard-api-5fb","type":"blocks","created_at":"2026-01-15T17:43:31.261453756+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":"open","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-15T17:36:53.604889443+01:00","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-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":"open","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-15T17:36:09.748005313+01:00","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"}]} diff --git a/files.go b/files.go new file mode 100644 index 0000000..929db4e --- /dev/null +++ b/files.go @@ -0,0 +1,77 @@ +package kanboard + +import ( + "context" + "encoding/base64" + "fmt" +) + +// GetAllTaskFiles returns all files attached to a task. +func (c *Client) GetAllTaskFiles(ctx context.Context, taskID int) ([]TaskFile, error) { + params := map[string]int{"task_id": taskID} + + var result []TaskFile + if err := c.call(ctx, "getAllTaskFiles", params, &result); err != nil { + return nil, fmt.Errorf("getAllTaskFiles: %w", err) + } + + return result, nil +} + +// CreateTaskFile uploads a file to a task. +// The file content is automatically base64 encoded. +// Returns the ID of the created file. +func (c *Client) CreateTaskFile(ctx context.Context, projectID, taskID int, filename string, content []byte) (int, error) { + params := map[string]any{ + "project_id": projectID, + "task_id": taskID, + "filename": filename, + "blob": base64.StdEncoding.EncodeToString(content), + } + + var result int + if err := c.call(ctx, "createTaskFile", params, &result); err != nil { + return 0, fmt.Errorf("createTaskFile: %w", err) + } + + if result == 0 { + return 0, fmt.Errorf("createTaskFile: failed to upload file") + } + + return result, nil +} + +// DownloadTaskFile downloads a file's content by its ID. +// The content is returned as raw bytes (decoded from base64). +func (c *Client) DownloadTaskFile(ctx context.Context, fileID int) ([]byte, error) { + params := map[string]int{"file_id": fileID} + + var result string + if err := c.call(ctx, "downloadTaskFile", params, &result); err != nil { + return nil, fmt.Errorf("downloadTaskFile: %w", err) + } + + // Decode base64 content + content, err := base64.StdEncoding.DecodeString(result) + if err != nil { + return nil, fmt.Errorf("downloadTaskFile: failed to decode content: %w", err) + } + + return content, nil +} + +// RemoveTaskFile deletes a file from a task. +func (c *Client) RemoveTaskFile(ctx context.Context, fileID int) error { + params := map[string]int{"file_id": fileID} + + var success bool + if err := c.call(ctx, "removeTaskFile", params, &success); err != nil { + return fmt.Errorf("removeTaskFile: %w", err) + } + + if !success { + return fmt.Errorf("removeTaskFile: delete failed") + } + + return nil +} diff --git a/files_test.go b/files_test.go new file mode 100644 index 0000000..027c655 --- /dev/null +++ b/files_test.go @@ -0,0 +1,324 @@ +package kanboard + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClient_GetAllTaskFiles(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 != "getAllTaskFiles" { + t.Errorf("expected method=getAllTaskFiles, 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(`[ + {"id": "1", "name": "document.pdf", "path": "files/1", "is_image": "0", "task_id": "42", "user_id": "1", "size": "1024"}, + {"id": "2", "name": "screenshot.png", "path": "files/2", "is_image": "1", "task_id": "42", "user_id": "1", "size": "2048"} + ]`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + files, err := client.GetAllTaskFiles(context.Background(), 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(files) != 2 { + t.Errorf("expected 2 files, got %d", len(files)) + } + if files[0].Name != "document.pdf" { + t.Errorf("expected name='document.pdf', got %s", files[0].Name) + } + if bool(files[1].IsImage) != true { + t.Error("expected second file to be an image") + } +} + +func TestClient_GetAllTaskFiles_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") + + files, err := client.GetAllTaskFiles(context.Background(), 42) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(files) != 0 { + t.Errorf("expected 0 files, got %d", len(files)) + } +} + +func TestClient_CreateTaskFile(t *testing.T) { + testContent := []byte("test 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 != "createTaskFile" { + t.Errorf("expected method=createTaskFile, 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["task_id"].(float64) != 42 { + t.Errorf("expected task_id=42, got %v", params["task_id"]) + } + if params["filename"] != "test.txt" { + t.Errorf("expected filename='test.txt', got %v", params["filename"]) + } + + // Verify base64 encoded content + expectedBlob := base64.StdEncoding.EncodeToString(testContent) + if params["blob"] != expectedBlob { + t.Errorf("expected blob to be base64 encoded") + } + + 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") + + fileID, err := client.CreateTaskFile(context.Background(), 1, 42, "test.txt", testContent) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if fileID != 10 { + t.Errorf("expected fileID=10, got %d", fileID) + } +} + +func TestClient_CreateTaskFile_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(`0`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + _, err := client.CreateTaskFile(context.Background(), 1, 42, "test.txt", []byte("content")) + if err == nil { + t.Fatal("expected error for failed file upload") + } +} + +func TestClient_DownloadTaskFile(t *testing.T) { + testContent := []byte("downloaded 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) + } + + params := req.Params.(map[string]any) + if params["file_id"].(float64) != 5 { + t.Errorf("expected file_id=5, got %v", params["file_id"]) + } + + // Return base64 encoded content + 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.DownloadTaskFile(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 TestClient_RemoveTaskFile(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) + } + + 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(`true`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + err := client.RemoveTaskFile(context.Background(), 5) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestClient_RemoveTaskFile_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.RemoveTaskFile(context.Background(), 5) + if err == nil { + t.Fatal("expected error for failed delete") + } +} + +func TestTaskScope_GetFiles(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 != "getAllTaskFiles" { + t.Errorf("expected method=getAllTaskFiles, got %s", req.Method) + } + + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`[{"id": "1", "name": "file.txt", "task_id": "42", "is_image": "0"}]`), + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + files, err := client.Task(42).GetFiles(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(files) != 1 { + t.Errorf("expected 1 file, got %d", len(files)) + } +} + +func TestTaskScope_UploadFile(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) + + callCount++ + if callCount == 1 { + // First call: getTask to get project_id + resp := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: json.RawMessage(`{"id": "42", "project_id": "1", "is_active": "1"}`), + } + json.NewEncoder(w).Encode(resp) + } else { + // Second call: createTaskFile + if req.Method != "createTaskFile" { + t.Errorf("expected method=createTaskFile, 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["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(`10`), + } + json.NewEncoder(w).Encode(resp) + } + })) + defer server.Close() + + client := NewClient(server.URL).WithAPIToken("test-token") + + fileID, err := client.Task(42).UploadFile(context.Background(), "test.txt", []byte("content")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if fileID != 10 { + t.Errorf("expected fileID=10, got %d", fileID) + } +} diff --git a/task_scope.go b/task_scope.go index 0f1e72d..446c8db 100644 --- a/task_scope.go +++ b/task_scope.go @@ -266,3 +266,18 @@ func (t *TaskScope) LinkTo(ctx context.Context, oppositeTaskID, linkID int) erro _, err := t.client.CreateTaskLink(ctx, t.taskID, oppositeTaskID, linkID) return err } + +// GetFiles returns all files attached to this task. +func (t *TaskScope) GetFiles(ctx context.Context) ([]TaskFile, error) { + return t.client.GetAllTaskFiles(ctx, t.taskID) +} + +// UploadFile uploads a file to this task and returns the file ID. +// The file content is automatically base64 encoded. +func (t *TaskScope) UploadFile(ctx context.Context, filename string, content []byte) (int, error) { + task, err := t.Get(ctx) + if err != nil { + return 0, err + } + return t.client.CreateTaskFile(ctx, int(task.ProjectID), t.taskID, filename, content) +}