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}