import Foundation /// One board template — **a schema-valid board folder that loaded** (09-templates.md ▸ Definition /// format: "A template is itself a board"). /// /// ### There is no template model, only a board model /// /// Everything the chooser shows and everything instantiation reproduces is read off the template /// board's own `index.md`, through the ordinary `BoardLoader`: `title` is the display name, /// `icon`/`iconColor` are the picker icon and what the new board inherits, the **body** is the /// blurb (which becomes the new board's description by simply being copied), and `template.order` /// is the chooser position. This type holds the loaded `BoardModel` and derives those from it — it /// stores no copies, so a template's identity can never drift from its file. /// /// That is 09's "dogfood" clause taken literally: "the template format *is* the board format — no /// second schema, no parallel Swift model to keep in sync". The pathfinder's Swift-struct catalog /// (and the `laneTitles` stub that stood in for it here) is exactly what this replaces. /// /// ### A value you can hold is a template that loaded /// /// The initializer is the load (`TemplateEngine.load(templateAt:origin:)`), so there is no /// "unloadable template" case in this type: an unloadable *user* template is a chooser row, not a /// template — 09 says it is "still listed — by folder name, marked unloadable, carrying the loader's /// fail-fast specifics — but can't be instantiated or previewed", and that listing is the chooser /// card's, built from the loader's error rather than from a half-built value of this type. Anything /// holding a `BoardTemplate` is therefore holding something instantiable. struct BoardTemplate: Identifiable, Sendable, Equatable { /// Which store the template came from — 09's two tiers ("bundled templates by `template.order`, /// then keyed user templates by `template.order`, then keyless user boards last"). Carried /// rather than derived from the URL: the chooser's ordering and its Reveal in Finder affordance /// both turn on the tier, and re-deriving it from a path prefix would be a second answer to a /// question the discovery walk already answered. enum Origin: Sendable, Equatable { case bundled case user } /// The template folder itself — `/Templates/basic.kanban`, or a folder in the user /// store. The instantiation source, and the identity here. let url: URL let origin: Origin /// The template board as the ordinary loader read it. Instantiation does **not** use this — it /// copies the tree on disk — but the chooser's mini per-lane preview renders from it, and every /// derived property below reads it. let model: BoardModel /// A template is identified by where it lives: slugs are stable but not unique across the two /// stores (a user template may legitimately be called `basic.kanban` too), and the chooser's /// selection must never be ambiguous between tiers. var id: String { url.path } /// The stable slug — 09: "The bundle folder name (`basic.kanban`) is the template's stable slug /// (tests, a11y ids)". The extension is dropped because an extension-less board folder is /// equally legal (01-storage-format.md § Document packaging) and `basic` is the name the design /// uses. var slug: String { url.deletingPathExtension().lastPathComponent } /// The display name: the board's `title`, falling back to the folder name — 01-storage-format.md /// § Board naming's rule, unchanged, because a template is a board. var name: String { model.title.value ?? slug } /// The picker blurb — the template board's **body**, which is also what the instantiated board /// carries as its description (09: the body is "shown in the picker *and* becoming the new /// board's description"). Whitespace-trimmed for display only; the bytes on disk are copied /// verbatim by instantiation and never touched here. var blurb: String { model.document.body.trimmingCharacters(in: .whitespacesAndNewlines) } /// The board icon shown in the picker and inherited by the new board — the lenient `icon` rule /// (`ItemSymbol`), so a template naming a symbol this OS cannot draw shows the board default /// rather than an empty box. var icon: String { ItemSymbol.name(model.icon, fallback: ItemSymbol.board) } /// The icon's palette tint, or `nil` for the chrome default — read raw, since `Palette` is the /// one place a colour name is resolved. var iconColor: String? { model.iconColor.value } /// The chooser position — `template.order`, 09's **one** picker key and its only subkey. /// /// Read out of the model's opaque `YAMLValue` rather than through a typed accessor, deliberately: /// 09 keeps the key's shape open ("future subkeys possible"), and `BoardModel.template` is /// carried raw for exactly that reason. `nil` covers every shape that is not a number under /// `order` — a missing key, a hand-dropped board that never had one, a malformed value — which /// is one case to the chooser: 09's keyless tier, sorted by display name. var order: Double? { guard case let .mapping(pairs) = model.template else { return nil } guard let value = pairs.last(where: { $0.key == .string("order") })?.value else { return nil } switch value { case let .int(number): return Double(number) case let .double(number): return number default: return nil } } /// The template's lanes in display order, for the chooser's mini per-lane preview — a real /// `BoardModel`'s lanes, not a list of strings the app maintains by hand. var lanes: [Lane] { model.lanes } } // MARK: - A chooser row /// One row of the template chooser: **a template that loaded, or a folder that didn't**. /// /// This is the either-shape 09-templates.md requires of the chooser and deliberately not of /// `BoardTemplate`: /// /// > **One bad template never fails the chooser** (the user store is hand-editable, so a malformed /// > board there is one edit away): a user template the loader rejects is still listed — by folder /// > name, marked unloadable, carrying the loader's fail-fast specifics — but can't be instantiated /// > or previewed; fix the files and it comes back. (09 ▸ Why this format) /// /// So the discovery walk answers with rows and `TemplateEngine.load` keeps answering with a /// `Result`: anything holding a `BoardTemplate` is still holding something instantiable, and the one /// place a failure is *rendered* is the one place that can carry it — the row. /// /// **The unloadable half is a user-store shape only.** A bundled template that does not load is a /// build defect, logged and skipped (`TemplateEngine.bundledTemplates()`), because nobody looking at /// the chooser can fix an app's own resources. enum TemplateRow: Identifiable, Sendable { case template(BoardTemplate) case unloadable(Unloadable) /// A store folder the loader rejected — the URL and the loader's error, whole and unreworded. struct Unloadable: Identifiable, Sendable, Equatable { let url: URL let error: BoardLoadError var id: String { url.path } /// **The folder name, sans extension** — "the failed load can supply neither /// `template.order` nor `title`, so the folder name is the only identity it has" (09 /// ▸ Storage). Spelled the way every other board display name falls back /// (`AppModel.folderDisplayName(of:)`, `BoardTemplate.slug`), so a broken `Notes.kanban` and /// a working one sort and read alike rather than differing by four characters. var name: String { url.deletingPathExtension().lastPathComponent } } var id: String { switch self { case let .template(template): template.id case let .unloadable(unloadable): unloadable.id } } var url: URL { switch self { case let .template(template): template.url case let .unloadable(unloadable): unloadable.url } } /// The name the chooser shows and the keyless tier sorts by. var name: String { switch self { case let .template(template): template.name case let .unloadable(unloadable): unloadable.name } } /// `template.order`, and **always `nil` for an unloadable row** — 09 sorts it with the keyless /// tier for the reason that it has no key to read. var order: Double? { switch self { case let .template(template): template.order case .unloadable: nil } } /// The template behind the row, or `nil` — the whole of "can't be instantiated or previewed", /// expressed as the absence of the value both of those need. var template: BoardTemplate? { switch self { case let .template(template): template case .unloadable: nil } } /// The failure behind the row, or `nil` — the other half of the same either, so a caller holding /// an optional row can ask both questions without a nested `case .some(.unloadable(…))`. var unloadable: Unloadable? { switch self { case .template: nil case let .unloadable(unloadable): unloadable } } }