Fixed major issue with sync logic. Should be stable now. Solidified with

tests... turns out syncing is hard...
This commit is contained in:
2025-10-06 18:04:56 -06:00
parent a19ff8ef66
commit 603a683ab2
4 changed files with 172 additions and 49 deletions

View File

@@ -595,19 +595,29 @@ class SyncService(private val context: Context, private val repository: ClimbRep
repository.resetAllData() repository.resetAllData()
// Filter out deleted gyms before importing
val deletedGymIds = backup.deletedItems.filter { it.type == "gym" }.map { it.id }.toSet()
backup.gyms.forEach { backupGym -> backup.gyms.forEach { backupGym ->
try { try {
if (!deletedGymIds.contains(backupGym.id)) {
val gym = backupGym.toGym() val gym = backupGym.toGym()
Log.d(TAG, "Importing gym: ${gym.name} (ID: ${gym.id})") Log.d(TAG, "Importing gym: ${gym.name} (ID: ${gym.id})")
repository.insertGymWithoutSync(gym) repository.insertGymWithoutSync(gym)
} else {
Log.d(TAG, "Skipping import of deleted gym: ${backupGym.id}")
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to import gym '${backupGym.name}': ${e.message}") Log.e(TAG, "Failed to import gym '${backupGym.name}': ${e.message}")
throw e throw e
} }
} }
// Filter out deleted problems before importing
val deletedProblemIds =
backup.deletedItems.filter { it.type == "problem" }.map { it.id }.toSet()
backup.problems.forEach { backupProblem -> backup.problems.forEach { backupProblem ->
try { try {
if (!deletedProblemIds.contains(backupProblem.id)) {
val updatedProblem = val updatedProblem =
if (imagePathMapping.isNotEmpty()) { if (imagePathMapping.isNotEmpty()) {
val newImagePaths = val newImagePaths =
@@ -626,7 +636,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep
oldPath oldPath
) )
val consistentFilename = val consistentFilename =
ImageNamingUtils.generateImageFilename( ImageNamingUtils
.generateImageFilename(
backupProblem.id, backupProblem.id,
index index
) )
@@ -639,22 +650,39 @@ class SyncService(private val context: Context, private val repository: ClimbRep
backupProblem backupProblem
} }
repository.insertProblemWithoutSync(updatedProblem.toProblem()) repository.insertProblemWithoutSync(updatedProblem.toProblem())
} else {
Log.d(TAG, "Skipping import of deleted problem: ${backupProblem.id}")
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to import problem '${backupProblem.name}': ${e.message}") 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 -> backup.sessions.forEach { backupSession ->
try { try {
if (!deletedSessionIds.contains(backupSession.id)) {
repository.insertSessionWithoutSync(backupSession.toClimbSession()) repository.insertSessionWithoutSync(backupSession.toClimbSession())
} else {
Log.d(TAG, "Skipping import of deleted session: ${backupSession.id}")
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to import session '${backupSession.id}': ${e.message}") 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 -> backup.attempts.forEach { backupAttempt ->
try { try {
if (!deletedAttemptIds.contains(backupAttempt.id)) {
repository.insertAttemptWithoutSync(backupAttempt.toAttempt()) repository.insertAttemptWithoutSync(backupAttempt.toAttempt())
} else {
Log.d(TAG, "Skipping import of deleted attempt: ${backupAttempt.id}")
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to import attempt '${backupAttempt.id}': ${e.message}") 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) dataStateManager.setLastModified(backup.exportedAt)
Log.d(TAG, "Data state synchronized to imported timestamp: ${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 localSessions = repository.getAllSessions().first()
val localAttempts = repository.getAllAttempts().first() val localAttempts = repository.getAllAttempts().first()
// Store active sessions before clearing // Store active sessions before clearing (but exclude any that were deleted)
val activeSessions = localSessions.filter { it.status == SessionStatus.ACTIVE } 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 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...") Log.d(TAG, "Merging data...")
val mergedGyms = mergeGyms(localGyms, serverBackup.gyms, serverBackup.deletedItems) 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 // Clear and update local deletions with merged list
repository.clearDeletedItems() repository.clearDeletedItems()
allDeletions.forEach { deletion -> allDeletions.forEach { deletion ->
// Re-add merged deletions to local store try {
// Note: This is a simplified approach - in production you might want a more val deletionJson = json.encodeToString(deletion)
// sophisticated merge 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 // Upload merged data back to server

View File

@@ -465,11 +465,25 @@ class SyncService: ObservableObject {
imagePathMapping: [String: String] = [:] imagePathMapping: [String: String] = [:]
) throws { ) throws {
do { do {
// Store active sessions and their attempts before import // Store active sessions and their attempts before import (but exclude any that were deleted)
let activeSessions = dataManager.sessions.filter { $0.status == .active } 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 activeSessionIds = Set(activeSessions.map { $0.id })
let allDeletedAttemptIds = Set(
(backup.deletedItems + localDeletedItems)
.filter { $0.type == "attempt" }
.map { $0.id }
)
let activeAttempts = dataManager.attempts.filter { let activeAttempts = dataManager.attempts.filter {
activeSessionIds.contains($0.sessionId) activeSessionIds.contains($0.sessionId)
&& !allDeletedAttemptIds.contains($0.id.uuidString)
} }
print( print(
@@ -500,18 +514,58 @@ class SyncService: ObservableObject {
updatedAt: problem.updatedAt 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( updatedBackup = ClimbDataBackup(
exportedAt: backup.exportedAt, exportedAt: backup.exportedAt,
version: backup.version, version: backup.version,
formatVersion: backup.formatVersion, formatVersion: backup.formatVersion,
gyms: backup.gyms, gyms: filteredGyms,
problems: updatedProblems, problems: filteredProblems,
sessions: backup.sessions, sessions: filteredSessions,
attempts: backup.attempts attempts: filteredAttempts,
deletedItems: backup.deletedItems
) )
} else { } 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 // Create a minimal ZIP with just the JSON data for existing import mechanism
@@ -538,6 +592,13 @@ class SyncService: ObservableObject {
dataManager.saveAttempts() dataManager.saveAttempts()
dataManager.saveActiveSession() 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 // Update local data state to match imported data timestamp
DataStateManager.shared.setLastModified(backup.exportedAt) DataStateManager.shared.setLastModified(backup.exportedAt)
print("Data state synchronized to imported timestamp: \(backup.exportedAt)") print("Data state synchronized to imported timestamp: \(backup.exportedAt)")

View File

@@ -452,8 +452,6 @@ class ClimbingDataManager: ObservableObject {
// Update Live Activity when attempt is deleted // Update Live Activity when attempt is deleted
updateLiveActivityForActiveSession() updateLiveActivityForActiveSession()
// Note: Attempts for active sessions are not synced until session is completed
} }
func attempts(forSession sessionId: UUID) -> [Attempt] { func attempts(forSession sessionId: UUID) -> [Attempt] {