979 lines
35 KiB
Swift
979 lines
35 KiB
Swift
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
|
|
_ = 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."
|
|
}
|
|
}
|
|
}
|