Phase 4: Implement relative date expressions

- Add parseRelativeExpression() to detect pattern: attr+/-duration
- Add resolveDateValue() to resolve absolute or relative dates
- Add applyDateAttribute() helper for date attributes with relative support
- Track attribute order in Modifier struct (AttributeOrder field)
- Refactor Apply() and ApplyToNew() to process attrs in order
- Support chaining: due:mon scheduled:due-3d wait:scheduled-1d
- Support addition and subtraction: due+1y, wait-2d
- Add comprehensive test suite for relative expressions
- Error if referencing undefined date attribute
- All 38+ tests passing
This commit is contained in:
2026-01-05 10:05:20 +01:00
parent cd476cfc99
commit 2afa4c6ee0
3 changed files with 298 additions and 90 deletions
+163 -90
View File
@@ -2,20 +2,24 @@ package engine
import (
"fmt"
"regexp"
"strings"
"time"
)
type Modifier struct {
SetAttributes map[string]*string // key -> value (nil = clear)
AddTags []string
RemoveTags []string
SetAttributes map[string]*string // key -> value (nil = clear)
AttributeOrder []string // Track order of attributes for relative references
AddTags []string
RemoveTags []string
}
func NewModifier() *Modifier {
return &Modifier{
SetAttributes: make(map[string]*string),
AddTags: []string{},
RemoveTags: []string{},
SetAttributes: make(map[string]*string),
AttributeOrder: []string{},
AddTags: []string{},
RemoveTags: []string{},
}
}
@@ -42,6 +46,9 @@ func ParseModifier(args []string) (*Modifier, error) {
} else {
m.SetAttributes[key] = &value
}
// Track order for relative date references
m.AttributeOrder = append(m.AttributeOrder, key)
}
}
@@ -50,8 +57,46 @@ func ParseModifier(args []string) (*Modifier, error) {
// Apply applies modifier to task
func (m *Modifier) Apply(task *Task) error {
// Apply attribute changes
for key, valuePtr := range m.SetAttributes {
// Track resolved dates for relative references
resolvedDates := make(map[string]time.Time)
// Initialize with existing task dates
if task.Due != nil {
resolvedDates["due"] = *task.Due
}
if task.Scheduled != nil {
resolvedDates["scheduled"] = *task.Scheduled
}
if task.Wait != nil {
resolvedDates["wait"] = *task.Wait
}
if task.Until != nil {
resolvedDates["until"] = *task.Until
}
if task.Start != nil {
resolvedDates["start"] = *task.Start
}
if task.End != nil {
resolvedDates["end"] = *task.End
}
resolvedDates["created"] = task.Created
resolvedDates["modified"] = task.Modified
// Apply attributes in the order they were specified (important for relative references)
dateKeys := map[string]bool{"due": true, "scheduled": true, "wait": true, "until": true}
for _, key := range m.AttributeOrder {
valuePtr := m.SetAttributes[key]
// Handle date attributes with relative expression support
if dateKeys[key] {
if err := applyDateAttribute(key, valuePtr, task, resolvedDates); err != nil {
return err
}
continue
}
// Handle non-date attributes
switch key {
case "priority":
if valuePtr == nil {
@@ -61,46 +106,6 @@ func (m *Modifier) Apply(task *Task) error {
}
case "project":
task.Project = valuePtr
case "due":
if valuePtr == nil {
task.Due = nil
} else {
parsed, err := ParseDate(*valuePtr)
if err != nil {
return fmt.Errorf("invalid due date: %w", err)
}
task.Due = &parsed
}
case "scheduled":
if valuePtr == nil {
task.Scheduled = nil
} else {
parsed, err := ParseDate(*valuePtr)
if err != nil {
return fmt.Errorf("invalid scheduled date: %w", err)
}
task.Scheduled = &parsed
}
case "wait":
if valuePtr == nil {
task.Wait = nil
} else {
parsed, err := ParseDate(*valuePtr)
if err != nil {
return fmt.Errorf("invalid wait date: %w", err)
}
task.Wait = &parsed
}
case "until":
if valuePtr == nil {
task.Until = nil
} else {
parsed, err := ParseDate(*valuePtr)
if err != nil {
return fmt.Errorf("invalid until date: %w", err)
}
task.Until = &parsed
}
case "recur":
if valuePtr == nil {
task.RecurrenceDuration = nil
@@ -135,8 +140,31 @@ func (m *Modifier) Apply(task *Task) error {
// ApplyToNew applies modifier to a new task (before it's saved)
// This is used when creating tasks with modifiers
func (m *Modifier) ApplyToNew(task *Task) error {
// Apply attribute changes (same as Apply but without Save)
for key, valuePtr := range m.SetAttributes {
// Track resolved dates for relative references
resolvedDates := make(map[string]time.Time)
// Initialize with existing task dates (usually empty for new tasks)
if task.Created.IsZero() {
resolvedDates["created"] = timeNow()
} else {
resolvedDates["created"] = task.Created
}
// Apply attributes in the order they were specified (important for relative references)
dateKeys := map[string]bool{"due": true, "scheduled": true, "wait": true, "until": true}
for _, key := range m.AttributeOrder {
valuePtr := m.SetAttributes[key]
// Handle date attributes with relative expression support
if dateKeys[key] {
if err := applyDateAttribute(key, valuePtr, task, resolvedDates); err != nil {
return err
}
continue
}
// Handle non-date attributes
switch key {
case "priority":
if valuePtr == nil {
@@ -146,46 +174,6 @@ func (m *Modifier) ApplyToNew(task *Task) error {
}
case "project":
task.Project = valuePtr
case "due":
if valuePtr == nil {
task.Due = nil
} else {
parsed, err := ParseDate(*valuePtr)
if err != nil {
return fmt.Errorf("invalid due date: %w", err)
}
task.Due = &parsed
}
case "scheduled":
if valuePtr == nil {
task.Scheduled = nil
} else {
parsed, err := ParseDate(*valuePtr)
if err != nil {
return fmt.Errorf("invalid scheduled date: %w", err)
}
task.Scheduled = &parsed
}
case "wait":
if valuePtr == nil {
task.Wait = nil
} else {
parsed, err := ParseDate(*valuePtr)
if err != nil {
return fmt.Errorf("invalid wait date: %w", err)
}
task.Wait = &parsed
}
case "until":
if valuePtr == nil {
task.Until = nil
} else {
parsed, err := ParseDate(*valuePtr)
if err != nil {
return fmt.Errorf("invalid until date: %w", err)
}
task.Until = &parsed
}
case "recur":
if valuePtr == nil {
task.RecurrenceDuration = nil
@@ -202,3 +190,88 @@ func (m *Modifier) ApplyToNew(task *Task) error {
// Note: Tags are added after task is saved (in CreateTask function)
return nil
}
// parseRelativeExpression checks if a string is a relative date expression
// Returns: baseAttr, operator, offset, isRelative
// Example: "due-1d" -> "due", "-", "1d", true
func parseRelativeExpression(s string) (string, string, string, bool) {
// Pattern: <attribute><operator><duration>
// Examples: due-1d, scheduled+2w, wait-3days
re := regexp.MustCompile(`^([a-z_]+)([\+\-])(.+)$`)
matches := re.FindStringSubmatch(s)
if len(matches) == 4 {
return matches[1], matches[2], matches[3], true
}
return "", "", "", false
}
// resolveDateValue resolves a date value, which may be absolute or relative
// resolvedDates contains dates that have been resolved so far (for relative references)
func resolveDateValue(valueStr string, resolvedDates map[string]time.Time) (time.Time, error) {
// Check if it's a relative expression
if baseAttr, op, offsetStr, isRelative := parseRelativeExpression(valueStr); isRelative {
// Look up the base date
baseDate, exists := resolvedDates[baseAttr]
if !exists || baseDate.IsZero() {
return time.Time{}, fmt.Errorf("cannot reference '%s' in '%s' before it's set", baseAttr, valueStr)
}
// Parse the offset duration
duration, err := ParseRecurrencePattern(offsetStr)
if err != nil {
return time.Time{}, fmt.Errorf("invalid offset in '%s': %w", valueStr, err)
}
// Apply the operation
if op == "+" {
return baseDate.Add(duration), nil
} else {
return baseDate.Add(-duration), nil
}
}
// Not relative, parse as absolute date
return ParseDate(valueStr)
}
// applyDateAttribute applies a date attribute with support for relative expressions
func applyDateAttribute(key string, valuePtr *string, task *Task, resolvedDates map[string]time.Time) error {
if valuePtr == nil {
// Clear the date
switch key {
case "due":
task.Due = nil
case "scheduled":
task.Scheduled = nil
case "wait":
task.Wait = nil
case "until":
task.Until = nil
}
return nil
}
// Resolve the date (may be relative)
parsed, err := resolveDateValue(*valuePtr, resolvedDates)
if err != nil {
return fmt.Errorf("invalid %s date: %w", key, err)
}
// Set the date on the task
switch key {
case "due":
task.Due = &parsed
case "scheduled":
task.Scheduled = &parsed
case "wait":
task.Wait = &parsed
case "until":
task.Until = &parsed
}
// Store in resolved dates for future relative references
resolvedDates[key] = parsed
return nil
}