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 22e3752..e5006c8 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 @@ -595,66 +595,94 @@ class SyncService(private val context: Context, private val repository: ClimbRep repository.resetAllData() + // Filter out deleted gyms before importing + val deletedGymIds = backup.deletedItems.filter { it.type == "gym" }.map { it.id }.toSet() backup.gyms.forEach { backupGym -> try { - val gym = backupGym.toGym() - Log.d(TAG, "Importing gym: ${gym.name} (ID: ${gym.id})") - repository.insertGymWithoutSync(gym) + if (!deletedGymIds.contains(backupGym.id)) { + val gym = backupGym.toGym() + Log.d(TAG, "Importing gym: ${gym.name} (ID: ${gym.id})") + repository.insertGymWithoutSync(gym) + } else { + Log.d(TAG, "Skipping import of deleted gym: ${backupGym.id}") + } } catch (e: Exception) { Log.e(TAG, "Failed to import gym '${backupGym.name}': ${e.message}") throw e } } + // Filter out deleted problems before importing + val deletedProblemIds = + backup.deletedItems.filter { it.type == "problem" }.map { it.id }.toSet() backup.problems.forEach { backupProblem -> try { - val updatedProblem = - if (imagePathMapping.isNotEmpty()) { - val newImagePaths = - backupProblem.imagePaths?.map { oldPath -> - val filename = oldPath.substringAfterLast('/') + if (!deletedProblemIds.contains(backupProblem.id)) { + val updatedProblem = + if (imagePathMapping.isNotEmpty()) { + val newImagePaths = + backupProblem.imagePaths?.map { oldPath -> + val filename = oldPath.substringAfterLast('/') - imagePathMapping[filename] - ?: if (ImageNamingUtils.isValidImageFilename( - filename - ) - ) { - "problem_images/$filename" - } else { - val index = - backupProblem.imagePaths.indexOf( - oldPath + imagePathMapping[filename] + ?: if (ImageNamingUtils.isValidImageFilename( + filename ) - val consistentFilename = - ImageNamingUtils.generateImageFilename( - backupProblem.id, - index - ) - "problem_images/$consistentFilename" - } - } - ?: emptyList() - backupProblem.withUpdatedImagePaths(newImagePaths) - } else { - backupProblem - } - repository.insertProblemWithoutSync(updatedProblem.toProblem()) + ) { + "problem_images/$filename" + } else { + val index = + backupProblem.imagePaths.indexOf( + oldPath + ) + val consistentFilename = + ImageNamingUtils + .generateImageFilename( + backupProblem.id, + index + ) + "problem_images/$consistentFilename" + } + } + ?: emptyList() + backupProblem.withUpdatedImagePaths(newImagePaths) + } else { + backupProblem + } + repository.insertProblemWithoutSync(updatedProblem.toProblem()) + } else { + Log.d(TAG, "Skipping import of deleted problem: ${backupProblem.id}") + } } catch (e: Exception) { Log.e(TAG, "Failed to import problem '${backupProblem.name}': ${e.message}") } } + // Filter out deleted sessions before importing + val deletedSessionIds = + backup.deletedItems.filter { it.type == "session" }.map { it.id }.toSet() backup.sessions.forEach { backupSession -> try { - repository.insertSessionWithoutSync(backupSession.toClimbSession()) + if (!deletedSessionIds.contains(backupSession.id)) { + repository.insertSessionWithoutSync(backupSession.toClimbSession()) + } else { + Log.d(TAG, "Skipping import of deleted session: ${backupSession.id}") + } } catch (e: Exception) { Log.e(TAG, "Failed to import session '${backupSession.id}': ${e.message}") } } + // Filter out deleted attempts before importing + val deletedAttemptIds = + backup.deletedItems.filter { it.type == "attempt" }.map { it.id }.toSet() backup.attempts.forEach { backupAttempt -> try { - repository.insertAttemptWithoutSync(backupAttempt.toAttempt()) + if (!deletedAttemptIds.contains(backupAttempt.id)) { + repository.insertAttemptWithoutSync(backupAttempt.toAttempt()) + } else { + Log.d(TAG, "Skipping import of deleted attempt: ${backupAttempt.id}") + } } catch (e: Exception) { Log.e(TAG, "Failed to import attempt '${backupAttempt.id}': ${e.message}") } @@ -678,6 +706,19 @@ class SyncService(private val context: Context, private val repository: ClimbRep } } + // Import deletion records to prevent future resurrections + backup.deletedItems.forEach { deletion -> + try { + val deletionJson = json.encodeToString(deletion) + val preferences = + context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE) + preferences.edit { putString("deleted_${deletion.id}", deletionJson) } + Log.d(TAG, "Imported deletion record: ${deletion.type} ${deletion.id}") + } catch (e: Exception) { + Log.e(TAG, "Failed to import deletion record: ${e.message}") + } + } + dataStateManager.setLastModified(backup.exportedAt) Log.d(TAG, "Data state synchronized to imported timestamp: ${backup.exportedAt}") } @@ -694,10 +735,27 @@ class SyncService(private val context: Context, private val repository: ClimbRep val localSessions = repository.getAllSessions().first() val localAttempts = repository.getAllAttempts().first() - // Store active sessions before clearing - val activeSessions = localSessions.filter { it.status == SessionStatus.ACTIVE } + // Store active sessions before clearing (but exclude any that were deleted) + val localDeletedItems = repository.getDeletedItems() + val allDeletedSessionIds = + (serverBackup.deletedItems + localDeletedItems) + .filter { it.type == "session" } + .map { it.id } + .toSet() + val activeSessions = + localSessions.filter { + it.status == SessionStatus.ACTIVE && !allDeletedSessionIds.contains(it.id) + } val activeSessionIds = activeSessions.map { it.id }.toSet() - val activeAttempts = localAttempts.filter { activeSessionIds.contains(it.sessionId) } + val allDeletedAttemptIds = + (serverBackup.deletedItems + localDeletedItems) + .filter { it.type == "attempt" } + .map { it.id } + .toSet() + val activeAttempts = + localAttempts.filter { + activeSessionIds.contains(it.sessionId) && !allDeletedAttemptIds.contains(it.id) + } Log.d(TAG, "Merging data...") val mergedGyms = mergeGyms(localGyms, serverBackup.gyms, serverBackup.deletedItems) @@ -772,9 +830,15 @@ class SyncService(private val context: Context, private val repository: ClimbRep // 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 + try { + val deletionJson = json.encodeToString(deletion) + val preferences = + context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE) + preferences.edit { putString("deleted_${deletion.id}", deletionJson) } + Log.d(TAG, "Merged deletion record: ${deletion.type} ${deletion.id}") + } catch (e: Exception) { + Log.e(TAG, "Failed to save merged deletion: ${e.message}") + } } // Upload merged data back to server 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 fd78c73..1879d69 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/Services/SyncService.swift b/ios/OpenClimb/Services/SyncService.swift index f61b292..facff7b 100644 --- a/ios/OpenClimb/Services/SyncService.swift +++ b/ios/OpenClimb/Services/SyncService.swift @@ -465,11 +465,25 @@ class SyncService: ObservableObject { imagePathMapping: [String: String] = [:] ) throws { do { - // Store active sessions and their attempts before import - let activeSessions = dataManager.sessions.filter { $0.status == .active } + // Store active sessions and their attempts before import (but exclude any that were deleted) + let localDeletedItems = dataManager.getDeletedItems() + let allDeletedSessionIds = Set( + (backup.deletedItems + localDeletedItems) + .filter { $0.type == "session" } + .map { $0.id } + ) + let activeSessions = dataManager.sessions.filter { + $0.status == .active && !allDeletedSessionIds.contains($0.id.uuidString) + } let activeSessionIds = Set(activeSessions.map { $0.id }) + let allDeletedAttemptIds = Set( + (backup.deletedItems + localDeletedItems) + .filter { $0.type == "attempt" } + .map { $0.id } + ) let activeAttempts = dataManager.attempts.filter { activeSessionIds.contains($0.sessionId) + && !allDeletedAttemptIds.contains($0.id.uuidString) } print( @@ -500,18 +514,58 @@ class SyncService: ObservableObject { updatedAt: problem.updatedAt ) } + // Filter out deleted items before creating updated backup + let deletedGymIds = Set( + backup.deletedItems.filter { $0.type == "gym" }.map { $0.id }) + let deletedProblemIds = Set( + backup.deletedItems.filter { $0.type == "problem" }.map { $0.id }) + let deletedSessionIds = Set( + backup.deletedItems.filter { $0.type == "session" }.map { $0.id }) + let deletedAttemptIds = Set( + backup.deletedItems.filter { $0.type == "attempt" }.map { $0.id }) + + let filteredGyms = backup.gyms.filter { !deletedGymIds.contains($0.id) } + let filteredProblems = updatedProblems.filter { !deletedProblemIds.contains($0.id) } + let filteredSessions = backup.sessions.filter { !deletedSessionIds.contains($0.id) } + let filteredAttempts = backup.attempts.filter { !deletedAttemptIds.contains($0.id) } + updatedBackup = ClimbDataBackup( exportedAt: backup.exportedAt, version: backup.version, formatVersion: backup.formatVersion, - gyms: backup.gyms, - problems: updatedProblems, - sessions: backup.sessions, - attempts: backup.attempts + gyms: filteredGyms, + problems: filteredProblems, + sessions: filteredSessions, + attempts: filteredAttempts, + deletedItems: backup.deletedItems ) } else { - updatedBackup = backup + // Filter out deleted items even when no image path mapping + let deletedGymIds = Set( + backup.deletedItems.filter { $0.type == "gym" }.map { $0.id }) + let deletedProblemIds = Set( + backup.deletedItems.filter { $0.type == "problem" }.map { $0.id }) + let deletedSessionIds = Set( + backup.deletedItems.filter { $0.type == "session" }.map { $0.id }) + let deletedAttemptIds = Set( + backup.deletedItems.filter { $0.type == "attempt" }.map { $0.id }) + + let filteredGyms = backup.gyms.filter { !deletedGymIds.contains($0.id) } + let filteredProblems = backup.problems.filter { !deletedProblemIds.contains($0.id) } + let filteredSessions = backup.sessions.filter { !deletedSessionIds.contains($0.id) } + let filteredAttempts = backup.attempts.filter { !deletedAttemptIds.contains($0.id) } + + updatedBackup = ClimbDataBackup( + exportedAt: backup.exportedAt, + version: backup.version, + formatVersion: backup.formatVersion, + gyms: filteredGyms, + problems: filteredProblems, + sessions: filteredSessions, + attempts: filteredAttempts, + deletedItems: backup.deletedItems + ) } // Create a minimal ZIP with just the JSON data for existing import mechanism @@ -538,6 +592,13 @@ class SyncService: ObservableObject { dataManager.saveAttempts() dataManager.saveActiveSession() + // Import deletion records to prevent future resurrections + dataManager.clearDeletedItems() + if let data = try? JSONEncoder().encode(backup.deletedItems) { + UserDefaults.standard.set(data, forKey: "openclimb_deleted_items") + print("iOS IMPORT: Imported \(backup.deletedItems.count) deletion records") + } + // Update local data state to match imported data timestamp DataStateManager.shared.setLastModified(backup.exportedAt) print("Data state synchronized to imported timestamp: \(backup.exportedAt)") diff --git a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift index 68f42f6..2e94b37 100644 --- a/ios/OpenClimb/ViewModels/ClimbingDataManager.swift +++ b/ios/OpenClimb/ViewModels/ClimbingDataManager.swift @@ -452,8 +452,6 @@ class ClimbingDataManager: ObservableObject { // 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] {