b02c40f716
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>
508 lines
13 KiB
Go
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"
|
|
}
|
|
}
|