feat: Complete key:value format implementation and fix tag sync

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.
This commit is contained in:
2026-01-05 18:56:17 +01:00
parent 4c54814eb5
commit f5f7bc3ad7
6 changed files with 337 additions and 47 deletions
+4 -34
View File
@@ -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
}
+12 -2
View File
@@ -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;
+43
View File
@@ -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
}
+8
View File
@@ -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
+272 -14
View File
@@ -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 {
// 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 {
if change.ChangeType == "delete" {
// Handle deletions separately
continue
}
for uuidStr, change := range taskMap {
// Handle deletions
if change.ChangeType == "delete" {
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
continue
}
// Try to get existing task
// 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
}
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
+1
View File
@@ -366,3 +366,4 @@ For issues or questions:
## License
Same as opal-task main project.