All checks were successful
Ascently - Sync Deploy / build-and-push (push) Successful in 2m15s
547 lines
17 KiB
Swift
547 lines
17 KiB
Swift
//
|
|
// BackupFormat.swift
|
|
|
|
import Foundation
|
|
|
|
// MARK: - Backup Format Specification v2.0
|
|
|
|
/// Root structure for Ascently backup data
|
|
struct DeletedItem: Codable, Hashable {
|
|
let id: String
|
|
let type: String // "gym", "problem", "session", "attempt"
|
|
let deletedAt: String
|
|
}
|
|
|
|
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 isDeleted: Bool?
|
|
let createdAt: String
|
|
let updatedAt: String
|
|
|
|
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
|
|
|
|
self.isDeleted = false // Default to false until model is updated
|
|
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
self.createdAt = formatter.string(from: gym.createdAt)
|
|
self.updatedAt = formatter.string(from: gym.updatedAt)
|
|
}
|
|
|
|
init(
|
|
id: String,
|
|
name: String,
|
|
location: String?,
|
|
supportedClimbTypes: [ClimbType],
|
|
difficultySystems: [DifficultySystem],
|
|
customDifficultyGrades: [String] = [],
|
|
notes: String?,
|
|
isDeleted: Bool = false,
|
|
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.isDeleted = isDeleted
|
|
self.createdAt = createdAt
|
|
self.updatedAt = updatedAt
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
static func createTombstone(id: String, deletedAt: Date) -> BackupGym {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
let dateString = formatter.string(from: deletedAt)
|
|
|
|
return BackupGym(
|
|
id: id,
|
|
name: "DELETED",
|
|
location: nil,
|
|
supportedClimbTypes: [],
|
|
difficultySystems: [],
|
|
customDifficultyGrades: [],
|
|
notes: nil,
|
|
isDeleted: true,
|
|
createdAt: dateString,
|
|
updatedAt: dateString
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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 isDeleted: Bool?
|
|
let createdAt: String
|
|
let updatedAt: String
|
|
|
|
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
|
|
self.isDeleted = false // Default to false until model is updated
|
|
|
|
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)
|
|
}
|
|
|
|
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?,
|
|
isDeleted: Bool = false,
|
|
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.isDeleted = isDeleted
|
|
self.createdAt = createdAt
|
|
self.updatedAt = updatedAt
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
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,
|
|
isDeleted: self.isDeleted ?? false,
|
|
createdAt: self.createdAt,
|
|
updatedAt: self.updatedAt
|
|
)
|
|
}
|
|
|
|
static func createTombstone(id: String, gymId: String = UUID().uuidString, deletedAt: Date) -> BackupProblem {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
let dateString = formatter.string(from: deletedAt)
|
|
|
|
return BackupProblem(
|
|
id: id,
|
|
gymId: gymId,
|
|
name: "DELETED",
|
|
description: nil,
|
|
climbType: ClimbType.allCases.first!,
|
|
difficulty: DifficultyGrade(system: DifficultySystem.allCases.first!, grade: "0"),
|
|
tags: [],
|
|
location: nil,
|
|
imagePaths: nil,
|
|
isActive: false,
|
|
dateSet: nil,
|
|
notes: nil,
|
|
isDeleted: true,
|
|
createdAt: dateString,
|
|
updatedAt: dateString
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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 isDeleted: Bool?
|
|
let createdAt: String
|
|
let updatedAt: String
|
|
|
|
init(from session: ClimbSession) {
|
|
self.id = session.id.uuidString
|
|
self.gymId = session.gymId.uuidString
|
|
self.status = session.status
|
|
self.notes = session.notes
|
|
self.isDeleted = false // Default to false until model is updated
|
|
|
|
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)
|
|
}
|
|
|
|
init(
|
|
id: String,
|
|
gymId: String,
|
|
date: String,
|
|
startTime: String?,
|
|
endTime: String?,
|
|
duration: Int64?,
|
|
status: SessionStatus,
|
|
notes: String?,
|
|
isDeleted: Bool = false,
|
|
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.isDeleted = isDeleted
|
|
self.createdAt = createdAt
|
|
self.updatedAt = updatedAt
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
static func createTombstone(id: String, gymId: String = UUID().uuidString, deletedAt: Date) -> BackupClimbSession {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
let dateString = formatter.string(from: deletedAt)
|
|
|
|
return BackupClimbSession(
|
|
id: id,
|
|
gymId: gymId,
|
|
date: dateString,
|
|
startTime: nil,
|
|
endTime: nil,
|
|
duration: nil,
|
|
status: .completed,
|
|
notes: nil,
|
|
isDeleted: true,
|
|
createdAt: dateString,
|
|
updatedAt: dateString
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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 isDeleted: Bool?
|
|
let createdAt: String
|
|
let updatedAt: String?
|
|
|
|
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) }
|
|
self.isDeleted = false // Default to false until model is updated
|
|
|
|
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)
|
|
}
|
|
|
|
init(
|
|
id: String,
|
|
sessionId: String,
|
|
problemId: String,
|
|
result: AttemptResult,
|
|
highestHold: String?,
|
|
notes: String?,
|
|
duration: Int64?,
|
|
restTime: Int64?,
|
|
timestamp: String,
|
|
isDeleted: Bool = false,
|
|
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.isDeleted = isDeleted
|
|
self.createdAt = createdAt
|
|
self.updatedAt = updatedAt
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
static func createTombstone(id: String, sessionId: String = UUID().uuidString, problemId: String = UUID().uuidString, deletedAt: Date) -> BackupAttempt {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
let dateString = formatter.string(from: deletedAt)
|
|
|
|
return BackupAttempt(
|
|
id: id,
|
|
sessionId: sessionId,
|
|
problemId: problemId,
|
|
result: AttemptResult.allCases.first!,
|
|
highestHold: nil,
|
|
notes: nil,
|
|
duration: nil,
|
|
restTime: nil,
|
|
timestamp: dateString,
|
|
isDeleted: true,
|
|
createdAt: dateString,
|
|
updatedAt: dateString
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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<T>(_ transform: (Wrapped) -> T) -> T? {
|
|
return self.flatMap { .some(transform($0)) }
|
|
}
|
|
}
|