package engine import ( "encoding/json" "fmt" "time" "github.com/google/uuid" ) type Status byte const ( StatusPending Status = 'P' StatusCompleted Status = 'C' StatusDeleted Status = 'D' StatusRecurring Status = 'R' ) // MarshalJSON encodes Status as a single-character string (e.g. "P", "C"). func (s Status) MarshalJSON() ([]byte, error) { return json.Marshal(string(s)) } // UnmarshalJSON decodes a single-character string into a Status. func (s *Status) UnmarshalJSON(data []byte) error { var str string if err := json.Unmarshal(data, &str); err != nil { return err } if len(str) != 1 { return fmt.Errorf("invalid status: %q", str) } *s = Status(str[0]) return nil } type Priority int const ( PriorityLow Priority = 0 PriorityDefault Priority = 1 PriorityMedium Priority = 2 PriorityHigh Priority = 3 ) // Annotation represents a timestamped note on a task type Annotation struct { Timestamp int64 `json:"timestamp"` Text string `json:"text"` } type Task struct { // Identity UUID uuid.UUID `json:"uuid"` ID int `json:"id"` // Core fields Status Status `json:"status"` Description string `json:"description"` Project *string `json:"project"` Priority Priority `json:"priority"` // Timestamps Created time.Time `json:"created"` Modified time.Time `json:"modified"` Start *time.Time `json:"start,omitempty"` End *time.Time `json:"end,omitempty"` Due *time.Time `json:"due,omitempty"` Scheduled *time.Time `json:"scheduled,omitempty"` Wait *time.Time `json:"wait,omitempty"` Until *time.Time `json:"until,omitempty"` // Recurrence (parent-child approach) RecurrenceDuration *time.Duration `json:"recurrence_duration,omitempty"` ParentUUID *uuid.UUID `json:"parent_uuid,omitempty"` // Annotations (stored as JSON in DB) Annotations []Annotation `json:"annotations,omitempty"` // Derived fields (not stored in DB) Tags []string `json:"tags"` Urgency float64 `json:"urgency"` } // taskJSON is the wire format for Task, using unix timestamps instead of time.Time. type taskJSON struct { UUID uuid.UUID `json:"uuid"` ID int `json:"id"` Status Status `json:"status"` Description string `json:"description"` Project *string `json:"project"` Priority Priority `json:"priority"` Created int64 `json:"created"` Modified int64 `json:"modified"` Start *int64 `json:"start,omitempty"` End *int64 `json:"end,omitempty"` Due *int64 `json:"due,omitempty"` Scheduled *int64 `json:"scheduled,omitempty"` Wait *int64 `json:"wait,omitempty"` Until *int64 `json:"until,omitempty"` RecurrenceDuration *int64 `json:"recurrence_duration,omitempty"` ParentUUID *uuid.UUID `json:"parent_uuid,omitempty"` Annotations []Annotation `json:"annotations,omitempty"` Tags []string `json:"tags"` Urgency float64 `json:"urgency"` } // MarshalJSON emits Task with unix timestamps (int64) instead of RFC3339 strings. func (t Task) MarshalJSON() ([]byte, error) { toUnix := func(tp *time.Time) *int64 { if tp == nil { return nil } v := tp.Unix() return &v } var recurDur *int64 if t.RecurrenceDuration != nil { v := int64(*t.RecurrenceDuration / time.Second) recurDur = &v } return json.Marshal(taskJSON{ UUID: t.UUID, ID: t.ID, Status: t.Status, Description: t.Description, Project: t.Project, Priority: t.Priority, Created: t.Created.Unix(), Modified: t.Modified.Unix(), Start: toUnix(t.Start), End: toUnix(t.End), Due: toUnix(t.Due), Scheduled: toUnix(t.Scheduled), Wait: toUnix(t.Wait), Until: toUnix(t.Until), RecurrenceDuration: recurDur, ParentUUID: t.ParentUUID, Annotations: t.Annotations, Tags: t.Tags, Urgency: t.Urgency, }) } // UnmarshalJSON parses Task from JSON with unix timestamps (int64) and duration in seconds. func (t *Task) UnmarshalJSON(data []byte) error { var raw taskJSON if err := json.Unmarshal(data, &raw); err != nil { return err } fromUnix := func(v *int64) *time.Time { if v == nil { return nil } t := time.Unix(*v, 0) return &t } t.UUID = raw.UUID t.ID = raw.ID t.Status = raw.Status t.Description = raw.Description t.Project = raw.Project t.Priority = raw.Priority t.Created = time.Unix(raw.Created, 0) t.Modified = time.Unix(raw.Modified, 0) t.Start = fromUnix(raw.Start) t.End = fromUnix(raw.End) t.Due = fromUnix(raw.Due) t.Scheduled = fromUnix(raw.Scheduled) t.Wait = fromUnix(raw.Wait) t.Until = fromUnix(raw.Until) t.ParentUUID = raw.ParentUUID t.Annotations = raw.Annotations t.Tags = raw.Tags t.Urgency = raw.Urgency if raw.RecurrenceDuration != nil { d := time.Duration(*raw.RecurrenceDuration) * time.Second t.RecurrenceDuration = &d } return nil } // 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 annotationsToSQL(annotations []Annotation) interface{} { if len(annotations) == 0 { return nil } data, err := json.Marshal(annotations) if err != nil { return nil } return string(data) } func sqlToAnnotations(v interface{}) []Annotation { if v == nil { return nil } str, ok := v.(string) if !ok { return nil } var annotations []Annotation if err := json.Unmarshal([]byte(str), &annotations); err != nil { return nil } return annotations } 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 } } // Update task to trigger change log with tags if len(mod.AddTags) > 0 { task.Modified = timeNow() if err := task.Save(); err != nil { return nil, err } } } return task, nil } // scanner is satisfied by both *sql.Row and *sql.Rows. type scanner interface { Scan(dest ...interface{}) error } // scanTask reads a single task row from a scanner and populates all fields including tags. func scanTask(s scanner) (*Task, error) { 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{} annotationsStr interface{} ) err := s.Scan( &task.ID, &uuidStr, &task.Status, &task.Description, &project, &task.Priority, &created, &modified, &start, &end, &due, &scheduled, &wait, &until, &recurDuration, &parentUUIDStr, &annotationsStr, ) if err != nil { return nil, err } task.UUID, err = uuid.Parse(uuidStr) if err != nil { return nil, fmt.Errorf("failed to parse UUID: %w", err) } task.Created = time.Unix(created, 0) task.Modified = time.Unix(modified, 0) 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) task.Annotations = sqlToAnnotations(annotationsStr) tags, err := task.GetTags() if err != nil { return nil, fmt.Errorf("failed to load tags: %w", err) } task.Tags = tags 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, annotations FROM tasks WHERE uuid = ? ` task, err := scanTask(db.QueryRow(query, taskUUID.String())) if err != nil { return nil, fmt.Errorf("failed to get task: %w", err) } 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, annotations 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, err := scanTask(rows) if err != nil { return nil, fmt.Errorf("failed to scan task: %w", err) } 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 = ?, annotations = ? 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), annotationsToSQL(t.Annotations), 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, annotations ) 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), annotationsToSQL(t.Annotations), ) 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. // Returns the next recurring instance if one was spawned, or nil. func (t *Task) Complete() (*Task, error) { t.Status = StatusCompleted now := timeNow() t.End = &now if err := t.Save(); err != nil { return nil, err } // If this is a recurring instance, spawn next instance if t.ParentUUID != nil { next, err := SpawnNextInstance(t) if err != nil { return nil, fmt.Errorf("completed task but failed to spawn next instance: %w", err) } return next, nil } return nil, 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 } // PopulateUrgency computes and sets the Urgency field on the given tasks. func PopulateUrgency(tasks ...*Task) { cfg, _ := GetConfig() coeffs := BuildUrgencyCoefficients(cfg) for _, t := range tasks { t.Urgency = t.CalculateUrgency(coeffs) } }