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,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

View File

@@ -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)")

View File

@@ -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] {