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
This commit is contained in:
2026-01-05 11:05:07 +01:00
parent d0b46beeec
commit 79eb3bb62a
4 changed files with 621 additions and 2 deletions
+536
View File
@@ -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"
}
}
+80
View File
@@ -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
}
+3
View File
@@ -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() {
+2 -2
View File
@@ -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