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:
@@ -2,11 +2,14 @@ package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Modifier struct {
|
||||
SetAttributes map[string]*string // key -> value (nil = clear)
|
||||
AttributeOrder []string // Track order of attributes for relative references
|
||||
AddTags []string
|
||||
RemoveTags []string
|
||||
}
|
||||
@@ -14,6 +17,7 @@ type Modifier struct {
|
||||
func NewModifier() *Modifier {
|
||||
return &Modifier{
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRelativeDateExpressions(t *testing.T) {
|
||||
task, err := CreateTask("Test task with relative dates")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mods := []string{"due:mon", "wait:due-1d"}
|
||||
modifier, err := ParseModifier(mods)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = modifier.Apply(task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if task.Due == nil || task.Wait == nil {
|
||||
t.Fatal("Due and wait should be set")
|
||||
}
|
||||
|
||||
expectedWait := task.Due.AddDate(0, 0, -1)
|
||||
if !task.Wait.Equal(expectedWait) {
|
||||
t.Errorf("Wait should be 1 day before due. Expected %v, got %v", expectedWait, *task.Wait)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeDateChaining(t *testing.T) {
|
||||
task, err := CreateTask("Test chained relative dates")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mods := []string{"due:mon", "scheduled:due-3d", "wait:scheduled-1d"}
|
||||
modifier, err := ParseModifier(mods)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = modifier.Apply(task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if task.Due == nil || task.Scheduled == nil || task.Wait == nil {
|
||||
t.Fatal("All dates should be set")
|
||||
}
|
||||
|
||||
expectedScheduled := task.Due.AddDate(0, 0, -3)
|
||||
if !task.Scheduled.Equal(expectedScheduled) {
|
||||
t.Errorf("Scheduled should be 3 days before due. Expected %v, got %v", expectedScheduled, *task.Scheduled)
|
||||
}
|
||||
|
||||
expectedWait := task.Scheduled.AddDate(0, 0, -1)
|
||||
if !task.Wait.Equal(expectedWait) {
|
||||
t.Errorf("Wait should be 1 day before scheduled. Expected %v, got %v", expectedWait, *task.Wait)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeDateAddition(t *testing.T) {
|
||||
task, err := CreateTask("Test relative date addition")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mods := []string{"due:today", "until:due+1y"}
|
||||
modifier, err := ParseModifier(mods)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = modifier.Apply(task)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if task.Due == nil || task.Until == nil {
|
||||
t.Fatal("Due and until should be set")
|
||||
}
|
||||
|
||||
expectedUntil := task.Due.AddDate(1, 0, 0)
|
||||
diff := task.Until.Sub(expectedUntil)
|
||||
if diff < -24*time.Hour || diff > 24*time.Hour {
|
||||
t.Errorf("Until should be ~1 year after due. Expected %v, got %v (diff: %v)", expectedUntil, *task.Until, diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeDateReferenceError(t *testing.T) {
|
||||
task, err := CreateTask("Test reference error")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mods := []string{"wait:due-1d"}
|
||||
modifier, err := ParseModifier(mods)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = modifier.Apply(task)
|
||||
if err == nil {
|
||||
t.Error("Should error when referencing unset date")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeDateInNewTask(t *testing.T) {
|
||||
// Test relative dates when creating a new task via CreateTaskWithModifier
|
||||
mods := []string{"due:tomorrow", "wait:due-2d"}
|
||||
modifier, err := ParseModifier(mods)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
task, err := CreateTaskWithModifier("New task with relative dates", modifier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if task.Due == nil || task.Wait == nil {
|
||||
t.Fatal("Due and wait should be set")
|
||||
}
|
||||
|
||||
expectedWait := task.Due.AddDate(0, 0, -2)
|
||||
if !task.Wait.Equal(expectedWait) {
|
||||
t.Errorf("Wait should be 2 days before due. Expected %v, got %v", expectedWait, *task.Wait)
|
||||
}
|
||||
}
|
||||
@@ -271,3 +271,4 @@ func TestModifierWithRecurrence(t *testing.T) {
|
||||
t.Errorf("Expected recurrence %v, got %v", expected, *task.RecurrenceDuration)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user