mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
342 lines
13 KiB
Swift
342 lines
13 KiB
Swift
//
|
|
// CloudKitConflictResolver.swift
|
|
// Yattee
|
|
//
|
|
// Handles conflict resolution when local and remote records differ.
|
|
//
|
|
|
|
import CloudKit
|
|
import Foundation
|
|
|
|
/// Resolves conflicts between local and remote CloudKit records.
|
|
actor CloudKitConflictResolver {
|
|
// MARK: - Subscription Conflict Resolution
|
|
|
|
/// Resolves a conflict between local and server subscription records.
|
|
///
|
|
/// Strategy:
|
|
/// - Keep record with most recent `lastUpdatedAt`
|
|
/// - EXCEPT: always preserve local `notificationsEnabled` preference
|
|
func resolveSubscriptionConflict(
|
|
local: CKRecord,
|
|
server: CKRecord
|
|
) -> CKRecord {
|
|
let localDate = (local["lastUpdatedAt"] as? Date) ?? Date.distantPast
|
|
let serverDate = (server["lastUpdatedAt"] as? Date) ?? Date.distantPast
|
|
|
|
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
|
// This is required for CloudKit to accept the update
|
|
let resolved = server
|
|
|
|
// If local is newer, copy its fields to server record
|
|
if localDate > serverDate {
|
|
resolved["name"] = local["name"]
|
|
resolved["channelDescription"] = local["channelDescription"]
|
|
resolved["subscriberCount"] = local["subscriberCount"]
|
|
resolved["avatarURLString"] = local["avatarURLString"]
|
|
resolved["bannerURLString"] = local["bannerURLString"]
|
|
resolved["isVerified"] = local["isVerified"]
|
|
resolved["lastUpdatedAt"] = local["lastUpdatedAt"]
|
|
resolved["providerName"] = local["providerName"]
|
|
}
|
|
|
|
// But always preserve local notification preference
|
|
// (user's device-specific setting should not be overwritten by other devices)
|
|
if let localNotifications = local["notificationsEnabled"] {
|
|
resolved["notificationsEnabled"] = localNotifications
|
|
}
|
|
|
|
return resolved
|
|
}
|
|
|
|
// MARK: - WatchEntry Conflict Resolution
|
|
|
|
/// Resolves a conflict between local and server watch entry records.
|
|
///
|
|
/// Strategy:
|
|
/// - Use most recent updatedAt for watch progress (respects "mark as unwatched")
|
|
/// - Use most recent updatedAt for metadata fields
|
|
/// - Preserve finishedAt from whichever record marked it finished
|
|
func resolveWatchEntryConflict(
|
|
local: CKRecord,
|
|
server: CKRecord
|
|
) -> CKRecord {
|
|
let localSeconds = (local["watchedSeconds"] as? Double) ?? 0
|
|
let serverSeconds = (server["watchedSeconds"] as? Double) ?? 0
|
|
|
|
let localFinished = ((local["isFinished"] as? Int64) ?? 0) == 1
|
|
let serverFinished = ((server["isFinished"] as? Int64) ?? 0) == 1
|
|
|
|
let localUpdated = (local["updatedAt"] as? Date) ?? Date.distantPast
|
|
let serverUpdated = (server["updatedAt"] as? Date) ?? Date.distantPast
|
|
|
|
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
|
// This is required for CloudKit to accept the update
|
|
let resolved = server
|
|
|
|
// Copy metadata from local if it's newer
|
|
if localUpdated > serverUpdated {
|
|
resolved["title"] = local["title"]
|
|
resolved["authorName"] = local["authorName"]
|
|
resolved["authorID"] = local["authorID"]
|
|
resolved["duration"] = local["duration"]
|
|
resolved["thumbnailURLString"] = local["thumbnailURLString"]
|
|
}
|
|
|
|
// Use watch progress from the most recently updated record
|
|
// This respects user intent - if they marked as unwatched, that action wins
|
|
if localUpdated > serverUpdated {
|
|
resolved["watchedSeconds"] = localSeconds as CKRecordValue
|
|
resolved["isFinished"] = (localFinished ? 1 : 0) as CKRecordValue
|
|
resolved["finishedAt"] = local["finishedAt"]
|
|
} else {
|
|
resolved["watchedSeconds"] = serverSeconds as CKRecordValue
|
|
resolved["isFinished"] = (serverFinished ? 1 : 0) as CKRecordValue
|
|
resolved["finishedAt"] = server["finishedAt"]
|
|
}
|
|
|
|
// Use most recent updatedAt
|
|
resolved["updatedAt"] = max(localUpdated, serverUpdated) as CKRecordValue
|
|
|
|
return resolved
|
|
}
|
|
|
|
// MARK: - Bookmark Conflict Resolution
|
|
|
|
/// Resolves a conflict between local and server bookmark records.
|
|
///
|
|
/// Strategy:
|
|
/// - Keep most recent createdAt (newest wins)
|
|
/// - Preserve local note if it exists
|
|
/// - Keep local sortOrder (user's manual ordering)
|
|
func resolveBookmarkConflict(
|
|
local: CKRecord,
|
|
server: CKRecord
|
|
) -> CKRecord {
|
|
let localCreated = (local["createdAt"] as? Date) ?? Date.distantPast
|
|
let serverCreated = (server["createdAt"] as? Date) ?? Date.distantPast
|
|
|
|
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
|
let resolved = server
|
|
|
|
// If local is newer, copy its metadata to server record
|
|
if localCreated > serverCreated {
|
|
resolved["title"] = local["title"]
|
|
resolved["authorName"] = local["authorName"]
|
|
resolved["authorID"] = local["authorID"]
|
|
resolved["duration"] = local["duration"]
|
|
resolved["thumbnailURLString"] = local["thumbnailURLString"]
|
|
resolved["isLive"] = local["isLive"]
|
|
resolved["viewCount"] = local["viewCount"]
|
|
resolved["publishedAt"] = local["publishedAt"]
|
|
resolved["publishedText"] = local["publishedText"]
|
|
resolved["createdAt"] = local["createdAt"]
|
|
}
|
|
|
|
// Resolve note based on timestamp (most recent wins)
|
|
let localNoteModified = local["noteModifiedAt"] as? Date ?? Date.distantPast
|
|
let serverNoteModified = server["noteModifiedAt"] as? Date ?? Date.distantPast
|
|
|
|
if localNoteModified >= serverNoteModified {
|
|
// Local note is newer or equal - use local
|
|
resolved["note"] = local["note"]
|
|
resolved["noteModifiedAt"] = local["noteModifiedAt"]
|
|
} else {
|
|
// Server note is newer - use server (already in resolved)
|
|
// No need to set, already using server record
|
|
}
|
|
|
|
// Resolve tags based on timestamp (most recent wins)
|
|
let localTagsModified = local["tagsModifiedAt"] as? Date ?? Date.distantPast
|
|
let serverTagsModified = server["tagsModifiedAt"] as? Date ?? Date.distantPast
|
|
|
|
if localTagsModified >= serverTagsModified {
|
|
// Local tags are newer or equal - use local
|
|
resolved["tags"] = local["tags"]
|
|
resolved["tagsModifiedAt"] = local["tagsModifiedAt"]
|
|
} else {
|
|
// Server tags are newer - use server (already in resolved)
|
|
// No need to set, already using server record
|
|
}
|
|
|
|
// Always preserve local sortOrder (user's manual ordering)
|
|
if let localSortOrder = local["sortOrder"] {
|
|
resolved["sortOrder"] = localSortOrder
|
|
}
|
|
|
|
return resolved
|
|
}
|
|
|
|
// MARK: - Playlist Conflict Resolution
|
|
|
|
/// Resolves a conflict between local and server playlist records.
|
|
///
|
|
/// Strategy:
|
|
/// - Keep most recent updatedAt for metadata (title, description)
|
|
/// - Preserve server recordChangeTag
|
|
func resolveLocalPlaylistConflict(
|
|
local: CKRecord,
|
|
server: CKRecord
|
|
) -> CKRecord {
|
|
let localUpdated = (local["updatedAt"] as? Date) ?? Date.distantPast
|
|
let serverUpdated = (server["updatedAt"] as? Date) ?? Date.distantPast
|
|
|
|
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
|
let resolved = server
|
|
|
|
// If local is newer, copy its metadata to server record
|
|
if localUpdated > serverUpdated {
|
|
resolved["title"] = local["title"]
|
|
resolved["playlistDescription"] = local["playlistDescription"]
|
|
resolved["updatedAt"] = local["updatedAt"]
|
|
}
|
|
|
|
return resolved
|
|
}
|
|
|
|
/// Resolves a conflict between local and server playlist item records.
|
|
///
|
|
/// Strategy:
|
|
/// - Keep most recent addedAt for metadata
|
|
/// - Preserve local sortOrder (user's ordering)
|
|
func resolveLocalPlaylistItemConflict(
|
|
local: CKRecord,
|
|
server: CKRecord
|
|
) -> CKRecord {
|
|
let localAdded = (local["addedAt"] as? Date) ?? Date.distantPast
|
|
let serverAdded = (server["addedAt"] as? Date) ?? Date.distantPast
|
|
|
|
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
|
let resolved = server
|
|
|
|
// If local is newer, copy its metadata to server record
|
|
if localAdded > serverAdded {
|
|
resolved["title"] = local["title"]
|
|
resolved["authorName"] = local["authorName"]
|
|
resolved["authorID"] = local["authorID"]
|
|
resolved["duration"] = local["duration"]
|
|
resolved["thumbnailURLString"] = local["thumbnailURLString"]
|
|
resolved["isLive"] = local["isLive"]
|
|
}
|
|
|
|
// Always preserve local sortOrder (user's manual ordering)
|
|
if let localSortOrder = local["sortOrder"] {
|
|
resolved["sortOrder"] = localSortOrder
|
|
}
|
|
|
|
return resolved
|
|
}
|
|
|
|
// MARK: - SearchHistory Conflict Resolution
|
|
|
|
/// Resolves a conflict between local and server search history records.
|
|
///
|
|
/// Strategy:
|
|
/// - Keep most recent searchedAt (newest wins)
|
|
/// - Preserve server recordChangeTag
|
|
func resolveSearchHistoryConflict(
|
|
local: CKRecord,
|
|
server: CKRecord
|
|
) -> CKRecord {
|
|
let localSearched = (local["searchedAt"] as? Date) ?? Date.distantPast
|
|
let serverSearched = (server["searchedAt"] as? Date) ?? Date.distantPast
|
|
|
|
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
|
let resolved = server
|
|
|
|
// If local is newer, copy its fields to server record
|
|
if localSearched > serverSearched {
|
|
resolved["query"] = local["query"]
|
|
resolved["searchedAt"] = local["searchedAt"]
|
|
}
|
|
|
|
return resolved
|
|
}
|
|
|
|
// MARK: - RecentChannel Conflict Resolution
|
|
|
|
/// Resolves a conflict between local and server recent channel records.
|
|
///
|
|
/// Strategy:
|
|
/// - Keep most recent visitedAt (newest wins)
|
|
/// - Preserve server recordChangeTag
|
|
func resolveRecentChannelConflict(
|
|
local: CKRecord,
|
|
server: CKRecord
|
|
) -> CKRecord {
|
|
let localVisited = (local["visitedAt"] as? Date) ?? Date.distantPast
|
|
let serverVisited = (server["visitedAt"] as? Date) ?? Date.distantPast
|
|
|
|
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
|
let resolved = server
|
|
|
|
// If local is newer, copy its fields to server record
|
|
if localVisited > serverVisited {
|
|
resolved["name"] = local["name"]
|
|
resolved["avatarURLString"] = local["avatarURLString"]
|
|
resolved["providerName"] = local["providerName"]
|
|
resolved["visitedAt"] = local["visitedAt"]
|
|
}
|
|
|
|
return resolved
|
|
}
|
|
|
|
// MARK: - RecentPlaylist Conflict Resolution
|
|
|
|
/// Resolves a conflict between local and server recent playlist records.
|
|
///
|
|
/// Strategy:
|
|
/// - Keep most recent visitedAt (newest wins)
|
|
/// - Preserve server recordChangeTag
|
|
func resolveRecentPlaylistConflict(
|
|
local: CKRecord,
|
|
server: CKRecord
|
|
) -> CKRecord {
|
|
let localVisited = (local["visitedAt"] as? Date) ?? Date.distantPast
|
|
let serverVisited = (server["visitedAt"] as? Date) ?? Date.distantPast
|
|
|
|
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
|
let resolved = server
|
|
|
|
// If local is newer, copy its fields to server record
|
|
if localVisited > serverVisited {
|
|
resolved["title"] = local["title"]
|
|
resolved["authorName"] = local["authorName"]
|
|
resolved["authorID"] = local["authorID"]
|
|
resolved["thumbnailURLString"] = local["thumbnailURLString"]
|
|
resolved["videoCount"] = local["videoCount"]
|
|
resolved["providerName"] = local["providerName"]
|
|
resolved["visitedAt"] = local["visitedAt"]
|
|
}
|
|
|
|
return resolved
|
|
}
|
|
|
|
// MARK: - LayoutPreset Conflict Resolution
|
|
|
|
/// Resolves a conflict between local and server layout preset records.
|
|
///
|
|
/// Strategy:
|
|
/// - Last write wins based on updatedAt timestamp
|
|
/// - Preserve server recordChangeTag
|
|
func resolveLayoutPresetConflict(
|
|
local: CKRecord,
|
|
server: CKRecord
|
|
) -> CKRecord {
|
|
let localUpdated = (local["updatedAt"] as? Date) ?? Date.distantPast
|
|
let serverUpdated = (server["updatedAt"] as? Date) ?? Date.distantPast
|
|
|
|
// IMPORTANT: Always start with server record to preserve recordChangeTag
|
|
let resolved = server
|
|
|
|
// If local is newer, copy its fields to server record
|
|
if localUpdated > serverUpdated {
|
|
resolved["name"] = local["name"]
|
|
resolved["updatedAt"] = local["updatedAt"]
|
|
resolved["layoutJSON"] = local["layoutJSON"]
|
|
}
|
|
|
|
return resolved
|
|
}
|
|
}
|