// // BackupFormat.swift import Foundation // MARK: - Backup Format Specification v2.0 /// Root structure for OpenClimb backup data struct ClimbDataBackup: Codable { let exportedAt: String let version: String let formatVersion: String let gyms: [BackupGym] let problems: [BackupProblem] let sessions: [BackupClimbSession] let attempts: [BackupAttempt] init( exportedAt: String, version: String = "2.0", formatVersion: String = "2.0", gyms: [BackupGym], problems: [BackupProblem], sessions: [BackupClimbSession], attempts: [BackupAttempt] ) { self.exportedAt = exportedAt self.version = version self.formatVersion = formatVersion self.gyms = gyms self.problems = problems self.sessions = sessions self.attempts = attempts } } // Platform-neutral gym representation for backup/restore struct BackupGym: Codable { let id: String let name: String let location: String? let supportedClimbTypes: [ClimbType] let difficultySystems: [DifficultySystem] let customDifficultyGrades: [String] let notes: String? let createdAt: String let updatedAt: String /// Initialize from native iOS Gym model init(from gym: Gym) { self.id = gym.id.uuidString self.name = gym.name self.location = gym.location self.supportedClimbTypes = gym.supportedClimbTypes self.difficultySystems = gym.difficultySystems self.customDifficultyGrades = gym.customDifficultyGrades self.notes = gym.notes let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] self.createdAt = formatter.string(from: gym.createdAt) self.updatedAt = formatter.string(from: gym.updatedAt) } /// Initialize with explicit parameters for import init( id: String, name: String, location: String?, supportedClimbTypes: [ClimbType], difficultySystems: [DifficultySystem], customDifficultyGrades: [String] = [], notes: String?, createdAt: String, updatedAt: String ) { self.id = id self.name = name self.location = location self.supportedClimbTypes = supportedClimbTypes self.difficultySystems = difficultySystems self.customDifficultyGrades = customDifficultyGrades self.notes = notes self.createdAt = createdAt self.updatedAt = updatedAt } /// Convert to native iOS Gym model func toGym() throws -> Gym { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] guard let uuid = UUID(uuidString: id), let createdDate = formatter.date(from: createdAt), let updatedDate = formatter.date(from: updatedAt) else { throw BackupError.invalidDateFormat } return Gym.fromImport( id: uuid, name: name, location: location, supportedClimbTypes: supportedClimbTypes, difficultySystems: difficultySystems, customDifficultyGrades: customDifficultyGrades, notes: notes, createdAt: createdDate, updatedAt: updatedDate ) } } // Platform-neutral problem representation for backup/restore struct BackupProblem: Codable { let id: String let gymId: String let name: String? let description: String? let climbType: ClimbType let difficulty: DifficultyGrade let tags: [String] let location: String? let imagePaths: [String]? let isActive: Bool let dateSet: String? // ISO 8601 format let notes: String? let createdAt: String let updatedAt: String /// Initialize from native iOS Problem model init(from problem: Problem) { self.id = problem.id.uuidString self.gymId = problem.gymId.uuidString self.name = problem.name self.description = problem.description self.climbType = problem.climbType self.difficulty = problem.difficulty self.tags = problem.tags self.location = problem.location self.imagePaths = problem.imagePaths.isEmpty ? nil : problem.imagePaths self.isActive = problem.isActive self.notes = problem.notes let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] self.dateSet = problem.dateSet.map { formatter.string(from: $0) } self.createdAt = formatter.string(from: problem.createdAt) self.updatedAt = formatter.string(from: problem.updatedAt) } /// Initialize with explicit parameters for import init( id: String, gymId: String, name: String?, description: String?, climbType: ClimbType, difficulty: DifficultyGrade, tags: [String] = [], location: String?, imagePaths: [String]?, isActive: Bool, dateSet: String?, notes: String?, createdAt: String, updatedAt: String ) { self.id = id self.gymId = gymId self.name = name self.description = description self.climbType = climbType self.difficulty = difficulty self.tags = tags self.location = location self.imagePaths = imagePaths self.isActive = isActive self.dateSet = dateSet self.notes = notes self.createdAt = createdAt self.updatedAt = updatedAt } /// Convert to native iOS Problem model func toProblem() throws -> Problem { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] guard let uuid = UUID(uuidString: id), let gymUuid = UUID(uuidString: gymId), let createdDate = formatter.date(from: createdAt), let updatedDate = formatter.date(from: updatedAt) else { throw BackupError.invalidDateFormat } let dateSetDate = dateSet.flatMap { formatter.date(from: $0) } return Problem.fromImport( id: uuid, gymId: gymUuid, name: name, description: description, climbType: climbType, difficulty: difficulty, tags: tags, location: location, imagePaths: imagePaths ?? [], isActive: isActive, dateSet: dateSetDate, notes: notes, createdAt: createdDate, updatedAt: updatedDate ) } /// Create a copy with updated image paths for import processing func withUpdatedImagePaths(_ newImagePaths: [String]) -> BackupProblem { return BackupProblem( id: self.id, gymId: self.gymId, name: self.name, description: self.description, climbType: self.climbType, difficulty: self.difficulty, tags: self.tags, location: self.location, imagePaths: newImagePaths.isEmpty ? nil : newImagePaths, isActive: self.isActive, dateSet: self.dateSet, notes: self.notes, createdAt: self.createdAt, updatedAt: self.updatedAt ) } } // Platform-neutral climb session representation for backup/restore struct BackupClimbSession: Codable { let id: String let gymId: String let date: String // ISO 8601 format let startTime: String? // ISO 8601 format let endTime: String? // ISO 8601 format let duration: Int64? // Duration in seconds let status: SessionStatus let notes: String? let createdAt: String let updatedAt: String /// Initialize from native iOS ClimbSession model init(from session: ClimbSession) { self.id = session.id.uuidString self.gymId = session.gymId.uuidString self.status = session.status self.notes = session.notes let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] self.date = formatter.string(from: session.date) self.startTime = session.startTime.map { formatter.string(from: $0) } self.endTime = session.endTime.map { formatter.string(from: $0) } self.duration = session.duration.map { Int64($0) } self.createdAt = formatter.string(from: session.createdAt) self.updatedAt = formatter.string(from: session.updatedAt) } /// Initialize with explicit parameters for import init( id: String, gymId: String, date: String, startTime: String?, endTime: String?, duration: Int64?, status: SessionStatus, notes: String?, createdAt: String, updatedAt: String ) { self.id = id self.gymId = gymId self.date = date self.startTime = startTime self.endTime = endTime self.duration = duration self.status = status self.notes = notes self.createdAt = createdAt self.updatedAt = updatedAt } /// Convert to native iOS ClimbSession model func toClimbSession() throws -> ClimbSession { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] guard let uuid = UUID(uuidString: id), let gymUuid = UUID(uuidString: gymId), let dateValue = formatter.date(from: date), let createdDate = formatter.date(from: createdAt), let updatedDate = formatter.date(from: updatedAt) else { throw BackupError.invalidDateFormat } let startTimeValue = startTime.flatMap { formatter.date(from: $0) } let endTimeValue = endTime.flatMap { formatter.date(from: $0) } let durationValue = duration.map { Int($0) } return ClimbSession.fromImport( id: uuid, gymId: gymUuid, date: dateValue, startTime: startTimeValue, endTime: endTimeValue, duration: durationValue, status: status, notes: notes, createdAt: createdDate, updatedAt: updatedDate ) } } // Platform-neutral attempt representation for backup/restore struct BackupAttempt: Codable { let id: String let sessionId: String let problemId: String let result: AttemptResult let highestHold: String? let notes: String? let duration: Int64? // Duration in seconds let restTime: Int64? // Rest time in seconds let timestamp: String let createdAt: String let updatedAt: String? /// Initialize from native iOS Attempt model init(from attempt: Attempt) { self.id = attempt.id.uuidString self.sessionId = attempt.sessionId.uuidString self.problemId = attempt.problemId.uuidString self.result = attempt.result self.highestHold = attempt.highestHold self.notes = attempt.notes self.duration = attempt.duration.map { Int64($0) } self.restTime = attempt.restTime.map { Int64($0) } let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] self.timestamp = formatter.string(from: attempt.timestamp) self.createdAt = formatter.string(from: attempt.createdAt) self.updatedAt = formatter.string(from: attempt.updatedAt) } /// Initialize with explicit parameters for import init( id: String, sessionId: String, problemId: String, result: AttemptResult, highestHold: String?, notes: String?, duration: Int64?, restTime: Int64?, timestamp: String, createdAt: String, updatedAt: String? ) { self.id = id self.sessionId = sessionId self.problemId = problemId self.result = result self.highestHold = highestHold self.notes = notes self.duration = duration self.restTime = restTime self.timestamp = timestamp self.createdAt = createdAt self.updatedAt = updatedAt } /// Convert to native iOS Attempt model func toAttempt() throws -> Attempt { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] guard let uuid = UUID(uuidString: id), let sessionUuid = UUID(uuidString: sessionId), let problemUuid = UUID(uuidString: problemId), let timestampDate = formatter.date(from: timestamp), let createdDate = formatter.date(from: createdAt) else { throw BackupError.invalidDateFormat } let updatedDateParsed = updatedAt.flatMap { formatter.date(from: $0) } let updatedDate = updatedDateParsed ?? createdDate let durationValue = duration.map { Int($0) } let restTimeValue = restTime.map { Int($0) } return Attempt.fromImport( id: uuid, sessionId: sessionUuid, problemId: problemUuid, result: result, highestHold: highestHold, notes: notes, duration: durationValue, restTime: restTimeValue, timestamp: timestampDate, createdAt: createdDate, updatedAt: updatedDate ) } } // MARK: - Backup Format Errors enum BackupError: LocalizedError { case invalidDateFormat case invalidUUID case missingRequiredField(String) case unsupportedFormatVersion(String) var errorDescription: String? { switch self { case .invalidDateFormat: return "Invalid date format in backup data" case .invalidUUID: return "Invalid UUID format in backup data" case .missingRequiredField(let field): return "Missing required field: \(field)" case .unsupportedFormatVersion(let version): return "Unsupported backup format version: \(version)" } } } // MARK: - Extensions // MARK: - Helper Extensions for Optional Mapping extension Optional { func map(_ transform: (Wrapped) -> T) -> T? { return self.flatMap { .some(transform($0)) } } }