Sync bug fixes across the board!
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m15s
All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m15s
This commit is contained in:
@@ -466,7 +466,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 43;
|
||||
CURRENT_PROJECT_VERSION = 44;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -518,7 +518,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Ascently/Ascently.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 43;
|
||||
CURRENT_PROJECT_VERSION = 44;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
DRIVERKIT_DEPLOYMENT_TARGET = 24.6;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -610,7 +610,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 43;
|
||||
CURRENT_PROJECT_VERSION = 44;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||
@@ -641,7 +641,7 @@
|
||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||
CODE_SIGN_ENTITLEMENTS = SessionStatusLiveExtension.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 43;
|
||||
CURRENT_PROJECT_VERSION = 44;
|
||||
DEVELOPMENT_TEAM = 4BC9Y2LL4B;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = SessionStatusLive/Info.plist;
|
||||
|
||||
Binary file not shown.
@@ -388,7 +388,7 @@ struct BackupClimbSession: Codable {
|
||||
startTime: nil,
|
||||
endTime: nil,
|
||||
duration: nil,
|
||||
status: .finished,
|
||||
status: .completed,
|
||||
notes: nil,
|
||||
isDeleted: true,
|
||||
createdAt: dateString,
|
||||
|
||||
@@ -17,6 +17,7 @@ struct DeltaSyncRequest: Codable {
|
||||
|
||||
struct DeltaSyncResponse: Codable {
|
||||
let serverTime: String
|
||||
let requestFullSync: Bool?
|
||||
let gyms: [BackupGym]
|
||||
let problems: [BackupProblem]
|
||||
let sessions: [BackupClimbSession]
|
||||
|
||||
@@ -71,8 +71,6 @@ class ServerSyncProvider: SyncProvider {
|
||||
throw SyncError.notConnected
|
||||
}
|
||||
|
||||
// 1. Priority: Delta Sync
|
||||
// If we have synced before, assume we want to continue with delta sync
|
||||
if lastSyncTime != nil {
|
||||
AppLogger.info("Last sync time found, attempting delta sync", tag: logTag)
|
||||
do {
|
||||
@@ -81,18 +79,12 @@ class ServerSyncProvider: SyncProvider {
|
||||
return
|
||||
} catch {
|
||||
AppLogger.error("Delta sync failed, falling back to full sync check: \(error)", tag: logTag)
|
||||
// Fallthrough to full sync logic
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Full Sync Logic
|
||||
// Get local backup data
|
||||
let localBackup = createBackupFromDataManager(dataManager)
|
||||
|
||||
// Download server data
|
||||
let serverBackup = try await downloadData()
|
||||
|
||||
// Check if we have any local data
|
||||
let hasLocalData =
|
||||
!dataManager.gyms.isEmpty || !dataManager.problems.isEmpty
|
||||
|| !dataManager.sessions.isEmpty || !dataManager.attempts.isEmpty
|
||||
@@ -219,7 +211,6 @@ class ServerSyncProvider: SyncProvider {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let lastSyncString = formatter.string(from: lastSync)
|
||||
|
||||
// Collect items modified since last sync
|
||||
var modifiedGyms = dataManager.gyms.filter { gym in
|
||||
gym.updatedAt > lastSync
|
||||
}.map { BackupGym(from: $0) }
|
||||
@@ -249,7 +240,6 @@ class ServerSyncProvider: SyncProvider {
|
||||
!activeSessionIds.contains(attempt.sessionId) && attempt.createdAt > lastSync
|
||||
}.map { BackupAttempt(from: $0) }
|
||||
|
||||
// Handle deleted items as tombstones
|
||||
let deletedItems = dataManager.getDeletedItems().filter { item in
|
||||
if let deletedDate = formatter.date(from: item.deletedAt) {
|
||||
return deletedDate > lastSync
|
||||
@@ -316,6 +306,11 @@ class ServerSyncProvider: SyncProvider {
|
||||
let decoder = JSONDecoder()
|
||||
let deltaResponse = try decoder.decode(DeltaSyncResponse.self, from: data)
|
||||
|
||||
if let requestFullSync = deltaResponse.requestFullSync, requestFullSync {
|
||||
AppLogger.info("Server requested full sync", tag: logTag)
|
||||
throw SyncError.serverError(412)
|
||||
}
|
||||
|
||||
AppLogger.info(
|
||||
"Delta Sync: Received gyms=\(deltaResponse.gyms.count), problems=\(deltaResponse.problems.count), sessions=\(deltaResponse.sessions.count), attempts=\(deltaResponse.attempts.count)",
|
||||
tag: logTag
|
||||
@@ -337,7 +332,6 @@ class ServerSyncProvider: SyncProvider {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
// 1. Download images for problems that are NOT deleted
|
||||
var imagePathMapping: [String: String] = [:]
|
||||
for problem in response.problems {
|
||||
if let isDeleted = problem.isDeleted, isDeleted { continue }
|
||||
@@ -359,9 +353,7 @@ class ServerSyncProvider: SyncProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Merge Gyms
|
||||
for backupGym in response.gyms {
|
||||
// Handle Soft Delete
|
||||
if let isDeleted = backupGym.isDeleted, isDeleted {
|
||||
if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id }) {
|
||||
let existing = dataManager.gyms[index]
|
||||
@@ -373,7 +365,6 @@ class ServerSyncProvider: SyncProvider {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle Update/Insert
|
||||
if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id }) {
|
||||
let existing = dataManager.gyms[index]
|
||||
if let serverUpdate = formatter.date(from: backupGym.updatedAt),
|
||||
@@ -385,7 +376,6 @@ class ServerSyncProvider: SyncProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Merge Problems
|
||||
for backupProblem in response.problems {
|
||||
if let isDeleted = backupProblem.isDeleted, isDeleted {
|
||||
if let index = dataManager.problems.firstIndex(where: { $0.id.uuidString == backupProblem.id }) {
|
||||
@@ -415,7 +405,6 @@ class ServerSyncProvider: SyncProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Merge Sessions
|
||||
for backupSession in response.sessions {
|
||||
if let isDeleted = backupSession.isDeleted, isDeleted {
|
||||
if let index = dataManager.sessions.firstIndex(where: { $0.id.uuidString == backupSession.id }) {
|
||||
@@ -439,7 +428,6 @@ class ServerSyncProvider: SyncProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Merge Attempts
|
||||
for backupAttempt in response.attempts {
|
||||
if let isDeleted = backupAttempt.isDeleted, isDeleted {
|
||||
if let index = dataManager.attempts.firstIndex(where: { $0.id.uuidString == backupAttempt.id }) {
|
||||
@@ -477,7 +465,7 @@ class ServerSyncProvider: SyncProvider {
|
||||
for problem in modifiedProblems {
|
||||
guard let imagePaths = problem.imagePaths else { continue }
|
||||
for path in imagePaths {
|
||||
if let data = ImageManager.shared.getImageData(filename: path) {
|
||||
if let data = ImageManager.shared.loadImageData(fromPath: path) {
|
||||
try await uploadImage(filename: path, imageData: data)
|
||||
}
|
||||
}
|
||||
@@ -549,7 +537,7 @@ class ServerSyncProvider: SyncProvider {
|
||||
private func syncImagesToServer(dataManager: ClimbingDataManager) async throws {
|
||||
for problem in dataManager.problems {
|
||||
for path in problem.imagePaths {
|
||||
if let data = ImageManager.shared.getImageData(filename: path) {
|
||||
if let data = ImageManager.shared.loadImageData(fromPath: path) {
|
||||
try await uploadImage(filename: path, imageData: data)
|
||||
}
|
||||
}
|
||||
@@ -568,19 +556,15 @@ class ServerSyncProvider: SyncProvider {
|
||||
gyms: gyms,
|
||||
problems: problems,
|
||||
sessions: sessions,
|
||||
attempts: attempts,
|
||||
deletedItems: [] // Legacy field, empty
|
||||
attempts: attempts
|
||||
)
|
||||
}
|
||||
|
||||
private func mergeDataSafely(localBackup: ClimbDataBackup, serverBackup: ClimbDataBackup, dataManager: ClimbingDataManager) async throws {
|
||||
// Basic full merge that prefers server data if newer
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
// Merging Gyms
|
||||
for gym in serverBackup.gyms {
|
||||
// Check for soft delete
|
||||
if let isDeleted = gym.isDeleted, isDeleted {
|
||||
if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == gym.id }) {
|
||||
let existing = dataManager.gyms[index]
|
||||
@@ -591,7 +575,6 @@ class ServerSyncProvider: SyncProvider {
|
||||
continue
|
||||
}
|
||||
|
||||
// Update or Insert
|
||||
if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == gym.id }) {
|
||||
let existing = dataManager.gyms[index]
|
||||
if let serverUpdate = formatter.date(from: gym.updatedAt), serverUpdate >= existing.updatedAt {
|
||||
@@ -602,7 +585,6 @@ class ServerSyncProvider: SyncProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// Merging Problems
|
||||
for problem in serverBackup.problems {
|
||||
if let isDeleted = problem.isDeleted, isDeleted {
|
||||
if let index = dataManager.problems.firstIndex(where: { $0.id.uuidString == problem.id }) {
|
||||
@@ -624,7 +606,6 @@ class ServerSyncProvider: SyncProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// Merging Sessions
|
||||
for session in serverBackup.sessions {
|
||||
if let isDeleted = session.isDeleted, isDeleted {
|
||||
if let index = dataManager.sessions.firstIndex(where: { $0.id.uuidString == session.id }) {
|
||||
@@ -646,7 +627,6 @@ class ServerSyncProvider: SyncProvider {
|
||||
}
|
||||
}
|
||||
|
||||
// Merging Attempts
|
||||
for attempt in serverBackup.attempts {
|
||||
if let isDeleted = attempt.isDeleted, isDeleted {
|
||||
if let index = dataManager.attempts.firstIndex(where: { $0.id.uuidString == attempt.id }) {
|
||||
@@ -680,7 +660,6 @@ class ServerSyncProvider: SyncProvider {
|
||||
_ backup: ClimbDataBackup, dataManager: ClimbingDataManager,
|
||||
imagePathMapping: [String: String] = [:]
|
||||
) throws {
|
||||
// Logic from previous read
|
||||
let updatedProblems = backup.problems.map { problem in
|
||||
let updatedImagePaths = problem.imagePaths?.compactMap { oldPath in
|
||||
imagePathMapping[oldPath] ?? oldPath
|
||||
@@ -688,7 +667,6 @@ class ServerSyncProvider: SyncProvider {
|
||||
return problem.withUpdatedImagePaths(updatedImagePaths)
|
||||
}
|
||||
|
||||
// Re-construct data, filtering out deleted items (tombstones)
|
||||
dataManager.gyms = try backup.gyms.compactMap { gym in
|
||||
if let isDeleted = gym.isDeleted, isDeleted { return nil }
|
||||
return try gym.toGym()
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct SyncMerger {
|
||||
private static let logTag = "SyncMerger"
|
||||
|
||||
static func mergeDataSafely(
|
||||
localBackup: ClimbDataBackup,
|
||||
serverBackup: ClimbDataBackup,
|
||||
dataManager: ClimbingDataManager,
|
||||
imagePathMapping: [String: String]
|
||||
) throws -> (gyms: [Gym], problems: [Problem], sessions: [ClimbSession], attempts: [Attempt], uniqueDeletions: [DeletedItem]) {
|
||||
|
||||
// Merge deletion lists first to prevent resurrection of deleted items
|
||||
let localDeletions = dataManager.getDeletedItems()
|
||||
let allDeletions = localDeletions + serverBackup.deletedItems
|
||||
let uniqueDeletions = Array(Set(allDeletions))
|
||||
|
||||
AppLogger.info("Merging gyms...", tag: logTag)
|
||||
let mergedGyms = mergeGyms(
|
||||
local: dataManager.gyms,
|
||||
server: serverBackup.gyms,
|
||||
deletedItems: uniqueDeletions)
|
||||
|
||||
AppLogger.info("Merging problems...", tag: logTag)
|
||||
let mergedProblems = try mergeProblems(
|
||||
local: dataManager.problems,
|
||||
server: serverBackup.problems,
|
||||
imagePathMapping: imagePathMapping,
|
||||
deletedItems: uniqueDeletions)
|
||||
|
||||
AppLogger.info("Merging sessions...", tag: logTag)
|
||||
let mergedSessions = try mergeSessions(
|
||||
local: dataManager.sessions,
|
||||
server: serverBackup.sessions,
|
||||
deletedItems: uniqueDeletions)
|
||||
|
||||
AppLogger.info("Merging attempts...", tag: logTag)
|
||||
let mergedAttempts = try mergeAttempts(
|
||||
local: dataManager.attempts,
|
||||
server: serverBackup.attempts,
|
||||
deletedItems: uniqueDeletions)
|
||||
|
||||
return (mergedGyms, mergedProblems, mergedSessions, mergedAttempts, uniqueDeletions)
|
||||
}
|
||||
|
||||
private static func mergeGyms(local: [Gym], server: [BackupGym], deletedItems: [DeletedItem]) -> [Gym] {
|
||||
var merged = local
|
||||
let deletedGymIds = Set(deletedItems.filter { $0.type == "gym" }.map { $0.id })
|
||||
let localGymIds = Set(local.map { $0.id.uuidString })
|
||||
|
||||
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 = localGymIds.contains(serverGym.id)
|
||||
let isDeleted = deletedGymIds.contains(serverGym.id)
|
||||
|
||||
if !localHasGym && !isDeleted {
|
||||
merged.append(serverGymConverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
private static 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 })
|
||||
let localProblemIds = Set(local.map { $0.id.uuidString })
|
||||
|
||||
merged.removeAll { deletedProblemIds.contains($0.id.uuidString) }
|
||||
|
||||
for serverProblem in server {
|
||||
let localHasProblem = localProblemIds.contains(serverProblem.id)
|
||||
let isDeleted = deletedProblemIds.contains(serverProblem.id)
|
||||
|
||||
if !localHasProblem && !isDeleted {
|
||||
var problemToAdd = serverProblem
|
||||
|
||||
if !imagePathMapping.isEmpty, let imagePaths = serverProblem.imagePaths, !imagePaths.isEmpty {
|
||||
let updatedImagePaths = imagePaths.compactMap { oldPath in
|
||||
imagePathMapping[oldPath] ?? oldPath
|
||||
}
|
||||
if updatedImagePaths != imagePaths {
|
||||
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() {
|
||||
merged.append(serverProblemConverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
private static func mergeSessions(
|
||||
local: [ClimbSession], server: [BackupClimbSession], deletedItems: [DeletedItem]
|
||||
) throws -> [ClimbSession] {
|
||||
var merged = local
|
||||
let deletedSessionIds = Set(deletedItems.filter { $0.type == "session" }.map { $0.id })
|
||||
let localSessionIds = Set(local.map { $0.id.uuidString })
|
||||
|
||||
merged.removeAll { session in
|
||||
deletedSessionIds.contains(session.id.uuidString) && session.status != .active
|
||||
}
|
||||
|
||||
for serverSession in server {
|
||||
let localHasSession = localSessionIds.contains(serverSession.id)
|
||||
let isDeleted = deletedSessionIds.contains(serverSession.id)
|
||||
|
||||
if !localHasSession && !isDeleted {
|
||||
if let serverSessionConverted = try? serverSession.toClimbSession() {
|
||||
merged.append(serverSessionConverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
private static func mergeAttempts(
|
||||
local: [Attempt], server: [BackupAttempt], deletedItems: [DeletedItem]
|
||||
) throws -> [Attempt] {
|
||||
var merged = local
|
||||
let deletedAttemptIds = Set(deletedItems.filter { $0.type == "attempt" }.map { $0.id })
|
||||
let localAttemptIds = Set(local.map { $0.id.uuidString })
|
||||
|
||||
// Get active session IDs to protect their attempts
|
||||
let activeSessionIds = Set(
|
||||
local.compactMap { attempt in
|
||||
return attempt.sessionId
|
||||
}.filter { _ in
|
||||
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)
|
||||
}
|
||||
|
||||
for serverAttempt in server {
|
||||
let localHasAttempt = localAttemptIds.contains(serverAttempt.id)
|
||||
let isDeleted = deletedAttemptIds.contains(serverAttempt.id)
|
||||
|
||||
if !localHasAttempt && !isDeleted {
|
||||
if let serverAttemptConverted = try? serverAttempt.toAttempt() {
|
||||
merged.append(serverAttemptConverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
}
|
||||
@@ -631,6 +631,34 @@ class ClimbingDataManager: ObservableObject {
|
||||
userDefaults.removeObject(forKey: Keys.deletedItems)
|
||||
}
|
||||
|
||||
func cleanupOldDeletions() {
|
||||
guard let data = userDefaults.data(forKey: Keys.deletedItems),
|
||||
let deletions = try? decoder.decode([DeletedItem].self, from: data)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let cutoffDate = Date().addingTimeInterval(-90 * 24 * 60 * 60) // 90 days ago
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
let validDeletions = deletions.filter { item in
|
||||
if let date = formatter.date(from: item.deletedAt) {
|
||||
return date > cutoffDate
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if validDeletions.count < deletions.count {
|
||||
if let encodedData = try? encoder.encode(validDeletions) {
|
||||
userDefaults.set(encodedData, forKey: Keys.deletedItems)
|
||||
AppLogger.info(
|
||||
"Cleaned up \(deletions.count - validDeletions.count) old deletion records",
|
||||
tag: "ClimbingDataManager")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func attempts(forProblem problemId: UUID) -> [Attempt] {
|
||||
return attempts.filter { $0.problemId == problemId }.sorted { $0.timestamp > $1.timestamp }
|
||||
}
|
||||
@@ -669,6 +697,7 @@ class ClimbingDataManager: ObservableObject {
|
||||
}
|
||||
|
||||
private func cleanupOrphanedData() {
|
||||
cleanupOldDeletions()
|
||||
let validSessionIds = Set(sessions.map { $0.id })
|
||||
let validProblemIds = Set(problems.map { $0.id })
|
||||
let validGymIds = Set(gyms.map { $0.id })
|
||||
|
||||
Reference in New Issue
Block a user