Drag-to-reorder writes Routine.order through the document, which used to read as a content edit: isPristine would fork a starter for a mere reorder, and reconcile's semantic compare would clobber a reordered seed file back to bundle order. Both now normalize order (and updatedAt) away — a fixed- ULID file still never holds user content; ordering is bookkeeping. SyncEngine gains restoreSeed(id:) — the per-seed analog of the bulk restore, sharing one restoreSeedIfEligible core — and duplicate(routine:), which copies any routine (starter or not) to a fresh ULID with fresh exercise ids, a unique "… Copy" name, and last position in the list. Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
75 lines
3.8 KiB
Swift
75 lines
3.8 KiB
Swift
import Foundation
|
|
import IndieSync
|
|
|
|
/// The bundled immutable starter-routine library. Each seed ships as byte-canonical
|
|
/// `RoutineDocument` JSON in `Resources/StarterSplits/*.split.json` (the resource
|
|
/// directory and file extension keep their legacy "split" spelling — bundled asset
|
|
/// names are pinned, see below), keyed by a fixed ULID (the shared `01DXF6DT00`
|
|
/// prefix, minted from a frozen 2020 timestamp) so every install and device agrees
|
|
/// on the same identity.
|
|
///
|
|
/// Seeds are never mutated in place: editing one clones it to a fresh random ULID
|
|
/// and soft-deletes the seed (see `SyncEngine.save(routine:)`). The seed's tombstone
|
|
/// is exempt from pruning, so it vetoes resurrection forever; the app bundle — not
|
|
/// the stub — is the canonical restore source, since the seed content is immutable.
|
|
enum SeedLibrary {
|
|
/// One bundled seed: its fixed id, decoded document, and the verbatim bundle
|
|
/// bytes. The bytes are written to iCloud unchanged so the same seed is
|
|
/// byte-identical on every device — a same-path conflict between two devices
|
|
/// seeding at once is then semantically empty (both wrote the same bytes).
|
|
struct Seed: Sendable {
|
|
let id: String
|
|
let doc: RoutineDocument
|
|
let data: Data
|
|
}
|
|
|
|
/// Resolves the enclosing bundle in both the app and a hosted XCTest bundle
|
|
/// (whose `Bundle.main` is the test runner, not the app), where the flattened
|
|
/// resources actually live.
|
|
private final class BundleToken {}
|
|
|
|
/// Every bundled seed, decoded once and cached, in display order.
|
|
static let seeds: [Seed] = {
|
|
let bundle = Bundle(for: BundleToken.self)
|
|
// XcodeGen flattens resource groups, so the files land in the bundle root as
|
|
// "<Name>.split.json" — enumerate all json and filter on the compound suffix.
|
|
// PINNED: the bundled resource extension stays ".split.json" (asset names in
|
|
// StarterSplits/ are shipped files, not symbols — the rename must not touch them).
|
|
let urls = (bundle.urls(forResourcesWithExtension: "json", subdirectory: nil) ?? [])
|
|
.filter { $0.lastPathComponent.hasSuffix(".split.json") }
|
|
let loaded: [Seed] = urls.compactMap { url in
|
|
guard let data = try? Data(contentsOf: url),
|
|
let doc = try? DocumentCoder.decode(RoutineDocument.self, from: data)
|
|
else { return nil }
|
|
return Seed(id: doc.id, doc: doc, data: data)
|
|
}
|
|
return loaded.sorted { $0.doc.order < $1.doc.order }
|
|
}()
|
|
|
|
static let seedIDs: Set<String> = Set(seeds.map(\.id))
|
|
|
|
static func isSeed(id: String) -> Bool { seedIDs.contains(id) }
|
|
|
|
static func seed(id: String) -> Seed? { seeds.first { $0.id == id } }
|
|
|
|
/// True when `doc` is unchanged from its pristine seed in every field that counts
|
|
/// as *content* — i.e. ignoring `updatedAt` and `order`, both of which are
|
|
/// presentation/bookkeeping rather than content. The edit sheets stamp `updatedAt`
|
|
/// on every Save, so a no-op save would otherwise read as an edit and fork the seed;
|
|
/// and dragging a starter to reorder the list writes a new `order` to it — a reorder
|
|
/// must NOT fork a curated starter into a user routine. Normalizing both fields to a
|
|
/// common value before the `==` isolates real content changes. A non-seed id has no
|
|
/// seed to compare against — treated as pristine (callers gate on `isSeed` first).
|
|
static func isPristine(_ doc: RoutineDocument) -> Bool {
|
|
guard let seed = seed(id: doc.id) else { return true }
|
|
var edited = doc
|
|
var pristine = seed.doc
|
|
let commonDate = Date(timeIntervalSince1970: 0)
|
|
edited.updatedAt = commonDate
|
|
pristine.updatedAt = commonDate
|
|
edited.order = 0
|
|
pristine.order = 0
|
|
return edited == pristine
|
|
}
|
|
}
|