Files
gems/opal-task/cmd/edit.go
T
joakim f5f7bc3ad7 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.
2026-01-05 18:56:17 +01:00

507 lines
13 KiB
Go

package cmd
import (
"fmt"
"os"
"os/exec"
"sort"
"strings"
"time"
"git.jnss.me/joakim/opal/internal/engine"
"github.com/spf13/cobra"
)
var editCmd = &cobra.Command{
Use: "edit [filter...]",
Short: "Edit a task in $EDITOR",
Long: `Opens a task in your $EDITOR for interactive modification.
Examples:
opal edit 2 # Edit task with display ID 2
opal 2 edit # Flexible syntax (same as above)
opal edit +urgent # Edit task if only one matches`,
Run: func(cmd *cobra.Command, args []string) {
parsed := getParsedArgs(cmd)
if err := editTask(parsed.Filters); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
},
}
func editTask(args []string) error {
// Validate we have a filter
if len(args) == 0 {
return fmt.Errorf("no task specified for edit command")
}
// Parse filter
filter, err := engine.ParseFilter(args)
if err != nil {
return fmt.Errorf("failed to parse filter: %w", err)
}
// Load working set to resolve IDs
ws, err := engine.LoadWorkingSet()
if err != nil {
return fmt.Errorf("failed to load working set: %w", err)
}
// Resolve task
var task *engine.Task
if len(filter.IDs) > 0 {
// Resolve display ID (should be exactly one)
if len(filter.IDs) != 1 {
return fmt.Errorf("edit requires exactly one task (specified %d IDs)", len(filter.IDs))
}
task, err = ws.GetTaskByDisplayID(filter.IDs[0])
if err != nil {
return err
}
} else {
// Use filter to get tasks
tasks, err := engine.GetTasks(filter)
if err != nil {
return fmt.Errorf("failed to get tasks: %w", err)
}
if len(tasks) == 0 {
return fmt.Errorf("no task found matching filter")
}
if len(tasks) > 1 {
return fmt.Errorf("edit requires exactly one task (filter matched %d tasks)", len(tasks))
}
task = tasks[0]
}
// Create temporary file
tmpFile, err := os.CreateTemp("", "opal-task-*.txt")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
tmpPath := tmpFile.Name()
defer os.Remove(tmpPath) // Clean up
// Write task data to temp file
content := generateEditableContent(task)
if _, err := tmpFile.WriteString(content); err != nil {
tmpFile.Close()
return fmt.Errorf("failed to write temp file: %w", err)
}
tmpFile.Close()
// Launch editor
if err := launchEditor(tmpPath); err != nil {
return err
}
// Parse edited content
fields, err := parseEditedFile(tmpPath)
if err != nil {
return err
}
// Apply changes to task
if err := applyEditedFields(task, fields); err != nil {
return err
}
fmt.Printf("Task %s updated.\n", task.UUID)
return nil
}
// generateEditableContent creates the editable text format
func generateEditableContent(task *engine.Task) string {
var sb strings.Builder
// Header with read-only info
sb.WriteString(fmt.Sprintf("# Task UUID: %s (read-only)\n", task.UUID))
sb.WriteString(fmt.Sprintf("# Created: %s (read-only)\n", formatTimeForEdit(task.Created)))
sb.WriteString(fmt.Sprintf("# Modified: %s (read-only)\n", formatTimeForEdit(task.Modified)))
if task.ParentUUID != nil {
sb.WriteString(fmt.Sprintf("# Parent UUID: %s (this is a recurring task instance)\n", *task.ParentUUID))
sb.WriteString("# Note: Changing 'recurrence' will update the template for future instances\n")
}
if task.End != nil {
sb.WriteString(fmt.Sprintf("# End: %s (read-only, set on completion)\n", formatTimeForEdit(*task.End)))
}
sb.WriteString("#\n")
sb.WriteString("# Edit the fields below. Lines starting with # are ignored.\n")
sb.WriteString("# Leave a value empty to clear it.\n")
sb.WriteString("# Status: pending, completed, deleted (recurring/template is system-managed)\n")
sb.WriteString("# Priority: H (high), M (medium), L (low), D (default)\n")
sb.WriteString("\n")
// Editable fields
sb.WriteString(fmt.Sprintf("description: %s\n", task.Description))
sb.WriteString(fmt.Sprintf("status: %s\n", formatStatusForEdit(task.Status)))
sb.WriteString(fmt.Sprintf("priority: %s\n", formatPriorityForEdit(task.Priority)))
if task.Project != nil {
sb.WriteString(fmt.Sprintf("project: %s\n", *task.Project))
} else {
sb.WriteString("project: \n")
}
if task.Due != nil {
sb.WriteString(fmt.Sprintf("due: %s\n", formatTimeForEdit(*task.Due)))
} else {
sb.WriteString("due: \n")
}
if task.Scheduled != nil {
sb.WriteString(fmt.Sprintf("scheduled: %s\n", formatTimeForEdit(*task.Scheduled)))
} else {
sb.WriteString("scheduled: \n")
}
if task.Wait != nil {
sb.WriteString(fmt.Sprintf("wait: %s\n", formatTimeForEdit(*task.Wait)))
} else {
sb.WriteString("wait: \n")
}
if task.Until != nil {
sb.WriteString(fmt.Sprintf("until: %s\n", formatTimeForEdit(*task.Until)))
} else {
sb.WriteString("until: \n")
}
if task.Start != nil {
sb.WriteString(fmt.Sprintf("start: %s\n", formatTimeForEdit(*task.Start)))
} else {
sb.WriteString("start: \n")
}
// Recurrence - show template's recurrence or instance's parent recurrence
var recurrenceValue string
if task.RecurrenceDuration != nil {
recurrenceValue = engine.FormatRecurrenceDuration(*task.RecurrenceDuration)
} else if task.ParentUUID != nil {
// Load parent to show its recurrence
if parent, err := engine.GetTask(*task.ParentUUID); err == nil && parent.RecurrenceDuration != nil {
recurrenceValue = engine.FormatRecurrenceDuration(*parent.RecurrenceDuration)
}
}
sb.WriteString(fmt.Sprintf("recurrence: %s\n", recurrenceValue))
// Tags
if len(task.Tags) > 0 {
sb.WriteString(fmt.Sprintf("tags: %s\n", strings.Join(task.Tags, ",")))
} else {
sb.WriteString("tags: \n")
}
return sb.String()
}
// parseEditedFile reads and parses the modified file
func parseEditedFile(filepath string) (map[string]string, error) {
content, err := os.ReadFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to open edited file: %w", err)
}
return engine.ParseKeyValueFormat(string(content), true) // skip comments
}
// applyEditedFields applies parsed changes to task
func applyEditedFields(task *engine.Task, fields map[string]string) error {
// Validate required fields
description, hasDesc := fields["description"]
if !hasDesc || strings.TrimSpace(description) == "" {
return fmt.Errorf("description cannot be empty")
}
// Parse status
var newStatus engine.Status
if statusStr, ok := fields["status"]; ok && statusStr != "" {
parsed, err := parseStatus(statusStr)
if err != nil {
return err
}
newStatus = parsed
} else {
newStatus = task.Status
}
// Handle status changes specially
oldStatus := task.Status
// If changing to completed, use Complete() method
if newStatus == engine.StatusCompleted && oldStatus != engine.StatusCompleted {
// Apply other fields first
if err := applyNonStatusFields(task, fields); err != nil {
return err
}
// Then complete (which saves automatically)
return task.Complete()
}
// If changing to deleted, use Delete() method
if newStatus == engine.StatusDeleted && oldStatus != engine.StatusDeleted {
// Apply other fields first
if err := applyNonStatusFields(task, fields); err != nil {
return err
}
// Then delete (which saves automatically)
return task.Delete(false)
}
// If changing from completed/deleted to pending, clear end time
if newStatus == engine.StatusPending && (oldStatus == engine.StatusCompleted || oldStatus == engine.StatusDeleted) {
task.Status = engine.StatusPending
task.End = nil
} else {
task.Status = newStatus
}
// Apply all other fields
if err := applyNonStatusFields(task, fields); err != nil {
return err
}
// Save the task
return task.Save()
}
// applyNonStatusFields applies all fields except status
func applyNonStatusFields(task *engine.Task, fields map[string]string) error {
// Description
if desc, ok := fields["description"]; ok {
task.Description = desc
}
// Priority
if priStr, ok := fields["priority"]; ok && priStr != "" {
pri, err := parsePriority(priStr)
if err != nil {
return err
}
task.Priority = pri
}
// Project
if proj, ok := fields["project"]; ok {
if proj == "" {
task.Project = nil
} else {
task.Project = &proj
}
}
// Date fields
dateFields := map[string]**time.Time{
"due": &task.Due,
"scheduled": &task.Scheduled,
"wait": &task.Wait,
"until": &task.Until,
"start": &task.Start,
}
for fieldName, taskField := range dateFields {
if dateStr, ok := fields[fieldName]; ok {
if dateStr == "" {
*taskField = nil
} else {
parsed, err := engine.ParseDate(dateStr)
if err != nil {
return fmt.Errorf("invalid date for '%s': %w", fieldName, err)
}
*taskField = &parsed
}
}
}
// Recurrence
if recurStr, ok := fields["recurrence"]; ok {
if recurStr == "" {
// Clear recurrence
if task.ParentUUID != nil {
// This is an instance - clear parent's recurrence
parent, err := engine.GetTask(*task.ParentUUID)
if err != nil {
return fmt.Errorf("failed to load parent task: %w", err)
}
parent.RecurrenceDuration = nil
if err := parent.Save(); err != nil {
return fmt.Errorf("failed to update parent recurrence: %w", err)
}
fmt.Println("Cleared recurrence pattern (no more instances will be spawned)")
} else {
task.RecurrenceDuration = nil
}
} else {
// Parse recurrence
duration, err := engine.ParseRecurrencePattern(recurStr)
if err != nil {
return fmt.Errorf("invalid recurrence pattern: %w", err)
}
if task.ParentUUID != nil {
// This is an instance - update parent's recurrence
parent, err := engine.GetTask(*task.ParentUUID)
if err != nil {
return fmt.Errorf("failed to load parent task: %w", err)
}
parent.RecurrenceDuration = &duration
if err := parent.Save(); err != nil {
return fmt.Errorf("failed to update parent recurrence: %w", err)
}
fmt.Println("Updated recurrence pattern for future instances")
} else {
// This is a regular task or template
task.RecurrenceDuration = &duration
}
}
}
// Tags - replace all tags
if tagsStr, ok := fields["tags"]; ok {
// Remove all existing tags
for _, tag := range task.Tags {
if err := task.RemoveTag(tag); err != nil {
return fmt.Errorf("failed to remove tag '%s': %w", tag, err)
}
}
// Add new tags
if tagsStr != "" {
newTags := parseTags(tagsStr)
for _, tag := range newTags {
if err := task.AddTag(tag); err != nil {
return fmt.Errorf("failed to add tag '%s': %w", tag, err)
}
}
}
}
return nil
}
// getEditor returns the editor command ($EDITOR or 'vi')
func getEditor() string {
if editor := os.Getenv("EDITOR"); editor != "" {
return editor
}
return "vi"
}
// launchEditor opens file in editor and waits
func launchEditor(filepath string) error {
editor := getEditor()
cmd := exec.Command(editor, filepath)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return fmt.Errorf("editor exited with error (code %d)", exitErr.ExitCode())
}
return fmt.Errorf("failed to run editor: %w", err)
}
return nil
}
// parsePriority converts H/M/L/D to Priority constant
func parsePriority(s string) (engine.Priority, error) {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "H":
return engine.PriorityHigh, nil
case "M":
return engine.PriorityMedium, nil
case "L":
return engine.PriorityLow, nil
case "D", "":
return engine.PriorityDefault, nil
default:
return engine.PriorityDefault, fmt.Errorf("invalid priority '%s' (must be H, M, L, or D)", s)
}
}
// parseStatus converts string to Status constant
func parseStatus(s string) (engine.Status, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "pending":
return engine.StatusPending, nil
case "completed":
return engine.StatusCompleted, nil
case "deleted":
return engine.StatusDeleted, nil
case "recurring", "template":
// Accept these but keep them unchanged (system-managed status)
return engine.StatusRecurring, nil
default:
return engine.StatusPending, fmt.Errorf("invalid status '%s' (must be: pending, completed, deleted, or recurring)", s)
}
}
// parseTags splits comma-separated tags
func parseTags(s string) []string {
if s == "" {
return []string{}
}
parts := strings.Split(s, ",")
tags := make([]string, 0, len(parts))
for _, part := range parts {
tag := strings.TrimSpace(part)
if tag != "" {
tags = append(tags, tag)
}
}
sort.Strings(tags) // Sort alphabetically for consistency
return tags
}
// formatTimeForEdit formats a time for the edit file
func formatTimeForEdit(t time.Time) string {
// Check if time component is at midnight (00:00)
if t.Hour() == 0 && t.Minute() == 0 {
// Date only, no time component
return t.Format("2006-01-02")
}
// Date with time - use colon format that parser understands
return t.Format("2006-01-02:15:04")
}
// formatStatusForEdit formats status for the edit file
func formatStatusForEdit(s engine.Status) string {
switch s {
case engine.StatusPending:
return "pending"
case engine.StatusCompleted:
return "completed"
case engine.StatusDeleted:
return "deleted"
case engine.StatusRecurring:
return "recurring"
default:
return "pending"
}
}
// formatPriorityForEdit formats priority for the edit file
func formatPriorityForEdit(p engine.Priority) string {
switch p {
case engine.PriorityHigh:
return "H"
case engine.PriorityMedium:
return "M"
case engine.PriorityLow:
return "L"
case engine.PriorityDefault:
return "D"
default:
return "D"
}
}