u.go

  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}