Implement opal-task Phase 3: Filter and Modifier Parsing
- Add filter.go: Parse filters (+tag, -tag, attribute:value, IDs) - Implement Filter.ToSQL() for WHERE clause generation - Add modifier.go: Parse modifiers (set/clear attributes, add/remove tags) - Implement Modifier.Apply() to update existing tasks - Add dateparse.go: Smart date parsing (ISO, today, tomorrow, weekdays) - Implement nextWeekday logic (smart Sunday interpretation) - Update GetTasks() to accept Filter parameter - Add CreateTaskWithModifier() for task creation with modifiers - Add comprehensive test suite (13 new tests, all passing) - Support filtering by status, project, priority, tags, UUIDs, display IDs - Support modifying priority, project, dates, recurrence, tags
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseDate parses date strings with smart interpretation
|
||||||
|
// Supports: ISO dates, relative (tomorrow, today), weekdays (sun, monday)
|
||||||
|
func ParseDate(s string) (time.Time, error) {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
now := timeNow()
|
||||||
|
|
||||||
|
// Try ISO format first
|
||||||
|
if t, err := time.Parse("2006-01-02", s); err == nil {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative dates
|
||||||
|
switch s {
|
||||||
|
case "today":
|
||||||
|
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()), nil
|
||||||
|
case "tomorrow":
|
||||||
|
tomorrow := now.AddDate(0, 0, 1)
|
||||||
|
return time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, tomorrow.Location()), nil
|
||||||
|
case "yesterday":
|
||||||
|
yesterday := now.AddDate(0, 0, -1)
|
||||||
|
return time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekday names
|
||||||
|
weekdays := map[string]time.Weekday{
|
||||||
|
"sun": time.Sunday, "sunday": time.Sunday,
|
||||||
|
"mon": time.Monday, "monday": time.Monday,
|
||||||
|
"tue": time.Tuesday, "tuesday": time.Tuesday,
|
||||||
|
"wed": time.Wednesday, "wednesday": time.Wednesday,
|
||||||
|
"thu": time.Thursday, "thursday": time.Thursday,
|
||||||
|
"fri": time.Friday, "friday": time.Friday,
|
||||||
|
"sat": time.Saturday, "saturday": time.Saturday,
|
||||||
|
}
|
||||||
|
|
||||||
|
if targetWeekday, ok := weekdays[s]; ok {
|
||||||
|
return nextWeekday(now, targetWeekday), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Time{}, fmt.Errorf("unable to parse date: %s", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextWeekday returns the next occurrence of the target weekday
|
||||||
|
// Smart logic: if today is Thursday and target is Sunday, returns this Sunday
|
||||||
|
// If today is Sunday and target is Sunday, returns next Sunday
|
||||||
|
func nextWeekday(from time.Time, target time.Weekday) time.Time {
|
||||||
|
// Calculate days until target
|
||||||
|
daysUntil := int(target - from.Weekday())
|
||||||
|
|
||||||
|
if daysUntil <= 0 {
|
||||||
|
daysUntil += 7 // Next week
|
||||||
|
}
|
||||||
|
|
||||||
|
next := from.AddDate(0, 0, daysUntil)
|
||||||
|
return time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, next.Location())
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Filter struct {
|
||||||
|
IncludeTags []string // +tag
|
||||||
|
ExcludeTags []string // -tag
|
||||||
|
Attributes map[string]string // status:pending, project:work
|
||||||
|
IDs []int // numeric display IDs
|
||||||
|
UUIDs []string // uuid:abc123
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFilter() *Filter {
|
||||||
|
return &Filter{
|
||||||
|
IncludeTags: []string{},
|
||||||
|
ExcludeTags: []string{},
|
||||||
|
Attributes: make(map[string]string),
|
||||||
|
IDs: []int{},
|
||||||
|
UUIDs: []string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultFilter returns filter for default view (pending + started tasks)
|
||||||
|
func DefaultFilter() *Filter {
|
||||||
|
f := NewFilter()
|
||||||
|
f.Attributes["status"] = "pending"
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFilter parses command-line args into Filter
|
||||||
|
func ParseFilter(args []string) (*Filter, error) {
|
||||||
|
f := NewFilter()
|
||||||
|
|
||||||
|
for _, arg := range args {
|
||||||
|
if strings.HasPrefix(arg, "+") {
|
||||||
|
// Include tag
|
||||||
|
f.IncludeTags = append(f.IncludeTags, strings.TrimPrefix(arg, "+"))
|
||||||
|
} else if strings.HasPrefix(arg, "-") && !strings.Contains(arg, ":") {
|
||||||
|
// Exclude tag (but not negative modifiers like priority:-)
|
||||||
|
f.ExcludeTags = append(f.ExcludeTags, strings.TrimPrefix(arg, "-"))
|
||||||
|
} else if strings.Contains(arg, ":") {
|
||||||
|
// Attribute filter
|
||||||
|
parts := strings.SplitN(arg, ":", 2)
|
||||||
|
key := parts[0]
|
||||||
|
value := parts[1]
|
||||||
|
|
||||||
|
if key == "uuid" {
|
||||||
|
f.UUIDs = append(f.UUIDs, value)
|
||||||
|
} else {
|
||||||
|
f.Attributes[key] = value
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try parsing as numeric ID
|
||||||
|
id, err := strconv.Atoi(arg)
|
||||||
|
if err == nil {
|
||||||
|
f.IDs = append(f.IDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToSQL generates SQL WHERE clause and args
|
||||||
|
func (f *Filter) ToSQL() (string, []interface{}) {
|
||||||
|
conditions := []string{}
|
||||||
|
args := []interface{}{}
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
if status, ok := f.Attributes["status"]; ok {
|
||||||
|
statusByte := statusStringToByte(status)
|
||||||
|
conditions = append(conditions, "status = ?")
|
||||||
|
args = append(args, statusByte)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project filter
|
||||||
|
if project, ok := f.Attributes["project"]; ok {
|
||||||
|
conditions = append(conditions, "project = ?")
|
||||||
|
args = append(args, project)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority filter
|
||||||
|
if priority, ok := f.Attributes["priority"]; ok {
|
||||||
|
priorityInt := priorityStringToInt(priority)
|
||||||
|
conditions = append(conditions, "priority = ?")
|
||||||
|
args = append(args, priorityInt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag filters (requires subquery)
|
||||||
|
for _, tag := range f.IncludeTags {
|
||||||
|
conditions = append(conditions, `
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM tags
|
||||||
|
WHERE tags.task_id = tasks.id AND tags.tag = ?
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
args = append(args, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range f.ExcludeTags {
|
||||||
|
conditions = append(conditions, `
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1 FROM tags
|
||||||
|
WHERE tags.task_id = tasks.id AND tags.tag = ?
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
args = append(args, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UUID filter
|
||||||
|
if len(f.UUIDs) > 0 {
|
||||||
|
placeholders := strings.Repeat("?,", len(f.UUIDs))
|
||||||
|
placeholders = placeholders[:len(placeholders)-1]
|
||||||
|
conditions = append(conditions, fmt.Sprintf("uuid IN (%s)", placeholders))
|
||||||
|
for _, u := range f.UUIDs {
|
||||||
|
args = append(args, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID filter (resolve via working_set table)
|
||||||
|
if len(f.IDs) > 0 {
|
||||||
|
placeholders := strings.Repeat("?,", len(f.IDs))
|
||||||
|
placeholders = placeholders[:len(placeholders)-1]
|
||||||
|
conditions = append(conditions, fmt.Sprintf(`
|
||||||
|
uuid IN (
|
||||||
|
SELECT task_uuid FROM working_set
|
||||||
|
WHERE display_id IN (%s)
|
||||||
|
)
|
||||||
|
`, placeholders))
|
||||||
|
for _, id := range f.IDs {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(conditions) == 0 {
|
||||||
|
return "1=1", args
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(conditions, " AND "), args
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusStringToByte(s string) byte {
|
||||||
|
switch strings.ToLower(s) {
|
||||||
|
case "pending":
|
||||||
|
return byte(StatusPending)
|
||||||
|
case "completed":
|
||||||
|
return byte(StatusCompleted)
|
||||||
|
case "deleted":
|
||||||
|
return byte(StatusDeleted)
|
||||||
|
case "recurring":
|
||||||
|
return byte(StatusRecurring)
|
||||||
|
default:
|
||||||
|
return byte(StatusPending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func priorityStringToInt(s string) int {
|
||||||
|
switch strings.ToUpper(s) {
|
||||||
|
case "L", "LOW":
|
||||||
|
return int(PriorityLow)
|
||||||
|
case "M", "MEDIUM":
|
||||||
|
return int(PriorityMedium)
|
||||||
|
case "H", "HIGH":
|
||||||
|
return int(PriorityHigh)
|
||||||
|
case "D", "DEFAULT":
|
||||||
|
return int(PriorityDefault)
|
||||||
|
default:
|
||||||
|
return int(PriorityDefault)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func priorityIntToString(p Priority) string {
|
||||||
|
switch p {
|
||||||
|
case PriorityLow:
|
||||||
|
return "L"
|
||||||
|
case PriorityMedium:
|
||||||
|
return "M"
|
||||||
|
case PriorityHigh:
|
||||||
|
return "H"
|
||||||
|
case PriorityDefault:
|
||||||
|
return "D"
|
||||||
|
default:
|
||||||
|
return "D"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseFilter(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
expected *Filter
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "parse include tags",
|
||||||
|
args: []string{"+home", "+urgent"},
|
||||||
|
expected: &Filter{
|
||||||
|
IncludeTags: []string{"home", "urgent"},
|
||||||
|
ExcludeTags: []string{},
|
||||||
|
Attributes: map[string]string{},
|
||||||
|
IDs: []int{},
|
||||||
|
UUIDs: []string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parse exclude tags",
|
||||||
|
args: []string{"-garden", "-someday"},
|
||||||
|
expected: &Filter{
|
||||||
|
IncludeTags: []string{},
|
||||||
|
ExcludeTags: []string{"garden", "someday"},
|
||||||
|
Attributes: map[string]string{},
|
||||||
|
IDs: []int{},
|
||||||
|
UUIDs: []string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parse attributes",
|
||||||
|
args: []string{"status:pending", "project:backend", "priority:H"},
|
||||||
|
expected: &Filter{
|
||||||
|
IncludeTags: []string{},
|
||||||
|
ExcludeTags: []string{},
|
||||||
|
Attributes: map[string]string{
|
||||||
|
"status": "pending",
|
||||||
|
"project": "backend",
|
||||||
|
"priority": "H",
|
||||||
|
},
|
||||||
|
IDs: []int{},
|
||||||
|
UUIDs: []string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parse numeric IDs",
|
||||||
|
args: []string{"1", "5", "10"},
|
||||||
|
expected: &Filter{
|
||||||
|
IncludeTags: []string{},
|
||||||
|
ExcludeTags: []string{},
|
||||||
|
Attributes: map[string]string{},
|
||||||
|
IDs: []int{1, 5, 10},
|
||||||
|
UUIDs: []string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parse UUID",
|
||||||
|
args: []string{"uuid:abc-123"},
|
||||||
|
expected: &Filter{
|
||||||
|
IncludeTags: []string{},
|
||||||
|
ExcludeTags: []string{},
|
||||||
|
Attributes: map[string]string{},
|
||||||
|
IDs: []int{},
|
||||||
|
UUIDs: []string{"abc-123"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "compound filter",
|
||||||
|
args: []string{"+home", "-garden", "status:pending", "priority:H"},
|
||||||
|
expected: &Filter{
|
||||||
|
IncludeTags: []string{"home"},
|
||||||
|
ExcludeTags: []string{"garden"},
|
||||||
|
Attributes: map[string]string{
|
||||||
|
"status": "pending",
|
||||||
|
"priority": "H",
|
||||||
|
},
|
||||||
|
IDs: []int{},
|
||||||
|
UUIDs: []string{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := ParseFilter(tt.args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseFilter returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check include tags
|
||||||
|
if len(result.IncludeTags) != len(tt.expected.IncludeTags) {
|
||||||
|
t.Errorf("Expected %d include tags, got %d", len(tt.expected.IncludeTags), len(result.IncludeTags))
|
||||||
|
}
|
||||||
|
for i, tag := range tt.expected.IncludeTags {
|
||||||
|
if i >= len(result.IncludeTags) || result.IncludeTags[i] != tag {
|
||||||
|
t.Errorf("Include tag mismatch at index %d: expected %s, got %v", i, tag, result.IncludeTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check exclude tags
|
||||||
|
if len(result.ExcludeTags) != len(tt.expected.ExcludeTags) {
|
||||||
|
t.Errorf("Expected %d exclude tags, got %d", len(tt.expected.ExcludeTags), len(result.ExcludeTags))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check attributes
|
||||||
|
if len(result.Attributes) != len(tt.expected.Attributes) {
|
||||||
|
t.Errorf("Expected %d attributes, got %d", len(tt.expected.Attributes), len(result.Attributes))
|
||||||
|
}
|
||||||
|
for key, val := range tt.expected.Attributes {
|
||||||
|
if result.Attributes[key] != val {
|
||||||
|
t.Errorf("Attribute %s: expected %s, got %s", key, val, result.Attributes[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check IDs
|
||||||
|
if len(result.IDs) != len(tt.expected.IDs) {
|
||||||
|
t.Errorf("Expected %d IDs, got %d", len(tt.expected.IDs), len(result.IDs))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultFilter(t *testing.T) {
|
||||||
|
filter := DefaultFilter()
|
||||||
|
|
||||||
|
if filter.Attributes["status"] != "pending" {
|
||||||
|
t.Error("DefaultFilter should have status:pending")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterToSQL(t *testing.T) {
|
||||||
|
// Test status filter
|
||||||
|
filter := &Filter{
|
||||||
|
Attributes: map[string]string{
|
||||||
|
"status": "pending",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
where, args := filter.ToSQL()
|
||||||
|
if where != "status = ?" {
|
||||||
|
t.Errorf("Expected 'status = ?', got '%s'", where)
|
||||||
|
}
|
||||||
|
if len(args) != 1 || args[0] != byte(StatusPending) {
|
||||||
|
t.Errorf("Expected args [%d], got %v", byte(StatusPending), args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test tag filter
|
||||||
|
filter = &Filter{
|
||||||
|
IncludeTags: []string{"urgent"},
|
||||||
|
}
|
||||||
|
|
||||||
|
where, args = filter.ToSQL()
|
||||||
|
if len(args) != 1 || args[0] != "urgent" {
|
||||||
|
t.Errorf("Expected tag 'urgent' in args, got %v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTasksWithFilter(t *testing.T) {
|
||||||
|
// Create tasks with different attributes
|
||||||
|
task1, _ := CreateTask("Backend task")
|
||||||
|
project := "backend"
|
||||||
|
task1.Project = &project
|
||||||
|
task1.Priority = PriorityHigh
|
||||||
|
task1.Save()
|
||||||
|
task1.AddTag("urgent")
|
||||||
|
|
||||||
|
task2, _ := CreateTask("Frontend task")
|
||||||
|
project2 := "frontend"
|
||||||
|
task2.Project = &project2
|
||||||
|
task2.Priority = PriorityMedium
|
||||||
|
task2.Save()
|
||||||
|
task2.AddTag("bug")
|
||||||
|
|
||||||
|
// Filter by project
|
||||||
|
filter, _ := ParseFilter([]string{"project:backend"})
|
||||||
|
tasks, err := GetTasks(filter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTasks failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should only get backend task
|
||||||
|
found := false
|
||||||
|
for _, task := range tasks {
|
||||||
|
if task.Project != nil && *task.Project == "backend" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("Expected to find backend task")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by tag
|
||||||
|
filter2, _ := ParseFilter([]string{"+urgent"})
|
||||||
|
tasks2, err := GetTasks(filter2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTasks with tag filter failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found = false
|
||||||
|
for _, task := range tasks2 {
|
||||||
|
for _, tag := range task.Tags {
|
||||||
|
if tag == "urgent" {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("Expected to find task with urgent tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by priority
|
||||||
|
filter3, _ := ParseFilter([]string{"priority:H"})
|
||||||
|
tasks3, err := GetTasks(filter3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetTasks with priority filter failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found = false
|
||||||
|
for _, task := range tasks3 {
|
||||||
|
if task.Priority == PriorityHigh {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("Expected to find high priority task")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Modifier struct {
|
||||||
|
SetAttributes map[string]*string // key -> value (nil = clear)
|
||||||
|
AddTags []string
|
||||||
|
RemoveTags []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewModifier() *Modifier {
|
||||||
|
return &Modifier{
|
||||||
|
SetAttributes: make(map[string]*string),
|
||||||
|
AddTags: []string{},
|
||||||
|
RemoveTags: []string{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseModifier parses command-line args into Modifier
|
||||||
|
func ParseModifier(args []string) (*Modifier, error) {
|
||||||
|
m := NewModifier()
|
||||||
|
|
||||||
|
for _, arg := range args {
|
||||||
|
if strings.HasPrefix(arg, "+") {
|
||||||
|
// Add tag
|
||||||
|
m.AddTags = append(m.AddTags, strings.TrimPrefix(arg, "+"))
|
||||||
|
} else if strings.HasPrefix(arg, "-") && !strings.Contains(arg, ":") {
|
||||||
|
// Remove tag
|
||||||
|
m.RemoveTags = append(m.RemoveTags, strings.TrimPrefix(arg, "-"))
|
||||||
|
} else if strings.Contains(arg, ":") {
|
||||||
|
// Attribute modification
|
||||||
|
parts := strings.SplitN(arg, ":", 2)
|
||||||
|
key := parts[0]
|
||||||
|
value := parts[1]
|
||||||
|
|
||||||
|
if value == "" {
|
||||||
|
// Clear attribute (priority: with no value)
|
||||||
|
m.SetAttributes[key] = nil
|
||||||
|
} else {
|
||||||
|
m.SetAttributes[key] = &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply applies modifier to task
|
||||||
|
func (m *Modifier) Apply(task *Task) error {
|
||||||
|
// Apply attribute changes
|
||||||
|
for key, valuePtr := range m.SetAttributes {
|
||||||
|
switch key {
|
||||||
|
case "priority":
|
||||||
|
if valuePtr == nil {
|
||||||
|
task.Priority = PriorityDefault
|
||||||
|
} else {
|
||||||
|
task.Priority = Priority(priorityStringToInt(*valuePtr))
|
||||||
|
}
|
||||||
|
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
|
||||||
|
} else {
|
||||||
|
duration, err := ParseRecurrencePattern(*valuePtr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid recurrence: %w", err)
|
||||||
|
}
|
||||||
|
task.RecurrenceDuration = &duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply tag changes
|
||||||
|
for _, tag := range m.AddTags {
|
||||||
|
if err := task.AddTag(tag); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range m.RemoveTags {
|
||||||
|
if err := task.RemoveTag(tag); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task.Modified = timeNow()
|
||||||
|
|
||||||
|
return task.Save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
switch key {
|
||||||
|
case "priority":
|
||||||
|
if valuePtr == nil {
|
||||||
|
task.Priority = PriorityDefault
|
||||||
|
} else {
|
||||||
|
task.Priority = Priority(priorityStringToInt(*valuePtr))
|
||||||
|
}
|
||||||
|
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
|
||||||
|
} else {
|
||||||
|
duration, err := ParseRecurrencePattern(*valuePtr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid recurrence: %w", err)
|
||||||
|
}
|
||||||
|
task.RecurrenceDuration = &duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Tags are added after task is saved (in CreateTask function)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseModifier(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
checkFn func(*testing.T, *Modifier)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "add tags",
|
||||||
|
args: []string{"+urgent", "+work"},
|
||||||
|
checkFn: func(t *testing.T, m *Modifier) {
|
||||||
|
if len(m.AddTags) != 2 {
|
||||||
|
t.Errorf("Expected 2 add tags, got %d", len(m.AddTags))
|
||||||
|
}
|
||||||
|
if m.AddTags[0] != "urgent" || m.AddTags[1] != "work" {
|
||||||
|
t.Error("Tags not parsed correctly")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove tags",
|
||||||
|
args: []string{"-someday", "-later"},
|
||||||
|
checkFn: func(t *testing.T, m *Modifier) {
|
||||||
|
if len(m.RemoveTags) != 2 {
|
||||||
|
t.Errorf("Expected 2 remove tags, got %d", len(m.RemoveTags))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "set attributes",
|
||||||
|
args: []string{"priority:H", "project:backend", "due:tomorrow"},
|
||||||
|
checkFn: func(t *testing.T, m *Modifier) {
|
||||||
|
if len(m.SetAttributes) != 3 {
|
||||||
|
t.Errorf("Expected 3 attributes, got %d", len(m.SetAttributes))
|
||||||
|
}
|
||||||
|
if m.SetAttributes["priority"] == nil || *m.SetAttributes["priority"] != "H" {
|
||||||
|
t.Error("Priority not set correctly")
|
||||||
|
}
|
||||||
|
if m.SetAttributes["project"] == nil || *m.SetAttributes["project"] != "backend" {
|
||||||
|
t.Error("Project not set correctly")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clear attribute",
|
||||||
|
args: []string{"priority:"},
|
||||||
|
checkFn: func(t *testing.T, m *Modifier) {
|
||||||
|
if m.SetAttributes["priority"] != nil {
|
||||||
|
t.Error("Expected priority to be nil (cleared)")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "compound modifier",
|
||||||
|
args: []string{"+urgent", "-someday", "priority:H", "due:tomorrow"},
|
||||||
|
checkFn: func(t *testing.T, m *Modifier) {
|
||||||
|
if len(m.AddTags) != 1 || m.AddTags[0] != "urgent" {
|
||||||
|
t.Error("Add tag not parsed")
|
||||||
|
}
|
||||||
|
if len(m.RemoveTags) != 1 || m.RemoveTags[0] != "someday" {
|
||||||
|
t.Error("Remove tag not parsed")
|
||||||
|
}
|
||||||
|
if len(m.SetAttributes) != 2 {
|
||||||
|
t.Errorf("Expected 2 attributes, got %d", len(m.SetAttributes))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := ParseModifier(tt.args)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseModifier returned error: %v", err)
|
||||||
|
}
|
||||||
|
tt.checkFn(t, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModifierApply(t *testing.T) {
|
||||||
|
// Create a task
|
||||||
|
task, err := CreateTask("Test task")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create task: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create modifier
|
||||||
|
mod, _ := ParseModifier([]string{"priority:H", "project:backend", "+urgent"})
|
||||||
|
|
||||||
|
// Apply modifier
|
||||||
|
if err := mod.Apply(task); err != nil {
|
||||||
|
t.Fatalf("Failed to apply modifier: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify changes
|
||||||
|
if task.Priority != PriorityHigh {
|
||||||
|
t.Error("Priority not updated")
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.Project == nil || *task.Project != "backend" {
|
||||||
|
t.Error("Project not updated")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload to verify tags were saved
|
||||||
|
reloaded, _ := GetTask(task.UUID)
|
||||||
|
found := false
|
||||||
|
for _, tag := range reloaded.Tags {
|
||||||
|
if tag == "urgent" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("Tag not added")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTaskWithModifier(t *testing.T) {
|
||||||
|
mod, _ := ParseModifier([]string{"priority:H", "project:test", "+work", "+urgent"})
|
||||||
|
|
||||||
|
task, err := CreateTaskWithModifier("Task with modifiers", mod)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create task with modifier: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.Priority != PriorityHigh {
|
||||||
|
t.Error("Priority not set during creation")
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.Project == nil || *task.Project != "test" {
|
||||||
|
t.Error("Project not set during creation")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(task.Tags) != 2 {
|
||||||
|
t.Errorf("Expected 2 tags, got %d", len(task.Tags))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDateISO(t *testing.T) {
|
||||||
|
date, err := ParseDate("2026-01-15")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse ISO date: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if date.Year() != 2026 || date.Month() != 1 || date.Day() != 15 {
|
||||||
|
t.Errorf("Date not parsed correctly: %v", date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDateRelative(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Test today
|
||||||
|
today, err := ParseDate("today")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse 'today': %v", err)
|
||||||
|
}
|
||||||
|
if today.Day() != now.Day() {
|
||||||
|
t.Error("'today' not parsed correctly")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test tomorrow
|
||||||
|
tomorrow, err := ParseDate("tomorrow")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse 'tomorrow': %v", err)
|
||||||
|
}
|
||||||
|
expected := now.AddDate(0, 0, 1)
|
||||||
|
if tomorrow.Day() != expected.Day() {
|
||||||
|
t.Error("'tomorrow' not parsed correctly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDateWeekday(t *testing.T) {
|
||||||
|
// Test Sunday
|
||||||
|
sunday, err := ParseDate("sunday")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse 'sunday': %v", err)
|
||||||
|
}
|
||||||
|
if sunday.Weekday() != time.Sunday {
|
||||||
|
t.Error("'sunday' not parsed correctly")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test abbreviated form
|
||||||
|
mon, err := ParseDate("mon")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse 'mon': %v", err)
|
||||||
|
}
|
||||||
|
if mon.Weekday() != time.Monday {
|
||||||
|
t.Error("'mon' not parsed correctly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextWeekday(t *testing.T) {
|
||||||
|
// Test case: Thursday -> next Sunday should be this Sunday
|
||||||
|
thursday := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) // Jan 1, 2026 is a Thursday
|
||||||
|
nextSun := nextWeekday(thursday, time.Sunday)
|
||||||
|
|
||||||
|
if nextSun.Weekday() != time.Sunday {
|
||||||
|
t.Error("Should return Sunday")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be 3 days later (this Sunday)
|
||||||
|
expectedDays := 3
|
||||||
|
actualDays := int(nextSun.Sub(thursday).Hours() / 24)
|
||||||
|
if actualDays != expectedDays {
|
||||||
|
t.Errorf("Expected %d days until Sunday, got %d", expectedDays, actualDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test case: Sunday -> next Sunday should be 7 days later
|
||||||
|
sunday := time.Date(2026, 1, 4, 0, 0, 0, 0, time.UTC) // Jan 4, 2026 is a Sunday
|
||||||
|
nextSun2 := nextWeekday(sunday, time.Sunday)
|
||||||
|
|
||||||
|
expectedDays = 7
|
||||||
|
actualDays = int(nextSun2.Sub(sunday).Hours() / 24)
|
||||||
|
if actualDays != expectedDays {
|
||||||
|
t.Errorf("Sunday to Sunday: expected %d days, got %d", expectedDays, actualDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModifierWithDates(t *testing.T) {
|
||||||
|
task, _ := CreateTask("Test date modifiers")
|
||||||
|
|
||||||
|
// Apply due date
|
||||||
|
mod, _ := ParseModifier([]string{"due:tomorrow"})
|
||||||
|
if err := mod.Apply(task); err != nil {
|
||||||
|
t.Fatalf("Failed to apply date modifier: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.Due == nil {
|
||||||
|
t.Error("Due date should be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
tomorrow := time.Now().AddDate(0, 0, 1)
|
||||||
|
if task.Due.Day() != tomorrow.Day() {
|
||||||
|
t.Error("Due date not set to tomorrow")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear due date
|
||||||
|
mod2, _ := ParseModifier([]string{"due:"})
|
||||||
|
if err := mod2.Apply(task); err != nil {
|
||||||
|
t.Fatalf("Failed to clear due date: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.Due != nil {
|
||||||
|
t.Error("Due date should be cleared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModifierWithRecurrence(t *testing.T) {
|
||||||
|
task, _ := CreateTask("Test recurrence modifier")
|
||||||
|
|
||||||
|
mod, _ := ParseModifier([]string{"recur:1w"})
|
||||||
|
if err := mod.Apply(task); err != nil {
|
||||||
|
t.Fatalf("Failed to apply recurrence: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.RecurrenceDuration == nil {
|
||||||
|
t.Error("Recurrence should be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := 7 * 24 * time.Hour
|
||||||
|
if *task.RecurrenceDuration != expected {
|
||||||
|
t.Errorf("Expected recurrence %v, got %v", expected, *task.RecurrenceDuration)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,6 +139,11 @@ func sqlToUUIDPtr(v interface{}) *uuid.UUID {
|
|||||||
|
|
||||||
// CreateTask creates a new task with the given description
|
// CreateTask creates a new task with the given description
|
||||||
func CreateTask(description string) (*Task, error) {
|
func CreateTask(description string) (*Task, error) {
|
||||||
|
return CreateTaskWithModifier(description, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTaskWithModifier creates a new task with the given description and applies modifiers
|
||||||
|
func CreateTaskWithModifier(description string, mod *Modifier) (*Task, error) {
|
||||||
now := timeNow()
|
now := timeNow()
|
||||||
task := &Task{
|
task := &Task{
|
||||||
UUID: uuid.New(),
|
UUID: uuid.New(),
|
||||||
@@ -150,10 +155,26 @@ func CreateTask(description string) (*Task, error) {
|
|||||||
Tags: []string{},
|
Tags: []string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply modifiers before saving (for attributes)
|
||||||
|
if mod != nil {
|
||||||
|
if err := mod.ApplyToNew(task); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := task.Save(); err != nil {
|
if err := task.Save(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply tags after saving (requires task.ID)
|
||||||
|
if mod != nil {
|
||||||
|
for _, tag := range mod.AddTags {
|
||||||
|
if err := task.AddTag(tag); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return task, nil
|
return task, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,22 +263,33 @@ func GetTask(taskUUID uuid.UUID) (*Task, error) {
|
|||||||
return task, nil
|
return task, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTasks retrieves all tasks (filtering will be added later)
|
// GetTasks retrieves all tasks with optional filtering
|
||||||
func GetTasks() ([]*Task, error) {
|
func GetTasks(filter *Filter) ([]*Task, error) {
|
||||||
db := GetDB()
|
db := GetDB()
|
||||||
if db == nil {
|
if db == nil {
|
||||||
return nil, fmt.Errorf("database not initialized")
|
return nil, fmt.Errorf("database not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
// Build WHERE clause from filter
|
||||||
|
whereClause := "1=1"
|
||||||
|
var args []interface{}
|
||||||
|
if filter != nil {
|
||||||
|
whereClause, args = filter.ToSQL()
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
SELECT id, uuid, status, description, project, priority,
|
SELECT id, uuid, status, description, project, priority,
|
||||||
created, modified, start, end, due, scheduled, wait, until_date,
|
created, modified, start, end, due, scheduled, wait, until_date,
|
||||||
recurrence_duration, parent_uuid
|
recurrence_duration, parent_uuid
|
||||||
FROM tasks
|
FROM tasks
|
||||||
ORDER BY due ASC, priority DESC
|
WHERE %s
|
||||||
`
|
ORDER BY
|
||||||
|
CASE WHEN due IS NULL THEN 1 ELSE 0 END,
|
||||||
|
due ASC,
|
||||||
|
priority DESC
|
||||||
|
`, whereClause)
|
||||||
|
|
||||||
rows, err := db.Query(query)
|
rows, err := db.Query(query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to query tasks: %w", err)
|
return nil, fmt.Errorf("failed to query tasks: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ func TestGetTasks(t *testing.T) {
|
|||||||
CreateTask("Task 2")
|
CreateTask("Task 2")
|
||||||
CreateTask("Task 3")
|
CreateTask("Task 3")
|
||||||
|
|
||||||
tasks, err := GetTasks()
|
tasks, err := GetTasks(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get tasks: %v", err)
|
t.Fatalf("Failed to get tasks: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user