Files
gems/opal-task/internal/engine/task.go
T
joakim c99a4a2d95 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
2026-01-04 14:48:43 +01:00

617 lines
12 KiB
Go

package engine
import (
"fmt"
"time"
"github.com/google/uuid"
)
type Status byte
const (
StatusPending Status = 'P'
StatusCompleted Status = 'C'
StatusDeleted Status = 'D'
StatusRecurring Status = 'R'
)
type Priority int
const (
PriorityLow Priority = 0
PriorityDefault Priority = 1
PriorityMedium Priority = 2
PriorityHigh Priority = 3
)
type Task struct {
// Identity
UUID uuid.UUID
ID int
// Core fields
Status Status
Description string
Project *string
Priority Priority
// Timestamps
Created time.Time
Modified time.Time
Start *time.Time
End *time.Time
Due *time.Time
Scheduled *time.Time
Wait *time.Time
Until *time.Time
// Recurrence (parent-child approach)
RecurrenceDuration *time.Duration
ParentUUID *uuid.UUID
// Derived fields (not stored in DB)
Tags []string
}
// timeNow returns current time (allows mocking in tests)
var timeNow = time.Now
// Helper functions for SQL time conversion
func timeToSQL(t *time.Time) interface{} {
if t == nil {
return nil
}
return t.Unix()
}
func sqlToTime(v interface{}) *time.Time {
if v == nil {
return nil
}
unix, ok := v.(int64)
if !ok {
return nil
}
t := time.Unix(unix, 0)
return &t
}
func durationToSQL(d *time.Duration) interface{} {
if d == nil {
return nil
}
return int64(*d)
}
func sqlToDuration(v interface{}) *time.Duration {
if v == nil {
return nil
}
nanos, ok := v.(int64)
if !ok {
return nil
}
d := time.Duration(nanos)
return &d
}
func stringPtrToSQL(s *string) interface{} {
if s == nil {
return nil
}
return *s
}
func sqlToStringPtr(v interface{}) *string {
if v == nil {
return nil
}
str, ok := v.(string)
if !ok {
return nil
}
return &str
}
func uuidPtrToSQL(u *uuid.UUID) interface{} {
if u == nil {
return nil
}
return u.String()
}
func sqlToUUIDPtr(v interface{}) *uuid.UUID {
if v == nil {
return nil
}
str, ok := v.(string)
if !ok {
return nil
}
u, err := uuid.Parse(str)
if err != nil {
return nil
}
return &u
}
// CreateTask creates a new task with the given description
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()
task := &Task{
UUID: uuid.New(),
Status: StatusPending,
Description: description,
Priority: PriorityDefault,
Created: now,
Modified: now,
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 {
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
}
// GetTask retrieves a task by UUID
func GetTask(taskUUID uuid.UUID) (*Task, error) {
db := GetDB()
if db == nil {
return nil, fmt.Errorf("database not initialized")
}
query := `
SELECT id, uuid, status, description, project, priority,
created, modified, start, end, due, scheduled, wait, until_date,
recurrence_duration, parent_uuid
FROM tasks
WHERE uuid = ?
`
task := &Task{}
var (
uuidStr string
project interface{}
created int64
modified int64
start interface{}
end interface{}
due interface{}
scheduled interface{}
wait interface{}
until interface{}
recurDuration interface{}
parentUUIDStr interface{}
)
err := db.QueryRow(query, taskUUID.String()).Scan(
&task.ID,
&uuidStr,
&task.Status,
&task.Description,
&project,
&task.Priority,
&created,
&modified,
&start,
&end,
&due,
&scheduled,
&wait,
&until,
&recurDuration,
&parentUUIDStr,
)
if err != nil {
return nil, fmt.Errorf("failed to get task: %w", err)
}
// Parse UUID
task.UUID, err = uuid.Parse(uuidStr)
if err != nil {
return nil, fmt.Errorf("failed to parse UUID: %w", err)
}
// Convert timestamps
task.Created = time.Unix(created, 0)
task.Modified = time.Unix(modified, 0)
// Convert nullable fields
task.Project = sqlToStringPtr(project)
task.Start = sqlToTime(start)
task.End = sqlToTime(end)
task.Due = sqlToTime(due)
task.Scheduled = sqlToTime(scheduled)
task.Wait = sqlToTime(wait)
task.Until = sqlToTime(until)
task.RecurrenceDuration = sqlToDuration(recurDuration)
task.ParentUUID = sqlToUUIDPtr(parentUUIDStr)
// Load tags
tags, err := task.GetTags()
if err != nil {
return nil, fmt.Errorf("failed to load tags: %w", err)
}
task.Tags = tags
return task, nil
}
// GetTasks retrieves all tasks with optional filtering
func GetTasks(filter *Filter) ([]*Task, error) {
db := GetDB()
if db == nil {
return nil, fmt.Errorf("database not initialized")
}
// 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,
created, modified, start, end, due, scheduled, wait, until_date,
recurrence_duration, parent_uuid
FROM tasks
WHERE %s
ORDER BY
CASE WHEN due IS NULL THEN 1 ELSE 0 END,
due ASC,
priority DESC
`, whereClause)
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("failed to query tasks: %w", err)
}
defer rows.Close()
tasks := []*Task{}
for rows.Next() {
task := &Task{}
var (
uuidStr string
project interface{}
created int64
modified int64
start interface{}
end interface{}
due interface{}
scheduled interface{}
wait interface{}
until interface{}
recurDuration interface{}
parentUUIDStr interface{}
)
err := rows.Scan(
&task.ID,
&uuidStr,
&task.Status,
&task.Description,
&project,
&task.Priority,
&created,
&modified,
&start,
&end,
&due,
&scheduled,
&wait,
&until,
&recurDuration,
&parentUUIDStr,
)
if err != nil {
return nil, fmt.Errorf("failed to scan task: %w", err)
}
// Parse UUID
task.UUID, err = uuid.Parse(uuidStr)
if err != nil {
return nil, fmt.Errorf("failed to parse UUID: %w", err)
}
// Convert timestamps
task.Created = time.Unix(created, 0)
task.Modified = time.Unix(modified, 0)
// Convert nullable fields
task.Project = sqlToStringPtr(project)
task.Start = sqlToTime(start)
task.End = sqlToTime(end)
task.Due = sqlToTime(due)
task.Scheduled = sqlToTime(scheduled)
task.Wait = sqlToTime(wait)
task.Until = sqlToTime(until)
task.RecurrenceDuration = sqlToDuration(recurDuration)
task.ParentUUID = sqlToUUIDPtr(parentUUIDStr)
// Load tags
tags, err := task.GetTags()
if err != nil {
return nil, fmt.Errorf("failed to load tags: %w", err)
}
task.Tags = tags
tasks = append(tasks, task)
}
return tasks, nil
}
// Save inserts or updates the task in the database
func (t *Task) Save() error {
db := GetDB()
if db == nil {
return fmt.Errorf("database not initialized")
}
t.Modified = timeNow()
// Check if task exists
var exists bool
err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM tasks WHERE uuid = ?)", t.UUID.String()).Scan(&exists)
if err != nil {
return fmt.Errorf("failed to check if task exists: %w", err)
}
if exists {
// Update existing task
query := `
UPDATE tasks SET
status = ?, description = ?, project = ?, priority = ?,
modified = ?, start = ?, end = ?, due = ?,
scheduled = ?, wait = ?, until_date = ?,
recurrence_duration = ?, parent_uuid = ?
WHERE uuid = ?
`
_, err = db.Exec(query,
t.Status,
t.Description,
stringPtrToSQL(t.Project),
t.Priority,
t.Modified.Unix(),
timeToSQL(t.Start),
timeToSQL(t.End),
timeToSQL(t.Due),
timeToSQL(t.Scheduled),
timeToSQL(t.Wait),
timeToSQL(t.Until),
durationToSQL(t.RecurrenceDuration),
uuidPtrToSQL(t.ParentUUID),
t.UUID.String(),
)
if err != nil {
return fmt.Errorf("failed to update task: %w", err)
}
} else {
// Insert new task
query := `
INSERT INTO tasks (
uuid, status, description, project, priority,
created, modified, start, end, due, scheduled, wait, until_date,
recurrence_duration, parent_uuid
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
result, err := db.Exec(query,
t.UUID.String(),
t.Status,
t.Description,
stringPtrToSQL(t.Project),
t.Priority,
t.Created.Unix(),
t.Modified.Unix(),
timeToSQL(t.Start),
timeToSQL(t.End),
timeToSQL(t.Due),
timeToSQL(t.Scheduled),
timeToSQL(t.Wait),
timeToSQL(t.Until),
durationToSQL(t.RecurrenceDuration),
uuidPtrToSQL(t.ParentUUID),
)
if err != nil {
return fmt.Errorf("failed to insert task: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert id: %w", err)
}
t.ID = int(id)
}
return nil
}
// AddTag adds a tag to the task
func (t *Task) AddTag(tag string) error {
db := GetDB()
if db == nil {
return fmt.Errorf("database not initialized")
}
// Ensure task has been saved and has an ID
if t.ID == 0 {
return fmt.Errorf("task must be saved before adding tags")
}
_, err := db.Exec("INSERT OR IGNORE INTO tags (task_id, tag) VALUES (?, ?)", t.ID, tag)
if err != nil {
return fmt.Errorf("failed to add tag: %w", err)
}
// Update in-memory tags
for _, existingTag := range t.Tags {
if existingTag == tag {
return nil // Already exists
}
}
t.Tags = append(t.Tags, tag)
return nil
}
// RemoveTag removes a tag from the task
func (t *Task) RemoveTag(tag string) error {
db := GetDB()
if db == nil {
return fmt.Errorf("database not initialized")
}
if t.ID == 0 {
return fmt.Errorf("task must be saved before removing tags")
}
_, err := db.Exec("DELETE FROM tags WHERE task_id = ? AND tag = ?", t.ID, tag)
if err != nil {
return fmt.Errorf("failed to remove tag: %w", err)
}
// Update in-memory tags
newTags := []string{}
for _, existingTag := range t.Tags {
if existingTag != tag {
newTags = append(newTags, existingTag)
}
}
t.Tags = newTags
return nil
}
// GetTags retrieves all tags for the task
func (t *Task) GetTags() ([]string, error) {
db := GetDB()
if db == nil {
return nil, fmt.Errorf("database not initialized")
}
if t.ID == 0 {
return []string{}, nil
}
rows, err := db.Query("SELECT tag FROM tags WHERE task_id = ? ORDER BY tag", t.ID)
if err != nil {
return nil, fmt.Errorf("failed to get tags: %w", err)
}
defer rows.Close()
tags := []string{}
for rows.Next() {
var tag string
if err := rows.Scan(&tag); err != nil {
return nil, fmt.Errorf("failed to scan tag: %w", err)
}
tags = append(tags, tag)
}
return tags, nil
}
// Complete marks a task as completed
func (t *Task) Complete() error {
t.Status = StatusCompleted
now := timeNow()
t.End = &now
if err := t.Save(); err != nil {
return err
}
// If this is a recurring instance, spawn next instance
if t.ParentUUID != nil {
if err := SpawnNextInstance(t); err != nil {
// Log error but don't fail the completion
return fmt.Errorf("completed task but failed to spawn next instance: %w", err)
}
}
return nil
}
// Delete marks a task as deleted (soft delete)
func (t *Task) Delete(permanent bool) error {
db := GetDB()
if db == nil {
return fmt.Errorf("database not initialized")
}
if permanent {
// Hard delete - remove from database
_, err := db.Exec("DELETE FROM tasks WHERE uuid = ?", t.UUID.String())
if err != nil {
return fmt.Errorf("failed to delete task: %w", err)
}
} else {
// Soft delete - mark as deleted
t.Status = StatusDeleted
now := timeNow()
t.End = &now
if err := t.Save(); err != nil {
return err
}
}
return nil
}
// StartTask sets the start time for a task
func (t *Task) StartTask() error {
now := timeNow()
t.Start = &now
return t.Save()
}
// StopTask clears the start time for a task
func (t *Task) StopTask() error {
t.Start = nil
return t.Save()
}
// IsRecurringTemplate returns true if task is a recurring template
func (t *Task) IsRecurringTemplate() bool {
return t.Status == StatusRecurring && t.RecurrenceDuration != nil && t.ParentUUID == nil
}
// IsRecurringInstance returns true if task is a recurring instance
func (t *Task) IsRecurringInstance() bool {
return t.ParentUUID != nil
}