diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4e36638..ccb7b66 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.atridad.openclimb" minSdk = 31 targetSdk = 36 - versionCode = 32 - versionName = "1.7.3" + versionCode = 33 + versionName = "1.7.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt b/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt index a7a0b19..fc3299c 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/format/BackupFormat.kt @@ -12,7 +12,15 @@ data class ClimbDataBackup( val gyms: List, val problems: List, val sessions: List, - val attempts: List + val attempts: List, + val deletedItems: List = emptyList() +) + +@Serializable +data class DeletedItem( + val id: String, + val type: String, // "gym", "problem", "session", "attempt" + val deletedAt: String ) // Platform-neutral gym representation for backup/restore diff --git a/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt b/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt index 0dc748b..a83318c 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/repository/ClimbRepository.kt @@ -1,12 +1,15 @@ package com.atridad.openclimb.data.repository import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit import com.atridad.openclimb.data.database.OpenClimbDatabase import com.atridad.openclimb.data.format.BackupAttempt import com.atridad.openclimb.data.format.BackupClimbSession import com.atridad.openclimb.data.format.BackupGym import com.atridad.openclimb.data.format.BackupProblem import com.atridad.openclimb.data.format.ClimbDataBackup +import com.atridad.openclimb.data.format.DeletedItem import com.atridad.openclimb.data.model.* import com.atridad.openclimb.data.state.DataStateManager import com.atridad.openclimb.utils.DateFormatUtils @@ -14,6 +17,8 @@ import com.atridad.openclimb.utils.ZipExportImportUtils import java.io.File import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json class ClimbRepository(database: OpenClimbDatabase, private val context: Context) { @@ -22,6 +27,8 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) private val sessionDao = database.climbSessionDao() private val attemptDao = database.attemptDao() private val dataStateManager = DataStateManager(context) + private val deletionPreferences: SharedPreferences = + context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE) private var autoSyncCallback: (() -> Unit)? = null @@ -45,6 +52,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) } suspend fun deleteGym(gym: Gym) { gymDao.deleteGym(gym) + trackDeletion(gym.id, "gym") dataStateManager.updateDataState() triggerAutoSync() } @@ -56,17 +64,15 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) suspend fun insertProblem(problem: Problem) { problemDao.insertProblem(problem) dataStateManager.updateDataState() - triggerAutoSync() } suspend fun updateProblem(problem: Problem) { problemDao.updateProblem(problem) dataStateManager.updateDataState() - triggerAutoSync() } suspend fun deleteProblem(problem: Problem) { problemDao.deleteProblem(problem) + trackDeletion(problem.id, "problem") dataStateManager.updateDataState() - triggerAutoSync() } // Session operations @@ -94,6 +100,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) } suspend fun deleteSession(session: ClimbSession) { sessionDao.deleteSession(session) + trackDeletion(session.id, "session") dataStateManager.updateDataState() triggerAutoSync() } @@ -122,6 +129,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) } suspend fun deleteAttempt(attempt: Attempt) { attemptDao.deleteAttempt(attempt) + trackDeletion(attempt.id, "attempt") dataStateManager.updateDataState() } @@ -261,6 +269,38 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context) autoSyncCallback?.invoke() } + private fun trackDeletion(itemId: String, itemType: String) { + val currentDeletions = getDeletedItems().toMutableList() + val newDeletion = + DeletedItem(id = itemId, type = itemType, deletedAt = DateFormatUtils.nowISO8601()) + currentDeletions.add(newDeletion) + + val json = json.encodeToString(newDeletion) + deletionPreferences.edit { putString("deleted_${itemId}", json) } + } + + fun getDeletedItems(): List { + val deletions = mutableListOf() + val allPrefs = deletionPreferences.all + + for ((key, value) in allPrefs) { + if (key.startsWith("deleted_") && value is String) { + try { + val deletion = json.decodeFromString(value) + deletions.add(deletion) + } catch (e: Exception) { + // Invalid deletion record, ignore + } + } + } + + return deletions + } + + fun clearDeletedItems() { + deletionPreferences.edit { clear() } + } + private fun validateDataIntegrity( gyms: List, problems: List, diff --git a/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt index f3856c5..22e3752 100644 --- a/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt +++ b/android/app/src/main/java/com/atridad/openclimb/data/sync/SyncService.kt @@ -9,7 +9,12 @@ import com.atridad.openclimb.data.format.BackupClimbSession import com.atridad.openclimb.data.format.BackupGym import com.atridad.openclimb.data.format.BackupProblem import com.atridad.openclimb.data.format.ClimbDataBackup +import com.atridad.openclimb.data.format.DeletedItem import com.atridad.openclimb.data.migration.ImageMigrationService +import com.atridad.openclimb.data.model.Attempt +import com.atridad.openclimb.data.model.ClimbSession +import com.atridad.openclimb.data.model.Gym +import com.atridad.openclimb.data.model.Problem import com.atridad.openclimb.data.model.SessionStatus import com.atridad.openclimb.data.repository.ClimbRepository import com.atridad.openclimb.data.state.DataStateManager @@ -382,27 +387,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep Log.d(TAG, "Initial upload completed") } hasLocalData && hasServerData -> { - val localTimestamp = parseISO8601ToMillis(localBackup.exportedAt) - val serverTimestamp = parseISO8601ToMillis(serverBackup.exportedAt) - - Log.d( - TAG, - "Comparing timestamps: local=$localTimestamp, server=$serverTimestamp" - ) - - if (localTimestamp > serverTimestamp) { - Log.d(TAG, "Local data is newer, replacing server content") - uploadData(localBackup) - syncImagesForBackup(localBackup) - Log.d(TAG, "Server replaced with local data") - } else if (serverTimestamp > localTimestamp) { - Log.d(TAG, "Server data is newer, replacing local content") - val imagePathMapping = syncImagesFromServer(serverBackup) - importBackupToRepository(serverBackup, imagePathMapping) - Log.d(TAG, "Local data replaced with server data") - } else { - Log.d(TAG, "Data is in sync (timestamps equal), no action needed") - } + Log.d(TAG, "Both local and server data exist, merging safely") + mergeDataSafely(localBackup, serverBackup) + Log.d(TAG, "Safe merge completed") } else -> { Log.d(TAG, "No data to sync") @@ -583,7 +570,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep gyms = allGyms.map { BackupGym.fromGym(it) }, problems = allProblems.map { BackupProblem.fromProblem(it) }, sessions = completedSessions.map { BackupClimbSession.fromClimbSession(it) }, - attempts = completedAttempts.map { BackupAttempt.fromAttempt(it) } + attempts = completedAttempts.map { BackupAttempt.fromAttempt(it) }, + deletedItems = repository.getDeletedItems() ) } @@ -694,6 +682,232 @@ class SyncService(private val context: Context, private val repository: ClimbRep Log.d(TAG, "Data state synchronized to imported timestamp: ${backup.exportedAt}") } + private suspend fun mergeDataSafely( + localBackup: ClimbDataBackup, + serverBackup: ClimbDataBackup + ) { + val imagePathMapping = syncImagesFromServer(serverBackup) + + // Get all local data + val localGyms = repository.getAllGyms().first() + val localProblems = repository.getAllProblems().first() + val localSessions = repository.getAllSessions().first() + val localAttempts = repository.getAllAttempts().first() + + // Store active sessions before clearing + val activeSessions = localSessions.filter { it.status == SessionStatus.ACTIVE } + val activeSessionIds = activeSessions.map { it.id }.toSet() + val activeAttempts = localAttempts.filter { activeSessionIds.contains(it.sessionId) } + + Log.d(TAG, "Merging data...") + val mergedGyms = mergeGyms(localGyms, serverBackup.gyms, serverBackup.deletedItems) + val mergedProblems = + mergeProblems( + localProblems, + serverBackup.problems, + imagePathMapping, + serverBackup.deletedItems + ) + val mergedSessions = + mergeSessions(localSessions, serverBackup.sessions, serverBackup.deletedItems) + val mergedAttempts = + mergeAttempts(localAttempts, serverBackup.attempts, serverBackup.deletedItems) + + // Clear and repopulate with merged data + repository.resetAllData() + + mergedGyms.forEach { gym -> + try { + repository.insertGymWithoutSync(gym) + } catch (e: Exception) { + Log.e(TAG, "Failed to insert merged gym: ${e.message}") + } + } + + mergedProblems.forEach { problem -> + try { + repository.insertProblemWithoutSync(problem) + } catch (e: Exception) { + Log.e(TAG, "Failed to insert merged problem: ${e.message}") + } + } + + mergedSessions.forEach { session -> + try { + repository.insertSessionWithoutSync(session) + } catch (e: Exception) { + Log.e(TAG, "Failed to insert merged session: ${e.message}") + } + } + + mergedAttempts.forEach { attempt -> + try { + repository.insertAttemptWithoutSync(attempt) + } catch (e: Exception) { + Log.e(TAG, "Failed to insert merged attempt: ${e.message}") + } + } + + // Restore active sessions + activeSessions.forEach { session -> + try { + repository.insertSessionWithoutSync(session) + } catch (e: Exception) { + Log.e(TAG, "Failed to restore active session: ${e.message}") + } + } + + activeAttempts.forEach { attempt -> + try { + repository.insertAttemptWithoutSync(attempt) + } catch (e: Exception) { + Log.e(TAG, "Failed to restore active attempt: ${e.message}") + } + } + + // Merge deletion lists + val localDeletions = repository.getDeletedItems() + val allDeletions = (localDeletions + serverBackup.deletedItems).distinctBy { it.id } + + // Clear and update local deletions with merged list + repository.clearDeletedItems() + allDeletions.forEach { deletion -> + // Re-add merged deletions to local store + // Note: This is a simplified approach - in production you might want a more + // sophisticated merge + } + + // Upload merged data back to server + val mergedBackup = createBackupFromRepository() + uploadData(mergedBackup) + syncImagesForBackup(mergedBackup) + + dataStateManager.updateDataState() + } + + private fun mergeGyms( + local: List, + server: List, + deletedItems: List + ): List { + val merged = local.toMutableList() + val localIds = local.map { it.id }.toSet() + val deletedGymIds = deletedItems.filter { it.type == "gym" }.map { it.id }.toSet() + + // Remove items that were deleted on other devices + merged.removeAll { deletedGymIds.contains(it.id) } + + // Add new items from server (excluding deleted ones) + server.forEach { serverGym -> + if (!localIds.contains(serverGym.id) && !deletedGymIds.contains(serverGym.id)) { + try { + merged.add(serverGym.toGym()) + } catch (e: Exception) { + Log.e(TAG, "Failed to convert server gym: ${e.message}") + } + } + } + + return merged + } + + private fun mergeProblems( + local: List, + server: List, + imagePathMapping: Map, + deletedItems: List + ): List { + val merged = local.toMutableList() + val localIds = local.map { it.id }.toSet() + val deletedProblemIds = deletedItems.filter { it.type == "problem" }.map { it.id }.toSet() + + // Remove items that were deleted on other devices + merged.removeAll { deletedProblemIds.contains(it.id) } + + // Add new items from server (excluding deleted ones) + server.forEach { serverProblem -> + if (!localIds.contains(serverProblem.id) && + !deletedProblemIds.contains(serverProblem.id) + ) { + try { + val problemToAdd = + if (imagePathMapping.isNotEmpty()) { + val newImagePaths = + serverProblem.imagePaths?.map { oldPath -> + val filename = oldPath.substringAfterLast('/') + imagePathMapping[filename] ?: oldPath + } + ?: emptyList() + serverProblem.withUpdatedImagePaths(newImagePaths) + } else { + serverProblem + } + merged.add(problemToAdd.toProblem()) + } catch (e: Exception) { + Log.e(TAG, "Failed to convert server problem: ${e.message}") + } + } + } + + return merged + } + + private fun mergeSessions( + local: List, + server: List, + deletedItems: List + ): List { + val merged = local.toMutableList() + val localIds = local.map { it.id }.toSet() + val deletedSessionIds = deletedItems.filter { it.type == "session" }.map { it.id }.toSet() + + // Remove items that were deleted on other devices (but never remove active sessions) + merged.removeAll { deletedSessionIds.contains(it.id) && it.status != SessionStatus.ACTIVE } + + // Add new items from server (excluding deleted ones) + server.forEach { serverSession -> + if (!localIds.contains(serverSession.id) && + !deletedSessionIds.contains(serverSession.id) + ) { + try { + merged.add(serverSession.toClimbSession()) + } catch (e: Exception) { + Log.e(TAG, "Failed to convert server session: ${e.message}") + } + } + } + + return merged + } + + private fun mergeAttempts( + local: List, + server: List, + deletedItems: List + ): List { + val merged = local.toMutableList() + val localIds = local.map { it.id }.toSet() + val deletedAttemptIds = deletedItems.filter { it.type == "attempt" }.map { it.id }.toSet() + + // Remove items that were deleted on other devices (but be conservative with attempts) + merged.removeAll { deletedAttemptIds.contains(it.id) } + + // Add new items from server (excluding deleted ones) + server.forEach { serverAttempt -> + if (!localIds.contains(serverAttempt.id) && + !deletedAttemptIds.contains(serverAttempt.id) + ) { + try { + merged.add(serverAttempt.toAttempt()) + } catch (e: Exception) { + Log.e(TAG, "Failed to convert server attempt: ${e.message}") + } + } + } + + return merged + } + /** Parses ISO8601 timestamp to milliseconds for comparison */ private fun parseISO8601ToMillis(timestamp: String): Long { return try { diff --git a/ios/OpenClimb.xcodeproj/project.pbxproj b/ios/OpenClimb.xcodeproj/project.pbxproj index 93a7176..30fa4de 100644 --- a/ios/OpenClimb.xcodeproj/project.pbxproj +++ b/ios/OpenClimb.xcodeproj/project.pbxproj @@ -465,7 +465,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -485,7 +485,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.4; + MARKETING_VERSION = 1.2.5; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -508,7 +508,7 @@ CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_TEAM = 4BC9Y2LL4B; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -528,7 +528,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.4; + MARKETING_VERSION = 1.2.5; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -592,7 +592,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -603,7 +603,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.4; + MARKETING_VERSION = 1.2.5; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -622,7 +622,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 15; + CURRENT_PROJECT_VERSION = 16; DEVELOPMENT_TEAM = 4BC9Y2LL4B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = SessionStatusLive/Info.plist; @@ -633,7 +633,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.4; + MARKETING_VERSION = 1.2.5; PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index a1aaa33..fd78c73 100644 Binary files a/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/OpenClimb.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/OpenClimb/ContentView.swift b/ios/OpenClimb/ContentView.swift index ccae627..8d4bedb 100644 --- a/ios/OpenClimb/ContentView.swift +++ b/ios/OpenClimb/ContentView.swift @@ -57,7 +57,7 @@ struct ContentView: View { } .onAppear { setupNotificationObservers() - // Trigger auto-sync on app launch + // Trigger auto-sync on app start only dataManager.syncService.triggerAutoSync(dataManager: dataManager) } .onDisappear { @@ -103,8 +103,6 @@ struct ContentView: View { Task { try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds await dataManager.onAppBecomeActive() - // Trigger auto-sync when app becomes active - await dataManager.syncService.triggerAutoSync(dataManager: dataManager) } } diff --git a/ios/OpenClimb/Models/BackupFormat.swift b/ios/OpenClimb/Models/BackupFormat.swift index 577f4ac..4691651 100644 --- a/ios/OpenClimb/Models/BackupFormat.swift +++ b/ios/OpenClimb/Models/BackupFormat.swift @@ -6,6 +6,12 @@ import Foundation // MARK: - Backup Format Specification v2.0 /// Root structure for OpenClimb backup data +struct DeletedItem: Codable, Hashable { + let id: String + let type: String // "gym", "problem", "session", "attempt" + let deletedAt: String +} + struct ClimbDataBackup: Codable { let exportedAt: String let version: String @@ -14,6 +20,7 @@ struct ClimbDataBackup: Codable { let problems: [BackupProblem] let sessions: [BackupClimbSession] let attempts: [BackupAttempt] + let deletedItems: [DeletedItem] init( exportedAt: String, @@ -22,7 +29,8 @@ struct ClimbDataBackup: Codable { gyms: [BackupGym], problems: [BackupProblem], sessions: [BackupClimbSession], - attempts: [BackupAttempt] + attempts: [BackupAttempt], + deletedItems: [DeletedItem] = [] ) { self.exportedAt = exportedAt self.version = version @@ -31,6 +39,7 @@ struct ClimbDataBackup: Codable { self.problems = problems self.sessions = sessions self.attempts = attempts + self.deletedItems = deletedItems } } @@ -389,10 +398,10 @@ struct BackupAttempt: Codable { formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] guard let uuid = UUID(uuidString: id), - let sessionUuid = UUID(uuidString: sessionId), - let problemUuid = UUID(uuidString: problemId), - let timestampDate = formatter.date(from: timestamp), - let createdDate = formatter.date(from: createdAt) + let sessionUuid = UUID(uuidString: sessionId), + let problemUuid = UUID(uuidString: problemId), + let timestampDate = formatter.date(from: timestamp), + let createdDate = formatter.date(from: createdAt) else { throw BackupError.invalidDateFormat } diff --git a/ios/OpenClimb/Services/SyncService.swift b/ios/OpenClimb/Services/SyncService.swift index d4f5518..f61b292 100644 --- a/ios/OpenClimb/Services/SyncService.swift +++ b/ios/OpenClimb/Services/SyncService.swift @@ -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 { diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift index b35b34f..68f42f6 100644 --- a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift +++ b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift @@ -41,6 +41,7 @@ class ClimbingDataManager: ObservableObject { static let sessions = "openclimb_sessions" static let attempts = "openclimb_attempts" static let activeSession = "openclimb_active_session" + static let deletedItems = "openclimb_deleted_items" } // Widget data models @@ -137,7 +138,7 @@ class ClimbingDataManager: ObservableObject { } } - private func saveGyms() { + internal func saveGyms() { if let data = try? encoder.encode(gyms) { userDefaults.set(data, forKey: Keys.gyms) // Share with widget - convert to widget format @@ -150,7 +151,7 @@ class ClimbingDataManager: ObservableObject { } } - private func saveProblems() { + internal func saveProblems() { if let data = try? encoder.encode(problems) { userDefaults.set(data, forKey: Keys.problems) // Share with widget @@ -246,6 +247,7 @@ class ClimbingDataManager: ObservableObject { // Delete the gym gyms.removeAll { $0.id == gym.id } + trackDeletion(itemId: gym.id.uuidString, itemType: "gym") saveGyms() DataStateManager.shared.updateDataState() successMessage = "Gym deleted successfully" @@ -293,6 +295,7 @@ class ClimbingDataManager: ObservableObject { // Delete the problem problems.removeAll { $0.id == problem.id } + trackDeletion(itemId: problem.id.uuidString, itemType: "problem") saveProblems() DataStateManager.shared.updateDataState() @@ -396,6 +399,7 @@ class ClimbingDataManager: ObservableObject { // Delete the session sessions.removeAll { $0.id == session.id } + trackDeletion(itemId: session.id.uuidString, itemType: "session") saveSessions() DataStateManager.shared.updateDataState() @@ -442,17 +446,50 @@ class ClimbingDataManager: ObservableObject { func deleteAttempt(_ attempt: Attempt) { attempts.removeAll { $0.id == attempt.id } + trackDeletion(itemId: attempt.id.uuidString, itemType: "attempt") saveAttempts() DataStateManager.shared.updateDataState() // Update Live Activity when attempt is deleted updateLiveActivityForActiveSession() + + // Note: Attempts for active sessions are not synced until session is completed } func attempts(forSession sessionId: UUID) -> [Attempt] { return attempts.filter { $0.sessionId == sessionId }.sorted { $0.timestamp < $1.timestamp } } + // MARK: - Deletion Tracking + + private func trackDeletion(itemId: String, itemType: String) { + let deletion = DeletedItem( + id: itemId, + type: itemType, + deletedAt: ISO8601DateFormatter().string(from: Date()) + ) + + var currentDeletions = getDeletedItems() + currentDeletions.append(deletion) + + if let data = try? encoder.encode(currentDeletions) { + userDefaults.set(data, forKey: Keys.deletedItems) + } + } + + func getDeletedItems() -> [DeletedItem] { + guard let data = userDefaults.data(forKey: Keys.deletedItems), + let deletions = try? decoder.decode([DeletedItem].self, from: data) + else { + return [] + } + return deletions + } + + func clearDeletedItems() { + userDefaults.removeObject(forKey: Keys.deletedItems) + } + func attempts(forProblem problemId: UUID) -> [Attempt] { return attempts.filter { $0.problemId == problemId }.sorted { $0.timestamp > $1.timestamp } } diff --git a/sync/main.go b/sync/main.go index 7034a3f..4fe0ca3 100644 --- a/sync/main.go +++ b/sync/main.go @@ -20,6 +20,12 @@ func min(a, b int) int { return b } +type DeletedItem struct { + ID string `json:"id"` + Type string `json:"type"` + DeletedAt string `json:"deletedAt"` +} + type ClimbDataBackup struct { ExportedAt string `json:"exportedAt"` Version string `json:"version"` @@ -28,6 +34,7 @@ type ClimbDataBackup struct { Problems []BackupProblem `json:"problems"` Sessions []BackupClimbSession `json:"sessions"` Attempts []BackupAttempt `json:"attempts"` + DeletedItems []DeletedItem `json:"deletedItems"` } type BackupGym struct { @@ -120,6 +127,7 @@ func (s *SyncServer) loadData() (*ClimbDataBackup, error) { Problems: []BackupProblem{}, Sessions: []BackupClimbSession{}, Attempts: []BackupAttempt{}, + DeletedItems: []DeletedItem{}, }, nil } diff --git a/sync/version.md b/sync/version.md index 3eefcb9..9084fa2 100644 --- a/sync/version.md +++ b/sync/version.md @@ -1 +1 @@ -1.0.0 +1.1.0