cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
at main 1481 lines 37 kB view raw
1// TODO: add context field to table in [TaskHandler.listTasksInteractive] 2package handlers 3 4import ( 5 "context" 6 "fmt" 7 "os" 8 "slices" 9 "sort" 10 "strconv" 11 "strings" 12 "time" 13 14 "github.com/google/uuid" 15 "github.com/stormlightlabs/noteleaf/internal/models" 16 "github.com/stormlightlabs/noteleaf/internal/repo" 17 "github.com/stormlightlabs/noteleaf/internal/store" 18 "github.com/stormlightlabs/noteleaf/internal/ui" 19) 20 21// TaskHandler handles all task-related commands 22type TaskHandler struct { 23 db *store.Database 24 config *store.Config 25 repos *repo.Repositories 26} 27 28// NewTaskHandler creates a new task handler 29func NewTaskHandler() (*TaskHandler, error) { 30 db, err := store.NewDatabase() 31 if err != nil { 32 return nil, fmt.Errorf("failed to initialize database: %w", err) 33 } 34 35 config, err := store.LoadConfig() 36 if err != nil { 37 return nil, fmt.Errorf("failed to load configuration: %w", err) 38 } 39 40 repos := repo.NewRepositories(db.DB) 41 42 return &TaskHandler{ 43 db: db, 44 config: config, 45 repos: repos, 46 }, nil 47} 48 49// Close cleans up resources 50func (h *TaskHandler) Close() error { 51 return h.db.Close() 52} 53 54// Create creates a new task 55func (h *TaskHandler) Create(ctx context.Context, description, priority, project, context, due, wait, scheduled, recur, until, parentUUID, dependsOn string, tags []string) error { 56 if description == "" { 57 return fmt.Errorf("task description required") 58 } 59 60 parsed := parseDescription(description) 61 62 if project != "" { 63 parsed.Project = project 64 } 65 if context != "" { 66 parsed.Context = context 67 } 68 if due != "" { 69 parsed.Due = due 70 } 71 if wait != "" { 72 parsed.Wait = wait 73 } 74 if scheduled != "" { 75 parsed.Scheduled = scheduled 76 } 77 if recur != "" { 78 parsed.Recur = recur 79 } 80 if until != "" { 81 parsed.Until = until 82 } 83 if parentUUID != "" { 84 parsed.ParentUUID = parentUUID 85 } 86 if dependsOn != "" { 87 parsed.DependsOn = strings.Split(dependsOn, ",") 88 } 89 if len(tags) > 0 { 90 parsed.Tags = append(parsed.Tags, tags...) 91 } 92 93 task := &models.Task{ 94 UUID: uuid.New().String(), 95 Description: parsed.Description, 96 Status: "pending", 97 Priority: priority, 98 Project: parsed.Project, 99 Context: parsed.Context, 100 Tags: parsed.Tags, 101 Recur: models.RRule(parsed.Recur), 102 DependsOn: parsed.DependsOn, 103 } 104 105 if parsed.Due != "" { 106 if dueTime, err := time.Parse("2006-01-02", parsed.Due); err == nil { 107 task.Due = &dueTime 108 } else { 109 return fmt.Errorf("invalid due date format, use YYYY-MM-DD: %w", err) 110 } 111 } 112 113 if parsed.Wait != "" { 114 if waitTime, err := time.Parse("2006-01-02", parsed.Wait); err == nil { 115 task.Wait = &waitTime 116 } else { 117 return fmt.Errorf("invalid wait date format, use YYYY-MM-DD: %w", err) 118 } 119 } 120 121 if parsed.Scheduled != "" { 122 if scheduledTime, err := time.Parse("2006-01-02", parsed.Scheduled); err == nil { 123 task.Scheduled = &scheduledTime 124 } else { 125 return fmt.Errorf("invalid scheduled date format, use YYYY-MM-DD: %w", err) 126 } 127 } 128 129 if parsed.Until != "" { 130 if untilTime, err := time.Parse("2006-01-02", parsed.Until); err == nil { 131 task.Until = &untilTime 132 } else { 133 return fmt.Errorf("invalid until date format, use YYYY-MM-DD: %w", err) 134 } 135 } 136 137 if parsed.ParentUUID != "" { 138 task.ParentUUID = &parsed.ParentUUID 139 } 140 141 id, err := h.repos.Tasks.Create(ctx, task) 142 if err != nil { 143 return fmt.Errorf("failed to create task: %w", err) 144 } 145 146 fmt.Printf("Task created (ID: %d, UUID: %s): %s\n", id, task.UUID, task.Description) 147 148 if priority != "" { 149 fmt.Printf("Priority: %s\n", priority) 150 } 151 if task.Project != "" { 152 fmt.Printf("Project: %s\n", task.Project) 153 } 154 if task.Context != "" { 155 fmt.Printf("Context: %s\n", task.Context) 156 } 157 if len(task.Tags) > 0 { 158 fmt.Printf("Tags: %s\n", strings.Join(task.Tags, ", ")) 159 } 160 if task.Due != nil { 161 fmt.Printf("Due: %s\n", task.Due.Format("2006-01-02")) 162 } 163 if task.Recur != "" { 164 fmt.Printf("Recur: %s\n", task.Recur) 165 } 166 if task.Until != nil { 167 fmt.Printf("Until: %s\n", task.Until.Format("2006-01-02")) 168 } 169 if task.ParentUUID != nil { 170 fmt.Printf("Parent: %s\n", *task.ParentUUID) 171 } 172 if len(task.DependsOn) > 0 { 173 fmt.Printf("Depends on: %s\n", strings.Join(task.DependsOn, ", ")) 174 } 175 176 return nil 177} 178 179// List lists all tasks with optional filtering 180func (h *TaskHandler) List(ctx context.Context, static, showAll bool, status, priority, project, context, sortBy string) error { 181 if static { 182 return h.listTasksStatic(ctx, showAll, status, priority, project, context, sortBy) 183 } 184 185 return h.listTasksInteractive(ctx, showAll, status, priority, project, context) 186} 187 188func (h *TaskHandler) listTasksStatic(ctx context.Context, showAll bool, status, priority, project, context, sortBy string) error { 189 opts := repo.TaskListOptions{ 190 Status: status, 191 Priority: priority, 192 Project: project, 193 Context: context, 194 } 195 196 if !showAll && opts.Status == "" { 197 opts.Status = "pending" 198 } 199 200 tasks, err := h.repos.Tasks.List(ctx, opts) 201 if err != nil { 202 return fmt.Errorf("failed to list tasks: %w", err) 203 } 204 205 if sortBy == "urgency" { 206 now := time.Now() 207 sort.Slice(tasks, func(i, j int) bool { 208 return tasks[i].Urgency(now) > tasks[j].Urgency(now) 209 }) 210 } 211 212 if len(tasks) == 0 { 213 fmt.Printf("No tasks found matching criteria\n") 214 return nil 215 } 216 217 fmt.Printf("Found %d task(s)", len(tasks)) 218 if sortBy == "urgency" { 219 fmt.Printf(" (sorted by urgency)") 220 } 221 fmt.Printf(":\n\n") 222 223 for _, task := range tasks { 224 if sortBy == "urgency" { 225 urgency := task.Urgency(time.Now()) 226 fmt.Printf("[%.1f] ", urgency) 227 } 228 printTask(task) 229 } 230 231 return nil 232} 233 234func (h *TaskHandler) listTasksInteractive(ctx context.Context, showAll bool, status, priority, project, _ string) error { 235 taskTable := ui.NewTaskListFromTable(h.repos.Tasks, os.Stdout, os.Stdin, false, showAll, status, priority, project) 236 return taskTable.Browse(ctx) 237} 238 239// Update updates a task using parsed flag values 240func (h *TaskHandler) Update(ctx context.Context, taskID, description, status, priority, project, context, due, recur, until, parentUUID string, addTags, removeTags []string, addDeps, removeDeps string) error { 241 var task *models.Task 242 var err error 243 244 if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 245 task, err = h.repos.Tasks.Get(ctx, id) 246 } else { 247 task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 248 } 249 250 if err != nil { 251 return fmt.Errorf("failed to find task: %w", err) 252 } 253 254 if description != "" { 255 task.Description = description 256 } 257 if status != "" { 258 task.Status = status 259 } 260 if priority != "" { 261 task.Priority = priority 262 } 263 if project != "" { 264 task.Project = project 265 } 266 if context != "" { 267 task.Context = context 268 } 269 if due != "" { 270 if dueTime, err := time.Parse("2006-01-02", due); err == nil { 271 task.Due = &dueTime 272 } else { 273 return fmt.Errorf("invalid due date format, use YYYY-MM-DD: %w", err) 274 } 275 } 276 if recur != "" { 277 task.Recur = models.RRule(recur) 278 } 279 if until != "" { 280 if untilTime, err := time.Parse("2006-01-02", until); err == nil { 281 task.Until = &untilTime 282 } else { 283 return fmt.Errorf("invalid until date format, use YYYY-MM-DD: %w", err) 284 } 285 } 286 if parentUUID != "" { 287 task.ParentUUID = &parentUUID 288 } 289 290 for _, tag := range addTags { 291 if !slices.Contains(task.Tags, tag) { 292 task.Tags = append(task.Tags, tag) 293 } 294 } 295 296 for _, tag := range removeTags { 297 task.Tags = removeString(task.Tags, tag) 298 } 299 300 if addDeps != "" { 301 deps := strings.SplitSeq(addDeps, ",") 302 for dep := range deps { 303 dep = strings.TrimSpace(dep) 304 if dep != "" && !slices.Contains(task.DependsOn, dep) { 305 task.DependsOn = append(task.DependsOn, dep) 306 } 307 } 308 } 309 310 if removeDeps != "" { 311 deps := strings.SplitSeq(removeDeps, ",") 312 for dep := range deps { 313 dep = strings.TrimSpace(dep) 314 task.DependsOn = removeString(task.DependsOn, dep) 315 } 316 } 317 318 err = h.repos.Tasks.Update(ctx, task) 319 if err != nil { 320 return fmt.Errorf("failed to update task: %w", err) 321 } 322 323 fmt.Printf("Task updated (ID: %d): %s\n", task.ID, task.Description) 324 return nil 325} 326 327// EditInteractive opens an interactive task editor with status picker and priority toggle 328func (h *TaskHandler) EditInteractive(ctx context.Context, taskID string) error { 329 var task *models.Task 330 var err error 331 332 if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 333 task, err = h.repos.Tasks.Get(ctx, id) 334 } else { 335 task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 336 } 337 338 if err != nil { 339 return fmt.Errorf("failed to find task: %w", err) 340 } 341 342 editor := ui.NewTaskEditor(task, h.repos.Tasks, ui.TaskEditOptions{}) 343 updated, err := editor.Edit(ctx) 344 if err != nil { 345 if err.Error() == "edit cancelled" { 346 fmt.Println("Task edit cancelled") 347 return nil 348 } 349 return fmt.Errorf("failed to edit task: %w", err) 350 } 351 352 fmt.Printf("Task updated (ID: %d): %s\n", updated.ID, updated.Description) 353 fmt.Printf("Status: %s\n", ui.FormatStatusWithText(updated.Status)) 354 if updated.Priority != "" { 355 fmt.Printf("Priority: %s\n", ui.FormatPriorityWithText(updated.Priority)) 356 } 357 if updated.Project != "" { 358 fmt.Printf("Project: %s\n", updated.Project) 359 } 360 361 return nil 362} 363 364// Delete deletes a task 365func (h *TaskHandler) Delete(ctx context.Context, args []string) error { 366 if len(args) < 1 { 367 return fmt.Errorf("task ID required") 368 } 369 370 taskID := args[0] 371 var task *models.Task 372 var err error 373 374 if id, parseErr := strconv.ParseInt(taskID, 10, 64); parseErr == nil { 375 task, err = h.repos.Tasks.Get(ctx, id) 376 if err != nil { 377 return fmt.Errorf("failed to find task: %w", err) 378 } 379 380 err = h.repos.Tasks.Delete(ctx, id) 381 } else { 382 task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 383 if err != nil { 384 return fmt.Errorf("failed to find task: %w", err) 385 } 386 387 err = h.repos.Tasks.Delete(ctx, task.ID) 388 } 389 390 if err != nil { 391 return fmt.Errorf("failed to delete task: %w", err) 392 } 393 394 fmt.Printf("Task deleted (ID: %d): %s\n", task.ID, task.Description) 395 return nil 396} 397 398// View displays a single task 399func (h *TaskHandler) View(ctx context.Context, args []string, format string, jsonOutput, noMetadata bool) error { 400 if len(args) < 1 { 401 return fmt.Errorf("task ID required") 402 } 403 404 taskID := args[0] 405 var task *models.Task 406 var err error 407 408 if id, parseErr := strconv.ParseInt(taskID, 10, 64); parseErr == nil { 409 task, err = h.repos.Tasks.Get(ctx, id) 410 } else { 411 task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 412 } 413 414 if err != nil { 415 return fmt.Errorf("failed to find task: %w", err) 416 } 417 418 if jsonOutput { 419 return printTaskJSON(task) 420 } 421 422 if format == "brief" { 423 printTask(task) 424 } else { 425 printTaskDetail(task, noMetadata) 426 } 427 return nil 428} 429 430// Start starts time tracking for a task 431func (h *TaskHandler) Start(ctx context.Context, taskID string, description string) error { 432 var task *models.Task 433 var err error 434 435 if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 436 task, err = h.repos.Tasks.Get(ctx, id) 437 } else { 438 task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 439 } 440 441 if err != nil { 442 return fmt.Errorf("failed to find task: %w", err) 443 } 444 445 active, err := h.repos.TimeEntries.GetActiveByTaskID(ctx, task.ID) 446 if err != nil && err.Error() != "sql: no rows in result set" { 447 return fmt.Errorf("failed to check active time entry: %w", err) 448 } 449 if active != nil { 450 duration := time.Since(active.StartTime) 451 fmt.Printf("Task already started %s ago: %s\n", formatDuration(duration), task.Description) 452 return nil 453 } 454 455 _, err = h.repos.TimeEntries.Start(ctx, task.ID, description) 456 if err != nil { 457 return fmt.Errorf("failed to start time tracking: %w", err) 458 } 459 460 fmt.Printf("Started task (ID: %d): %s\n", task.ID, task.Description) 461 if description != "" { 462 fmt.Printf("Note: %s\n", description) 463 } 464 465 return nil 466} 467 468// Stop stops time tracking for a task 469func (h *TaskHandler) Stop(ctx context.Context, taskID string) error { 470 var task *models.Task 471 var err error 472 473 if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 474 task, err = h.repos.Tasks.Get(ctx, id) 475 } else { 476 task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 477 } 478 479 if err != nil { 480 return fmt.Errorf("failed to find task: %w", err) 481 } 482 483 entry, err := h.repos.TimeEntries.StopActiveByTaskID(ctx, task.ID) 484 if err != nil { 485 if err.Error() == "no active time entry found for task" { 486 fmt.Printf("No active time tracking for task: %s\n", task.Description) 487 return nil 488 } 489 return fmt.Errorf("failed to stop time tracking: %w", err) 490 } 491 492 fmt.Printf("Stopped task (ID: %d): %s\n", task.ID, task.Description) 493 fmt.Printf("Time tracked: %s\n", formatDuration(entry.GetDuration())) 494 495 return nil 496} 497 498// Timesheet shows time tracking summary 499func (h *TaskHandler) Timesheet(ctx context.Context, days int, taskID string) error { 500 var entries []*models.TimeEntry 501 var err error 502 503 if taskID != "" { 504 var task *models.Task 505 if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 506 task, err = h.repos.Tasks.Get(ctx, id) 507 } else { 508 task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 509 } 510 511 if err != nil { 512 return fmt.Errorf("failed to find task: %w", err) 513 } 514 515 entries, err = h.repos.TimeEntries.GetByTaskID(ctx, task.ID) 516 if err != nil { 517 return fmt.Errorf("failed to get time entries: %w", err) 518 } 519 520 fmt.Printf("Timesheet for task: %s\n\n", task.Description) 521 } else { 522 end := time.Now() 523 start := end.AddDate(0, 0, -days) 524 525 entries, err = h.repos.TimeEntries.GetByDateRange(ctx, start, end) 526 if err != nil { 527 return fmt.Errorf("failed to get time entries: %w", err) 528 } 529 530 fmt.Printf("Timesheet for last %d days:\n\n", days) 531 } 532 533 if len(entries) == 0 { 534 fmt.Printf("No time entries found\n") 535 return nil 536 } 537 538 taskTotals := make(map[int64]time.Duration) 539 dayTotals := make(map[string]time.Duration) 540 totalTime := time.Duration(0) 541 542 fmt.Printf("%-20s %-10s %-12s %-40s %s\n", "Date", "Duration", "Status", "Task", "Note") 543 fmt.Printf("%s\n", strings.Repeat("-", 95)) 544 545 for _, entry := range entries { 546 task, err := h.repos.Tasks.Get(ctx, entry.TaskID) 547 if err != nil { 548 continue 549 } 550 551 duration := entry.GetDuration() 552 day := entry.StartTime.Format("2006-01-02") 553 status := "completed" 554 if entry.IsActive() { 555 status = "active" 556 } 557 558 taskTotals[entry.TaskID] += duration 559 dayTotals[day] += duration 560 totalTime += duration 561 562 note := entry.Description 563 if len(note) > 35 { 564 note = note[:32] + "..." 565 } 566 567 taskDesc := task.Description 568 if len(taskDesc) > 37 { 569 taskDesc = taskDesc[:34] + "..." 570 } 571 572 fmt.Printf("%-20s %-10s %-12s %-40s %s\n", 573 day, 574 formatDuration(duration), 575 status, 576 fmt.Sprintf("[%d] %s", task.ID, taskDesc), 577 note, 578 ) 579 } 580 581 fmt.Printf("%s\n", strings.Repeat("-", 95)) 582 fmt.Printf("Total time: %s\n", formatDuration(totalTime)) 583 584 return nil 585} 586 587// Done marks a task as completed 588func (h *TaskHandler) Done(ctx context.Context, args []string) error { 589 if len(args) < 1 { 590 return fmt.Errorf("task ID required") 591 } 592 593 taskID := args[0] 594 var task *models.Task 595 var err error 596 597 if id, parseErr := strconv.ParseInt(taskID, 10, 64); parseErr == nil { 598 task, err = h.repos.Tasks.Get(ctx, id) 599 } else { 600 task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 601 } 602 603 if err != nil { 604 return fmt.Errorf("failed to find task: %w", err) 605 } 606 607 if task.Status == "completed" { 608 fmt.Printf("Task already completed: %s\n", task.Description) 609 return nil 610 } 611 612 now := time.Now() 613 task.Status = "completed" 614 task.End = &now 615 616 err = h.repos.Tasks.Update(ctx, task) 617 if err != nil { 618 return fmt.Errorf("failed to update task: %w", err) 619 } 620 621 fmt.Printf("Task completed (ID: %d): %s\n", task.ID, task.Description) 622 return nil 623} 624 625// ListProjects lists all projects with their task counts 626func (h *TaskHandler) ListProjects(ctx context.Context, static bool, todoTxt ...bool) error { 627 useTodoTxt := len(todoTxt) > 0 && todoTxt[0] 628 if static { 629 return h.listProjectsStatic(ctx, useTodoTxt) 630 } 631 return h.listProjectsInteractive(ctx, useTodoTxt) 632} 633 634func (h *TaskHandler) listProjectsStatic(ctx context.Context, todoTxt bool) error { 635 tasks, err := h.repos.Tasks.List(ctx, repo.TaskListOptions{}) 636 if err != nil { 637 return fmt.Errorf("failed to list tasks for projects: %w", err) 638 } 639 640 projectCounts := make(map[string]int) 641 for _, task := range tasks { 642 if task.Project != "" { 643 projectCounts[task.Project]++ 644 } 645 } 646 647 if len(projectCounts) == 0 { 648 fmt.Printf("No projects found\n") 649 return nil 650 } 651 652 projects := make([]string, 0, len(projectCounts)) 653 for project := range projectCounts { 654 projects = append(projects, project) 655 } 656 slices.Sort(projects) 657 658 fmt.Printf("Found %d project(s):\n\n", len(projects)) 659 for _, project := range projects { 660 count := projectCounts[project] 661 if todoTxt { 662 fmt.Printf("+%s (%d task%s)\n", project, count, pluralize(count)) 663 } else { 664 fmt.Printf("%s (%d task%s)\n", project, count, pluralize(count)) 665 } 666 } 667 668 return nil 669} 670 671// TODO: Add todo.txt format support to interactive mode 672func (h *TaskHandler) listProjectsInteractive(ctx context.Context, _ bool) error { 673 projectTable := ui.NewProjectListFromTable(h.repos.Tasks, nil, nil, false) 674 return projectTable.Browse(ctx) 675} 676 677// ListTags lists all tags with their task counts 678func (h *TaskHandler) ListTags(ctx context.Context, static bool) error { 679 if static { 680 return h.listTagsStatic(ctx) 681 } 682 683 return h.listTagsInteractive(ctx) 684} 685 686// ListContexts lists all contexts with their task counts 687func (h *TaskHandler) ListContexts(ctx context.Context, static bool, todoTxt ...bool) error { 688 useTodoTxt := len(todoTxt) > 0 && todoTxt[0] 689 if static { 690 return h.listContextsStatic(ctx, useTodoTxt) 691 } 692 return h.listContextsInteractive(ctx, useTodoTxt) 693} 694 695func (h *TaskHandler) listContextsStatic(ctx context.Context, todoTxt bool) error { 696 tasks, err := h.repos.Tasks.List(ctx, repo.TaskListOptions{}) 697 if err != nil { 698 return fmt.Errorf("failed to list tasks for contexts: %w", err) 699 } 700 701 contextCounts := make(map[string]int) 702 for _, task := range tasks { 703 if task.Context != "" { 704 contextCounts[task.Context]++ 705 } 706 } 707 708 if len(contextCounts) == 0 { 709 fmt.Printf("No contexts found\n") 710 return nil 711 } 712 713 contexts := make([]string, 0, len(contextCounts)) 714 for context := range contextCounts { 715 contexts = append(contexts, context) 716 } 717 slices.Sort(contexts) 718 719 fmt.Printf("Found %d context(s):\n\n", len(contexts)) 720 for _, context := range contexts { 721 count := contextCounts[context] 722 if todoTxt { 723 fmt.Printf("@%s (%d task%s)\n", context, count, pluralize(count)) 724 } else { 725 fmt.Printf("%s (%d task%s)\n", context, count, pluralize(count)) 726 } 727 } 728 729 return nil 730} 731 732func (h *TaskHandler) listContextsInteractive(ctx context.Context, todoTxt bool) error { 733 fmt.Println("Interactive context listing not implemented yet - using static mode") 734 return h.listContextsStatic(ctx, todoTxt) 735} 736 737func (h *TaskHandler) listTagsStatic(ctx context.Context) error { 738 tasks, err := h.repos.Tasks.List(ctx, repo.TaskListOptions{}) 739 if err != nil { 740 return fmt.Errorf("failed to list tasks for tags: %w", err) 741 } 742 743 tagCounts := make(map[string]int) 744 for _, task := range tasks { 745 for _, tag := range task.Tags { 746 tagCounts[tag]++ 747 } 748 } 749 750 if len(tagCounts) == 0 { 751 fmt.Printf("No tags found\n") 752 return nil 753 } 754 755 tags := make([]string, 0, len(tagCounts)) 756 for tag := range tagCounts { 757 tags = append(tags, tag) 758 } 759 slices.Sort(tags) 760 761 fmt.Printf("Found %d tag(s):\n\n", len(tags)) 762 for _, tag := range tags { 763 count := tagCounts[tag] 764 fmt.Printf("%s (%d task%s)\n", tag, count, pluralize(count)) 765 } 766 767 return nil 768} 769 770func (h *TaskHandler) listTagsInteractive(ctx context.Context) error { 771 tagTable := ui.NewTagListFromTable(h.repos.Tasks, nil, nil, false) 772 return tagTable.Browse(ctx) 773} 774 775// SetRecur sets the recurrence rule for a task 776func (h *TaskHandler) SetRecur(ctx context.Context, taskID, rule, until string) error { 777 var task *models.Task 778 var err error 779 780 if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 781 task, err = h.repos.Tasks.Get(ctx, id) 782 } else { 783 task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 784 } 785 786 if err != nil { 787 return fmt.Errorf("failed to find task: %w", err) 788 } 789 790 if rule != "" { 791 task.Recur = models.RRule(rule) 792 } 793 794 if until != "" { 795 if untilTime, err := time.Parse("2006-01-02", until); err == nil { 796 task.Until = &untilTime 797 } else { 798 return fmt.Errorf("invalid until date format, use YYYY-MM-DD: %w", err) 799 } 800 } 801 802 err = h.repos.Tasks.Update(ctx, task) 803 if err != nil { 804 return fmt.Errorf("failed to update task recurrence: %w", err) 805 } 806 807 fmt.Printf("Recurrence set for task (ID: %d): %s\n", task.ID, task.Description) 808 if task.Recur != "" { 809 fmt.Printf("Rule: %s\n", task.Recur) 810 } 811 if task.Until != nil { 812 fmt.Printf("Until: %s\n", task.Until.Format("2006-01-02")) 813 } 814 815 return nil 816} 817 818// ClearRecur clears the recurrence rule from a task 819func (h *TaskHandler) ClearRecur(ctx context.Context, taskID string) error { 820 var task *models.Task 821 var err error 822 823 if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 824 task, err = h.repos.Tasks.Get(ctx, id) 825 } else { 826 task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 827 } 828 829 if err != nil { 830 return fmt.Errorf("failed to find task: %w", err) 831 } 832 833 task.Recur = "" 834 task.Until = nil 835 836 err = h.repos.Tasks.Update(ctx, task) 837 if err != nil { 838 return fmt.Errorf("failed to clear task recurrence: %w", err) 839 } 840 841 fmt.Printf("Recurrence cleared for task (ID: %d): %s\n", task.ID, task.Description) 842 return nil 843} 844 845// ShowRecur displays the recurrence details for a task 846func (h *TaskHandler) ShowRecur(ctx context.Context, taskID string) error { 847 var task *models.Task 848 var err error 849 850 if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 851 task, err = h.repos.Tasks.Get(ctx, id) 852 } else { 853 task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 854 } 855 856 if err != nil { 857 return fmt.Errorf("failed to find task: %w", err) 858 } 859 860 fmt.Printf("Task (ID: %d): %s\n", task.ID, task.Description) 861 if task.Recur != "" { 862 fmt.Printf("Recurrence rule: %s\n", task.Recur) 863 if task.Until != nil { 864 fmt.Printf("Recurrence until: %s\n", task.Until.Format("2006-01-02")) 865 } else { 866 fmt.Printf("Recurrence until: (no end date)\n") 867 } 868 } else { 869 fmt.Printf("No recurrence set\n") 870 } 871 872 return nil 873} 874 875// AddDep adds a dependency to a task 876func (h *TaskHandler) AddDep(ctx context.Context, taskID, dependsOnUUID string) error { 877 var task *models.Task 878 var err error 879 880 if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 881 task, err = h.repos.Tasks.Get(ctx, id) 882 } else { 883 task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 884 } 885 886 if err != nil { 887 return fmt.Errorf("failed to find task: %w", err) 888 } 889 890 if _, err := h.repos.Tasks.GetByUUID(ctx, dependsOnUUID); err != nil { 891 return fmt.Errorf("dependency task not found: %w", err) 892 } 893 894 err = h.repos.Tasks.AddDependency(ctx, task.UUID, dependsOnUUID) 895 if err != nil { 896 return fmt.Errorf("failed to add dependency: %w", err) 897 } 898 899 fmt.Printf("Dependency added to task (ID: %d): %s\n", task.ID, task.Description) 900 fmt.Printf("Now depends on: %s\n", dependsOnUUID) 901 902 return nil 903} 904 905// RemoveDep removes a dependency from a task 906func (h *TaskHandler) RemoveDep(ctx context.Context, taskID, dependsOnUUID string) error { 907 var task *models.Task 908 var err error 909 910 if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 911 task, err = h.repos.Tasks.Get(ctx, id) 912 } else { 913 task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 914 } 915 916 if err != nil { 917 return fmt.Errorf("failed to find task: %w", err) 918 } 919 920 err = h.repos.Tasks.RemoveDependency(ctx, task.UUID, dependsOnUUID) 921 if err != nil { 922 return fmt.Errorf("failed to remove dependency: %w", err) 923 } 924 925 fmt.Printf("Dependency removed from task (ID: %d): %s\n", task.ID, task.Description) 926 fmt.Printf("No longer depends on: %s\n", dependsOnUUID) 927 928 return nil 929} 930 931// ListDeps lists all dependencies for a task 932func (h *TaskHandler) ListDeps(ctx context.Context, taskID string) error { 933 var task *models.Task 934 var err error 935 936 if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 937 task, err = h.repos.Tasks.Get(ctx, id) 938 } else { 939 task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 940 } 941 942 if err != nil { 943 return fmt.Errorf("failed to find task: %w", err) 944 } 945 946 fmt.Printf("Task (ID: %d): %s\n", task.ID, task.Description) 947 948 if len(task.DependsOn) == 0 { 949 fmt.Printf("No dependencies\n") 950 return nil 951 } 952 953 fmt.Printf("Depends on %d task(s):\n", len(task.DependsOn)) 954 for _, depUUID := range task.DependsOn { 955 depTask, err := h.repos.Tasks.GetByUUID(ctx, depUUID) 956 if err != nil { 957 fmt.Printf(" - %s (not found)\n", depUUID) 958 continue 959 } 960 fmt.Printf(" - [%d] %s (UUID: %s)\n", depTask.ID, depTask.Description, depTask.UUID) 961 } 962 963 return nil 964} 965 966// BlockedByDep shows tasks that are blocked by the given task 967func (h *TaskHandler) BlockedByDep(ctx context.Context, taskID string) error { 968 var task *models.Task 969 var err error 970 971 if id, err_ := strconv.ParseInt(taskID, 10, 64); err_ == nil { 972 task, err = h.repos.Tasks.Get(ctx, id) 973 } else { 974 task, err = h.repos.Tasks.GetByUUID(ctx, taskID) 975 } 976 977 if err != nil { 978 return fmt.Errorf("failed to find task: %w", err) 979 } 980 981 fmt.Printf("Task (ID: %d): %s\n", task.ID, task.Description) 982 983 dependents, err := h.repos.Tasks.GetDependents(ctx, task.UUID) 984 if err != nil { 985 return fmt.Errorf("failed to get dependent tasks: %w", err) 986 } 987 988 if len(dependents) == 0 { 989 fmt.Printf("No tasks are blocked by this task\n") 990 return nil 991 } 992 993 fmt.Printf("Blocks %d task(s):\n", len(dependents)) 994 for _, dep := range dependents { 995 fmt.Printf(" - [%d] %s\n", dep.ID, dep.Description) 996 } 997 998 return nil 999} 1000 1001// NextActions shows actionable tasks sorted by urgency 1002func (h *TaskHandler) NextActions(ctx context.Context, limit int) error { 1003 opts := repo.TaskListOptions{ 1004 SortBy: "urgency", 1005 SortOrder: "desc", 1006 } 1007 1008 tasks, err := h.repos.Tasks.List(ctx, opts) 1009 if err != nil { 1010 return fmt.Errorf("failed to list tasks: %w", err) 1011 } 1012 1013 now := time.Now() 1014 var actionable []*models.Task 1015 for _, task := range tasks { 1016 if task.IsActionable(now) { 1017 actionable = append(actionable, task) 1018 } 1019 } 1020 1021 sort.Slice(actionable, func(i, j int) bool { 1022 return actionable[i].Urgency(now) > actionable[j].Urgency(now) 1023 }) 1024 1025 if limit > 0 && len(actionable) > limit { 1026 actionable = actionable[:limit] 1027 } 1028 1029 if len(actionable) == 0 { 1030 fmt.Println("No actionable tasks found") 1031 return nil 1032 } 1033 1034 fmt.Printf("Next Actions (%d tasks, sorted by urgency):\n\n", len(actionable)) 1035 for i, task := range actionable { 1036 urgency := task.Urgency(now) 1037 fmt.Printf("%d. [Urgency: %.1f] ", i+1, urgency) 1038 printTask(task) 1039 } 1040 1041 return nil 1042} 1043 1044// ReportCompleted shows completed tasks 1045func (h *TaskHandler) ReportCompleted(ctx context.Context, limit int) error { 1046 opts := repo.TaskListOptions{ 1047 Status: "done", 1048 SortBy: "modified", 1049 SortOrder: "desc", 1050 Limit: limit, 1051 } 1052 1053 tasks, err := h.repos.Tasks.List(ctx, opts) 1054 if err != nil { 1055 return fmt.Errorf("failed to list completed tasks: %w", err) 1056 } 1057 1058 if len(tasks) == 0 { 1059 fmt.Println("No completed tasks found") 1060 return nil 1061 } 1062 1063 fmt.Printf("Completed Tasks (%d):\n\n", len(tasks)) 1064 for _, task := range tasks { 1065 fmt.Printf(" ") 1066 printTask(task) 1067 if task.End != nil { 1068 fmt.Printf(" Completed: %s\n", task.End.Format("2006-01-02 15:04")) 1069 } 1070 } 1071 1072 return nil 1073} 1074 1075// ReportWaiting shows tasks that are waiting 1076func (h *TaskHandler) ReportWaiting(ctx context.Context) error { 1077 opts := repo.TaskListOptions{ 1078 SortBy: "wait", 1079 SortOrder: "asc", 1080 } 1081 1082 tasks, err := h.repos.Tasks.List(ctx, opts) 1083 if err != nil { 1084 return fmt.Errorf("failed to list tasks: %w", err) 1085 } 1086 1087 now := time.Now() 1088 var waiting []*models.Task 1089 for _, task := range tasks { 1090 if task.IsWaiting(now) { 1091 waiting = append(waiting, task) 1092 } 1093 } 1094 1095 if len(waiting) == 0 { 1096 fmt.Println("No waiting tasks found") 1097 return nil 1098 } 1099 1100 fmt.Printf("Waiting Tasks (%d):\n\n", len(waiting)) 1101 for _, task := range waiting { 1102 fmt.Printf(" ") 1103 printTask(task) 1104 if task.Wait != nil { 1105 daysUntil := int(task.Wait.Sub(now).Hours() / 24) 1106 fmt.Printf(" Wait until: %s (%d days)\n", task.Wait.Format("2006-01-02"), daysUntil) 1107 } 1108 } 1109 1110 return nil 1111} 1112 1113// ReportBlocked shows blocked tasks 1114func (h *TaskHandler) ReportBlocked(ctx context.Context) error { 1115 opts := repo.TaskListOptions{ 1116 Status: "blocked", 1117 } 1118 1119 tasks, err := h.repos.Tasks.List(ctx, opts) 1120 if err != nil { 1121 return fmt.Errorf("failed to list blocked tasks: %w", err) 1122 } 1123 1124 if len(tasks) == 0 { 1125 fmt.Println("No blocked tasks found") 1126 return nil 1127 } 1128 1129 fmt.Printf("Blocked Tasks (%d):\n\n", len(tasks)) 1130 for _, task := range tasks { 1131 fmt.Printf(" ") 1132 printTask(task) 1133 1134 if len(task.DependsOn) > 0 { 1135 fmt.Printf(" Depends on %d task(s)\n", len(task.DependsOn)) 1136 } 1137 } 1138 1139 return nil 1140} 1141 1142// Calendar shows tasks by due date in a calendar-like view 1143func (h *TaskHandler) Calendar(ctx context.Context, weeks int) error { 1144 if weeks <= 0 { 1145 weeks = 4 1146 } 1147 1148 now := time.Now() 1149 startDate := now.Truncate(24 * time.Hour) 1150 endDate := startDate.AddDate(0, 0, weeks*7) 1151 1152 opts := repo.TaskListOptions{ 1153 SortBy: "due", 1154 SortOrder: "asc", 1155 } 1156 1157 tasks, err := h.repos.Tasks.List(ctx, opts) 1158 if err != nil { 1159 return fmt.Errorf("failed to list tasks: %w", err) 1160 } 1161 1162 tasksByDate := make(map[string][]*models.Task) 1163 overdue := []*models.Task{} 1164 1165 for _, task := range tasks { 1166 if task.Due == nil || task.IsCompleted() || task.IsDone() { 1167 continue 1168 } 1169 1170 dueDate := task.Due.Truncate(24 * time.Hour) 1171 if dueDate.Before(startDate) { 1172 overdue = append(overdue, task) 1173 } else if dueDate.Before(endDate) { 1174 dateKey := dueDate.Format("2006-01-02") 1175 tasksByDate[dateKey] = append(tasksByDate[dateKey], task) 1176 } 1177 } 1178 1179 fmt.Printf("Calendar View (Next %d weeks)\n", weeks) 1180 fmt.Printf("Today: %s\n\n", now.Format("Monday, January 2, 2006")) 1181 1182 if len(overdue) > 0 { 1183 fmt.Printf("OVERDUE (%d tasks):\n", len(overdue)) 1184 for _, task := range overdue { 1185 daysOverdue := int(now.Sub(*task.Due).Hours() / 24) 1186 fmt.Printf(" [%d days overdue] ", daysOverdue) 1187 printTask(task) 1188 } 1189 fmt.Println() 1190 } 1191 1192 currentDate := startDate 1193 for currentDate.Before(endDate) { 1194 weekStart := currentDate 1195 weekEnd := currentDate.AddDate(0, 0, 6) 1196 1197 weekTasks := 0 1198 for d := weekStart; !d.After(weekEnd); d = d.AddDate(0, 0, 1) { 1199 dateKey := d.Format("2006-01-02") 1200 weekTasks += len(tasksByDate[dateKey]) 1201 } 1202 1203 if weekTasks > 0 { 1204 fmt.Printf("Week of %s (%d tasks):\n", weekStart.Format("Jan 2"), weekTasks) 1205 1206 for d := weekStart; !d.After(weekEnd); d = d.AddDate(0, 0, 1) { 1207 dateKey := d.Format("2006-01-02") 1208 dayTasks := tasksByDate[dateKey] 1209 1210 if len(dayTasks) > 0 { 1211 dayName := d.Format("Monday, Jan 2") 1212 if d.Format("2006-01-02") == now.Format("2006-01-02") { 1213 dayName += " (TODAY)" 1214 } 1215 fmt.Printf(" %s:\n", dayName) 1216 for _, task := range dayTasks { 1217 fmt.Printf(" ") 1218 printTask(task) 1219 } 1220 } 1221 } 1222 fmt.Println() 1223 } 1224 1225 currentDate = currentDate.AddDate(0, 0, 7) 1226 } 1227 1228 if len(overdue) == 0 && len(tasksByDate) == 0 { 1229 fmt.Println("No tasks with due dates in the next", weeks, "weeks") 1230 } 1231 1232 return nil 1233} 1234 1235// Annotate adds an annotation to a task 1236func (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 1268func (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 1298func (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 1335func (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 1425func (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 1449func (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}