package sync import ( "fmt" "os" "time" "git.jnss.me/joakim/opal/internal/engine" "github.com/google/uuid" ) // ConflictResolution defines how to handle conflicts type ConflictResolution int const ( LastWriteWins ConflictResolution = iota // Use modified timestamp ServerWins // Server always preferred ClientWins // Client always preferred Manual // Prompt user (future) ) // MergeTasks merges local and remote task lists func MergeTasks(local, remote []*engine.Task, strategy ConflictResolution) ([]*engine.Task, int, error) { // Create maps for quick lookup localMap := make(map[uuid.UUID]*engine.Task) remoteMap := make(map[uuid.UUID]*engine.Task) for _, task := range local { localMap[task.UUID] = task } for _, task := range remote { remoteMap[task.UUID] = task } result := []*engine.Task{} conflicts := 0 // Process all unique UUIDs seen := make(map[uuid.UUID]bool) // Add all local tasks for _, task := range local { if seen[task.UUID] { continue } seen[task.UUID] = true remoteTask, existsRemote := remoteMap[task.UUID] if !existsRemote { // Local only - add it result = append(result, task) continue } // Exists in both - resolve conflict if DetectConflict(task, remoteTask) { conflicts++ winner := resolveConflict(task, remoteTask, strategy) logConflict(task, remoteTask, winner) result = append(result, winner) } else { // No conflict - use either (same content) result = append(result, task) } } // Add remote-only tasks for _, task := range remote { if seen[task.UUID] { continue } seen[task.UUID] = true result = append(result, task) } return result, conflicts, nil } // DetectConflict checks if two tasks with same UUID have conflicting changes func DetectConflict(local, remote *engine.Task) bool { // If modified times are the same, no conflict if local.Modified.Unix() == remote.Modified.Unix() { return false } // If one is significantly older (e.g., > 1 second difference), consider it a conflict return true } // resolveConflict determines which version wins func resolveConflict(local, remote *engine.Task, strategy ConflictResolution) *engine.Task { switch strategy { case ServerWins: return remote case ClientWins: return local case LastWriteWins: if remote.Modified.After(local.Modified) { return remote } return local default: // Default to last-write-wins if remote.Modified.After(local.Modified) { return remote } return local } } // logConflict writes conflict information to log file func logConflict(local, remote *engine.Task, winner *engine.Task) { logPath, err := engine.GetSyncConflictLogPath() if err != nil { return } winnerLabel := "local" if winner.UUID == remote.UUID && winner.Modified.Equal(remote.Modified) { winnerLabel = "remote" } entry := fmt.Sprintf( "[%s] Conflict on task %s\n"+ " Local: modified %s - %s\n"+ " Remote: modified %s - %s\n"+ " Winner: %s\n\n", time.Now().Format(time.RFC3339), local.UUID, local.Modified.Format(time.RFC3339), local.Description, remote.Modified.Format(time.RFC3339), remote.Description, winnerLabel, ) f, _ := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if f != nil { defer f.Close() f.WriteString(entry) } } // ParseStrategy converts string to ConflictResolution func ParseStrategy(s string) ConflictResolution { switch s { case "server-wins": return ServerWins case "client-wins": return ClientWins case "last-write-wins": return LastWriteWins default: return LastWriteWins } } // StrategyString converts ConflictResolution to string func StrategyString(strategy ConflictResolution) string { switch strategy { case ServerWins: return "server-wins" case ClientWins: return "client-wins" case LastWriteWins: return "last-write-wins" default: return "last-write-wins" } }