Files
workouts/Scripts/generate_starter_splits.swift
T
rzen 936a585ece 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.
2026-07-06 01:16:05 -04:00

133 lines
5.1 KiB
Swift

// 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)")
}