checkvist-api/tasks.go
Oliver Jakoubek b716d4d0fe Fix Close/Invalidate/Reopen to handle API array response
The Checkvist API returns an array of tasks (containing the modified task
and potentially its subtasks) for close, reopen, and invalidate operations.
The code was incorrectly trying to decode into a single Task struct.

Changes:
- Decode response into []Task instead of Task
- Return first element (the modified task)
- Add defensive error handling for empty arrays
- Update tests to mock array responses

Fixes: checkvist-api-2zr
2026-01-15 10:48:38 +01:00

275 lines
7.5 KiB
Go

package checkvist
import (
"context"
"fmt"
"time"
)
// tasks.go contains the TaskService for CRUD operations on tasks within a checklist.
// TaskService provides operations on tasks within a specific checklist.
type TaskService struct {
client *Client
checklistID int
}
// Tasks returns a TaskService for performing task operations on the specified checklist.
func (c *Client) Tasks(checklistID int) *TaskService {
return &TaskService{
client: c,
checklistID: checklistID,
}
}
// List returns all tasks in the checklist.
func (s *TaskService) List(ctx context.Context) ([]Task, error) {
path := fmt.Sprintf("/checklists/%d/tasks.json", s.checklistID)
var tasks []Task
if err := s.client.doGet(ctx, path, &tasks); err != nil {
return nil, err
}
// Parse due dates
for i := range tasks {
parseDueDate(&tasks[i])
}
return tasks, nil
}
// Get returns a single task by ID, including its parent hierarchy.
func (s *TaskService) Get(ctx context.Context, taskID int) (*Task, error) {
path := fmt.Sprintf("/checklists/%d/tasks/%d.json", s.checklistID, taskID)
var task Task
if err := s.client.doGet(ctx, path, &task); err != nil {
return nil, err
}
parseDueDate(&task)
return &task, nil
}
// CreateTaskRequest represents the request body for creating a task.
type CreateTaskRequest struct {
Content string `json:"content"`
ParentID int `json:"parent_id,omitempty"`
Position int `json:"position,omitempty"`
Due string `json:"due_date,omitempty"`
Priority int `json:"priority,omitempty"`
Tags string `json:"tags,omitempty"`
Repeat string `json:"repeat,omitempty"`
}
// createTaskWrapper wraps the task fields for the nested JSON format
// expected by the Checkvist API: {"task": {"content": "...", ...}}
type createTaskWrapper struct {
Task CreateTaskRequest `json:"task"`
}
// TaskBuilder provides a fluent interface for building task creation requests.
type TaskBuilder struct {
content string
parentID int
position int
due string
priority int
tags []string
repeat string
}
// NewTask creates a new TaskBuilder with the given content.
func NewTask(content string) *TaskBuilder {
return &TaskBuilder{content: content}
}
// WithParent sets the parent task ID for creating a subtask.
func (b *TaskBuilder) WithParent(parentID int) *TaskBuilder {
b.parentID = parentID
return b
}
// WithPosition sets the position of the task within its siblings.
func (b *TaskBuilder) WithPosition(position int) *TaskBuilder {
b.position = position
return b
}
// WithDueDate sets the due date using a DueDate value.
func (b *TaskBuilder) WithDueDate(due DueDate) *TaskBuilder {
b.due = due.String()
return b
}
// WithPriority sets the priority level (1 = highest, 2 = high, 0 = normal).
func (b *TaskBuilder) WithPriority(priority int) *TaskBuilder {
b.priority = priority
return b
}
// WithTags sets the tags for the task.
func (b *TaskBuilder) WithTags(tags ...string) *TaskBuilder {
b.tags = tags
return b
}
// WithRepeat sets the repeat pattern for the task using Checkvist's smart syntax.
// Common patterns include:
// - "daily" - repeats every day
// - "weekly" - repeats every week
// - "monthly" - repeats every month
// - "yearly" - repeats every year
// - "every 2 days" - repeats every 2 days
// - "every week on monday" - repeats weekly on Monday
// - "every month on 15" - repeats monthly on the 15th
// - "every 2 weeks on friday" - repeats every 2 weeks on Friday
func (b *TaskBuilder) WithRepeat(pattern string) *TaskBuilder {
b.repeat = pattern
return b
}
// build converts the TaskBuilder to a CreateTaskRequest.
func (b *TaskBuilder) build() CreateTaskRequest {
req := CreateTaskRequest{
Content: b.content,
ParentID: b.parentID,
Position: b.position,
Due: b.due,
Priority: b.priority,
Repeat: b.repeat,
}
if len(b.tags) > 0 {
for i, tag := range b.tags {
if i > 0 {
req.Tags += ", "
}
req.Tags += tag
}
}
return req
}
// Create creates a new task using a TaskBuilder.
func (s *TaskService) Create(ctx context.Context, builder *TaskBuilder) (*Task, error) {
path := fmt.Sprintf("/checklists/%d/tasks.json", s.checklistID)
body := createTaskWrapper{Task: builder.build()}
var task Task
if err := s.client.doPost(ctx, path, body, &task); err != nil {
return nil, err
}
parseDueDate(&task)
return &task, nil
}
// UpdateTaskRequest represents the request body for updating a task.
type UpdateTaskRequest struct {
Content *string `json:"content,omitempty"`
ParentID *int `json:"parent_id,omitempty"`
Position *int `json:"position,omitempty"`
Due *string `json:"due_date,omitempty"`
Priority *int `json:"priority,omitempty"`
Tags *string `json:"tags,omitempty"`
}
// updateTaskWrapper wraps the task fields for PUT requests
type updateTaskWrapper struct {
Task UpdateTaskRequest `json:"task"`
}
// Update updates an existing task.
func (s *TaskService) Update(ctx context.Context, taskID int, req UpdateTaskRequest) (*Task, error) {
path := fmt.Sprintf("/checklists/%d/tasks/%d.json", s.checklistID, taskID)
body := updateTaskWrapper{Task: req}
var task Task
if err := s.client.doPut(ctx, path, body, &task); err != nil {
return nil, err
}
parseDueDate(&task)
return &task, nil
}
// Delete permanently deletes a task.
func (s *TaskService) Delete(ctx context.Context, taskID int) error {
path := fmt.Sprintf("/checklists/%d/tasks/%d.json", s.checklistID, taskID)
return s.client.doDelete(ctx, path)
}
// Close marks a task as completed.
// The API returns an array containing the modified task and potentially its subtasks.
func (s *TaskService) Close(ctx context.Context, taskID int) (*Task, error) {
path := fmt.Sprintf("/checklists/%d/tasks/%d/close.json", s.checklistID, taskID)
var tasks []Task
if err := s.client.doPost(ctx, path, nil, &tasks); err != nil {
return nil, err
}
if len(tasks) == 0 {
return nil, fmt.Errorf("close task: unexpected empty response")
}
parseDueDate(&tasks[0])
return &tasks[0], nil
}
// Reopen reopens a closed or invalidated task.
// The API returns an array containing the modified task and potentially its subtasks.
func (s *TaskService) Reopen(ctx context.Context, taskID int) (*Task, error) {
path := fmt.Sprintf("/checklists/%d/tasks/%d/reopen.json", s.checklistID, taskID)
var tasks []Task
if err := s.client.doPost(ctx, path, nil, &tasks); err != nil {
return nil, err
}
if len(tasks) == 0 {
return nil, fmt.Errorf("reopen task: unexpected empty response")
}
parseDueDate(&tasks[0])
return &tasks[0], nil
}
// Invalidate marks a task as invalidated (strikethrough).
// The API returns an array containing the modified task and potentially its subtasks.
func (s *TaskService) Invalidate(ctx context.Context, taskID int) (*Task, error) {
path := fmt.Sprintf("/checklists/%d/tasks/%d/invalidate.json", s.checklistID, taskID)
var tasks []Task
if err := s.client.doPost(ctx, path, nil, &tasks); err != nil {
return nil, err
}
if len(tasks) == 0 {
return nil, fmt.Errorf("invalidate task: unexpected empty response")
}
parseDueDate(&tasks[0])
return &tasks[0], nil
}
// parseDueDate attempts to parse the DueDateRaw string into a time.Time.
// It supports the Checkvist API format (YYYY/MM/DD) and ISO 8601 format (YYYY-MM-DD).
func parseDueDate(task *Task) {
if task.DueDateRaw == "" {
return
}
// Try multiple date formats (API uses slashes, ISO uses dashes)
formats := []string{
"2006/01/02", // Checkvist API format
"2006-01-02", // ISO 8601 format
}
for _, format := range formats {
if t, err := time.Parse(format, task.DueDateRaw); err == nil {
task.DueDate = &t
return
}
}
}