package cmd import ( "fmt" "os" "os/exec" "sort" "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) { content, err := os.ReadFile(filepath) if err != nil { return nil, fmt.Errorf("failed to open edited file: %w", err) } return engine.ParseKeyValueFormat(string(content), true) // skip comments } // 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) _, err := task.Complete() return err } // 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) } } sort.Strings(tags) // Sort alphabetically for consistency 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" } }