Files
gems/opal-task/cmd/add.go
T
joakim 1d55f04a1f Fix recurring task modifier application bug
When creating recurring tasks, modifiers (due, wait, priority, etc) were
not being applied because AttributeOrder was not copied to the temporary
modifier. This caused all date attributes to be ignored.

Refactored addRecurringTask to:
- Create task structs directly instead of using CreateTask (avoiding
  premature saves)
- Use ApplyToNew() instead of Apply() for modifiers before first save
- Properly copy AttributeOrder when building the temporary modifier
- Save template and instance once with all fields correctly set

This ensures recurring tasks now properly have due dates, wait dates,
and other modifiers applied when created via 'opal add' or batch import.
2026-01-05 11:18:43 +01:00

200 lines
5.3 KiB
Go

package cmd
import (
"fmt"
"os"
"strings"
"time"
"git.jnss.me/joakim/opal/internal/engine"
"github.com/google/uuid"
"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
// Description = all non-filter/modifier words joined with spaces
// Filters/Modifiers = args with +, -, or containing :
func parseAddArgs(args []string) (string, []string, error) {
var descParts []string
var modifiers []string
for _, arg := range args {
isFilterOrModifier := strings.HasPrefix(arg, "+") ||
strings.HasPrefix(arg, "-") ||
strings.Contains(arg, ":")
if isFilterOrModifier {
modifiers = append(modifiers, arg)
} else {
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 {
// Extract recurrence pattern
recurPattern := mod.SetAttributes["recur"]
if recurPattern == nil {
return fmt.Errorf("no recurrence pattern specified")
}
// Validate: recurring tasks must have due date
if mod.SetAttributes["due"] == nil {
return fmt.Errorf("recurring tasks require a due date (use due:YYYY-MM-DD or due:monday)")
}
duration, err := engine.ParseRecurrencePattern(*recurPattern)
if err != nil {
return fmt.Errorf("invalid recurrence pattern: %w", err)
}
// Create template task (without saving yet)
now := time.Now()
template := &engine.Task{
UUID: uuid.New(),
Status: engine.StatusRecurring,
Description: description,
Priority: engine.PriorityDefault,
Created: now,
Modified: now,
RecurrenceDuration: &duration,
Tags: []string{},
}
// Create modifier without the recur attribute
tempMod := &engine.Modifier{
SetAttributes: make(map[string]*string),
AttributeOrder: []string{},
AddTags: mod.AddTags,
RemoveTags: mod.RemoveTags,
}
// Copy all attributes except recur
for _, key := range mod.AttributeOrder {
if key != "recur" {
val := mod.SetAttributes[key]
tempMod.SetAttributes[key] = val
tempMod.AttributeOrder = append(tempMod.AttributeOrder, key)
}
}
// Apply modifiers to template before first save
if err := tempMod.ApplyToNew(template); err != nil {
return fmt.Errorf("failed to apply modifiers to template: %w", err)
}
// Save template
if err := template.Save(); err != nil {
return fmt.Errorf("failed to save template: %w", err)
}
// Add tags to template (requires task.ID from save)
for _, tag := range mod.AddTags {
if err := template.AddTag(tag); err != nil {
return fmt.Errorf("failed to add tag to template: %w", err)
}
}
// Create first instance
instance := &engine.Task{
UUID: uuid.New(),
Status: engine.StatusPending,
Description: description,
Priority: template.Priority,
Created: now,
Modified: now,
ParentUUID: &template.UUID,
Due: template.Due,
Wait: template.Wait,
Scheduled: template.Scheduled,
Project: template.Project,
Tags: []string{},
}
if err := instance.Save(); err != nil {
return fmt.Errorf("failed to save first instance: %w", err)
}
// Copy tags to instance
for _, tag := range template.Tags {
if err := instance.AddTag(tag); err != nil {
return fmt.Errorf("failed to add tag to instance: %w", err)
}
}
fmt.Printf("Created recurring task %s\n", template.UUID)
fmt.Printf("First instance: %s\n", instance.UUID)
fmt.Printf("Recurrence: %s\n", engine.FormatRecurrenceDuration(duration))
return nil
}