Compare commits
4 Commits
IOS_1.2.3
...
ANDROID_1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
603a683ab2
|
|||
|
a19ff8ef66
|
|||
|
c10fa48bf5
|
|||
|
acf487db93
|
@@ -16,8 +16,8 @@ android {
|
||||
applicationId = "com.atridad.openclimb"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 30
|
||||
versionName = "1.7.2"
|
||||
versionCode = 33
|
||||
versionName = "1.7.4"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,15 @@ data class ClimbDataBackup(
|
||||
val gyms: List<BackupGym>,
|
||||
val problems: List<BackupProblem>,
|
||||
val sessions: List<BackupClimbSession>,
|
||||
val attempts: List<BackupAttempt>
|
||||
val attempts: List<BackupAttempt>,
|
||||
val deletedItems: List<DeletedItem> = 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
|
||||
|
||||
@@ -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
|
||||
@@ -79,15 +85,22 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
suspend fun insertSession(session: ClimbSession) {
|
||||
sessionDao.insertSession(session)
|
||||
dataStateManager.updateDataState()
|
||||
triggerAutoSync()
|
||||
// Only trigger sync for completed sessions
|
||||
if (session.status != SessionStatus.ACTIVE) {
|
||||
triggerAutoSync()
|
||||
}
|
||||
}
|
||||
suspend fun updateSession(session: ClimbSession) {
|
||||
sessionDao.updateSession(session)
|
||||
dataStateManager.updateDataState()
|
||||
triggerAutoSync()
|
||||
// Only trigger sync for completed sessions
|
||||
if (session.status != SessionStatus.ACTIVE) {
|
||||
triggerAutoSync()
|
||||
}
|
||||
}
|
||||
suspend fun deleteSession(session: ClimbSession) {
|
||||
sessionDao.deleteSession(session)
|
||||
trackDeletion(session.id, "session")
|
||||
dataStateManager.updateDataState()
|
||||
triggerAutoSync()
|
||||
}
|
||||
@@ -109,17 +122,15 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
||||
suspend fun insertAttempt(attempt: Attempt) {
|
||||
attemptDao.insertAttempt(attempt)
|
||||
dataStateManager.updateDataState()
|
||||
triggerAutoSync()
|
||||
}
|
||||
suspend fun updateAttempt(attempt: Attempt) {
|
||||
attemptDao.updateAttempt(attempt)
|
||||
dataStateManager.updateDataState()
|
||||
triggerAutoSync()
|
||||
}
|
||||
suspend fun deleteAttempt(attempt: Attempt) {
|
||||
attemptDao.deleteAttempt(attempt)
|
||||
trackDeletion(attempt.id, "attempt")
|
||||
dataStateManager.updateDataState()
|
||||
triggerAutoSync()
|
||||
}
|
||||
|
||||
suspend fun exportAllDataToZipUri(context: Context, uri: android.net.Uri) {
|
||||
@@ -258,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<DeletedItem> {
|
||||
val deletions = mutableListOf<DeletedItem>()
|
||||
val allPrefs = deletionPreferences.all
|
||||
|
||||
for ((key, value) in allPrefs) {
|
||||
if (key.startsWith("deleted_") && value is String) {
|
||||
try {
|
||||
val deletion = json.decodeFromString<DeletedItem>(value)
|
||||
deletions.add(deletion)
|
||||
} catch (e: Exception) {
|
||||
// Invalid deletion record, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deletions
|
||||
}
|
||||
|
||||
fun clearDeletedItems() {
|
||||
deletionPreferences.edit { clear() }
|
||||
}
|
||||
|
||||
private fun validateDataIntegrity(
|
||||
gyms: List<Gym>,
|
||||
problems: List<Problem>,
|
||||
|
||||
@@ -9,7 +9,13 @@ 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
|
||||
import com.atridad.openclimb.utils.DateFormatUtils
|
||||
@@ -381,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")
|
||||
@@ -564,14 +552,26 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
val allSessions = repository.getAllSessions().first()
|
||||
val allAttempts = repository.getAllAttempts().first()
|
||||
|
||||
// Filter out active sessions and their attempts from sync
|
||||
val completedSessions = allSessions.filter { it.status != SessionStatus.ACTIVE }
|
||||
val activeSessionIds =
|
||||
allSessions.filter { it.status == SessionStatus.ACTIVE }.map { it.id }.toSet()
|
||||
val completedAttempts = allAttempts.filter { !activeSessionIds.contains(it.sessionId) }
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Sync exclusions: ${allSessions.size - completedSessions.size} active sessions, ${allAttempts.size - completedAttempts.size} active session attempts"
|
||||
)
|
||||
|
||||
return ClimbDataBackup(
|
||||
exportedAt = dataStateManager.getLastModified(),
|
||||
version = "2.0",
|
||||
formatVersion = "2.0",
|
||||
gyms = allGyms.map { BackupGym.fromGym(it) },
|
||||
problems = allProblems.map { BackupProblem.fromProblem(it) },
|
||||
sessions = allSessions.map { BackupClimbSession.fromClimbSession(it) },
|
||||
attempts = allAttempts.map { BackupAttempt.fromAttempt(it) }
|
||||
sessions = completedSessions.map { BackupClimbSession.fromClimbSession(it) },
|
||||
attempts = completedAttempts.map { BackupAttempt.fromAttempt(it) },
|
||||
deletedItems = repository.getDeletedItems()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -579,77 +579,399 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
||||
backup: ClimbDataBackup,
|
||||
imagePathMapping: Map<String, String> = emptyMap()
|
||||
) {
|
||||
// Store active sessions and their attempts before reset
|
||||
val activeSessions =
|
||||
repository.getAllSessions().first().filter { it.status == SessionStatus.ACTIVE }
|
||||
val activeSessionIds = activeSessions.map { it.id }.toSet()
|
||||
val activeAttempts =
|
||||
repository.getAllAttempts().first().filter {
|
||||
activeSessionIds.contains(it.sessionId)
|
||||
}
|
||||
|
||||
Log.d(
|
||||
TAG,
|
||||
"Preserving ${activeSessions.size} active sessions and ${activeAttempts.size} active attempts during import"
|
||||
)
|
||||
|
||||
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}")
|
||||
}
|
||||
}
|
||||
|
||||
// Restore active sessions and their attempts after import
|
||||
activeSessions.forEach { session ->
|
||||
try {
|
||||
Log.d(TAG, "Restoring active session: ${session.id}")
|
||||
repository.insertSessionWithoutSync(session)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to restore active session '${session.id}': ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
activeAttempts.forEach { attempt ->
|
||||
try {
|
||||
repository.insertAttemptWithoutSync(attempt)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to restore active attempt '${attempt.id}': ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
// 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}")
|
||||
}
|
||||
|
||||
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 (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 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)
|
||||
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 ->
|
||||
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
|
||||
val mergedBackup = createBackupFromRepository()
|
||||
uploadData(mergedBackup)
|
||||
syncImagesForBackup(mergedBackup)
|
||||
|
||||
dataStateManager.updateDataState()
|
||||
}
|
||||
|
||||
private fun mergeGyms(
|
||||
local: List<Gym>,
|
||||
server: List<BackupGym>,
|
||||
deletedItems: List<DeletedItem>
|
||||
): List<Gym> {
|
||||
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<Problem>,
|
||||
server: List<BackupProblem>,
|
||||
imagePathMapping: Map<String, String>,
|
||||
deletedItems: List<DeletedItem>
|
||||
): List<Problem> {
|
||||
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<ClimbSession>,
|
||||
server: List<BackupClimbSession>,
|
||||
deletedItems: List<DeletedItem>
|
||||
): List<ClimbSession> {
|
||||
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<Attempt>,
|
||||
server: List<BackupAttempt>,
|
||||
deletedItems: List<DeletedItem>
|
||||
): List<Attempt> {
|
||||
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 {
|
||||
|
||||
@@ -454,4 +454,61 @@ class SyncMergeLogicTest {
|
||||
dateString1 > dateString2
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test active sessions excluded from sync`() {
|
||||
// Test scenario: Active sessions should not be included in sync data
|
||||
// This tests the new behavior where active sessions are excluded from sync
|
||||
// until they are completed
|
||||
|
||||
val allLocalSessions =
|
||||
listOf(
|
||||
BackupClimbSession(
|
||||
id = "active_session_1",
|
||||
gymId = "gym1",
|
||||
date = "2024-01-01",
|
||||
startTime = "2024-01-01T10:00:00",
|
||||
endTime = null,
|
||||
duration = null,
|
||||
status = SessionStatus.ACTIVE,
|
||||
notes = null,
|
||||
createdAt = "2024-01-01T10:00:00",
|
||||
updatedAt = "2024-01-01T10:00:00"
|
||||
),
|
||||
BackupClimbSession(
|
||||
id = "completed_session_1",
|
||||
gymId = "gym1",
|
||||
date = "2023-12-31",
|
||||
startTime = "2023-12-31T15:00:00",
|
||||
endTime = "2023-12-31T17:00:00",
|
||||
duration = 7200000,
|
||||
status = SessionStatus.COMPLETED,
|
||||
notes = "Previous session",
|
||||
createdAt = "2023-12-31T15:00:00",
|
||||
updatedAt = "2023-12-31T17:00:00"
|
||||
)
|
||||
)
|
||||
|
||||
// Simulate filtering that would happen in createBackupFromRepository
|
||||
val sessionsForSync = allLocalSessions.filter { it.status != SessionStatus.ACTIVE }
|
||||
|
||||
// Only completed sessions should be included in sync
|
||||
assertEquals("Should only include completed sessions in sync", 1, sessionsForSync.size)
|
||||
|
||||
// Active session should be excluded
|
||||
assertFalse(
|
||||
"Should not contain active session in sync",
|
||||
sessionsForSync.any {
|
||||
it.id == "active_session_1" && it.status == SessionStatus.ACTIVE
|
||||
}
|
||||
)
|
||||
|
||||
// Completed session should be included
|
||||
assertTrue(
|
||||
"Should contain completed session in sync",
|
||||
sessionsForSync.any {
|
||||
it.id == "completed_session_1" && it.status == SessionStatus.COMPLETED
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,7 +465,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 14;
|
||||
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.3;
|
||||
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 = 14;
|
||||
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.3;
|
||||
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 = 14;
|
||||
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.3;
|
||||
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 = 14;
|
||||
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.3;
|
||||
MARKETING_VERSION = 1.2.5;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -378,7 +352,6 @@ class SyncService: ObservableObject {
|
||||
print("Renamed local image: \(filename) -> \(consistentFilename)")
|
||||
|
||||
// Update problem's image path in memory for consistency
|
||||
// Note: This would require updating the problem in the data manager
|
||||
} catch {
|
||||
print("Failed to rename local image, using original: \(error)")
|
||||
}
|
||||
@@ -397,20 +370,125 @@ class SyncService: ObservableObject {
|
||||
|
||||
private func createBackupFromDataManager(_ dataManager: ClimbingDataManager) -> ClimbDataBackup
|
||||
{
|
||||
// Filter out active sessions and their attempts from sync
|
||||
let completedSessions = dataManager.sessions.filter { $0.status != .active }
|
||||
let activeSessionIds = Set(
|
||||
dataManager.sessions.filter { $0.status == .active }.map { $0.id })
|
||||
let completedAttempts = dataManager.attempts.filter {
|
||||
!activeSessionIds.contains($0.sessionId)
|
||||
}
|
||||
|
||||
print(
|
||||
"iOS SYNC: Excluding \(dataManager.sessions.count - completedSessions.count) active sessions and \(dataManager.attempts.count - completedAttempts.count) active session attempts from sync"
|
||||
)
|
||||
|
||||
return ClimbDataBackup(
|
||||
exportedAt: DataStateManager.shared.getLastModified(),
|
||||
gyms: dataManager.gyms.map { BackupGym(from: $0) },
|
||||
problems: dataManager.problems.map { BackupProblem(from: $0) },
|
||||
sessions: dataManager.sessions.map { BackupClimbSession(from: $0) },
|
||||
attempts: dataManager.attempts.map { BackupAttempt(from: $0) }
|
||||
sessions: completedSessions.map { BackupClimbSession(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 (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(
|
||||
"iOS IMPORT: Preserving \(activeSessions.count) active sessions and \(activeAttempts.count) active attempts during import"
|
||||
)
|
||||
|
||||
// Update problem image paths to point to downloaded images
|
||||
let updatedBackup: ClimbDataBackup
|
||||
@@ -436,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
|
||||
@@ -456,12 +574,36 @@ class SyncService: ObservableObject {
|
||||
// Use existing import method which properly handles data restoration
|
||||
try dataManager.importData(from: zipData, showSuccessMessage: false)
|
||||
|
||||
// Restore active sessions and their attempts after import
|
||||
for session in activeSessions {
|
||||
print("iOS IMPORT: Restoring active session: \(session.id)")
|
||||
dataManager.sessions.append(session)
|
||||
if session.id == dataManager.activeSession?.id {
|
||||
dataManager.activeSession = session
|
||||
}
|
||||
}
|
||||
|
||||
for attempt in activeAttempts {
|
||||
dataManager.attempts.append(attempt)
|
||||
}
|
||||
|
||||
// Save restored data
|
||||
dataManager.saveSessions()
|
||||
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)")
|
||||
|
||||
} catch {
|
||||
|
||||
throw SyncError.importFailed(error)
|
||||
}
|
||||
}
|
||||
@@ -777,6 +919,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 {
|
||||
|
||||
@@ -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
|
||||
@@ -158,7 +159,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveSessions() {
|
||||
internal func saveSessions() {
|
||||
if let data = try? encoder.encode(sessions) {
|
||||
userDefaults.set(data, forKey: Keys.sessions)
|
||||
// Share with widget - convert to widget format
|
||||
@@ -176,7 +177,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAttempts() {
|
||||
internal func saveAttempts() {
|
||||
if let data = try? encoder.encode(attempts) {
|
||||
userDefaults.set(data, forKey: Keys.attempts)
|
||||
// Share with widget - convert to widget format
|
||||
@@ -197,7 +198,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveActiveSession() {
|
||||
internal func saveActiveSession() {
|
||||
if let activeSession = activeSession,
|
||||
let data = try? encoder.encode(activeSession)
|
||||
{
|
||||
@@ -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()
|
||||
|
||||
@@ -326,9 +329,6 @@ class ClimbingDataManager: ObservableObject {
|
||||
saveSessions()
|
||||
DataStateManager.shared.updateDataState()
|
||||
|
||||
successMessage = "Session started successfully"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// MARK: - Start Live Activity for new session
|
||||
if let gym = gym(withId: gymId) {
|
||||
Task {
|
||||
@@ -336,9 +336,6 @@ class ClimbingDataManager: ObservableObject {
|
||||
for: newSession, gymName: gym.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
}
|
||||
|
||||
func endSession(_ sessionId: UUID) {
|
||||
@@ -356,8 +353,6 @@ class ClimbingDataManager: ObservableObject {
|
||||
saveActiveSession()
|
||||
saveSessions()
|
||||
DataStateManager.shared.updateDataState()
|
||||
successMessage = "Session completed successfully"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
@@ -380,14 +375,14 @@ class ClimbingDataManager: ObservableObject {
|
||||
|
||||
saveSessions()
|
||||
DataStateManager.shared.updateDataState()
|
||||
successMessage = "Session updated successfully"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// Update Live Activity when session is updated
|
||||
updateLiveActivityForActiveSession()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
// Only trigger sync if session is completed
|
||||
if session.status != .active {
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,10 +399,9 @@ class ClimbingDataManager: ObservableObject {
|
||||
|
||||
// Delete the session
|
||||
sessions.removeAll { $0.id == session.id }
|
||||
trackDeletion(itemId: session.id.uuidString, itemType: "session")
|
||||
saveSessions()
|
||||
DataStateManager.shared.updateDataState()
|
||||
successMessage = "Session deleted successfully"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// Update Live Activity when session is deleted
|
||||
updateLiveActivityForActiveSession()
|
||||
@@ -435,12 +429,6 @@ class ClimbingDataManager: ObservableObject {
|
||||
saveAttempts()
|
||||
DataStateManager.shared.updateDataState()
|
||||
|
||||
successMessage = "Attempt logged successfully"
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// Update Live Activity when new attempt is added
|
||||
updateLiveActivityForActiveSession()
|
||||
}
|
||||
@@ -450,35 +438,56 @@ class ClimbingDataManager: ObservableObject {
|
||||
attempts[index] = attempt
|
||||
saveAttempts()
|
||||
DataStateManager.shared.updateDataState()
|
||||
successMessage = "Attempt updated successfully"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// Update Live Activity when attempt is updated
|
||||
updateLiveActivityForActiveSession()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteAttempt(_ attempt: Attempt) {
|
||||
attempts.removeAll { $0.id == attempt.id }
|
||||
trackDeletion(itemId: attempt.id.uuidString, itemType: "attempt")
|
||||
saveAttempts()
|
||||
DataStateManager.shared.updateDataState()
|
||||
successMessage = "Attempt deleted successfully"
|
||||
clearMessageAfterDelay()
|
||||
|
||||
// Update Live Activity when attempt is deleted
|
||||
updateLiveActivityForActiveSession()
|
||||
|
||||
// Trigger auto-sync if enabled
|
||||
syncService.triggerAutoSync(dataManager: self)
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -131,7 +131,6 @@ final class LiveActivityManager {
|
||||
)
|
||||
|
||||
await currentActivity.update(.init(state: updatedContentState, staleDate: nil))
|
||||
print("Live Activity updated successfully")
|
||||
}
|
||||
|
||||
/// Call this when a ClimbSession ends to end the Live Activity
|
||||
|
||||
@@ -252,4 +252,78 @@ final class OpenClimbTests: XCTestCase {
|
||||
XCTAssertNotNil(parsedDate)
|
||||
XCTAssertEqual(date.timeIntervalSince1970, parsedDate!.timeIntervalSince1970, accuracy: 1.0)
|
||||
}
|
||||
|
||||
// MARK: - Active Session Preservation Tests
|
||||
|
||||
func testActiveSessionPreservationDuringImport() throws {
|
||||
// Test that active sessions are preserved during import operations
|
||||
// This tests the fix for the bug where active sessions disappear after sync
|
||||
|
||||
// Simulate an active session that exists locally but not in import data
|
||||
let activeSessionId = UUID()
|
||||
let gymId = UUID()
|
||||
|
||||
// Test data structure representing local active session
|
||||
let localActiveSession: [String: Any] = [
|
||||
"id": activeSessionId.uuidString,
|
||||
"gymId": gymId.uuidString,
|
||||
"status": "active",
|
||||
"date": "2024-01-01",
|
||||
"startTime": "2024-01-01T10:00:00Z",
|
||||
]
|
||||
|
||||
// Test data structure representing server sessions (without the active one)
|
||||
let serverSessions: [[String: Any]] = [
|
||||
[
|
||||
"id": UUID().uuidString,
|
||||
"gymId": gymId.uuidString,
|
||||
"status": "completed",
|
||||
"date": "2023-12-31",
|
||||
"startTime": "2023-12-31T15:00:00Z",
|
||||
"endTime": "2023-12-31T17:00:00Z",
|
||||
]
|
||||
]
|
||||
|
||||
// Verify test setup
|
||||
XCTAssertEqual(localActiveSession["status"] as? String, "active")
|
||||
XCTAssertEqual(serverSessions.count, 1)
|
||||
XCTAssertEqual(serverSessions[0]["status"] as? String, "completed")
|
||||
|
||||
// Verify that the active session ID is not in the server sessions
|
||||
let serverSessionIds = serverSessions.compactMap { $0["id"] as? String }
|
||||
XCTAssertFalse(serverSessionIds.contains(activeSessionId.uuidString))
|
||||
|
||||
// Test that we can identify an active session
|
||||
if let status = localActiveSession["status"] as? String {
|
||||
XCTAssertTrue(status == "active")
|
||||
} else {
|
||||
XCTFail("Failed to extract session status")
|
||||
}
|
||||
|
||||
// Test session ID validation
|
||||
if let sessionIdString = localActiveSession["id"] as? String,
|
||||
let sessionId = UUID(uuidString: sessionIdString)
|
||||
{
|
||||
XCTAssertEqual(sessionId, activeSessionId)
|
||||
} else {
|
||||
XCTFail("Failed to parse session ID")
|
||||
}
|
||||
|
||||
// Test that combining sessions preserves both local active and server completed
|
||||
var combinedSessions = serverSessions
|
||||
combinedSessions.append(localActiveSession)
|
||||
|
||||
XCTAssertEqual(combinedSessions.count, 2)
|
||||
|
||||
// Verify both session types are present
|
||||
let hasActiveSession = combinedSessions.contains { session in
|
||||
(session["status"] as? String) == "active"
|
||||
}
|
||||
let hasCompletedSession = combinedSessions.contains { session in
|
||||
(session["status"] as? String) == "completed"
|
||||
}
|
||||
|
||||
XCTAssertTrue(hasActiveSession, "Combined sessions should contain active session")
|
||||
XCTAssertTrue(hasCompletedSession, "Combined sessions should contain completed session")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.0.0
|
||||
1.1.0
|
||||
|
||||
Reference in New Issue
Block a user