Sync Server DONE!
This commit is contained in:
978
ios/OpenClimb/Services/SyncService.swift
Normal file
978
ios/OpenClimb/Services/SyncService.swift
Normal file
@@ -0,0 +1,978 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
@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 - compare timestamps (last writer wins)
|
||||
let localTimestamp = parseISO8601ToMillis(timestamp: localBackup.exportedAt)
|
||||
let serverTimestamp = parseISO8601ToMillis(timestamp: serverBackup.exportedAt)
|
||||
|
||||
print("🕐 DEBUG iOS Timestamp Comparison:")
|
||||
print(" Local exportedAt: '\(localBackup.exportedAt)' -> \(localTimestamp)")
|
||||
print(" Server exportedAt: '\(serverBackup.exportedAt)' -> \(serverTimestamp)")
|
||||
print(
|
||||
" DataStateManager last modified: '\(DataStateManager.shared.getLastModified())'"
|
||||
)
|
||||
print(" Comparison result: local=\(localTimestamp), server=\(serverTimestamp)")
|
||||
|
||||
if localTimestamp > serverTimestamp {
|
||||
// Local is newer - replace server with local data
|
||||
print("🔄 iOS SYNC: Case 3a - Local data is newer, replacing server content")
|
||||
let currentBackup = createBackupFromDataManager(dataManager)
|
||||
_ = try await uploadData(currentBackup)
|
||||
try await syncImagesToServer(dataManager: dataManager)
|
||||
print("Server replaced with local data")
|
||||
} else if serverTimestamp > localTimestamp {
|
||||
// Server is newer - replace local with server data
|
||||
print("🔄 iOS SYNC: Case 3b - Server data is newer, replacing local content")
|
||||
let imagePathMapping = try await syncImagesFromServer(
|
||||
backup: serverBackup, dataManager: dataManager)
|
||||
try importBackupToDataManager(
|
||||
serverBackup, dataManager: dataManager, imagePathMapping: imagePathMapping)
|
||||
print("Local data replaced with server data")
|
||||
} else {
|
||||
// Timestamps are equal - no sync needed
|
||||
print(
|
||||
"🔄 iOS SYNC: Case 3c - Data is in sync (timestamps equal), no action needed"
|
||||
)
|
||||
}
|
||||
} 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
|
||||
let localPath = 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
|
||||
// Note: This would require updating the problem in the data manager
|
||||
} 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
|
||||
{
|
||||
return ClimbDataBackup(
|
||||
exportedAt: DataStateManager.shared.getLastModified(),
|
||||
gyms: dataManager.gyms.map { BackupGym(from: $0) },
|
||||
problems: dataManager.problems.map { BackupProblem(from: $0) },
|
||||
sessions: dataManager.sessions.map { BackupClimbSession(from: $0) },
|
||||
attempts: dataManager.attempts.map { BackupAttempt(from: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func importBackupToDataManager(
|
||||
_ backup: ClimbDataBackup, dataManager: ClimbingDataManager,
|
||||
imagePathMapping: [String: String] = [:]
|
||||
) throws {
|
||||
do {
|
||||
|
||||
// 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
|
||||
)
|
||||
}
|
||||
updatedBackup = ClimbDataBackup(
|
||||
exportedAt: backup.exportedAt,
|
||||
version: backup.version,
|
||||
formatVersion: backup.formatVersion,
|
||||
gyms: backup.gyms,
|
||||
problems: updatedProblems,
|
||||
sessions: backup.sessions,
|
||||
attempts: backup.attempts
|
||||
)
|
||||
|
||||
} else {
|
||||
updatedBackup = backup
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// 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) {
|
||||
guard isConnected && isConfigured && isAutoSyncEnabled else { return }
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await syncWithServer(dataManager: dataManager)
|
||||
} catch {
|
||||
print("Auto-sync failed: \(error)")
|
||||
// Don't show UI errors for auto-sync failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DEPRECATED: Complex merge logic replaced with simple timestamp-based sync
|
||||
// These methods are no longer used but kept for reference
|
||||
@available(*, deprecated, message: "Use simple timestamp-based sync instead")
|
||||
private func performIntelligentMerge(local: ClimbDataBackup, server: ClimbDataBackup) throws
|
||||
-> ClimbDataBackup
|
||||
{
|
||||
print("Merging data - preserving all entities to prevent data loss")
|
||||
|
||||
// Merge gyms by ID, keeping most recently updated
|
||||
let mergedGyms = mergeGyms(local: local.gyms, server: server.gyms)
|
||||
|
||||
// Merge problems by ID, keeping most recently updated
|
||||
let mergedProblems = mergeProblems(local: local.problems, server: server.problems)
|
||||
|
||||
// Merge sessions by ID, keeping most recently updated
|
||||
let mergedSessions = mergeSessions(local: local.sessions, server: server.sessions)
|
||||
|
||||
// Merge attempts by ID, keeping most recently updated
|
||||
let mergedAttempts = mergeAttempts(local: local.attempts, server: server.attempts)
|
||||
|
||||
print(
|
||||
"Merge results: gyms=\(mergedGyms.count), problems=\(mergedProblems.count), sessions=\(mergedSessions.count), attempts=\(mergedAttempts.count)"
|
||||
)
|
||||
|
||||
return ClimbDataBackup(
|
||||
exportedAt: ISO8601DateFormatter().string(from: Date()),
|
||||
version: "2.0",
|
||||
formatVersion: "2.0",
|
||||
gyms: mergedGyms,
|
||||
problems: mergedProblems,
|
||||
sessions: mergedSessions,
|
||||
attempts: mergedAttempts
|
||||
)
|
||||
}
|
||||
|
||||
private func mergeGyms(local: [BackupGym], server: [BackupGym]) -> [BackupGym] {
|
||||
var merged: [String: BackupGym] = [:]
|
||||
|
||||
// Add all local gyms
|
||||
for gym in local {
|
||||
merged[gym.id] = gym
|
||||
}
|
||||
|
||||
// Add server gyms, replacing if newer
|
||||
for serverGym in server {
|
||||
if let localGym = merged[serverGym.id] {
|
||||
// Keep the most recently updated
|
||||
if isNewerThan(serverGym.updatedAt, localGym.updatedAt) {
|
||||
merged[serverGym.id] = serverGym
|
||||
}
|
||||
} else {
|
||||
// New gym from server
|
||||
merged[serverGym.id] = serverGym
|
||||
}
|
||||
}
|
||||
|
||||
return Array(merged.values)
|
||||
}
|
||||
|
||||
private func mergeProblems(local: [BackupProblem], server: [BackupProblem]) -> [BackupProblem] {
|
||||
var merged: [String: BackupProblem] = [:]
|
||||
|
||||
// Add all local problems
|
||||
for problem in local {
|
||||
merged[problem.id] = problem
|
||||
}
|
||||
|
||||
// Add server problems, replacing if newer or merging image paths
|
||||
for serverProblem in server {
|
||||
if let localProblem = merged[serverProblem.id] {
|
||||
// Merge image paths from both sources
|
||||
let localImages = Set(localProblem.imagePaths ?? [])
|
||||
let serverImages = Set(serverProblem.imagePaths ?? [])
|
||||
let mergedImages = Array(localImages.union(serverImages))
|
||||
|
||||
// Use most recently updated problem data but with merged images
|
||||
let newerProblem =
|
||||
isNewerThan(serverProblem.updatedAt, localProblem.updatedAt)
|
||||
? serverProblem : localProblem
|
||||
merged[serverProblem.id] = BackupProblem(
|
||||
id: newerProblem.id,
|
||||
gymId: newerProblem.gymId,
|
||||
name: newerProblem.name,
|
||||
description: newerProblem.description,
|
||||
climbType: newerProblem.climbType,
|
||||
difficulty: newerProblem.difficulty,
|
||||
tags: newerProblem.tags,
|
||||
location: newerProblem.location,
|
||||
imagePaths: mergedImages.isEmpty ? nil : mergedImages,
|
||||
isActive: newerProblem.isActive,
|
||||
dateSet: newerProblem.dateSet,
|
||||
notes: newerProblem.notes,
|
||||
createdAt: newerProblem.createdAt,
|
||||
updatedAt: newerProblem.updatedAt
|
||||
)
|
||||
} else {
|
||||
// New problem from server
|
||||
merged[serverProblem.id] = serverProblem
|
||||
}
|
||||
}
|
||||
|
||||
return Array(merged.values)
|
||||
}
|
||||
|
||||
private func mergeSessions(local: [BackupClimbSession], server: [BackupClimbSession])
|
||||
-> [BackupClimbSession]
|
||||
{
|
||||
var merged: [String: BackupClimbSession] = [:]
|
||||
|
||||
// Add all local sessions
|
||||
for session in local {
|
||||
merged[session.id] = session
|
||||
}
|
||||
|
||||
// Add server sessions, replacing if newer
|
||||
for serverSession in server {
|
||||
if let localSession = merged[serverSession.id] {
|
||||
// Keep the most recently updated
|
||||
if isNewerThan(serverSession.updatedAt, localSession.updatedAt) {
|
||||
merged[serverSession.id] = serverSession
|
||||
}
|
||||
} else {
|
||||
// New session from server
|
||||
merged[serverSession.id] = serverSession
|
||||
}
|
||||
}
|
||||
|
||||
return Array(merged.values)
|
||||
}
|
||||
|
||||
private func mergeAttempts(local: [BackupAttempt], server: [BackupAttempt]) -> [BackupAttempt] {
|
||||
var merged: [String: BackupAttempt] = [:]
|
||||
|
||||
// Add all local attempts
|
||||
for attempt in local {
|
||||
merged[attempt.id] = attempt
|
||||
}
|
||||
|
||||
// Add server attempts, replacing if newer
|
||||
for serverAttempt in server {
|
||||
if let localAttempt = merged[serverAttempt.id] {
|
||||
// Keep the most recently created (attempts don't typically get updated)
|
||||
if isNewerThan(serverAttempt.createdAt, localAttempt.createdAt) {
|
||||
merged[serverAttempt.id] = serverAttempt
|
||||
}
|
||||
} else {
|
||||
// New attempt from server
|
||||
merged[serverAttempt.id] = serverAttempt
|
||||
}
|
||||
}
|
||||
|
||||
return Array(merged.values)
|
||||
}
|
||||
|
||||
private func isNewerThan(_ dateString1: String, _ dateString2: String) -> Bool {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
guard let date1 = formatter.date(from: dateString1),
|
||||
let date2 = formatter.date(from: dateString2)
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return date1 > date2
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Removed SyncTrigger enum - now using simple auto sync on any data change
|
||||
|
||||
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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user