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
366 lines
20 KiB
Swift
366 lines
20 KiB
Swift
import Foundation
|
|
import os
|
|
|
|
/// The template engine — where templates live, and what "create a board from one" means on disk
|
|
/// (09-templates.md).
|
|
///
|
|
/// ### The whole engine is the ordinary loader and the ordinary writer
|
|
///
|
|
/// A template is a board folder, so discovery is `BoardLoader.load` and instantiation is a tree copy
|
|
/// plus the Writer's own remint-and-restamp machinery (`BoardWriter.remintDescendants`,
|
|
/// `stampCopiedDescendant`, `updateIndex`). There is no template schema, no template catalog in
|
|
/// Swift, and no second copy path — which is 09's "dogfood" clause and 02-architecture.md's single
|
|
/// write door, both held by having nothing here to hold them with.
|
|
///
|
|
/// ### What instantiation is
|
|
///
|
|
/// > copy the tree — **skipping `.trash/`** … — **mint fresh GUIDs** for every lane/card folder,
|
|
/// > stamp `created`/`modified` fresh …, and **set the new board's `title` to the user-chosen
|
|
/// > document name**. The `template:` key is kept — inert on an ordinary board. **`.git` is never
|
|
/// > copied** … Beyond the `template:` residue, the result is indistinguishable from a hand-built
|
|
/// > board. (09 ▸ Instantiation)
|
|
///
|
|
/// Five things follow, and each is the ordinary machinery pointed at a board root rather than a
|
|
/// rule this file invents:
|
|
///
|
|
/// - **The two exclusions are top-level only** (`BoardTreeCopy`): `.git` and `.trash/` mean
|
|
/// something at a board root and nowhere else. `.git` is skipped so an instantiated board is never
|
|
/// silently in git mode (06-history-undo.md's no-silent-auto-init); its actual mode follows 06's
|
|
/// nearest-`.git`-wins detection at the destination the save panel chose. `.trash/` is skipped
|
|
/// because "a new board isn't born with trash".
|
|
/// - **Everything else copies verbatim** — strays, `CLAUDE.user.md`, a seeded `.gitignore`,
|
|
/// attachments, card bodies, unknown keys, line endings (09 ▸ Save as Template, "Strays copy
|
|
/// through … *and* instantiation alike"), and **symlinks as symlinks**, never traversed
|
|
/// (01-storage-format.md § Fractal layout ▸ Rules).
|
|
/// - **Fresh identity at every level**: `remintDescendants` from the copied root renames every
|
|
/// UUID-shaped folder it can reach, so no id survives from the template. Template GUIDs are inert
|
|
/// anyway — "instantiation remints at its own boundary" (01 § Fractal layout ▸ Rules).
|
|
/// - **Born today**: `CopyStamps.born` on every `index.md` — `created` *and* `modified` stamped from
|
|
/// one `Date` for the whole tree, `modified-by` cleared as on any app-mediated write (01
|
|
/// § Frontmatter). The stamps are frontmatter-level; **body bytes are never rewritten** (the
|
|
/// round-trip guarantee — `updateIndex` edits by line span).
|
|
/// - **The `template:` key rides along untouched**, because `updateIndex` preserves unknown keys by
|
|
/// construction rather than by remembering to.
|
|
///
|
|
/// ### Two readings 09 does not spell out, taken here and stated
|
|
///
|
|
/// - **A legacy `deleted:` key in a hand-dropped template copies through.** 09's exclusions are
|
|
/// `.git` and `.trash/` and stop there; the tombstone model is retired, so a stale `deleted:` is
|
|
/// just a key in a file that copies verbatim. The migration has exactly one owner — "Legacy
|
|
/// `deleted:` keys migrate on load-and-write, never destroy" (01 § Deletion), run by the store on
|
|
/// the new board's first load — and stripping the key here would silently resurrect a card the
|
|
/// template's author had deleted, while skipping the card would destroy content. Neither is this
|
|
/// engine's call to make; copying honestly and letting the one migrator run is.
|
|
/// - **Loose files beside a card's `index.md` are normalized on arrival.** 01's carve-out relocates
|
|
/// them into `attachments/`, and 04-interactions.md ▸ Clipboard settled that an **import boundary**
|
|
/// does it at write time rather than leaving it for the loader ("A paste is an import boundary, so
|
|
/// normalization applies"). Instantiation is the same kind of boundary — the app is materializing
|
|
/// the tree — so the new board lands already normalized instead of opening with a warning row
|
|
/// about a mess the instantiation itself made. Board- and lane-level strays keep the verbatim
|
|
/// posture, exactly as the carve-out is scoped.
|
|
///
|
|
/// ### Atomicity: construct-then-clean, not stage-then-rename
|
|
///
|
|
/// "A half-instantiated board must never be left at the destination." Two ways to promise that, and
|
|
/// the rename-into-place one is the wrong one here:
|
|
///
|
|
/// - A temp staged **beside** the destination is not reliably writable — the save panel's grant is
|
|
/// the item the user named, not its parent (03-board-ui.md's own reasoning for why Duplicate's
|
|
/// silent sibling can be refused at all), so staging there could fail for a destination that is
|
|
/// perfectly writable.
|
|
/// - A temp in `NSTemporaryDirectory()` may be on another volume, where `rename` degrades to a copy —
|
|
/// a second full copy of the tree, and no atomicity in exchange (`BoardWriter.atomicReplace` keeps
|
|
/// its temp in the same directory for exactly this reason; that trick does not survive being scaled
|
|
/// to a folder the app may not write beside).
|
|
///
|
|
/// So the promise is kept the way `BoardWriter.copyItem` and `BoardDuplicator` keep it: **the
|
|
/// destination is created by this call and removed by this call on every exit that is not a board**
|
|
/// — cancellation and failure alike, "because a half-copied board is pure residue: nothing was there
|
|
/// before, so there is no true state for a reload to show". The one thing never removed is a
|
|
/// destination this call did not create: an existing name is the user's, and a create that clobbered
|
|
/// one would be the create path silently deleting a board (`BoardWriter.createBoard`'s refusal, at
|
|
/// folder scale).
|
|
enum TemplateEngine {
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "templates")
|
|
|
|
// MARK: - Where templates live
|
|
|
|
/// The store folder's name in both locations — the bundle's and Application Support's.
|
|
static let storeFolderName = "Templates"
|
|
|
|
/// The bundled store: `<app bundle>/Contents/Resources/Templates/`, holding one board folder per
|
|
/// template (09 ▸ Definition format). `nil` only if the running bundle has no resources at all,
|
|
/// which is not a state a shipped app is in.
|
|
static var bundledStore: URL? {
|
|
Bundle.main.resourceURL?.appendingPathComponent(storeFolderName, isDirectory: true)
|
|
}
|
|
|
|
/// The user store: `<Application Support>/<bundle id>/Templates/`, beside the board registry and
|
|
/// the clipboard staging directory — 09's settled location ("Application Support … inside the
|
|
/// app container — friction-free sandbox writes, no location ceremony"), spelled the way every
|
|
/// other app-wide store in this app spells it (`ClipboardStore.defaultStagingRoot`,
|
|
/// `BoardRegistry`; 02-architecture.md § Per-board app state, "App-wide state has the same home").
|
|
///
|
|
/// **Named, never created here.** Discovery of a store that does not exist is an empty list, not
|
|
/// a directory the app made on the off-chance: the store is minted by the first Save as Template,
|
|
/// and by Reveal in Finder, both of which are 09's other cards.
|
|
static var userStore: URL {
|
|
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
|
?? URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
|
|
.appendingPathComponent("Library/Application Support", isDirectory: true)
|
|
let bundleIdentifier = Bundle.main.bundleIdentifier ?? "dev.rzen.indie.Kanban"
|
|
return support
|
|
.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
|
.appendingPathComponent(storeFolderName, isDirectory: true)
|
|
}
|
|
|
|
// MARK: - Discovery
|
|
|
|
/// The candidate template folders in a store, in folder-name order — every visible directory,
|
|
/// which is deliberately *not* narrowed to `.kanban`: "a board folder dropped in becomes a
|
|
/// template" (09 ▸ Storage), and an extension-less board folder is as legal as a suffixed one
|
|
/// (01-storage-format.md § Document packaging).
|
|
///
|
|
/// Hidden entries and symlinks are excluded — `BoardLoader.directoryCandidates`, the loader's own
|
|
/// listing, so a `.DS_Store` is not a template and a link is never followed out of the store.
|
|
/// A missing store is an empty list.
|
|
static func templateFolders(in store: URL) -> [URL] {
|
|
(try? BoardLoader.directoryCandidates(in: store)) ?? []
|
|
}
|
|
|
|
/// Loads one template folder through the ordinary loader.
|
|
///
|
|
/// The loader's error is handed back whole rather than reworded: the chooser's unloadable row
|
|
/// shows "the loader's fail-fast specifics" (09 ▸ Why this format), and a second taxonomy of
|
|
/// board problems is precisely what a files-first app must not grow.
|
|
static func load(templateAt url: URL, origin: BoardTemplate.Origin) -> Result<BoardTemplate, BoardLoadError> {
|
|
do {
|
|
let result = try BoardLoader.load(boardRoot: url)
|
|
return .success(BoardTemplate(url: url, origin: origin, model: result.model))
|
|
} catch {
|
|
return .failure(error)
|
|
}
|
|
}
|
|
|
|
/// Every bundled template that loads, in chooser order.
|
|
///
|
|
/// **A bundled template that does not load is a build defect, not a user's problem**, so it is
|
|
/// logged and skipped rather than surfaced: the unloadable-row treatment 09 specifies exists
|
|
/// because "the user store is hand-editable, so a malformed board there is one edit away" — an
|
|
/// app's own resources are neither hand-edited nor fixable by the person looking at the chooser.
|
|
/// The suite walks this same list and loads each folder, which is 09's "testable for free".
|
|
///
|
|
/// Order is `template.order`, then display name for anything keyless — 09's chooser order for
|
|
/// the bundled tier.
|
|
static func bundledTemplates() -> [BoardTemplate] {
|
|
guard let store = bundledStore else { return [] }
|
|
var templates: [BoardTemplate] = []
|
|
for folder in templateFolders(in: store) {
|
|
switch load(templateAt: folder, origin: .bundled) {
|
|
case let .success(template):
|
|
templates.append(template)
|
|
case let .failure(error):
|
|
logger.error("bundled template \(folder.lastPathComponent, privacy: .public) failed to load: \(error.description, privacy: .public)")
|
|
}
|
|
}
|
|
return sortedForChooser(templates)
|
|
}
|
|
|
|
/// 09's chooser order within one tier: keyed templates by `template.order`, then keyless ones by
|
|
/// display name. `localizedStandardCompare` for the names, the same Finder ordering every other
|
|
/// name listing in the app uses.
|
|
static func sortedForChooser(_ templates: [BoardTemplate]) -> [BoardTemplate] {
|
|
templates.sorted { left, right in
|
|
switch (left.order, right.order) {
|
|
case let (leftOrder?, rightOrder?):
|
|
leftOrder == rightOrder
|
|
? left.name.localizedStandardCompare(right.name) == .orderedAscending
|
|
: leftOrder < rightOrder
|
|
case (.some, .none):
|
|
true
|
|
case (.none, .some):
|
|
false
|
|
case (.none, .none):
|
|
left.name.localizedStandardCompare(right.name) == .orderedAscending
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Outcomes
|
|
|
|
/// The two ways instantiation ends without a board.
|
|
///
|
|
/// **There is no `.refused` here**, unlike `BoardDuplicator`: this flow's destination came from
|
|
/// the save panel, and the panel's grant *is* the sandbox's answer — asking the same question
|
|
/// again would be a loop, so a permission failure is an ordinary failure with an ordinary
|
|
/// message (`BoardDuplicator.duplicate(boardAt:titled:into:)` takes the same position for the
|
|
/// same reason).
|
|
enum Failure: Error, Sendable, Equatable {
|
|
/// The user cancelled. The partial is already gone and there is nothing to report — the
|
|
/// duplicate rule, verbatim: a cancelled create never happened.
|
|
case cancelled
|
|
|
|
/// Anything else: a full disk, an unreadable template, a name already taken. The one-shot
|
|
/// banner's vocabulary (02-architecture.md § Write-failure surfacing), which the chooser
|
|
/// renders as an alert because a board that was never created has no window to carry a row.
|
|
case failed(BoardWriteError)
|
|
}
|
|
|
|
// MARK: - Instantiation
|
|
|
|
/// Creates a board at `destination` from `template`, titled `title`.
|
|
///
|
|
/// `title` is the **user-chosen document name** (`documentName(of:)` off the save panel's URL),
|
|
/// never 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. The template's own title is
|
|
/// what seeds the panel's *suggested* name, which is the chooser's end of the same sentence.
|
|
///
|
|
/// `isCancelled` is read between items and nowhere else, defaulting to the ambient task's own
|
|
/// cancellation — so a caller cancels an instantiation the way it cancels anything else, and a
|
|
/// test can trip it deterministically at item *N* (`BoardDuplicator`'s seam, for its reasons).
|
|
/// 02-architecture.md's Cancel-on-safe-copies rule is *about* copy-shaped work like this; the
|
|
/// in-progress row that offers the button belongs to the chooser surface being built alongside
|
|
/// it, and this parameter is what it attaches to.
|
|
///
|
|
/// Returns `destination` — the caller opens it through the ordinary open path, so a new board
|
|
/// registers, bookmarks and titles itself like any other.
|
|
@discardableResult
|
|
static func instantiate(
|
|
template: BoardTemplate,
|
|
to destination: URL,
|
|
title: String,
|
|
isCancelled: () -> Bool = { Task.isCancelled }
|
|
) throws(Failure) -> URL {
|
|
let operation = WriteOperation.createBoard
|
|
|
|
func failure(at url: URL, _ message: String) -> Failure {
|
|
.failed(BoardWriteError(operation: operation, path: url.path, reason: .io(message: message)))
|
|
}
|
|
|
|
// **Refused, never clobbered**, and checked before anything is created so the cleanup below
|
|
// can never reach a destination this call did not make. The save panel's replace prompt
|
|
// grants access; it does not delete anything, so an occupied name arrives here intact and
|
|
// leaves that way (`BoardWriter.createBoard`'s stance, at folder scale).
|
|
guard !FileManager.default.fileExists(atPath: destination.path) else {
|
|
throw failure(at: destination, "something already exists here")
|
|
}
|
|
|
|
// Cancelled before it began is still cancelled — answered here so the empty destination
|
|
// never exists at all.
|
|
if isCancelled() { throw .cancelled }
|
|
|
|
do {
|
|
try BoardTreeCopy.createDirectory(at: destination)
|
|
} catch {
|
|
throw failure(at: destination, "could not create board folder: \(error.localizedDescription)")
|
|
}
|
|
|
|
do {
|
|
try copyTree(of: template, to: destination, isCancelled: isCancelled)
|
|
try mintIdentitiesAndStamps(at: destination, title: title, operation: operation)
|
|
} catch {
|
|
// Cancelled or failed, the partial goes — the whole of this call's atomicity, and the
|
|
// reason nothing half-made is ever left where the user pointed.
|
|
try? FileManager.default.removeItem(at: destination)
|
|
throw error
|
|
}
|
|
|
|
return destination
|
|
}
|
|
|
|
/// The copy half: the template's tree, minus the two board-root exclusions, with folder
|
|
/// attributes deliberately **not** carried (`BoardTreeCopy`'s flag documents why — a bundled
|
|
/// template's read-only mode must not mint a read-only board, and a board born today must not
|
|
/// wear the template's dates).
|
|
private static func copyTree(
|
|
of template: BoardTemplate,
|
|
to destination: URL,
|
|
isCancelled: () -> Bool
|
|
) throws(Failure) {
|
|
do throws(BoardTreeCopy.Stop) {
|
|
try BoardTreeCopy.copy(
|
|
contentsOf: template.url,
|
|
into: destination,
|
|
excludingTopLevel: [BoardLoader.trashFolderName, ".git"],
|
|
carryingFolderAttributes: false,
|
|
isCancelled: isCancelled
|
|
)
|
|
} catch {
|
|
switch error {
|
|
case .cancelled:
|
|
throw .cancelled
|
|
case let .failed(url, underlying):
|
|
throw .failed(BoardWriteError(
|
|
operation: .createBoard,
|
|
path: url.path,
|
|
reason: .io(message: "could not copy the template: \(underlying.localizedDescription)")
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The born half, on the tree already at the destination: fresh identities, fresh stamps, the
|
|
/// chosen title, and the loose-file normalization an import boundary owes.
|
|
///
|
|
/// **The root is strict and the descendants are lenient**, which is `BoardWriter.copyItem`'s
|
|
/// split for its reason: the root *must* be rewritten (it carries the title the user just typed),
|
|
/// so a template whose own `index.md` cannot be edited in place refuses the create — while a
|
|
/// nested card that is readable-but-uneditable is copied byte-verbatim and simply not stamped,
|
|
/// because failing a whole create over one hand-dropped flow mapping would be hostile. Its stale
|
|
/// `modified-by` surviving is the self-reported-provenance honest limit 01 § Frontmatter already
|
|
/// acknowledges.
|
|
private static func mintIdentitiesAndStamps(
|
|
at root: URL,
|
|
title: String,
|
|
operation: WriteOperation
|
|
) throws(Failure) {
|
|
do throws(BoardWriteError) {
|
|
var materialized: [URL] = []
|
|
try BoardWriter.remintDescendants(of: root, collecting: &materialized, operation: operation)
|
|
|
|
// One `Date` for the whole tree, so the board and every item in it are born at the same
|
|
// instant rather than merely close (`BoardWriter.newDocumentText`'s convention).
|
|
let now = Date()
|
|
try BoardWriter.updateIndex(inItemFolder: root, operation: operation) { document in
|
|
document.set(FrontmatterKeys.created, to: .date(now))
|
|
document.set(FrontmatterKeys.title, to: .string(title))
|
|
}
|
|
for folder in materialized {
|
|
try BoardWriter.stampCopiedDescendant(at: folder, stamps: .born, now: now, operation: operation)
|
|
}
|
|
|
|
// The import boundary's normalization, on the final paths. Lanes only: the carve-out is
|
|
// card-level and one level deep, so `normalizeLooseFiles(inLane:)` is the exact reach.
|
|
// The lane list is the loader's own level detection — `directoryCandidates` (hidden
|
|
// entries and symlinks already out) narrowed by the identity predicate — so a stray
|
|
// folder at board level is never descended into here either.
|
|
let lanes = ((try? BoardLoader.directoryCandidates(in: root)) ?? [])
|
|
.filter { BoardLoader.isUUIDShaped($0.lastPathComponent) }
|
|
for lane in lanes {
|
|
try BoardWriter.normalizeLooseFiles(inLane: lane)
|
|
}
|
|
} catch {
|
|
throw .failed(error)
|
|
}
|
|
}
|
|
|
|
// MARK: - Naming
|
|
|
|
/// The document name behind a chosen URL — `~/Boards/Roadmap.kanban` → `Roadmap`.
|
|
///
|
|
/// An extension-less name is as legal a board as a `.kanban` one (01-storage-format.md
|
|
/// § Document packaging, "Extension-less board folders still open"), so the extension is
|
|
/// stripped rather than required.
|
|
static func documentName(of destination: URL) -> String {
|
|
destination.deletingPathExtension().lastPathComponent
|
|
}
|
|
|
|
/// The save panel's suggested file name for a template — "seed the save panel's suggested name
|
|
/// from the template title" (09 ▸ Instantiation), with the package extension on the end so the
|
|
/// board is created as a `.kanban` document by default.
|
|
static func suggestedFileName(for template: BoardTemplate) -> String {
|
|
"\(template.name).kanban"
|
|
}
|
|
}
|