cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat: task annotations & history stack

+950 -5
+147 -2
cmd/task_commands.go
··· 1 1 package main 2 2 3 3 import ( 4 + "fmt" 5 + "strconv" 4 6 "strings" 5 7 6 8 "github.com/spf13/cobra" ··· 37 39 ) 38 40 39 41 for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ 40 - addTaskCmd, listTaskCmd, viewTaskCmd, updateTaskCmd, editTaskCmd, deleteTaskCmd, 42 + addTaskCmd, listTaskCmd, viewTaskCmd, updateTaskCmd, editTaskCmd, deleteTaskCmd, taskAnnotateCmd, taskBulkEditCmd, 41 43 } { 42 44 cmd := init(c.handler) 43 45 cmd.GroupID = "task-ops" ··· 53 55 } 54 56 55 57 for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ 56 - timesheetViewCmd, taskStartCmd, taskStopCmd, taskCompleteCmd, taskRecurCmd, taskDependCmd, 58 + timesheetViewCmd, taskStartCmd, taskStopCmd, taskCompleteCmd, taskRecurCmd, taskDependCmd, taskUndoCmd, taskHistoryCmd, 57 59 } { 58 60 cmd := init(c.handler) 59 61 cmd.GroupID = "task-tracking" ··· 609 611 root.AddCommand(addCmd, removeCmd, listCmd, blockedByCmd) 610 612 return root 611 613 } 614 + 615 + func taskAnnotateCmd(h *handlers.TaskHandler) *cobra.Command { 616 + root := &cobra.Command{ 617 + Use: "annotate", 618 + Aliases: []string{"note"}, 619 + Short: "Manage task annotations", 620 + Long: `Add, list, or remove annotations on tasks. 621 + 622 + Annotations are timestamped notes that provide context and updates 623 + about a task's progress or relevant information.`, 624 + } 625 + 626 + addCmd := &cobra.Command{ 627 + Use: "add <task-id> <annotation>", 628 + Short: "Add an annotation to a task", 629 + Aliases: []string{"create"}, 630 + Args: cobra.MinimumNArgs(2), 631 + RunE: func(c *cobra.Command, args []string) error { 632 + taskID := args[0] 633 + annotation := strings.Join(args[1:], " ") 634 + defer h.Close() 635 + return h.Annotate(c.Context(), taskID, annotation) 636 + }, 637 + } 638 + 639 + listCmd := &cobra.Command{ 640 + Use: "list <task-id>", 641 + Short: "List all annotations for a task", 642 + Aliases: []string{"ls", "show"}, 643 + Args: cobra.ExactArgs(1), 644 + RunE: func(c *cobra.Command, args []string) error { 645 + defer h.Close() 646 + return h.ListAnnotations(c.Context(), args[0]) 647 + }, 648 + } 649 + 650 + removeCmd := &cobra.Command{ 651 + Use: "remove <task-id> <index>", 652 + Short: "Remove an annotation by index", 653 + Aliases: []string{"rm", "delete"}, 654 + Args: cobra.ExactArgs(2), 655 + RunE: func(c *cobra.Command, args []string) error { 656 + taskID := args[0] 657 + index, err := strconv.Atoi(args[1]) 658 + if err != nil { 659 + return fmt.Errorf("invalid annotation index: %w", err) 660 + } 661 + defer h.Close() 662 + return h.RemoveAnnotation(c.Context(), taskID, index) 663 + }, 664 + } 665 + 666 + root.AddCommand(addCmd, listCmd, removeCmd) 667 + return root 668 + } 669 + 670 + func taskBulkEditCmd(h *handlers.TaskHandler) *cobra.Command { 671 + cmd := &cobra.Command{ 672 + Use: "bulk-edit <task-id>...", 673 + Aliases: []string{"bulk"}, 674 + Short: "Update multiple tasks at once", 675 + Long: `Update multiple tasks with the same changes. 676 + 677 + Allows batch updates to status, priority, project, context, and tags. 678 + Use --add-tags to add tags without replacing existing ones. 679 + Use --remove-tags to remove specific tags from tasks. 680 + 681 + Examples: 682 + noteleaf todo bulk-edit 1 2 3 --status done 683 + noteleaf todo bulk-edit 1 2 --project web --priority high 684 + noteleaf todo bulk-edit 1 2 3 --add-tags urgent,review`, 685 + Args: cobra.MinimumNArgs(1), 686 + RunE: func(c *cobra.Command, args []string) error { 687 + status, _ := c.Flags().GetString("status") 688 + priority, _ := c.Flags().GetString("priority") 689 + project, _ := c.Flags().GetString("project") 690 + context, _ := c.Flags().GetString("context") 691 + tags, _ := c.Flags().GetStringSlice("tags") 692 + addTags, _ := c.Flags().GetBool("add-tags") 693 + removeTags, _ := c.Flags().GetBool("remove-tags") 694 + 695 + defer h.Close() 696 + return h.BulkEdit(c.Context(), args, status, priority, project, context, tags, addTags, removeTags) 697 + }, 698 + } 699 + 700 + cmd.Flags().String("status", "", "Set status for all tasks") 701 + cmd.Flags().String("priority", "", "Set priority for all tasks") 702 + cmd.Flags().String("project", "", "Set project for all tasks") 703 + cmd.Flags().String("context", "", "Set context for all tasks") 704 + cmd.Flags().StringSlice("tags", []string{}, "Set tags for all tasks") 705 + cmd.Flags().Bool("add-tags", false, "Add tags instead of replacing") 706 + cmd.Flags().Bool("remove-tags", false, "Remove specified tags") 707 + 708 + return cmd 709 + } 710 + 711 + func taskUndoCmd(h *handlers.TaskHandler) *cobra.Command { 712 + cmd := &cobra.Command{ 713 + Use: "undo <task-id>", 714 + Short: "Undo the last change to a task", 715 + Long: `Revert a task to its previous state before the last update. 716 + 717 + This command uses the task history to restore the task to how it was 718 + before the most recent modification. 719 + 720 + Examples: 721 + noteleaf todo undo 1 722 + noteleaf todo undo abc-123-uuid`, 723 + Args: cobra.ExactArgs(1), 724 + RunE: func(c *cobra.Command, args []string) error { 725 + defer h.Close() 726 + return h.UndoTask(c.Context(), args[0]) 727 + }, 728 + } 729 + 730 + return cmd 731 + } 732 + 733 + func taskHistoryCmd(h *handlers.TaskHandler) *cobra.Command { 734 + cmd := &cobra.Command{ 735 + Use: "history <task-id>", 736 + Aliases: []string{"log"}, 737 + Short: "Show change history for a task", 738 + Long: `Display the history of changes made to a task. 739 + 740 + Shows a chronological list of modifications with timestamps. 741 + 742 + Examples: 743 + noteleaf todo history 1 744 + noteleaf todo history 1 --limit 5`, 745 + Args: cobra.ExactArgs(1), 746 + RunE: func(c *cobra.Command, args []string) error { 747 + limit, _ := c.Flags().GetInt("limit") 748 + defer h.Close() 749 + return h.ShowHistory(c.Context(), args[0], limit) 750 + }, 751 + } 752 + 753 + cmd.Flags().IntP("limit", "n", 10, "Limit number of history entries") 754 + 755 + return cmd 756 + }
+3 -3
internal/docs/ROADMAP.md
··· 130 130 - [x] Recurrence (`recur`, `until`, templates) 131 131 - [x] Wait/scheduled dates 132 132 - [x] Urgency scoring 133 - - [ ] Operations 134 - - [ ] `annotate` 135 - - [ ] Bulk edit and undo/history 133 + - [x] Operations 134 + - [x] `annotate` 135 + - [x] Bulk edit and undo/history 136 136 - [x] Reports and Views 137 137 - [x] Next actions 138 138 - [x] Completed/waiting/blocked reports
+248
internal/handlers/tasks.go
··· 1231 1231 1232 1232 return nil 1233 1233 } 1234 + 1235 + // Annotate adds an annotation to a task 1236 + func (h *TaskHandler) Annotate(ctx context.Context, taskID, annotation string) error { 1237 + if annotation == "" { 1238 + return fmt.Errorf("annotation text required") 1239 + } 1240 + 1241 + var task *models.Task 1242 + var err error 1243 + 1244 + if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 1245 + task, err = h.repos.Tasks.Get(ctx, id) 1246 + } else { 1247 + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 1248 + } 1249 + 1250 + if err != nil { 1251 + return fmt.Errorf("failed to find task: %w", err) 1252 + } 1253 + 1254 + task.Annotations = append(task.Annotations, annotation) 1255 + 1256 + err = h.repos.Tasks.Update(ctx, task) 1257 + if err != nil { 1258 + return fmt.Errorf("failed to update task: %w", err) 1259 + } 1260 + 1261 + fmt.Printf("Annotation added to task (ID: %d): %s\n", task.ID, task.Description) 1262 + fmt.Printf("Annotation: %s\n", annotation) 1263 + 1264 + return nil 1265 + } 1266 + 1267 + // ListAnnotations lists all annotations for a task 1268 + func (h *TaskHandler) ListAnnotations(ctx context.Context, taskID string) error { 1269 + var task *models.Task 1270 + var err error 1271 + 1272 + if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 1273 + task, err = h.repos.Tasks.Get(ctx, id) 1274 + } else { 1275 + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 1276 + } 1277 + 1278 + if err != nil { 1279 + return fmt.Errorf("failed to find task: %w", err) 1280 + } 1281 + 1282 + fmt.Printf("Task (ID: %d): %s\n", task.ID, task.Description) 1283 + 1284 + if len(task.Annotations) == 0 { 1285 + fmt.Printf("No annotations\n") 1286 + return nil 1287 + } 1288 + 1289 + fmt.Printf("Annotations (%d):\n", len(task.Annotations)) 1290 + for i, annotation := range task.Annotations { 1291 + fmt.Printf(" %d. %s\n", i+1, annotation) 1292 + } 1293 + 1294 + return nil 1295 + } 1296 + 1297 + // RemoveAnnotation removes an annotation from a task by index 1298 + func (h *TaskHandler) RemoveAnnotation(ctx context.Context, taskID string, index int) error { 1299 + var task *models.Task 1300 + var err error 1301 + 1302 + if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 1303 + task, err = h.repos.Tasks.Get(ctx, id) 1304 + } else { 1305 + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 1306 + } 1307 + 1308 + if err != nil { 1309 + return fmt.Errorf("failed to find task: %w", err) 1310 + } 1311 + 1312 + if len(task.Annotations) == 0 { 1313 + return fmt.Errorf("task has no annotations") 1314 + } 1315 + 1316 + if index < 1 || index > len(task.Annotations) { 1317 + return fmt.Errorf("annotation index out of range (1-%d)", len(task.Annotations)) 1318 + } 1319 + 1320 + annotation := task.Annotations[index-1] 1321 + task.Annotations = append(task.Annotations[:index-1], task.Annotations[index:]...) 1322 + 1323 + err = h.repos.Tasks.Update(ctx, task) 1324 + if err != nil { 1325 + return fmt.Errorf("failed to update task: %w", err) 1326 + } 1327 + 1328 + fmt.Printf("Annotation removed from task (ID: %d): %s\n", task.ID, task.Description) 1329 + fmt.Printf("Removed: %s\n", annotation) 1330 + 1331 + return nil 1332 + } 1333 + 1334 + // BulkEdit updates multiple tasks with the same changes 1335 + func (h *TaskHandler) BulkEdit(ctx context.Context, taskIDs []string, status, priority, project, context string, tags []string, addTags, removeTags bool) error { 1336 + if len(taskIDs) == 0 { 1337 + return fmt.Errorf("no task IDs provided") 1338 + } 1339 + 1340 + var ids []int64 1341 + for _, taskID := range taskIDs { 1342 + id, err := strconv.ParseInt(taskID, 10, 64) 1343 + if err != nil { 1344 + task, err := h.repos.Tasks.GetByUUID(ctx, taskID) 1345 + if err != nil { 1346 + return fmt.Errorf("invalid task ID %s: %w", taskID, err) 1347 + } 1348 + id = task.ID 1349 + } 1350 + ids = append(ids, id) 1351 + } 1352 + 1353 + updates := &models.Task{ 1354 + Status: status, 1355 + Priority: priority, 1356 + Project: project, 1357 + Context: context, 1358 + } 1359 + 1360 + if len(tags) > 0 { 1361 + if addTags { 1362 + for _, id := range ids { 1363 + task, err := h.repos.Tasks.Get(ctx, id) 1364 + if err != nil { 1365 + return fmt.Errorf("failed to get task: %w", err) 1366 + } 1367 + for _, tag := range tags { 1368 + if !slices.Contains(task.Tags, tag) { 1369 + task.Tags = append(task.Tags, tag) 1370 + } 1371 + } 1372 + if err := h.repos.Tasks.Update(ctx, task); err != nil { 1373 + return fmt.Errorf("failed to update task: %w", err) 1374 + } 1375 + } 1376 + } else if removeTags { 1377 + for _, id := range ids { 1378 + task, err := h.repos.Tasks.Get(ctx, id) 1379 + if err != nil { 1380 + return fmt.Errorf("failed to get task: %w", err) 1381 + } 1382 + for _, tag := range tags { 1383 + task.Tags = removeString(task.Tags, tag) 1384 + } 1385 + if err := h.repos.Tasks.Update(ctx, task); err != nil { 1386 + return fmt.Errorf("failed to update task: %w", err) 1387 + } 1388 + } 1389 + } else { 1390 + updates.Tags = tags 1391 + } 1392 + } 1393 + 1394 + if err := h.repos.Tasks.BulkUpdate(ctx, ids, updates); err != nil { 1395 + return fmt.Errorf("bulk update failed: %w", err) 1396 + } 1397 + 1398 + fmt.Printf("Updated %d task(s)\n", len(ids)) 1399 + if status != "" { 1400 + fmt.Printf("Status: %s\n", status) 1401 + } 1402 + if priority != "" { 1403 + fmt.Printf("Priority: %s\n", priority) 1404 + } 1405 + if project != "" { 1406 + fmt.Printf("Project: %s\n", project) 1407 + } 1408 + if context != "" { 1409 + fmt.Printf("Context: %s\n", context) 1410 + } 1411 + if len(tags) > 0 { 1412 + if addTags { 1413 + fmt.Printf("Added tags: %s\n", strings.Join(tags, ", ")) 1414 + } else if removeTags { 1415 + fmt.Printf("Removed tags: %s\n", strings.Join(tags, ", ")) 1416 + } else { 1417 + fmt.Printf("Set tags: %s\n", strings.Join(tags, ", ")) 1418 + } 1419 + } 1420 + 1421 + return nil 1422 + } 1423 + 1424 + // UndoTask reverts a task to its previous state 1425 + func (h *TaskHandler) UndoTask(ctx context.Context, taskID string) error { 1426 + var task *models.Task 1427 + var err error 1428 + 1429 + if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 1430 + task, err = h.repos.Tasks.Get(ctx, id) 1431 + } else { 1432 + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 1433 + } 1434 + 1435 + if err != nil { 1436 + return fmt.Errorf("failed to find task: %w", err) 1437 + } 1438 + 1439 + err = h.repos.Tasks.UndoLastChange(ctx, task.ID) 1440 + if err != nil { 1441 + return fmt.Errorf("failed to undo task: %w", err) 1442 + } 1443 + 1444 + fmt.Printf("Undid last change to task (ID: %d)\n", task.ID) 1445 + return nil 1446 + } 1447 + 1448 + // ShowHistory displays the change history for a task 1449 + func (h *TaskHandler) ShowHistory(ctx context.Context, taskID string, limit int) error { 1450 + var task *models.Task 1451 + var err error 1452 + 1453 + if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 1454 + task, err = h.repos.Tasks.Get(ctx, id) 1455 + } else { 1456 + task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 1457 + } 1458 + 1459 + if err != nil { 1460 + return fmt.Errorf("failed to find task: %w", err) 1461 + } 1462 + 1463 + history, err := h.repos.Tasks.GetHistory(ctx, task.ID, limit) 1464 + if err != nil { 1465 + return fmt.Errorf("failed to get history: %w", err) 1466 + } 1467 + 1468 + if len(history) == 0 { 1469 + fmt.Printf("No history found for task (ID: %d): %s\n", task.ID, task.Description) 1470 + return nil 1471 + } 1472 + 1473 + fmt.Printf("Task (ID: %d): %s\n", task.ID, task.Description) 1474 + fmt.Printf("History (%d changes):\n\n", len(history)) 1475 + 1476 + for i, h := range history { 1477 + fmt.Printf("%d. [%s] %s at %s\n", i+1, h.Operation, task.Description, h.CreatedAt.Format("2006-01-02 15:04:05")) 1478 + } 1479 + 1480 + return nil 1481 + }
+338
internal/handlers/tasks_test.go
··· 2123 2123 t.Errorf("BlockedByDep failed: %v", err) 2124 2124 } 2125 2125 }) 2126 + 2127 + t.Run("Annotate", func(t *testing.T) { 2128 + suite := NewHandlerTestSuite(t) 2129 + defer suite.cleanup() 2130 + 2131 + handler, err := NewTaskHandler() 2132 + if err != nil { 2133 + t.Fatalf("Failed to create handler: %v", err) 2134 + } 2135 + defer handler.Close() 2136 + 2137 + id, err := handler.repos.Tasks.Create(ctx, &models.Task{ 2138 + UUID: uuid.New().String(), 2139 + Description: "Test task", 2140 + Status: "pending", 2141 + }) 2142 + if err != nil { 2143 + t.Fatalf("Failed to create task: %v", err) 2144 + } 2145 + 2146 + t.Run("adds annotation successfully", func(t *testing.T) { 2147 + err := handler.Annotate(ctx, strconv.FormatInt(id, 10), "First annotation") 2148 + shared.AssertNoError(t, err, "Annotate should succeed") 2149 + 2150 + task, err := handler.repos.Tasks.Get(ctx, id) 2151 + shared.AssertNoError(t, err, "Get should succeed") 2152 + shared.AssertEqual(t, 1, len(task.Annotations), "should have 1 annotation") 2153 + shared.AssertEqual(t, "First annotation", task.Annotations[0], "annotation text should match") 2154 + }) 2155 + 2156 + t.Run("adds multiple annotations", func(t *testing.T) { 2157 + err := handler.Annotate(ctx, strconv.FormatInt(id, 10), "Second annotation") 2158 + shared.AssertNoError(t, err, "Annotate should succeed") 2159 + 2160 + task, err := handler.repos.Tasks.Get(ctx, id) 2161 + shared.AssertNoError(t, err, "Get should succeed") 2162 + shared.AssertEqual(t, 2, len(task.Annotations), "should have 2 annotations") 2163 + }) 2164 + 2165 + t.Run("fails with empty annotation", func(t *testing.T) { 2166 + err := handler.Annotate(ctx, strconv.FormatInt(id, 10), "") 2167 + shared.AssertError(t, err, "should fail with empty annotation") 2168 + shared.AssertContains(t, err.Error(), "annotation text required", "error message") 2169 + }) 2170 + 2171 + t.Run("fails with invalid task ID", func(t *testing.T) { 2172 + err := handler.Annotate(ctx, "99999", "Test annotation") 2173 + shared.AssertError(t, err, "should fail with invalid task ID") 2174 + shared.AssertContains(t, err.Error(), "failed to find task", "error message") 2175 + }) 2176 + 2177 + t.Run("works with UUID", func(t *testing.T) { 2178 + task := &models.Task{ 2179 + UUID: uuid.New().String(), 2180 + Description: "UUID task", 2181 + Status: "pending", 2182 + } 2183 + _, err := handler.repos.Tasks.Create(ctx, task) 2184 + shared.AssertNoError(t, err, "Create should succeed") 2185 + 2186 + err = handler.Annotate(ctx, task.UUID, "UUID annotation") 2187 + shared.AssertNoError(t, err, "Annotate with UUID should succeed") 2188 + 2189 + retrieved, err := handler.repos.Tasks.GetByUUID(ctx, task.UUID) 2190 + shared.AssertNoError(t, err, "GetByUUID should succeed") 2191 + shared.AssertEqual(t, 1, len(retrieved.Annotations), "should have 1 annotation") 2192 + }) 2193 + }) 2194 + 2195 + t.Run("ListAnnotations", func(t *testing.T) { 2196 + suite := NewHandlerTestSuite(t) 2197 + defer suite.cleanup() 2198 + 2199 + handler, err := NewTaskHandler() 2200 + if err != nil { 2201 + t.Fatalf("Failed to create handler: %v", err) 2202 + } 2203 + defer handler.Close() 2204 + 2205 + t.Run("lists annotations successfully", func(t *testing.T) { 2206 + task := &models.Task{ 2207 + UUID: uuid.New().String(), 2208 + Description: "Test task", 2209 + Status: "pending", 2210 + Annotations: []string{"Annotation 1", "Annotation 2", "Annotation 3"}, 2211 + } 2212 + id, err := handler.repos.Tasks.Create(ctx, task) 2213 + shared.AssertNoError(t, err, "Create should succeed") 2214 + 2215 + err = handler.ListAnnotations(ctx, strconv.FormatInt(id, 10)) 2216 + shared.AssertNoError(t, err, "ListAnnotations should succeed") 2217 + }) 2218 + 2219 + t.Run("handles task with no annotations", func(t *testing.T) { 2220 + task := &models.Task{ 2221 + UUID: uuid.New().String(), 2222 + Description: "Task without annotations", 2223 + Status: "pending", 2224 + } 2225 + id, err := handler.repos.Tasks.Create(ctx, task) 2226 + shared.AssertNoError(t, err, "Create should succeed") 2227 + 2228 + err = handler.ListAnnotations(ctx, strconv.FormatInt(id, 10)) 2229 + shared.AssertNoError(t, err, "ListAnnotations should succeed for empty annotations") 2230 + }) 2231 + 2232 + t.Run("fails with invalid task ID", func(t *testing.T) { 2233 + err := handler.ListAnnotations(ctx, "99999") 2234 + shared.AssertError(t, err, "should fail with invalid task ID") 2235 + shared.AssertContains(t, err.Error(), "failed to find task", "error message") 2236 + }) 2237 + }) 2238 + 2239 + t.Run("RemoveAnnotation", func(t *testing.T) { 2240 + suite := NewHandlerTestSuite(t) 2241 + defer suite.cleanup() 2242 + 2243 + handler, err := NewTaskHandler() 2244 + if err != nil { 2245 + t.Fatalf("Failed to create handler: %v", err) 2246 + } 2247 + defer handler.Close() 2248 + 2249 + t.Run("removes annotation successfully", func(t *testing.T) { 2250 + task := &models.Task{ 2251 + UUID: uuid.New().String(), 2252 + Description: "Test task", 2253 + Status: "pending", 2254 + Annotations: []string{"First", "Second", "Third"}, 2255 + } 2256 + id, err := handler.repos.Tasks.Create(ctx, task) 2257 + shared.AssertNoError(t, err, "Create should succeed") 2258 + 2259 + err = handler.RemoveAnnotation(ctx, strconv.FormatInt(id, 10), 2) 2260 + shared.AssertNoError(t, err, "RemoveAnnotation should succeed") 2261 + 2262 + retrieved, err := handler.repos.Tasks.Get(ctx, id) 2263 + shared.AssertNoError(t, err, "Get should succeed") 2264 + shared.AssertEqual(t, 2, len(retrieved.Annotations), "should have 2 annotations") 2265 + shared.AssertEqual(t, "First", retrieved.Annotations[0], "first annotation should remain") 2266 + shared.AssertEqual(t, "Third", retrieved.Annotations[1], "third annotation should be second") 2267 + }) 2268 + 2269 + t.Run("fails with invalid index (too low)", func(t *testing.T) { 2270 + task := &models.Task{ 2271 + UUID: uuid.New().String(), 2272 + Description: "Test task", 2273 + Status: "pending", 2274 + Annotations: []string{"First"}, 2275 + } 2276 + id, err := handler.repos.Tasks.Create(ctx, task) 2277 + shared.AssertNoError(t, err, "Create should succeed") 2278 + 2279 + err = handler.RemoveAnnotation(ctx, strconv.FormatInt(id, 10), 0) 2280 + shared.AssertError(t, err, "should fail with index 0") 2281 + shared.AssertContains(t, err.Error(), "index out of range", "error message") 2282 + }) 2283 + 2284 + t.Run("fails with invalid index (too high)", func(t *testing.T) { 2285 + task := &models.Task{ 2286 + UUID: uuid.New().String(), 2287 + Description: "Test task", 2288 + Status: "pending", 2289 + Annotations: []string{"First"}, 2290 + } 2291 + id, err := handler.repos.Tasks.Create(ctx, task) 2292 + shared.AssertNoError(t, err, "Create should succeed") 2293 + 2294 + err = handler.RemoveAnnotation(ctx, strconv.FormatInt(id, 10), 5) 2295 + shared.AssertError(t, err, "should fail with index > len") 2296 + shared.AssertContains(t, err.Error(), "index out of range", "error message") 2297 + }) 2298 + 2299 + t.Run("fails when task has no annotations", func(t *testing.T) { 2300 + task := &models.Task{ 2301 + UUID: uuid.New().String(), 2302 + Description: "Task without annotations", 2303 + Status: "pending", 2304 + } 2305 + id, err := handler.repos.Tasks.Create(ctx, task) 2306 + shared.AssertNoError(t, err, "Create should succeed") 2307 + 2308 + err = handler.RemoveAnnotation(ctx, strconv.FormatInt(id, 10), 1) 2309 + shared.AssertError(t, err, "should fail when no annotations") 2310 + shared.AssertContains(t, err.Error(), "has no annotations", "error message") 2311 + }) 2312 + 2313 + t.Run("fails with invalid task ID", func(t *testing.T) { 2314 + err := handler.RemoveAnnotation(ctx, "99999", 1) 2315 + shared.AssertError(t, err, "should fail with invalid task ID") 2316 + shared.AssertContains(t, err.Error(), "failed to find task", "error message") 2317 + }) 2318 + }) 2319 + 2320 + t.Run("BulkEdit", func(t *testing.T) { 2321 + suite := NewHandlerTestSuite(t) 2322 + defer suite.cleanup() 2323 + 2324 + handler, err := NewTaskHandler() 2325 + if err != nil { 2326 + t.Fatalf("Failed to create handler: %v", err) 2327 + } 2328 + defer handler.Close() 2329 + 2330 + t.Run("updates multiple tasks successfully", func(t *testing.T) { 2331 + id1, err := handler.repos.Tasks.Create(ctx, &models.Task{ 2332 + UUID: uuid.New().String(), 2333 + Description: "Task 1", 2334 + Status: "pending", 2335 + }) 2336 + shared.AssertNoError(t, err, "Create should succeed") 2337 + 2338 + id2, err := handler.repos.Tasks.Create(ctx, &models.Task{ 2339 + UUID: uuid.New().String(), 2340 + Description: "Task 2", 2341 + Status: "pending", 2342 + }) 2343 + shared.AssertNoError(t, err, "Create should succeed") 2344 + 2345 + taskIDs := []string{strconv.FormatInt(id1, 10), strconv.FormatInt(id2, 10)} 2346 + err = handler.BulkEdit(ctx, taskIDs, "done", "high", "test-project", "", []string{}, false, false) 2347 + shared.AssertNoError(t, err, "BulkEdit should succeed") 2348 + 2349 + task1, err := handler.repos.Tasks.Get(ctx, id1) 2350 + shared.AssertNoError(t, err, "Get should succeed") 2351 + shared.AssertEqual(t, "done", task1.Status, "task 1 status should be updated") 2352 + shared.AssertEqual(t, "high", task1.Priority, "task 1 priority should be updated") 2353 + shared.AssertEqual(t, "test-project", task1.Project, "task 1 project should be updated") 2354 + 2355 + task2, err := handler.repos.Tasks.Get(ctx, id2) 2356 + shared.AssertNoError(t, err, "Get should succeed") 2357 + shared.AssertEqual(t, "done", task2.Status, "task 2 status should be updated") 2358 + shared.AssertEqual(t, "high", task2.Priority, "task 2 priority should be updated") 2359 + shared.AssertEqual(t, "test-project", task2.Project, "task 2 project should be updated") 2360 + }) 2361 + 2362 + t.Run("updates with tag replacement", func(t *testing.T) { 2363 + id1, err := handler.repos.Tasks.Create(ctx, &models.Task{ 2364 + UUID: uuid.New().String(), 2365 + Description: "Task 1", 2366 + Status: "pending", 2367 + Tags: []string{"old-tag"}, 2368 + }) 2369 + shared.AssertNoError(t, err, "Create should succeed") 2370 + 2371 + taskIDs := []string{strconv.FormatInt(id1, 10)} 2372 + err = handler.BulkEdit(ctx, taskIDs, "", "", "", "", []string{"new-tag1", "new-tag2"}, false, false) 2373 + shared.AssertNoError(t, err, "BulkEdit should succeed") 2374 + 2375 + task, err := handler.repos.Tasks.Get(ctx, id1) 2376 + shared.AssertNoError(t, err, "Get should succeed") 2377 + shared.AssertEqual(t, 2, len(task.Tags), "should have 2 tags") 2378 + shared.AssertTrue(t, slices.Contains(task.Tags, "new-tag1"), "should contain new-tag1") 2379 + shared.AssertTrue(t, slices.Contains(task.Tags, "new-tag2"), "should contain new-tag2") 2380 + }) 2381 + 2382 + t.Run("adds tags with add-tags flag", func(t *testing.T) { 2383 + id1, err := handler.repos.Tasks.Create(ctx, &models.Task{ 2384 + UUID: uuid.New().String(), 2385 + Description: "Task 1", 2386 + Status: "pending", 2387 + Tags: []string{"existing-tag"}, 2388 + }) 2389 + shared.AssertNoError(t, err, "Create should succeed") 2390 + 2391 + taskIDs := []string{strconv.FormatInt(id1, 10)} 2392 + err = handler.BulkEdit(ctx, taskIDs, "", "", "", "", []string{"new-tag"}, true, false) 2393 + shared.AssertNoError(t, err, "BulkEdit should succeed") 2394 + 2395 + task, err := handler.repos.Tasks.Get(ctx, id1) 2396 + shared.AssertNoError(t, err, "Get should succeed") 2397 + shared.AssertEqual(t, 2, len(task.Tags), "should have 2 tags") 2398 + shared.AssertTrue(t, slices.Contains(task.Tags, "existing-tag"), "should contain existing-tag") 2399 + shared.AssertTrue(t, slices.Contains(task.Tags, "new-tag"), "should contain new-tag") 2400 + }) 2401 + 2402 + t.Run("removes tags with remove-tags flag", func(t *testing.T) { 2403 + id1, err := handler.repos.Tasks.Create(ctx, &models.Task{ 2404 + UUID: uuid.New().String(), 2405 + Description: "Task 1", 2406 + Status: "pending", 2407 + Tags: []string{"tag1", "tag2", "tag3"}, 2408 + }) 2409 + shared.AssertNoError(t, err, "Create should succeed") 2410 + 2411 + taskIDs := []string{strconv.FormatInt(id1, 10)} 2412 + err = handler.BulkEdit(ctx, taskIDs, "", "", "", "", []string{"tag2"}, false, true) 2413 + shared.AssertNoError(t, err, "BulkEdit should succeed") 2414 + 2415 + task, err := handler.repos.Tasks.Get(ctx, id1) 2416 + shared.AssertNoError(t, err, "Get should succeed") 2417 + shared.AssertEqual(t, 2, len(task.Tags), "should have 2 tags") 2418 + shared.AssertTrue(t, slices.Contains(task.Tags, "tag1"), "should contain tag1") 2419 + shared.AssertTrue(t, slices.Contains(task.Tags, "tag3"), "should contain tag3") 2420 + shared.AssertFalse(t, slices.Contains(task.Tags, "tag2"), "should not contain tag2") 2421 + }) 2422 + 2423 + t.Run("fails with no task IDs", func(t *testing.T) { 2424 + err := handler.BulkEdit(ctx, []string{}, "done", "", "", "", []string{}, false, false) 2425 + shared.AssertError(t, err, "should fail with no task IDs") 2426 + shared.AssertContains(t, err.Error(), "no task IDs provided", "error message") 2427 + }) 2428 + 2429 + t.Run("fails with invalid task ID", func(t *testing.T) { 2430 + err := handler.BulkEdit(ctx, []string{"99999"}, "done", "", "", "", []string{}, false, false) 2431 + shared.AssertError(t, err, "should fail with invalid task ID") 2432 + }) 2433 + 2434 + t.Run("works with UUIDs", func(t *testing.T) { 2435 + task1 := &models.Task{ 2436 + UUID: uuid.New().String(), 2437 + Description: "UUID task 1", 2438 + Status: "pending", 2439 + } 2440 + _, err := handler.repos.Tasks.Create(ctx, task1) 2441 + shared.AssertNoError(t, err, "Create should succeed") 2442 + 2443 + task2 := &models.Task{ 2444 + UUID: uuid.New().String(), 2445 + Description: "UUID task 2", 2446 + Status: "pending", 2447 + } 2448 + _, err = handler.repos.Tasks.Create(ctx, task2) 2449 + shared.AssertNoError(t, err, "Create should succeed") 2450 + 2451 + taskIDs := []string{task1.UUID, task2.UUID} 2452 + err = handler.BulkEdit(ctx, taskIDs, "done", "", "", "", []string{}, false, false) 2453 + shared.AssertNoError(t, err, "BulkEdit with UUIDs should succeed") 2454 + 2455 + retrieved1, err := handler.repos.Tasks.GetByUUID(ctx, task1.UUID) 2456 + shared.AssertNoError(t, err, "GetByUUID should succeed") 2457 + shared.AssertEqual(t, "done", retrieved1.Status, "task 1 status should be updated") 2458 + 2459 + retrieved2, err := handler.repos.Tasks.GetByUUID(ctx, task2.UUID) 2460 + shared.AssertNoError(t, err, "GetByUUID should succeed") 2461 + shared.AssertEqual(t, "done", retrieved2.Status, "task 2 status should be updated") 2462 + }) 2463 + }) 2126 2464 }
+9
internal/models/models.go
··· 221 221 Modified time.Time `json:"modified"` 222 222 } 223 223 224 + // TaskHistory represents a historical snapshot of a task for undo functionality 225 + type TaskHistory struct { 226 + ID int64 `json:"id"` 227 + TaskID int64 `json:"task_id"` 228 + Operation string `json:"operation"` // update, delete 229 + Snapshot string `json:"snapshot"` // JSON snapshot of task 230 + CreatedAt time.Time `json:"created_at"` 231 + } 232 + 224 233 // MarshalTags converts tags slice to JSON string for database storage 225 234 func (t *Task) MarshalTags() (string, error) { 226 235 if len(t.Tags) == 0 {
+118
internal/repo/task_repository.go
··· 4 4 import ( 5 5 "context" 6 6 "database/sql" 7 + "encoding/json" 7 8 "fmt" 8 9 "strings" 9 10 "time" ··· 199 200 200 201 // Update modifies an existing task 201 202 func (r *TaskRepository) Update(ctx context.Context, task *models.Task) error { 203 + oldTask, err := r.Get(ctx, task.ID) 204 + if err != nil { 205 + return fmt.Errorf("failed to get current task state: %w", err) 206 + } 207 + 208 + if err := r.SaveHistory(ctx, oldTask, "update"); err != nil { 209 + return fmt.Errorf("failed to save history: %w", err) 210 + } 211 + 202 212 task.Modified = time.Now() 203 213 204 214 tags, err := marshalTaskTags(task) ··· 721 731 } 722 732 return tasks, nil 723 733 } 734 + 735 + // BulkUpdate applies the same updates to multiple tasks 736 + func (r *TaskRepository) BulkUpdate(ctx context.Context, taskIDs []int64, updates *models.Task) error { 737 + if len(taskIDs) == 0 { 738 + return fmt.Errorf("no task IDs provided") 739 + } 740 + 741 + for _, id := range taskIDs { 742 + task, err := r.Get(ctx, id) 743 + if err != nil { 744 + return fmt.Errorf("failed to get task %d: %w", id, err) 745 + } 746 + 747 + if updates.Status != "" { 748 + task.Status = updates.Status 749 + } 750 + if updates.Priority != "" { 751 + task.Priority = updates.Priority 752 + } 753 + if updates.Project != "" { 754 + task.Project = updates.Project 755 + } 756 + if updates.Context != "" { 757 + task.Context = updates.Context 758 + } 759 + if len(updates.Tags) > 0 { 760 + task.Tags = updates.Tags 761 + } 762 + if updates.Due != nil { 763 + task.Due = updates.Due 764 + } 765 + 766 + if err := r.Update(ctx, task); err != nil { 767 + return fmt.Errorf("failed to update task %d: %w", id, err) 768 + } 769 + } 770 + 771 + return nil 772 + } 773 + 774 + // SaveHistory saves a snapshot of a task before an operation 775 + func (r *TaskRepository) SaveHistory(ctx context.Context, task *models.Task, operation string) error { 776 + snapshot, err := json.Marshal(task) 777 + if err != nil { 778 + return fmt.Errorf("failed to marshal task snapshot: %w", err) 779 + } 780 + 781 + query := `INSERT INTO task_history (task_id, operation, snapshot) VALUES (?, ?, ?)` 782 + if _, err := r.db.ExecContext(ctx, query, task.ID, operation, string(snapshot)); err != nil { 783 + return fmt.Errorf("failed to save task history: %w", err) 784 + } 785 + 786 + return nil 787 + } 788 + 789 + // GetHistory retrieves the history of changes for a task 790 + func (r *TaskRepository) GetHistory(ctx context.Context, taskID int64, limit int) ([]*models.TaskHistory, error) { 791 + query := `SELECT id, task_id, operation, snapshot, created_at FROM task_history WHERE task_id = ? ORDER BY created_at DESC` 792 + if limit > 0 { 793 + query += fmt.Sprintf(" LIMIT %d", limit) 794 + } 795 + 796 + rows, err := r.db.QueryContext(ctx, query, taskID) 797 + if err != nil { 798 + return nil, fmt.Errorf("failed to query task history: %w", err) 799 + } 800 + defer rows.Close() 801 + 802 + var history []*models.TaskHistory 803 + for rows.Next() { 804 + h := &models.TaskHistory{} 805 + if err := rows.Scan(&h.ID, &h.TaskID, &h.Operation, &h.Snapshot, &h.CreatedAt); err != nil { 806 + return nil, fmt.Errorf("failed to scan task history: %w", err) 807 + } 808 + history = append(history, h) 809 + } 810 + 811 + return history, rows.Err() 812 + } 813 + 814 + // UndoLastChange reverts a task to its previous state 815 + func (r *TaskRepository) UndoLastChange(ctx context.Context, taskID int64) error { 816 + history, err := r.GetHistory(ctx, taskID, 1) 817 + if err != nil { 818 + return fmt.Errorf("failed to get task history: %w", err) 819 + } 820 + 821 + if len(history) == 0 { 822 + return fmt.Errorf("no history found for task") 823 + } 824 + 825 + lastHistory := history[0] 826 + var task models.Task 827 + if err := json.Unmarshal([]byte(lastHistory.Snapshot), &task); err != nil { 828 + return fmt.Errorf("failed to unmarshal task snapshot: %w", err) 829 + } 830 + 831 + if err := r.Update(ctx, &task); err != nil { 832 + return fmt.Errorf("failed to restore task: %w", err) 833 + } 834 + 835 + deleteQuery := `DELETE FROM task_history WHERE id = ?` 836 + if _, err := r.db.ExecContext(ctx, deleteQuery, lastHistory.ID); err != nil { 837 + return fmt.Errorf("failed to delete history entry: %w", err) 838 + } 839 + 840 + return nil 841 + }
+75
internal/repo/task_repository_test.go
··· 1174 1174 shared.AssertNoError(t, err, "GetBlockedTasks for independent should succeed") 1175 1175 shared.AssertEqual(t, 0, len(emptyBlocked), "independent task should not block anything") 1176 1176 }) 1177 + 1178 + t.Run("BulkUpdate", func(t *testing.T) { 1179 + t.Run("updates multiple tasks successfully", func(t *testing.T) { 1180 + task1 := CreateSampleTask() 1181 + task1.Status = "pending" 1182 + task1.Priority = "low" 1183 + id1, err := repo.Create(ctx, task1) 1184 + shared.AssertNoError(t, err, "Create should succeed") 1185 + 1186 + task2 := CreateSampleTask() 1187 + task2.Status = "pending" 1188 + task2.Priority = "low" 1189 + id2, err := repo.Create(ctx, task2) 1190 + shared.AssertNoError(t, err, "Create should succeed") 1191 + 1192 + updates := &models.Task{ 1193 + Status: "done", 1194 + Priority: "high", 1195 + Project: "bulk-test", 1196 + } 1197 + 1198 + err = repo.BulkUpdate(ctx, []int64{id1, id2}, updates) 1199 + shared.AssertNoError(t, err, "BulkUpdate should succeed") 1200 + 1201 + updated1, err := repo.Get(ctx, id1) 1202 + shared.AssertNoError(t, err, "Get should succeed") 1203 + shared.AssertEqual(t, "done", updated1.Status, "task 1 status should be updated") 1204 + shared.AssertEqual(t, "high", updated1.Priority, "task 1 priority should be updated") 1205 + shared.AssertEqual(t, "bulk-test", updated1.Project, "task 1 project should be updated") 1206 + 1207 + updated2, err := repo.Get(ctx, id2) 1208 + shared.AssertNoError(t, err, "Get should succeed") 1209 + shared.AssertEqual(t, "done", updated2.Status, "task 2 status should be updated") 1210 + shared.AssertEqual(t, "high", updated2.Priority, "task 2 priority should be updated") 1211 + shared.AssertEqual(t, "bulk-test", updated2.Project, "task 2 project should be updated") 1212 + }) 1213 + 1214 + t.Run("fails with no task IDs", func(t *testing.T) { 1215 + updates := &models.Task{Status: "done"} 1216 + err := repo.BulkUpdate(ctx, []int64{}, updates) 1217 + shared.AssertError(t, err, "should fail with empty task IDs") 1218 + shared.AssertContains(t, err.Error(), "no task IDs provided", "error message") 1219 + }) 1220 + 1221 + t.Run("fails with invalid task ID", func(t *testing.T) { 1222 + updates := &models.Task{Status: "done"} 1223 + err := repo.BulkUpdate(ctx, []int64{99999}, updates) 1224 + shared.AssertError(t, err, "should fail with invalid task ID") 1225 + shared.AssertContains(t, err.Error(), "failed to get task", "error message") 1226 + }) 1227 + 1228 + t.Run("updates only non-empty fields", func(t *testing.T) { 1229 + task1 := CreateSampleTask() 1230 + task1.Status = "pending" 1231 + task1.Priority = "low" 1232 + task1.Project = "original-project" 1233 + task1.Context = "original-context" 1234 + id1, err := repo.Create(ctx, task1) 1235 + shared.AssertNoError(t, err, "Create should succeed") 1236 + 1237 + updates := &models.Task{ 1238 + Status: "done", 1239 + } 1240 + 1241 + err = repo.BulkUpdate(ctx, []int64{id1}, updates) 1242 + shared.AssertNoError(t, err, "BulkUpdate should succeed") 1243 + 1244 + updated, err := repo.Get(ctx, id1) 1245 + shared.AssertNoError(t, err, "Get should succeed") 1246 + shared.AssertEqual(t, "done", updated.Status, "status should be updated") 1247 + shared.AssertEqual(t, "low", updated.Priority, "priority should remain unchanged") 1248 + shared.AssertEqual(t, "original-project", updated.Project, "project should remain unchanged") 1249 + shared.AssertEqual(t, "original-context", updated.Context, "context should remain unchanged") 1250 + }) 1251 + }) 1177 1252 }
+1
internal/store/sql/migrations/0011_create_task_history_table_down.sql
··· 1 + DROP TABLE IF EXISTS task_history;
+11
internal/store/sql/migrations/0011_create_task_history_table_up.sql
··· 1 + -- Task history table for undo functionality 2 + CREATE TABLE IF NOT EXISTS task_history ( 3 + id INTEGER PRIMARY KEY AUTOINCREMENT, 4 + task_id INTEGER NOT NULL, 5 + operation TEXT NOT NULL, -- 'update', 'delete' 6 + snapshot TEXT NOT NULL, -- JSON snapshot of task before operation 7 + created_at DATETIME DEFAULT CURRENT_TIMESTAMP 8 + ); 9 + 10 + CREATE INDEX IF NOT EXISTS idx_task_history_task_id ON task_history(task_id); 11 + CREATE INDEX IF NOT EXISTS idx_task_history_created_at ON task_history(created_at);