Files
gems/opal-task/cmd/edit.go
T
joakim b02c40f716 feat: improve CLI output with relative dates, rich feedback, and recurring task info
Add relative date formatting (today, tomorrow, in 3d, etc.) for list and
detail views. Add structured feedback helpers for add/complete/delete
operations showing display IDs and parsed modifiers. Change Complete() to
return spawned recurring instance so callers can display recurrence info.
Add AppendTask to working set for immediate display ID assignment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:44:56 +01:00

508 lines
13 KiB
Go

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"
}
}