Implement report system and fix template task filtering

- Fix template task filtering bug: templates now hidden from all reports
  except 'template' and 'all' reports, even when using custom filters
- Add support for status:template filter to explicitly show templates
- Implement comprehensive report system with 12 predefined reports:
  * active - Started tasks
  * all - All tasks including templates
  * completed - Completed tasks
  * list - Pending tasks (default)
  * minimal - Pending tasks in minimal format
  * newest - Most recent pending tasks
  * oldest - Oldest pending tasks
  * overdue - Overdue tasks
  * ready - Tasks ready to work on
  * recurring - Pending recurring instances
  * template - Recurring template tasks
  * waiting - Hidden/waiting tasks
- Replace list command with report-based architecture
- Add configurable default_report option (defaults to 'list')
- Add minimal display format (ID + description only)
- Support flexible syntax: 'opal <report> [filters]' or 'opal [filters] <report>'
- Add 'opal reports' command to list all available reports
This commit is contained in:
2026-01-05 21:17:07 +01:00
parent f5f7bc3ad7
commit 59861bc3bf
7 changed files with 568 additions and 68 deletions
-58
View File
@@ -1,58 +0,0 @@
package cmd
import (
"fmt"
"os"
"git.jnss.me/joakim/opal/internal/engine"
"github.com/spf13/cobra"
)
var listCmd = &cobra.Command{
Use: "list [filter...]",
Short: "List tasks",
Long: `List tasks matching the filter criteria.
Examples:
opal list # List all pending tasks
opal list +home # List tasks with +home tag
opal list project:backend # List backend project tasks
opal list priority:H # List high priority tasks
opal 2 list # List using filter 2 (flexible syntax)`,
Run: func(cmd *cobra.Command, args []string) {
parsed := getParsedArgs(cmd)
if err := listTasks(parsed.Filters); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func listTasks(args []string) error {
// Parse filter
var filter *engine.Filter
var err error
if len(args) == 0 {
filter = engine.DefaultFilter()
} else {
filter, err = engine.ParseFilter(args)
if err != nil {
return fmt.Errorf("failed to parse filter: %w", err)
}
}
// Build working set
ws, err := engine.BuildWorkingSet(filter)
if err != nil {
return fmt.Errorf("failed to build working set: %w", err)
}
// Get tasks
tasks := ws.GetTasks()
// Display
fmt.Println(engine.FormatTaskList(tasks, ws))
return nil
}
+97
View File
@@ -0,0 +1,97 @@
package cmd
import (
"fmt"
"os"
"sort"
"git.jnss.me/joakim/opal/internal/engine"
"github.com/spf13/cobra"
)
// CreateReportCommands generates commands for all reports dynamically
func CreateReportCommands() []*cobra.Command {
reports := engine.AllReports()
commands := make([]*cobra.Command, 0, len(reports))
// Create a command for each report
for name, report := range reports {
// Capture in closure
reportName := name
reportObj := report
cmd := &cobra.Command{
Use: reportName + " [filter...]",
Short: reportObj.Description,
Long: fmt.Sprintf("%s\n\nThis is a report that shows: %s", reportObj.Description, reportObj.Description),
Run: func(cmd *cobra.Command, args []string) {
parsed := getParsedArgs(cmd)
if err := runReport(reportName, parsed.Filters); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
commands = append(commands, cmd)
}
return commands
}
// runReport executes a report by name with optional filters
func runReport(reportName string, filters []string) error {
// Get the report
report, err := engine.GetReport(reportName)
if err != nil {
return err
}
// Execute the report
tasks, err := report.Execute(filters)
if err != nil {
return fmt.Errorf("failed to execute report: %w", err)
}
// Build working set for display IDs
ws, err := engine.BuildWorkingSet(report.BaseFilter)
if err != nil {
return fmt.Errorf("failed to build working set: %w", err)
}
// Display tasks based on format
var output string
if report.DisplayFormat == engine.DisplayFormatMinimal {
output = engine.FormatTaskListWithFormat(tasks, ws, "minimal")
} else {
output = engine.FormatTaskListWithFormat(tasks, ws, "table")
}
fmt.Println(output)
return nil
}
// reportsCmd shows all available reports
var reportsCmd = &cobra.Command{
Use: "reports",
Short: "List all available reports",
Long: `Display a list of all available task reports with their descriptions.`,
Run: func(cmd *cobra.Command, args []string) {
reports := engine.AllReports()
// Sort by name
names := make([]string, 0, len(reports))
for name := range reports {
names = append(names, name)
}
sort.Strings(names)
fmt.Println("Available reports:\n")
for _, name := range names {
report := reports[name]
fmt.Printf(" %-12s %s\n", name, report.Description)
}
fmt.Println("\nUsage: opal <report-name> [filters...]")
fmt.Println("Example: opal ready +home")
},
}
+44 -7
View File
@@ -23,9 +23,16 @@ const parsedArgsKey contextKey = "parsedArgs"
// Command classification // Command classification
var commandNames = []string{ var commandNames = []string{
"add", "list", "done", "modify", "delete", "add", "done", "modify", "delete",
"start", "stop", "count", "projects", "tags", "start", "stop", "count", "projects", "tags",
"info", "edit", "server", "sync", "info", "edit", "server", "sync", "reports",
}
// Report names (dynamically populated)
var reportNames = []string{
"active", "all", "completed", "list", "minimal",
"newest", "oldest", "overdue", "ready", "recurring",
"template", "waiting",
} }
var commandsWithModifiers = map[string]bool{ var commandsWithModifiers = map[string]bool{
@@ -39,9 +46,22 @@ var rootCmd = &cobra.Command{
Long: `Opal is a powerful command-line task manager inspired by taskwarrior. Long: `Opal is a powerful command-line task manager inspired by taskwarrior.
It supports filtering, tags, priorities, projects, and recurring tasks.`, It supports filtering, tags, priorities, projects, and recurring tasks.`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
// Default behavior: list tasks // Default behavior: run configured default report (defaults to "list")
parsed := getParsedArgs(cmd) parsed := getParsedArgs(cmd)
if err := listTasks(parsed.Filters); err != nil {
// 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) fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
@@ -102,11 +122,12 @@ func preprocessArgs(args []string) *ParsedArgs {
} }
} }
// Find command position // Find command position (check both regular commands and reports)
cmdIdx := -1 cmdIdx := -1
cmdName := "" cmdName := ""
for i, arg := range args { for i, arg := range args {
// Check regular commands
for _, name := range commandNames { for _, name := range commandNames {
if arg == name { if arg == name {
cmdIdx = i cmdIdx = i
@@ -114,6 +135,16 @@ func preprocessArgs(args []string) *ParsedArgs {
break break
} }
} }
// Check report names
if cmdIdx < 0 {
for _, name := range reportNames {
if arg == name {
cmdIdx = i
cmdName = name
break
}
}
}
if cmdIdx >= 0 { if cmdIdx >= 0 {
break break
} }
@@ -159,9 +190,8 @@ func preprocessArgs(args []string) *ParsedArgs {
func init() { func init() {
cobra.OnInitialize(initializeApp) cobra.OnInitialize(initializeApp)
// Add subcommands // Add regular subcommands
rootCmd.AddCommand(addCmd) rootCmd.AddCommand(addCmd)
rootCmd.AddCommand(listCmd)
rootCmd.AddCommand(doneCmd) rootCmd.AddCommand(doneCmd)
rootCmd.AddCommand(modifyCmd) rootCmd.AddCommand(modifyCmd)
rootCmd.AddCommand(deleteCmd) rootCmd.AddCommand(deleteCmd)
@@ -172,6 +202,13 @@ func init() {
rootCmd.AddCommand(tagsCmd) rootCmd.AddCommand(tagsCmd)
rootCmd.AddCommand(infoCmd) rootCmd.AddCommand(infoCmd)
rootCmd.AddCommand(editCmd) rootCmd.AddCommand(editCmd)
rootCmd.AddCommand(reportsCmd)
// Add report commands dynamically
reportCommands := CreateReportCommands()
for _, cmd := range reportCommands {
rootCmd.AddCommand(cmd)
}
} }
func initializeApp() { func initializeApp() {
+3
View File
@@ -13,6 +13,7 @@ import (
type Config struct { type Config struct {
DefaultFilter string `mapstructure:"default_filter"` DefaultFilter string `mapstructure:"default_filter"`
DefaultSort string `mapstructure:"default_sort"` DefaultSort string `mapstructure:"default_sort"`
DefaultReport string `mapstructure:"default_report"`
ColorOutput bool `mapstructure:"color_output"` ColorOutput bool `mapstructure:"color_output"`
WeekStartDay string `mapstructure:"week_start_day"` WeekStartDay string `mapstructure:"week_start_day"`
DefaultDueTime string `mapstructure:"default_due_time"` DefaultDueTime string `mapstructure:"default_due_time"`
@@ -83,6 +84,7 @@ func LoadConfig() (*Config, error) {
// Set defaults // Set defaults
v.SetDefault("default_filter", "status:pending") v.SetDefault("default_filter", "status:pending")
v.SetDefault("default_sort", "due,priority") v.SetDefault("default_sort", "due,priority")
v.SetDefault("default_report", "list")
v.SetDefault("color_output", true) v.SetDefault("color_output", true)
v.SetDefault("week_start_day", "monday") v.SetDefault("week_start_day", "monday")
v.SetDefault("default_due_time", "") v.SetDefault("default_due_time", "")
@@ -131,6 +133,7 @@ func SaveConfig(cfg *Config) error {
v.Set("default_filter", cfg.DefaultFilter) v.Set("default_filter", cfg.DefaultFilter)
v.Set("default_sort", cfg.DefaultSort) v.Set("default_sort", cfg.DefaultSort)
v.Set("default_report", cfg.DefaultReport)
v.Set("color_output", cfg.ColorOutput) v.Set("color_output", cfg.ColorOutput)
v.Set("week_start_day", cfg.WeekStartDay) v.Set("week_start_day", cfg.WeekStartDay)
v.Set("default_due_time", cfg.DefaultDueTime) v.Set("default_due_time", cfg.DefaultDueTime)
+25
View File
@@ -11,10 +11,35 @@ import (
// FormatTaskList formats a list of tasks for display // FormatTaskList formats a list of tasks for display
func FormatTaskList(tasks []*Task, ws *WorkingSet) string { func FormatTaskList(tasks []*Task, ws *WorkingSet) string {
return FormatTaskListWithFormat(tasks, ws, "table")
}
// FormatTaskListWithFormat formats a list of tasks with specified format
func FormatTaskListWithFormat(tasks []*Task, ws *WorkingSet, format string) string {
if len(tasks) == 0 { if len(tasks) == 0 {
return "No tasks found." return "No tasks found."
} }
// Minimal format: just ID and description
if format == "minimal" {
result := ""
for i, task := range tasks {
displayID := i + 1
if ws != nil {
// Use working set display ID if available
for id, uuid := range ws.byID {
if uuid == task.UUID {
displayID = id
break
}
}
}
result += fmt.Sprintf("%3d %s\n", displayID, task.Description)
}
return result
}
// Table format (default)
t := table.NewWriter() t := table.NewWriter()
// Configure style // Configure style
+29 -3
View File
@@ -70,11 +70,37 @@ func (f *Filter) ToSQL() (string, []interface{}) {
conditions := []string{} conditions := []string{}
args := []interface{}{} args := []interface{}{}
// Track if we have an explicit status filter
hasStatusFilter := false
// Status filter // Status filter
if status, ok := f.Attributes["status"]; ok { if status, ok := f.Attributes["status"]; ok {
statusByte := statusStringToByte(status) hasStatusFilter = true
conditions = append(conditions, "status = ?")
args = append(args, statusByte) // Handle special "template" status
if status == "template" {
// Templates are: status=recurring AND parent_uuid IS NULL AND recurrence_duration IS NOT NULL
conditions = append(conditions, "status = ? AND parent_uuid IS NULL AND recurrence_duration IS NOT NULL")
args = append(args, byte(StatusRecurring))
} else {
statusByte := statusStringToByte(status)
conditions = append(conditions, "status = ?")
args = append(args, statusByte)
}
}
// Implicit template exclusion (Option A):
// Exclude recurring templates UNLESS:
// - Filter explicitly includes status (any status, including "template")
// - This is an "all" report (marked with _all=true)
if !hasStatusFilter {
// Check if this is the "all" report
if _, isAllReport := f.Attributes["_all"]; !isAllReport {
// No explicit status filter and not "all" report - exclude templates
// Templates have status=recurring AND parent_uuid IS NULL
conditions = append(conditions, "(status != ? OR parent_uuid IS NOT NULL)")
args = append(args, byte(StatusRecurring))
}
} }
// Project filter // Project filter
+370
View File
@@ -0,0 +1,370 @@
package engine
import (
"fmt"
)
// DisplayFormat defines how tasks should be displayed
type DisplayFormat string
const (
DisplayFormatTable DisplayFormat = "table"
DisplayFormatMinimal DisplayFormat = "minimal"
)
// Report defines a named task report with filters and display options
type Report struct {
Name string
Description string
BaseFilter *Filter // Base filter that cannot be overridden
DisplayFormat DisplayFormat // How to display results
SortFunc func([]*Task) []*Task
LimitFunc func([]*Task) []*Task
}
// AllReports returns all predefined reports
func AllReports() map[string]*Report {
return map[string]*Report{
"active": ActiveReport(),
"all": AllReport(),
"completed": CompletedReport(),
"list": ListReport(),
"minimal": MinimalReport(),
"newest": NewestReport(),
"oldest": OldestReport(),
"overdue": OverdueReport(),
"ready": ReadyReport(),
"recurring": RecurringReport(),
"template": TemplateReport(),
"waiting": WaitingReport(),
}
}
// GetReport retrieves a report by name
func GetReport(name string) (*Report, error) {
reports := AllReports()
report, exists := reports[name]
if !exists {
return nil, fmt.Errorf("unknown report: %s", name)
}
return report, nil
}
// ActiveReport shows started tasks
func ActiveReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "pending"
filter.Attributes["_started"] = "true" // Special marker for tasks with start time
return &Report{
Name: "active",
Description: "Started tasks",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
}
}
// AllReport shows all tasks (no status filter)
func AllReport() *Report {
filter := NewFilter()
filter.Attributes["_all"] = "true" // Special marker to include templates
return &Report{
Name: "all",
Description: "All tasks",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
}
}
// CompletedReport shows completed tasks
func CompletedReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "completed"
return &Report{
Name: "completed",
Description: "Completed tasks",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
}
}
// ListReport shows pending tasks (default report)
func ListReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "pending"
return &Report{
Name: "list",
Description: "Pending tasks",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
}
}
// MinimalReport shows pending tasks in minimal format
func MinimalReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "pending"
return &Report{
Name: "minimal",
Description: "Pending tasks (minimal format)",
BaseFilter: filter,
DisplayFormat: DisplayFormatMinimal,
}
}
// NewestReport shows most recent pending tasks
func NewestReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "pending"
return &Report{
Name: "newest",
Description: "Most recent pending tasks",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
SortFunc: func(tasks []*Task) []*Task {
// Sort by created descending
sorted := make([]*Task, len(tasks))
copy(sorted, tasks)
for i := 0; i < len(sorted)-1; i++ {
for j := i + 1; j < len(sorted); j++ {
if sorted[i].Created.Before(sorted[j].Created) {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
return sorted
},
LimitFunc: func(tasks []*Task) []*Task {
if len(tasks) > 10 {
return tasks[:10]
}
return tasks
},
}
}
// OldestReport shows oldest pending tasks
func OldestReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "pending"
return &Report{
Name: "oldest",
Description: "Oldest pending tasks",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
SortFunc: func(tasks []*Task) []*Task {
// Sort by created ascending (already default, but explicit)
sorted := make([]*Task, len(tasks))
copy(sorted, tasks)
for i := 0; i < len(sorted)-1; i++ {
for j := i + 1; j < len(sorted); j++ {
if sorted[i].Created.After(sorted[j].Created) {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
return sorted
},
}
}
// OverdueReport shows overdue tasks
func OverdueReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "pending"
filter.Attributes["_overdue"] = "true" // Special marker for overdue tasks
return &Report{
Name: "overdue",
Description: "Overdue tasks",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
}
}
// ReadyReport shows tasks ready to work on
func ReadyReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "pending"
filter.Attributes["_ready"] = "true" // Special marker for ready tasks
return &Report{
Name: "ready",
Description: "Tasks ready to work on",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
}
}
// RecurringReport shows pending recurring instances
func RecurringReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "pending"
filter.Attributes["_recurring_instance"] = "true" // Special marker for recurring instances
return &Report{
Name: "recurring",
Description: "Pending recurring task instances",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
}
}
// TemplateReport shows recurring template tasks
func TemplateReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "template" // Special status for templates
return &Report{
Name: "template",
Description: "Recurring template tasks",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
}
}
// WaitingReport shows waiting/hidden tasks
func WaitingReport() *Report {
filter := NewFilter()
filter.Attributes["status"] = "pending"
filter.Attributes["_waiting"] = "true" // Special marker for waiting tasks
return &Report{
Name: "waiting",
Description: "Hidden/waiting tasks",
BaseFilter: filter,
DisplayFormat: DisplayFormatTable,
}
}
// Execute runs the report with optional additional filters
func (r *Report) Execute(additionalFilters []string) ([]*Task, error) {
// Start with base filter
filter := r.BaseFilter
// Merge additional filters if provided
if len(additionalFilters) > 0 {
userFilter, err := ParseFilter(additionalFilters)
if err != nil {
return nil, fmt.Errorf("failed to parse additional filters: %w", err)
}
// Merge filters (user filters add to base, but cannot override status)
filter = mergeFilters(r.BaseFilter, userFilter)
}
// Get tasks
tasks, err := GetTasks(filter)
if err != nil {
return nil, err
}
// Apply post-filter for special markers
tasks = r.applyPostFilters(tasks)
// Apply sorting if defined
if r.SortFunc != nil {
tasks = r.SortFunc(tasks)
}
// Apply limit if defined
if r.LimitFunc != nil {
tasks = r.LimitFunc(tasks)
}
return tasks, nil
}
// applyPostFilters applies special filters that can't be done in SQL
func (r *Report) applyPostFilters(tasks []*Task) []*Task {
now := timeNow()
filtered := []*Task{}
for _, task := range tasks {
include := true
// Check for _started marker
if r.BaseFilter.Attributes["_started"] == "true" {
if task.Start == nil {
include = false
}
}
// Check for _overdue marker
if r.BaseFilter.Attributes["_overdue"] == "true" {
if task.Due == nil || !task.Due.Before(now) {
include = false
}
}
// Check for _ready marker
if r.BaseFilter.Attributes["_ready"] == "true" {
// Task is ready if scheduled and wait are either null or in the past
if task.Scheduled != nil && task.Scheduled.After(now) {
include = false
}
if task.Wait != nil && task.Wait.After(now) {
include = false
}
}
// Check for _waiting marker
if r.BaseFilter.Attributes["_waiting"] == "true" {
if task.Wait == nil || !task.Wait.After(now) {
include = false
}
}
// Check for _recurring_instance marker
if r.BaseFilter.Attributes["_recurring_instance"] == "true" {
if task.ParentUUID == nil {
include = false
}
}
if include {
filtered = append(filtered, task)
}
}
return filtered
}
// mergeFilters combines base filter with user filter
func mergeFilters(base, user *Filter) *Filter {
merged := NewFilter()
// Copy base attributes (these take precedence)
for k, v := range base.Attributes {
merged.Attributes[k] = v
}
// Add user attributes (but don't override status or special markers)
for k, v := range user.Attributes {
if k != "status" && k[0] != '_' {
merged.Attributes[k] = v
}
}
// Merge tags
merged.IncludeTags = append(merged.IncludeTags, base.IncludeTags...)
merged.IncludeTags = append(merged.IncludeTags, user.IncludeTags...)
merged.ExcludeTags = append(merged.ExcludeTags, base.ExcludeTags...)
merged.ExcludeTags = append(merged.ExcludeTags, user.ExcludeTags...)
// Merge IDs and UUIDs
merged.IDs = append(merged.IDs, base.IDs...)
merged.IDs = append(merged.IDs, user.IDs...)
merged.UUIDs = append(merged.UUIDs, base.UUIDs...)
merged.UUIDs = append(merged.UUIDs, user.UUIDs...)
return merged
}