7aaaa86a0a
Add annotations as JSON column on tasks table with Annotate/Denotate methods and CLI commands. Add undo system backed by change_log with lightweight undo_stack table (capped at 10 entries). All mutating CLI commands (add, done, delete, modify, start, stop) now record undo entries. Undo restores prior task state from change_log data. Schema changes (in v1 migration): - annotations TEXT column on tasks - undo_stack table - annotations field in change_log triggers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
545 lines
15 KiB
Go
545 lines
15 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")
|
|
}
|
|
|
|
// Annotations
|
|
sb.WriteString("\n# Annotations (add/remove/modify lines below)\n")
|
|
if len(task.Annotations) > 0 {
|
|
for i, ann := range task.Annotations {
|
|
ts := time.Unix(ann.Timestamp, 0).Format("2006-01-02 15:04")
|
|
sb.WriteString(fmt.Sprintf("annotation.%d: %s | %s\n", i+1, ts, ann.Text))
|
|
}
|
|
}
|
|
sb.WriteString("# To add: annotation.N: YYYY-MM-DD HH:MM | text\n")
|
|
sb.WriteString("# To remove: delete the line\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
|
|
}
|
|
}
|
|
}
|
|
|
|
// Annotations - rebuild from annotation.N fields
|
|
var newAnnotations []engine.Annotation
|
|
for key, value := range fields {
|
|
if strings.HasPrefix(key, "annotation.") && value != "" {
|
|
parts := strings.SplitN(value, " | ", 2)
|
|
if len(parts) == 2 {
|
|
ts, err := time.Parse("2006-01-02 15:04", strings.TrimSpace(parts[0]))
|
|
if err != nil {
|
|
// If we can't parse the timestamp, use current time
|
|
ts = time.Now()
|
|
}
|
|
newAnnotations = append(newAnnotations, engine.Annotation{
|
|
Timestamp: ts.Unix(),
|
|
Text: strings.TrimSpace(parts[1]),
|
|
})
|
|
} else {
|
|
// No timestamp separator, treat entire value as text
|
|
newAnnotations = append(newAnnotations, engine.Annotation{
|
|
Timestamp: time.Now().Unix(),
|
|
Text: strings.TrimSpace(value),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
task.Annotations = newAnnotations
|
|
|
|
// 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"
|
|
}
|
|
}
|