Fixed major issue with sync logic. Should be stable now. Solidified with
tests... turns out syncing is hard...
This commit is contained in:
@@ -595,66 +595,94 @@ 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 {
|
||||||
val gym = backupGym.toGym()
|
if (!deletedGymIds.contains(backupGym.id)) {
|
||||||
Log.d(TAG, "Importing gym: ${gym.name} (ID: ${gym.id})")
|
val gym = backupGym.toGym()
|
||||||
repository.insertGymWithoutSync(gym)
|
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) {
|
} 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 {
|
||||||
val updatedProblem =
|
if (!deletedProblemIds.contains(backupProblem.id)) {
|
||||||
if (imagePathMapping.isNotEmpty()) {
|
val updatedProblem =
|
||||||
val newImagePaths =
|
if (imagePathMapping.isNotEmpty()) {
|
||||||
backupProblem.imagePaths?.map { oldPath ->
|
val newImagePaths =
|
||||||
val filename = oldPath.substringAfterLast('/')
|
backupProblem.imagePaths?.map { oldPath ->
|
||||||
|
val filename = oldPath.substringAfterLast('/')
|
||||||
|
|
||||||
imagePathMapping[filename]
|
imagePathMapping[filename]
|
||||||
?: if (ImageNamingUtils.isValidImageFilename(
|
?: if (ImageNamingUtils.isValidImageFilename(
|
||||||
filename
|
filename
|
||||||
)
|
|
||||||
) {
|
|
||||||
"problem_images/$filename"
|
|
||||||
} else {
|
|
||||||
val index =
|
|
||||||
backupProblem.imagePaths.indexOf(
|
|
||||||
oldPath
|
|
||||||
)
|
)
|
||||||
val consistentFilename =
|
) {
|
||||||
ImageNamingUtils.generateImageFilename(
|
"problem_images/$filename"
|
||||||
backupProblem.id,
|
} else {
|
||||||
index
|
val index =
|
||||||
)
|
backupProblem.imagePaths.indexOf(
|
||||||
"problem_images/$consistentFilename"
|
oldPath
|
||||||
}
|
)
|
||||||
}
|
val consistentFilename =
|
||||||
?: emptyList()
|
ImageNamingUtils
|
||||||
backupProblem.withUpdatedImagePaths(newImagePaths)
|
.generateImageFilename(
|
||||||
} else {
|
backupProblem.id,
|
||||||
backupProblem
|
index
|
||||||
}
|
)
|
||||||
repository.insertProblemWithoutSync(updatedProblem.toProblem())
|
"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) {
|
} 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 {
|
||||||
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) {
|
} 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 {
|
||||||
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) {
|
} 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
|
||||||
|
|||||||
Binary file not shown.
@@ -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)")
|
||||||
|
|||||||
@@ -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] {
|
||||||
|
|||||||
Reference in New Issue
Block a user