From 10421b0ec67b309a2ad1b74fef32e1eef9402c43 Mon Sep 17 00:00:00 2001 From: Joakim Date: Wed, 25 Feb 2026 22:30:50 +0100 Subject: [PATCH] feat: add hard delete flag and opal clean command Add --hard flag to `opal delete` for permanent removal and a new `opal clean` command to bulk-purge soft-deleted tasks with optional --older duration filter. Co-Authored-By: Claude Opus 4.6 --- opal-task/cmd/clean.go | 77 +++++++++++++++++++++++++++++++ opal-task/cmd/delete.go | 41 ++++++++++++---- opal-task/cmd/root.go | 4 +- opal-task/internal/engine/task.go | 42 +++++++++++++++++ 4 files changed, 153 insertions(+), 11 deletions(-) create mode 100644 opal-task/cmd/clean.go diff --git a/opal-task/cmd/clean.go b/opal-task/cmd/clean.go new file mode 100644 index 0000000..b275734 --- /dev/null +++ b/opal-task/cmd/clean.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "fmt" + "os" + "time" + + "git.jnss.me/joakim/opal/internal/engine" + "github.com/spf13/cobra" +) + +var olderFlag string + +var cleanCmd = &cobra.Command{ + Use: "clean", + Short: "Purge soft-deleted tasks from the database", + Run: func(cmd *cobra.Command, args []string) { + if err := cleanTasks(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + }, +} + +func init() { + cleanCmd.Flags().StringVar(&olderFlag, "older", "", "Only purge tasks deleted longer than this duration ago (e.g. 30d, 1w)") +} + +func cleanTasks() error { + var olderThan *time.Duration + if olderFlag != "" { + d, err := engine.ParseRecurrencePattern(olderFlag) + if err != nil { + return fmt.Errorf("invalid duration %q: %w", olderFlag, err) + } + olderThan = &d + } + + tasks, err := engine.GetDeletedTasks(olderThan) + if err != nil { + return err + } + + if len(tasks) == 0 { + fmt.Println("No deleted tasks to purge.") + return nil + } + + if dryRunFlag { + fmt.Printf("Would permanently remove %d deleted task(s).\n", len(tasks)) + return nil + } + + if len(tasks) > 1 { + fmt.Printf("Permanently remove %d deleted task(s)? This cannot be undone. (y/N): ", len(tasks)) + var confirm string + fmt.Scanln(&confirm) + if confirm != "y" && confirm != "Y" { + fmt.Println("Cancelled.") + return nil + } + } + + for _, task := range tasks { + if err := task.Delete(true); err != nil { + return fmt.Errorf("failed to purge task %s: %w", task.UUID, err) + } + } + + fmt.Printf("Purged %d deleted task(s).\n", len(tasks)) + + if err := engine.CleanupChangeLog(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to clean up change log: %v\n", err) + } + + return nil +} diff --git a/opal-task/cmd/delete.go b/opal-task/cmd/delete.go index 9703997..d2bd2f8 100644 --- a/opal-task/cmd/delete.go +++ b/opal-task/cmd/delete.go @@ -8,19 +8,25 @@ import ( "github.com/spf13/cobra" ) +var hardDeleteFlag bool + var deleteCmd = &cobra.Command{ Use: "delete [filter...]", Short: "Delete tasks", Run: func(cmd *cobra.Command, args []string) { parsed := getParsedArgs(cmd) - if err := deleteTasks(parsed.Filters); err != nil { + if err := deleteTasks(parsed.Filters, hardDeleteFlag); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } }, } -func deleteTasks(args []string) error { +func init() { + deleteCmd.Flags().BoolVar(&hardDeleteFlag, "hard", false, "Permanently remove task from database") +} + +func deleteTasks(args []string, hard bool) error { filter, err := engine.ParseFilter(args) if err != nil { return err @@ -53,15 +59,24 @@ func deleteTasks(args []string) error { return fmt.Errorf("no tasks matched filter") } + action := "delete" + if hard { + action = "permanently delete" + } + if dryRunFlag { - fmt.Print(engine.FormatTaskConfirmList("delete", tasks, ws)) + fmt.Print(engine.FormatTaskConfirmList(action, tasks, ws)) fmt.Println("Dry run — no changes made.") return nil } - if len(tasks) > 1 { - fmt.Print(engine.FormatTaskConfirmList("delete", tasks, ws)) - fmt.Printf("Proceed? (y/N): ") + if len(tasks) > 1 || hard { + fmt.Print(engine.FormatTaskConfirmList(action, tasks, ws)) + if hard { + fmt.Printf("This cannot be undone. Proceed? (y/N): ") + } else { + fmt.Printf("Proceed? (y/N): ") + } var confirm string fmt.Scanln(&confirm) if confirm != "y" && confirm != "Y" { @@ -71,14 +86,20 @@ func deleteTasks(args []string) error { } for _, task := range tasks { - task.Delete(false) // Soft delete - engine.RecordUndo("delete", task.UUID) + task.Delete(hard) + if !hard { + engine.RecordUndo("delete", task.UUID) + } } + verb := "Deleted" + if hard { + verb = "Permanently deleted" + } if len(tasks) == 1 { - fmt.Printf("Deleted task %s\n", engine.FormatTaskSummary(tasks[0], ws)) + fmt.Printf("%s task %s\n", verb, engine.FormatTaskSummary(tasks[0], ws)) } else { - fmt.Printf("Deleted %d task(s).\n", len(tasks)) + fmt.Printf("%s %d task(s).\n", verb, len(tasks)) } return nil } diff --git a/opal-task/cmd/root.go b/opal-task/cmd/root.go index b4b642e..8c1f523 100644 --- a/opal-task/cmd/root.go +++ b/opal-task/cmd/root.go @@ -32,7 +32,7 @@ var ( // Command classification var commandNames = []string{ - "add", "done", "modify", "delete", + "add", "done", "modify", "delete", "clean", "start", "stop", "count", "projects", "tags", "info", "edit", "server", "sync", "reports", "setup", "version", "annotate", "denotate", "undo", "uncomplete", "log", "completion", @@ -284,6 +284,7 @@ func init() { undoCmd.GroupID = "task" uncompleteCmd.GroupID = "task" logCmd.GroupID = "task" + cleanCmd.GroupID = "task" rootCmd.AddCommand(addCmd) rootCmd.AddCommand(doneCmd) @@ -298,6 +299,7 @@ func init() { rootCmd.AddCommand(undoCmd) rootCmd.AddCommand(uncompleteCmd) rootCmd.AddCommand(logCmd) + rootCmd.AddCommand(cleanCmd) // Other commands countCmd.GroupID = "other" diff --git a/opal-task/internal/engine/task.go b/opal-task/internal/engine/task.go index 0b86e10..b13d9f7 100644 --- a/opal-task/internal/engine/task.go +++ b/opal-task/internal/engine/task.go @@ -736,6 +736,48 @@ func (t *Task) IsRecurringInstance() bool { return t.ParentUUID != nil } +// GetDeletedTasks retrieves soft-deleted tasks, optionally filtered by age. +// If olderThan is non-nil, only returns tasks deleted more than that duration ago. +func GetDeletedTasks(olderThan *time.Duration) ([]*Task, error) { + db := GetDB() + if db == nil { + return nil, fmt.Errorf("database not initialized") + } + + query := ` + SELECT id, uuid, status, description, project, priority, + created, modified, start, end, due, scheduled, wait, until_date, + recurrence_duration, parent_uuid, annotations + FROM tasks + WHERE status = ?` + args := []interface{}{byte(StatusDeleted)} + + if olderThan != nil { + cutoff := timeNow().Add(-*olderThan).Unix() + query += ` AND end < ?` + args = append(args, cutoff) + } + + query += ` ORDER BY end ASC` + + rows, err := db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query deleted tasks: %w", err) + } + defer rows.Close() + + var tasks []*Task + for rows.Next() { + task, err := scanTask(rows) + if err != nil { + return nil, fmt.Errorf("failed to scan task: %w", err) + } + tasks = append(tasks, task) + } + + return tasks, nil +} + // PopulateUrgency computes and sets the Urgency field on the given tasks. func PopulateUrgency(tasks ...*Task) { cfg, _ := GetConfig()