From 7c6ec97c62c4f6bae8237e2e7074cb347095370e Mon Sep 17 00:00:00 2001 From: Joakim Date: Sun, 4 Jan 2026 14:44:24 +0100 Subject: [PATCH] Implement opal-task Phase 2: Core Task Model CRUD - Add complete CRUD operations: CreateTask, GetTask, GetTasks, Save - Implement tag operations: AddTag, RemoveTag, GetTags - Add task lifecycle methods: Complete, Delete, StartTask, StopTask - Implement SQL type conversion helpers for nullable fields - Add comprehensive test suite with 9 passing tests - Fix timestamp handling (Created/Modified as Unix timestamps) - Implement soft delete (status change) and hard delete options --- .gitignore | 2 + opal-task/internal/engine/task.go | 527 +++++++++++++++++++++++++ opal-task/internal/engine/task_test.go | 290 ++++++++++++++ 3 files changed, 819 insertions(+) create mode 100644 opal-task/internal/engine/task_test.go diff --git a/.gitignore b/.gitignore index eb07373..137e01e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ # Binaries for programs and plugins jade jadedepo +opal + *.exe *.exe~ *.dll diff --git a/opal-task/internal/engine/task.go b/opal-task/internal/engine/task.go index b579ff9..aacd002 100644 --- a/opal-task/internal/engine/task.go +++ b/opal-task/internal/engine/task.go @@ -1,6 +1,7 @@ package engine import ( + "fmt" "time" "github.com/google/uuid" @@ -55,3 +56,529 @@ type Task struct { // 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) { + now := timeNow() + task := &Task{ + UUID: uuid.New(), + Status: StatusPending, + Description: description, + Priority: PriorityDefault, + Created: now, + Modified: now, + Tags: []string{}, + } + + if err := task.Save(); 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 (filtering will be added later) +func GetTasks() ([]*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 + ORDER BY due ASC, priority DESC + ` + + rows, err := db.Query(query) + 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 +} diff --git a/opal-task/internal/engine/task_test.go b/opal-task/internal/engine/task_test.go new file mode 100644 index 0000000..bcdf828 --- /dev/null +++ b/opal-task/internal/engine/task_test.go @@ -0,0 +1,290 @@ +package engine + +import ( + "os" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestMain(m *testing.M) { + // Setup test database + os.Setenv("HOME", "/tmp/opal-test") + + // Ensure config directory exists + configDir := "/tmp/opal-test/.config/jade" + if err := os.MkdirAll(configDir, 0755); err != nil { + panic(err) + } + + if err := InitDB(); err != nil { + panic(err) + } + defer CloseDB() + + // Run tests + code := m.Run() + + // Cleanup + os.RemoveAll("/tmp/opal-test/.config") + os.Exit(code) +} + +func TestCreateTask(t *testing.T) { + task, err := CreateTask("Test task") + if err != nil { + t.Fatalf("Failed to create task: %v", err) + } + + if task.UUID == uuid.Nil { + t.Error("Task UUID should not be nil") + } + + if task.Description != "Test task" { + t.Errorf("Expected description 'Test task', got '%s'", task.Description) + } + + if task.Status != StatusPending { + t.Errorf("Expected status Pending, got %v", task.Status) + } + + if task.Priority != PriorityDefault { + t.Errorf("Expected priority Default, got %v", task.Priority) + } +} + +func TestGetTask(t *testing.T) { + // Create a task + created, err := CreateTask("Test get task") + if err != nil { + t.Fatalf("Failed to create task: %v", err) + } + + // Retrieve it + retrieved, err := GetTask(created.UUID) + if err != nil { + t.Fatalf("Failed to get task: %v", err) + } + + if retrieved.UUID != created.UUID { + t.Error("UUIDs don't match") + } + + if retrieved.Description != created.Description { + t.Error("Descriptions don't match") + } +} + +func TestTaskSave(t *testing.T) { + task, err := CreateTask("Test save") + if err != nil { + t.Fatalf("Failed to create task: %v", err) + } + + // Modify task + task.Description = "Modified description" + task.Priority = PriorityHigh + project := "test-project" + task.Project = &project + + if err := task.Save(); err != nil { + t.Fatalf("Failed to save task: %v", err) + } + + // Retrieve and verify + retrieved, err := GetTask(task.UUID) + if err != nil { + t.Fatalf("Failed to get task: %v", err) + } + + if retrieved.Description != "Modified description" { + t.Error("Description not updated") + } + + if retrieved.Priority != PriorityHigh { + t.Error("Priority not updated") + } + + if retrieved.Project == nil || *retrieved.Project != "test-project" { + t.Error("Project not updated") + } +} + +func TestTaskTags(t *testing.T) { + task, err := CreateTask("Test tags") + if err != nil { + t.Fatalf("Failed to create task: %v", err) + } + + // Add tags + if err := task.AddTag("urgent"); err != nil { + t.Fatalf("Failed to add tag: %v", err) + } + + if err := task.AddTag("work"); err != nil { + t.Fatalf("Failed to add tag: %v", err) + } + + // Retrieve and verify tags + retrieved, err := GetTask(task.UUID) + if err != nil { + t.Fatalf("Failed to get task: %v", err) + } + + if len(retrieved.Tags) != 2 { + t.Errorf("Expected 2 tags, got %d", len(retrieved.Tags)) + } + + // Remove a tag + if err := retrieved.RemoveTag("urgent"); err != nil { + t.Fatalf("Failed to remove tag: %v", err) + } + + // Verify removal + retrieved2, err := GetTask(task.UUID) + if err != nil { + t.Fatalf("Failed to get task: %v", err) + } + + if len(retrieved2.Tags) != 1 { + t.Errorf("Expected 1 tag after removal, got %d", len(retrieved2.Tags)) + } + + if retrieved2.Tags[0] != "work" { + t.Errorf("Expected remaining tag 'work', got '%s'", retrieved2.Tags[0]) + } +} + +func TestTaskComplete(t *testing.T) { + task, err := CreateTask("Test complete") + if err != nil { + t.Fatalf("Failed to create task: %v", err) + } + + if err := task.Complete(); err != nil { + t.Fatalf("Failed to complete task: %v", err) + } + + if task.Status != StatusCompleted { + t.Error("Status should be Completed") + } + + if task.End == nil { + t.Error("End time should be set") + } + + // Verify in database + retrieved, err := GetTask(task.UUID) + if err != nil { + t.Fatalf("Failed to get task: %v", err) + } + + if retrieved.Status != StatusCompleted { + t.Error("Retrieved task should be completed") + } +} + +func TestTaskDelete(t *testing.T) { + // Test soft delete + task1, err := CreateTask("Test soft delete") + if err != nil { + t.Fatalf("Failed to create task: %v", err) + } + + if err := task1.Delete(false); err != nil { + t.Fatalf("Failed to soft delete: %v", err) + } + + retrieved, err := GetTask(task1.UUID) + if err != nil { + t.Fatalf("Failed to get deleted task: %v", err) + } + + if retrieved.Status != StatusDeleted { + t.Error("Task should be marked as deleted") + } + + // Test hard delete + task2, err := CreateTask("Test hard delete") + if err != nil { + t.Fatalf("Failed to create task: %v", err) + } + + if err := task2.Delete(true); err != nil { + t.Fatalf("Failed to hard delete: %v", err) + } + + _, err = GetTask(task2.UUID) + if err == nil { + t.Error("Should not be able to retrieve hard-deleted task") + } +} + +func TestTaskStartStop(t *testing.T) { + task, err := CreateTask("Test start/stop") + if err != nil { + t.Fatalf("Failed to create task: %v", err) + } + + // Start task + if err := task.StartTask(); err != nil { + t.Fatalf("Failed to start task: %v", err) + } + + if task.Start == nil { + t.Error("Start time should be set") + } + + // Stop task + if err := task.StopTask(); err != nil { + t.Fatalf("Failed to stop task: %v", err) + } + + if task.Start != nil { + t.Error("Start time should be nil after stop") + } +} + +func TestGetTasks(t *testing.T) { + // Create multiple tasks + CreateTask("Task 1") + CreateTask("Task 2") + CreateTask("Task 3") + + tasks, err := GetTasks() + if err != nil { + t.Fatalf("Failed to get tasks: %v", err) + } + + if len(tasks) < 3 { + t.Errorf("Expected at least 3 tasks, got %d", len(tasks)) + } +} + +func TestTaskWithDueDate(t *testing.T) { + task, err := CreateTask("Task with due date") + if err != nil { + t.Fatalf("Failed to create task: %v", err) + } + + dueDate := time.Now().Add(24 * time.Hour) + task.Due = &dueDate + + if err := task.Save(); err != nil { + t.Fatalf("Failed to save task with due date: %v", err) + } + + retrieved, err := GetTask(task.UUID) + if err != nil { + t.Fatalf("Failed to get task: %v", err) + } + + if retrieved.Due == nil { + t.Error("Due date should be set") + } + + if retrieved.Due.Unix() != dueDate.Unix() { + t.Error("Due dates don't match") + } +}