[All Platforms] 2.1.0 - Sync Optimizations
This commit is contained in:
@@ -136,6 +136,314 @@ class SyncService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func performDeltaSync(dataManager: ClimbingDataManager) async throws {
|
||||
guard isConfigured else {
|
||||
throw SyncError.notConfigured
|
||||
}
|
||||
|
||||
guard let url = URL(string: "\(serverURL)/sync/delta") else {
|
||||
throw SyncError.invalidURL
|
||||
}
|
||||
|
||||
// Get last sync time, or use epoch if never synced
|
||||
let lastSync = lastSyncTime ?? Date(timeIntervalSince1970: 0)
|
||||
let formatter = ISO8601DateFormatter()
|
||||
let lastSyncString = formatter.string(from: lastSync)
|
||||
|
||||
// Collect items modified since last sync
|
||||
let modifiedGyms = dataManager.gyms.filter { gym in
|
||||
gym.updatedAt > lastSync
|
||||
}.map { BackupGym(from: $0) }
|
||||
|
||||
let modifiedProblems = dataManager.problems.filter { problem in
|
||||
problem.updatedAt > lastSync
|
||||
}.map { problem -> BackupProblem in
|
||||
var backupProblem = BackupProblem(from: problem)
|
||||
if !problem.imagePaths.isEmpty {
|
||||
let normalizedPaths = problem.imagePaths.enumerated().map { index, _ in
|
||||
ImageNamingUtils.generateImageFilename(
|
||||
problemId: problem.id.uuidString, imageIndex: index)
|
||||
}
|
||||
return BackupProblem(
|
||||
id: backupProblem.id,
|
||||
gymId: backupProblem.gymId,
|
||||
name: backupProblem.name,
|
||||
description: backupProblem.description,
|
||||
climbType: backupProblem.climbType,
|
||||
difficulty: backupProblem.difficulty,
|
||||
tags: backupProblem.tags,
|
||||
location: backupProblem.location,
|
||||
imagePaths: normalizedPaths,
|
||||
isActive: backupProblem.isActive,
|
||||
dateSet: backupProblem.dateSet,
|
||||
notes: backupProblem.notes,
|
||||
createdAt: backupProblem.createdAt,
|
||||
updatedAt: backupProblem.updatedAt
|
||||
)
|
||||
}
|
||||
return backupProblem
|
||||
}
|
||||
|
||||
let modifiedSessions = dataManager.sessions.filter { session in
|
||||
session.status != .active && session.updatedAt > lastSync
|
||||
}.map { BackupClimbSession(from: $0) }
|
||||
|
||||
let activeSessionIds = Set(
|
||||
dataManager.sessions.filter { $0.status == .active }.map { $0.id })
|
||||
let modifiedAttempts = dataManager.attempts.filter { attempt in
|
||||
!activeSessionIds.contains(attempt.sessionId) && attempt.createdAt > lastSync
|
||||
}.map { BackupAttempt(from: $0) }
|
||||
|
||||
let modifiedDeletions = dataManager.getDeletedItems().filter { item in
|
||||
if let deletedDate = formatter.date(from: item.deletedAt) {
|
||||
return deletedDate > lastSync
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
print(
|
||||
"iOS DELTA SYNC: Sending gyms=\(modifiedGyms.count), problems=\(modifiedProblems.count), sessions=\(modifiedSessions.count), attempts=\(modifiedAttempts.count), deletions=\(modifiedDeletions.count)"
|
||||
)
|
||||
|
||||
// Create delta request
|
||||
let deltaRequest = DeltaSyncRequest(
|
||||
lastSyncTime: lastSyncString,
|
||||
gyms: modifiedGyms,
|
||||
problems: modifiedProblems,
|
||||
sessions: modifiedSessions,
|
||||
attempts: modifiedAttempts,
|
||||
deletedItems: modifiedDeletions
|
||||
)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
let jsonData = try encoder.encode(deltaRequest)
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.httpBody = jsonData
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw SyncError.invalidResponse
|
||||
}
|
||||
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
break
|
||||
case 401:
|
||||
throw SyncError.unauthorized
|
||||
default:
|
||||
throw SyncError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
let deltaResponse = try decoder.decode(DeltaSyncResponse.self, from: data)
|
||||
|
||||
print(
|
||||
"iOS DELTA SYNC: Received gyms=\(deltaResponse.gyms.count), problems=\(deltaResponse.problems.count), sessions=\(deltaResponse.sessions.count), attempts=\(deltaResponse.attempts.count), deletions=\(deltaResponse.deletedItems.count)"
|
||||
)
|
||||
|
||||
// Apply server changes to local data
|
||||
try await applyDeltaResponse(deltaResponse, dataManager: dataManager)
|
||||
|
||||
// Sync only modified problem images
|
||||
try await syncModifiedImages(modifiedProblems: modifiedProblems, dataManager: dataManager)
|
||||
|
||||
// Update last sync time to server time
|
||||
if let serverTime = formatter.date(from: deltaResponse.serverTime) {
|
||||
lastSyncTime = serverTime
|
||||
userDefaults.set(lastSyncTime, forKey: Keys.lastSyncTime)
|
||||
}
|
||||
}
|
||||
|
||||
private func applyDeltaResponse(_ response: DeltaSyncResponse, dataManager: ClimbingDataManager)
|
||||
async throws
|
||||
{
|
||||
let formatter = ISO8601DateFormatter()
|
||||
|
||||
// Download images for new/modified problems from server
|
||||
var imagePathMapping: [String: String] = [:]
|
||||
for problem in response.problems {
|
||||
guard let imagePaths = problem.imagePaths, !imagePaths.isEmpty else { continue }
|
||||
|
||||
for (index, imagePath) in imagePaths.enumerated() {
|
||||
let serverFilename = URL(fileURLWithPath: imagePath).lastPathComponent
|
||||
|
||||
do {
|
||||
let imageData = try await downloadImage(filename: serverFilename)
|
||||
let consistentFilename = ImageNamingUtils.generateImageFilename(
|
||||
problemId: problem.id, imageIndex: index)
|
||||
let imageManager = ImageManager.shared
|
||||
_ = try imageManager.saveImportedImage(imageData, filename: consistentFilename)
|
||||
imagePathMapping[serverFilename] = consistentFilename
|
||||
} catch SyncError.imageNotFound {
|
||||
print("Image not found on server: \(serverFilename)")
|
||||
continue
|
||||
} catch {
|
||||
print("Failed to download image \(serverFilename): \(error)")
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge gyms
|
||||
for backupGym in response.gyms {
|
||||
if let index = dataManager.gyms.firstIndex(where: { $0.id.uuidString == backupGym.id })
|
||||
{
|
||||
let existing = dataManager.gyms[index]
|
||||
if backupGym.updatedAt >= formatter.string(from: existing.updatedAt) {
|
||||
dataManager.gyms[index] = try backupGym.toGym()
|
||||
}
|
||||
} else {
|
||||
dataManager.gyms.append(try backupGym.toGym())
|
||||
}
|
||||
}
|
||||
|
||||
// Merge problems
|
||||
for backupProblem in response.problems {
|
||||
var problemToMerge = backupProblem
|
||||
if !imagePathMapping.isEmpty, let imagePaths = backupProblem.imagePaths {
|
||||
let updatedPaths = imagePaths.compactMap { imagePathMapping[$0] ?? $0 }
|
||||
problemToMerge = BackupProblem(
|
||||
id: backupProblem.id,
|
||||
gymId: backupProblem.gymId,
|
||||
name: backupProblem.name,
|
||||
description: backupProblem.description,
|
||||
climbType: backupProblem.climbType,
|
||||
difficulty: backupProblem.difficulty,
|
||||
tags: backupProblem.tags,
|
||||
location: backupProblem.location,
|
||||
imagePaths: updatedPaths,
|
||||
isActive: backupProblem.isActive,
|
||||
dateSet: backupProblem.dateSet,
|
||||
notes: backupProblem.notes,
|
||||
createdAt: backupProblem.createdAt,
|
||||
updatedAt: backupProblem.updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
if let index = dataManager.problems.firstIndex(where: {
|
||||
$0.id.uuidString == problemToMerge.id
|
||||
}) {
|
||||
let existing = dataManager.problems[index]
|
||||
if problemToMerge.updatedAt >= formatter.string(from: existing.updatedAt) {
|
||||
dataManager.problems[index] = try problemToMerge.toProblem()
|
||||
}
|
||||
} else {
|
||||
dataManager.problems.append(try problemToMerge.toProblem())
|
||||
}
|
||||
}
|
||||
|
||||
// Merge sessions
|
||||
for backupSession in response.sessions {
|
||||
if let index = dataManager.sessions.firstIndex(where: {
|
||||
$0.id.uuidString == backupSession.id
|
||||
}) {
|
||||
let existing = dataManager.sessions[index]
|
||||
if backupSession.updatedAt >= formatter.string(from: existing.updatedAt) {
|
||||
dataManager.sessions[index] = try backupSession.toClimbSession()
|
||||
}
|
||||
} else {
|
||||
dataManager.sessions.append(try backupSession.toClimbSession())
|
||||
}
|
||||
}
|
||||
|
||||
// Merge attempts
|
||||
for backupAttempt in response.attempts {
|
||||
if let index = dataManager.attempts.firstIndex(where: {
|
||||
$0.id.uuidString == backupAttempt.id
|
||||
}) {
|
||||
let existing = dataManager.attempts[index]
|
||||
if backupAttempt.createdAt >= formatter.string(from: existing.createdAt) {
|
||||
dataManager.attempts[index] = try backupAttempt.toAttempt()
|
||||
}
|
||||
} else {
|
||||
dataManager.attempts.append(try backupAttempt.toAttempt())
|
||||
}
|
||||
}
|
||||
|
||||
// Apply deletions
|
||||
let allDeletions = dataManager.getDeletedItems() + response.deletedItems
|
||||
let uniqueDeletions = Array(Set(allDeletions))
|
||||
applyDeletionsToDataManager(deletions: uniqueDeletions, dataManager: dataManager)
|
||||
|
||||
// Save all changes
|
||||
dataManager.saveGyms()
|
||||
dataManager.saveProblems()
|
||||
dataManager.saveSessions()
|
||||
dataManager.saveAttempts()
|
||||
|
||||
// Update deletion records
|
||||
dataManager.clearDeletedItems()
|
||||
if let data = try? JSONEncoder().encode(uniqueDeletions) {
|
||||
UserDefaults.standard.set(data, forKey: "ascently_deleted_items")
|
||||
}
|
||||
|
||||
DataStateManager.shared.updateDataState()
|
||||
}
|
||||
|
||||
private func applyDeletionsToDataManager(
|
||||
deletions: [DeletedItem], dataManager: ClimbingDataManager
|
||||
) {
|
||||
let deletedGymIds = Set(deletions.filter { $0.type == "gym" }.map { $0.id })
|
||||
let deletedProblemIds = Set(deletions.filter { $0.type == "problem" }.map { $0.id })
|
||||
let deletedSessionIds = Set(deletions.filter { $0.type == "session" }.map { $0.id })
|
||||
let deletedAttemptIds = Set(deletions.filter { $0.type == "attempt" }.map { $0.id })
|
||||
|
||||
dataManager.gyms.removeAll { deletedGymIds.contains($0.id.uuidString) }
|
||||
dataManager.problems.removeAll { deletedProblemIds.contains($0.id.uuidString) }
|
||||
dataManager.sessions.removeAll { deletedSessionIds.contains($0.id.uuidString) }
|
||||
dataManager.attempts.removeAll { deletedAttemptIds.contains($0.id.uuidString) }
|
||||
}
|
||||
|
||||
private func syncModifiedImages(
|
||||
modifiedProblems: [BackupProblem], dataManager: ClimbingDataManager
|
||||
) async throws {
|
||||
guard !modifiedProblems.isEmpty else { return }
|
||||
|
||||
print("iOS DELTA SYNC: Syncing images for \(modifiedProblems.count) modified problems")
|
||||
|
||||
for backupProblem in modifiedProblems {
|
||||
guard
|
||||
let problem = dataManager.problems.first(where: {
|
||||
$0.id.uuidString == backupProblem.id
|
||||
})
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
for (index, imagePath) in problem.imagePaths.enumerated() {
|
||||
let filename = URL(fileURLWithPath: imagePath).lastPathComponent
|
||||
let consistentFilename = ImageNamingUtils.generateImageFilename(
|
||||
problemId: problem.id.uuidString, imageIndex: index)
|
||||
|
||||
let imageManager = ImageManager.shared
|
||||
let fullPath = imageManager.imagesDirectory.appendingPathComponent(filename).path
|
||||
|
||||
if let imageData = imageManager.loadImageData(fromPath: fullPath) {
|
||||
do {
|
||||
if filename != consistentFilename {
|
||||
let newPath = imageManager.imagesDirectory.appendingPathComponent(
|
||||
consistentFilename
|
||||
).path
|
||||
try? FileManager.default.moveItem(atPath: fullPath, toPath: newPath)
|
||||
}
|
||||
|
||||
try await uploadImage(filename: consistentFilename, imageData: imageData)
|
||||
print("Uploaded modified problem image: \(consistentFilename)")
|
||||
} catch {
|
||||
print("Failed to upload image \(consistentFilename): \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func uploadImage(filename: String, imageData: Data) async throws {
|
||||
guard isConfigured else {
|
||||
throw SyncError.notConfigured
|
||||
@@ -246,6 +554,17 @@ class SyncService: ObservableObject {
|
||||
!serverBackup.gyms.isEmpty || !serverBackup.problems.isEmpty
|
||||
|| !serverBackup.sessions.isEmpty || !serverBackup.attempts.isEmpty
|
||||
|
||||
// If both client and server have been synced before, use delta sync
|
||||
if hasLocalData && hasServerData && lastSyncTime != nil {
|
||||
print("iOS SYNC: Using delta sync for incremental updates")
|
||||
try await performDeltaSync(dataManager: dataManager)
|
||||
|
||||
// Update last sync time
|
||||
lastSyncTime = Date()
|
||||
userDefaults.set(lastSyncTime, forKey: Keys.lastSyncTime)
|
||||
return
|
||||
}
|
||||
|
||||
if !hasLocalData && hasServerData {
|
||||
// Case 1: No local data - do full restore from server
|
||||
print("iOS SYNC: Case 1 - No local data, performing full restore from server")
|
||||
@@ -286,7 +605,6 @@ class SyncService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses ISO8601 timestamp to milliseconds for comparison
|
||||
private func parseISO8601ToMillis(timestamp: String) -> Int64 {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
if let date = formatter.date(from: timestamp) {
|
||||
@@ -1150,7 +1468,6 @@ class SyncService: ObservableObject {
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user