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:
2026-01-04 14:48:43 +01:00
parent 7c6ec97c62
commit c99a4a2d95
7 changed files with 999 additions and 7 deletions
+63
View File
@@ -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())
}
+189
View File
@@ -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"
}
}
+233
View File
@@ -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")
}
}
+204
View File
@@ -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
}
+271
View File
@@ -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)
}
}
+38 -6
View File
@@ -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)
} }
+1 -1
View File
@@ -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)
} }