iOS Build 23
This commit is contained in:
@@ -11,6 +11,9 @@ class SyncService: ObservableObject {
|
||||
@Published var isTesting = false
|
||||
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private var syncTask: Task<Void, Never>?
|
||||
private var pendingChanges = false
|
||||
private let syncDebounceDelay: TimeInterval = 2.0
|
||||
|
||||
private enum Keys {
|
||||
static let serverURL = "sync_server_url"
|
||||
@@ -44,6 +47,11 @@ class SyncService: ObservableObject {
|
||||
self.lastSyncTime = lastSync
|
||||
}
|
||||
self.isConnected = userDefaults.bool(forKey: Keys.isConnected)
|
||||
|
||||
// Perform image naming migration on initialization
|
||||
Task {
|
||||
await performImageNamingMigration()
|
||||
}
|
||||
}
|
||||
|
||||
func downloadData() async throws -> ClimbDataBackup {
|
||||
@@ -144,6 +152,9 @@ class SyncService: ObservableObject {
|
||||
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = imageData
|
||||
|
||||
request.timeoutInterval = 60.0
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
@@ -173,6 +184,9 @@ class SyncService: ObservableObject {
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
request.timeoutInterval = 45.0
|
||||
request.cachePolicy = .returnCacheDataElseLoad
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
@@ -283,7 +297,6 @@ class SyncService: ObservableObject {
|
||||
{
|
||||
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 }
|
||||
|
||||
@@ -293,19 +306,13 @@ class SyncService: ObservableObject {
|
||||
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)
|
||||
let consistentFilename = 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 {
|
||||
@@ -329,12 +336,8 @@ class SyncService: ObservableObject {
|
||||
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)
|
||||
let consistentFilename = ImageNamingUtils.generateImageFilename(
|
||||
problemId: problem.id.uuidString, imageIndex: index)
|
||||
|
||||
// Load image data
|
||||
let imageManager = ImageManager.shared
|
||||
@@ -392,6 +395,53 @@ class SyncService: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
func createBackupForExport(_ dataManager: ClimbingDataManager) -> ClimbDataBackup {
|
||||
// Filter out active sessions and their attempts from sync
|
||||
let completedSessions = dataManager.sessions.filter { $0.status != .active }
|
||||
let activeSessionIds = Set(
|
||||
dataManager.sessions.filter { $0.status == .active }.map { $0.id })
|
||||
let completedAttempts = dataManager.attempts.filter {
|
||||
!activeSessionIds.contains($0.sessionId)
|
||||
}
|
||||
|
||||
// Create backup with normalized image paths for export
|
||||
return ClimbDataBackup(
|
||||
exportedAt: DataStateManager.shared.getLastModified(),
|
||||
gyms: dataManager.gyms.map { BackupGym(from: $0) },
|
||||
problems: dataManager.problems.map { problem 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)
|
||||
}
|
||||
|
||||
backupProblem = 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
|
||||
},
|
||||
sessions: completedSessions.map { BackupClimbSession(from: $0) },
|
||||
attempts: completedAttempts.map { BackupAttempt(from: $0) },
|
||||
deletedItems: dataManager.getDeletedItems()
|
||||
)
|
||||
}
|
||||
|
||||
private func mergeDataSafely(
|
||||
localBackup: ClimbDataBackup,
|
||||
serverBackup: ClimbDataBackup,
|
||||
@@ -620,17 +670,31 @@ class SyncService: ObservableObject {
|
||||
}
|
||||
let jsonData = try encoder.encode(backup)
|
||||
|
||||
// Collect all downloaded images from ImageManager
|
||||
// Collect all 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))
|
||||
// Get original problems to access actual image paths on disk
|
||||
if let problemsData = userDefaults.data(forKey: "problems"),
|
||||
let problems = try? JSONDecoder().decode([Problem].self, from: problemsData)
|
||||
{
|
||||
// Create a mapping from normalized paths to actual paths
|
||||
for problem in problems {
|
||||
for (index, imagePath) in problem.imagePaths.enumerated() {
|
||||
// Get the actual filename on disk
|
||||
let actualFilename = URL(fileURLWithPath: imagePath).lastPathComponent
|
||||
let fullPath = imageManager.imagesDirectory.appendingPathComponent(
|
||||
actualFilename
|
||||
).path
|
||||
|
||||
// Generate the normalized filename for the ZIP
|
||||
let normalizedFilename = ImageNamingUtils.generateImageFilename(
|
||||
problemId: problem.id.uuidString, imageIndex: index)
|
||||
|
||||
if let imageData = imageManager.loadImageData(fromPath: fullPath) {
|
||||
imageFiles.append((filename: normalizedFilename, data: imageData))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -875,20 +939,51 @@ class SyncService: ObservableObject {
|
||||
}
|
||||
|
||||
func triggerAutoSync(dataManager: ClimbingDataManager) {
|
||||
// Early exit if sync cannot proceed - don't set isSyncing
|
||||
guard isConnected && isConfigured && isAutoSyncEnabled else {
|
||||
// Ensure isSyncing is false when sync is not possible
|
||||
if isSyncing {
|
||||
isSyncing = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent multiple simultaneous syncs
|
||||
guard !isSyncing else {
|
||||
if isSyncing {
|
||||
pendingChanges = true
|
||||
return
|
||||
}
|
||||
|
||||
syncTask?.cancel()
|
||||
|
||||
syncTask = Task {
|
||||
try? await Task.sleep(nanoseconds: UInt64(syncDebounceDelay * 1_000_000_000))
|
||||
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
repeat {
|
||||
pendingChanges = false
|
||||
|
||||
do {
|
||||
try await syncWithServer(dataManager: dataManager)
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.isSyncing = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if pendingChanges {
|
||||
try? await Task.sleep(nanoseconds: UInt64(syncDebounceDelay * 1_000_000_000))
|
||||
}
|
||||
} while pendingChanges && !Task.isCancelled
|
||||
}
|
||||
}
|
||||
|
||||
func forceSyncNow(dataManager: ClimbingDataManager) {
|
||||
guard isConnected && isConfigured else { return }
|
||||
|
||||
syncTask?.cancel()
|
||||
syncTask = nil
|
||||
pendingChanges = false
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await syncWithServer(dataManager: dataManager)
|
||||
@@ -901,6 +996,10 @@ class SyncService: ObservableObject {
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
syncTask?.cancel()
|
||||
syncTask = nil
|
||||
pendingChanges = false
|
||||
isSyncing = false
|
||||
isConnected = false
|
||||
lastSyncTime = nil
|
||||
syncError = nil
|
||||
@@ -917,6 +1016,112 @@ class SyncService: ObservableObject {
|
||||
userDefaults.removeObject(forKey: Keys.lastSyncTime)
|
||||
userDefaults.removeObject(forKey: Keys.isConnected)
|
||||
userDefaults.removeObject(forKey: Keys.autoSyncEnabled)
|
||||
syncTask?.cancel()
|
||||
syncTask = nil
|
||||
pendingChanges = false
|
||||
}
|
||||
|
||||
deinit {
|
||||
syncTask?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Image Naming Migration
|
||||
|
||||
private func performImageNamingMigration() async {
|
||||
let migrationKey = "image_naming_migration_completed_v2"
|
||||
guard !userDefaults.bool(forKey: migrationKey) else {
|
||||
print("Image naming migration already completed")
|
||||
return
|
||||
}
|
||||
|
||||
print("Starting image naming migration...")
|
||||
var updateCount = 0
|
||||
let imageManager = ImageManager.shared
|
||||
|
||||
// Get all problems from UserDefaults
|
||||
if let problemsData = userDefaults.data(forKey: "problems"),
|
||||
var problems = try? JSONDecoder().decode([Problem].self, from: problemsData)
|
||||
{
|
||||
|
||||
for problemIndex in 0..<problems.count {
|
||||
let problem = problems[problemIndex]
|
||||
guard !problem.imagePaths.isEmpty else { continue }
|
||||
|
||||
var updatedImagePaths: [String] = []
|
||||
var hasChanges = false
|
||||
|
||||
for (imageIndex, imagePath) in problem.imagePaths.enumerated() {
|
||||
let currentFilename = URL(fileURLWithPath: imagePath).lastPathComponent
|
||||
let consistentFilename = ImageNamingUtils.generateImageFilename(
|
||||
problemId: problem.id.uuidString, imageIndex: imageIndex)
|
||||
|
||||
if currentFilename != consistentFilename {
|
||||
let oldPath = imageManager.imagesDirectory.appendingPathComponent(
|
||||
currentFilename
|
||||
).path
|
||||
let newPath = imageManager.imagesDirectory.appendingPathComponent(
|
||||
consistentFilename
|
||||
).path
|
||||
|
||||
if FileManager.default.fileExists(atPath: oldPath) {
|
||||
do {
|
||||
try FileManager.default.moveItem(atPath: oldPath, toPath: newPath)
|
||||
updatedImagePaths.append(consistentFilename)
|
||||
hasChanges = true
|
||||
updateCount += 1
|
||||
print("Migrated image: \(currentFilename) -> \(consistentFilename)")
|
||||
} catch {
|
||||
print("Failed to migrate image \(currentFilename): \(error)")
|
||||
updatedImagePaths.append(imagePath)
|
||||
}
|
||||
} else {
|
||||
updatedImagePaths.append(imagePath)
|
||||
}
|
||||
} else {
|
||||
updatedImagePaths.append(imagePath)
|
||||
}
|
||||
}
|
||||
|
||||
if hasChanges {
|
||||
// Decode problem to dictionary, update imagePaths, re-encode
|
||||
if let problemData = try? JSONEncoder().encode(problem),
|
||||
var problemDict = try? JSONSerialization.jsonObject(with: problemData)
|
||||
as? [String: Any]
|
||||
{
|
||||
problemDict["imagePaths"] = updatedImagePaths
|
||||
problemDict["updatedAt"] = ISO8601DateFormatter().string(from: Date())
|
||||
if let updatedData = try? JSONSerialization.data(
|
||||
withJSONObject: problemDict),
|
||||
let updatedProblem = try? JSONDecoder().decode(
|
||||
Problem.self, from: updatedData)
|
||||
{
|
||||
problems[problemIndex] = updatedProblem
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if updateCount > 0 {
|
||||
if let updatedData = try? JSONEncoder().encode(problems) {
|
||||
userDefaults.set(updatedData, forKey: "problems")
|
||||
print("Updated \(updateCount) image paths in UserDefaults")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userDefaults.set(true, forKey: migrationKey)
|
||||
print("Image naming migration completed, updated \(updateCount) images")
|
||||
|
||||
// Notify ClimbingDataManager to reload data if images were updated
|
||||
if updateCount > 0 {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(
|
||||
name: NSNotification.Name("ImageMigrationCompleted"),
|
||||
object: nil,
|
||||
userInfo: ["updateCount": updateCount]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Safe Merge Functions
|
||||
@@ -926,13 +1131,14 @@ class SyncService: ObservableObject {
|
||||
var merged = local
|
||||
let deletedGymIds = Set(deletedItems.filter { $0.type == "gym" }.map { $0.id })
|
||||
|
||||
// Remove items that were deleted on other devices
|
||||
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 = local.contains(where: { $0.id.uuidString == serverGym.id })
|
||||
let localHasGym = localGymIds.contains(serverGym.id)
|
||||
let isDeleted = deletedGymIds.contains(serverGym.id)
|
||||
|
||||
if !localHasGym && !isDeleted {
|
||||
@@ -953,41 +1159,44 @@ class SyncService: ObservableObject {
|
||||
var merged = local
|
||||
let deletedProblemIds = Set(deletedItems.filter { $0.type == "problem" }.map { $0.id })
|
||||
|
||||
// Remove items that were deleted on other devices
|
||||
let localProblemIds = Set(local.map { $0.id.uuidString })
|
||||
|
||||
merged.removeAll { deletedProblemIds.contains($0.id.uuidString) }
|
||||
|
||||
// Add new items from server (excluding deleted ones)
|
||||
for serverProblem in server {
|
||||
var problemToAdd = serverProblem
|
||||
let localHasProblem = localProblemIds.contains(serverProblem.id)
|
||||
let isDeleted = deletedProblemIds.contains(serverProblem.id)
|
||||
|
||||
// Update image paths if needed
|
||||
if !imagePathMapping.isEmpty {
|
||||
let updatedImagePaths = serverProblem.imagePaths?.compactMap { oldPath in
|
||||
imagePathMapping[oldPath] ?? oldPath
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
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() {
|
||||
let localHasProblem = local.contains(where: { $0.id.uuidString == problemToAdd.id })
|
||||
let isDeleted = deletedProblemIds.contains(problemToAdd.id)
|
||||
|
||||
if !localHasProblem && !isDeleted {
|
||||
if let serverProblemConverted = try? problemToAdd.toProblem() {
|
||||
merged.append(serverProblemConverted)
|
||||
}
|
||||
}
|
||||
@@ -1004,19 +1213,18 @@ class SyncService: ObservableObject {
|
||||
var merged = local
|
||||
let deletedSessionIds = Set(deletedItems.filter { $0.type == "session" }.map { $0.id })
|
||||
|
||||
// Remove items that were deleted on other devices (but never remove active sessions)
|
||||
let localSessionIds = Set(local.map { $0.id.uuidString })
|
||||
|
||||
merged.removeAll { session in
|
||||
deletedSessionIds.contains(session.id.uuidString) && session.status != .active
|
||||
}
|
||||
|
||||
// Add new items from server (excluding deleted ones)
|
||||
for serverSession in server {
|
||||
if let serverSessionConverted = try? serverSession.toClimbSession() {
|
||||
let localHasSession = local.contains(where: { $0.id.uuidString == serverSession.id }
|
||||
)
|
||||
let isDeleted = deletedSessionIds.contains(serverSession.id)
|
||||
let localHasSession = localSessionIds.contains(serverSession.id)
|
||||
let isDeleted = deletedSessionIds.contains(serverSession.id)
|
||||
|
||||
if !localHasSession && !isDeleted {
|
||||
if !localHasSession && !isDeleted {
|
||||
if let serverSessionConverted = try? serverSession.toClimbSession() {
|
||||
merged.append(serverSessionConverted)
|
||||
}
|
||||
}
|
||||
@@ -1031,6 +1239,8 @@ class SyncService: ObservableObject {
|
||||
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
|
||||
@@ -1048,14 +1258,12 @@ class SyncService: ObservableObject {
|
||||
&& !activeSessionIds.contains(attempt.sessionId)
|
||||
}
|
||||
|
||||
// Add new items from server (excluding deleted ones)
|
||||
for serverAttempt in server {
|
||||
if let serverAttemptConverted = try? serverAttempt.toAttempt() {
|
||||
let localHasAttempt = local.contains(where: { $0.id.uuidString == serverAttempt.id }
|
||||
)
|
||||
let isDeleted = deletedAttemptIds.contains(serverAttempt.id)
|
||||
let localHasAttempt = localAttemptIds.contains(serverAttempt.id)
|
||||
let isDeleted = deletedAttemptIds.contains(serverAttempt.id)
|
||||
|
||||
if !localHasAttempt && !isDeleted {
|
||||
if !localHasAttempt && !isDeleted {
|
||||
if let serverAttemptConverted = try? serverAttempt.toAttempt() {
|
||||
merged.append(serverAttemptConverted)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user