1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package t
6
7import (
8 "errors"
9 "fmt"
10 "strings"
11 "time"
12
13 "git.secluded.site/np/cmd/shared"
14 "git.secluded.site/np/internal/cli"
15 "git.secluded.site/np/internal/db"
16 "git.secluded.site/np/internal/event"
17 "git.secluded.site/np/internal/task"
18 "github.com/spf13/cobra"
19)
20
21var uCmd = &cobra.Command{
22 Use: "u",
23 Short: "Update tasks",
24 Long: `Update one or more tasks' status, title, or description`,
25 RunE: runUpdateTask,
26}
27
28func init() {
29 TCmd.AddCommand(uCmd)
30
31 uCmd.Flags().StringArrayP("id", "i", []string{}, "Task ID (required, repeatable for batch updates)")
32 uCmd.Flags().StringArrayP("status", "s", []string{}, "New task status (repeatable, must match number of IDs)")
33 uCmd.Flags().StringP("title", "t", "", "New task title (only for single task updates)")
34 uCmd.Flags().StringP("description", "d", "", "New task description (only for single task updates)")
35 uCmd.Flags().StringP("reason", "r", "", "Reason for update (required for title/description changes and status=cancelled/failed)")
36 _ = uCmd.MarkFlagRequired("id")
37}
38
39func runUpdateTask(cmd *cobra.Command, _ []string) error {
40 env, err := shared.Environment(cmd)
41 if err != nil {
42 return err
43 }
44
45 sessionDoc, found, err := shared.ActiveSession(cmd, env)
46 if err != nil {
47 return err
48 }
49 if !found {
50 return nil
51 }
52
53 taskIDs, err := cmd.Flags().GetStringArray("id")
54 if err != nil {
55 return err
56 }
57 statuses, err := cmd.Flags().GetStringArray("status")
58 if err != nil {
59 return err
60 }
61
62 // Trim and validate IDs
63 var cleanIDs []string
64 for _, id := range taskIDs {
65 cleaned := strings.TrimSpace(id)
66 if cleaned != "" {
67 cleanIDs = append(cleanIDs, cleaned)
68 }
69 }
70 taskIDs = cleanIDs
71
72 if len(taskIDs) == 0 {
73 _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.update.no_ids"))
74 return nil
75 }
76
77 // Check if this is a batch status update
78 isBatchStatusUpdate := len(statuses) > 0 && len(statuses) == len(taskIDs)
79 isSingleTask := len(taskIDs) == 1
80
81 // Title/description only allowed for single task
82 titleInput, _ := cmd.Flags().GetString("title")
83 descInput, _ := cmd.Flags().GetString("description")
84 reasonInput, _ := cmd.Flags().GetString("reason")
85 titleChanged := cmd.Flags().Changed("title")
86 descChanged := cmd.Flags().Changed("description")
87
88 if (titleChanged || descChanged) && !isSingleTask {
89 _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.update.title_desc_single_only"))
90 return nil
91 }
92
93 // Handle batch status updates
94 if isBatchStatusUpdate && !titleChanged && !descChanged {
95 return runBatchStatusUpdate(cmd, env, sessionDoc.SID, taskIDs, statuses, reasonInput)
96 }
97
98 // Handle single task update (possibly with title/description)
99 if !isSingleTask {
100 _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.update.status_count_mismatch"))
101 return nil
102 }
103
104 return runSingleTaskUpdate(cmd, env, sessionDoc.SID, taskIDs[0], statuses, titleInput, descInput, reasonInput, titleChanged, descChanged)
105}
106
107func runBatchStatusUpdate(cmd *cobra.Command, env *cli.Environment, sid string, taskIDs []string, statuses []string, reasonInput string) error {
108 type updatePair struct {
109 id string
110 status task.Status
111 }
112
113 // Parse and validate all statuses first
114 var pairs []updatePair
115 for i, taskID := range taskIDs {
116 statusStr := strings.TrimSpace(statuses[i])
117 newStatus, err := task.ParseStatus(statusStr)
118 if err != nil {
119 _, _ = fmt.Fprintf(cmd.OutOrStdout(), env.Localizer.T("task.update.invalid_status"), statusStr, taskID)
120 return nil
121 }
122 pairs = append(pairs, updatePair{id: taskID, status: newStatus})
123 }
124
125 reason := strings.TrimSpace(reasonInput)
126 // Check if any status requires a reason
127 reasonRequired := false
128 for _, pair := range pairs {
129 if pair.status == task.StatusCancelled || pair.status == task.StatusFailed {
130 reasonRequired = true
131 break
132 }
133 }
134
135 if reasonRequired && reason == "" {
136 _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.update.reason_required_status"))
137 return nil
138 }
139
140 var lastUpdatedAt time.Time
141 var updatedCount int
142
143 err := env.DB.Update(cmd.Context(), func(txn *db.Txn) error {
144 taskTxn := env.TaskStore.WithTxn(txn)
145 eventTxn := env.EventStore.WithTxn(txn)
146 sessionTxn := env.SessionStore.WithTxn(txn)
147
148 for _, pair := range pairs {
149 before, err := taskTxn.Get(sid, pair.id)
150 if err != nil {
151 if errors.Is(err, task.ErrNotFound) {
152 _, _ = fmt.Fprintf(cmd.OutOrStdout(), env.Localizer.T("task.update.not_found"), pair.id)
153 continue
154 }
155 return err
156 }
157
158 // Skip if status unchanged
159 if before.Status == pair.status {
160 continue
161 }
162
163 updated, err := taskTxn.Update(sid, pair.id, func(t *task.Task) error {
164 t.Status = pair.status
165 return nil
166 })
167 if err != nil {
168 return err
169 }
170
171 _, err = eventTxn.Append(sid, event.BuildTaskStatusChanged(shared.CommandString(), reason, before, updated))
172 if err != nil {
173 return err
174 }
175
176 lastUpdatedAt = updated.UpdatedAt
177 updatedCount++
178 }
179
180 if updatedCount > 0 {
181 _, err := sessionTxn.TouchAt(sid, lastUpdatedAt)
182 return err
183 }
184
185 return nil
186 })
187 if err != nil {
188 return err
189 }
190
191 if updatedCount == 0 {
192 _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.update.no_updates"))
193 return nil
194 }
195
196 tasks, err := env.LoadTasks(cmd.Context(), sid)
197 if err != nil {
198 return err
199 }
200 _, _ = fmt.Fprint(cmd.OutOrStdout(), cli.RenderTasksOnly(tasks, env.Localizer))
201
202 out := cmd.OutOrStdout()
203 _, _ = fmt.Fprintln(out, "")
204 _, _ = fmt.Fprintf(out, env.Localizer.T("task.update.success"), updatedCount)
205
206 // Check if all work is complete
207 allComplete := true
208 pending, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusPending)
209 if err != nil {
210 return err
211 }
212 inProgress, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusInProgress)
213 if err != nil {
214 return err
215 }
216 if len(pending) > 0 || len(inProgress) > 0 {
217 allComplete = false
218 }
219
220 if allComplete {
221 _, _ = fmt.Fprintln(out, env.Localizer.T("task.update.none_pending_archive"))
222 } else {
223 _, _ = fmt.Fprintln(out, env.Localizer.T("task.update.continue_working"))
224 }
225
226 return nil
227}
228
229func runSingleTaskUpdate(cmd *cobra.Command, env *cli.Environment, sid string, taskID string, statuses []string, titleInput, descInput, reasonInput string, titleChanged, descChanged bool) error {
230 current, err := env.TaskStore.Get(cmd.Context(), sid, taskID)
231 if err != nil {
232 if errors.Is(err, task.ErrNotFound) {
233 _, _ = fmt.Fprintf(cmd.OutOrStdout(), env.Localizer.T("task.update.not_found"), taskID)
234 return nil
235 }
236 return err
237 }
238
239 newTitle := current.Title
240 if titleChanged {
241 newTitle = strings.TrimSpace(titleInput)
242 if newTitle == "" {
243 _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.update.title_empty"))
244 return nil
245 }
246 }
247
248 newDescription := current.Description
249 if descChanged {
250 newDescription = strings.TrimSpace(descInput)
251 }
252
253 var newStatus task.Status
254 statusChanged := len(statuses) > 0
255 if statusChanged {
256 statusStr := strings.TrimSpace(statuses[0])
257 newStatus, err = task.ParseStatus(statusStr)
258 if err != nil {
259 _, _ = fmt.Fprintf(cmd.OutOrStdout(), env.Localizer.T("task.update.invalid_status_single"), statusStr)
260 return nil
261 }
262 } else {
263 newStatus = current.Status
264 }
265
266 if !titleChanged && !descChanged && !statusChanged {
267 _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.update.no_changes_provided"))
268 return nil
269 }
270
271 reason := strings.TrimSpace(reasonInput)
272 reasonRequired := titleChanged || descChanged ||
273 (statusChanged && (newStatus == task.StatusCancelled || newStatus == task.StatusFailed))
274
275 if reasonRequired && reason == "" {
276 _, _ = fmt.Fprintln(cmd.OutOrStdout(), env.Localizer.T("task.update.reason_required"))
277 return nil
278 }
279
280 contentChanged := titleChanged || descChanged
281 statusOnlyChanged := statusChanged && !contentChanged
282
283 var updated task.Task
284 err = env.DB.Update(cmd.Context(), func(txn *db.Txn) error {
285 taskTxn := env.TaskStore.WithTxn(txn)
286 eventTxn := env.EventStore.WithTxn(txn)
287 sessionTxn := env.SessionStore.WithTxn(txn)
288
289 before, err := taskTxn.Get(sid, taskID)
290 if err != nil {
291 return err
292 }
293
294 updated, err = taskTxn.Update(sid, taskID, func(t *task.Task) error {
295 if titleChanged {
296 t.Title = newTitle
297 }
298 if descChanged {
299 t.Description = newDescription
300 }
301 if statusChanged {
302 t.Status = newStatus
303 }
304 return nil
305 })
306 if err != nil {
307 return err
308 }
309
310 if contentChanged {
311 _, err = eventTxn.Append(sid, event.BuildTaskUpdated(shared.CommandString(), reason, taskID, before, updated))
312 if err != nil {
313 return err
314 }
315 }
316
317 if statusChanged {
318 _, err = eventTxn.Append(sid, event.BuildTaskStatusChanged(shared.CommandString(), reason, before, updated))
319 if err != nil {
320 return err
321 }
322 }
323
324 _, err = sessionTxn.TouchAt(sid, updated.UpdatedAt)
325 return err
326 })
327 if err != nil {
328 return err
329 }
330
331 tasks, err := env.LoadTasks(cmd.Context(), sid)
332 if err != nil {
333 return err
334 }
335 _, _ = fmt.Fprint(cmd.OutOrStdout(), cli.RenderTasksOnly(tasks, env.Localizer))
336
337 out := cmd.OutOrStdout()
338 _, _ = fmt.Fprintln(out, "")
339 if statusOnlyChanged && newStatus == task.StatusCompleted {
340 pending, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusPending)
341 if err != nil {
342 return err
343 }
344 inProgress, err := env.TaskStore.ListByStatus(cmd.Context(), sid, task.StatusInProgress)
345 if err != nil {
346 return err
347 }
348
349 if len(pending) == 0 && len(inProgress) == 0 {
350 _, _ = fmt.Fprintln(out, env.Localizer.T("task.update.none_pending_archive"))
351 } else {
352 _, _ = fmt.Fprintln(out, env.Localizer.T("task.update.completed_continue"))
353 }
354 } else {
355 _, _ = fmt.Fprintln(out, env.Localizer.T("task.update.updated_review"))
356 }
357
358 return nil
359}