Compare commits
2 Commits
ANDROID_1.
...
IOS_1.2.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
603a683ab2
|
|||
|
a19ff8ef66
|
@@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.atridad.openclimb"
|
applicationId = "com.atridad.openclimb"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 32
|
versionCode = 33
|
||||||
versionName = "1.7.3"
|
versionName = "1.7.4"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,15 @@ data class ClimbDataBackup(
|
|||||||
val gyms: List<BackupGym>,
|
val gyms: List<BackupGym>,
|
||||||
val problems: List<BackupProblem>,
|
val problems: List<BackupProblem>,
|
||||||
val sessions: List<BackupClimbSession>,
|
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
|
// Platform-neutral gym representation for backup/restore
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
package com.atridad.openclimb.data.repository
|
package com.atridad.openclimb.data.repository
|
||||||
|
|
||||||
import android.content.Context
|
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.database.OpenClimbDatabase
|
||||||
import com.atridad.openclimb.data.format.BackupAttempt
|
import com.atridad.openclimb.data.format.BackupAttempt
|
||||||
import com.atridad.openclimb.data.format.BackupClimbSession
|
import com.atridad.openclimb.data.format.BackupClimbSession
|
||||||
import com.atridad.openclimb.data.format.BackupGym
|
import com.atridad.openclimb.data.format.BackupGym
|
||||||
import com.atridad.openclimb.data.format.BackupProblem
|
import com.atridad.openclimb.data.format.BackupProblem
|
||||||
import com.atridad.openclimb.data.format.ClimbDataBackup
|
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.model.*
|
||||||
import com.atridad.openclimb.data.state.DataStateManager
|
import com.atridad.openclimb.data.state.DataStateManager
|
||||||
import com.atridad.openclimb.utils.DateFormatUtils
|
import com.atridad.openclimb.utils.DateFormatUtils
|
||||||
@@ -14,6 +17,8 @@ import com.atridad.openclimb.utils.ZipExportImportUtils
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
class ClimbRepository(database: OpenClimbDatabase, private val context: Context) {
|
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 sessionDao = database.climbSessionDao()
|
||||||
private val attemptDao = database.attemptDao()
|
private val attemptDao = database.attemptDao()
|
||||||
private val dataStateManager = DataStateManager(context)
|
private val dataStateManager = DataStateManager(context)
|
||||||
|
private val deletionPreferences: SharedPreferences =
|
||||||
|
context.getSharedPreferences("deleted_items", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
private var autoSyncCallback: (() -> Unit)? = null
|
private var autoSyncCallback: (() -> Unit)? = null
|
||||||
|
|
||||||
@@ -45,6 +52,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
|||||||
}
|
}
|
||||||
suspend fun deleteGym(gym: Gym) {
|
suspend fun deleteGym(gym: Gym) {
|
||||||
gymDao.deleteGym(gym)
|
gymDao.deleteGym(gym)
|
||||||
|
trackDeletion(gym.id, "gym")
|
||||||
dataStateManager.updateDataState()
|
dataStateManager.updateDataState()
|
||||||
triggerAutoSync()
|
triggerAutoSync()
|
||||||
}
|
}
|
||||||
@@ -56,17 +64,15 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
|||||||
suspend fun insertProblem(problem: Problem) {
|
suspend fun insertProblem(problem: Problem) {
|
||||||
problemDao.insertProblem(problem)
|
problemDao.insertProblem(problem)
|
||||||
dataStateManager.updateDataState()
|
dataStateManager.updateDataState()
|
||||||
triggerAutoSync()
|
|
||||||
}
|
}
|
||||||
suspend fun updateProblem(problem: Problem) {
|
suspend fun updateProblem(problem: Problem) {
|
||||||
problemDao.updateProblem(problem)
|
problemDao.updateProblem(problem)
|
||||||
dataStateManager.updateDataState()
|
dataStateManager.updateDataState()
|
||||||
triggerAutoSync()
|
|
||||||
}
|
}
|
||||||
suspend fun deleteProblem(problem: Problem) {
|
suspend fun deleteProblem(problem: Problem) {
|
||||||
problemDao.deleteProblem(problem)
|
problemDao.deleteProblem(problem)
|
||||||
|
trackDeletion(problem.id, "problem")
|
||||||
dataStateManager.updateDataState()
|
dataStateManager.updateDataState()
|
||||||
triggerAutoSync()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Session operations
|
// Session operations
|
||||||
@@ -94,6 +100,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
|||||||
}
|
}
|
||||||
suspend fun deleteSession(session: ClimbSession) {
|
suspend fun deleteSession(session: ClimbSession) {
|
||||||
sessionDao.deleteSession(session)
|
sessionDao.deleteSession(session)
|
||||||
|
trackDeletion(session.id, "session")
|
||||||
dataStateManager.updateDataState()
|
dataStateManager.updateDataState()
|
||||||
triggerAutoSync()
|
triggerAutoSync()
|
||||||
}
|
}
|
||||||
@@ -122,6 +129,7 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
|||||||
}
|
}
|
||||||
suspend fun deleteAttempt(attempt: Attempt) {
|
suspend fun deleteAttempt(attempt: Attempt) {
|
||||||
attemptDao.deleteAttempt(attempt)
|
attemptDao.deleteAttempt(attempt)
|
||||||
|
trackDeletion(attempt.id, "attempt")
|
||||||
dataStateManager.updateDataState()
|
dataStateManager.updateDataState()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +269,38 @@ class ClimbRepository(database: OpenClimbDatabase, private val context: Context)
|
|||||||
autoSyncCallback?.invoke()
|
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(
|
private fun validateDataIntegrity(
|
||||||
gyms: List<Gym>,
|
gyms: List<Gym>,
|
||||||
problems: List<Problem>,
|
problems: List<Problem>,
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import com.atridad.openclimb.data.format.BackupClimbSession
|
|||||||
import com.atridad.openclimb.data.format.BackupGym
|
import com.atridad.openclimb.data.format.BackupGym
|
||||||
import com.atridad.openclimb.data.format.BackupProblem
|
import com.atridad.openclimb.data.format.BackupProblem
|
||||||
import com.atridad.openclimb.data.format.ClimbDataBackup
|
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.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.model.SessionStatus
|
||||||
import com.atridad.openclimb.data.repository.ClimbRepository
|
import com.atridad.openclimb.data.repository.ClimbRepository
|
||||||
import com.atridad.openclimb.data.state.DataStateManager
|
import com.atridad.openclimb.data.state.DataStateManager
|
||||||
@@ -382,27 +387,9 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
Log.d(TAG, "Initial upload completed")
|
Log.d(TAG, "Initial upload completed")
|
||||||
}
|
}
|
||||||
hasLocalData && hasServerData -> {
|
hasLocalData && hasServerData -> {
|
||||||
val localTimestamp = parseISO8601ToMillis(localBackup.exportedAt)
|
Log.d(TAG, "Both local and server data exist, merging safely")
|
||||||
val serverTimestamp = parseISO8601ToMillis(serverBackup.exportedAt)
|
mergeDataSafely(localBackup, serverBackup)
|
||||||
|
Log.d(TAG, "Safe merge completed")
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Log.d(TAG, "No data to sync")
|
Log.d(TAG, "No data to sync")
|
||||||
@@ -583,7 +570,8 @@ class SyncService(private val context: Context, private val repository: ClimbRep
|
|||||||
gyms = allGyms.map { BackupGym.fromGym(it) },
|
gyms = allGyms.map { BackupGym.fromGym(it) },
|
||||||
problems = allProblems.map { BackupProblem.fromProblem(it) },
|
problems = allProblems.map { BackupProblem.fromProblem(it) },
|
||||||
sessions = completedSessions.map { BackupClimbSession.fromClimbSession(it) },
|
sessions = completedSessions.map { BackupClimbSession.fromClimbSession(it) },
|
||||||
attempts = completedAttempts.map { BackupAttempt.fromAttempt(it) }
|
attempts = completedAttempts.map { BackupAttempt.fromAttempt(it) },
|
||||||
|
deletedItems = repository.getDeletedItems()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,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 =
|
||||||
@@ -638,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
|
||||||
)
|
)
|
||||||
@@ -651,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}")
|
||||||
}
|
}
|
||||||
@@ -690,10 +706,272 @@ 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}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 */
|
/** Parses ISO8601 timestamp to milliseconds for comparison */
|
||||||
private fun parseISO8601ToMillis(timestamp: String): Long {
|
private fun parseISO8601ToMillis(timestamp: String): Long {
|
||||||
return try {
|
return try {
|
||||||
|
|||||||
@@ -465,7 +465,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 15;
|
CURRENT_PROJECT_VERSION = 16;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -485,7 +485,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.4;
|
MARKETING_VERSION = 1.2.5;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -508,7 +508,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
CODE_SIGN_ENTITLEMENTS = OpenClimb/OpenClimb.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 15;
|
CURRENT_PROJECT_VERSION = 16;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -528,7 +528,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.4;
|
MARKETING_VERSION = 1.2.5;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@@ -592,7 +592,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 15;
|
CURRENT_PROJECT_VERSION = 16;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||||
@@ -603,7 +603,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.4;
|
MARKETING_VERSION = 1.2.5;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -622,7 +622,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 15;
|
CURRENT_PROJECT_VERSION = 16;
|
||||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||||
@@ -633,7 +633,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2.4;
|
MARKETING_VERSION = 1.2.5;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
|
PRODUCT_BUNDLE_IDENTIFIER = com.atridad.OpenClimb.SessionStatusLive;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
|
|||||||
Binary file not shown.
@@ -57,7 +57,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
setupNotificationObservers()
|
setupNotificationObservers()
|
||||||
// Trigger auto-sync on app launch
|
// Trigger auto-sync on app start only
|
||||||
dataManager.syncService.triggerAutoSync(dataManager: dataManager)
|
dataManager.syncService.triggerAutoSync(dataManager: dataManager)
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
@@ -103,8 +103,6 @@ struct ContentView: View {
|
|||||||
Task {
|
Task {
|
||||||
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
|
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds
|
||||||
await dataManager.onAppBecomeActive()
|
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
|
// MARK: - Backup Format Specification v2.0
|
||||||
|
|
||||||
/// Root structure for OpenClimb backup data
|
/// 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 {
|
struct ClimbDataBackup: Codable {
|
||||||
let exportedAt: String
|
let exportedAt: String
|
||||||
let version: String
|
let version: String
|
||||||
@@ -14,6 +20,7 @@ struct ClimbDataBackup: Codable {
|
|||||||
let problems: [BackupProblem]
|
let problems: [BackupProblem]
|
||||||
let sessions: [BackupClimbSession]
|
let sessions: [BackupClimbSession]
|
||||||
let attempts: [BackupAttempt]
|
let attempts: [BackupAttempt]
|
||||||
|
let deletedItems: [DeletedItem]
|
||||||
|
|
||||||
init(
|
init(
|
||||||
exportedAt: String,
|
exportedAt: String,
|
||||||
@@ -22,7 +29,8 @@ struct ClimbDataBackup: Codable {
|
|||||||
gyms: [BackupGym],
|
gyms: [BackupGym],
|
||||||
problems: [BackupProblem],
|
problems: [BackupProblem],
|
||||||
sessions: [BackupClimbSession],
|
sessions: [BackupClimbSession],
|
||||||
attempts: [BackupAttempt]
|
attempts: [BackupAttempt],
|
||||||
|
deletedItems: [DeletedItem] = []
|
||||||
) {
|
) {
|
||||||
self.exportedAt = exportedAt
|
self.exportedAt = exportedAt
|
||||||
self.version = version
|
self.version = version
|
||||||
@@ -31,6 +39,7 @@ struct ClimbDataBackup: Codable {
|
|||||||
self.problems = problems
|
self.problems = problems
|
||||||
self.sessions = sessions
|
self.sessions = sessions
|
||||||
self.attempts = attempts
|
self.attempts = attempts
|
||||||
|
self.deletedItems = deletedItems
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -247,39 +247,13 @@ class SyncService: ObservableObject {
|
|||||||
try await syncImagesToServer(dataManager: dataManager)
|
try await syncImagesToServer(dataManager: dataManager)
|
||||||
print("Initial upload completed")
|
print("Initial upload completed")
|
||||||
} else if hasLocalData && hasServerData {
|
} else if hasLocalData && hasServerData {
|
||||||
// Case 3: Both have data - compare timestamps (last writer wins)
|
// Case 3: Both have data - use safe merge strategy
|
||||||
let localTimestamp = parseISO8601ToMillis(timestamp: localBackup.exportedAt)
|
print("iOS SYNC: Case 3 - Merging local and server data safely")
|
||||||
let serverTimestamp = parseISO8601ToMillis(timestamp: serverBackup.exportedAt)
|
try await mergeDataSafely(
|
||||||
|
localBackup: localBackup,
|
||||||
print("DEBUG iOS Timestamp Comparison:")
|
serverBackup: serverBackup,
|
||||||
print(" Local exportedAt: '\(localBackup.exportedAt)' -> \(localTimestamp)")
|
dataManager: dataManager)
|
||||||
print(" Server exportedAt: '\(serverBackup.exportedAt)' -> \(serverTimestamp)")
|
print("Safe merge completed")
|
||||||
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"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
print("No data to sync")
|
print("No data to sync")
|
||||||
}
|
}
|
||||||
@@ -413,21 +387,103 @@ class SyncService: ObservableObject {
|
|||||||
gyms: dataManager.gyms.map { BackupGym(from: $0) },
|
gyms: dataManager.gyms.map { BackupGym(from: $0) },
|
||||||
problems: dataManager.problems.map { BackupProblem(from: $0) },
|
problems: dataManager.problems.map { BackupProblem(from: $0) },
|
||||||
sessions: completedSessions.map { BackupClimbSession(from: $0) },
|
sessions: completedSessions.map { BackupClimbSession(from: $0) },
|
||||||
attempts: completedAttempts.map { BackupAttempt(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(
|
private func importBackupToDataManager(
|
||||||
_ backup: ClimbDataBackup, dataManager: ClimbingDataManager,
|
_ backup: ClimbDataBackup, dataManager: ClimbingDataManager,
|
||||||
imagePathMapping: [String: String] = [:]
|
imagePathMapping: [String: String] = [:]
|
||||||
) throws {
|
) throws {
|
||||||
do {
|
do {
|
||||||
|
// Store active sessions and their attempts before import (but exclude any that were deleted)
|
||||||
// Store active sessions and their attempts before import
|
let localDeletedItems = dataManager.getDeletedItems()
|
||||||
let activeSessions = dataManager.sessions.filter { $0.status == .active }
|
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(
|
||||||
@@ -458,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
|
||||||
@@ -496,12 +592,18 @@ 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)")
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
|
|
||||||
throw SyncError.importFailed(error)
|
throw SyncError.importFailed(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -817,6 +919,151 @@ class SyncService: ObservableObject {
|
|||||||
userDefaults.removeObject(forKey: Keys.isConnected)
|
userDefaults.removeObject(forKey: Keys.isConnected)
|
||||||
userDefaults.removeObject(forKey: Keys.autoSyncEnabled)
|
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 {
|
enum SyncError: LocalizedError {
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
static let sessions = "openclimb_sessions"
|
static let sessions = "openclimb_sessions"
|
||||||
static let attempts = "openclimb_attempts"
|
static let attempts = "openclimb_attempts"
|
||||||
static let activeSession = "openclimb_active_session"
|
static let activeSession = "openclimb_active_session"
|
||||||
|
static let deletedItems = "openclimb_deleted_items"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Widget data models
|
// Widget data models
|
||||||
@@ -137,7 +138,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveGyms() {
|
internal func saveGyms() {
|
||||||
if let data = try? encoder.encode(gyms) {
|
if let data = try? encoder.encode(gyms) {
|
||||||
userDefaults.set(data, forKey: Keys.gyms)
|
userDefaults.set(data, forKey: Keys.gyms)
|
||||||
// Share with widget - convert to widget format
|
// 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) {
|
if let data = try? encoder.encode(problems) {
|
||||||
userDefaults.set(data, forKey: Keys.problems)
|
userDefaults.set(data, forKey: Keys.problems)
|
||||||
// Share with widget
|
// Share with widget
|
||||||
@@ -246,6 +247,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
|
|
||||||
// Delete the gym
|
// Delete the gym
|
||||||
gyms.removeAll { $0.id == gym.id }
|
gyms.removeAll { $0.id == gym.id }
|
||||||
|
trackDeletion(itemId: gym.id.uuidString, itemType: "gym")
|
||||||
saveGyms()
|
saveGyms()
|
||||||
DataStateManager.shared.updateDataState()
|
DataStateManager.shared.updateDataState()
|
||||||
successMessage = "Gym deleted successfully"
|
successMessage = "Gym deleted successfully"
|
||||||
@@ -293,6 +295,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
|
|
||||||
// Delete the problem
|
// Delete the problem
|
||||||
problems.removeAll { $0.id == problem.id }
|
problems.removeAll { $0.id == problem.id }
|
||||||
|
trackDeletion(itemId: problem.id.uuidString, itemType: "problem")
|
||||||
saveProblems()
|
saveProblems()
|
||||||
DataStateManager.shared.updateDataState()
|
DataStateManager.shared.updateDataState()
|
||||||
|
|
||||||
@@ -396,6 +399,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
|
|
||||||
// Delete the session
|
// Delete the session
|
||||||
sessions.removeAll { $0.id == session.id }
|
sessions.removeAll { $0.id == session.id }
|
||||||
|
trackDeletion(itemId: session.id.uuidString, itemType: "session")
|
||||||
saveSessions()
|
saveSessions()
|
||||||
DataStateManager.shared.updateDataState()
|
DataStateManager.shared.updateDataState()
|
||||||
|
|
||||||
@@ -442,6 +446,7 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
|
|
||||||
func deleteAttempt(_ attempt: Attempt) {
|
func deleteAttempt(_ attempt: Attempt) {
|
||||||
attempts.removeAll { $0.id == attempt.id }
|
attempts.removeAll { $0.id == attempt.id }
|
||||||
|
trackDeletion(itemId: attempt.id.uuidString, itemType: "attempt")
|
||||||
saveAttempts()
|
saveAttempts()
|
||||||
DataStateManager.shared.updateDataState()
|
DataStateManager.shared.updateDataState()
|
||||||
|
|
||||||
@@ -453,6 +458,36 @@ class ClimbingDataManager: ObservableObject {
|
|||||||
return attempts.filter { $0.sessionId == sessionId }.sorted { $0.timestamp < $1.timestamp }
|
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] {
|
func attempts(forProblem problemId: UUID) -> [Attempt] {
|
||||||
return attempts.filter { $0.problemId == problemId }.sorted { $0.timestamp > $1.timestamp }
|
return attempts.filter { $0.problemId == problemId }.sorted { $0.timestamp > $1.timestamp }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ func min(a, b int) int {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DeletedItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
DeletedAt string `json:"deletedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
type ClimbDataBackup struct {
|
type ClimbDataBackup struct {
|
||||||
ExportedAt string `json:"exportedAt"`
|
ExportedAt string `json:"exportedAt"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
@@ -28,6 +34,7 @@ type ClimbDataBackup struct {
|
|||||||
Problems []BackupProblem `json:"problems"`
|
Problems []BackupProblem `json:"problems"`
|
||||||
Sessions []BackupClimbSession `json:"sessions"`
|
Sessions []BackupClimbSession `json:"sessions"`
|
||||||
Attempts []BackupAttempt `json:"attempts"`
|
Attempts []BackupAttempt `json:"attempts"`
|
||||||
|
DeletedItems []DeletedItem `json:"deletedItems"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BackupGym struct {
|
type BackupGym struct {
|
||||||
@@ -120,6 +127,7 @@ func (s *SyncServer) loadData() (*ClimbDataBackup, error) {
|
|||||||
Problems: []BackupProblem{},
|
Problems: []BackupProblem{},
|
||||||
Sessions: []BackupClimbSession{},
|
Sessions: []BackupClimbSession{},
|
||||||
Attempts: []BackupAttempt{},
|
Attempts: []BackupAttempt{},
|
||||||
|
DeletedItems: []DeletedItem{},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.0.0
|
1.1.0
|
||||||
|
|||||||
Reference in New Issue
Block a user