tasks.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package lunatask
  6
  7import (
  8	"bytes"
  9	"context"
 10	"encoding/json"
 11	"errors"
 12	"fmt"
 13	"net/http"
 14	"net/url"
 15)
 16
 17// Source represents a task source like GitHub or other integrations
 18type Source struct {
 19	Source   string `json:"source"`
 20	SourceID string `json:"source_id"`
 21}
 22
 23// Task represents a task returned from the Lunatask API
 24type Task struct {
 25	ID             string   `json:"id"`
 26	AreaID         *string  `json:"area_id"`
 27	GoalID         *string  `json:"goal_id"`
 28	Name           *string  `json:"name"`
 29	Note           *string  `json:"note"`
 30	Status         *string  `json:"status"`
 31	PreviousStatus *string  `json:"previous_status"`
 32	Estimate       *int     `json:"estimate"`
 33	Priority       *int     `json:"priority"`
 34	Progress       *int     `json:"progress"`
 35	Motivation     *string  `json:"motivation"`
 36	Eisenhower     *int     `json:"eisenhower"`
 37	Sources        []Source `json:"sources"`
 38	ScheduledOn    *string  `json:"scheduled_on"`
 39	CompletedAt    *string  `json:"completed_at"`
 40	CreatedAt      string   `json:"created_at"`
 41	UpdatedAt      string   `json:"updated_at"`
 42}
 43
 44// CreateTaskRequest represents the request to create a task in Lunatask
 45type CreateTaskRequest struct {
 46	Name        string  `json:"name"`
 47	AreaID      *string `json:"area_id,omitempty"`
 48	GoalID      *string `json:"goal_id,omitempty"`
 49	Note        *string `json:"note,omitempty"`
 50	Status      *string `json:"status,omitempty"`
 51	Motivation  *string `json:"motivation,omitempty"`
 52	Estimate    *int    `json:"estimate,omitempty"`
 53	Priority    *int    `json:"priority,omitempty"`
 54	Eisenhower  *int    `json:"eisenhower,omitempty"`
 55	ScheduledOn *string `json:"scheduled_on,omitempty"`
 56	CompletedAt *string `json:"completed_at,omitempty"`
 57	Source      *string `json:"source,omitempty"`
 58	SourceID    *string `json:"source_id,omitempty"`
 59}
 60
 61// UpdateTaskRequest represents the request to update a task in Lunatask.
 62// All fields are optional; only provided fields will be updated.
 63type UpdateTaskRequest struct {
 64	Name        *string `json:"name,omitempty"`
 65	AreaID      *string `json:"area_id,omitempty"`
 66	GoalID      *string `json:"goal_id,omitempty"`
 67	Note        *string `json:"note,omitempty"`
 68	Status      *string `json:"status,omitempty"`
 69	Motivation  *string `json:"motivation,omitempty"`
 70	Estimate    *int    `json:"estimate,omitempty"`
 71	Priority    *int    `json:"priority,omitempty"`
 72	Eisenhower  *int    `json:"eisenhower,omitempty"`
 73	ScheduledOn *string `json:"scheduled_on,omitempty"`
 74	CompletedAt *string `json:"completed_at,omitempty"`
 75}
 76
 77// TaskResponse represents a single task response from the API
 78type TaskResponse struct {
 79	Task Task `json:"task"`
 80}
 81
 82// TasksResponse represents a list of tasks response from the API
 83type TasksResponse struct {
 84	Tasks []Task `json:"tasks"`
 85}
 86
 87// ListTasksOptions contains optional filters for listing tasks
 88type ListTasksOptions struct {
 89	Source   *string
 90	SourceID *string
 91}
 92
 93// ListTasks retrieves all tasks, optionally filtered by source and/or source_id
 94func (c *Client) ListTasks(ctx context.Context, opts *ListTasksOptions) ([]Task, error) {
 95	u := fmt.Sprintf("%s/tasks", c.BaseURL)
 96
 97	if opts != nil {
 98		params := url.Values{}
 99		if opts.Source != nil && *opts.Source != "" {
100			params.Set("source", *opts.Source)
101		}
102		if opts.SourceID != nil && *opts.SourceID != "" {
103			params.Set("source_id", *opts.SourceID)
104		}
105		if len(params) > 0 {
106			u = fmt.Sprintf("%s?%s", u, params.Encode())
107		}
108	}
109
110	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
111	if err != nil {
112		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
113	}
114
115	body, err := c.doRequest(req)
116	if err != nil {
117		return nil, err
118	}
119
120	var response TasksResponse
121	if err := json.Unmarshal(body, &response); err != nil {
122		return nil, fmt.Errorf("failed to parse response: %w", err)
123	}
124
125	return response.Tasks, nil
126}
127
128// GetTask retrieves a specific task by ID
129func (c *Client) GetTask(ctx context.Context, taskID string) (*Task, error) {
130	if taskID == "" {
131		return nil, fmt.Errorf("%w: task ID cannot be empty", ErrBadRequest)
132	}
133
134	req, err := http.NewRequestWithContext(
135		ctx,
136		http.MethodGet,
137		fmt.Sprintf("%s/tasks/%s", c.BaseURL, taskID),
138		nil,
139	)
140	if err != nil {
141		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
142	}
143
144	body, err := c.doRequest(req)
145	if err != nil {
146		return nil, err
147	}
148
149	var response TaskResponse
150	if err := json.Unmarshal(body, &response); err != nil {
151		return nil, fmt.Errorf("failed to parse response: %w", err)
152	}
153
154	return &response.Task, nil
155}
156
157// CreateTask creates a new task in Lunatask.
158// Returns nil, nil if a matching task already exists (HTTP 204).
159func (c *Client) CreateTask(ctx context.Context, task *CreateTaskRequest) (*Task, error) {
160	if task.Name == "" {
161		return nil, fmt.Errorf("%w: name is required", ErrBadRequest)
162	}
163
164	payloadBytes, err := json.Marshal(task)
165	if err != nil {
166		return nil, fmt.Errorf("failed to marshal payload: %w", err)
167	}
168
169	req, err := http.NewRequestWithContext(
170		ctx,
171		http.MethodPost,
172		fmt.Sprintf("%s/tasks", c.BaseURL),
173		bytes.NewBuffer(payloadBytes),
174	)
175	if err != nil {
176		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
177	}
178	req.Header.Set("Content-Type", "application/json")
179
180	body, err := c.doRequest(req)
181	if err != nil {
182		// Check for 204 No Content (task already exists)
183		var apiErr *APIError
184		if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNoContent {
185			return nil, nil
186		}
187		return nil, err
188	}
189
190	// Handle empty body (204 case that slipped through)
191	if len(body) == 0 {
192		return nil, nil
193	}
194
195	var response TaskResponse
196	if err := json.Unmarshal(body, &response); err != nil {
197		return nil, fmt.Errorf("failed to parse response: %w", err)
198	}
199
200	return &response.Task, nil
201}
202
203// UpdateTask updates an existing task in Lunatask
204func (c *Client) UpdateTask(ctx context.Context, taskID string, task *UpdateTaskRequest) (*Task, error) {
205	if taskID == "" {
206		return nil, fmt.Errorf("%w: task ID cannot be empty", ErrBadRequest)
207	}
208
209	payloadBytes, err := json.Marshal(task)
210	if err != nil {
211		return nil, fmt.Errorf("failed to marshal payload: %w", err)
212	}
213
214	req, err := http.NewRequestWithContext(
215		ctx,
216		http.MethodPut,
217		fmt.Sprintf("%s/tasks/%s", c.BaseURL, taskID),
218		bytes.NewBuffer(payloadBytes),
219	)
220	if err != nil {
221		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
222	}
223	req.Header.Set("Content-Type", "application/json")
224
225	body, err := c.doRequest(req)
226	if err != nil {
227		return nil, err
228	}
229
230	var response TaskResponse
231	if err := json.Unmarshal(body, &response); err != nil {
232		return nil, fmt.Errorf("failed to parse response: %w", err)
233	}
234
235	return &response.Task, nil
236}
237
238// DeleteTask deletes a task in Lunatask
239func (c *Client) DeleteTask(ctx context.Context, taskID string) (*Task, error) {
240	if taskID == "" {
241		return nil, fmt.Errorf("%w: task ID cannot be empty", ErrBadRequest)
242	}
243
244	req, err := http.NewRequestWithContext(
245		ctx,
246		http.MethodDelete,
247		fmt.Sprintf("%s/tasks/%s", c.BaseURL, taskID),
248		nil,
249	)
250	if err != nil {
251		return nil, fmt.Errorf("failed to create HTTP request: %w", err)
252	}
253
254	body, err := c.doRequest(req)
255	if err != nil {
256		return nil, err
257	}
258
259	var response TaskResponse
260	if err := json.Unmarshal(body, &response); err != nil {
261		return nil, fmt.Errorf("failed to parse response: %w", err)
262	}
263
264	return &response.Task, nil
265}