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 cb5508f..e4345f6 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/Services/Sync/ServerSyncProvider.swift b/ios/Ascently/Services/Sync/ServerSyncProvider.swift new file mode 100644 index 0000000..167a33f --- /dev/null +++ b/ios/Ascently/Services/Sync/ServerSyncProvider.swift @@ -0,0 +1,1188 @@ +import Foundation +import Combine + +class ServerSyncProvider: SyncProvider { + 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) } + } + + var authToken: String { + 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 + } + + 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 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 + } + + guard isConnected else { + throw SyncError.notConnected + } + + // 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 + + let hasServerData = + !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) + let imagePathMapping = try await syncImagesFromServer( + backup: serverBackup, dataManager: dataManager) + AppLogger.info("Importing data after images...", tag: logTag) + try importBackupToDataManager( + serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping) + AppLogger.info("Full restore completed", tag: logTag) + } else if hasLocalData && !hasServerData { + AppLogger.info("Uploading local data to server", tag: logTag) + let currentBackup = createBackupFromDataManager(dataManager) + _ = try await uploadData(currentBackup) + AppLogger.info("Uploading local images to server...", tag: logTag) + try await syncImagesToServer(dataManager: dataManager) + AppLogger.info("Initial upload completed", tag: logTag) + } else if hasLocalData && hasServerData { + AppLogger.info("Merging local and server data", tag: logTag) + try await mergeDataSafely( + localBackup: localBackup, + serverBackup: serverBackup, + dataManager: dataManager) + AppLogger.info("Safe merge completed", tag: logTag) + } else { + AppLogger.info("No data to sync", tag: logTag) + } + + // 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 + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, 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) + } + + do { + let backup = try JSONDecoder().decode(ClimbDataBackup.self, from: data) + return backup + } catch { + throw SyncError.decodingError(error) + } + } + + private func uploadData(_ backup: ClimbDataBackup) async throws -> ClimbDataBackup { + guard let url = URL(string: "\(serverURL)/sync") else { + throw SyncError.invalidURL + } + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + let jsonData = try encoder.encode(backup) + + var request = URLRequest(url: url) + request.httpMethod = "PUT" + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = jsonData + + let (data, 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 + case 400: + throw SyncError.badRequest + default: + throw SyncError.serverError(httpResponse.statusCode) + } + + do { + let responseBackup = try JSONDecoder().decode(ClimbDataBackup.self, from: data) + return responseBackup + } catch { + throw SyncError.decodingError(error) + } + } + + private func performDeltaSync(dataManager: ClimbingDataManager) async throws { + guard let url = URL(string: "\(serverURL)/sync/delta") else { + throw SyncError.invalidURL + } + + // Get last sync time, or use epoch if never synced + let lastSync = lastSyncTime ?? Date(timeIntervalSince1970: 0) + let formatter = ISO8601DateFormatter() + let lastSyncString = formatter.string(from: lastSync) + + // Collect items modified since last sync + let modifiedGyms = dataManager.gyms.filter { gym in + gym.updatedAt > lastSync + }.map { BackupGym(from: $0) } + + let modifiedProblems = dataManager.problems.filter { problem in + problem.updatedAt > lastSync + }.map { problem -> BackupProblem in + let backupProblem = BackupProblem(from: problem) + if !problem.imagePaths.isEmpty { + let normalizedPaths = problem.imagePaths.enumerated().map { index, _ in + 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 + } + + let 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 + !activeSessionIds.contains(attempt.sessionId) && attempt.createdAt > lastSync + }.map { BackupAttempt(from: $0) } + + let modifiedDeletions = dataManager.getDeletedItems().filter { item in + if let deletedDate = formatter.date(from: item.deletedAt) { + return deletedDate > lastSync + } + return false + } + + AppLogger.info( + "Delta Sync: Sending gyms=\(modifiedGyms.count), problems=\(modifiedProblems.count), sessions=\(modifiedSessions.count), attempts=\(modifiedAttempts.count), deletions=\(modifiedDeletions.count)", + tag: logTag + ) + + // Create delta request + let deltaRequest = DeltaSyncRequest( + lastSyncTime: lastSyncString, + gyms: modifiedGyms, + problems: modifiedProblems, + sessions: modifiedSessions, + attempts: modifiedAttempts, + deletedItems: modifiedDeletions + ) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let jsonData = try encoder.encode(deltaRequest) + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = jsonData + + let (data, 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) + } + + let decoder = JSONDecoder() + 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)", + tag: logTag + ) + + // Apply server changes to local data + try await applyDeltaResponse(deltaResponse, dataManager: dataManager) + + // Sync only modified problem images + try await syncModifiedImages(modifiedProblems: modifiedProblems, dataManager: dataManager) + + // Update last sync time to server time + if let serverTime = formatter.date(from: deltaResponse.serverTime) { + 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() + + // 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 + var imagePathMapping: [String: String] = [:] + for problem in response.problems { + if deletedItemSet.contains("problem:" + problem.id) { + 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) + 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 + for backupGym in response.gyms { + if deletedItemSet.contains("gym:" + backupGym.id) { + continue + } + + 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) { + dataManager.gyms[index] = try backupGym.toGym() + } + } else { + dataManager.gyms.append(try backupGym.toGym()) + } + } + + // Merge problems + for backupProblem in response.problems { + if deletedItemSet.contains("problem:" + backupProblem.id) { + 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 + ) + } + + 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) { + dataManager.problems[index] = try problemToMerge.toProblem() + } + } else { + dataManager.problems.append(try problemToMerge.toProblem()) + } + } + + // Merge sessions + for backupSession in response.sessions { + if deletedItemSet.contains("session:" + backupSession.id) { + continue + } + + 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) { + dataManager.sessions[index] = try backupSession.toClimbSession() + } + } else { + dataManager.sessions.append(try backupSession.toClimbSession()) + } + } + + // Merge attempts + for backupAttempt in response.attempts { + if deletedItemSet.contains("attempt:" + backupAttempt.id) { + continue + } + + 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) { + dataManager.attempts[index] = try backupAttempt.toAttempt() + } + } else { + dataManager.attempts.append(try backupAttempt.toAttempt()) + } + } + + // 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) } + } + + 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 + } + + 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) + } + } + + private func downloadImage(filename: String) async throws -> Data { + guard let url = URL(string: "\(serverURL)/images/download?filename=\(filename)") else { + throw SyncError.invalidURL + } + + var request = URLRequest(url: url) + 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 + } + + switch httpResponse.statusCode { + case 200: + return data + case 401: + throw SyncError.unauthorized + case 404: + throw SyncError.imageNotFound + default: + throw SyncError.serverError(httpResponse.statusCode) + } + } + + private func syncImagesFromServer(backup: ClimbDataBackup, dataManager: ClimbingDataManager) + async throws -> [String: String] + { + var imagePathMapping: [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 + } + } + } + + return imagePathMapping + } + + 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 + } + } + } + } + } + + 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 + ) + + 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() + ) + } + + 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 + ) + + // Update data manager with merged data + dataManager.gyms = mergedResult.gyms + dataManager.problems = mergedResult.problems + dataManager.sessions = mergedResult.sessions + dataManager.attempts = mergedResult.attempts + + // 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)) + } + } + } + } + + // 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 + ) + } + + // Add central directory + var centralDirectory = Data() + for entry in fileEntries { + centralDirectory.append(createCentralDirectoryHeader(entry: entry)) + } + + // Add end of central directory record + let endOfCentralDir = createEndOfCentralDirectoryRecord( + fileCount: UInt16(fileEntries.count), + centralDirSize: UInt32(centralDirectory.count), + centralDirOffset: currentOffset + ) + + zipData.append(centralDirectory) + zipData.append(endOfCentralDir) + + 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 + } +} diff --git a/ios/Ascently/Services/Sync/SyncMerger.swift b/ios/Ascently/Services/Sync/SyncMerger.swift new file mode 100644 index 0000000..e942748 --- /dev/null +++ b/ios/Ascently/Services/Sync/SyncMerger.swift @@ -0,0 +1,181 @@ +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 { 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) + } + + 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 + } +} diff --git a/ios/Ascently/Services/Sync/SyncProvider.swift b/ios/Ascently/Services/Sync/SyncProvider.swift new file mode 100644 index 0000000..074fa4f --- /dev/null +++ b/ios/Ascently/Services/Sync/SyncProvider.swift @@ -0,0 +1,73 @@ +import Foundation + +enum SyncProviderType: String, CaseIterable, Identifiable { + case none + case server + case iCloud + + var id: String { rawValue } + var displayName: String { + switch self { + case .none: return "None" + case .server: return "Self-Hosted Server" + case .iCloud: return "iCloud" + } + } +} + +protocol SyncProvider { + var type: SyncProviderType { get } + var isConfigured: Bool { get } + var isConnected: Bool { get } + + func sync(dataManager: ClimbingDataManager) async throws + func testConnection() async throws + func disconnect() +} + +enum SyncError: LocalizedError { + case notConfigured + case notConnected + case invalidURL + case invalidResponse + case unauthorized + case badRequest + case serverError(Int) + case decodingError(Error) + case exportFailed + case importFailed(Error) + case imageNotFound + case imageUploadFailed + case providerError(String) + + var errorDescription: String? { + switch self { + case .notConfigured: + return "Sync server not configured. Please set server URL and auth token." + case .notConnected: + return "Not connected to sync server. Please test connection first." + case .invalidURL: + return "Invalid server URL." + case .invalidResponse: + return "Invalid response from server." + case .unauthorized: + return "Authentication failed. Check your auth token." + case .badRequest: + return "Bad request. Check your data format." + case .serverError(let code): + return "Server error (code \(code))." + case .decodingError(let error): + return "Failed to decode response: \(error.localizedDescription)" + case .exportFailed: + return "Failed to export local data." + case .importFailed(let error): + return "Failed to import data: \(error.localizedDescription)" + case .imageNotFound: + return "Image not found on server." + case .imageUploadFailed: + return "Failed to upload image to server." + case .providerError(let message): + return "Sync provider error: \(message)" + } + } +} diff --git a/ios/Ascently/Services/SyncService.swift b/ios/Ascently/Services/SyncService.swift index 1f71a01..31c9138 100644 --- a/ios/Ascently/Services/SyncService.swift +++ b/ios/Ascently/Services/SyncService.swift @@ -10,6 +10,15 @@ class SyncService: ObservableObject { @Published var isConnected = false @Published var isTesting = false @Published var isOfflineMode = false + + @Published var providerType: SyncProviderType = .server { + didSet { + updateActiveProvider() + userDefaults.set(providerType.rawValue, forKey: Keys.providerType) + } + } + + private var activeProvider: SyncProvider? private let userDefaults = UserDefaults.standard private let logTag = "SyncService" @@ -17,22 +26,6 @@ class SyncService: ObservableObject { private var pendingChanges = false private let syncDebounceDelay: TimeInterval = 2.0 - private func logDebug(_ message: @autoclosure () -> String) { - AppLogger.debug(message(), tag: logTag) - } - - private func logInfo(_ message: @autoclosure () -> String) { - AppLogger.info(message(), tag: logTag) - } - - private func logWarning(_ message: @autoclosure () -> String) { - AppLogger.warning(message(), tag: logTag) - } - - private func logError(_ message: @autoclosure () -> String) { - AppLogger.error(message(), tag: logTag) - } - private enum Keys { static let serverURL = "sync_server_url" static let authToken = "sync_auth_token" @@ -40,11 +33,16 @@ class SyncService: ObservableObject { static let isConnected = "is_connected" static let autoSyncEnabled = "auto_sync_enabled" static let offlineMode = "offline_mode" + static let providerType = "sync_provider_type" } + // Legacy properties for compatibility with SettingsView var serverURL: String { get { userDefaults.string(forKey: Keys.serverURL) ?? "" } - set { userDefaults.set(newValue, forKey: Keys.serverURL) } + set { + userDefaults.set(newValue, forKey: Keys.serverURL) + // If active provider is server, it will pick up the change from UserDefaults + } } var authToken: String { @@ -53,7 +51,7 @@ class SyncService: ObservableObject { } var isConfigured: Bool { - return !serverURL.isEmpty && !authToken.isEmpty + return activeProvider?.isConfigured ?? false } var isAutoSyncEnabled: Bool { @@ -68,514 +66,56 @@ class SyncService: ObservableObject { isConnected = userDefaults.bool(forKey: Keys.isConnected) isAutoSyncEnabled = userDefaults.object(forKey: Keys.autoSyncEnabled) as? Bool ?? true isOfflineMode = userDefaults.bool(forKey: Keys.offlineMode) + + if let savedType = userDefaults.string(forKey: Keys.providerType), + let type = SyncProviderType(rawValue: savedType) { + self.providerType = type + } else { + self.providerType = .server // Default + } + + updateActiveProvider() } - - func downloadData() async throws -> ClimbDataBackup { - guard isConfigured else { - throw SyncError.notConfigured + + private func updateActiveProvider() { + switch providerType { + case .server: + activeProvider = ServerSyncProvider() + case .iCloud: + // Placeholder for iCloud provider + activeProvider = nil + case .none: + activeProvider = nil } - - guard let url = URL(string: "\(serverURL)/sync") else { - throw SyncError.invalidURL - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Accept") - - let (data, 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) - } - - do { - let backup = try JSONDecoder().decode(ClimbDataBackup.self, from: data) - return backup - } catch { - throw SyncError.decodingError(error) - } - } - - func uploadData(_ backup: ClimbDataBackup) async throws -> ClimbDataBackup { - guard isConfigured else { - throw SyncError.notConfigured - } - - guard let url = URL(string: "\(serverURL)/sync") else { - throw SyncError.invalidURL - } - - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - - let jsonData = try encoder.encode(backup) - - var request = URLRequest(url: url) - request.httpMethod = "PUT" - request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.httpBody = jsonData - - let (data, 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 - case 400: - throw SyncError.badRequest - default: - throw SyncError.serverError(httpResponse.statusCode) - } - - do { - let responseBackup = try JSONDecoder().decode(ClimbDataBackup.self, from: data) - return responseBackup - } catch { - throw SyncError.decodingError(error) - } - } - - func performDeltaSync(dataManager: ClimbingDataManager) async throws { - guard isConfigured else { - throw SyncError.notConfigured - } - - guard let url = URL(string: "\(serverURL)/sync/delta") else { - throw SyncError.invalidURL - } - - // Get last sync time, or use epoch if never synced - let lastSync = lastSyncTime ?? Date(timeIntervalSince1970: 0) - let formatter = ISO8601DateFormatter() - let lastSyncString = formatter.string(from: lastSync) - - // Collect items modified since last sync - let modifiedGyms = dataManager.gyms.filter { gym in - gym.updatedAt > lastSync - }.map { BackupGym(from: $0) } - - let modifiedProblems = dataManager.problems.filter { problem in - problem.updatedAt > lastSync - }.map { problem -> BackupProblem in - let backupProblem = BackupProblem(from: problem) - if !problem.imagePaths.isEmpty { - let normalizedPaths = problem.imagePaths.enumerated().map { index, _ in - 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 - } - - let 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 - !activeSessionIds.contains(attempt.sessionId) && attempt.createdAt > lastSync - }.map { BackupAttempt(from: $0) } - - let modifiedDeletions = dataManager.getDeletedItems().filter { item in - if let deletedDate = formatter.date(from: item.deletedAt) { - return deletedDate > lastSync - } - return false - } - - logInfo( - "iOS DELTA SYNC: Sending gyms=\(modifiedGyms.count), problems=\(modifiedProblems.count), sessions=\(modifiedSessions.count), attempts=\(modifiedAttempts.count), deletions=\(modifiedDeletions.count)" - ) - - // Create delta request - let deltaRequest = DeltaSyncRequest( - lastSyncTime: lastSyncString, - gyms: modifiedGyms, - problems: modifiedProblems, - sessions: modifiedSessions, - attempts: modifiedAttempts, - deletedItems: modifiedDeletions - ) - - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - let jsonData = try encoder.encode(deltaRequest) - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.httpBody = jsonData - - let (data, 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) - } - - let decoder = JSONDecoder() - let deltaResponse = try decoder.decode(DeltaSyncResponse.self, from: data) - - logInfo( - "iOS DELTA SYNC: Received gyms=\(deltaResponse.gyms.count), problems=\(deltaResponse.problems.count), sessions=\(deltaResponse.sessions.count), attempts=\(deltaResponse.attempts.count), deletions=\(deltaResponse.deletedItems.count)" - ) - - // Apply server changes to local data - try await applyDeltaResponse(deltaResponse, dataManager: dataManager) - - // Sync only modified problem images - try await syncModifiedImages(modifiedProblems: modifiedProblems, dataManager: dataManager) - - // Update last sync time to server time - if let serverTime = formatter.date(from: deltaResponse.serverTime) { - lastSyncTime = serverTime - userDefaults.set(lastSyncTime, forKey: Keys.lastSyncTime) - } - } - - private func applyDeltaResponse(_ response: DeltaSyncResponse, dataManager: ClimbingDataManager) - async throws - { - let formatter = ISO8601DateFormatter() - - // Merge and apply deletions first to prevent resurrection - let allDeletions = dataManager.getDeletedItems() + response.deletedItems - let uniqueDeletions = Array(Set(allDeletions)) - - logInfo( - "iOS DELTA SYNC: Applying \(uniqueDeletions.count) deletion records before merging data" - ) - 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 - var imagePathMapping: [String: String] = [:] - for problem in response.problems { - if deletedItemSet.contains("problem:" + problem.id) { - 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) - imagePathMapping[serverFilename] = consistentFilename - } catch SyncError.imageNotFound { - logInfo("Image not found on server: \(serverFilename)") - continue - } catch { - logInfo("Failed to download image \(serverFilename): \(error)") - continue - } - } - } - - // Merge gyms - for backupGym in response.gyms { - if deletedItemSet.contains("gym:" + backupGym.id) { - continue - } - - 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) { - dataManager.gyms[index] = try backupGym.toGym() - } - } else { - dataManager.gyms.append(try backupGym.toGym()) - } - } - - // Merge problems - for backupProblem in response.problems { - if deletedItemSet.contains("problem:" + backupProblem.id) { - 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 - ) - } - - 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) { - dataManager.problems[index] = try problemToMerge.toProblem() - } - } else { - dataManager.problems.append(try problemToMerge.toProblem()) - } - } - - // Merge sessions - for backupSession in response.sessions { - if deletedItemSet.contains("session:" + backupSession.id) { - continue - } - - 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) { - dataManager.sessions[index] = try backupSession.toClimbSession() - } - } else { - dataManager.sessions.append(try backupSession.toClimbSession()) - } - } - - // Merge attempts - for backupAttempt in response.attempts { - if deletedItemSet.contains("attempt:" + backupAttempt.id) { - continue - } - - 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) { - dataManager.attempts[index] = try backupAttempt.toAttempt() - } - } else { - dataManager.attempts.append(try backupAttempt.toAttempt()) - } - } - - // 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) } - } - - private func syncModifiedImages( - modifiedProblems: [BackupProblem], dataManager: ClimbingDataManager - ) async throws { - guard !modifiedProblems.isEmpty else { return } - - logInfo("iOS DELTA SYNC: Syncing images for \(modifiedProblems.count) modified problems") - - 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) - logInfo("Uploaded modified problem image: \(consistentFilename)") - } catch { - logInfo("Failed to upload image \(consistentFilename): \(error)") - } - } - } - } - } - - func uploadImage(filename: String, imageData: Data) async throws { - guard isConfigured else { - throw SyncError.notConfigured - } - - guard let url = URL(string: "\(serverURL)/images/upload?filename=\(filename)") else { - throw SyncError.invalidURL - } - - 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) - } - } - - func downloadImage(filename: String) async throws -> Data { - guard isConfigured else { - throw SyncError.notConfigured - } - - guard let url = URL(string: "\(serverURL)/images/download?filename=\(filename)") else { - throw SyncError.invalidURL - } - - var request = URLRequest(url: url) - 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 - } - - switch httpResponse.statusCode { - case 200: - - return data - case 401: - - throw SyncError.unauthorized - case 404: - - throw SyncError.imageNotFound - default: - - throw SyncError.serverError(httpResponse.statusCode) + + // Update status based on new provider + if let provider = activeProvider { + isConnected = provider.isConnected + } else { + isConnected = false } } func syncWithServer(dataManager: ClimbingDataManager) async throws { if isOfflineMode { - logInfo("Sync skipped: Offline mode is enabled.") + AppLogger.info("Sync skipped: Offline mode is enabled.", tag: logTag) return } - - guard isConfigured else { + + guard let provider = activeProvider else { + if providerType == .none { + return + } throw SyncError.notConfigured } - guard isConnected else { - throw SyncError.notConnected + guard provider.isConfigured else { + throw SyncError.notConfigured + } + + // For server provider, we check connection. For others, maybe not needed or different check. + if providerType == .server && !provider.isConnected { + throw SyncError.notConnected } isSyncing = true @@ -586,725 +126,33 @@ class SyncService: ObservableObject { } do { - // 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 - - let hasServerData = - !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 { - logInfo("iOS SYNC: Using delta sync for incremental updates") - try await performDeltaSync(dataManager: dataManager) - - // Update last sync time - lastSyncTime = Date() - userDefaults.set(lastSyncTime, forKey: Keys.lastSyncTime) - return - } - - if !hasLocalData && hasServerData { - // Case 1: No local data - do full restore from server - logInfo("iOS SYNC: Case 1 - No local data, performing full restore from server") - logInfo("Syncing images from server first...") - let imagePathMapping = try await syncImagesFromServer( - backup: serverBackup, dataManager: dataManager) - logInfo("Importing data after images...") - try importBackupToDataManager( - serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping) - logInfo("Full restore completed") - } else if hasLocalData && !hasServerData { - // Case 2: No server data - upload local data to server - logInfo("iOS SYNC: Case 2 - No server data, uploading local data to server") - let currentBackup = createBackupFromDataManager(dataManager) - _ = try await uploadData(currentBackup) - logInfo("Uploading local images to server...") - try await syncImagesToServer(dataManager: dataManager) - logInfo("Initial upload completed") - } else if hasLocalData && hasServerData { - // Case 3: Both have data - use safe merge strategy - logInfo("iOS SYNC: Case 3 - Merging local and server data safely") - try await mergeDataSafely( - localBackup: localBackup, - serverBackup: serverBackup, - dataManager: dataManager) - logInfo("Safe merge completed") - } else { - logInfo("No data to sync") - } - + try await provider.sync(dataManager: dataManager) + // Update last sync time - lastSyncTime = Date() - userDefaults.set(lastSyncTime, forKey: Keys.lastSyncTime) - + // Provider might have updated it in UserDefaults, reload it + if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date { + self.lastSyncTime = lastSync + } + } catch { syncError = error.localizedDescription throw error } } - private func parseISO8601ToMillis(timestamp: String) -> Int64 { - let formatter = ISO8601DateFormatter() - if let date = formatter.date(from: timestamp) { - return Int64(date.timeIntervalSince1970 * 1000) - } - logInfo("Failed to parse timestamp: \(timestamp), using 0") - return 0 - } - - private func syncImagesFromServer(backup: ClimbDataBackup, dataManager: ClimbingDataManager) - async throws -> [String: String] - { - var imagePathMapping: [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 - logInfo("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)") - } catch SyncError.imageNotFound { - logInfo("Image not found on server: \(serverFilename)") - continue - } catch { - logInfo("Failed to download image \(serverFilename): \(error)") - continue - } - } - } - - return imagePathMapping - } - - 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) - logInfo("Renamed local image: \(filename) -> \(consistentFilename)") - - // Update problem's image path in memory for consistency - } catch { - logInfo("Failed to rename local image, using original: \(error)") - } - } - - try await uploadImage(filename: consistentFilename, imageData: imageData) - logInfo("Successfully uploaded image: \(consistentFilename)") - } catch { - logInfo("Failed to upload image \(consistentFilename): \(error)") - // Continue with other images even if one fails - } - } - } - } - } - - 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) - } - - logInfo( - "iOS SYNC: Excluding \(dataManager.sessions.count - completedSessions.count) active sessions and \(dataManager.attempts.count - completedAttempts.count) active session attempts from sync" - ) - - return ClimbDataBackup( - exportedAt: DataStateManager.shared.getLastModified(), - gyms: dataManager.gyms.map { BackupGym(from: $0) }, - problems: dataManager.problems.map { BackupProblem(from: $0) }, - sessions: completedSessions.map { BackupClimbSession(from: $0) }, - attempts: completedAttempts.map { BackupAttempt(from: $0) }, - deletedItems: dataManager.getDeletedItems() - ) - } - - func createBackupForExport(_ 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) - } - - // Create backup with normalized image paths for export - return ClimbDataBackup( - exportedAt: DataStateManager.shared.getLastModified(), - gyms: dataManager.gyms.map { BackupGym(from: $0) }, - problems: dataManager.problems.map { problem in - var backupProblem = BackupProblem(from: problem) - - if !problem.imagePaths.isEmpty { - let normalizedPaths = problem.imagePaths.enumerated().map { index, _ in - ImageNamingUtils.generateImageFilename( - problemId: problem.id.uuidString, imageIndex: index) - } - - backupProblem = 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 - }, - sessions: completedSessions.map { BackupClimbSession(from: $0) }, - attempts: completedAttempts.map { BackupAttempt(from: $0) }, - deletedItems: dataManager.getDeletedItems() - ) - } - - private func mergeDataSafely( - localBackup: ClimbDataBackup, - serverBackup: ClimbDataBackup, - dataManager: ClimbingDataManager - ) async throws { - // Download server images first - let imagePathMapping = try await syncImagesFromServer( - backup: serverBackup, dataManager: dataManager) - - // Merge deletion lists first to prevent resurrection of deleted items - let localDeletions = dataManager.getDeletedItems() - let allDeletions = localDeletions + serverBackup.deletedItems - let uniqueDeletions = Array(Set(allDeletions)) - - logInfo("Merging gyms...") - let mergedGyms = mergeGyms( - local: dataManager.gyms, - server: serverBackup.gyms, - deletedItems: uniqueDeletions) - - logInfo("Merging problems...") - let mergedProblems = try mergeProblems( - local: dataManager.problems, - server: serverBackup.problems, - imagePathMapping: imagePathMapping, - deletedItems: uniqueDeletions) - - logInfo("Merging sessions...") - let mergedSessions = try mergeSessions( - local: dataManager.sessions, - server: serverBackup.sessions, - deletedItems: uniqueDeletions) - - logInfo("Merging attempts...") - let mergedAttempts = try mergeAttempts( - local: dataManager.attempts, - server: serverBackup.attempts, - deletedItems: uniqueDeletions) - - // 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() - - // Update local deletions with merged list - dataManager.clearDeletedItems() - if let data = try? JSONEncoder().encode(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 { - 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) - } - - logInfo( - "iOS IMPORT: Preserving \(activeSessions.count) active sessions and \(activeAttempts.count) active attempts during import" - ) - - // Update problem image paths to point to downloaded images - let updatedBackup: ClimbDataBackup - 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 - 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 { - logInfo("iOS IMPORT: Restoring active session: \(session.id)") - dataManager.sessions.append(session) - if session.id == dataManager.activeSession?.id { - dataManager.activeSession = session - } - } - - for attempt in activeAttempts { - dataManager.attempts.append(attempt) - } - - // Save restored data - dataManager.saveSessions() - dataManager.saveAttempts() - dataManager.saveActiveSession() - - // Import deletion records to prevent future resurrections - dataManager.clearDeletedItems() - if let data = try? JSONEncoder().encode(backup.deletedItems) { - UserDefaults.standard.set(data, forKey: "ascently_deleted_items") - logInfo("iOS IMPORT: Imported \(backup.deletedItems.count) deletion records") - } - - // Update local data state to match imported data timestamp - DataStateManager.shared.setLastModified(backup.exportedAt) - logInfo("Data state synchronized to imported timestamp: \(backup.exportedAt)") - - } catch { - throw SyncError.importFailed(error) - } - } - - 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: "problems"), - 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)) - } - } - } - } - - // 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 - ) - } - - // Add central directory - var centralDirectory = Data() - for entry in fileEntries { - centralDirectory.append(createCentralDirectoryHeader(entry: entry)) - } - - // Add end of central directory record - let endOfCentralDir = createEndOfCentralDirectoryRecord( - fileCount: UInt16(fileEntries.count), - centralDirSize: UInt32(centralDirectory.count), - centralDirOffset: currentOffset - ) - - zipData.append(centralDirectory) - zipData.append(endOfCentralDir) - - 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 - } - func testConnection() async throws { - guard isConfigured else { + guard let provider = activeProvider else { + AppLogger.error("Test connection failed: No active provider", tag: logTag) throw SyncError.notConfigured } - + isTesting = true defer { isTesting = false } - - 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 httpResponse.statusCode == 200 else { - throw SyncError.serverError(httpResponse.statusCode) - } - - // Connection successful, mark as connected - isConnected = true - userDefaults.set(true, forKey: Keys.isConnected) + + try await provider.testConnection() + + isConnected = provider.isConnected + userDefaults.set(isConnected, forKey: Keys.isConnected) } func triggerAutoSync(dataManager: ClimbingDataManager) { @@ -1365,6 +213,8 @@ class SyncService: ObservableObject { } func disconnect() { + activeProvider?.disconnect() + syncTask?.cancel() syncTask = nil pendingChanges = false @@ -1372,6 +222,9 @@ class SyncService: ObservableObject { isConnected = false lastSyncTime = nil syncError = nil + + // These are shared keys, so clearing them affects all providers if they use them + // But disconnect() is usually user initiated action userDefaults.set(false, forKey: Keys.isConnected) userDefaults.removeObject(forKey: Keys.lastSyncTime) } @@ -1388,201 +241,11 @@ class SyncService: ObservableObject { syncTask?.cancel() syncTask = nil pendingChanges = false + + activeProvider?.disconnect() } deinit { syncTask?.cancel() } - - // MARK: - Merging - // 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 }) - - 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 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 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 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 { 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) - } - - 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 - } -} - -enum SyncError: LocalizedError { - case notConfigured - case notConnected - case invalidURL - case invalidResponse - case unauthorized - case badRequest - case serverError(Int) - case decodingError(Error) - case exportFailed - case importFailed(Error) - case imageNotFound - case imageUploadFailed - - var errorDescription: String? { - switch self { - case .notConfigured: - return "Sync server not configured. Please set server URL and auth token." - case .notConnected: - return "Not connected to sync server. Please test connection first." - case .invalidURL: - return "Invalid server URL." - case .invalidResponse: - return "Invalid response from server." - case .unauthorized: - return "Authentication failed. Check your auth token." - case .badRequest: - return "Bad request. Check your data format." - case .serverError(let code): - return "Server error (code \(code))." - case .decodingError(let error): - return "Failed to decode response: \(error.localizedDescription)" - case .exportFailed: - return "Failed to export local data." - case .importFailed(let error): - return "Failed to import data: \(error.localizedDescription)" - case .imageNotFound: - return "Image not found on server." - case .imageUploadFailed: - return "Failed to upload image to server." - } - } } diff --git a/ios/Ascently/Views/SettingsView.swift b/ios/Ascently/Views/SettingsView.swift index e32f91a..52fb964 100644 --- a/ios/Ascently/Views/SettingsView.swift +++ b/ios/Ascently/Views/SettingsView.swift @@ -794,6 +794,12 @@ struct SyncSettingsView: View { syncService.serverURL = newURL syncService.authToken = newToken + + // Ensure provider type is set to server + if syncService.providerType != .server { + syncService.providerType = .server + } + dismiss() } .fontWeight(.semibold) @@ -834,6 +840,13 @@ struct SyncSettingsView: View { Task { do { + // Ensure we are using the server provider + await MainActor.run { + if syncService.providerType != .server { + syncService.providerType = .server + } + } + // Temporarily set the values for testing syncService.serverURL = testURL syncService.authToken = testToken