Files
gems/opal-task/cmd/add.go
T
joakim f57baee6bc fix: IMP-4/5/6 — parser allowlist, delete ID resolution, consistent errors
IMP-5: Replace strings.Contains(arg, ":") heuristic with an allowlist
of recognized attribute keys (ValidAttributeKeys). Colons in task
descriptions (URLs, "Meeting: topic") are no longer misinterpreted as
modifiers. Canonical key sets live in engine/keys.go and are shared
across parseAddArgs, ParseFilter, and ParseModifier. ParseModifier now
errors on unknown keys.

IMP-4: delete command now loads the working set and resolves display IDs
via GetTaskByDisplayID, matching the pattern used by done/modify.

IMP-6: All action commands (done, delete, modify, start, stop) now
return an error on no-match (stderr, exit 1). Previously done/delete
printed to stdout and exited 0; start/stop had no check at all.

Also adds requirements and design docs for the CLI UX improvements.

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

118 lines
3.0 KiB
Go

package cmd
import (
"fmt"
"os"
"strings"
"git.jnss.me/joakim/opal/internal/engine"
"github.com/spf13/cobra"
)
var addCmd = &cobra.Command{
Use: "add <description> [modifiers...]",
Short: "Add a new task",
Long: `Add a new task with optional modifiers.
Examples:
opal add buy groceries # No quotes needed!
opal add review PR priority:H project:backend
opal add buy groceries +shop carrots # Tag can be anywhere
opal add team meeting due:mon recur:1w +meetings`,
Run: func(cmd *cobra.Command, args []string) {
parsed := getParsedArgs(cmd)
// For add, combine filters and modifiers (all are args to parse)
allArgs := append(parsed.Filters, parsed.Modifiers...)
if err := addTask(allArgs); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func addTask(args []string) error {
// Parse description and modifiers from args
// Description = all words that are NOT filters/modifiers
// Filters/Modifiers = words with +, -, or containing :
description, modifierArgs, err := parseAddArgs(args)
if err != nil {
return err
}
// Parse modifiers
var mod *engine.Modifier
if len(modifierArgs) > 0 {
mod, err = engine.ParseModifier(modifierArgs)
if err != nil {
return fmt.Errorf("failed to parse modifiers: %w", err)
}
}
// Check if this is a recurring task
isRecurring := mod != nil && mod.SetAttributes["recur"] != nil
if isRecurring {
// Create recurring task (template + first instance)
return addRecurringTask(description, mod)
}
// Create regular task
task, err := engine.CreateTaskWithModifier(description, mod)
if err != nil {
return fmt.Errorf("failed to create task: %w", err)
}
fmt.Printf("Created task %s\n", task.UUID)
if len(task.Tags) > 0 {
fmt.Printf("Tags: %s\n", strings.Join(task.Tags, ", "))
}
return nil
}
// parseAddArgs extracts description and modifiers from args.
// Tags (+tag, -tag) are always modifiers. For key:value tokens, only
// recognized attribute keys (engine.ValidAttributeKeys) are treated as
// modifiers — everything else becomes part of the description.
func parseAddArgs(args []string) (string, []string, error) {
var descParts []string
var modifiers []string
for _, arg := range args {
if strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-") {
modifiers = append(modifiers, arg)
continue
}
if idx := strings.Index(arg, ":"); idx > 0 {
key := arg[:idx]
if engine.ValidAttributeKeys[key] {
modifiers = append(modifiers, arg)
continue
}
}
descParts = append(descParts, arg)
}
if len(descParts) == 0 {
return "", nil, fmt.Errorf("description is required")
}
description := strings.Join(descParts, " ")
return description, modifiers, nil
}
func addRecurringTask(description string, mod *engine.Modifier) error {
instance, err := engine.CreateRecurringTask(description, mod)
if err != nil {
return err
}
fmt.Printf("Created recurring task %s\n", *instance.ParentUUID)
fmt.Printf("First instance: %s\n", instance.UUID)
return nil
}