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
This commit is contained in:
@@ -1,89 +1,100 @@
|
||||
import Foundation
|
||||
|
||||
/// A board template — what File ▸ New Board… (⌥⌘N) instantiates (09-templates.md).
|
||||
/// One board template — **a schema-valid board folder that loaded** (09-templates.md ▸ Definition
|
||||
/// format: "A template is itself a board").
|
||||
///
|
||||
/// ### One template today, and that is the shape of this card, not a shortcut
|
||||
/// ### There is no template model, only a board model
|
||||
///
|
||||
/// 09 settles both the inventory (all ten pathfinder templates carry over) and the definition
|
||||
/// format, and the format is the interesting part: **a template is itself a board** — a schema-valid
|
||||
/// board folder in the app's resources, read by the same `BoardLoader`, its `index.md` supplying the
|
||||
/// display name (`title`), the picker blurb (the body), the icon, and the chooser position
|
||||
/// (`template.order`). None of that exists yet. What this card ships is the *entry point*: the
|
||||
/// chooser window, the save panel, and a real path from ⌥⌘N to an open board, with exactly one
|
||||
/// template behind it so that path is exercised rather than described.
|
||||
/// 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.
|
||||
///
|
||||
// m9-templates: the inventory becomes a walk of `<bundle>/Templates/*.kanban` plus the user store in
|
||||
// Application Support, each folder loaded through `BoardLoader` — `name`/`blurb`/`icon` off the
|
||||
// template board's own `index.md`, order off its `template.order`, an unloadable user template still
|
||||
// listed (by folder name, marked unloadable, carrying the loader's specifics) but not instantiable.
|
||||
// `laneTitles` stops existing at that point: instantiation becomes a tree copy that skips `.trash/`,
|
||||
// mints fresh GUIDs, and stamps `created`/`modified` fresh (`BoardWriter.CopyStamps.born`), never
|
||||
// copying `.git`. The chooser's mini preview renders from the loaded `BoardModel` rather than from
|
||||
// these strings.
|
||||
/// 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 {
|
||||
|
||||
/// The bundle folder name a real template would have (`basic.kanban` → `basic`) — 09 calls it
|
||||
/// "the template's stable slug (tests, a11y ids)", so it is the identity here too.
|
||||
let slug: String
|
||||
/// 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 chooser's display name — a real template's `title`.
|
||||
let name: String
|
||||
/// 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
|
||||
|
||||
/// The chooser's blurb — a real template's `index.md` body, which also becomes the new board's
|
||||
/// description. Nothing is written from it yet: this card creates lanes, not board bodies.
|
||||
let blurb: String
|
||||
let origin: Origin
|
||||
|
||||
/// The board icon shown in the picker and inherited by the new board.
|
||||
let icon: String
|
||||
/// 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
|
||||
|
||||
/// The lanes to create, in order.
|
||||
let laneTitles: [String]
|
||||
/// 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 }
|
||||
|
||||
var id: String { slug }
|
||||
/// 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 plain scaffold, and the one template that exists.
|
||||
/// 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.
|
||||
///
|
||||
// m9-templates: the bundled `basic.kanban` is 09's "plain To Do / Done scaffold" — two lanes,
|
||||
// not these three. The third is here because a chooser preview with two lanes reads as a mistake
|
||||
// and because this stub's whole job is to prove the create path; when the bundled template
|
||||
// arrives it replaces this value wholesale and 09's inventory is the only source.
|
||||
static let basic = BoardTemplate(
|
||||
slug: "basic",
|
||||
name: "Basic",
|
||||
blurb: "Three lanes to move work through.",
|
||||
icon: ItemSymbol.board,
|
||||
laneTitles: ["To Do", "Doing", "Done"]
|
||||
)
|
||||
|
||||
/// Every template the chooser offers, in chooser order.
|
||||
static let all: [BoardTemplate] = [.basic]
|
||||
|
||||
// MARK: - Instantiation
|
||||
|
||||
/// Writes this template to `rootURL`: the board's `index.md`, then one lane per title, in order.
|
||||
///
|
||||
/// **The board's title is the document name the user chose**, not the template's — 09
|
||||
/// ▸ Instantiation says so, and 01-storage-format.md § Board naming is the reason: display name
|
||||
/// and folder name start out matching, so a board called "Roadmap" on disk is called "Roadmap" in
|
||||
/// its window title. An extension-less name is as legal a board as a `.kanban` one, so the
|
||||
/// extension is stripped rather than required.
|
||||
///
|
||||
/// Lanes land at `1024`, `2048`, `3072` without this function saying so: each `createLane` call
|
||||
/// appends after the visible siblings the previous one left behind (`Ranks.append(toVisible:)`),
|
||||
/// which is what makes the array's order the board's order.
|
||||
///
|
||||
/// Separated from the panel and from the window flow deliberately — this is the whole of what
|
||||
/// "instantiate a template" means on disk, and a test drives it against a temp folder without
|
||||
/// going anywhere near `NSSavePanel`.
|
||||
func instantiate(at rootURL: URL) throws(BoardWriteError) {
|
||||
try BoardWriter.createBoard(at: rootURL, title: Self.documentName(of: rootURL))
|
||||
for title in laneTitles {
|
||||
_ = try BoardWriter.createLane(inBoard: rootURL, title: title)
|
||||
/// 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 document name behind a chosen URL — `~/Boards/Roadmap.kanban` → `Roadmap`.
|
||||
static func documentName(of rootURL: URL) -> String {
|
||||
rootURL.deletingPathExtension().lastPathComponent
|
||||
}
|
||||
/// 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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user