Files
lanework/Kanban/App/BoardTemplate.swift
T
rzen b6f559375b Build the template engine — board-as-template instantiation
A template is a board folder the ordinary loader reads — no second
schema, no Swift catalog. BoardTemplate became exactly that: a loaded
BoardModel with chooser-facing derivations, the lane-title stub gone.
TemplateEngine instantiates by the copy-remint-restamp walk: .git and
.trash excluded at top level only — both names mean something at a
board root and nowhere else, and .gitignore must survive — every
materialized folder reminted, created/modified stamped fresh (born
today, not forked), modified-by cleared, the template: key carried
inert, the blurb and style inherited, and loose card files normalized
at this import boundary per the paste precedent so a new board never
opens with a notice about a mess its own birth made. Legacy deleted:
keys copy through verbatim to the one migrator — stripping would
resurrect, skipping would destroy. Atomicity is construct-then-clean:
a sibling temp can be sandbox-refused and a cross-volume rename is
just a second copy, so the call removes what it created on every
non-board exit and never touches an occupied destination. The
cancellable per-item walk extracted into BoardTreeCopy serves
Duplicate and instantiation with two parameters — top-level exclusions
and folder-attribute carriage, the only axes they differ on.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 18:46:23 -04:00

101 lines
5.7 KiB
Swift

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 — `<app bundle>/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 }
}