[iOS & Android] iOS 1.2.5 & Android 1.7.4 [Sync] Sync 1.1.0
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m25s
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m25s
This commit is contained in:
@@ -247,39 +247,13 @@ class SyncService: ObservableObject {
|
||||
try await syncImagesToServer(dataManager: dataManager)
|
||||
print("Initial upload completed")
|
||||
} else if hasLocalData && hasServerData {
|
||||
// Case 3: Both have data - compare timestamps (last writer wins)
|
||||
let localTimestamp = parseISO8601ToMillis(timestamp: localBackup.exportedAt)
|
||||
let serverTimestamp = parseISO8601ToMillis(timestamp: serverBackup.exportedAt)
|
||||
|
||||
print("DEBUG iOS Timestamp Comparison:")
|
||||
print(" Local exportedAt: '\(localBackup.exportedAt)' -> \(localTimestamp)")
|
||||
print(" Server exportedAt: '\(serverBackup.exportedAt)' -> \(serverTimestamp)")
|
||||
print(
|
||||
" DataStateManager last modified: '\(DataStateManager.shared.getLastModified())'"
|
||||
)
|
||||
print(" Comparison result: local=\(localTimestamp), server=\(serverTimestamp)")
|
||||
|
||||
if localTimestamp > serverTimestamp {
|
||||
// Local is newer - replace server with local data
|
||||
print("iOS SYNC: Case 3a - Local data is newer, replacing server content")
|
||||
let currentBackup = createBackupFromDataManager(dataManager)
|
||||
_ = try await uploadData(currentBackup)
|
||||
try await syncImagesToServer(dataManager: dataManager)
|
||||
print("Server replaced with local data")
|
||||
} else if serverTimestamp > localTimestamp {
|
||||
// Server is newer - replace local with server data
|
||||
print("iOS SYNC: Case 3b - Server data is newer, replacing local content")
|
||||
let imagePathMapping = try await syncImagesFromServer(
|
||||
backup: serverBackup, dataManager: dataManager)
|
||||
try importBackupToDataManager(
|
||||
serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping)
|
||||
print("Local data replaced with server data")
|
||||
} else {
|
||||
// Timestamps are equal - no sync needed
|
||||
print(
|
||||
"iOS SYNC: Case 3c - Data is in sync (timestamps equal), no action needed"
|
||||
)
|
||||
}
|
||||
// Case 3: Both have data - use safe merge strategy
|
||||
print("iOS SYNC: Case 3 - Merging local and server data safely")
|
||||
try await mergeDataSafely(
|
||||
localBackup: localBackup,
|
||||
serverBackup: serverBackup,
|
||||
dataManager: dataManager)
|
||||
print("Safe merge completed")
|
||||
} else {
|
||||
print("No data to sync")
|
||||
}
|
||||
@@ -413,16 +387,84 @@ class SyncService: ObservableObject {
|
||||
gyms: dataManager.gyms.map { BackupGym(from: $0) },
|
||||
problems: dataManager.problems.map { BackupProblem(from: $0) },
|
||||
sessions: completedSessions.map { BackupClimbSession(from: $0) },
|
||||
attempts: completedAttempts.map { BackupAttempt(from: $0) }
|
||||
attempts: completedAttempts.map { BackupAttempt(from: $0) },
|
||||
deletedItems: dataManager.getDeletedItems()
|
||||
)
|
||||
}
|
||||
|
||||
private func mergeDataSafely(
|
||||
localBackup: ClimbDataBackup,
|
||||
serverBackup: ClimbDataBackup,
|
||||
dataManager: ClimbingDataManager
|
||||
) async throws {
|
||||
// Download server images first
|
||||
let imagePathMapping = try await syncImagesFromServer(
|
||||
backup: serverBackup, dataManager: dataManager)
|
||||
|
||||
// Merge data additively - never remove existing local data
|
||||
print("Merging gyms...")
|
||||
let mergedGyms = mergeGyms(
|
||||
local: dataManager.gyms,
|
||||
server: serverBackup.gyms,
|
||||
deletedItems: serverBackup.deletedItems)
|
||||
|
||||
print("Merging problems...")
|
||||
let mergedProblems = try mergeProblems(
|
||||
local: dataManager.problems,
|
||||
server: serverBackup.problems,
|
||||
imagePathMapping: imagePathMapping,
|
||||
deletedItems: serverBackup.deletedItems)
|
||||
|
||||
print("Merging sessions...")
|
||||
let mergedSessions = try mergeSessions(
|
||||
local: dataManager.sessions,
|
||||
server: serverBackup.sessions,
|
||||
deletedItems: serverBackup.deletedItems)
|
||||
|
||||
print("Merging attempts...")
|
||||
let mergedAttempts = try mergeAttempts(
|
||||
local: dataManager.attempts,
|
||||
server: serverBackup.attempts,
|
||||
deletedItems: serverBackup.deletedItems)
|
||||
|
||||
// Update data manager with merged data
|
||||
dataManager.gyms = mergedGyms
|
||||
dataManager.problems = mergedProblems
|
||||
dataManager.sessions = mergedSessions
|
||||
dataManager.attempts = mergedAttempts
|
||||
|
||||
// Save all data
|
||||
dataManager.saveGyms()
|
||||
dataManager.saveProblems()
|
||||
dataManager.saveSessions()
|
||||
dataManager.saveAttempts()
|
||||
dataManager.saveActiveSession()
|
||||
|
||||
// Merge deletion lists
|
||||
let localDeletions = dataManager.getDeletedItems()
|
||||
let allDeletions = localDeletions + serverBackup.deletedItems
|
||||
let uniqueDeletions = Array(Set(allDeletions))
|
||||
|
||||
// Update local deletions with merged list
|
||||
dataManager.clearDeletedItems()
|
||||
if let data = try? JSONEncoder().encode(uniqueDeletions) {
|
||||
UserDefaults.standard.set(data, forKey: "openclimb_deleted_items")
|
||||
}
|
||||
|
||||
// Upload merged data back to server
|
||||
let mergedBackup = createBackupFromDataManager(dataManager)
|
||||
_ = try await uploadData(mergedBackup)
|
||||
try await syncImagesToServer(dataManager: dataManager)
|
||||
|
||||
// Update timestamp
|
||||
DataStateManager.shared.updateDataState()
|
||||
}
|
||||
|
||||
private func importBackupToDataManager(
|
||||
_ backup: ClimbDataBackup, dataManager: ClimbingDataManager,
|
||||
imagePathMapping: [String: String] = [:]
|
||||
) throws {
|
||||
do {
|
||||
|
||||
// Store active sessions and their attempts before import
|
||||
let activeSessions = dataManager.sessions.filter { $0.status == .active }
|
||||
let activeSessionIds = Set(activeSessions.map { $0.id })
|
||||
@@ -501,7 +543,6 @@ class SyncService: ObservableObject {
|
||||
print("Data state synchronized to imported timestamp: \(backup.exportedAt)")
|
||||
|
||||
} catch {
|
||||
|
||||
throw SyncError.importFailed(error)
|
||||
}
|
||||
}
|
||||
@@ -817,6 +858,151 @@ class SyncService: ObservableObject {
|
||||
userDefaults.removeObject(forKey: Keys.isConnected)
|
||||
userDefaults.removeObject(forKey: Keys.autoSyncEnabled)
|
||||
}
|
||||
|
||||
// MARK: - Safe Merge Functions
|
||||
|
||||
private func mergeGyms(local: [Gym], server: [BackupGym], deletedItems: [DeletedItem]) -> [Gym]
|
||||
{
|
||||
var merged = local
|
||||
let deletedGymIds = Set(deletedItems.filter { $0.type == "gym" }.map { $0.id })
|
||||
|
||||
// Remove items that were deleted on other devices
|
||||
merged.removeAll { deletedGymIds.contains($0.id.uuidString) }
|
||||
|
||||
// Add new items from server (excluding deleted ones)
|
||||
for serverGym in server {
|
||||
if let serverGymConverted = try? serverGym.toGym() {
|
||||
let localHasGym = local.contains(where: { $0.id.uuidString == serverGym.id })
|
||||
let isDeleted = deletedGymIds.contains(serverGym.id)
|
||||
|
||||
if !localHasGym && !isDeleted {
|
||||
merged.append(serverGymConverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
private func mergeProblems(
|
||||
local: [Problem],
|
||||
server: [BackupProblem],
|
||||
imagePathMapping: [String: String],
|
||||
deletedItems: [DeletedItem]
|
||||
) throws -> [Problem] {
|
||||
var merged = local
|
||||
let deletedProblemIds = Set(deletedItems.filter { $0.type == "problem" }.map { $0.id })
|
||||
|
||||
// Remove items that were deleted on other devices
|
||||
merged.removeAll { deletedProblemIds.contains($0.id.uuidString) }
|
||||
|
||||
// Add new items from server (excluding deleted ones)
|
||||
for serverProblem in server {
|
||||
var problemToAdd = serverProblem
|
||||
|
||||
// Update image paths if needed
|
||||
if !imagePathMapping.isEmpty {
|
||||
let updatedImagePaths = serverProblem.imagePaths?.compactMap { oldPath in
|
||||
imagePathMapping[oldPath] ?? oldPath
|
||||
}
|
||||
problemToAdd = BackupProblem(
|
||||
id: serverProblem.id,
|
||||
gymId: serverProblem.gymId,
|
||||
name: serverProblem.name,
|
||||
description: serverProblem.description,
|
||||
climbType: serverProblem.climbType,
|
||||
difficulty: serverProblem.difficulty,
|
||||
tags: serverProblem.tags,
|
||||
location: serverProblem.location,
|
||||
imagePaths: updatedImagePaths,
|
||||
isActive: serverProblem.isActive,
|
||||
dateSet: serverProblem.dateSet,
|
||||
notes: serverProblem.notes,
|
||||
createdAt: serverProblem.createdAt,
|
||||
updatedAt: serverProblem.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
if let serverProblemConverted = try? problemToAdd.toProblem() {
|
||||
let localHasProblem = local.contains(where: { $0.id.uuidString == problemToAdd.id })
|
||||
let isDeleted = deletedProblemIds.contains(problemToAdd.id)
|
||||
|
||||
if !localHasProblem && !isDeleted {
|
||||
merged.append(serverProblemConverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
private func mergeSessions(
|
||||
local: [ClimbSession], server: [BackupClimbSession], deletedItems: [DeletedItem]
|
||||
) throws
|
||||
-> [ClimbSession]
|
||||
{
|
||||
var merged = local
|
||||
let deletedSessionIds = Set(deletedItems.filter { $0.type == "session" }.map { $0.id })
|
||||
|
||||
// Remove items that were deleted on other devices (but never remove active sessions)
|
||||
merged.removeAll { session in
|
||||
deletedSessionIds.contains(session.id.uuidString) && session.status != .active
|
||||
}
|
||||
|
||||
// Add new items from server (excluding deleted ones)
|
||||
for serverSession in server {
|
||||
if let serverSessionConverted = try? serverSession.toClimbSession() {
|
||||
let localHasSession = local.contains(where: { $0.id.uuidString == serverSession.id }
|
||||
)
|
||||
let isDeleted = deletedSessionIds.contains(serverSession.id)
|
||||
|
||||
if !localHasSession && !isDeleted {
|
||||
merged.append(serverSessionConverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
private func mergeAttempts(
|
||||
local: [Attempt], server: [BackupAttempt], deletedItems: [DeletedItem]
|
||||
) throws -> [Attempt] {
|
||||
var merged = local
|
||||
let deletedAttemptIds = Set(deletedItems.filter { $0.type == "attempt" }.map { $0.id })
|
||||
|
||||
// Get active session IDs to protect their attempts
|
||||
let activeSessionIds = Set(
|
||||
local.compactMap { attempt in
|
||||
// This is a simplified check - in a real implementation you'd want to cross-reference with sessions
|
||||
return attempt.sessionId
|
||||
}.filter { sessionId in
|
||||
// Check if this session ID belongs to an active session
|
||||
// For now, we'll be conservative and not delete attempts during merge
|
||||
return true
|
||||
})
|
||||
|
||||
// Remove items that were deleted on other devices (but be conservative with attempts)
|
||||
merged.removeAll { attempt in
|
||||
deletedAttemptIds.contains(attempt.id.uuidString)
|
||||
&& !activeSessionIds.contains(attempt.sessionId)
|
||||
}
|
||||
|
||||
// Add new items from server (excluding deleted ones)
|
||||
for serverAttempt in server {
|
||||
if let serverAttemptConverted = try? serverAttempt.toAttempt() {
|
||||
let localHasAttempt = local.contains(where: { $0.id.uuidString == serverAttempt.id }
|
||||
)
|
||||
let isDeleted = deletedAttemptIds.contains(serverAttempt.id)
|
||||
|
||||
if !localHasAttempt && !isDeleted {
|
||||
merged.append(serverAttemptConverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
}
|
||||
|
||||
enum SyncError: LocalizedError {
|
||||
|
||||
Reference in New Issue
Block a user