diff --git a/cmd/task_commands.go b/cmd/task_commands.go index b7bdf26..8b9aa07 100644 --- a/cmd/task_commands.go +++ b/cmd/task_commands.go @@ -1,6 +1,8 @@ package main import ( + "fmt" + "strconv" "strings" "github.com/spf13/cobra" @@ -37,7 +39,7 @@ time tracking. Tasks can be filtered by status, priority, project, or context.`, ) for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ - addTaskCmd, listTaskCmd, viewTaskCmd, updateTaskCmd, editTaskCmd, deleteTaskCmd, + addTaskCmd, listTaskCmd, viewTaskCmd, updateTaskCmd, editTaskCmd, deleteTaskCmd, taskAnnotateCmd, taskBulkEditCmd, } { cmd := init(c.handler) cmd.GroupID = "task-ops" @@ -53,7 +55,7 @@ time tracking. Tasks can be filtered by status, priority, project, or context.`, } for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ - timesheetViewCmd, taskStartCmd, taskStopCmd, taskCompleteCmd, taskRecurCmd, taskDependCmd, + timesheetViewCmd, taskStartCmd, taskStopCmd, taskCompleteCmd, taskRecurCmd, taskDependCmd, taskUndoCmd, taskHistoryCmd, } { cmd := init(c.handler) cmd.GroupID = "task-tracking" @@ -609,3 +611,146 @@ UUIDs to specify dependencies.`, root.AddCommand(addCmd, removeCmd, listCmd, blockedByCmd) return root } + +func taskAnnotateCmd(h *handlers.TaskHandler) *cobra.Command { + root := &cobra.Command{ + Use: "annotate", + Aliases: []string{"note"}, + Short: "Manage task annotations", + Long: `Add, list, or remove annotations on tasks. + +Annotations are timestamped notes that provide context and updates +about a task's progress or relevant information.`, + } + + addCmd := &cobra.Command{ + Use: "add ", + Short: "Add an annotation to a task", + Aliases: []string{"create"}, + Args: cobra.MinimumNArgs(2), + RunE: func(c *cobra.Command, args []string) error { + taskID := args[0] + annotation := strings.Join(args[1:], " ") + defer h.Close() + return h.Annotate(c.Context(), taskID, annotation) + }, + } + + listCmd := &cobra.Command{ + Use: "list ", + Short: "List all annotations for a task", + Aliases: []string{"ls", "show"}, + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + defer h.Close() + return h.ListAnnotations(c.Context(), args[0]) + }, + } + + removeCmd := &cobra.Command{ + Use: "remove ", + Short: "Remove an annotation by index", + Aliases: []string{"rm", "delete"}, + Args: cobra.ExactArgs(2), + RunE: func(c *cobra.Command, args []string) error { + taskID := args[0] + index, err := strconv.Atoi(args[1]) + if err != nil { + return fmt.Errorf("invalid annotation index: %w", err) + } + defer h.Close() + return h.RemoveAnnotation(c.Context(), taskID, index) + }, + } + + root.AddCommand(addCmd, listCmd, removeCmd) + return root +} + +func taskBulkEditCmd(h *handlers.TaskHandler) *cobra.Command { + cmd := &cobra.Command{ + Use: "bulk-edit ...", + Aliases: []string{"bulk"}, + Short: "Update multiple tasks at once", + Long: `Update multiple tasks with the same changes. + +Allows batch updates to status, priority, project, context, and tags. +Use --add-tags to add tags without replacing existing ones. +Use --remove-tags to remove specific tags from tasks. + +Examples: + noteleaf todo bulk-edit 1 2 3 --status done + noteleaf todo bulk-edit 1 2 --project web --priority high + noteleaf todo bulk-edit 1 2 3 --add-tags urgent,review`, + Args: cobra.MinimumNArgs(1), + RunE: func(c *cobra.Command, args []string) error { + status, _ := c.Flags().GetString("status") + priority, _ := c.Flags().GetString("priority") + project, _ := c.Flags().GetString("project") + context, _ := c.Flags().GetString("context") + tags, _ := c.Flags().GetStringSlice("tags") + addTags, _ := c.Flags().GetBool("add-tags") + removeTags, _ := c.Flags().GetBool("remove-tags") + + defer h.Close() + return h.BulkEdit(c.Context(), args, status, priority, project, context, tags, addTags, removeTags) + }, + } + + cmd.Flags().String("status", "", "Set status for all tasks") + cmd.Flags().String("priority", "", "Set priority for all tasks") + cmd.Flags().String("project", "", "Set project for all tasks") + cmd.Flags().String("context", "", "Set context for all tasks") + cmd.Flags().StringSlice("tags", []string{}, "Set tags for all tasks") + cmd.Flags().Bool("add-tags", false, "Add tags instead of replacing") + cmd.Flags().Bool("remove-tags", false, "Remove specified tags") + + return cmd +} + +func taskUndoCmd(h *handlers.TaskHandler) *cobra.Command { + cmd := &cobra.Command{ + Use: "undo ", + Short: "Undo the last change to a task", + Long: `Revert a task to its previous state before the last update. + +This command uses the task history to restore the task to how it was +before the most recent modification. + +Examples: + noteleaf todo undo 1 + noteleaf todo undo abc-123-uuid`, + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + defer h.Close() + return h.UndoTask(c.Context(), args[0]) + }, + } + + return cmd +} + +func taskHistoryCmd(h *handlers.TaskHandler) *cobra.Command { + cmd := &cobra.Command{ + Use: "history ", + Aliases: []string{"log"}, + Short: "Show change history for a task", + Long: `Display the history of changes made to a task. + +Shows a chronological list of modifications with timestamps. + +Examples: + noteleaf todo history 1 + noteleaf todo history 1 --limit 5`, + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + limit, _ := c.Flags().GetInt("limit") + defer h.Close() + return h.ShowHistory(c.Context(), args[0], limit) + }, + } + + cmd.Flags().IntP("limit", "n", 10, "Limit number of history entries") + + return cmd +} diff --git a/internal/docs/ROADMAP.md b/internal/docs/ROADMAP.md index 737f16a..6139ce6 100644 --- a/internal/docs/ROADMAP.md +++ b/internal/docs/ROADMAP.md @@ -130,9 +130,9 @@ Planned functionality for a complete baseline release. - [x] Recurrence (`recur`, `until`, templates) - [x] Wait/scheduled dates - [x] Urgency scoring -- [ ] Operations - - [ ] `annotate` - - [ ] Bulk edit and undo/history +- [x] Operations + - [x] `annotate` + - [x] Bulk edit and undo/history - [x] Reports and Views - [x] Next actions - [x] Completed/waiting/blocked reports diff --git a/internal/handlers/tasks.go b/internal/handlers/tasks.go index 9e77c9f..ef4a300 100644 --- a/internal/handlers/tasks.go +++ b/internal/handlers/tasks.go @@ -1231,3 +1231,251 @@ func (h *TaskHandler) Calendar(ctx context.Context, weeks int) error { return nil } + +// Annotate adds an annotation to a task +func (h *TaskHandler) Annotate(ctx context.Context, taskID, annotation string) error { + if annotation == "" { + return fmt.Errorf("annotation text required") + } + + var task *models.Task + var err error + + if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { + task, err = h.repos.Tasks.Get(ctx, id) + } else { + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) + } + + if err != nil { + return fmt.Errorf("failed to find task: %w", err) + } + + task.Annotations = append(task.Annotations, annotation) + + err = h.repos.Tasks.Update(ctx, task) + if err != nil { + return fmt.Errorf("failed to update task: %w", err) + } + + fmt.Printf("Annotation added to task (ID: %d): %s\n", task.ID, task.Description) + fmt.Printf("Annotation: %s\n", annotation) + + return nil +} + +// ListAnnotations lists all annotations for a task +func (h *TaskHandler) ListAnnotations(ctx context.Context, taskID string) error { + var task *models.Task + var err error + + if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { + task, err = h.repos.Tasks.Get(ctx, id) + } else { + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) + } + + if err != nil { + return fmt.Errorf("failed to find task: %w", err) + } + + fmt.Printf("Task (ID: %d): %s\n", task.ID, task.Description) + + if len(task.Annotations) == 0 { + fmt.Printf("No annotations\n") + return nil + } + + fmt.Printf("Annotations (%d):\n", len(task.Annotations)) + for i, annotation := range task.Annotations { + fmt.Printf(" %d. %s\n", i+1, annotation) + } + + return nil +} + +// RemoveAnnotation removes an annotation from a task by index +func (h *TaskHandler) RemoveAnnotation(ctx context.Context, taskID string, index int) error { + var task *models.Task + var err error + + if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { + task, err = h.repos.Tasks.Get(ctx, id) + } else { + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) + } + + if err != nil { + return fmt.Errorf("failed to find task: %w", err) + } + + if len(task.Annotations) == 0 { + return fmt.Errorf("task has no annotations") + } + + if index < 1 || index > len(task.Annotations) { + return fmt.Errorf("annotation index out of range (1-%d)", len(task.Annotations)) + } + + annotation := task.Annotations[index-1] + task.Annotations = append(task.Annotations[:index-1], task.Annotations[index:]...) + + err = h.repos.Tasks.Update(ctx, task) + if err != nil { + return fmt.Errorf("failed to update task: %w", err) + } + + fmt.Printf("Annotation removed from task (ID: %d): %s\n", task.ID, task.Description) + fmt.Printf("Removed: %s\n", annotation) + + return nil +} + +// BulkEdit updates multiple tasks with the same changes +func (h *TaskHandler) BulkEdit(ctx context.Context, taskIDs []string, status, priority, project, context string, tags []string, addTags, removeTags bool) error { + if len(taskIDs) == 0 { + return fmt.Errorf("no task IDs provided") + } + + var ids []int64 + for _, taskID := range taskIDs { + id, err := strconv.ParseInt(taskID, 10, 64) + if err != nil { + task, err := h.repos.Tasks.GetByUUID(ctx, taskID) + if err != nil { + return fmt.Errorf("invalid task ID %s: %w", taskID, err) + } + id = task.ID + } + ids = append(ids, id) + } + + updates := &models.Task{ + Status: status, + Priority: priority, + Project: project, + Context: context, + } + + if len(tags) > 0 { + if addTags { + for _, id := range ids { + task, err := h.repos.Tasks.Get(ctx, id) + if err != nil { + return fmt.Errorf("failed to get task: %w", err) + } + for _, tag := range tags { + if !slices.Contains(task.Tags, tag) { + task.Tags = append(task.Tags, tag) + } + } + if err := h.repos.Tasks.Update(ctx, task); err != nil { + return fmt.Errorf("failed to update task: %w", err) + } + } + } else if removeTags { + for _, id := range ids { + task, err := h.repos.Tasks.Get(ctx, id) + if err != nil { + return fmt.Errorf("failed to get task: %w", err) + } + for _, tag := range tags { + task.Tags = removeString(task.Tags, tag) + } + if err := h.repos.Tasks.Update(ctx, task); err != nil { + return fmt.Errorf("failed to update task: %w", err) + } + } + } else { + updates.Tags = tags + } + } + + if err := h.repos.Tasks.BulkUpdate(ctx, ids, updates); err != nil { + return fmt.Errorf("bulk update failed: %w", err) + } + + fmt.Printf("Updated %d task(s)\n", len(ids)) + if status != "" { + fmt.Printf("Status: %s\n", status) + } + if priority != "" { + fmt.Printf("Priority: %s\n", priority) + } + if project != "" { + fmt.Printf("Project: %s\n", project) + } + if context != "" { + fmt.Printf("Context: %s\n", context) + } + if len(tags) > 0 { + if addTags { + fmt.Printf("Added tags: %s\n", strings.Join(tags, ", ")) + } else if removeTags { + fmt.Printf("Removed tags: %s\n", strings.Join(tags, ", ")) + } else { + fmt.Printf("Set tags: %s\n", strings.Join(tags, ", ")) + } + } + + return nil +} + +// UndoTask reverts a task to its previous state +func (h *TaskHandler) UndoTask(ctx context.Context, taskID string) error { + var task *models.Task + var err error + + if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { + task, err = h.repos.Tasks.Get(ctx, id) + } else { + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) + } + + if err != nil { + return fmt.Errorf("failed to find task: %w", err) + } + + err = h.repos.Tasks.UndoLastChange(ctx, task.ID) + if err != nil { + return fmt.Errorf("failed to undo task: %w", err) + } + + fmt.Printf("Undid last change to task (ID: %d)\n", task.ID) + return nil +} + +// ShowHistory displays the change history for a task +func (h *TaskHandler) ShowHistory(ctx context.Context, taskID string, limit int) error { + var task *models.Task + var err error + + if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { + task, err = h.repos.Tasks.Get(ctx, id) + } else { + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) + } + + if err != nil { + return fmt.Errorf("failed to find task: %w", err) + } + + history, err := h.repos.Tasks.GetHistory(ctx, task.ID, limit) + if err != nil { + return fmt.Errorf("failed to get history: %w", err) + } + + if len(history) == 0 { + fmt.Printf("No history found for task (ID: %d): %s\n", task.ID, task.Description) + return nil + } + + fmt.Printf("Task (ID: %d): %s\n", task.ID, task.Description) + fmt.Printf("History (%d changes):\n\n", len(history)) + + for i, h := range history { + fmt.Printf("%d. [%s] %s at %s\n", i+1, h.Operation, task.Description, h.CreatedAt.Format("2006-01-02 15:04:05")) + } + + return nil +} diff --git a/internal/handlers/tasks_test.go b/internal/handlers/tasks_test.go index 37f2294..10df77b 100644 --- a/internal/handlers/tasks_test.go +++ b/internal/handlers/tasks_test.go @@ -2123,4 +2123,342 @@ func TestTaskHandler(t *testing.T) { t.Errorf("BlockedByDep failed: %v", err) } }) + + t.Run("Annotate", func(t *testing.T) { + suite := NewHandlerTestSuite(t) + defer suite.cleanup() + + handler, err := NewTaskHandler() + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + defer handler.Close() + + id, err := handler.repos.Tasks.Create(ctx, &models.Task{ + UUID: uuid.New().String(), + Description: "Test task", + Status: "pending", + }) + if err != nil { + t.Fatalf("Failed to create task: %v", err) + } + + t.Run("adds annotation successfully", func(t *testing.T) { + err := handler.Annotate(ctx, strconv.FormatInt(id, 10), "First annotation") + shared.AssertNoError(t, err, "Annotate should succeed") + + task, err := handler.repos.Tasks.Get(ctx, id) + shared.AssertNoError(t, err, "Get should succeed") + shared.AssertEqual(t, 1, len(task.Annotations), "should have 1 annotation") + shared.AssertEqual(t, "First annotation", task.Annotations[0], "annotation text should match") + }) + + t.Run("adds multiple annotations", func(t *testing.T) { + err := handler.Annotate(ctx, strconv.FormatInt(id, 10), "Second annotation") + shared.AssertNoError(t, err, "Annotate should succeed") + + task, err := handler.repos.Tasks.Get(ctx, id) + shared.AssertNoError(t, err, "Get should succeed") + shared.AssertEqual(t, 2, len(task.Annotations), "should have 2 annotations") + }) + + t.Run("fails with empty annotation", func(t *testing.T) { + err := handler.Annotate(ctx, strconv.FormatInt(id, 10), "") + shared.AssertError(t, err, "should fail with empty annotation") + shared.AssertContains(t, err.Error(), "annotation text required", "error message") + }) + + t.Run("fails with invalid task ID", func(t *testing.T) { + err := handler.Annotate(ctx, "99999", "Test annotation") + shared.AssertError(t, err, "should fail with invalid task ID") + shared.AssertContains(t, err.Error(), "failed to find task", "error message") + }) + + t.Run("works with UUID", func(t *testing.T) { + task := &models.Task{ + UUID: uuid.New().String(), + Description: "UUID task", + Status: "pending", + } + _, err := handler.repos.Tasks.Create(ctx, task) + shared.AssertNoError(t, err, "Create should succeed") + + err = handler.Annotate(ctx, task.UUID, "UUID annotation") + shared.AssertNoError(t, err, "Annotate with UUID should succeed") + + retrieved, err := handler.repos.Tasks.GetByUUID(ctx, task.UUID) + shared.AssertNoError(t, err, "GetByUUID should succeed") + shared.AssertEqual(t, 1, len(retrieved.Annotations), "should have 1 annotation") + }) + }) + + t.Run("ListAnnotations", func(t *testing.T) { + suite := NewHandlerTestSuite(t) + defer suite.cleanup() + + handler, err := NewTaskHandler() + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + defer handler.Close() + + t.Run("lists annotations successfully", func(t *testing.T) { + task := &models.Task{ + UUID: uuid.New().String(), + Description: "Test task", + Status: "pending", + Annotations: []string{"Annotation 1", "Annotation 2", "Annotation 3"}, + } + id, err := handler.repos.Tasks.Create(ctx, task) + shared.AssertNoError(t, err, "Create should succeed") + + err = handler.ListAnnotations(ctx, strconv.FormatInt(id, 10)) + shared.AssertNoError(t, err, "ListAnnotations should succeed") + }) + + t.Run("handles task with no annotations", func(t *testing.T) { + task := &models.Task{ + UUID: uuid.New().String(), + Description: "Task without annotations", + Status: "pending", + } + id, err := handler.repos.Tasks.Create(ctx, task) + shared.AssertNoError(t, err, "Create should succeed") + + err = handler.ListAnnotations(ctx, strconv.FormatInt(id, 10)) + shared.AssertNoError(t, err, "ListAnnotations should succeed for empty annotations") + }) + + t.Run("fails with invalid task ID", func(t *testing.T) { + err := handler.ListAnnotations(ctx, "99999") + shared.AssertError(t, err, "should fail with invalid task ID") + shared.AssertContains(t, err.Error(), "failed to find task", "error message") + }) + }) + + t.Run("RemoveAnnotation", func(t *testing.T) { + suite := NewHandlerTestSuite(t) + defer suite.cleanup() + + handler, err := NewTaskHandler() + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + defer handler.Close() + + t.Run("removes annotation successfully", func(t *testing.T) { + task := &models.Task{ + UUID: uuid.New().String(), + Description: "Test task", + Status: "pending", + Annotations: []string{"First", "Second", "Third"}, + } + id, err := handler.repos.Tasks.Create(ctx, task) + shared.AssertNoError(t, err, "Create should succeed") + + err = handler.RemoveAnnotation(ctx, strconv.FormatInt(id, 10), 2) + shared.AssertNoError(t, err, "RemoveAnnotation should succeed") + + retrieved, err := handler.repos.Tasks.Get(ctx, id) + shared.AssertNoError(t, err, "Get should succeed") + shared.AssertEqual(t, 2, len(retrieved.Annotations), "should have 2 annotations") + shared.AssertEqual(t, "First", retrieved.Annotations[0], "first annotation should remain") + shared.AssertEqual(t, "Third", retrieved.Annotations[1], "third annotation should be second") + }) + + t.Run("fails with invalid index (too low)", func(t *testing.T) { + task := &models.Task{ + UUID: uuid.New().String(), + Description: "Test task", + Status: "pending", + Annotations: []string{"First"}, + } + id, err := handler.repos.Tasks.Create(ctx, task) + shared.AssertNoError(t, err, "Create should succeed") + + err = handler.RemoveAnnotation(ctx, strconv.FormatInt(id, 10), 0) + shared.AssertError(t, err, "should fail with index 0") + shared.AssertContains(t, err.Error(), "index out of range", "error message") + }) + + t.Run("fails with invalid index (too high)", func(t *testing.T) { + task := &models.Task{ + UUID: uuid.New().String(), + Description: "Test task", + Status: "pending", + Annotations: []string{"First"}, + } + id, err := handler.repos.Tasks.Create(ctx, task) + shared.AssertNoError(t, err, "Create should succeed") + + err = handler.RemoveAnnotation(ctx, strconv.FormatInt(id, 10), 5) + shared.AssertError(t, err, "should fail with index > len") + shared.AssertContains(t, err.Error(), "index out of range", "error message") + }) + + t.Run("fails when task has no annotations", func(t *testing.T) { + task := &models.Task{ + UUID: uuid.New().String(), + Description: "Task without annotations", + Status: "pending", + } + id, err := handler.repos.Tasks.Create(ctx, task) + shared.AssertNoError(t, err, "Create should succeed") + + err = handler.RemoveAnnotation(ctx, strconv.FormatInt(id, 10), 1) + shared.AssertError(t, err, "should fail when no annotations") + shared.AssertContains(t, err.Error(), "has no annotations", "error message") + }) + + t.Run("fails with invalid task ID", func(t *testing.T) { + err := handler.RemoveAnnotation(ctx, "99999", 1) + shared.AssertError(t, err, "should fail with invalid task ID") + shared.AssertContains(t, err.Error(), "failed to find task", "error message") + }) + }) + + t.Run("BulkEdit", func(t *testing.T) { + suite := NewHandlerTestSuite(t) + defer suite.cleanup() + + handler, err := NewTaskHandler() + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + defer handler.Close() + + t.Run("updates multiple tasks successfully", func(t *testing.T) { + id1, err := handler.repos.Tasks.Create(ctx, &models.Task{ + UUID: uuid.New().String(), + Description: "Task 1", + Status: "pending", + }) + shared.AssertNoError(t, err, "Create should succeed") + + id2, err := handler.repos.Tasks.Create(ctx, &models.Task{ + UUID: uuid.New().String(), + Description: "Task 2", + Status: "pending", + }) + shared.AssertNoError(t, err, "Create should succeed") + + taskIDs := []string{strconv.FormatInt(id1, 10), strconv.FormatInt(id2, 10)} + err = handler.BulkEdit(ctx, taskIDs, "done", "high", "test-project", "", []string{}, false, false) + shared.AssertNoError(t, err, "BulkEdit should succeed") + + task1, err := handler.repos.Tasks.Get(ctx, id1) + shared.AssertNoError(t, err, "Get should succeed") + shared.AssertEqual(t, "done", task1.Status, "task 1 status should be updated") + shared.AssertEqual(t, "high", task1.Priority, "task 1 priority should be updated") + shared.AssertEqual(t, "test-project", task1.Project, "task 1 project should be updated") + + task2, err := handler.repos.Tasks.Get(ctx, id2) + shared.AssertNoError(t, err, "Get should succeed") + shared.AssertEqual(t, "done", task2.Status, "task 2 status should be updated") + shared.AssertEqual(t, "high", task2.Priority, "task 2 priority should be updated") + shared.AssertEqual(t, "test-project", task2.Project, "task 2 project should be updated") + }) + + t.Run("updates with tag replacement", func(t *testing.T) { + id1, err := handler.repos.Tasks.Create(ctx, &models.Task{ + UUID: uuid.New().String(), + Description: "Task 1", + Status: "pending", + Tags: []string{"old-tag"}, + }) + shared.AssertNoError(t, err, "Create should succeed") + + taskIDs := []string{strconv.FormatInt(id1, 10)} + err = handler.BulkEdit(ctx, taskIDs, "", "", "", "", []string{"new-tag1", "new-tag2"}, false, false) + shared.AssertNoError(t, err, "BulkEdit should succeed") + + task, err := handler.repos.Tasks.Get(ctx, id1) + shared.AssertNoError(t, err, "Get should succeed") + shared.AssertEqual(t, 2, len(task.Tags), "should have 2 tags") + shared.AssertTrue(t, slices.Contains(task.Tags, "new-tag1"), "should contain new-tag1") + shared.AssertTrue(t, slices.Contains(task.Tags, "new-tag2"), "should contain new-tag2") + }) + + t.Run("adds tags with add-tags flag", func(t *testing.T) { + id1, err := handler.repos.Tasks.Create(ctx, &models.Task{ + UUID: uuid.New().String(), + Description: "Task 1", + Status: "pending", + Tags: []string{"existing-tag"}, + }) + shared.AssertNoError(t, err, "Create should succeed") + + taskIDs := []string{strconv.FormatInt(id1, 10)} + err = handler.BulkEdit(ctx, taskIDs, "", "", "", "", []string{"new-tag"}, true, false) + shared.AssertNoError(t, err, "BulkEdit should succeed") + + task, err := handler.repos.Tasks.Get(ctx, id1) + shared.AssertNoError(t, err, "Get should succeed") + shared.AssertEqual(t, 2, len(task.Tags), "should have 2 tags") + shared.AssertTrue(t, slices.Contains(task.Tags, "existing-tag"), "should contain existing-tag") + shared.AssertTrue(t, slices.Contains(task.Tags, "new-tag"), "should contain new-tag") + }) + + t.Run("removes tags with remove-tags flag", func(t *testing.T) { + id1, err := handler.repos.Tasks.Create(ctx, &models.Task{ + UUID: uuid.New().String(), + Description: "Task 1", + Status: "pending", + Tags: []string{"tag1", "tag2", "tag3"}, + }) + shared.AssertNoError(t, err, "Create should succeed") + + taskIDs := []string{strconv.FormatInt(id1, 10)} + err = handler.BulkEdit(ctx, taskIDs, "", "", "", "", []string{"tag2"}, false, true) + shared.AssertNoError(t, err, "BulkEdit should succeed") + + task, err := handler.repos.Tasks.Get(ctx, id1) + shared.AssertNoError(t, err, "Get should succeed") + shared.AssertEqual(t, 2, len(task.Tags), "should have 2 tags") + shared.AssertTrue(t, slices.Contains(task.Tags, "tag1"), "should contain tag1") + shared.AssertTrue(t, slices.Contains(task.Tags, "tag3"), "should contain tag3") + shared.AssertFalse(t, slices.Contains(task.Tags, "tag2"), "should not contain tag2") + }) + + t.Run("fails with no task IDs", func(t *testing.T) { + err := handler.BulkEdit(ctx, []string{}, "done", "", "", "", []string{}, false, false) + shared.AssertError(t, err, "should fail with no task IDs") + shared.AssertContains(t, err.Error(), "no task IDs provided", "error message") + }) + + t.Run("fails with invalid task ID", func(t *testing.T) { + err := handler.BulkEdit(ctx, []string{"99999"}, "done", "", "", "", []string{}, false, false) + shared.AssertError(t, err, "should fail with invalid task ID") + }) + + t.Run("works with UUIDs", func(t *testing.T) { + task1 := &models.Task{ + UUID: uuid.New().String(), + Description: "UUID task 1", + Status: "pending", + } + _, err := handler.repos.Tasks.Create(ctx, task1) + shared.AssertNoError(t, err, "Create should succeed") + + task2 := &models.Task{ + UUID: uuid.New().String(), + Description: "UUID task 2", + Status: "pending", + } + _, err = handler.repos.Tasks.Create(ctx, task2) + shared.AssertNoError(t, err, "Create should succeed") + + taskIDs := []string{task1.UUID, task2.UUID} + err = handler.BulkEdit(ctx, taskIDs, "done", "", "", "", []string{}, false, false) + shared.AssertNoError(t, err, "BulkEdit with UUIDs should succeed") + + retrieved1, err := handler.repos.Tasks.GetByUUID(ctx, task1.UUID) + shared.AssertNoError(t, err, "GetByUUID should succeed") + shared.AssertEqual(t, "done", retrieved1.Status, "task 1 status should be updated") + + retrieved2, err := handler.repos.Tasks.GetByUUID(ctx, task2.UUID) + shared.AssertNoError(t, err, "GetByUUID should succeed") + shared.AssertEqual(t, "done", retrieved2.Status, "task 2 status should be updated") + }) + }) } diff --git a/internal/models/models.go b/internal/models/models.go index 46d1b3b..d7beb4e 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -221,6 +221,15 @@ type Article struct { Modified time.Time `json:"modified"` } +// TaskHistory represents a historical snapshot of a task for undo functionality +type TaskHistory struct { + ID int64 `json:"id"` + TaskID int64 `json:"task_id"` + Operation string `json:"operation"` // update, delete + Snapshot string `json:"snapshot"` // JSON snapshot of task + CreatedAt time.Time `json:"created_at"` +} + // MarshalTags converts tags slice to JSON string for database storage func (t *Task) MarshalTags() (string, error) { if len(t.Tags) == 0 { diff --git a/internal/repo/task_repository.go b/internal/repo/task_repository.go index a7e60a9..14b9b59 100644 --- a/internal/repo/task_repository.go +++ b/internal/repo/task_repository.go @@ -4,6 +4,7 @@ package repo import ( "context" "database/sql" + "encoding/json" "fmt" "strings" "time" @@ -199,6 +200,15 @@ func (r *TaskRepository) Get(ctx context.Context, id int64) (*models.Task, error // Update modifies an existing task func (r *TaskRepository) Update(ctx context.Context, task *models.Task) error { + oldTask, err := r.Get(ctx, task.ID) + if err != nil { + return fmt.Errorf("failed to get current task state: %w", err) + } + + if err := r.SaveHistory(ctx, oldTask, "update"); err != nil { + return fmt.Errorf("failed to save history: %w", err) + } + task.Modified = time.Now() tags, err := marshalTaskTags(task) @@ -721,3 +731,111 @@ func (r *TaskRepository) GetBlockedTasks(ctx context.Context, blockingUUID strin } return tasks, nil } + +// BulkUpdate applies the same updates to multiple tasks +func (r *TaskRepository) BulkUpdate(ctx context.Context, taskIDs []int64, updates *models.Task) error { + if len(taskIDs) == 0 { + return fmt.Errorf("no task IDs provided") + } + + for _, id := range taskIDs { + task, err := r.Get(ctx, id) + if err != nil { + return fmt.Errorf("failed to get task %d: %w", id, err) + } + + if updates.Status != "" { + task.Status = updates.Status + } + if updates.Priority != "" { + task.Priority = updates.Priority + } + if updates.Project != "" { + task.Project = updates.Project + } + if updates.Context != "" { + task.Context = updates.Context + } + if len(updates.Tags) > 0 { + task.Tags = updates.Tags + } + if updates.Due != nil { + task.Due = updates.Due + } + + if err := r.Update(ctx, task); err != nil { + return fmt.Errorf("failed to update task %d: %w", id, err) + } + } + + return nil +} + +// SaveHistory saves a snapshot of a task before an operation +func (r *TaskRepository) SaveHistory(ctx context.Context, task *models.Task, operation string) error { + snapshot, err := json.Marshal(task) + if err != nil { + return fmt.Errorf("failed to marshal task snapshot: %w", err) + } + + query := `INSERT INTO task_history (task_id, operation, snapshot) VALUES (?, ?, ?)` + if _, err := r.db.ExecContext(ctx, query, task.ID, operation, string(snapshot)); err != nil { + return fmt.Errorf("failed to save task history: %w", err) + } + + return nil +} + +// GetHistory retrieves the history of changes for a task +func (r *TaskRepository) GetHistory(ctx context.Context, taskID int64, limit int) ([]*models.TaskHistory, error) { + query := `SELECT id, task_id, operation, snapshot, created_at FROM task_history WHERE task_id = ? ORDER BY created_at DESC` + if limit > 0 { + query += fmt.Sprintf(" LIMIT %d", limit) + } + + rows, err := r.db.QueryContext(ctx, query, taskID) + if err != nil { + return nil, fmt.Errorf("failed to query task history: %w", err) + } + defer rows.Close() + + var history []*models.TaskHistory + for rows.Next() { + h := &models.TaskHistory{} + if err := rows.Scan(&h.ID, &h.TaskID, &h.Operation, &h.Snapshot, &h.CreatedAt); err != nil { + return nil, fmt.Errorf("failed to scan task history: %w", err) + } + history = append(history, h) + } + + return history, rows.Err() +} + +// UndoLastChange reverts a task to its previous state +func (r *TaskRepository) UndoLastChange(ctx context.Context, taskID int64) error { + history, err := r.GetHistory(ctx, taskID, 1) + if err != nil { + return fmt.Errorf("failed to get task history: %w", err) + } + + if len(history) == 0 { + return fmt.Errorf("no history found for task") + } + + lastHistory := history[0] + var task models.Task + if err := json.Unmarshal([]byte(lastHistory.Snapshot), &task); err != nil { + return fmt.Errorf("failed to unmarshal task snapshot: %w", err) + } + + if err := r.Update(ctx, &task); err != nil { + return fmt.Errorf("failed to restore task: %w", err) + } + + deleteQuery := `DELETE FROM task_history WHERE id = ?` + if _, err := r.db.ExecContext(ctx, deleteQuery, lastHistory.ID); err != nil { + return fmt.Errorf("failed to delete history entry: %w", err) + } + + return nil +} diff --git a/internal/repo/task_repository_test.go b/internal/repo/task_repository_test.go index 74bc1eb..b90ded1 100644 --- a/internal/repo/task_repository_test.go +++ b/internal/repo/task_repository_test.go @@ -1174,4 +1174,79 @@ func TestTaskRepository(t *testing.T) { shared.AssertNoError(t, err, "GetBlockedTasks for independent should succeed") shared.AssertEqual(t, 0, len(emptyBlocked), "independent task should not block anything") }) + + t.Run("BulkUpdate", func(t *testing.T) { + t.Run("updates multiple tasks successfully", func(t *testing.T) { + task1 := CreateSampleTask() + task1.Status = "pending" + task1.Priority = "low" + id1, err := repo.Create(ctx, task1) + shared.AssertNoError(t, err, "Create should succeed") + + task2 := CreateSampleTask() + task2.Status = "pending" + task2.Priority = "low" + id2, err := repo.Create(ctx, task2) + shared.AssertNoError(t, err, "Create should succeed") + + updates := &models.Task{ + Status: "done", + Priority: "high", + Project: "bulk-test", + } + + err = repo.BulkUpdate(ctx, []int64{id1, id2}, updates) + shared.AssertNoError(t, err, "BulkUpdate should succeed") + + updated1, err := repo.Get(ctx, id1) + shared.AssertNoError(t, err, "Get should succeed") + shared.AssertEqual(t, "done", updated1.Status, "task 1 status should be updated") + shared.AssertEqual(t, "high", updated1.Priority, "task 1 priority should be updated") + shared.AssertEqual(t, "bulk-test", updated1.Project, "task 1 project should be updated") + + updated2, err := repo.Get(ctx, id2) + shared.AssertNoError(t, err, "Get should succeed") + shared.AssertEqual(t, "done", updated2.Status, "task 2 status should be updated") + shared.AssertEqual(t, "high", updated2.Priority, "task 2 priority should be updated") + shared.AssertEqual(t, "bulk-test", updated2.Project, "task 2 project should be updated") + }) + + t.Run("fails with no task IDs", func(t *testing.T) { + updates := &models.Task{Status: "done"} + err := repo.BulkUpdate(ctx, []int64{}, updates) + shared.AssertError(t, err, "should fail with empty task IDs") + shared.AssertContains(t, err.Error(), "no task IDs provided", "error message") + }) + + t.Run("fails with invalid task ID", func(t *testing.T) { + updates := &models.Task{Status: "done"} + err := repo.BulkUpdate(ctx, []int64{99999}, updates) + shared.AssertError(t, err, "should fail with invalid task ID") + shared.AssertContains(t, err.Error(), "failed to get task", "error message") + }) + + t.Run("updates only non-empty fields", func(t *testing.T) { + task1 := CreateSampleTask() + task1.Status = "pending" + task1.Priority = "low" + task1.Project = "original-project" + task1.Context = "original-context" + id1, err := repo.Create(ctx, task1) + shared.AssertNoError(t, err, "Create should succeed") + + updates := &models.Task{ + Status: "done", + } + + err = repo.BulkUpdate(ctx, []int64{id1}, updates) + shared.AssertNoError(t, err, "BulkUpdate should succeed") + + updated, err := repo.Get(ctx, id1) + shared.AssertNoError(t, err, "Get should succeed") + shared.AssertEqual(t, "done", updated.Status, "status should be updated") + shared.AssertEqual(t, "low", updated.Priority, "priority should remain unchanged") + shared.AssertEqual(t, "original-project", updated.Project, "project should remain unchanged") + shared.AssertEqual(t, "original-context", updated.Context, "context should remain unchanged") + }) + }) } diff --git a/internal/store/sql/migrations/0011_create_task_history_table_down.sql b/internal/store/sql/migrations/0011_create_task_history_table_down.sql new file mode 100644 index 0000000..bafc502 --- /dev/null +++ b/internal/store/sql/migrations/0011_create_task_history_table_down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS task_history; diff --git a/internal/store/sql/migrations/0011_create_task_history_table_up.sql b/internal/store/sql/migrations/0011_create_task_history_table_up.sql new file mode 100644 index 0000000..0db8f96 --- /dev/null +++ b/internal/store/sql/migrations/0011_create_task_history_table_up.sql @@ -0,0 +1,11 @@ +-- Task history table for undo functionality +CREATE TABLE IF NOT EXISTS task_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + operation TEXT NOT NULL, -- 'update', 'delete' + snapshot TEXT NOT NULL, -- JSON snapshot of task before operation + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_task_history_task_id ON task_history(task_id); +CREATE INDEX IF NOT EXISTS idx_task_history_created_at ON task_history(created_at);