Files
gems/opal-task/cmd/root.go
T
joakim 07d1a78dfc feat: add uncomplete command to restore completed tasks to pending
Dedicated command that sets status back to pending and clears End time.
Unlike undo, works on any completed task regardless of when it was
completed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:22:51 +01:00

329 lines
8.4 KiB
Go

package cmd
import (
"context"
"fmt"
"os"
"strings"
"git.jnss.me/joakim/opal/internal/engine"
"github.com/spf13/cobra"
)
// ParsedArgs represents preprocessed command arguments
type ParsedArgs struct {
Command string
Filters []string
Modifiers []string
}
// Context key for parsed args
type contextKey string
const parsedArgsKey contextKey = "parsedArgs"
// Global flags
var (
configDirFlag string
dataDirFlag string
dryRunFlag bool
)
// Command classification
var commandNames = []string{
"add", "done", "modify", "delete",
"start", "stop", "count", "projects", "tags",
"info", "edit", "server", "sync", "reports", "setup",
"version", "annotate", "denotate", "undo", "uncomplete", "log", "completion",
}
// Report names (dynamically populated)
var reportNames = []string{
"active", "all", "completed", "list", "minimal",
"newest", "next", "oldest", "overdue", "ready", "recurring",
"template", "waiting",
}
var commandsWithModifiers = map[string]bool{
"add": true,
"modify": true,
"annotate": true,
}
var rootCmd = &cobra.Command{
Use: "opal [filter] [command|report] [modifiers]",
Short: "Opal task manager - taskwarrior-inspired CLI task management",
Long: `Opal is a powerful command-line task manager.
It supports filtering, tags, priorities, projects, and recurring tasks.`,
Run: func(cmd *cobra.Command, args []string) {
// Default behavior: run configured default report (defaults to "list")
parsed := getParsedArgs(cmd)
// Get default report from config
cfg, err := engine.GetConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
defaultReport := cfg.DefaultReport
if defaultReport == "" {
defaultReport = "list"
}
if err := runReport(defaultReport, parsed.Filters); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func Execute() error {
// Check for help flags/command BEFORE preprocessing
// This allows Cobra to handle help naturally for the root command
if len(os.Args) > 1 {
firstArg := os.Args[1]
if firstArg == "-h" || firstArg == "--help" || firstArg == "help" {
// Let Cobra handle help - skip preprocessing
return rootCmd.Execute()
}
}
// Preprocess arguments BEFORE Cobra routing
if len(os.Args) > 1 {
parsed := preprocessArgs(os.Args[1:])
// Store in context for commands to use
ctx := context.WithValue(context.Background(), parsedArgsKey, parsed)
rootCmd.SetContext(ctx)
// Rewrite os.Args for Cobra based on parsed command
// This allows Cobra to route to the correct command
if parsed.Command != "list" || len(parsed.Filters) > 0 || len(parsed.Modifiers) > 0 {
// Reconstruct args: [command, ...filters, ...modifiers]
newArgs := []string{os.Args[0], parsed.Command}
newArgs = append(newArgs, parsed.Filters...)
newArgs = append(newArgs, parsed.Modifiers...)
os.Args = newArgs
}
}
return rootCmd.Execute()
}
// getParsedArgs retrieves preprocessed args from context
func getParsedArgs(cmd *cobra.Command) *ParsedArgs {
if v := cmd.Context().Value(parsedArgsKey); v != nil {
if parsed, ok := v.(*ParsedArgs); ok {
return parsed
}
}
return &ParsedArgs{}
}
// preprocessArgs parses command-line arguments before Cobra routing
// Returns: command name, filters, modifiers
// Flags (--foo) are stripped from filters/modifiers; Cobra handles them from os.Args.
func preprocessArgs(args []string) *ParsedArgs {
if len(args) == 0 {
return &ParsedArgs{
Command: "list", // Default command
Filters: []string{},
Modifiers: []string{},
}
}
// Find command position, skipping flag-like args
cmdIdx := -1
cmdName := ""
for i, arg := range args {
if strings.HasPrefix(arg, "-") {
continue // Skip flags — Cobra handles them
}
// Check regular commands
for _, name := range commandNames {
if arg == name {
cmdIdx = i
cmdName = name
break
}
}
// Check report names
if cmdIdx < 0 {
for _, name := range reportNames {
if arg == name {
cmdIdx = i
cmdName = name
break
}
}
}
if cmdIdx >= 0 {
break
}
}
// If no command found, treat as filters for default list command
if cmdIdx == -1 {
return &ParsedArgs{
Command: "list",
Filters: stripFlags(args),
Modifiers: []string{},
}
}
// Split arguments around command
leftArgs := stripFlags(args[:cmdIdx])
rightArgs := []string{}
if cmdIdx+1 < len(args) {
rightArgs = stripFlags(args[cmdIdx+1:])
}
// Determine how to interpret right args
if commandsWithModifiers[cmdName] {
return &ParsedArgs{
Command: cmdName,
Filters: leftArgs,
Modifiers: rightArgs,
}
} else {
allFilters := append(leftArgs, rightArgs...)
return &ParsedArgs{
Command: cmdName,
Filters: allFilters,
Modifiers: []string{},
}
}
}
// stripFlags removes flag-like args (starting with -) from a slice
func stripFlags(args []string) []string {
var result []string
for _, arg := range args {
if !strings.HasPrefix(arg, "-") {
result = append(result, arg)
}
}
return result
}
func init() {
// Add persistent flags for directory overrides
rootCmd.PersistentFlags().StringVar(&configDirFlag, "config-dir", "",
"Config directory (default: $XDG_CONFIG_HOME/opal or ~/.config/opal)")
rootCmd.PersistentFlags().StringVar(&dataDirFlag, "data-dir", "",
"Data directory (default: $XDG_DATA_HOME/opal or ~/.local/share/opal)")
rootCmd.PersistentFlags().BoolVar(&dryRunFlag, "dry-run", false,
"Show matched tasks without performing the action")
// Use PersistentPreRun for initialization (runs for all subcommands unless overridden)
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
initializeApp()
}
// Command groups for organized help output
rootCmd.AddGroup(
&cobra.Group{ID: "task", Title: "Task Commands:"},
&cobra.Group{ID: "report", Title: "Reports:"},
&cobra.Group{ID: "other", Title: "Other:"},
)
// Task commands
addCmd.GroupID = "task"
doneCmd.GroupID = "task"
modifyCmd.GroupID = "task"
deleteCmd.GroupID = "task"
startCmd.GroupID = "task"
stopCmd.GroupID = "task"
editCmd.GroupID = "task"
infoCmd.GroupID = "task"
annotateCmd.GroupID = "task"
denotateCmd.GroupID = "task"
undoCmd.GroupID = "task"
uncompleteCmd.GroupID = "task"
logCmd.GroupID = "task"
rootCmd.AddCommand(addCmd)
rootCmd.AddCommand(doneCmd)
rootCmd.AddCommand(modifyCmd)
rootCmd.AddCommand(deleteCmd)
rootCmd.AddCommand(startCmd)
rootCmd.AddCommand(stopCmd)
rootCmd.AddCommand(infoCmd)
rootCmd.AddCommand(editCmd)
rootCmd.AddCommand(annotateCmd)
rootCmd.AddCommand(denotateCmd)
rootCmd.AddCommand(undoCmd)
rootCmd.AddCommand(uncompleteCmd)
rootCmd.AddCommand(logCmd)
// Other commands
countCmd.GroupID = "other"
projectsCmd.GroupID = "other"
tagsCmd.GroupID = "other"
reportsCmd.GroupID = "other"
versionCmd.GroupID = "other"
completionCmd.GroupID = "other"
rootCmd.AddCommand(countCmd)
rootCmd.AddCommand(projectsCmd)
rootCmd.AddCommand(tagsCmd)
rootCmd.AddCommand(reportsCmd)
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(completionCmd)
// Enable --version flag on root command
rootCmd.Version = Version
// Add report commands dynamically
reportCommands := CreateReportCommands()
for _, cmd := range reportCommands {
cmd.GroupID = "report"
rootCmd.AddCommand(cmd)
}
}
func initializeApp() {
// Set directory overrides from flags if provided
if configDirFlag != "" {
engine.SetConfigDirOverride(configDirFlag)
}
if dataDirFlag != "" {
engine.SetDataDirOverride(dataDirFlag)
}
// Check for first run (before initializing DB/config)
isFirstRun := engine.IsFirstRun()
// Initialize database
if err := engine.InitDB(); err != nil {
fmt.Fprintf(os.Stderr, "Error initializing database: %v\n", err)
os.Exit(1)
}
// On first run, create the config file with defaults
if isFirstRun {
if err := engine.InitConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Error creating config: %v\n", err)
os.Exit(1)
}
showFirstRunMessage()
}
// Load config (reads file if present, otherwise uses defaults)
if _, err := engine.LoadConfig(); err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
}
func showFirstRunMessage() {
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "👋 Welcome to Opal Task Manager!")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Default configuration created. To customize your setup,")
fmt.Fprintln(os.Stderr, "run: opal setup")
fmt.Fprintln(os.Stderr, "")
}