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.serverURL = newURL
|
||||||
syncService.authToken = newToken
|
syncService.authToken = newToken
|
||||||
|
|
||||||
|
// Ensure provider type is set to server
|
||||||
|
if syncService.providerType != .server {
|
||||||
|
syncService.providerType = .server
|
||||||
|
}
|
||||||
|
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
@@ -834,6 +840,13 @@ struct SyncSettingsView: View {
|
|||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
|
// Ensure we are using the server provider
|
||||||
|
await MainActor.run {
|
||||||
|
if syncService.providerType != .server {
|
||||||
|
syncService.providerType = .server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Temporarily set the values for testing
|
// Temporarily set the values for testing
|
||||||
syncService.serverURL = testURL
|
syncService.serverURL = testURL
|
||||||
syncService.authToken = testToken
|
syncService.authToken = testToken
|
||||||
|
|||||||
Reference in New Issue
Block a user