2.4.0 - Updated Sync Architecture (Provider pattern)
This commit is contained in:
Binary file not shown.
1188
ios/Ascently/Services/Sync/ServerSyncProvider.swift
Normal file
1188
ios/Ascently/Services/Sync/ServerSyncProvider.swift
Normal file
File diff suppressed because it is too large
Load Diff
181
ios/Ascently/Services/Sync/SyncMerger.swift
Normal file
181
ios/Ascently/Services/Sync/SyncMerger.swift
Normal file
@@ -0,0 +1,181 @@
|
||||
import Foundation
|
||||
|
||||
struct SyncMerger {
|
||||
private static let logTag = "SyncMerger"
|
||||
|
||||
static func mergeDataSafely(
|
||||
localBackup: ClimbDataBackup,
|
||||
serverBackup: ClimbDataBackup,
|
||||
dataManager: ClimbingDataManager,
|
||||
imagePathMapping: [String: String]
|
||||
) throws -> (gyms: [Gym], problems: [Problem], sessions: [ClimbSession], attempts: [Attempt], uniqueDeletions: [DeletedItem]) {
|
||||
|
||||
// Merge deletion lists first to prevent resurrection of deleted items
|
||||
let localDeletions = dataManager.getDeletedItems()
|
||||
let allDeletions = localDeletions + serverBackup.deletedItems
|
||||
let uniqueDeletions = Array(Set(allDeletions))
|
||||
|
||||
AppLogger.info("Merging gyms...", tag: logTag)
|
||||
let mergedGyms = mergeGyms(
|
||||
local: dataManager.gyms,
|
||||
server: serverBackup.gyms,
|
||||
deletedItems: uniqueDeletions)
|
||||
|
||||
AppLogger.info("Merging problems...", tag: logTag)
|
||||
let mergedProblems = try mergeProblems(
|
||||
local: dataManager.problems,
|
||||
server: serverBackup.problems,
|
||||
imagePathMapping: imagePathMapping,
|
||||
deletedItems: uniqueDeletions)
|
||||
|
||||
AppLogger.info("Merging sessions...", tag: logTag)
|
||||
let mergedSessions = try mergeSessions(
|
||||
local: dataManager.sessions,
|
||||
server: serverBackup.sessions,
|
||||
deletedItems: uniqueDeletions)
|
||||
|
||||
AppLogger.info("Merging attempts...", tag: logTag)
|
||||
let mergedAttempts = try mergeAttempts(
|
||||
local: dataManager.attempts,
|
||||
server: serverBackup.attempts,
|
||||
deletedItems: uniqueDeletions)
|
||||
|
||||
return (mergedGyms, mergedProblems, mergedSessions, mergedAttempts, uniqueDeletions)
|
||||
}
|
||||
|
||||
private static func mergeGyms(local: [Gym], server: [BackupGym], deletedItems: [DeletedItem]) -> [Gym] {
|
||||
var merged = local
|
||||
let deletedGymIds = Set(deletedItems.filter { $0.type == "gym" }.map { $0.id })
|
||||
let localGymIds = Set(local.map { $0.id.uuidString })
|
||||
|
||||
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 = localGymIds.contains(serverGym.id)
|
||||
let isDeleted = deletedGymIds.contains(serverGym.id)
|
||||
|
||||
if !localHasGym && !isDeleted {
|
||||
merged.append(serverGymConverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
private static 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 })
|
||||
let localProblemIds = Set(local.map { $0.id.uuidString })
|
||||
|
||||
merged.removeAll { deletedProblemIds.contains($0.id.uuidString) }
|
||||
|
||||
for serverProblem in server {
|
||||
let localHasProblem = localProblemIds.contains(serverProblem.id)
|
||||
let isDeleted = deletedProblemIds.contains(serverProblem.id)
|
||||
|
||||
if !localHasProblem && !isDeleted {
|
||||
var problemToAdd = serverProblem
|
||||
|
||||
if !imagePathMapping.isEmpty, let imagePaths = serverProblem.imagePaths, !imagePaths.isEmpty {
|
||||
let updatedImagePaths = imagePaths.compactMap { oldPath in
|
||||
imagePathMapping[oldPath] ?? oldPath
|
||||
}
|
||||
if updatedImagePaths != imagePaths {
|
||||
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() {
|
||||
merged.append(serverProblemConverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
private static func mergeSessions(
|
||||
local: [ClimbSession], server: [BackupClimbSession], deletedItems: [DeletedItem]
|
||||
) throws -> [ClimbSession] {
|
||||
var merged = local
|
||||
let deletedSessionIds = Set(deletedItems.filter { $0.type == "session" }.map { $0.id })
|
||||
let localSessionIds = Set(local.map { $0.id.uuidString })
|
||||
|
||||
merged.removeAll { session in
|
||||
deletedSessionIds.contains(session.id.uuidString) && session.status != .active
|
||||
}
|
||||
|
||||
for serverSession in server {
|
||||
let localHasSession = localSessionIds.contains(serverSession.id)
|
||||
let isDeleted = deletedSessionIds.contains(serverSession.id)
|
||||
|
||||
if !localHasSession && !isDeleted {
|
||||
if let serverSessionConverted = try? serverSession.toClimbSession() {
|
||||
merged.append(serverSessionConverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
private static func mergeAttempts(
|
||||
local: [Attempt], server: [BackupAttempt], deletedItems: [DeletedItem]
|
||||
) throws -> [Attempt] {
|
||||
var merged = local
|
||||
let deletedAttemptIds = Set(deletedItems.filter { $0.type == "attempt" }.map { $0.id })
|
||||
let localAttemptIds = Set(local.map { $0.id.uuidString })
|
||||
|
||||
// Get active session IDs to protect their attempts
|
||||
let activeSessionIds = Set(
|
||||
local.compactMap { attempt in
|
||||
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)
|
||||
}
|
||||
|
||||
for serverAttempt in server {
|
||||
let localHasAttempt = localAttemptIds.contains(serverAttempt.id)
|
||||
let isDeleted = deletedAttemptIds.contains(serverAttempt.id)
|
||||
|
||||
if !localHasAttempt && !isDeleted {
|
||||
if let serverAttemptConverted = try? serverAttempt.toAttempt() {
|
||||
merged.append(serverAttemptConverted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
}
|
||||
73
ios/Ascently/Services/Sync/SyncProvider.swift
Normal file
73
ios/Ascently/Services/Sync/SyncProvider.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
|
||||
enum SyncProviderType: String, CaseIterable, Identifiable {
|
||||
case none
|
||||
case server
|
||||
case iCloud
|
||||
|
||||
var id: String { rawValue }
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .none: return "None"
|
||||
case .server: return "Self-Hosted Server"
|
||||
case .iCloud: return "iCloud"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protocol SyncProvider {
|
||||
var type: SyncProviderType { get }
|
||||
var isConfigured: Bool { get }
|
||||
var isConnected: Bool { get }
|
||||
|
||||
func sync(dataManager: ClimbingDataManager) async throws
|
||||
func testConnection() async throws
|
||||
func disconnect()
|
||||
}
|
||||
|
||||
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
|
||||
case providerError(String)
|
||||
|
||||
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."
|
||||
case .providerError(let message):
|
||||
return "Sync provider error: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -794,6 +794,12 @@ struct SyncSettingsView: View {
|
||||
|
||||
syncService.serverURL = newURL
|
||||
syncService.authToken = newToken
|
||||
|
||||
// Ensure provider type is set to server
|
||||
if syncService.providerType != .server {
|
||||
syncService.providerType = .server
|
||||
}
|
||||
|
||||
dismiss()
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
@@ -834,6 +840,13 @@ struct SyncSettingsView: View {
|
||||
|
||||
Task {
|
||||
do {
|
||||
// Ensure we are using the server provider
|
||||
await MainActor.run {
|
||||
if syncService.providerType != .server {
|
||||
syncService.providerType = .server
|
||||
}
|
||||
}
|
||||
|
||||
// Temporarily set the values for testing
|
||||
syncService.serverURL = testURL
|
||||
syncService.authToken = testToken
|
||||
|
||||
Reference in New Issue
Block a user