f5f7bc3ad7
Implement complete key:value format parsing for change log entries and fix critical tag synchronization issue from server to client. Key Changes: 1. Shared Key:Value Parser (NEW: internal/engine/parser.go) - Created ParseKeyValueFormat() for both edit and sync operations - Supports flexible whitespace: 'key:value' and 'key: value' - Handles comment skipping for edit files - Consolidates parsing logic (DRY principle) 2. Database Triggers - Tags Support (internal/engine/database.go) - Added tags to track_task_create trigger - Added tags to track_task_update trigger - Tags sorted alphabetically via SQL ORDER BY - Format: 'tags: alpha,bravo,charlie' 3. Task Creation - Tag Update Fix (internal/engine/task.go) - CreateTaskWithModifier() now triggers update after adding tags - Ensures tags appear in change log (UPDATE entry) - Fixes missing tags in initial CREATE entries 4. Edit Command - Use Shared Parser (cmd/edit.go) - Replaced custom parseEditedFile() with shared ParseKeyValueFormat() - Added tag sorting in parseTags() - Removed ~30 lines, improved maintainability 5. Sync Client - Complete Implementation (internal/sync/client.go) - NEW: applyChangeDataToTask() - parses all fields from change log - NEW: Helper functions for status, priority, tag parsing - FIXED: parseChanges() - sort by timestamp+ID before grouping - Added parent/child task ordering (prevents FK violations) - Enhanced tag sync in merge loop with task reload - Specific validation error messages per field Critical Bug Fix: - When CREATE and UPDATE have same timestamp, old code kept CREATE (no tags) - New code sorts by ID as tiebreaker, ensuring UPDATE (with tags) is used - Verified: Server->client tag sync now works correctly Validation: - Description must not be empty (both edit and sync) - Recurrence validated (not negative, max 100 years) - All timestamps parsed correctly (Unix epoch) - Tags sorted alphabetically in all contexts Testing: - Fresh pull from server: ✅ All tags present - API-created tasks: ✅ Tags sync correctly - Local->server->client round-trip: ✅ No data loss - Same-second CREATE+UPDATE: ✅ Correct entry processed - Parent/child tasks: ✅ Correct ordering Files Changed: - NEW: internal/engine/parser.go (+44 lines) - Modified: internal/engine/database.go (+10 lines) - Modified: internal/engine/task.go (+8 lines) - Modified: cmd/edit.go (-25 lines net) - Modified: internal/sync/client.go (+280 lines) - Modified: srv/README.md (+1 line) Total: +318 lines added, -25 removed, net +293 lines This completes Phase 6: Full bidirectional sync with complete tag support.
625 lines
13 KiB
Go
625 lines
13 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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|