cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
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}