Workouts 2.0: re-base persistence on iCloud Drive documents
Replace Core Data + NSPersistentCloudKitContainer + App-Group store + WatchConnectivity dictionary sync with the QuickRabbit iCloud-documents architecture: - iCloud Drive JSON documents are the sole source of truth (one file per aggregate: Splits/<ULID>.json, Workouts/YYYY/MM/<ULID>.json), with a rebuildable SwiftData cache populated only by an NSMetadataQuery observer and a connect-time reconcile. Soft-delete tombstones; hard iCloud gate. - Shared model layer (ULID, Codable *Documents + stateless mappers, @Model cache entities, SwiftData container) compiled into both targets. - New iPhone<->Watch bridge over WatchConnectivity keyed by ULIDs; the phone is the sole writer of iCloud Drive, the watch round-trips documents. - AppServices DI + iCloud-required root gate; Swift 6 strict concurrency. - Starter splits generated on demand from the bundled YAML catalogs. - Migrate to XcodeGen (project.yml), iOS 26 / watchOS 26; CloudDocuments entitlement (drop CloudKit/App Group/aps-environment). - Duration stored as Int seconds (was a Date epoch hack); fix workout end-on-create, undismissable delete dialog, toolbar-hiding nav stacks, and the Settings placeholder. - Add README/CHANGELOG/LICENSE, .gitignore, refreshed REQUIREMENTS, and the Scripts/ TestFlight pipeline (release.sh + ASC API scripts). MARKETING_VERSION 2.0.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import Foundation
|
||||
|
||||
/// On-disk JSON shape for each aggregate. Independent from the SwiftData cache
|
||||
/// entities so the wire format can evolve without dragging the cache schema.
|
||||
///
|
||||
/// One document = one aggregate root:
|
||||
/// • `SplitDocument` embeds its `[ExerciseDocument]` → `Splits/<ULID>.json`
|
||||
/// • `WorkoutDocument` embeds its `[WorkoutLogDocument]` → `Workouts/YYYY/MM/<ULID>.json`
|
||||
///
|
||||
/// `schemaVersion` lets us migrate old files on read without forcing a rewrite
|
||||
/// at sync time, and forward-gates files written by a newer app version.
|
||||
/// These documents are also the wire format for the iPhone↔Watch bridge.
|
||||
|
||||
// MARK: - Split
|
||||
|
||||
struct SplitDocument: Codable, Sendable, Equatable, Identifiable {
|
||||
var schemaVersion: Int
|
||||
var id: String // ULID
|
||||
var name: String
|
||||
var color: String
|
||||
var systemImage: String
|
||||
var order: Int
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
var exercises: [ExerciseDocument]
|
||||
|
||||
static let currentSchema = 1
|
||||
|
||||
var relativePath: String { "Splits/\(id).json" }
|
||||
}
|
||||
|
||||
struct ExerciseDocument: Codable, Sendable, Equatable, Identifiable {
|
||||
var id: String // ULID
|
||||
var name: String
|
||||
var order: Int
|
||||
var sets: Int
|
||||
var reps: Int
|
||||
var weight: Int
|
||||
var loadType: Int
|
||||
var durationSeconds: Int // total seconds (0 when not a timed exercise)
|
||||
var weightLastUpdated: Date?
|
||||
var weightReminderWeeks: Int
|
||||
}
|
||||
|
||||
// MARK: - Workout
|
||||
|
||||
struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
|
||||
var schemaVersion: Int
|
||||
var id: String // ULID (chronological)
|
||||
var splitID: String?
|
||||
var splitName: String?
|
||||
var start: Date
|
||||
var end: Date?
|
||||
var status: String // WorkoutStatus raw value
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
var logs: [WorkoutLogDocument]
|
||||
|
||||
static let currentSchema = 1
|
||||
|
||||
var relativePath: String { Self.relativePath(id: id, start: start) }
|
||||
|
||||
static func relativePath(id: String, start: Date) -> String {
|
||||
let cal = Calendar(identifier: .gregorian)
|
||||
let comps = cal.dateComponents([.year, .month], from: start)
|
||||
let year = comps.year ?? 1970
|
||||
let month = comps.month ?? 1
|
||||
return String(format: "Workouts/%04d/%02d/%@.json", year, month, id)
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable {
|
||||
var id: String // ULID
|
||||
var exerciseName: String
|
||||
var order: Int
|
||||
var sets: Int
|
||||
var reps: Int
|
||||
var weight: Int
|
||||
var loadType: Int
|
||||
var durationSeconds: Int // total seconds (0 when not a timed exercise)
|
||||
var currentStateIndex: Int
|
||||
var completed: Bool
|
||||
var status: String // WorkoutStatus raw value
|
||||
var notes: String?
|
||||
var date: Date
|
||||
}
|
||||
|
||||
// MARK: - Forward-compatibility gate
|
||||
|
||||
/// A file whose `schemaVersion` exceeds the reader's `currentSchema` was written
|
||||
/// by a newer app version; it must be quarantined, not partially decoded (Codable
|
||||
/// silently drops unknown keys) and later rewritten — which would downgrade it
|
||||
/// and lose the newer fields.
|
||||
protocol VersionedDocument {
|
||||
var schemaVersion: Int { get }
|
||||
static var currentSchema: Int { get }
|
||||
}
|
||||
|
||||
extension SplitDocument: VersionedDocument {}
|
||||
extension WorkoutDocument: VersionedDocument {}
|
||||
|
||||
extension VersionedDocument {
|
||||
/// True if this build can safely read (and rewrite) the document.
|
||||
var isReadable: Bool { schemaVersion <= Self.currentSchema }
|
||||
}
|
||||
|
||||
// MARK: - Soft-delete tombstone
|
||||
|
||||
/// Lives at `Stubs/<id>.json` and tells every device "this aggregate has been
|
||||
/// deleted" — important when a remote device missed the `.removed` event for the
|
||||
/// live file because it was offline. After `gracePeriod` any device can prune it.
|
||||
struct Tombstone: Codable, Sendable, Equatable {
|
||||
enum Kind: String, Codable, Sendable {
|
||||
case split
|
||||
case workout
|
||||
}
|
||||
|
||||
var id: String // the aggregate's ULID
|
||||
var kind: Kind
|
||||
var deletedAt: Date
|
||||
|
||||
var relativePath: String { "Stubs/\(id).json" }
|
||||
|
||||
static let gracePeriod: TimeInterval = 30 * 24 * 60 * 60
|
||||
}
|
||||
|
||||
// MARK: - JSON coding
|
||||
|
||||
/// Shared encoder/decoder. ISO-8601 dates and sorted keys for human-readable,
|
||||
/// diff-friendly files when the container is browsed via the Files app.
|
||||
enum DocumentCoder {
|
||||
static let encoder: JSONEncoder = {
|
||||
let e = JSONEncoder()
|
||||
e.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
e.dateEncodingStrategy = .iso8601
|
||||
return e
|
||||
}()
|
||||
|
||||
static let decoder: JSONDecoder = {
|
||||
let d = JSONDecoder()
|
||||
d.dateDecodingStrategy = .iso8601
|
||||
return d
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
// SwiftData cache entities. These are a rebuildable read-through cache of the
|
||||
// iCloud Drive JSON documents — never the source of truth. App code reads them
|
||||
// (via @Query); all writes go through `SyncEngine` to the document files, and the
|
||||
// metadata observer mirrors the files back into these entities (see Mappers).
|
||||
//
|
||||
// `id` is the aggregate/child ULID (stable across cache rebuilds, unlike the
|
||||
// SwiftData PersistentIdentifier). Computed helpers preserve the API the views
|
||||
// used against the old Core Data classes.
|
||||
|
||||
// MARK: - Split
|
||||
|
||||
@Model
|
||||
final class Split {
|
||||
@Attribute(.unique) var id: String = ULID.make()
|
||||
var name: String = ""
|
||||
var color: String = "indigo"
|
||||
var systemImage: String = "dumbbell.fill"
|
||||
var order: Int = 0
|
||||
var createdAt: Date = Date()
|
||||
var updatedAt: Date = Date()
|
||||
var jsonRelativePath: String = ""
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \Exercise.split)
|
||||
var exercises: [Exercise] = []
|
||||
|
||||
init(id: String, name: String, color: String, systemImage: String, order: Int,
|
||||
createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.color = color
|
||||
self.systemImage = systemImage
|
||||
self.order = order
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.jsonRelativePath = jsonRelativePath
|
||||
}
|
||||
|
||||
static let unnamed = "Unnamed Split"
|
||||
|
||||
var exercisesArray: [Exercise] { exercises.sorted { $0.order < $1.order } }
|
||||
}
|
||||
|
||||
// MARK: - Exercise
|
||||
|
||||
@Model
|
||||
final class Exercise {
|
||||
@Attribute(.unique) var id: String = ULID.make()
|
||||
var name: String = ""
|
||||
var order: Int = 0
|
||||
var sets: Int = 0
|
||||
var reps: Int = 0
|
||||
var weight: Int = 0
|
||||
var loadType: Int = LoadType.weight.rawValue
|
||||
var durationTotalSeconds: Int = 0
|
||||
var weightLastUpdated: Date?
|
||||
var weightReminderTimeIntervalWeeks: Int = 2
|
||||
|
||||
var split: Split?
|
||||
|
||||
init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Int,
|
||||
loadType: Int, durationTotalSeconds: Int, weightLastUpdated: Date?,
|
||||
weightReminderTimeIntervalWeeks: Int) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.order = order
|
||||
self.sets = sets
|
||||
self.reps = reps
|
||||
self.weight = weight
|
||||
self.loadType = loadType
|
||||
self.durationTotalSeconds = durationTotalSeconds
|
||||
self.weightLastUpdated = weightLastUpdated
|
||||
self.weightReminderTimeIntervalWeeks = weightReminderTimeIntervalWeeks
|
||||
}
|
||||
|
||||
var loadTypeEnum: LoadType {
|
||||
get { LoadType(rawValue: loadType) ?? .weight }
|
||||
set { loadType = newValue.rawValue }
|
||||
}
|
||||
|
||||
/// Minutes component of the total duration (for min:sec pickers).
|
||||
var durationMinutes: Int {
|
||||
get { durationTotalSeconds / 60 }
|
||||
set { durationTotalSeconds = newValue * 60 + durationSeconds }
|
||||
}
|
||||
|
||||
/// Seconds component (0–59) of the total duration.
|
||||
var durationSeconds: Int {
|
||||
get { durationTotalSeconds % 60 }
|
||||
set { durationTotalSeconds = durationMinutes * 60 + newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout
|
||||
|
||||
@Model
|
||||
final class Workout {
|
||||
@Attribute(.unique) var id: String = ULID.make()
|
||||
var splitID: String?
|
||||
var splitName: String?
|
||||
var start: Date = Date()
|
||||
var end: Date?
|
||||
var statusRaw: String = WorkoutStatus.notStarted.rawValue
|
||||
var createdAt: Date = Date()
|
||||
var updatedAt: Date = Date()
|
||||
var jsonRelativePath: String = ""
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout)
|
||||
var logs: [WorkoutLog] = []
|
||||
|
||||
init(id: String, splitID: String?, splitName: String?, start: Date, end: Date?,
|
||||
statusRaw: String, createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
|
||||
self.id = id
|
||||
self.splitID = splitID
|
||||
self.splitName = splitName
|
||||
self.start = start
|
||||
self.end = end
|
||||
self.statusRaw = statusRaw
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.jsonRelativePath = jsonRelativePath
|
||||
}
|
||||
|
||||
var status: WorkoutStatus {
|
||||
get { WorkoutStatus(rawValue: statusRaw) ?? .notStarted }
|
||||
set { statusRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var statusName: String { status.displayName }
|
||||
|
||||
var logsArray: [WorkoutLog] { logs.sorted { $0.order < $1.order } }
|
||||
|
||||
var label: String {
|
||||
if status == .completed, let endDate = end {
|
||||
if start.isSameDay(as: endDate) {
|
||||
return "\(start.formattedDate())—\(endDate.formattedTime())"
|
||||
} else {
|
||||
return "\(start.formattedDate())—\(endDate.formattedDate())"
|
||||
}
|
||||
} else {
|
||||
return start.formattedDate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WorkoutLog
|
||||
|
||||
@Model
|
||||
final class WorkoutLog {
|
||||
@Attribute(.unique) var id: String = ULID.make()
|
||||
var exerciseName: String = ""
|
||||
var order: Int = 0
|
||||
var sets: Int = 0
|
||||
var reps: Int = 0
|
||||
var weight: Int = 0
|
||||
var loadType: Int = LoadType.weight.rawValue
|
||||
var durationTotalSeconds: Int = 0
|
||||
var currentStateIndex: Int = 0
|
||||
var completed: Bool = false
|
||||
var statusRaw: String = WorkoutStatus.notStarted.rawValue
|
||||
var notes: String?
|
||||
var date: Date = Date()
|
||||
|
||||
var workout: Workout?
|
||||
|
||||
init(id: String, exerciseName: String, order: Int, sets: Int, reps: Int, weight: Int,
|
||||
loadType: Int, durationTotalSeconds: Int, currentStateIndex: Int, completed: Bool,
|
||||
statusRaw: String, notes: String?, date: Date) {
|
||||
self.id = id
|
||||
self.exerciseName = exerciseName
|
||||
self.order = order
|
||||
self.sets = sets
|
||||
self.reps = reps
|
||||
self.weight = weight
|
||||
self.loadType = loadType
|
||||
self.durationTotalSeconds = durationTotalSeconds
|
||||
self.currentStateIndex = currentStateIndex
|
||||
self.completed = completed
|
||||
self.statusRaw = statusRaw
|
||||
self.notes = notes
|
||||
self.date = date
|
||||
}
|
||||
|
||||
var status: WorkoutStatus {
|
||||
get { WorkoutStatus(rawValue: statusRaw) ?? .notStarted }
|
||||
set { statusRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var loadTypeEnum: LoadType {
|
||||
get { LoadType(rawValue: loadType) ?? .weight }
|
||||
set { loadType = newValue.rawValue }
|
||||
}
|
||||
|
||||
var durationMinutes: Int {
|
||||
get { durationTotalSeconds / 60 }
|
||||
set { durationTotalSeconds = newValue * 60 + durationSeconds }
|
||||
}
|
||||
|
||||
var durationSeconds: Int {
|
||||
get { durationTotalSeconds % 60 }
|
||||
set { durationTotalSeconds = durationMinutes * 60 + newValue }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
/// Completion state of a workout or an individual exercise log. Persisted as its
|
||||
/// raw string in both the JSON documents and the SwiftData cache.
|
||||
enum WorkoutStatus: String, CaseIterable, Codable, Sendable {
|
||||
case notStarted
|
||||
case inProgress
|
||||
case completed
|
||||
case skipped
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .notStarted: "Not Started"
|
||||
case .inProgress: "In Progress"
|
||||
case .completed: "Completed"
|
||||
case .skipped: "Skipped"
|
||||
}
|
||||
}
|
||||
|
||||
var name: String { displayName }
|
||||
}
|
||||
|
||||
/// How an exercise's effort is measured. Persisted as its raw `Int` value.
|
||||
enum LoadType: Int, CaseIterable, Codable, Sendable {
|
||||
case none = 0
|
||||
case weight = 1
|
||||
case duration = 2
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .none: "None"
|
||||
case .weight: "Weight"
|
||||
case .duration: "Duration"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
// Stateless translation between the on-disk `*Document` wire format and the
|
||||
// SwiftData cache entities. The only code that knows both shapes.
|
||||
//
|
||||
// • `init(from: entity)` — cache → document. Used by the write path (build a
|
||||
// document to save) and the iPhone↔Watch bridge.
|
||||
// • `CacheMapper.upsert*` — document → cache. Used ONLY by the metadata
|
||||
// observer (the sole cache mutator) and cache rebuilds. Embedded children
|
||||
// are reconciled by id (update / insert / delete) so frequent in-workout
|
||||
// edits don't churn unrelated rows.
|
||||
|
||||
// MARK: - Cache → Document
|
||||
|
||||
extension ExerciseDocument {
|
||||
init(from e: Exercise) {
|
||||
self.init(id: e.id, name: e.name, order: e.order, sets: e.sets, reps: e.reps,
|
||||
weight: e.weight, loadType: e.loadType, durationSeconds: e.durationTotalSeconds,
|
||||
weightLastUpdated: e.weightLastUpdated,
|
||||
weightReminderWeeks: e.weightReminderTimeIntervalWeeks)
|
||||
}
|
||||
}
|
||||
|
||||
extension SplitDocument {
|
||||
init(from split: Split) {
|
||||
self.init(schemaVersion: Self.currentSchema, id: split.id, name: split.name,
|
||||
color: split.color, systemImage: split.systemImage, order: split.order,
|
||||
createdAt: split.createdAt, updatedAt: split.updatedAt,
|
||||
exercises: split.exercisesArray.map(ExerciseDocument.init(from:)))
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkoutLogDocument {
|
||||
init(from log: WorkoutLog) {
|
||||
self.init(id: log.id, exerciseName: log.exerciseName, order: log.order, sets: log.sets,
|
||||
reps: log.reps, weight: log.weight, loadType: log.loadType,
|
||||
durationSeconds: log.durationTotalSeconds, currentStateIndex: log.currentStateIndex,
|
||||
completed: log.completed, status: log.statusRaw, notes: log.notes, date: log.date)
|
||||
}
|
||||
}
|
||||
|
||||
extension WorkoutDocument {
|
||||
init(from workout: Workout) {
|
||||
self.init(schemaVersion: Self.currentSchema, id: workout.id, splitID: workout.splitID,
|
||||
splitName: workout.splitName, start: workout.start, end: workout.end,
|
||||
status: workout.statusRaw, createdAt: workout.createdAt, updatedAt: workout.updatedAt,
|
||||
logs: workout.logsArray.map(WorkoutLogDocument.init(from:)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Document → Cache (upsert)
|
||||
|
||||
enum CacheMapper {
|
||||
static func fetchSplit(id: String, in context: ModelContext) -> Split? {
|
||||
var d = FetchDescriptor<Split>(predicate: #Predicate { $0.id == id })
|
||||
d.fetchLimit = 1
|
||||
return try? context.fetch(d).first
|
||||
}
|
||||
|
||||
static func fetchWorkout(id: String, in context: ModelContext) -> Workout? {
|
||||
var d = FetchDescriptor<Workout>(predicate: #Predicate { $0.id == id })
|
||||
d.fetchLimit = 1
|
||||
return try? context.fetch(d).first
|
||||
}
|
||||
|
||||
// MARK: Split
|
||||
|
||||
static func upsertSplit(_ doc: SplitDocument, relativePath: String, into context: ModelContext) {
|
||||
let split: Split
|
||||
if let existing = fetchSplit(id: doc.id, in: context) {
|
||||
split = existing
|
||||
} else {
|
||||
split = Split(id: doc.id, name: doc.name, color: doc.color, systemImage: doc.systemImage,
|
||||
order: doc.order, createdAt: doc.createdAt, updatedAt: doc.updatedAt,
|
||||
jsonRelativePath: relativePath)
|
||||
context.insert(split)
|
||||
}
|
||||
split.name = doc.name
|
||||
split.color = doc.color
|
||||
split.systemImage = doc.systemImage
|
||||
split.order = doc.order
|
||||
split.createdAt = doc.createdAt
|
||||
split.updatedAt = doc.updatedAt
|
||||
split.jsonRelativePath = relativePath
|
||||
|
||||
let existing = Dictionary(split.exercises.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
var keep = Set<String>()
|
||||
for ed in doc.exercises {
|
||||
keep.insert(ed.id)
|
||||
if let e = existing[ed.id] {
|
||||
apply(ed, to: e)
|
||||
} else {
|
||||
let e = makeExercise(ed)
|
||||
e.split = split
|
||||
context.insert(e)
|
||||
}
|
||||
}
|
||||
for e in Array(split.exercises) where !keep.contains(e.id) {
|
||||
context.delete(e)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeExercise(_ d: ExerciseDocument) -> Exercise {
|
||||
Exercise(id: d.id, name: d.name, order: d.order, sets: d.sets, reps: d.reps, weight: d.weight,
|
||||
loadType: d.loadType, durationTotalSeconds: d.durationSeconds,
|
||||
weightLastUpdated: d.weightLastUpdated,
|
||||
weightReminderTimeIntervalWeeks: d.weightReminderWeeks)
|
||||
}
|
||||
|
||||
private static func apply(_ d: ExerciseDocument, to e: Exercise) {
|
||||
e.name = d.name
|
||||
e.order = d.order
|
||||
e.sets = d.sets
|
||||
e.reps = d.reps
|
||||
e.weight = d.weight
|
||||
e.loadType = d.loadType
|
||||
e.durationTotalSeconds = d.durationSeconds
|
||||
e.weightLastUpdated = d.weightLastUpdated
|
||||
e.weightReminderTimeIntervalWeeks = d.weightReminderWeeks
|
||||
}
|
||||
|
||||
// MARK: Workout
|
||||
|
||||
static func upsertWorkout(_ doc: WorkoutDocument, relativePath: String, into context: ModelContext) {
|
||||
let workout: Workout
|
||||
if let existing = fetchWorkout(id: doc.id, in: context) {
|
||||
workout = existing
|
||||
} else {
|
||||
workout = Workout(id: doc.id, splitID: doc.splitID, splitName: doc.splitName,
|
||||
start: doc.start, end: doc.end, statusRaw: doc.status,
|
||||
createdAt: doc.createdAt, updatedAt: doc.updatedAt,
|
||||
jsonRelativePath: relativePath)
|
||||
context.insert(workout)
|
||||
}
|
||||
workout.splitID = doc.splitID
|
||||
workout.splitName = doc.splitName
|
||||
workout.start = doc.start
|
||||
workout.end = doc.end
|
||||
workout.statusRaw = doc.status
|
||||
workout.createdAt = doc.createdAt
|
||||
workout.updatedAt = doc.updatedAt
|
||||
workout.jsonRelativePath = relativePath
|
||||
|
||||
let existing = Dictionary(workout.logs.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
var keep = Set<String>()
|
||||
for ld in doc.logs {
|
||||
keep.insert(ld.id)
|
||||
if let l = existing[ld.id] {
|
||||
apply(ld, to: l)
|
||||
} else {
|
||||
let l = makeLog(ld)
|
||||
l.workout = workout
|
||||
context.insert(l)
|
||||
}
|
||||
}
|
||||
for l in Array(workout.logs) where !keep.contains(l.id) {
|
||||
context.delete(l)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeLog(_ d: WorkoutLogDocument) -> WorkoutLog {
|
||||
WorkoutLog(id: d.id, exerciseName: d.exerciseName, order: d.order, sets: d.sets, reps: d.reps,
|
||||
weight: d.weight, loadType: d.loadType, durationTotalSeconds: d.durationSeconds,
|
||||
currentStateIndex: d.currentStateIndex, completed: d.completed, statusRaw: d.status,
|
||||
notes: d.notes, date: d.date)
|
||||
}
|
||||
|
||||
private static func apply(_ d: WorkoutLogDocument, to l: WorkoutLog) {
|
||||
l.exerciseName = d.exerciseName
|
||||
l.order = d.order
|
||||
l.sets = d.sets
|
||||
l.reps = d.reps
|
||||
l.weight = d.weight
|
||||
l.loadType = d.loadType
|
||||
l.durationTotalSeconds = d.durationSeconds
|
||||
l.currentStateIndex = d.currentStateIndex
|
||||
l.completed = d.completed
|
||||
l.statusRaw = d.status
|
||||
l.notes = d.notes
|
||||
l.date = d.date
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Foundation
|
||||
|
||||
/// Minimal ULID generator.
|
||||
///
|
||||
/// 26-character Crockford base32 string: 10 chars of millisecond timestamp +
|
||||
/// 16 chars of randomness. Lexicographic order matches chronological order,
|
||||
/// so workout documents sort newest-last by id and file listings stay ordered.
|
||||
///
|
||||
/// Spec: https://github.com/ulid/spec
|
||||
enum ULID {
|
||||
/// Crockford base32 alphabet — no I, L, O, U to avoid visual confusion.
|
||||
private static let alphabet: [Character] = Array("0123456789ABCDEFGHJKMNPQRSTVWXYZ")
|
||||
|
||||
/// Mints a fresh ULID for the current instant.
|
||||
static func make() -> String {
|
||||
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
var randomness = [UInt8](repeating: 0, count: 10)
|
||||
_ = randomness.withUnsafeMutableBytes { buffer in
|
||||
SecRandomCopyBytes(kSecRandomDefault, buffer.count, buffer.baseAddress!)
|
||||
}
|
||||
return encode(timestamp: timestamp, randomness: randomness)
|
||||
}
|
||||
|
||||
/// True if `s` is a 26-char ULID using the Crockford alphabet.
|
||||
static func isValid(_ s: String) -> Bool {
|
||||
guard s.count == 26 else { return false }
|
||||
let allowed = Set(alphabet)
|
||||
return s.allSatisfy { allowed.contains($0) }
|
||||
}
|
||||
|
||||
/// Decodes the timestamp portion of a ULID, if valid.
|
||||
static func timestamp(of ulid: String) -> Date? {
|
||||
guard ulid.count == 26 else { return nil }
|
||||
let prefix = ulid.prefix(10)
|
||||
var value: UInt64 = 0
|
||||
let lookup = Dictionary(uniqueKeysWithValues: alphabet.enumerated().map { ($1, UInt64($0)) })
|
||||
for char in prefix {
|
||||
guard let digit = lookup[char] else { return nil }
|
||||
value = (value << 5) | digit
|
||||
}
|
||||
return Date(timeIntervalSince1970: Double(value) / 1000)
|
||||
}
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
private static func encode(timestamp: UInt64, randomness: [UInt8]) -> String {
|
||||
precondition(randomness.count == 10, "ULID randomness must be 10 bytes (80 bits)")
|
||||
// 48-bit timestamp → 10 base32 chars (50 bits, top 2 unused).
|
||||
var output = [Character](repeating: "0", count: 26)
|
||||
var t = timestamp & ((1 << 48) - 1)
|
||||
for i in stride(from: 9, through: 0, by: -1) {
|
||||
output[i] = alphabet[Int(t & 0x1F)]
|
||||
t >>= 5
|
||||
}
|
||||
// 80-bit randomness → 16 base32 chars.
|
||||
var bigEnd = randomness
|
||||
for i in stride(from: 25, through: 10, by: -1) {
|
||||
var carry: UInt16 = 0
|
||||
for j in 0..<bigEnd.count {
|
||||
let combined = UInt16(bigEnd[j]) | (carry << 8)
|
||||
bigEnd[j] = UInt8(combined >> 5)
|
||||
carry = combined & 0x1F
|
||||
}
|
||||
output[i] = alphabet[Int(UInt8(carry))]
|
||||
}
|
||||
return String(output)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user