From 79eb3bb62a8643bd86b211f45a67b59c54d66027 Mon Sep 17 00:00:00 2001 From: Joakim Date: Mon, 5 Jan 2026 11:05:07 +0100 Subject: [PATCH] Add info and edit commands for interactive task management - Add 'opal info' command to display detailed task information - Shows all task attributes including UUID, timestamps, and metadata - Supports flexible syntax (opal info 2 or opal 2 info) - Displays recurrence information and parent UUID for recurring tasks - Add 'opal edit' command to edit tasks in $EDITOR - Opens task in text editor with human-readable format - Supports all editable fields with smart date formatting - Special handling for recurring tasks (updates parent template) - Status changes trigger appropriate methods (Complete/Delete) - Auto-saves changes on editor exit without confirmation - Clear validation and error messages - Register new commands in root command dispatcher --- opal-task/cmd/edit.go | 536 ++++++++++++++++++++++++++++++++++++++++ opal-task/cmd/info.go | 80 ++++++ opal-task/cmd/root.go | 3 + opal-task/doc/chores.md | 4 +- 4 files changed, 621 insertions(+), 2 deletions(-) create mode 100644 opal-task/cmd/edit.go create mode 100644 opal-task/cmd/info.go diff --git a/opal-task/cmd/edit.go b/opal-task/cmd/edit.go new file mode 100644 index 0000000..7c573b7 --- /dev/null +++ b/opal-task/cmd/edit.go @@ -0,0 +1,536 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "git.jnss.me/joakim/opal/internal/engine" + "github.com/spf13/cobra" +) + +var editCmd = &cobra.Command{ + Use: "edit [filter...]", + Short: "Edit a task in $EDITOR", + Long: `Opens a task in your $EDITOR for interactive modification. + +Examples: + opal edit 2 # Edit task with display ID 2 + opal 2 edit # Flexible syntax (same as above) + opal edit +urgent # Edit task if only one matches`, + Run: func(cmd *cobra.Command, args []string) { + parsed := getParsedArgs(cmd) + if err := editTask(parsed.Filters); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func editTask(args []string) error { + // Validate we have a filter + if len(args) == 0 { + return fmt.Errorf("no task specified for edit command") + } + + // Parse filter + filter, err := engine.ParseFilter(args) + if err != nil { + return fmt.Errorf("failed to parse filter: %w", err) + } + + // Load working set to resolve IDs + ws, err := engine.LoadWorkingSet() + if err != nil { + return fmt.Errorf("failed to load working set: %w", err) + } + + // Resolve task + var task *engine.Task + + if len(filter.IDs) > 0 { + // Resolve display ID (should be exactly one) + if len(filter.IDs) != 1 { + return fmt.Errorf("edit requires exactly one task (specified %d IDs)", len(filter.IDs)) + } + task, err = ws.GetTaskByDisplayID(filter.IDs[0]) + if err != nil { + return err + } + } else { + // Use filter to get tasks + tasks, err := engine.GetTasks(filter) + if err != nil { + return fmt.Errorf("failed to get tasks: %w", err) + } + + if len(tasks) == 0 { + return fmt.Errorf("no task found matching filter") + } + if len(tasks) > 1 { + return fmt.Errorf("edit requires exactly one task (filter matched %d tasks)", len(tasks)) + } + + task = tasks[0] + } + + // Create temporary file + tmpFile, err := os.CreateTemp("", "opal-task-*.txt") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) // Clean up + + // Write task data to temp file + content := generateEditableContent(task) + if _, err := tmpFile.WriteString(content); err != nil { + tmpFile.Close() + return fmt.Errorf("failed to write temp file: %w", err) + } + tmpFile.Close() + + // Launch editor + if err := launchEditor(tmpPath); err != nil { + return err + } + + // Parse edited content + fields, err := parseEditedFile(tmpPath) + if err != nil { + return err + } + + // Apply changes to task + if err := applyEditedFields(task, fields); err != nil { + return err + } + + fmt.Printf("Task %s updated.\n", task.UUID) + return nil +} + +// generateEditableContent creates the editable text format +func generateEditableContent(task *engine.Task) string { + var sb strings.Builder + + // Header with read-only info + sb.WriteString(fmt.Sprintf("# Task UUID: %s (read-only)\n", task.UUID)) + sb.WriteString(fmt.Sprintf("# Created: %s (read-only)\n", formatTimeForEdit(task.Created))) + sb.WriteString(fmt.Sprintf("# Modified: %s (read-only)\n", formatTimeForEdit(task.Modified))) + + if task.ParentUUID != nil { + sb.WriteString(fmt.Sprintf("# Parent UUID: %s (this is a recurring task instance)\n", *task.ParentUUID)) + sb.WriteString("# Note: Changing 'recurrence' will update the template for future instances\n") + } + + if task.End != nil { + sb.WriteString(fmt.Sprintf("# End: %s (read-only, set on completion)\n", formatTimeForEdit(*task.End))) + } + + sb.WriteString("#\n") + sb.WriteString("# Edit the fields below. Lines starting with # are ignored.\n") + sb.WriteString("# Leave a value empty to clear it.\n") + sb.WriteString("# Status: pending, completed, deleted (recurring/template is system-managed)\n") + sb.WriteString("# Priority: H (high), M (medium), L (low), D (default)\n") + sb.WriteString("\n") + + // Editable fields + sb.WriteString(fmt.Sprintf("description: %s\n", task.Description)) + sb.WriteString(fmt.Sprintf("status: %s\n", formatStatusForEdit(task.Status))) + sb.WriteString(fmt.Sprintf("priority: %s\n", formatPriorityForEdit(task.Priority))) + + if task.Project != nil { + sb.WriteString(fmt.Sprintf("project: %s\n", *task.Project)) + } else { + sb.WriteString("project: \n") + } + + if task.Due != nil { + sb.WriteString(fmt.Sprintf("due: %s\n", formatTimeForEdit(*task.Due))) + } else { + sb.WriteString("due: \n") + } + + if task.Scheduled != nil { + sb.WriteString(fmt.Sprintf("scheduled: %s\n", formatTimeForEdit(*task.Scheduled))) + } else { + sb.WriteString("scheduled: \n") + } + + if task.Wait != nil { + sb.WriteString(fmt.Sprintf("wait: %s\n", formatTimeForEdit(*task.Wait))) + } else { + sb.WriteString("wait: \n") + } + + if task.Until != nil { + sb.WriteString(fmt.Sprintf("until: %s\n", formatTimeForEdit(*task.Until))) + } else { + sb.WriteString("until: \n") + } + + if task.Start != nil { + sb.WriteString(fmt.Sprintf("start: %s\n", formatTimeForEdit(*task.Start))) + } else { + sb.WriteString("start: \n") + } + + // Recurrence - show template's recurrence or instance's parent recurrence + var recurrenceValue string + if task.RecurrenceDuration != nil { + recurrenceValue = engine.FormatRecurrenceDuration(*task.RecurrenceDuration) + } else if task.ParentUUID != nil { + // Load parent to show its recurrence + if parent, err := engine.GetTask(*task.ParentUUID); err == nil && parent.RecurrenceDuration != nil { + recurrenceValue = engine.FormatRecurrenceDuration(*parent.RecurrenceDuration) + } + } + sb.WriteString(fmt.Sprintf("recurrence: %s\n", recurrenceValue)) + + // Tags + if len(task.Tags) > 0 { + sb.WriteString(fmt.Sprintf("tags: %s\n", strings.Join(task.Tags, ","))) + } else { + sb.WriteString("tags: \n") + } + + return sb.String() +} + +// parseEditedFile reads and parses the modified file +func parseEditedFile(filepath string) (map[string]string, error) { + file, err := os.Open(filepath) + if err != nil { + return nil, fmt.Errorf("failed to open edited file: %w", err) + } + defer file.Close() + + fields := make(map[string]string) + scanner := bufio.NewScanner(file) + lineNum := 0 + + for scanner.Scan() { + lineNum++ + line := scanner.Text() + + // Skip comments and empty lines + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + + // Parse "key: value" + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("line %d: invalid format (expected 'field: value')", lineNum) + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + fields[key] = value + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading file: %w", err) + } + + return fields, nil +} + +// applyEditedFields applies parsed changes to task +func applyEditedFields(task *engine.Task, fields map[string]string) error { + // Validate required fields + description, hasDesc := fields["description"] + if !hasDesc || strings.TrimSpace(description) == "" { + return fmt.Errorf("description cannot be empty") + } + + // Parse status + var newStatus engine.Status + if statusStr, ok := fields["status"]; ok && statusStr != "" { + parsed, err := parseStatus(statusStr) + if err != nil { + return err + } + newStatus = parsed + } else { + newStatus = task.Status + } + + // Handle status changes specially + oldStatus := task.Status + + // If changing to completed, use Complete() method + if newStatus == engine.StatusCompleted && oldStatus != engine.StatusCompleted { + // Apply other fields first + if err := applyNonStatusFields(task, fields); err != nil { + return err + } + // Then complete (which saves automatically) + return task.Complete() + } + + // If changing to deleted, use Delete() method + if newStatus == engine.StatusDeleted && oldStatus != engine.StatusDeleted { + // Apply other fields first + if err := applyNonStatusFields(task, fields); err != nil { + return err + } + // Then delete (which saves automatically) + return task.Delete(false) + } + + // If changing from completed/deleted to pending, clear end time + if newStatus == engine.StatusPending && (oldStatus == engine.StatusCompleted || oldStatus == engine.StatusDeleted) { + task.Status = engine.StatusPending + task.End = nil + } else { + task.Status = newStatus + } + + // Apply all other fields + if err := applyNonStatusFields(task, fields); err != nil { + return err + } + + // Save the task + return task.Save() +} + +// applyNonStatusFields applies all fields except status +func applyNonStatusFields(task *engine.Task, fields map[string]string) error { + // Description + if desc, ok := fields["description"]; ok { + task.Description = desc + } + + // Priority + if priStr, ok := fields["priority"]; ok && priStr != "" { + pri, err := parsePriority(priStr) + if err != nil { + return err + } + task.Priority = pri + } + + // Project + if proj, ok := fields["project"]; ok { + if proj == "" { + task.Project = nil + } else { + task.Project = &proj + } + } + + // Date fields + dateFields := map[string]**time.Time{ + "due": &task.Due, + "scheduled": &task.Scheduled, + "wait": &task.Wait, + "until": &task.Until, + "start": &task.Start, + } + + for fieldName, taskField := range dateFields { + if dateStr, ok := fields[fieldName]; ok { + if dateStr == "" { + *taskField = nil + } else { + parsed, err := engine.ParseDate(dateStr) + if err != nil { + return fmt.Errorf("invalid date for '%s': %w", fieldName, err) + } + *taskField = &parsed + } + } + } + + // Recurrence + if recurStr, ok := fields["recurrence"]; ok { + if recurStr == "" { + // Clear recurrence + if task.ParentUUID != nil { + // This is an instance - clear parent's recurrence + parent, err := engine.GetTask(*task.ParentUUID) + if err != nil { + return fmt.Errorf("failed to load parent task: %w", err) + } + parent.RecurrenceDuration = nil + if err := parent.Save(); err != nil { + return fmt.Errorf("failed to update parent recurrence: %w", err) + } + fmt.Println("Cleared recurrence pattern (no more instances will be spawned)") + } else { + task.RecurrenceDuration = nil + } + } else { + // Parse recurrence + duration, err := engine.ParseRecurrencePattern(recurStr) + if err != nil { + return fmt.Errorf("invalid recurrence pattern: %w", err) + } + + if task.ParentUUID != nil { + // This is an instance - update parent's recurrence + parent, err := engine.GetTask(*task.ParentUUID) + if err != nil { + return fmt.Errorf("failed to load parent task: %w", err) + } + parent.RecurrenceDuration = &duration + if err := parent.Save(); err != nil { + return fmt.Errorf("failed to update parent recurrence: %w", err) + } + fmt.Println("Updated recurrence pattern for future instances") + } else { + // This is a regular task or template + task.RecurrenceDuration = &duration + } + } + } + + // Tags - replace all tags + if tagsStr, ok := fields["tags"]; ok { + // Remove all existing tags + for _, tag := range task.Tags { + if err := task.RemoveTag(tag); err != nil { + return fmt.Errorf("failed to remove tag '%s': %w", tag, err) + } + } + + // Add new tags + if tagsStr != "" { + newTags := parseTags(tagsStr) + for _, tag := range newTags { + if err := task.AddTag(tag); err != nil { + return fmt.Errorf("failed to add tag '%s': %w", tag, err) + } + } + } + } + + return nil +} + +// getEditor returns the editor command ($EDITOR or 'vi') +func getEditor() string { + if editor := os.Getenv("EDITOR"); editor != "" { + return editor + } + return "vi" +} + +// launchEditor opens file in editor and waits +func launchEditor(filepath string) error { + editor := getEditor() + + cmd := exec.Command(editor, filepath) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return fmt.Errorf("editor exited with error (code %d)", exitErr.ExitCode()) + } + return fmt.Errorf("failed to run editor: %w", err) + } + + return nil +} + +// parsePriority converts H/M/L/D to Priority constant +func parsePriority(s string) (engine.Priority, error) { + switch strings.ToUpper(strings.TrimSpace(s)) { + case "H": + return engine.PriorityHigh, nil + case "M": + return engine.PriorityMedium, nil + case "L": + return engine.PriorityLow, nil + case "D", "": + return engine.PriorityDefault, nil + default: + return engine.PriorityDefault, fmt.Errorf("invalid priority '%s' (must be H, M, L, or D)", s) + } +} + +// parseStatus converts string to Status constant +func parseStatus(s string) (engine.Status, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "pending": + return engine.StatusPending, nil + case "completed": + return engine.StatusCompleted, nil + case "deleted": + return engine.StatusDeleted, nil + case "recurring", "template": + // Accept these but keep them unchanged (system-managed status) + return engine.StatusRecurring, nil + default: + return engine.StatusPending, fmt.Errorf("invalid status '%s' (must be: pending, completed, deleted, or recurring)", s) + } +} + +// parseTags splits comma-separated tags +func parseTags(s string) []string { + if s == "" { + return []string{} + } + + parts := strings.Split(s, ",") + tags := make([]string, 0, len(parts)) + for _, part := range parts { + tag := strings.TrimSpace(part) + if tag != "" { + tags = append(tags, tag) + } + } + return tags +} + +// formatTimeForEdit formats a time for the edit file +func formatTimeForEdit(t time.Time) string { + // Check if time component is at midnight (00:00) + if t.Hour() == 0 && t.Minute() == 0 { + // Date only, no time component + return t.Format("2006-01-02") + } + // Date with time - use colon format that parser understands + return t.Format("2006-01-02:15:04") +} + +// formatStatusForEdit formats status for the edit file +func formatStatusForEdit(s engine.Status) string { + switch s { + case engine.StatusPending: + return "pending" + case engine.StatusCompleted: + return "completed" + case engine.StatusDeleted: + return "deleted" + case engine.StatusRecurring: + return "recurring" + default: + return "pending" + } +} + +// formatPriorityForEdit formats priority for the edit file +func formatPriorityForEdit(p engine.Priority) string { + switch p { + case engine.PriorityHigh: + return "H" + case engine.PriorityMedium: + return "M" + case engine.PriorityLow: + return "L" + case engine.PriorityDefault: + return "D" + default: + return "D" + } +} diff --git a/opal-task/cmd/info.go b/opal-task/cmd/info.go new file mode 100644 index 0000000..18d0409 --- /dev/null +++ b/opal-task/cmd/info.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "fmt" + "os" + + "git.jnss.me/joakim/opal/internal/engine" + "github.com/spf13/cobra" +) + +var infoCmd = &cobra.Command{ + Use: "info [filter...]", + Short: "Show detailed information about a task", + Long: `Display all details about a specific task. + +Examples: + opal info 2 # Show details for task with display ID 2 + opal 2 info # Flexible syntax (same as above) + opal info +urgent # Show details if only one task matches`, + Run: func(cmd *cobra.Command, args []string) { + parsed := getParsedArgs(cmd) + if err := showTaskInfo(parsed.Filters); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func showTaskInfo(args []string) error { + // Validate we have a filter + if len(args) == 0 { + return fmt.Errorf("no task specified for info command") + } + + // Parse filter + filter, err := engine.ParseFilter(args) + if err != nil { + return fmt.Errorf("failed to parse filter: %w", err) + } + + // Load working set to resolve IDs + ws, err := engine.LoadWorkingSet() + if err != nil { + return fmt.Errorf("failed to load working set: %w", err) + } + + // Resolve task + var task *engine.Task + + if len(filter.IDs) > 0 { + // Resolve display ID (should be exactly one) + if len(filter.IDs) != 1 { + return fmt.Errorf("info requires exactly one task (specified %d IDs)", len(filter.IDs)) + } + task, err = ws.GetTaskByDisplayID(filter.IDs[0]) + if err != nil { + return err + } + } else { + // Use filter to get tasks + tasks, err := engine.GetTasks(filter) + if err != nil { + return fmt.Errorf("failed to get tasks: %w", err) + } + + if len(tasks) == 0 { + return fmt.Errorf("no task found matching filter") + } + if len(tasks) > 1 { + return fmt.Errorf("info requires exactly one task (filter matched %d tasks)", len(tasks)) + } + + task = tasks[0] + } + + // Display detailed info + fmt.Println(engine.FormatTaskDetail(task)) + + return nil +} diff --git a/opal-task/cmd/root.go b/opal-task/cmd/root.go index 934439c..532d36d 100644 --- a/opal-task/cmd/root.go +++ b/opal-task/cmd/root.go @@ -25,6 +25,7 @@ const parsedArgsKey contextKey = "parsedArgs" var commandNames = []string{ "add", "list", "done", "modify", "delete", "start", "stop", "count", "projects", "tags", + "info", "edit", } var commandsWithModifiers = map[string]bool{ @@ -169,6 +170,8 @@ func init() { rootCmd.AddCommand(countCmd) rootCmd.AddCommand(projectsCmd) rootCmd.AddCommand(tagsCmd) + rootCmd.AddCommand(infoCmd) + rootCmd.AddCommand(editCmd) } func initializeApp() { diff --git a/opal-task/doc/chores.md b/opal-task/doc/chores.md index a6088bb..2deb8c2 100644 --- a/opal-task/doc/chores.md +++ b/opal-task/doc/chores.md @@ -2,11 +2,11 @@ Bytte håndhånklær due:today recur:3d +bad wait:due-1d Bytte dusjhåndklær due:sun recur:weekly +bad wait:due-1d Bytte kjøkkenklut og glasshånklær due:mon recur:3d +kjøkken wait:due-1d Rense filter i varmepumpe due:eom recur:monthly wait:due-2d -Skifte på sengen due:sun recur:weekly wait:due-1d +Skifte på sengen due:sun recur:weekly wait:due-1d +soverom Av-ise og vaske kjøleskap og fryser due:eom recur:3m +kjøkken wait:due-1w Av-ise og vaske fryser due:eom recur:3m +kjeller wait:due-1w Vaske dusjen due:15jan +bad recur:2w wait:due-1d Vaske toalett, servant og vinduskarm due:fri recur:5d +bad wait:due-2d -Vaske kjøkkenbenk due:eod recur:1d +Vaske kjøkkenbenk due:eod recur:1d +kjøkken