diff --git a/opal-task/cmd/edit.go b/opal-task/cmd/edit.go index 7c573b7..bb99654 100644 --- a/opal-task/cmd/edit.go +++ b/opal-task/cmd/edit.go @@ -1,10 +1,10 @@ package cmd import ( - "bufio" "fmt" "os" "os/exec" + "sort" "strings" "time" @@ -203,42 +203,11 @@ func generateEditableContent(task *engine.Task) string { // parseEditedFile reads and parses the modified file func parseEditedFile(filepath string) (map[string]string, error) { - file, err := os.Open(filepath) + content, err := os.ReadFile(filepath) if err != nil { return nil, fmt.Errorf("failed to open edited file: %w", err) } - defer file.Close() - - fields := make(map[string]string) - scanner := bufio.NewScanner(file) - lineNum := 0 - - for scanner.Scan() { - lineNum++ - line := scanner.Text() - - // Skip comments and empty lines - trimmed := strings.TrimSpace(line) - if trimmed == "" || strings.HasPrefix(trimmed, "#") { - continue - } - - // Parse "key: value" - parts := strings.SplitN(line, ":", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("line %d: invalid format (expected 'field: value')", lineNum) - } - - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - fields[key] = value - } - - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("error reading file: %w", err) - } - - return fields, nil + return engine.ParseKeyValueFormat(string(content), true) // skip comments } // applyEditedFields applies parsed changes to task @@ -489,6 +458,7 @@ func parseTags(s string) []string { tags = append(tags, tag) } } + sort.Strings(tags) // Sort alphabetically for consistency return tags } diff --git a/opal-task/internal/engine/database.go b/opal-task/internal/engine/database.go index e531c2f..5254c7a 100644 --- a/opal-task/internal/engine/database.go +++ b/opal-task/internal/engine/database.go @@ -214,7 +214,12 @@ func runMigrations() error { CASE WHEN NEW.wait IS NOT NULL THEN 'wait: ' || NEW.wait || CHAR(10) ELSE '' END || CASE WHEN NEW.until_date IS NOT NULL THEN 'until: ' || NEW.until_date || CHAR(10) ELSE '' END || CASE WHEN NEW.recurrence_duration IS NOT NULL THEN 'recurrence: ' || NEW.recurrence_duration || CHAR(10) ELSE '' END || - CASE WHEN NEW.parent_uuid IS NOT NULL THEN 'parent_uuid: ' || NEW.parent_uuid || CHAR(10) ELSE '' END + CASE WHEN NEW.parent_uuid IS NOT NULL THEN 'parent_uuid: ' || NEW.parent_uuid || CHAR(10) ELSE '' END || + (SELECT CASE WHEN COUNT(*) > 0 + THEN 'tags: ' || GROUP_CONCAT(tag, ',') || CHAR(10) + ELSE '' + END + FROM (SELECT tag FROM tags WHERE task_id = NEW.id ORDER BY tag)) ); END; @@ -248,7 +253,12 @@ func runMigrations() error { CASE WHEN NEW.wait IS NOT NULL THEN 'wait: ' || NEW.wait || CHAR(10) ELSE '' END || CASE WHEN NEW.until_date IS NOT NULL THEN 'until: ' || NEW.until_date || CHAR(10) ELSE '' END || CASE WHEN NEW.recurrence_duration IS NOT NULL THEN 'recurrence: ' || NEW.recurrence_duration || CHAR(10) ELSE '' END || - CASE WHEN NEW.parent_uuid IS NOT NULL THEN 'parent_uuid: ' || NEW.parent_uuid || CHAR(10) ELSE '' END + CASE WHEN NEW.parent_uuid IS NOT NULL THEN 'parent_uuid: ' || NEW.parent_uuid || CHAR(10) ELSE '' END || + (SELECT CASE WHEN COUNT(*) > 0 + THEN 'tags: ' || GROUP_CONCAT(tag, ',') || CHAR(10) + ELSE '' + END + FROM (SELECT tag FROM tags WHERE task_id = NEW.id ORDER BY tag)) ); END; diff --git a/opal-task/internal/engine/parser.go b/opal-task/internal/engine/parser.go new file mode 100644 index 0000000..9ffebe4 --- /dev/null +++ b/opal-task/internal/engine/parser.go @@ -0,0 +1,43 @@ +package engine + +import ( + "fmt" + "strings" +) + +// ParseKeyValueFormat parses key:value format from text +// Each line should be "key:value" or "key: value" (whitespace trimmed) +// If skipComments=true, lines starting with # are ignored +// Empty lines are always skipped +func ParseKeyValueFormat(data string, skipComments bool) (map[string]string, error) { + fields := make(map[string]string) + lines := strings.Split(data, "\n") + + for i, line := range lines { + // Trim whitespace + line = strings.TrimSpace(line) + + // Skip empty lines + if line == "" { + continue + } + + // Skip comments if requested + if skipComments && strings.HasPrefix(line, "#") { + continue + } + + // Split on first ':' + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("line %d: invalid format (expected 'key:value')", i+1) + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + fields[key] = value + } + + return fields, nil +} diff --git a/opal-task/internal/engine/task.go b/opal-task/internal/engine/task.go index ec88ba7..64e54ca 100644 --- a/opal-task/internal/engine/task.go +++ b/opal-task/internal/engine/task.go @@ -173,6 +173,14 @@ func CreateTaskWithModifier(description string, mod *Modifier) (*Task, error) { 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 diff --git a/opal-task/internal/sync/client.go b/opal-task/internal/sync/client.go index 42ef985..aabd9e2 100644 --- a/opal-task/internal/sync/client.go +++ b/opal-task/internal/sync/client.go @@ -6,6 +6,10 @@ import ( "fmt" "io" "net/http" + "os" + "sort" + "strconv" + "strings" "time" "git.jnss.me/joakim/opal/internal/engine" @@ -198,6 +202,21 @@ func (c *Client) Sync(strategy ConflictResolution) (*SyncResult, error) { return nil, fmt.Errorf("failed to parse changes: %w", err) } + // Sort: parent tasks before children (ensures parent exists when child is saved) + sort.Slice(remoteTasks, func(i, j int) bool { + // Tasks without ParentUUID come first + iHasParent := remoteTasks[i].ParentUUID != nil + jHasParent := remoteTasks[j].ParentUUID != nil + + if !iHasParent && jHasParent { + return true // i (no parent) before j (has parent) + } + if iHasParent && !jHasParent { + return false // j (no parent) before i (has parent) + } + return false // maintain original order + }) + result.Pulled = len(remoteTasks) // Get local changes since last sync @@ -218,6 +237,42 @@ func (c *Client) Sync(strategy ConflictResolution) (*SyncResult, error) { for _, task := range merged { if err := task.Save(); err != nil { result.Errors = append(result.Errors, fmt.Sprintf("failed to save task %s: %v", task.UUID, err)) + continue + } + + // Reload task to ensure we have the database ID + savedTask, err := engine.GetTask(task.UUID) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("failed to reload task %s: %v", task.UUID, err)) + continue + } + + // Sync tags: task.Tags contains the desired tag list (from parsing or local) + // Get current tags from database + currentTags, _ := savedTask.GetTags() + + // Build sets for comparison + currentSet := make(map[string]bool) + for _, tag := range currentTags { + currentSet[tag] = true + } + desiredSet := make(map[string]bool) + for _, tag := range task.Tags { + desiredSet[tag] = true + } + + // Remove tags no longer present + for tag := range currentSet { + if !desiredSet[tag] { + savedTask.RemoveTag(tag) + } + } + + // Add new tags + for tag := range desiredSet { + if !currentSet[tag] { + savedTask.AddTag(tag) + } } } @@ -308,42 +363,71 @@ func (c *Client) getLocalChanges(since int64) ([]*engine.Task, error) { // parseChanges converts change log entries to tasks func (c *Client) parseChanges(changes []ChangeLogEntry) ([]*engine.Task, error) { - // Group changes by UUID and use latest + // Sort changes by timestamp (primary) and ID (secondary) to ensure correct order + // This handles same-second updates (e.g., CREATE followed by UPDATE with tags) + sort.Slice(changes, func(i, j int) bool { + if changes[i].ChangedAt != changes[j].ChangedAt { + return changes[i].ChangedAt < changes[j].ChangedAt + } + // Same timestamp: use ID as tiebreaker (higher ID = later change) + return changes[i].ID < changes[j].ID + }) + + // Group changes by UUID - last one wins (latest by timestamp+ID) taskMap := make(map[string]ChangeLogEntry) for _, change := range changes { - existing, exists := taskMap[change.TaskUUID] - if !exists || change.ChangedAt > existing.ChangedAt { - taskMap[change.TaskUUID] = change - } + // Since changes are sorted, we can simply overwrite + // (later entries will overwrite earlier ones) + taskMap[change.TaskUUID] = change } // Parse tasks from change data (key:value format) var tasks []*engine.Task + for uuidStr, change := range taskMap { + // Handle deletions if change.ChangeType == "delete" { - // Handle deletions separately + taskUUID, err := uuid.Parse(uuidStr) + if err != nil { + continue + } + + // Delete locally if exists + if task, err := engine.GetTask(taskUUID); err == nil { + task.Delete(false) // Soft delete + } continue } + // Parse the key:value data + fields, err := engine.ParseKeyValueFormat(change.Data, false) // no comments in change log + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: skipping task %s - failed to parse change data: %v\n", uuidStr, err) + continue + } + + // Get or create task taskUUID, err := uuid.Parse(uuidStr) if err != nil { + fmt.Fprintf(os.Stderr, "Warning: skipping task - invalid UUID %s: %v\n", uuidStr, err) continue } - // Try to get existing task task, err := engine.GetTask(taskUUID) if err != nil { - // Task doesn't exist locally - create from change data + // Task doesn't exist locally - create new task = &engine.Task{ UUID: taskUUID, Tags: []string{}, } } - // Parse the key:value data and update task - // For now, we'll use the server's task data directly via GetTask - // In a more complete implementation, we'd parse the key:value format + // Apply parsed change data to task + if err := applyChangeDataToTask(task, fields); err != nil { + fmt.Fprintf(os.Stderr, "Warning: skipping task %s - failed to apply changes: %v\n", uuidStr, err) + continue + } tasks = append(tasks, task) } @@ -351,6 +435,180 @@ func (c *Client) parseChanges(changes []ChangeLogEntry) ([]*engine.Task, error) return tasks, nil } +// applyChangeDataToTask applies parsed change log data to a task +// Expects fields from ParseKeyValueFormat with sync data (Unix timestamps, etc.) +func applyChangeDataToTask(task *engine.Task, fields map[string]string) error { + // UUID (required) + if uuidStr, ok := fields["uuid"]; ok && uuidStr != "" { + parsed, err := uuid.Parse(uuidStr) + if err != nil { + return fmt.Errorf("invalid uuid '%s': %w", uuidStr, err) + } + task.UUID = parsed + } + + // Description (required, non-empty) + if desc, ok := fields["description"]; ok { + if strings.TrimSpace(desc) == "" { + return fmt.Errorf("description cannot be empty") + } + task.Description = desc + } + + // Status + if statusStr, ok := fields["status"]; ok && statusStr != "" { + task.Status = parseStatusFromChangeLog(statusStr) + } + + // Priority + if priStr, ok := fields["priority"]; ok && priStr != "" { + task.Priority = parsePriorityFromChangeLog(priStr) + } + + // Project (nullable) + if proj, ok := fields["project"]; ok { + if proj == "" { + task.Project = nil + } else { + task.Project = &proj + } + } + + // Created timestamp + if createdStr, ok := fields["created"]; ok && createdStr != "" { + ts, err := strconv.ParseInt(createdStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid created timestamp '%s': %w", createdStr, err) + } + task.Created = time.Unix(ts, 0) + } + + // Modified timestamp + if modStr, ok := fields["modified"]; ok && modStr != "" { + ts, err := strconv.ParseInt(modStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid modified timestamp '%s': %w", modStr, err) + } + task.Modified = time.Unix(ts, 0) + } + + // Date fields (nullable Unix timestamps) + dateFields := map[string]**time.Time{ + "start": &task.Start, + "end": &task.End, + "due": &task.Due, + "scheduled": &task.Scheduled, + "wait": &task.Wait, + "until": &task.Until, + } + + for fieldName, taskField := range dateFields { + if dateStr, ok := fields[fieldName]; ok { + if dateStr == "" { + *taskField = nil + } else { + ts, err := strconv.ParseInt(dateStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid %s timestamp '%s': %w", fieldName, dateStr, err) + } + t := time.Unix(ts, 0) + *taskField = &t + } + } + } + + // Recurrence duration (nullable, int64 nanoseconds) + if recurStr, ok := fields["recurrence"]; ok { + if recurStr == "" { + task.RecurrenceDuration = nil + } else { + nanos, err := strconv.ParseInt(recurStr, 10, 64) + if err != nil { + return fmt.Errorf("invalid recurrence '%s': %w", recurStr, err) + } + // Validate: not negative, not unreasonably large (max 100 years) + if nanos < 0 { + return fmt.Errorf("recurrence cannot be negative: %d", nanos) + } + maxNanos := int64(time.Hour * 24 * 365 * 100) + if nanos > maxNanos { + return fmt.Errorf("recurrence too large (max 100 years): %d", nanos) + } + duration := time.Duration(nanos) + task.RecurrenceDuration = &duration + } + } + + // Parent UUID (nullable) + if parentStr, ok := fields["parent_uuid"]; ok { + if parentStr == "" { + task.ParentUUID = nil + } else { + parsed, err := uuid.Parse(parentStr) + if err != nil { + return fmt.Errorf("invalid parent_uuid '%s': %w", parentStr, err) + } + task.ParentUUID = &parsed + } + } + + // Tags (comma-separated, sorted) + if tagsStr, ok := fields["tags"]; ok { + task.Tags = parseTagsFromChangeLog(tagsStr) + } + + return nil +} + +// parseStatusFromChangeLog parses status from change log string +func parseStatusFromChangeLog(s string) engine.Status { + switch s { + case "pending": + return engine.StatusPending + case "completed": + return engine.StatusCompleted + case "deleted": + return engine.StatusDeleted + case "recurring": + return engine.StatusRecurring + default: + return engine.StatusPending + } +} + +// parsePriorityFromChangeLog parses priority from change log string +func parsePriorityFromChangeLog(s string) engine.Priority { + switch s { + case "L": + return engine.PriorityLow + case "D": + return engine.PriorityDefault + case "M": + return engine.PriorityMedium + case "H": + return engine.PriorityHigh + default: + return engine.PriorityDefault + } +} + +// parseTagsFromChangeLog parses and sorts tags from change log +func parseTagsFromChangeLog(s string) []string { + if s == "" { + return []string{} + } + parts := strings.Split(s, ",") + tags := make([]string, 0, len(parts)) + for _, tag := range parts { + tag = strings.TrimSpace(tag) + if tag != "" { + tags = append(tags, tag) + } + } + sort.Strings(tags) // Sort alphabetically for consistency + return tags +} + // pushQueuedChanges sends queued changes to server func (c *Client) pushQueuedChanges(changes []QueuedChange) error { var tasks []*engine.Task diff --git a/opal-task/srv/README.md b/opal-task/srv/README.md index cc80424..d5c1e25 100644 --- a/opal-task/srv/README.md +++ b/opal-task/srv/README.md @@ -366,3 +366,4 @@ For issues or questions: ## License Same as opal-task main project. +