Files
Ascently/ios/OpenClimb/Services/SyncService.swift
Atridad Lahiji 6a39d23f28
All checks were successful
OpenClimb Docker Deploy / build-and-push (push) Successful in 2m13s
[iOS & Android] iOS 1.3.0 & Android 1.8.0
2025-10-09 21:00:12 -06:00

1111 lines
41 KiB
Swift

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: &currentOffset
)
// 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: &currentOffset
)
// 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: &currentOffset
)
}
// 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."
}
}
}