diff --git a/cmd/t/a.go b/cmd/t/a.go index c7c8d346e1198a3a460bf8e08ed0a4ff1e6dffae..e3985309cdeedefd9ee596ff36ab200e36edcf1b 100644 --- a/cmd/t/a.go +++ b/cmd/t/a.go @@ -5,8 +5,14 @@ package t import ( + "errors" "fmt" + "strings" + "git.secluded.site/np/cmd/shared" + "git.secluded.site/np/internal/db" + "git.secluded.site/np/internal/event" + "git.secluded.site/np/internal/task" "github.com/spf13/cobra" ) @@ -14,9 +20,7 @@ var aCmd = &cobra.Command{ Use: "a", Short: "Add tasks", Long: `Add one or more tasks to the session`, - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("[STUB] Add tasks to current session") - }, + RunE: runAddTasks, } func init() { @@ -27,3 +31,133 @@ func init() { _ = aCmd.MarkFlagRequired("title") _ = aCmd.MarkFlagRequired("description") } + +func runAddTasks(cmd *cobra.Command, _ []string) error { + env, err := shared.Environment(cmd) + if err != nil { + return err + } + + sessionDoc, found, err := shared.ActiveSession(cmd, env) + if err != nil { + return err + } + if !found { + return nil + } + + titles, err := cmd.Flags().GetStringArray("title") + if err != nil { + return err + } + descriptions, err := cmd.Flags().GetStringArray("description") + if err != nil { + return err + } + + if len(titles) != len(descriptions) { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Number of titles and descriptions must match.") + return nil + } + if len(titles) == 0 { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Provide at least one task with -t title -d description.") + return nil + } + + type taskInput struct { + title string + description string + id string + } + + var inputs []taskInput + for i := range titles { + title := strings.TrimSpace(titles[i]) + description := strings.TrimSpace(descriptions[i]) + + if title == "" { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Task title at position %d cannot be empty.\n", i+1) + return nil + } + + id := task.GenerateID(sessionDoc.SID, title, description) + inputs = append(inputs, taskInput{ + title: title, + description: description, + id: id, + }) + } + + var lastUpdated task.Task + var addedCount int + + err = env.DB.Update(cmd.Context(), func(txn *db.Txn) error { + taskTxn := env.TaskStore.WithTxn(txn) + eventTxn := env.EventStore.WithTxn(txn) + sessionTxn := env.SessionStore.WithTxn(txn) + + for _, input := range inputs { + exists, err := taskTxn.Exists(sessionDoc.SID, input.id) + if err != nil { + return err + } + if exists { + continue + } + + seq, err := eventTxn.LatestSequence(sessionDoc.SID) + if err != nil { + return err + } + nextSeq := seq + 1 + + created, err := taskTxn.Create(sessionDoc.SID, task.CreateParams{ + ID: input.id, + Title: input.title, + Description: input.description, + Status: task.StatusPending, + CreatedSeq: nextSeq, + }) + if err != nil { + return err + } + + _, err = eventTxn.Append(sessionDoc.SID, event.BuildTaskAdded(shared.CommandString(), "", created)) + if err != nil { + return err + } + + lastUpdated = created + addedCount++ + } + + if addedCount > 0 { + _, err := sessionTxn.TouchAt(sessionDoc.SID, lastUpdated.UpdatedAt) + if err != nil { + return err + } + } + + return nil + }) + if err != nil { + if errors.Is(err, task.ErrEmptyTitle) { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Task title cannot be empty.") + return nil + } + return err + } + + if _, err := shared.PrintPlan(cmd, env, sessionDoc.SID); err != nil { + return err + } + + if addedCount > 0 { + out := cmd.OutOrStdout() + _, _ = fmt.Fprintln(out, "") + _, _ = fmt.Fprintf(out, "Added %d task(s).\n", addedCount) + _, _ = fmt.Fprintln(out, "Update task statuses with `np t u -i task-id -s in_progress|completed|failed|cancelled` as you work.") + } + + return nil +} diff --git a/cmd/t/t.go b/cmd/t/t.go index f68cf6c1464ba286636d3438ff73177bf56b64a6..09a91957f4b71327e4b76150f783df64b2ef01c3 100644 --- a/cmd/t/t.go +++ b/cmd/t/t.go @@ -7,6 +7,9 @@ package t import ( "fmt" + "git.secluded.site/np/cmd/shared" + "git.secluded.site/np/internal/cli" + "git.secluded.site/np/internal/task" "github.com/spf13/cobra" ) @@ -14,16 +17,58 @@ var TCmd = &cobra.Command{ Use: "t", Short: "Task commands", Long: `Manage session tasks`, - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("[STUB] Display remaining tasks and descriptions") - fmt.Println("Legend: ☐ pending ⟳ in progress ☑ completed") - fmt.Println("☐ Example pending task [a1b2c3d4]") - fmt.Println(" Example task description") - fmt.Println("⟳ Example in-progress task [e5f6g7h8]") - fmt.Println(" Another task description") - }, + RunE: runListTasks, } func init() { TCmd.Flags().StringP("status", "s", "all", "Filter tasks by status (pending, in_progress, completed, all, etc.)") } + +func runListTasks(cmd *cobra.Command, _ []string) error { + env, err := shared.Environment(cmd) + if err != nil { + return err + } + + sessionDoc, found, err := shared.ActiveSession(cmd, env) + if err != nil { + return err + } + if !found { + return nil + } + + statusFlag, err := cmd.Flags().GetString("status") + if err != nil { + return err + } + + var statusFilter task.Status + if statusFlag != "" && statusFlag != "all" { + statusFilter, err = task.ParseStatus(statusFlag) + if err != nil { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Invalid status %q. Valid: pending, in_progress, completed, failed, cancelled, all.\n", statusFlag) + return nil + } + } + + tasks, err := env.LoadTasksByStatus(cmd.Context(), sessionDoc.SID, statusFilter) + if err != nil { + return err + } + + goalDoc, ok, err := env.LoadGoal(cmd.Context(), sessionDoc.SID) + if err != nil { + return err + } + + state := cli.PlanState{ + Tasks: tasks, + } + if ok { + state.Goal = &goalDoc + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), cli.RenderPlan(state)) + return nil +} diff --git a/cmd/t/u.go b/cmd/t/u.go index 7858b7a4d47395a5a9de6274bc8d53bca5583d3d..795e4b101d843e3def36a37092184a0f0032b94d 100644 --- a/cmd/t/u.go +++ b/cmd/t/u.go @@ -5,25 +5,185 @@ package t import ( + "errors" "fmt" + "strings" + "git.secluded.site/np/cmd/shared" + "git.secluded.site/np/internal/db" + "git.secluded.site/np/internal/event" + "git.secluded.site/np/internal/task" "github.com/spf13/cobra" ) var uCmd = &cobra.Command{ Use: "u", Short: "Update task", - Long: `Update a task's status`, - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("[STUB] Update task status") - }, + Long: `Update a task's status, title, or description`, + RunE: runUpdateTask, } func init() { TCmd.AddCommand(uCmd) uCmd.Flags().StringP("id", "i", "", "Task ID (required)") - uCmd.Flags().StringP("status", "s", "", "Task status (required)") + uCmd.Flags().StringP("status", "s", "", "New task status") + uCmd.Flags().StringP("title", "t", "", "New task title") + uCmd.Flags().StringP("description", "d", "", "New task description") + uCmd.Flags().StringP("reason", "r", "", "Reason for update (required for title/description changes and status=cancelled/failed)") _ = uCmd.MarkFlagRequired("id") - _ = uCmd.MarkFlagRequired("status") +} + +func runUpdateTask(cmd *cobra.Command, _ []string) error { + env, err := shared.Environment(cmd) + if err != nil { + return err + } + + sessionDoc, found, err := shared.ActiveSession(cmd, env) + if err != nil { + return err + } + if !found { + return nil + } + + taskID, err := cmd.Flags().GetString("id") + if err != nil { + return err + } + taskID = strings.TrimSpace(taskID) + + current, err := env.TaskStore.Get(cmd.Context(), sessionDoc.SID, taskID) + if err != nil { + if errors.Is(err, task.ErrNotFound) { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Task %q not found in current session.\n", taskID) + return nil + } + return err + } + + titleInput, err := cmd.Flags().GetString("title") + if err != nil { + return err + } + descInput, err := cmd.Flags().GetString("description") + if err != nil { + return err + } + statusInput, err := cmd.Flags().GetString("status") + if err != nil { + return err + } + reasonInput, err := cmd.Flags().GetString("reason") + if err != nil { + return err + } + + newTitle := current.Title + titleChanged := cmd.Flags().Changed("title") + if titleChanged { + newTitle = strings.TrimSpace(titleInput) + if newTitle == "" { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Task title cannot be empty.") + return nil + } + } + + newDescription := current.Description + descriptionChanged := cmd.Flags().Changed("description") + if descriptionChanged { + newDescription = strings.TrimSpace(descInput) + } + + var newStatus task.Status + statusChanged := cmd.Flags().Changed("status") + if statusChanged { + newStatus, err = task.ParseStatus(statusInput) + if err != nil { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Invalid status %q. Valid: pending, in_progress, completed, failed, cancelled.\n", statusInput) + return nil + } + } else { + newStatus = current.Status + } + + if !titleChanged && !descriptionChanged && !statusChanged { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Provide at least one of --title, --description, or --status to update the task.") + return nil + } + + reason := strings.TrimSpace(reasonInput) + reasonRequired := titleChanged || descriptionChanged || + (statusChanged && (newStatus == task.StatusCancelled || newStatus == task.StatusFailed)) + + if reasonRequired && reason == "" { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Reason (-r/--reason) is required for title/description updates or when changing status to cancelled/failed.") + return nil + } + + contentChanged := titleChanged || descriptionChanged + statusOnlyChanged := statusChanged && !contentChanged + + var updated task.Task + err = env.DB.Update(cmd.Context(), func(txn *db.Txn) error { + taskTxn := env.TaskStore.WithTxn(txn) + eventTxn := env.EventStore.WithTxn(txn) + sessionTxn := env.SessionStore.WithTxn(txn) + + before, err := taskTxn.Get(sessionDoc.SID, taskID) + if err != nil { + return err + } + + updated, err = taskTxn.Update(sessionDoc.SID, taskID, func(t *task.Task) error { + if titleChanged { + t.Title = newTitle + } + if descriptionChanged { + t.Description = newDescription + } + if statusChanged { + t.Status = newStatus + } + return nil + }) + if err != nil { + return err + } + + if contentChanged { + _, err = eventTxn.Append(sessionDoc.SID, event.BuildTaskUpdated(shared.CommandString(), reason, taskID, before, updated)) + if err != nil { + return err + } + } + + if statusChanged { + _, err = eventTxn.Append(sessionDoc.SID, event.BuildTaskStatusChanged(shared.CommandString(), reason, before, updated)) + if err != nil { + return err + } + } + + _, err = sessionTxn.TouchAt(sessionDoc.SID, updated.UpdatedAt) + return err + }) + if err != nil { + return err + } + + if _, err := shared.PrintPlan(cmd, env, sessionDoc.SID); err != nil { + return err + } + + out := cmd.OutOrStdout() + _, _ = fmt.Fprintln(out, "") + if statusOnlyChanged && newStatus == task.StatusCompleted { + _, _ = fmt.Fprintln(out, "Task marked completed. Continue working through remaining tasks with `np t u`.") + } else { + _, _ = fmt.Fprintln(out, "Task updated. Use `np p` to review the full plan.") + } + + return nil }