Seed starter splits deterministically with immutable clone-on-edit seeds
Starter splits ship as byte-canonical SplitDocument JSON with fixed ULIDs (Workouts/Resources/StarterSplits, regenerated by Scripts/generate_starter_splits.swift) and auto-seed after connect into a verifiably empty container, re-checked after a settle delay — wrong guesses are harmless because identical bytes make same-path conflicts empty and tombstones reap resurrected seeds. Seeds are immutable: SyncEngine.save(split:) forks an edited seed to a fresh ULID and soft-deletes the original, whose stub is exempt from pruning (IndieSync 0.3.0 prune(exempting:)) and vetoes resurrection forever; split views resolve by id through a redirect map to follow the swap. Add Starter Splits in Settings restores deleted seeds by lifting the veto stub and rewriting the bundle bytes. Also fixes ingestFromWatch bypassing the tombstone veto (a phone-deleted workout resurrected when a stale watch resent it) and reaps a live file immediately when its tombstone arrives. SplitDetailView also picks up the category-grouped exercise sections from the exercise-category work.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
// Generates the bundled starter-split JSON resources for Workouts.
|
||||
// Deterministic: fixed ULID timestamp (2020-01-01T00:00:00Z) + SHA256-of-label
|
||||
// randomness, fixed document timestamps, encoder matching DocumentCoder
|
||||
// (prettyPrinted + sortedKeys + iso8601). Re-running always emits identical bytes.
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - ULID (Crockford base32, 48-bit ms timestamp + 80 deterministic bits)
|
||||
|
||||
let crockford = Array("0123456789ABCDEFGHJKMNPQRSTVWXYZ")
|
||||
|
||||
func deterministicULID(label: String) -> String {
|
||||
let timestampMS: UInt64 = 1_577_836_800_000 // 2020-01-01T00:00:00Z
|
||||
var bytes = [UInt8]()
|
||||
for shift in stride(from: 40, through: 0, by: -8) {
|
||||
bytes.append(UInt8((timestampMS >> UInt64(shift)) & 0xFF))
|
||||
}
|
||||
let digest = SHA256.hash(data: Data(label.utf8))
|
||||
bytes.append(contentsOf: Array(digest).prefix(10))
|
||||
precondition(bytes.count == 16)
|
||||
|
||||
// Encode 128 bits as 26 Crockford base32 chars: big-int division by 32, 26 digits.
|
||||
var chars = [Character]()
|
||||
var num = bytes
|
||||
for _ in 0..<26 {
|
||||
var remainder = 0
|
||||
var quotient = [UInt8]()
|
||||
for byte in num {
|
||||
let acc = remainder * 256 + Int(byte)
|
||||
quotient.append(UInt8(acc / 32))
|
||||
remainder = acc % 32
|
||||
}
|
||||
chars.append(crockford[remainder])
|
||||
num = quotient
|
||||
}
|
||||
return String(chars.reversed())
|
||||
}
|
||||
|
||||
// MARK: - Document shapes (field names must mirror Shared/Model/Documents.swift)
|
||||
|
||||
struct ExerciseDocument: Codable {
|
||||
var id: String
|
||||
var name: String
|
||||
var order: Int
|
||||
var sets: Int
|
||||
var reps: Int
|
||||
var weight: Int
|
||||
var loadType: Int
|
||||
var durationSeconds: Int
|
||||
var weightLastUpdated: Date?
|
||||
var weightReminderWeeks: Int
|
||||
var category: Int?
|
||||
}
|
||||
|
||||
struct SplitDocument: Codable {
|
||||
var schemaVersion: Int
|
||||
var id: String
|
||||
var name: String
|
||||
var color: String
|
||||
var systemImage: String
|
||||
var order: Int
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
var exercises: [ExerciseDocument]
|
||||
var activityType: Int?
|
||||
}
|
||||
|
||||
// MARK: - Seed data (canonical source for Workouts/Resources/StarterSplits/*.json)
|
||||
|
||||
struct Ex { let name: String; var weight = 0; var sets = 4; var reps = 10; var load = 1; var dur = 0; var cat = 0 }
|
||||
struct Sp { let name: String; let color: String; let icon: String; let activity: Int; let ex: [Ex] }
|
||||
|
||||
let seeds: [Sp] = [
|
||||
Sp(name: "Upper Body", color: "blue", icon: "figure.strengthtraining.traditional", activity: 0, ex: [
|
||||
Ex(name: "Lat Pull Down", weight: 110),
|
||||
Ex(name: "Tricep Press", weight: 100),
|
||||
Ex(name: "Chest Press", weight: 40),
|
||||
Ex(name: "Seated Row", weight: 90),
|
||||
]),
|
||||
Sp(name: "Core", color: "orange", icon: "figure.core.training", activity: 3, ex: [
|
||||
Ex(name: "Abdominal", weight: 0),
|
||||
Ex(name: "Rotary", weight: 0),
|
||||
]),
|
||||
Sp(name: "Lower Body", color: "green", icon: "figure.run", activity: 0, ex: [
|
||||
Ex(name: "Abductor", weight: 140),
|
||||
Ex(name: "Adductor", weight: 140),
|
||||
Ex(name: "Leg Press", weight: 160),
|
||||
Ex(name: "Leg Curl", weight: 70),
|
||||
]),
|
||||
Sp(name: "Bodyweight Core", color: "teal", icon: "figure.core.training", activity: 3, ex: [
|
||||
Ex(name: "Cat-Cow", sets: 1, reps: 10, load: 0, cat: 1),
|
||||
Ex(name: "Dead Bug", sets: 1, reps: 8, load: 0, cat: 1),
|
||||
Ex(name: "Bird Dog", sets: 1, reps: 8, load: 0, cat: 1),
|
||||
Ex(name: "Plank", sets: 3, reps: 0, load: 2, dur: 45),
|
||||
Ex(name: "Hollow Body Hold", sets: 3, reps: 0, load: 2, dur: 30),
|
||||
Ex(name: "Side Plank", sets: 3, reps: 0, load: 2, dur: 30),
|
||||
Ex(name: "Leg Raises", sets: 3, reps: 12, load: 0),
|
||||
Ex(name: "Bird Dog", sets: 3, reps: 8, load: 0),
|
||||
Ex(name: "Reverse Crunch", sets: 3, reps: 12, load: 0),
|
||||
]),
|
||||
]
|
||||
|
||||
// MARK: - Emit
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
|
||||
let fixedDate = ISO8601DateFormatter().date(from: "2020-01-01T00:00:00Z")!
|
||||
let outDir = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true)
|
||||
|
||||
for (order, sp) in seeds.enumerated() {
|
||||
let slug = sp.name.lowercased().replacingOccurrences(of: " ", with: "-")
|
||||
let doc = SplitDocument(
|
||||
schemaVersion: 1,
|
||||
id: deterministicULID(label: "starter.\(slug)"),
|
||||
name: sp.name, color: sp.color, systemImage: sp.icon, order: order,
|
||||
createdAt: fixedDate, updatedAt: fixedDate,
|
||||
exercises: sp.ex.enumerated().map { idx, e in
|
||||
ExerciseDocument(
|
||||
id: deterministicULID(label: "starter.\(slug).ex\(idx)"),
|
||||
name: e.name, order: idx, sets: e.sets, reps: e.reps, weight: e.weight,
|
||||
loadType: e.load, durationSeconds: e.dur, weightLastUpdated: nil,
|
||||
weightReminderWeeks: 2, category: e.cat)
|
||||
},
|
||||
activityType: sp.activity)
|
||||
let data = try encoder.encode(doc)
|
||||
let file = outDir.appendingPathComponent("\(sp.name).split.json")
|
||||
try data.write(to: file)
|
||||
print("\(doc.id) \(sp.name) (\(sp.ex.count) exercises)")
|
||||
}
|
||||
Reference in New Issue
Block a user