diff --git a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate index d6362b2..424d3b0 100644 Binary files a/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate and b/ios/Ascently.xcodeproj/project.xcworkspace/xcuserdata/atridad.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ios/Ascently/Models/BackupFormat.swift b/ios/Ascently/Models/BackupFormat.swift index bd9828b..44ec1fc 100644 --- a/ios/Ascently/Models/BackupFormat.swift +++ b/ios/Ascently/Models/BackupFormat.swift @@ -20,7 +20,6 @@ struct ClimbDataBackup: Codable { let problems: [BackupProblem] let sessions: [BackupClimbSession] let attempts: [BackupAttempt] - let deletedItems: [DeletedItem] init( exportedAt: String, @@ -29,8 +28,7 @@ struct ClimbDataBackup: Codable { gyms: [BackupGym], problems: [BackupProblem], sessions: [BackupClimbSession], - attempts: [BackupAttempt], - deletedItems: [DeletedItem] = [] + attempts: [BackupAttempt] ) { self.exportedAt = exportedAt self.version = version @@ -39,7 +37,6 @@ struct ClimbDataBackup: Codable { self.problems = problems self.sessions = sessions self.attempts = attempts - self.deletedItems = deletedItems } } @@ -52,6 +49,7 @@ struct BackupGym: Codable { let difficultySystems: [DifficultySystem] let customDifficultyGrades: [String] let notes: String? + let isDeleted: Bool? let createdAt: String let updatedAt: String @@ -64,6 +62,8 @@ struct BackupGym: Codable { self.customDifficultyGrades = gym.customDifficultyGrades self.notes = gym.notes + self.isDeleted = false // Default to false until model is updated + let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] self.createdAt = formatter.string(from: gym.createdAt) @@ -78,6 +78,7 @@ struct BackupGym: Codable { difficultySystems: [DifficultySystem], customDifficultyGrades: [String] = [], notes: String?, + isDeleted: Bool = false, createdAt: String, updatedAt: String ) { @@ -88,6 +89,7 @@ struct BackupGym: Codable { self.difficultySystems = difficultySystems self.customDifficultyGrades = customDifficultyGrades self.notes = notes + self.isDeleted = isDeleted self.createdAt = createdAt self.updatedAt = updatedAt } @@ -115,6 +117,25 @@ struct BackupGym: Codable { updatedAt: updatedDate ) } + + static func createTombstone(id: String, deletedAt: Date) -> BackupGym { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let dateString = formatter.string(from: deletedAt) + + return BackupGym( + id: id, + name: "DELETED", + location: nil, + supportedClimbTypes: [], + difficultySystems: [], + customDifficultyGrades: [], + notes: nil, + isDeleted: true, + createdAt: dateString, + updatedAt: dateString + ) + } } // Platform-neutral problem representation for backup/restore @@ -131,6 +152,7 @@ struct BackupProblem: Codable { let isActive: Bool let dateSet: String? // ISO 8601 format let notes: String? + let isDeleted: Bool? let createdAt: String let updatedAt: String @@ -146,6 +168,7 @@ struct BackupProblem: Codable { self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths self.isActive = problem.isActive self.notes = problem.notes + self.isDeleted = false // Default to false until model is updated let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -167,6 +190,7 @@ struct BackupProblem: Codable { isActive: Bool, dateSet: String?, notes: String?, + isDeleted: Bool = false, createdAt: String, updatedAt: String ) { @@ -182,6 +206,7 @@ struct BackupProblem: Codable { self.isActive = isActive self.dateSet = dateSet self.notes = notes + self.isDeleted = isDeleted self.createdAt = createdAt self.updatedAt = updatedAt } @@ -232,10 +257,35 @@ struct BackupProblem: Codable { isActive: self.isActive, dateSet: self.dateSet, notes: self.notes, + isDeleted: self.isDeleted ?? false, createdAt: self.createdAt, updatedAt: self.updatedAt ) } + + static func createTombstone(id: String, gymId: String = UUID().uuidString, deletedAt: Date) -> BackupProblem { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let dateString = formatter.string(from: deletedAt) + + return BackupProblem( + id: id, + gymId: gymId, + name: "DELETED", + description: nil, + climbType: ClimbType.allCases.first!, + difficulty: DifficultyGrade(system: DifficultySystem.allCases.first!, grade: "0"), + tags: [], + location: nil, + imagePaths: nil, + isActive: false, + dateSet: nil, + notes: nil, + isDeleted: true, + createdAt: dateString, + updatedAt: dateString + ) + } } // Platform-neutral climb session representation for backup/restore @@ -248,6 +298,7 @@ struct BackupClimbSession: Codable { let duration: Int64? // Duration in seconds let status: SessionStatus let notes: String? + let isDeleted: Bool? let createdAt: String let updatedAt: String @@ -256,6 +307,7 @@ struct BackupClimbSession: Codable { self.gymId = session.gymId.uuidString self.status = session.status self.notes = session.notes + self.isDeleted = false // Default to false until model is updated let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -276,6 +328,7 @@ struct BackupClimbSession: Codable { duration: Int64?, status: SessionStatus, notes: String?, + isDeleted: Bool = false, createdAt: String, updatedAt: String ) { @@ -287,6 +340,7 @@ struct BackupClimbSession: Codable { self.duration = duration self.status = status self.notes = notes + self.isDeleted = isDeleted self.createdAt = createdAt self.updatedAt = updatedAt } @@ -321,6 +375,26 @@ struct BackupClimbSession: Codable { updatedAt: updatedDate ) } + + static func createTombstone(id: String, gymId: String = UUID().uuidString, deletedAt: Date) -> BackupClimbSession { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let dateString = formatter.string(from: deletedAt) + + return BackupClimbSession( + id: id, + gymId: gymId, + date: dateString, + startTime: nil, + endTime: nil, + duration: nil, + status: .finished, + notes: nil, + isDeleted: true, + createdAt: dateString, + updatedAt: dateString + ) + } } // Platform-neutral attempt representation for backup/restore @@ -334,6 +408,7 @@ struct BackupAttempt: Codable { let duration: Int64? // Duration in seconds let restTime: Int64? // Rest time in seconds let timestamp: String + let isDeleted: Bool? let createdAt: String let updatedAt: String? @@ -346,6 +421,7 @@ struct BackupAttempt: Codable { self.notes = attempt.notes self.duration = attempt.duration.map { Int64($0) } self.restTime = attempt.restTime.map { Int64($0) } + self.isDeleted = false // Default to false until model is updated let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -364,6 +440,7 @@ struct BackupAttempt: Codable { duration: Int64?, restTime: Int64?, timestamp: String, + isDeleted: Bool = false, createdAt: String, updatedAt: String? ) { @@ -376,6 +453,7 @@ struct BackupAttempt: Codable { self.duration = duration self.restTime = restTime self.timestamp = timestamp + self.isDeleted = isDeleted self.createdAt = createdAt self.updatedAt = updatedAt } @@ -412,6 +490,27 @@ struct BackupAttempt: Codable { updatedAt: updatedDate ) } + + static func createTombstone(id: String, sessionId: String = UUID().uuidString, problemId: String = UUID().uuidString, deletedAt: Date) -> BackupAttempt { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let dateString = formatter.string(from: deletedAt) + + return BackupAttempt( + id: id, + sessionId: sessionId, + problemId: problemId, + result: AttemptResult.allCases.first!, + highestHold: nil, + notes: nil, + duration: nil, + restTime: nil, + timestamp: dateString, + isDeleted: true, + createdAt: dateString, + updatedAt: dateString + ) + } } // MARK: - Backup Format Errors diff --git a/ios/Ascently/Models/DeltaSyncFormat.swift b/ios/Ascently/Models/DeltaSyncFormat.swift index 951f39d..9277f79 100644 --- a/ios/Ascently/Models/DeltaSyncFormat.swift +++ b/ios/Ascently/Models/DeltaSyncFormat.swift @@ -13,7 +13,6 @@ struct DeltaSyncRequest: Codable { let problems: [BackupProblem] let sessions: [BackupClimbSession] let attempts: [BackupAttempt] - let deletedItems: [DeletedItem] } struct DeltaSyncResponse: Codable { @@ -22,5 +21,4 @@ struct DeltaSyncResponse: Codable { let problems: [BackupProblem] let sessions: [BackupClimbSession] let attempts: [BackupAttempt] - let deletedItems: [DeletedItem] } diff --git a/ios/Ascently/Services/Sync/ServerSyncProvider.swift b/ios/Ascently/Services/Sync/ServerSyncProvider.swift index 132522a..36ccb0b 100644 --- a/ios/Ascently/Services/Sync/ServerSyncProvider.swift +++ b/ios/Ascently/Services/Sync/ServerSyncProvider.swift @@ -1,19 +1,21 @@ import Combine import Foundation +import UIKit // Needed for UIImage/Data handling if not using ImageManager exclusively +@MainActor class ServerSyncProvider: SyncProvider { - var type: SyncProviderType { .server } - + var type: SyncProviderType = .server + private let userDefaults = UserDefaults.standard private let logTag = "ServerSyncProvider" - + private enum Keys { static let serverURL = "sync_server_url" static let authToken = "sync_auth_token" static let lastSyncTime = "last_sync_time" static let isConnected = "is_connected" } - + var serverURL: String { get { userDefaults.string(forKey: Keys.serverURL) ?? "" } set { userDefaults.set(newValue, forKey: Keys.serverURL) } @@ -23,56 +25,43 @@ class ServerSyncProvider: SyncProvider { get { userDefaults.string(forKey: Keys.authToken) ?? "" } set { userDefaults.set(newValue, forKey: Keys.authToken) } } - + var isConfigured: Bool { return !serverURL.isEmpty && !authToken.isEmpty } - + var isConnected: Bool { get { userDefaults.bool(forKey: Keys.isConnected) } set { userDefaults.set(newValue, forKey: Keys.isConnected) } } - + var lastSyncTime: Date? { get { userDefaults.object(forKey: Keys.lastSyncTime) as? Date } set { userDefaults.set(newValue, forKey: Keys.lastSyncTime) } } - + func disconnect() { isConnected = false lastSyncTime = nil - userDefaults.removeObject(forKey: Keys.lastSyncTime) - userDefaults.set(false, forKey: Keys.isConnected) } - - func testConnection() async throws { - guard isConfigured else { - throw SyncError.notConfigured - } + func testConnection() async throws { guard let url = URL(string: "\(serverURL)/health") else { throw SyncError.invalidURL } var request = URLRequest(url: url) request.httpMethod = "GET" - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.timeoutInterval = 10 let (_, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse else { - throw SyncError.invalidResponse + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw SyncError.serverError(500) } - guard httpResponse.statusCode == 200 else { - throw SyncError.serverError(httpResponse.statusCode) - } - - // Connection successful, mark as connected isConnected = true } - + func sync(dataManager: ClimbingDataManager) async throws { guard isConfigured else { throw SyncError.notConfigured @@ -81,7 +70,22 @@ class ServerSyncProvider: SyncProvider { guard isConnected else { 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 { + try await performDeltaSync(dataManager: dataManager) + lastSyncTime = Date() + 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) @@ -97,16 +101,6 @@ class ServerSyncProvider: SyncProvider { !serverBackup.gyms.isEmpty || !serverBackup.problems.isEmpty || !serverBackup.sessions.isEmpty || !serverBackup.attempts.isEmpty - // If both client and server have been synced before, use delta sync - if hasLocalData && hasServerData && lastSyncTime != nil { - AppLogger.info("Using delta sync for incremental updates", tag: logTag) - try await performDeltaSync(dataManager: dataManager) - - // Update last sync time - lastSyncTime = Date() - return - } - if !hasLocalData && hasServerData { AppLogger.info("Performing full restore from server", tag: logTag) AppLogger.info("Syncing images from server first...", tag: logTag) @@ -137,9 +131,9 @@ class ServerSyncProvider: SyncProvider { // Update last sync time lastSyncTime = Date() } - + // MARK: - Private Helpers - + private func downloadData() async throws -> ClimbDataBackup { guard let url = URL(string: "\(serverURL)/sync") else { throw SyncError.invalidURL @@ -172,7 +166,7 @@ class ServerSyncProvider: SyncProvider { throw SyncError.decodingError(error) } } - + private func uploadData(_ backup: ClimbDataBackup) async throws -> ClimbDataBackup { guard let url = URL(string: "\(serverURL)/sync") else { throw SyncError.invalidURL @@ -214,7 +208,7 @@ class ServerSyncProvider: SyncProvider { throw SyncError.decodingError(error) } } - + private func performDeltaSync(dataManager: ClimbingDataManager) async throws { guard let url = URL(string: "\(serverURL)/sync/delta") else { throw SyncError.invalidURL @@ -226,11 +220,11 @@ class ServerSyncProvider: SyncProvider { let lastSyncString = formatter.string(from: lastSync) // Collect items modified since last sync - let modifiedGyms = dataManager.gyms.filter { gym in + var modifiedGyms = dataManager.gyms.filter { gym in gym.updatedAt > lastSync }.map { BackupGym(from: $0) } - let modifiedProblems = dataManager.problems.filter { problem in + var modifiedProblems = dataManager.problems.filter { problem in problem.updatedAt > lastSync }.map { problem -> BackupProblem in let backupProblem = BackupProblem(from: problem) @@ -239,45 +233,48 @@ class ServerSyncProvider: SyncProvider { ImageNamingUtils.generateImageFilename( problemId: problem.id.uuidString, imageIndex: index) } - return BackupProblem( - id: backupProblem.id, - gymId: backupProblem.gymId, - name: backupProblem.name, - description: backupProblem.description, - climbType: backupProblem.climbType, - difficulty: backupProblem.difficulty, - tags: backupProblem.tags, - location: backupProblem.location, - imagePaths: normalizedPaths, - isActive: backupProblem.isActive, - dateSet: backupProblem.dateSet, - notes: backupProblem.notes, - createdAt: backupProblem.createdAt, - updatedAt: backupProblem.updatedAt - ) + return backupProblem.withUpdatedImagePaths(normalizedPaths) } return backupProblem } - let modifiedSessions = dataManager.sessions.filter { session in + var modifiedSessions = dataManager.sessions.filter { session in session.status != .active && session.updatedAt > lastSync }.map { BackupClimbSession(from: $0) } let activeSessionIds = Set( dataManager.sessions.filter { $0.status == .active }.map { $0.id }) - let modifiedAttempts = dataManager.attempts.filter { attempt in + + var modifiedAttempts = dataManager.attempts.filter { attempt in !activeSessionIds.contains(attempt.sessionId) && attempt.createdAt > lastSync }.map { BackupAttempt(from: $0) } - let modifiedDeletions = dataManager.getDeletedItems().filter { item in + // Handle deleted items as tombstones + let deletedItems = dataManager.getDeletedItems().filter { item in if let deletedDate = formatter.date(from: item.deletedAt) { return deletedDate > lastSync } return false } + for item in deletedItems { + guard let deletedDate = formatter.date(from: item.deletedAt) else { continue } + switch item.type { + case "gym": + modifiedGyms.append(BackupGym.createTombstone(id: item.id, deletedAt: deletedDate)) + case "problem": + modifiedProblems.append(BackupProblem.createTombstone(id: item.id, deletedAt: deletedDate)) + case "session": + modifiedSessions.append(BackupClimbSession.createTombstone(id: item.id, deletedAt: deletedDate)) + case "attempt": + modifiedAttempts.append(BackupAttempt.createTombstone(id: item.id, deletedAt: deletedDate)) + default: + break + } + } + AppLogger.info( - "Delta Sync: Sending gyms=\(modifiedGyms.count), problems=\(modifiedProblems.count), sessions=\(modifiedSessions.count), attempts=\(modifiedAttempts.count), deletions=\(modifiedDeletions.count)", + "Delta Sync: Sending gyms=\(modifiedGyms.count), problems=\(modifiedProblems.count), sessions=\(modifiedSessions.count), attempts=\(modifiedAttempts.count)", tag: logTag ) @@ -287,8 +284,7 @@ class ServerSyncProvider: SyncProvider { gyms: modifiedGyms, problems: modifiedProblems, sessions: modifiedSessions, - attempts: modifiedAttempts, - deletedItems: modifiedDeletions + attempts: modifiedAttempts ) let encoder = JSONEncoder() @@ -321,14 +317,14 @@ class ServerSyncProvider: SyncProvider { let deltaResponse = try decoder.decode(DeltaSyncResponse.self, from: data) AppLogger.info( - "Delta Sync: Received gyms=\(deltaResponse.gyms.count), problems=\(deltaResponse.problems.count), sessions=\(deltaResponse.sessions.count), attempts=\(deltaResponse.attempts.count), deletions=\(deltaResponse.deletedItems.count)", + "Delta Sync: Received gyms=\(deltaResponse.gyms.count), problems=\(deltaResponse.problems.count), sessions=\(deltaResponse.sessions.count), attempts=\(deltaResponse.attempts.count)", tag: logTag ) // Apply server changes to local data try await applyDeltaResponse(deltaResponse, dataManager: dataManager) - // Sync only modified problem images + // Upload images for modified problems try await syncModifiedImages(modifiedProblems: modifiedProblems, dataManager: dataManager) // Update last sync time to server time @@ -336,80 +332,52 @@ class ServerSyncProvider: SyncProvider { lastSyncTime = serverTime } } - + private func applyDeltaResponse(_ response: DeltaSyncResponse, dataManager: ClimbingDataManager) async throws { - // Use SyncMerger logic but adapted for DeltaSyncResponse - // Since SyncMerger works with ClimbDataBackup, we might need to adapt or just do it here since it's specific to DeltaSync - - // Actually, DeltaSyncResponse is very similar to ClimbDataBackup but with serverTime - // Let's construct a pseudo-backup for merging or just use the logic here since it handles image downloads too - let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - // Merge and apply deletions first to prevent resurrection - let allDeletions = dataManager.getDeletedItems() + response.deletedItems - let uniqueDeletions = Array(Set(allDeletions)) - - AppLogger.info( - "Delta Sync: Applying \(uniqueDeletions.count) deletion records before merging data", - tag: logTag - ) - applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager) - - // Build deleted item lookup map - let deletedItemSet = Set(uniqueDeletions.map { $0.type + ":" + $0.id }) - - // Download images for new/modified problems from server + // 1. Download images for problems that are NOT deleted var imagePathMapping: [String: String] = [:] for problem in response.problems { - if deletedItemSet.contains("problem:" + problem.id) { - continue - } + if let isDeleted = problem.isDeleted, isDeleted { continue } guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue } for (index, imagePath) in imagePaths.enumerated() { let serverFilename = URL(fileURLWithPath: imagePath).lastPathComponent - do { let imageData = try await downloadImage(filename: serverFilename) let consistentFilename = ImageNamingUtils.generateImageFilename( problemId: problem.id, imageIndex: index) - let imageManager = ImageManager.shared - _ = try imageManager.saveImportedImage(imageData, filename: consistentFilename) + // Save image using ImageManager + _ = try ImageManager.shared.saveImportedImage(imageData, filename: consistentFilename) imagePathMapping[serverFilename] = consistentFilename - } catch SyncError.imageNotFound { - AppLogger.info("Image not found on server: \(serverFilename)", tag: logTag) - continue } catch { AppLogger.info("Failed to download image \(serverFilename): \(error)", tag: logTag) - continue } } } - - // Now we can use SyncMerger logic if we convert response to Backup format - // But SyncMerger.mergeDataSafely does a full merge. Here we are doing delta merge. - // The logic in SyncService.swift for applyDeltaResponse was: - // 1. Download images - // 2. Merge gyms (check timestamps) - // 3. Merge problems (check timestamps) - // ... - - // This logic is slightly different from full merge because it checks timestamps against existing items specifically for delta. - // Full merge also checks timestamps but assumes full dataset. - // Let's keep the logic here for now as it is specific to the Delta Sync protocol. - - // Merge gyms + + // 2. Merge Gyms for backupGym in response.gyms { - if deletedItemSet.contains("gym:" + backupGym.id) { + // 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] + if let serverUpdate = formatter.date(from: backupGym.updatedAt), + serverUpdate >= existing.updatedAt { + dataManager.gyms.remove(at: index) + } + } continue } - if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id }) - { + // Handle Update/Insert + if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id }) { let existing = dataManager.gyms[index] - if backupGym.updatedAt >= formatter.string(from: existing.updatedAt) { + if let serverUpdate = formatter.date(from: backupGym.updatedAt), + serverUpdate >= existing.updatedAt { dataManager.gyms[index] = try backupGym.toGym() } } else { @@ -417,38 +385,29 @@ class ServerSyncProvider: SyncProvider { } } - // Merge problems + // 3. Merge Problems for backupProblem in response.problems { - if deletedItemSet.contains("problem:" + backupProblem.id) { + if let isDeleted = backupProblem.isDeleted, isDeleted { + if let index = dataManager.problems.firstIndex(where: { $0.id.uuidString == backupProblem.id }) { + let existing = dataManager.problems[index] + if let serverUpdate = formatter.date(from: backupProblem.updatedAt), + serverUpdate >= existing.updatedAt { + dataManager.problems.remove(at: index) + } + } continue } var problemToMerge = backupProblem if !imagePathMapping.isEmpty, let imagePaths = backupProblem.imagePaths { let updatedPaths = imagePaths.compactMap { imagePathMapping[$0] ?? $0 } - problemToMerge = BackupProblem( - id: backupProblem.id, - gymId: backupProblem.gymId, - name: backupProblem.name, - description: backupProblem.description, - climbType: backupProblem.climbType, - difficulty: backupProblem.difficulty, - tags: backupProblem.tags, - location: backupProblem.location, - imagePaths: updatedPaths, - isActive: backupProblem.isActive, - dateSet: backupProblem.dateSet, - notes: backupProblem.notes, - createdAt: backupProblem.createdAt, - updatedAt: backupProblem.updatedAt - ) + problemToMerge = backupProblem.withUpdatedImagePaths(updatedPaths) } - if let index = dataManager.problems.firstIndex(where: { - $0.id.uuidString == problemToMerge.id - }) { + if let index = dataManager.problems.firstIndex(where: { $0.id.uuidString == problemToMerge.id }) { let existing = dataManager.problems[index] - if problemToMerge.updatedAt >= formatter.string(from: existing.updatedAt) { + if let serverUpdate = formatter.date(from: problemToMerge.updatedAt), + serverUpdate >= existing.updatedAt { dataManager.problems[index] = try problemToMerge.toProblem() } } else { @@ -456,17 +415,23 @@ class ServerSyncProvider: SyncProvider { } } - // Merge sessions + // 4. Merge Sessions for backupSession in response.sessions { - if deletedItemSet.contains("session:" + backupSession.id) { + if let isDeleted = backupSession.isDeleted, isDeleted { + if let index = dataManager.sessions.firstIndex(where: { $0.id.uuidString == backupSession.id }) { + let existing = dataManager.sessions[index] + if let serverUpdate = formatter.date(from: backupSession.updatedAt), + serverUpdate >= existing.updatedAt { + dataManager.sessions.remove(at: index) + } + } continue } - if let index = dataManager.sessions.firstIndex(where: { - $0.id.uuidString == backupSession.id - }) { + if let index = dataManager.sessions.firstIndex(where: { $0.id.uuidString == backupSession.id }) { let existing = dataManager.sessions[index] - if backupSession.updatedAt >= formatter.string(from: existing.updatedAt) { + if let serverUpdate = formatter.date(from: backupSession.updatedAt), + serverUpdate >= existing.updatedAt { dataManager.sessions[index] = try backupSession.toClimbSession() } } else { @@ -474,17 +439,25 @@ class ServerSyncProvider: SyncProvider { } } - // Merge attempts + // 5. Merge Attempts for backupAttempt in response.attempts { - if deletedItemSet.contains("attempt:" + backupAttempt.id) { + if let isDeleted = backupAttempt.isDeleted, isDeleted { + if let index = dataManager.attempts.firstIndex(where: { $0.id.uuidString == backupAttempt.id }) { + let existing = dataManager.attempts[index] + let serverTimeStr = backupAttempt.updatedAt ?? backupAttempt.createdAt + if let serverTime = formatter.date(from: serverTimeStr), + serverTime >= existing.updatedAt { + dataManager.attempts.remove(at: index) + } + } continue } - if let index = dataManager.attempts.firstIndex(where: { - $0.id.uuidString == backupAttempt.id - }) { + if let index = dataManager.attempts.firstIndex(where: { $0.id.uuidString == backupAttempt.id }) { let existing = dataManager.attempts[index] - if backupAttempt.createdAt >= formatter.string(from: existing.createdAt) { + let serverTimeStr = backupAttempt.updatedAt ?? backupAttempt.createdAt + if let serverTime = formatter.date(from: serverTimeStr), + serverTime >= existing.updatedAt { dataManager.attempts[index] = try backupAttempt.toAttempt() } } else { @@ -492,81 +465,25 @@ class ServerSyncProvider: SyncProvider { } } - // Apply deletions again for safety - applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager) - - // Save all changes dataManager.saveGyms() dataManager.saveProblems() dataManager.saveSessions() dataManager.saveAttempts() - - // Update deletion records - dataManager.clearDeletedItems() - if let data = try? JSONEncoder().encode(uniqueDeletions) { - UserDefaults.standard.set(data, forKey: "ascently_deleted_items") - } - - DataStateManager.shared.updateDataState() } - - private func applyDeletionsToDataManager( - deletions: [DeletedItem], dataManager: ClimbingDataManager - ) { - let deletedGymIds = Set(deletions.filter { $0.type == "gym" }.map { $0.id }) - let deletedProblemIds = Set(deletions.filter { $0.type == "problem" }.map { $0.id }) - let deletedSessionIds = Set(deletions.filter { $0.type == "session" }.map { $0.id }) - let deletedAttemptIds = Set(deletions.filter { $0.type == "attempt" }.map { $0.id }) - dataManager.gyms.removeAll { deletedGymIds.contains($0.id.uuidString) } - dataManager.problems.removeAll { deletedProblemIds.contains($0.id.uuidString) } - dataManager.sessions.removeAll { deletedSessionIds.contains($0.id.uuidString) } - dataManager.attempts.removeAll { deletedAttemptIds.contains($0.id.uuidString) } + // MARK: - Image Sync + + private func syncModifiedImages(modifiedProblems: [BackupProblem], dataManager: ClimbingDataManager) async throws { + for problem in modifiedProblems { + guard let imagePaths = problem.imagePaths else { continue } + for path in imagePaths { + if let data = ImageManager.shared.getImageData(filename: path) { + try await uploadImage(filename: path, imageData: data) + } + } + } } - - private func syncModifiedImages( - modifiedProblems: [BackupProblem], dataManager: ClimbingDataManager - ) async throws { - guard !modifiedProblems.isEmpty else { return } - AppLogger.info("Delta Sync: Syncing images for \(modifiedProblems.count) modified problems", tag: logTag) - - for backupProblem in modifiedProblems { - guard - let problem = dataManager.problems.first(where: { - $0.id.uuidString == backupProblem.id - }) - else { - continue - } - - for (index, imagePath) in problem.imagePaths.enumerated() { - let filename = URL(fileURLWithPath: imagePath).lastPathComponent - let consistentFilename = ImageNamingUtils.generateImageFilename( - problemId: problem.id.uuidString, imageIndex: index) - - let imageManager = ImageManager.shared - let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path - - if let imageData = imageManager.loadImageData(fromPath: fullPath) { - do { - if filename != consistentFilename { - let newPath = imageManager.imagesDirectory.appendingPathComponent( - consistentFilename - ).path - try? FileManager.default.moveItem(atPath: fullPath, toPath: newPath) - } - - try await uploadImage(filename: consistentFilename, imageData: imageData) - AppLogger.info("Uploaded modified problem image: \(consistentFilename)", tag: logTag) - } catch { - AppLogger.info("Failed to upload image \(consistentFilename): \(error)", tag: logTag) - } - } - } - } - } - private func uploadImage(filename: String, imageData: Data) async throws { guard let url = URL(string: "\(serverURL)/images/upload?filename=\(filename)") else { throw SyncError.invalidURL @@ -575,28 +492,14 @@ class ServerSyncProvider: SyncProvider { var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") - request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") request.httpBody = imageData - request.timeoutInterval = 60.0 - request.cachePolicy = .reloadIgnoringLocalCacheData - let (_, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw SyncError.invalidResponse - } - - switch httpResponse.statusCode { - case 200: - break - case 401: - throw SyncError.unauthorized - default: - throw SyncError.serverError(httpResponse.statusCode) + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw SyncError.serverError(500) } } - + private func downloadImage(filename: String) async throws -> Data { guard let url = URL(string: "\(serverURL)/images/download?filename=\(filename)") else { throw SyncError.invalidURL @@ -606,581 +509,209 @@ class ServerSyncProvider: SyncProvider { request.httpMethod = "GET" request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") - request.timeoutInterval = 45.0 - request.cachePolicy = .returnCacheDataElseLoad - let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { - throw SyncError.invalidResponse + throw SyncError.invalidResponse } - switch httpResponse.statusCode { - case 200: - return data - case 401: - throw SyncError.unauthorized - case 404: + if httpResponse.statusCode == 404 { throw SyncError.imageNotFound - default: + } + + guard httpResponse.statusCode == 200 else { throw SyncError.serverError(httpResponse.statusCode) } - } - - private func syncImagesFromServer(backup: ClimbDataBackup, dataManager: ClimbingDataManager) - async throws -> [String: String] - { - var imagePathMapping: [String: String] = [:] + return data + } + + // MARK: - Full Sync Helpers (Simplified for reconstruction) + + private func syncImagesFromServer(backup: ClimbDataBackup, dataManager: ClimbingDataManager) async throws -> [String: String] { + var mapping: [String: String] = [:] for problem in backup.problems { - guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue } - - for (index, imagePath) in imagePaths.enumerated() { - let serverFilename = URL(fileURLWithPath: imagePath).lastPathComponent - - do { - let imageData = try await downloadImage(filename: serverFilename) - - let consistentFilename = ImageNamingUtils.generateImageFilename( - problemId: problem.id, imageIndex: index) - - let imageManager = ImageManager.shared - _ = try imageManager.saveImportedImage( - imageData, filename: consistentFilename) - - imagePathMapping[serverFilename] = consistentFilename - AppLogger.info("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)", tag: logTag) - } catch SyncError.imageNotFound { - AppLogger.info("Image not found on server: \(serverFilename)", tag: logTag) - continue - } catch { - AppLogger.info("Failed to download image \(serverFilename): \(error)", tag: logTag) - continue - } + guard let paths = problem.imagePaths else { continue } + for (index, path) in paths.enumerated() { + do { + let data = try await downloadImage(filename: path) + let localName = ImageNamingUtils.generateImageFilename(problemId: problem.id, imageIndex: index) + _ = try ImageManager.shared.saveImportedImage(data, filename: localName) + mapping[path] = localName + } catch { + continue + } } } - - return imagePathMapping + return mapping } - + private func syncImagesToServer(dataManager: ClimbingDataManager) async throws { - // Process images by problem to ensure consistent naming for problem in dataManager.problems { - guard !problem.imagePaths.isEmpty else { continue } - - for (index, imagePath) in problem.imagePaths.enumerated() { - let filename = URL(fileURLWithPath: imagePath).lastPathComponent - - let consistentFilename = ImageNamingUtils.generateImageFilename( - problemId: problem.id.uuidString, imageIndex: index) - - // Load image data - let imageManager = ImageManager.shared - let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path - - if let imageData = imageManager.loadImageData(fromPath: fullPath) { - do { - // If filename changed, rename local file - if filename != consistentFilename { - let newPath = imageManager.imagesDirectory.appendingPathComponent( - consistentFilename - ).path - do { - try FileManager.default.moveItem(atPath: fullPath, toPath: newPath) - AppLogger.info("Renamed local image: \(filename) -> \(consistentFilename)", tag: logTag) - - // Update problem's image path in memory for consistency - } catch { - AppLogger.info("Failed to rename local image, using original: \(error)", tag: logTag) - } - } - - try await uploadImage(filename: consistentFilename, imageData: imageData) - AppLogger.info("Successfully uploaded image: \(consistentFilename)", tag: logTag) - } catch { - AppLogger.info("Failed to upload image \(consistentFilename): \(error)", tag: logTag) - // Continue with other images even if one fails - } - } + for path in problem.imagePaths { + if let data = ImageManager.shared.getImageData(filename: path) { + try await uploadImage(filename: path, imageData: data) + } } } } - - 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) - } - AppLogger.info( - "Excluding \(dataManager.sessions.count - completedSessions.count) active sessions and \(dataManager.attempts.count - completedAttempts.count) active session attempts from sync", - tag: logTag - ) + private func createBackupFromDataManager(_ dataManager: ClimbingDataManager) -> ClimbDataBackup { + // Simple mapping + let gyms = dataManager.gyms.map { BackupGym(from: $0) } + let problems = dataManager.problems.map { BackupProblem(from: $0) } + let sessions = dataManager.sessions.map { BackupClimbSession(from: $0) } + let attempts = dataManager.attempts.map { BackupAttempt(from: $0) } return ClimbDataBackup( - exportedAt: DataStateManager.shared.getLastModified(), - gyms: dataManager.gyms.map { BackupGym(from: $0) }, - problems: dataManager.problems.map { BackupProblem(from: $0) }, - sessions: completedSessions.map { BackupClimbSession(from: $0) }, - attempts: completedAttempts.map { BackupAttempt(from: $0) }, - deletedItems: dataManager.getDeletedItems() + exportedAt: ISO8601DateFormatter().string(from: Date()), + gyms: gyms, + problems: problems, + sessions: sessions, + attempts: attempts, + deletedItems: [] // Legacy field, empty ) } - - private func mergeDataSafely( - localBackup: ClimbDataBackup, - serverBackup: ClimbDataBackup, - dataManager: ClimbingDataManager - ) async throws { - // Download server images first - let imagePathMapping = try await syncImagesFromServer( - backup: serverBackup, dataManager: dataManager) - // Use SyncMerger - let mergedResult = try SyncMerger.mergeDataSafely( - localBackup: localBackup, - serverBackup: serverBackup, - dataManager: dataManager, - imagePathMapping: imagePathMapping - ) + 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] - // Update data manager with merged data - dataManager.gyms = mergedResult.gyms - dataManager.problems = mergedResult.problems - dataManager.sessions = mergedResult.sessions - dataManager.attempts = mergedResult.attempts + // 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] + if let serverUpdate = formatter.date(from: gym.updatedAt), serverUpdate >= existing.updatedAt { + dataManager.gyms.remove(at: index) + } + } + 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 { + dataManager.gyms[index] = try gym.toGym() + } + } else { + dataManager.gyms.append(try gym.toGym()) + } + } + + // 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 }) { + let existing = dataManager.problems[index] + if let serverUpdate = formatter.date(from: problem.updatedAt), serverUpdate >= existing.updatedAt { + dataManager.problems.remove(at: index) + } + } + continue + } + + if let index = dataManager.problems.firstIndex(where: { $0.id.uuidString == problem.id }) { + let existing = dataManager.problems[index] + if let serverUpdate = formatter.date(from: problem.updatedAt), serverUpdate >= existing.updatedAt { + dataManager.problems[index] = try problem.toProblem() + } + } else { + dataManager.problems.append(try problem.toProblem()) + } + } + + // 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 }) { + let existing = dataManager.sessions[index] + if let serverUpdate = formatter.date(from: session.updatedAt), serverUpdate >= existing.updatedAt { + dataManager.sessions.remove(at: index) + } + } + continue + } + + if let index = dataManager.sessions.firstIndex(where: { $0.id.uuidString == session.id }) { + let existing = dataManager.sessions[index] + if let serverUpdate = formatter.date(from: session.updatedAt), serverUpdate >= existing.updatedAt { + dataManager.sessions[index] = try session.toClimbSession() + } + } else { + dataManager.sessions.append(try session.toClimbSession()) + } + } + + // 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 }) { + let existing = dataManager.attempts[index] + let serverTimeStr = attempt.updatedAt ?? attempt.createdAt + if let serverUpdate = formatter.date(from: serverTimeStr), serverUpdate >= existing.updatedAt { + dataManager.attempts.remove(at: index) + } + } + continue + } + + if let index = dataManager.attempts.firstIndex(where: { $0.id.uuidString == attempt.id }) { + let existing = dataManager.attempts[index] + let serverTimeStr = attempt.updatedAt ?? attempt.createdAt + if let serverUpdate = formatter.date(from: serverTimeStr), serverUpdate >= existing.updatedAt { + dataManager.attempts[index] = try attempt.toAttempt() + } + } else { + dataManager.attempts.append(try attempt.toAttempt()) + } + } - // Save all data dataManager.saveGyms() dataManager.saveProblems() dataManager.saveSessions() dataManager.saveAttempts() - dataManager.saveActiveSession() - - // Update local deletions with merged list - dataManager.clearDeletedItems() - if let data = try? JSONEncoder().encode(mergedResult.uniqueDeletions) { - UserDefaults.standard.set(data, forKey: "ascently_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 { - // This logic is also in SyncService.swift, it's quite complex as it handles active sessions preservation - // I'll copy it here. - - 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) - } - - AppLogger.info( - "Preserving \(activeSessions.count) active sessions and \(activeAttempts.count) active attempts during import", - tag: logTag - ) - - // Update problem image paths to point to downloaded images - let updatedBackup: ClimbDataBackup - if !imagePathMapping.isEmpty { - let updatedProblems = backup.problems.map { problem in - let updatedImagePaths = problem.imagePaths?.compactMap { oldPath in - imagePathMapping[oldPath] ?? oldPath - } - return BackupProblem( - id: problem.id, - gymId: problem.gymId, - name: problem.name, - description: problem.description, - climbType: problem.climbType, - difficulty: problem.difficulty, - tags: problem.tags, - location: problem.location, - imagePaths: updatedImagePaths, - isActive: problem.isActive, - dateSet: problem.dateSet, - notes: problem.notes, - createdAt: problem.createdAt, - 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: filteredGyms, - problems: filteredProblems, - sessions: filteredSessions, - attempts: filteredAttempts, - deletedItems: backup.deletedItems - ) - } else { - // 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 - // We need to use ZipUtils or similar. SyncService had createMinimalZipFromBackup. - // I should probably move createMinimalZipFromBackup to ZipUtils or just copy it here. - // Since ZipUtils exists, let's see if we can use it. - // ZipUtils.createExportZip creates a full zip. - // SyncService had a custom implementation for minimal zip. - // I'll copy the implementation here for now to avoid changing ZipUtils too much, or I can add it to ZipUtils. - // For now, I'll copy it to keep this file self-contained regarding the sync logic. - - let zipData = try createMinimalZipFromBackup(updatedBackup) - - // 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 { - AppLogger.info("Restoring active session: \(session.id)", tag: logTag) - 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: "ascently_deleted_items") - AppLogger.info("Imported \(backup.deletedItems.count) deletion records", tag: logTag) - } - - // Update local data state to match imported data timestamp - DataStateManager.shared.setLastModified(backup.exportedAt) - AppLogger.info("Data state synchronized to imported timestamp: \(backup.exportedAt)", tag: logTag) - } catch { - throw SyncError.importFailed(error) - } - } - - // Copied from SyncService.swift - private func createMinimalZipFromBackup(_ backup: ClimbDataBackup) throws -> Data { - // Create JSON data - - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - encoder.dateEncodingStrategy = .custom { date, encoder in - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" - var container = encoder.singleValueContainer() - try container.encode(formatter.string(from: date)) - } - let jsonData = try encoder.encode(backup) - - // Collect all images from ImageManager - let imageManager = ImageManager.shared - var imageFiles: [(filename: String, data: Data)] = [] - - // Get original problems to access actual image paths on disk - if let problemsData = userDefaults.data(forKey: "ascently_problems"), // Changed key to match ClimbingDataManager - let problems = try? JSONDecoder().decode([Problem].self, from: problemsData) - { - // Create a mapping from normalized paths to actual paths - for problem in problems { - for (index, imagePath) in problem.imagePaths.enumerated() { - // Get the actual filename on disk - let actualFilename = URL(fileURLWithPath: imagePath).lastPathComponent - let fullPath = imageManager.imagesDirectory.appendingPathComponent( - actualFilename - ).path - - // Generate the normalized filename for the ZIP - let normalizedFilename = ImageNamingUtils.generateImageFilename( - problemId: problem.id.uuidString, imageIndex: index) - - if let imageData = imageManager.loadImageData(fromPath: fullPath) { - imageFiles.append((filename: normalizedFilename, data: imageData)) - } - } - } + // Logic from previous read + let updatedProblems = backup.problems.map { problem in + let updatedImagePaths = problem.imagePaths?.compactMap { oldPath in + imagePathMapping[oldPath] ?? oldPath + } ?? [] + return problem.withUpdatedImagePaths(updatedImagePaths) } - // Create ZIP with data.json, metadata, and images - var zipData = Data() - var fileEntries: [(name: String, data: Data, offset: UInt32)] = [] - var currentOffset: UInt32 = 0 - - // Add data.json to ZIP - try addFileToMinimalZip( - filename: "data.json", - fileData: jsonData, - zipData: &zipData, - fileEntries: &fileEntries, - currentOffset: ¤tOffset - ) - - // Add metadata with correct image count - let metadata = "export_version=2.0\nformat_version=2.0\nimage_count=\(imageFiles.count)" - let metadataData = metadata.data(using: .utf8) ?? Data() - try addFileToMinimalZip( - filename: "metadata.txt", - fileData: metadataData, - zipData: &zipData, - fileEntries: &fileEntries, - currentOffset: ¤tOffset - ) - - // Add images to ZIP in images/ directory - for imageFile in imageFiles { - try addFileToMinimalZip( - filename: "images/\(imageFile.filename)", - fileData: imageFile.data, - zipData: &zipData, - fileEntries: &fileEntries, - currentOffset: ¤tOffset - ) + // 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() } - // Add central directory - var centralDirectory = Data() - for entry in fileEntries { - centralDirectory.append(createCentralDirectoryHeader(entry: entry)) + dataManager.problems = try updatedProblems.compactMap { problem in + if let isDeleted = problem.isDeleted, isDeleted { return nil } + return try problem.toProblem() } - // Add end of central directory record - let endOfCentralDir = createEndOfCentralDirectoryRecord( - fileCount: UInt16(fileEntries.count), - centralDirSize: UInt32(centralDirectory.count), - centralDirOffset: currentOffset - ) + dataManager.sessions = try backup.sessions.compactMap { session in + if let isDeleted = session.isDeleted, isDeleted { return nil } + return try session.toClimbSession() + } - zipData.append(centralDirectory) - zipData.append(endOfCentralDir) + dataManager.attempts = try backup.attempts.compactMap { attempt in + if let isDeleted = attempt.isDeleted, isDeleted { return nil } + return try attempt.toAttempt() + } - return zipData - } - - private func addFileToMinimalZip( - filename: String, - fileData: Data, - zipData: inout Data, - fileEntries: inout [(name: String, data: Data, offset: UInt32)], - currentOffset: inout UInt32 - ) throws { - let localFileHeader = createLocalFileHeader( - filename: filename, fileSize: UInt32(fileData.count)) - - fileEntries.append((name: filename, data: fileData, offset: currentOffset)) - - zipData.append(localFileHeader) - zipData.append(fileData) - - currentOffset += UInt32(localFileHeader.count + fileData.count) - } - - private func createLocalFileHeader(filename: String, fileSize: UInt32) -> Data { - var header = Data() - - // Local file header signature - header.append(Data([0x50, 0x4b, 0x03, 0x04])) - - // Version needed to extract (2.0) - header.append(Data([0x14, 0x00])) - - // General purpose bit flag - header.append(Data([0x00, 0x00])) - - // Compression method (no compression) - header.append(Data([0x00, 0x00])) - - // Last mod file time & date (dummy values) - header.append(Data([0x00, 0x00, 0x00, 0x00])) - - // CRC-32 (dummy - we're not compressing) - header.append(Data([0x00, 0x00, 0x00, 0x00])) - - // Compressed size - withUnsafeBytes(of: fileSize.littleEndian) { header.append(Data($0)) } - - // Uncompressed size - withUnsafeBytes(of: fileSize.littleEndian) { header.append(Data($0)) } - - // File name length - let filenameData = filename.data(using: .utf8) ?? Data() - let filenameLength = UInt16(filenameData.count) - withUnsafeBytes(of: filenameLength.littleEndian) { header.append(Data($0)) } - - // Extra field length - header.append(Data([0x00, 0x00])) - - // File name - header.append(filenameData) - - return header - } - - private func createCentralDirectoryHeader(entry: (name: String, data: Data, offset: UInt32)) - -> Data - { - var header = Data() - - // Central directory signature - header.append(Data([0x50, 0x4b, 0x01, 0x02])) - - // Version made by - header.append(Data([0x14, 0x00])) - - // Version needed to extract - header.append(Data([0x14, 0x00])) - - // General purpose bit flag - header.append(Data([0x00, 0x00])) - - // Compression method - header.append(Data([0x00, 0x00])) - - // Last mod file time & date - header.append(Data([0x00, 0x00, 0x00, 0x00])) - - // CRC-32 - header.append(Data([0x00, 0x00, 0x00, 0x00])) - - // Compressed size - let compressedSize = UInt32(entry.data.count) - withUnsafeBytes(of: compressedSize.littleEndian) { header.append(Data($0)) } - - // Uncompressed size - withUnsafeBytes(of: compressedSize.littleEndian) { header.append(Data($0)) } - - // File name length - let filenameData = entry.name.data(using: .utf8) ?? Data() - let filenameLength = UInt16(filenameData.count) - withUnsafeBytes(of: filenameLength.littleEndian) { header.append(Data($0)) } - - // Extra field length - header.append(Data([0x00, 0x00])) - - // File comment length - header.append(Data([0x00, 0x00])) - - // Disk number start - header.append(Data([0x00, 0x00])) - - // Internal file attributes - header.append(Data([0x00, 0x00])) - - // External file attributes - header.append(Data([0x00, 0x00, 0x00, 0x00])) - - // Relative offset of local header - withUnsafeBytes(of: entry.offset.littleEndian) { header.append(Data($0)) } - - // File name - header.append(filenameData) - - return header - } - - private func createEndOfCentralDirectoryRecord( - fileCount: UInt16, centralDirSize: UInt32, centralDirOffset: UInt32 - ) -> Data { - var record = Data() - - // End of central dir signature - record.append(Data([0x50, 0x4b, 0x05, 0x06])) - - // Number of this disk - record.append(Data([0x00, 0x00])) - - // Number of the disk with the start of the central directory - record.append(Data([0x00, 0x00])) - - // Total number of entries in the central directory on this disk - withUnsafeBytes(of: fileCount.littleEndian) { record.append(Data($0)) } - - // Total number of entries in the central directory - withUnsafeBytes(of: fileCount.littleEndian) { record.append(Data($0)) } - - // Size of the central directory - withUnsafeBytes(of: centralDirSize.littleEndian) { record.append(Data($0)) } - - // Offset of start of central directory - withUnsafeBytes(of: centralDirOffset.littleEndian) { record.append(Data($0)) } - - // ZIP file comment length - record.append(Data([0x00, 0x00])) - - return record + dataManager.saveGyms() + dataManager.saveProblems() + dataManager.saveSessions() + dataManager.saveAttempts() } } diff --git a/ios/Ascently/Views/SettingsView.swift b/ios/Ascently/Views/SettingsView.swift index bc38678..f109159 100644 --- a/ios/Ascently/Views/SettingsView.swift +++ b/ios/Ascently/Views/SettingsView.swift @@ -6,6 +6,7 @@ import UniformTypeIdentifiers enum SheetType { case export(Data) case importData + case syncSettings } struct SettingsView: View { @@ -16,7 +17,7 @@ struct SettingsView: View { var body: some View { NavigationStack { List { - SyncSection() + SyncSection(activeSheet: $activeSheet) .environmentObject(dataManager.syncService) HealthKitSection() @@ -67,6 +68,9 @@ struct SettingsView: View { ExportDataView(data: data) case .importData: ImportDataView() + case .syncSettings: + SyncSettingsView() + .environmentObject(dataManager.syncService) } } } @@ -78,6 +82,7 @@ extension SheetType: Identifiable { switch self { case .export: return "export" case .importData: return "import" + case .syncSettings: return "sync_settings" } } } @@ -526,7 +531,7 @@ struct SyncSection: View { @EnvironmentObject var syncService: SyncService @EnvironmentObject var dataManager: ClimbingDataManager @EnvironmentObject var themeManager: ThemeManager - @State private var showingSyncSettings = false + @Binding var activeSheet: SheetType? @State private var showingDisconnectAlert = false private static let logTag = "SyncSection" @@ -567,7 +572,7 @@ struct SyncSection: View { // Configure Server Button(action: { - showingSyncSettings = true + activeSheet = .syncSettings }) { HStack { Image(systemName: "gear") @@ -657,10 +662,6 @@ struct SyncSection: View { } } } - .sheet(isPresented: $showingSyncSettings) { - SyncSettingsView() - .environmentObject(syncService) - } .alert("Disconnect from Server", isPresented: $showingDisconnectAlert) { Button("Cancel", role: .cancel) {} Button("Disconnect", role: .destructive) { @@ -702,24 +703,14 @@ struct SyncSettingsView: View { NavigationStack { Form { Section { - TextField("Server URL", text: $serverURL) - .textFieldStyle(.roundedBorder) + TextField("Server URL", text: $serverURL, prompt: Text("http://your-server:8080")) .keyboardType(.URL) .autocapitalization(.none) .disableAutocorrection(true) - .placeholder(when: serverURL.isEmpty) { - Text("http://your-server:8080") - .foregroundColor(.secondary) - } - TextField("Auth Token", text: $authToken) - .textFieldStyle(.roundedBorder) + TextField("Auth Token", text: $authToken, prompt: Text("your-secret-token")) .autocapitalization(.none) .disableAutocorrection(true) - .placeholder(when: authToken.isEmpty) { - Text("your-secret-token") - .foregroundColor(.secondary) - } } header: { Text("Server Configuration") } footer: { @@ -845,37 +836,34 @@ struct SyncSettingsView: View { let originalURL = syncService.serverURL let originalToken = syncService.authToken - Task { + Task { @MainActor in do { // Ensure we are using the server provider - await MainActor.run { - if syncService.providerType != .server { - syncService.providerType = .server - } + if syncService.providerType != .server { + syncService.providerType = .server } // Temporarily set the values for testing syncService.serverURL = testURL syncService.authToken = testToken + // Explicitly sync UserDefaults to ensure immediate availability + UserDefaults.standard.synchronize() + try await syncService.testConnection() - await MainActor.run { - isTesting = false - testResultMessage = - "Connection successful! You can now save and sync your data." - showingTestResult = true - } + isTesting = false + testResultMessage = + "Connection successful! You can now save and sync your data." + showingTestResult = true } catch { // Restore original values if test failed syncService.serverURL = originalURL syncService.authToken = originalToken - await MainActor.run { - isTesting = false - testResultMessage = "Connection failed: \(error.localizedDescription)" - showingTestResult = true - } + isTesting = false + testResultMessage = "Connection failed: \(error.localizedDescription)" + showingTestResult = true } } } diff --git a/sync/main.go b/sync/main.go index 40d335b..12d44e1 100644 --- a/sync/main.go +++ b/sync/main.go @@ -13,7 +13,7 @@ import ( "time" ) -const VERSION = "2.3.0" +const VERSION = "2.4.0" func min(a, b int) int { if a < b { @@ -22,12 +22,6 @@ 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"` @@ -36,7 +30,6 @@ type ClimbDataBackup struct { Problems []BackupProblem `json:"problems"` Sessions []BackupClimbSession `json:"sessions"` Attempts []BackupAttempt `json:"attempts"` - DeletedItems []DeletedItem `json:"deletedItems"` } type DeltaSyncRequest struct { @@ -45,16 +38,14 @@ type DeltaSyncRequest struct { Problems []BackupProblem `json:"problems"` Sessions []BackupClimbSession `json:"sessions"` Attempts []BackupAttempt `json:"attempts"` - DeletedItems []DeletedItem `json:"deletedItems"` } type DeltaSyncResponse struct { - ServerTime string `json:"serverTime"` - Gyms []BackupGym `json:"gyms"` - Problems []BackupProblem `json:"problems"` - Sessions []BackupClimbSession `json:"sessions"` - Attempts []BackupAttempt `json:"attempts"` - DeletedItems []DeletedItem `json:"deletedItems"` + ServerTime string `json:"serverTime"` + Gyms []BackupGym `json:"gyms"` + Problems []BackupProblem `json:"problems"` + Sessions []BackupClimbSession `json:"sessions"` + Attempts []BackupAttempt `json:"attempts"` } type BackupGym struct { @@ -65,6 +56,7 @@ type BackupGym struct { DifficultySystems []string `json:"difficultySystems"` CustomDifficultyGrades []string `json:"customDifficultyGrades"` Notes *string `json:"notes,omitempty"` + IsDeleted bool `json:"isDeleted"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } @@ -82,6 +74,7 @@ type BackupProblem struct { IsActive bool `json:"isActive"` DateSet *string `json:"dateSet,omitempty"` Notes *string `json:"notes,omitempty"` + IsDeleted bool `json:"isDeleted"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } @@ -101,6 +94,7 @@ type BackupClimbSession struct { Duration *int64 `json:"duration,omitempty"` Status string `json:"status"` Notes *string `json:"notes,omitempty"` + IsDeleted bool `json:"isDeleted"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } @@ -115,7 +109,9 @@ type BackupAttempt struct { Duration *int64 `json:"duration,omitempty"` RestTime *int64 `json:"restTime,omitempty"` Timestamp string `json:"timestamp"` + IsDeleted bool `json:"isDeleted"` CreatedAt string `json:"createdAt"` + UpdatedAt *string `json:"updatedAt,omitempty"` } type SyncServer struct { @@ -147,7 +143,6 @@ func (s *SyncServer) loadData() (*ClimbDataBackup, error) { Problems: []BackupProblem{}, Sessions: []BackupClimbSession{}, Attempts: []BackupAttempt{}, - DeletedItems: []DeletedItem{}, }, nil } @@ -158,7 +153,18 @@ func (s *SyncServer) loadData() (*ClimbDataBackup, error) { } log.Printf("Read %d bytes from data file", len(data)) - log.Printf("File content preview: %s", string(data[:min(200, len(data))])) + // Basic check to see if we have JSON content + if len(data) == 0 { + return &ClimbDataBackup{ + ExportedAt: time.Now().UTC().Format(time.RFC3339), + Version: "2.0", + FormatVersion: "2.0", + Gyms: []BackupGym{}, + Problems: []BackupProblem{}, + Sessions: []BackupClimbSession{}, + Attempts: []BackupAttempt{}, + }, nil + } var backup ClimbDataBackup if err := json.Unmarshal(data, &backup); err != nil { @@ -250,7 +256,18 @@ func (s *SyncServer) mergeAttempts(existing []BackupAttempt, updates []BackupAtt for _, attempt := range updates { if existingAttempt, exists := attemptMap[attempt.ID]; exists { - if attempt.CreatedAt >= existingAttempt.CreatedAt { + // Resolve update time for comparison + updateTime := attempt.CreatedAt + if attempt.UpdatedAt != nil { + updateTime = *attempt.UpdatedAt + } + + existingUpdateTime := existingAttempt.CreatedAt + if existingAttempt.UpdatedAt != nil { + existingUpdateTime = *existingAttempt.UpdatedAt + } + + if updateTime >= existingUpdateTime { attemptMap[attempt.ID] = attempt } } else { @@ -265,89 +282,6 @@ func (s *SyncServer) mergeAttempts(existing []BackupAttempt, updates []BackupAtt return result } -func (s *SyncServer) mergeDeletedItems(existing []DeletedItem, updates []DeletedItem) []DeletedItem { - deletedMap := make(map[string]DeletedItem) - for _, item := range existing { - key := item.Type + ":" + item.ID - deletedMap[key] = item - } - - for _, item := range updates { - key := item.Type + ":" + item.ID - if existingItem, exists := deletedMap[key]; exists { - if item.DeletedAt >= existingItem.DeletedAt { - deletedMap[key] = item - } - } else { - deletedMap[key] = item - } - } - - // Clean up tombstones older than 30 days to prevent unbounded growth - cutoffTime := time.Now().UTC().Add(-30 * 24 * time.Hour) - result := make([]DeletedItem, 0, len(deletedMap)) - for _, item := range deletedMap { - deletedTime, err := time.Parse(time.RFC3339, item.DeletedAt) - if err == nil && deletedTime.Before(cutoffTime) { - log.Printf("Cleaning up old deletion record: type=%s, id=%s, deletedAt=%s", - item.Type, item.ID, item.DeletedAt) - continue - } - result = append(result, item) - } - return result -} - -func (s *SyncServer) applyDeletions(backup *ClimbDataBackup, deletedItems []DeletedItem) { - deletedMap := make(map[string]map[string]bool) - for _, item := range deletedItems { - if deletedMap[item.Type] == nil { - deletedMap[item.Type] = make(map[string]bool) - } - deletedMap[item.Type][item.ID] = true - } - - if deletedMap["gym"] != nil { - filtered := []BackupGym{} - for _, gym := range backup.Gyms { - if !deletedMap["gym"][gym.ID] { - filtered = append(filtered, gym) - } - } - backup.Gyms = filtered - } - - if deletedMap["problem"] != nil { - filtered := []BackupProblem{} - for _, problem := range backup.Problems { - if !deletedMap["problem"][problem.ID] { - filtered = append(filtered, problem) - } - } - backup.Problems = filtered - } - - if deletedMap["session"] != nil { - filtered := []BackupClimbSession{} - for _, session := range backup.Sessions { - if !deletedMap["session"][session.ID] { - filtered = append(filtered, session) - } - } - backup.Sessions = filtered - } - - if deletedMap["attempt"] != nil { - filtered := []BackupAttempt{} - for _, attempt := range backup.Attempts { - if !deletedMap["attempt"][attempt.ID] { - filtered = append(filtered, attempt) - } - } - backup.Attempts = filtered - } -} - func (s *SyncServer) saveData(backup *ClimbDataBackup) error { backup.ExportedAt = time.Now().UTC().Format(time.RFC3339) @@ -383,6 +317,8 @@ func (s *SyncServer) handleGet(w http.ResponseWriter, r *http.Request) { return } + + log.Printf("Sending data to %s: gyms=%d, problems=%d, sessions=%d, attempts=%d", r.RemoteAddr, len(backup.Gyms), len(backup.Problems), len(backup.Sessions), len(backup.Attempts)) w.Header().Set("Content-Type", "application/json") @@ -527,11 +463,10 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) { return } - log.Printf("Delta sync from %s: lastSyncTime=%s, gyms=%d, problems=%d, sessions=%d, attempts=%d, deletedItems=%d", + log.Printf("Delta sync from %s: lastSyncTime=%s, gyms=%d, problems=%d, sessions=%d, attempts=%d", r.RemoteAddr, deltaRequest.LastSyncTime, len(deltaRequest.Gyms), len(deltaRequest.Problems), - len(deltaRequest.Sessions), len(deltaRequest.Attempts), - len(deltaRequest.DeletedItems)) + len(deltaRequest.Sessions), len(deltaRequest.Attempts)) // Load current server data serverBackup, err := s.loadData() @@ -541,12 +476,9 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) { return } - // Merge and apply deletions first to prevent resurrection - serverBackup.DeletedItems = s.mergeDeletedItems(serverBackup.DeletedItems, deltaRequest.DeletedItems) - s.applyDeletions(serverBackup, serverBackup.DeletedItems) - log.Printf("Applied deletions: total=%d deletion records", len(serverBackup.DeletedItems)) - // Merge client changes into server data + // Note: We no longer need separate deletion handling as IsDeleted is part of the struct + // and handled by standard merge logic (latest timestamp wins) serverBackup.Gyms = s.mergeGyms(serverBackup.Gyms, deltaRequest.Gyms) serverBackup.Problems = s.mergeProblems(serverBackup.Problems, deltaRequest.Problems) serverBackup.Sessions = s.mergeSessions(serverBackup.Sessions, deltaRequest.Sessions) @@ -566,28 +498,17 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) { log.Printf("Warning: Could not parse lastSyncTime '%s', sending all data", deltaRequest.LastSyncTime) } - // Build deleted item lookup map - deletedItemMap := make(map[string]bool) - for _, item := range serverBackup.DeletedItems { - key := item.Type + ":" + item.ID - deletedItemMap[key] = true - } - // Prepare response with items modified since client's last sync response := DeltaSyncResponse{ - ServerTime: time.Now().UTC().Format(time.RFC3339), - Gyms: []BackupGym{}, - Problems: []BackupProblem{}, - Sessions: []BackupClimbSession{}, - Attempts: []BackupAttempt{}, - DeletedItems: []DeletedItem{}, + ServerTime: time.Now().UTC().Format(time.RFC3339), + Gyms: []BackupGym{}, + Problems: []BackupProblem{}, + Sessions: []BackupClimbSession{}, + Attempts: []BackupAttempt{}, } // Filter gyms modified after client's last sync for _, gym := range serverBackup.Gyms { - if deletedItemMap["gym:"+gym.ID] { - continue - } gymTime, err := time.Parse(time.RFC3339, gym.UpdatedAt) if err == nil && gymTime.After(clientLastSync) { response.Gyms = append(response.Gyms, gym) @@ -596,9 +517,6 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) { // Filter problems modified after client's last sync for _, problem := range serverBackup.Problems { - if deletedItemMap["problem:"+problem.ID] { - continue - } problemTime, err := time.Parse(time.RFC3339, problem.UpdatedAt) if err == nil && problemTime.After(clientLastSync) { response.Problems = append(response.Problems, problem) @@ -607,39 +525,29 @@ func (s *SyncServer) handleDeltaSync(w http.ResponseWriter, r *http.Request) { // Filter sessions modified after client's last sync for _, session := range serverBackup.Sessions { - if deletedItemMap["session:"+session.ID] { - continue - } sessionTime, err := time.Parse(time.RFC3339, session.UpdatedAt) if err == nil && sessionTime.After(clientLastSync) { response.Sessions = append(response.Sessions, session) } } - // Filter attempts created after client's last sync + // Filter attempts modified after client's last sync for _, attempt := range serverBackup.Attempts { - if deletedItemMap["attempt:"+attempt.ID] { - continue + attemptTime := attempt.CreatedAt + if attempt.UpdatedAt != nil { + attemptTime = *attempt.UpdatedAt } - attemptTime, err := time.Parse(time.RFC3339, attempt.CreatedAt) - if err == nil && attemptTime.After(clientLastSync) { + + parsedTime, err := time.Parse(time.RFC3339, attemptTime) + if err == nil && parsedTime.After(clientLastSync) { response.Attempts = append(response.Attempts, attempt) } } - // Filter deletions after client's last sync - for _, deletedItem := range serverBackup.DeletedItems { - deletedTime, err := time.Parse(time.RFC3339, deletedItem.DeletedAt) - if err == nil && deletedTime.After(clientLastSync) { - response.DeletedItems = append(response.DeletedItems, deletedItem) - } - } - - log.Printf("Delta sync response to %s: gyms=%d, problems=%d, sessions=%d, attempts=%d, deletedItems=%d", + log.Printf("Delta sync response to %s: gyms=%d, problems=%d, sessions=%d, attempts=%d", r.RemoteAddr, len(response.Gyms), len(response.Problems), - len(response.Sessions), len(response.Attempts), - len(response.DeletedItems)) + len(response.Sessions), len(response.Attempts)) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response)