cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
29
fork

Configure Feed

Select the types of activity you want to include in your feed.

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}