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 // ".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 = 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 } }