import Combine import Foundation @MainActor class SyncService: ObservableObject { @Published var isSyncing = false @Published var lastSyncTime: Date? @Published var syncError: String? @Published var isConnected = false @Published var isTesting = false private let userDefaults = UserDefaults.standard private enum Keys { static let serverURL = "sync_server_url" static let authToken = "sync_auth_token" static let lastSyncTime = "last_sync_time" static let isConnected = "sync_is_connected" static let autoSyncEnabled = "auto_sync_enabled" } 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 isAutoSyncEnabled: Bool { get { userDefaults.bool(forKey: Keys.autoSyncEnabled) } set { userDefaults.set(newValue, forKey: Keys.autoSyncEnabled) } } init() { if let lastSync = userDefaults.object(forKey: Keys.lastSyncTime) as? Date { self.lastSyncTime = lastSync } self.isConnected = userDefaults.bool(forKey: Keys.isConnected) } func downloadData() async throws -> ClimbDataBackup { guard isConfigured else { throw SyncError.notConfigured } 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 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 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") 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) } } func syncWithServer(dataManager: ClimbingDataManager) async throws { guard isConfigured else { throw SyncError.notConfigured } guard isConnected else { throw SyncError.notConnected } isSyncing = true syncError = nil defer { isSyncing = false } 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 !hasLocalData && hasServerData { // Case 1: No local data - do full restore from server print("iOS SYNC: Case 1 - No local data, performing full restore from server") print("Syncing images from server first...") let imagePathMapping = try await syncImagesFromServer( backup: serverBackup, dataManager: dataManager) print("Importing data after images...") try importBackupToDataManager( serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping) print("Full restore completed") } else if hasLocalData && !hasServerData { // Case 2: No server data - upload local data to server print("iOS SYNC: Case 2 - No server data, uploading local data to server") let currentBackup = createBackupFromDataManager(dataManager) _ = try await uploadData(currentBackup) print("Uploading local images to server...") try await syncImagesToServer(dataManager: dataManager) print("Initial upload completed") } else if hasLocalData && hasServerData { // Case 3: Both have data - use safe merge strategy print("iOS SYNC: Case 3 - Merging local and server data safely") try await mergeDataSafely( localBackup: localBackup, serverBackup: serverBackup, dataManager: dataManager) print("Safe merge completed") } else { print("No data to sync") } // Update last sync time lastSyncTime = Date() userDefaults.set(lastSyncTime, forKey: Keys.lastSyncTime) } catch { syncError = error.localizedDescription throw error } } /// Parses ISO8601 timestamp to milliseconds for comparison private func parseISO8601ToMillis(timestamp: String) -> Int64 { let formatter = ISO8601DateFormatter() if let date = formatter.date(from: timestamp) { return Int64(date.timeIntervalSince1970 * 1000) } print("Failed to parse timestamp: \(timestamp), using 0") return 0 } private func syncImagesFromServer(backup: ClimbDataBackup, dataManager: ClimbingDataManager) async throws -> [String: String] { var imagePathMapping: [String: String] = [:] // Process images by problem to maintain consistent naming 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) // Generate consistent filename if needed let consistentFilename = ImageNamingUtils.isValidImageFilename(serverFilename) ? serverFilename : ImageNamingUtils.generateImageFilename( problemId: problem.id, imageIndex: index) // Save image with consistent filename let imageManager = ImageManager.shared _ = try imageManager.saveImportedImage( imageData, filename: consistentFilename) // Map server filename to consistent local filename imagePathMapping[serverFilename] = consistentFilename print("Downloaded and mapped image: \(serverFilename) -> \(consistentFilename)") } catch SyncError.imageNotFound { print("Image not found on server: \(serverFilename)") continue } catch { print("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 // Ensure filename follows consistent naming convention let consistentFilename = ImageNamingUtils.isValidImageFilename(filename) ? filename : 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) print("Renamed local image: \(filename) -> \(consistentFilename)") // Update problem's image path in memory for consistency } catch { print("Failed to rename local image, using original: \(error)") } } try await uploadImage(filename: consistentFilename, imageData: imageData) print("Successfully uploaded image: \(consistentFilename)") } catch { print("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) } print( "iOS SYNC: Excluding \(dataManager.sessions.count - completedSessions.count) active sessions and \(dataManager.attempts.count - completedAttempts.count) active session attempts from sync" ) return ClimbDataBackup( exportedAt: DataStateManager.shared.getLastModified(), gyms: dataManager.gyms.map { BackupGym(from: $0) }, problems: dataManager.problems.map { BackupProblem(from: $0) }, sessions: 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)) print("Merging gyms...") let mergedGyms = mergeGyms( local: dataManager.gyms, server: serverBackup.gyms, deletedItems: uniqueDeletions) print("Merging problems...") let mergedProblems = try mergeProblems( local: dataManager.problems, server: serverBackup.problems, imagePathMapping: imagePathMapping, deletedItems: uniqueDeletions) print("Merging sessions...") let mergedSessions = try mergeSessions( local: dataManager.sessions, server: serverBackup.sessions, deletedItems: uniqueDeletions) print("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: "openclimb_deleted_items") } // Upload merged data back to server let mergedBackup = createBackupFromDataManager(dataManager) _ = try await uploadData(mergedBackup) try await syncImagesToServer(dataManager: dataManager) // Update timestamp DataStateManager.shared.updateDataState() } private func importBackupToDataManager( _ backup: ClimbDataBackup, dataManager: ClimbingDataManager, imagePathMapping: [String: String] = [:] ) throws { do { // Store active sessions and their attempts before import (but exclude any that were deleted) let localDeletedItems = dataManager.getDeletedItems() let allDeletedSessionIds = Set( (backup.deletedItems + localDeletedItems) .filter { $0.type == "session" } .map { $0.id } ) let activeSessions = dataManager.sessions.filter { $0.status == .active && !allDeletedSessionIds.contains($0.id.uuidString) } let activeSessionIds = Set(activeSessions.map { $0.id }) let allDeletedAttemptIds = Set( (backup.deletedItems + localDeletedItems) .filter { $0.type == "attempt" } .map { $0.id } ) let activeAttempts = dataManager.attempts.filter { activeSessionIds.contains($0.sessionId) && !allDeletedAttemptIds.contains($0.id.uuidString) } print( "iOS IMPORT: Preserving \(activeSessions.count) active sessions and \(activeAttempts.count) active attempts during import" ) // Update problem image paths to point to downloaded images let updatedBackup: ClimbDataBackup 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 { print("iOS IMPORT: Restoring active session: \(session.id)") dataManager.sessions.append(session) if session.id == dataManager.activeSession?.id { dataManager.activeSession = session } } for attempt in activeAttempts { dataManager.attempts.append(attempt) } // Save restored data dataManager.saveSessions() dataManager.saveAttempts() dataManager.saveActiveSession() // Import deletion records to prevent future resurrections dataManager.clearDeletedItems() if let data = try? JSONEncoder().encode(backup.deletedItems) { UserDefaults.standard.set(data, forKey: "openclimb_deleted_items") print("iOS IMPORT: Imported \(backup.deletedItems.count) deletion records") } // Update local data state to match imported data timestamp DataStateManager.shared.setLastModified(backup.exportedAt) print("Data state synchronized to imported timestamp: \(backup.exportedAt)") } catch { throw SyncError.importFailed(error) } } 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 downloaded images from ImageManager let imageManager = ImageManager.shared var imageFiles: [(filename: String, data: Data)] = [] let imagePaths = Set(backup.problems.flatMap { $0.imagePaths ?? [] }) for imagePath in imagePaths { let filename = URL(fileURLWithPath: imagePath).lastPathComponent let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path if let imageData = imageManager.loadImageData(fromPath: fullPath) { imageFiles.append((filename: filename, 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 { 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) } func triggerAutoSync(dataManager: ClimbingDataManager) { // Early exit if sync cannot proceed - don't set isSyncing guard isConnected && isConfigured && isAutoSyncEnabled else { // Ensure isSyncing is false when sync is not possible if isSyncing { isSyncing = false } return } // Prevent multiple simultaneous syncs guard !isSyncing else { return } Task { do { try await syncWithServer(dataManager: dataManager) } catch { await MainActor.run { self.isSyncing = false } } } } func disconnect() { isConnected = false lastSyncTime = nil syncError = nil userDefaults.set(false, forKey: Keys.isConnected) userDefaults.removeObject(forKey: Keys.lastSyncTime) } func clearConfiguration() { serverURL = "" authToken = "" lastSyncTime = nil isConnected = false isAutoSyncEnabled = true userDefaults.removeObject(forKey: Keys.lastSyncTime) userDefaults.removeObject(forKey: Keys.isConnected) userDefaults.removeObject(forKey: Keys.autoSyncEnabled) } // MARK: - Safe Merge Functions private func mergeGyms(local: [Gym], server: [BackupGym], deletedItems: [DeletedItem]) -> [Gym] { var merged = local let deletedGymIds = Set(deletedItems.filter { $0.type == "gym" }.map { $0.id }) // Remove items that were deleted on other devices merged.removeAll { deletedGymIds.contains($0.id.uuidString) } // Add new items from server (excluding deleted ones) for serverGym in server { if let serverGymConverted = try? serverGym.toGym() { let localHasGym = local.contains(where: { $0.id.uuidString == serverGym.id }) let isDeleted = deletedGymIds.contains(serverGym.id) if !localHasGym && !isDeleted { merged.append(serverGymConverted) } } } return merged } private func mergeProblems( local: [Problem], server: [BackupProblem], imagePathMapping: [String: String], deletedItems: [DeletedItem] ) throws -> [Problem] { var merged = local let deletedProblemIds = Set(deletedItems.filter { $0.type == "problem" }.map { $0.id }) // Remove items that were deleted on other devices merged.removeAll { deletedProblemIds.contains($0.id.uuidString) } // Add new items from server (excluding deleted ones) for serverProblem in server { var problemToAdd = serverProblem // Update image paths if needed if !imagePathMapping.isEmpty { let updatedImagePaths = serverProblem.imagePaths?.compactMap { oldPath in imagePathMapping[oldPath] ?? oldPath } problemToAdd = BackupProblem( id: serverProblem.id, gymId: serverProblem.gymId, name: serverProblem.name, description: serverProblem.description, climbType: serverProblem.climbType, difficulty: serverProblem.difficulty, tags: serverProblem.tags, location: serverProblem.location, imagePaths: updatedImagePaths, isActive: serverProblem.isActive, dateSet: serverProblem.dateSet, notes: serverProblem.notes, createdAt: serverProblem.createdAt, updatedAt: serverProblem.updatedAt ) } if let serverProblemConverted = try? problemToAdd.toProblem() { let localHasProblem = local.contains(where: { $0.id.uuidString == problemToAdd.id }) let isDeleted = deletedProblemIds.contains(problemToAdd.id) if !localHasProblem && !isDeleted { merged.append(serverProblemConverted) } } } return merged } private func mergeSessions( local: [ClimbSession], server: [BackupClimbSession], deletedItems: [DeletedItem] ) throws -> [ClimbSession] { var merged = local let deletedSessionIds = Set(deletedItems.filter { $0.type == "session" }.map { $0.id }) // Remove items that were deleted on other devices (but never remove active sessions) merged.removeAll { session in deletedSessionIds.contains(session.id.uuidString) && session.status != .active } // Add new items from server (excluding deleted ones) for serverSession in server { if let serverSessionConverted = try? serverSession.toClimbSession() { let localHasSession = local.contains(where: { $0.id.uuidString == serverSession.id } ) let isDeleted = deletedSessionIds.contains(serverSession.id) if !localHasSession && !isDeleted { merged.append(serverSessionConverted) } } } return merged } private func mergeAttempts( local: [Attempt], server: [BackupAttempt], deletedItems: [DeletedItem] ) throws -> [Attempt] { var merged = local let deletedAttemptIds = Set(deletedItems.filter { $0.type == "attempt" }.map { $0.id }) // Get active session IDs to protect their attempts let activeSessionIds = Set( local.compactMap { attempt in // This is a simplified check - in a real implementation you'd want to cross-reference with sessions return attempt.sessionId }.filter { sessionId in // Check if this session ID belongs to an active session // For now, we'll be conservative and not delete attempts during merge return true }) // Remove items that were deleted on other devices (but be conservative with attempts) merged.removeAll { attempt in deletedAttemptIds.contains(attempt.id.uuidString) && !activeSessionIds.contains(attempt.sessionId) } // Add new items from server (excluding deleted ones) for serverAttempt in server { if let serverAttemptConverted = try? serverAttempt.toAttempt() { let localHasAttempt = local.contains(where: { $0.id.uuidString == serverAttempt.id } ) let isDeleted = deletedAttemptIds.contains(serverAttempt.id) if !localHasAttempt && !isDeleted { merged.append(serverAttemptConverted) } } } return merged } } enum SyncError: LocalizedError { 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." } } }