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:
@@ -28,12 +28,10 @@ import Foundation
|
||||
/// sibling before dismissing the banner (the attachment partial-cleanup precedent): a cancelled
|
||||
/// duplicate never happened."
|
||||
///
|
||||
/// So the walk *is* the promise: one `FileManager.copyItem` per file — bytes and their metadata
|
||||
/// copied by the file system, never re-encoded, symlinks copied as symlinks — folders recreated by
|
||||
/// hand, and a cancellation read **between** items, never mid-file (a copy interrupted inside a
|
||||
/// 200 MB pack file leaves rubble that is harder to reason about than one more file's wait). The
|
||||
/// entries of every folder are walked in name order, so the same cancellation removes the same
|
||||
/// partial tree every time and a failure names the same file twice running.
|
||||
/// The walk itself is `BoardTreeCopy`, shared with template instantiation (09-templates.md), which
|
||||
/// needs the same cancellable per-item copy of a whole board and differs only in what it excludes
|
||||
/// and whether folder attributes carry. Duplicate excludes **nothing** and carries **everything** —
|
||||
/// which is not a default taken but this flow's entire definition, stated in the call below.
|
||||
///
|
||||
/// **The partial goes on both exits.** Cancel promises removal; a failure gets it too, because a
|
||||
/// half-copied board is pure residue — nothing was there before, so there is no true state for it to
|
||||
@@ -42,11 +40,6 @@ import Foundation
|
||||
/// The one thing never removed is a destination this walk did not create — an existing name is the
|
||||
/// user's, and refusing to clobber it is the same stance `BoardWriter.createBoard` takes.
|
||||
///
|
||||
/// The accepted cost of building folders by hand rather than handing the tree to one `copyItem`:
|
||||
/// **extended attributes on folders do not carry** (files keep theirs — each is still copied by
|
||||
/// `copyItem` — and POSIX permissions and timestamps are carried explicitly below). Nothing the app,
|
||||
/// the storage format, or git keeps lives in a folder xattr.
|
||||
///
|
||||
/// ### Not `@MainActor`
|
||||
///
|
||||
/// Duplicating a board with a year of `.git` behind it is real I/O, and it runs while the original's
|
||||
@@ -85,13 +78,6 @@ enum BoardDuplicator {
|
||||
case failed(BoardWriteError)
|
||||
}
|
||||
|
||||
/// Why a walk stopped and on which item — the recursion's private currency, converted to a
|
||||
/// `Failure` (and the partial removed) the moment it surfaces.
|
||||
private enum WalkStop: Error {
|
||||
case cancelled
|
||||
case failed(url: URL, error: any Error)
|
||||
}
|
||||
|
||||
// MARK: - Where the copy lands
|
||||
|
||||
/// The Finder-style destination for duplicating `rootURL`: `"Board copy"`, then `"Board copy 2"`,
|
||||
@@ -214,13 +200,16 @@ enum BoardDuplicator {
|
||||
// ours to remove, and this is also where the sandbox says "not here" — the refusal the save
|
||||
// panel answers, raised before a single byte has been copied.
|
||||
do {
|
||||
try createDirectory(at: destination)
|
||||
try BoardTreeCopy.createDirectory(at: destination)
|
||||
} catch {
|
||||
throw failure(at: destination, error)
|
||||
}
|
||||
|
||||
do {
|
||||
try copyContents(of: rootURL, into: destination, isCancelled: isCancelled)
|
||||
// No exclusions and folder attributes carried: a duplicate is a full fork — `.git`,
|
||||
// `.trash/`, strays, modes and dates included (03; 01-storage-format.md § Fractal layout
|
||||
// ▸ Rules, "Whole-board copies are the carve-out").
|
||||
try BoardTreeCopy.copy(contentsOf: rootURL, into: destination, isCancelled: isCancelled)
|
||||
} catch {
|
||||
// Cancelled or failed, the partial sibling goes: the tree exists only because this call
|
||||
// made it, and half a board is not a state anything should have to render.
|
||||
@@ -233,7 +222,7 @@ enum BoardDuplicator {
|
||||
}
|
||||
}
|
||||
|
||||
restoreAttributes(from: rootAttributes, onto: destination)
|
||||
BoardTreeCopy.restoreAttributes(from: rootAttributes, onto: destination)
|
||||
// m7-git: strip the copy's remote configuration — "the duplicate keeps `.git` but has its
|
||||
// remote configuration stripped ... it must not silently push into the original's remote"
|
||||
// (03-board-ui.md). Remotes only: the repo-local `user.name`/`user.email` survives, so the
|
||||
@@ -242,107 +231,6 @@ enum BoardDuplicator {
|
||||
return destination
|
||||
}
|
||||
|
||||
// MARK: - The walk
|
||||
|
||||
/// Copies everything inside `source` into the already-created `destination`, one item at a time.
|
||||
///
|
||||
/// Name order, hidden entries included (no `.skipsHiddenFiles`): `.git`, `.DS_Store` and every
|
||||
/// other dotfile are part of the fork, and a deterministic order is what makes a cancellation
|
||||
/// reproducible.
|
||||
///
|
||||
/// Each entry's type comes from `attributesOfItem`, which does **not** traverse symlinks — so a
|
||||
/// link is copied as a link (by `copyItem`, which does not follow it either) rather than being
|
||||
/// mistaken for the folder it points at and walked into.
|
||||
private static func copyContents(
|
||||
of source: URL,
|
||||
into destination: URL,
|
||||
isCancelled: () -> Bool
|
||||
) throws(WalkStop) {
|
||||
let entries: [URL]
|
||||
do {
|
||||
entries = try FileManager.default.contentsOfDirectory(
|
||||
at: source,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: []
|
||||
)
|
||||
} catch {
|
||||
throw .failed(url: source, error: error)
|
||||
}
|
||||
|
||||
for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
|
||||
// Between items, never mid-item: this is the whole of "checks cancellation between
|
||||
// items", and the reason the copy is a walk at all.
|
||||
if isCancelled() { throw .cancelled }
|
||||
|
||||
let attributes: [FileAttributeKey: Any]
|
||||
do {
|
||||
attributes = try FileManager.default.attributesOfItem(atPath: entry.path)
|
||||
} catch {
|
||||
throw .failed(url: entry, error: error)
|
||||
}
|
||||
|
||||
let isDirectory = attributes[.type] as? FileAttributeType == .typeDirectory
|
||||
let target = destination.appendingPathComponent(entry.lastPathComponent, isDirectory: isDirectory)
|
||||
|
||||
guard isDirectory else {
|
||||
// Files, symlinks, and whatever else the file system holds: `copyItem` lands the
|
||||
// bytes and the metadata that rides with them, byte-for-byte, unread.
|
||||
do {
|
||||
try FileManager.default.copyItem(at: entry, to: target)
|
||||
} catch {
|
||||
throw .failed(url: entry, error: error)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
do {
|
||||
try createDirectory(at: target)
|
||||
} catch {
|
||||
throw .failed(url: entry, error: error)
|
||||
}
|
||||
try copyContents(of: entry, into: target, isCancelled: isCancelled)
|
||||
restoreAttributes(from: attributes, onto: target)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates `url` as a plain directory, wearing the process's own default permissions until its
|
||||
/// contents have landed (`restoreAttributes(from:onto:)` puts the source's back afterwards).
|
||||
///
|
||||
/// **Default permissions first, the source's last**, because a folder is not only a thing being
|
||||
/// copied but the thing being copied *into*: a source folder that is read-only, or unreadable,
|
||||
/// would otherwise be reproduced as a destination this walk cannot write its own children into —
|
||||
/// and, when something later fails, as a partial tree the cleanup cannot remove either. `cp -R`
|
||||
/// defers the mode for the same reason.
|
||||
///
|
||||
/// `withIntermediateDirectories: false` throughout: every parent either exists already (the walk
|
||||
/// just made it) or is the one the user pointed at, and inventing a missing folder would be this
|
||||
/// function deciding where a board lives.
|
||||
private static func createDirectory(at url: URL) throws {
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false)
|
||||
}
|
||||
|
||||
/// Puts a folder's POSIX permissions and its creation and modification dates back, **after** its
|
||||
/// contents have landed — writing into a folder is itself a modification, and its mode may be
|
||||
/// what stops the writing (above), so both wait for the subtree to be finished.
|
||||
///
|
||||
/// Best effort by design: a volume that will not take a date back (or a file system with no
|
||||
/// creation dates at all) is not a reason to fail a duplicate that otherwise worked, and neither
|
||||
/// a folder's timestamp nor its mode is something the storage format reads.
|
||||
private static func restoreAttributes(from attributes: [FileAttributeKey: Any], onto url: URL) {
|
||||
var carried: [FileAttributeKey: Any] = [:]
|
||||
if let permissions = attributes[.posixPermissions] {
|
||||
carried[.posixPermissions] = permissions
|
||||
}
|
||||
if let created = attributes[.creationDate] {
|
||||
carried[.creationDate] = created
|
||||
}
|
||||
if let modified = attributes[.modificationDate] {
|
||||
carried[.modificationDate] = modified
|
||||
}
|
||||
guard !carried.isEmpty else { return }
|
||||
try? FileManager.default.setAttributes(carried, ofItemAtPath: url.path)
|
||||
}
|
||||
|
||||
// MARK: - Classifying a refusal
|
||||
|
||||
/// Whether `error` is the sandbox saying **not here** — the one failure 03 answers with a save
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import Foundation
|
||||
|
||||
/// The **board-scale tree copy**: a per-item file walk that checks cancellation between items,
|
||||
/// shared by File ▸ Duplicate (`BoardDuplicator`) and template instantiation (`TemplateEngine`).
|
||||
///
|
||||
/// ### Why the walk is a walk
|
||||
///
|
||||
/// 03-board-ui.md settles the shape for Duplicate and the reason generalizes to every copy of a
|
||||
/// whole board: "the copy runs as a per-item file walk that checks cancellation between items —
|
||||
/// **never one monolithic `copyItem`** — and Cancel removes the partial sibling before dismissing
|
||||
/// the banner (the attachment partial-cleanup precedent)". A monolithic `copyItem` is uncancellable
|
||||
/// and indivisible; a walk is both, and it is also the only shape that can *exclude* something
|
||||
/// (09-templates.md's `.git` and `.trash/`).
|
||||
///
|
||||
/// So the walk is the promise: one `FileManager.copyItem` per file — bytes and their metadata
|
||||
/// copied by the file system, never re-encoded, **symlinks copied as symlinks** (01-storage-format.md
|
||||
/// § Fractal layout ▸ Rules: "Copy flows copy the link itself, never its target: Duplicate, Save as
|
||||
/// Template, instantiation, cross-board copies … preserve the link verbatim") — folders recreated by
|
||||
/// hand, and a cancellation read **between** items, never mid-file (a copy interrupted inside a
|
||||
/// 200 MB pack file leaves rubble that is harder to reason about than one more file's wait). The
|
||||
/// entries of every folder are walked in name order, so the same cancellation removes the same
|
||||
/// partial tree every time and a failure names the same file twice running.
|
||||
///
|
||||
/// The accepted cost of building folders by hand rather than handing the tree to one `copyItem`:
|
||||
/// **extended attributes on folders do not carry** (files keep theirs — each is still copied by
|
||||
/// `copyItem`). Nothing the app, the storage format, or git keeps lives in a folder xattr.
|
||||
///
|
||||
/// ### What it does not do
|
||||
///
|
||||
/// It never creates the destination root, never removes a partial, and never classifies a failure.
|
||||
/// Those are the caller's, because they are exactly where the two flows differ: Duplicate answers a
|
||||
/// permission failure with a save panel, instantiation with a banner, and each owns its own cleanup
|
||||
/// promise. This type only knows how to move a tree, one item at a time, and where to stop.
|
||||
///
|
||||
/// ### Not `@MainActor`
|
||||
///
|
||||
/// Copying a board with a year of `.git` behind it is real I/O and runs off the main actor so an
|
||||
/// in-progress banner can actually spin (02-architecture.md § The banner surface). Safe by
|
||||
/// construction: it touches only the two URLs it is handed, and a board's security-scoped access is
|
||||
/// a process-wide grant, not a per-thread one.
|
||||
enum BoardTreeCopy {
|
||||
|
||||
/// Why a walk stopped and on which item — the recursion's currency, which each caller converts
|
||||
/// into its own failure vocabulary the moment it surfaces.
|
||||
enum Stop: Error {
|
||||
case cancelled
|
||||
case failed(url: URL, error: any Error)
|
||||
}
|
||||
|
||||
/// Copies everything inside `source` into the already-created `destination`, one item at a time.
|
||||
///
|
||||
/// Name order, hidden entries included (no `.skipsHiddenFiles`): `.DS_Store`, `.gitignore`,
|
||||
/// `CLAUDE.user.md` and every other dotfile or stray is part of the copy — "the copy is literal
|
||||
/// apart from the stated exclusions" (09-templates.md ▸ Save as Template) — and a deterministic
|
||||
/// order is what makes a cancellation reproducible.
|
||||
///
|
||||
/// `excludedTopLevelNames` is compared **lowercased against the top level only**, which is the
|
||||
/// exclusions' actual scope rather than a shortcut: `.git` and `.trash/` mean something at a
|
||||
/// board root and nowhere else (06-history-undo.md's nearest-`.git`-wins detection starts at the
|
||||
/// board root; `BoardLoader.trashCandidates` looks in exactly one place), so a `.git` a template
|
||||
/// author left inside a card folder is an ordinary stray and copies verbatim like any other.
|
||||
///
|
||||
/// `carriesFolderAttributes` decides whether a recreated folder gets the source folder's POSIX
|
||||
/// permissions and timestamps back once its contents have landed. Duplicate says yes — it is a
|
||||
/// fork, down to the mode bits. Instantiation says no: a template folder is content the app
|
||||
/// ships or the user dropped, and carrying a read-only mode (or a two-year-old date) out of it
|
||||
/// would mint a board that is read-only, or born older than itself, from a copy whose whole
|
||||
/// premise is "born today" (09-templates.md ▸ Instantiation).
|
||||
///
|
||||
/// Each entry's type comes from `attributesOfItem`, which does **not** traverse symlinks — so a
|
||||
/// link is copied as a link (by `copyItem`, which does not follow it either) rather than being
|
||||
/// mistaken for the folder it points at and walked into.
|
||||
static func copy(
|
||||
contentsOf source: URL,
|
||||
into destination: URL,
|
||||
excludingTopLevel excludedTopLevelNames: Set<String> = [],
|
||||
carryingFolderAttributes carriesFolderAttributes: Bool = true,
|
||||
isCancelled: () -> Bool
|
||||
) throws(Stop) {
|
||||
try copyContents(
|
||||
of: source,
|
||||
into: destination,
|
||||
excluding: excludedTopLevelNames,
|
||||
carryingFolderAttributes: carriesFolderAttributes,
|
||||
isCancelled: isCancelled
|
||||
)
|
||||
}
|
||||
|
||||
/// The recursion. `excluded` is emptied one level down, which is what makes the exclusions
|
||||
/// top-level-only without the walk having to count its own depth.
|
||||
private static func copyContents(
|
||||
of source: URL,
|
||||
into destination: URL,
|
||||
excluding excluded: Set<String>,
|
||||
carryingFolderAttributes carriesFolderAttributes: Bool,
|
||||
isCancelled: () -> Bool
|
||||
) throws(Stop) {
|
||||
let entries: [URL]
|
||||
do {
|
||||
entries = try FileManager.default.contentsOfDirectory(
|
||||
at: source,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: []
|
||||
)
|
||||
} catch {
|
||||
throw .failed(url: source, error: error)
|
||||
}
|
||||
|
||||
for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
|
||||
guard !excluded.contains(entry.lastPathComponent.lowercased()) else { continue }
|
||||
|
||||
// Between items, never mid-item: this is the whole of "checks cancellation between
|
||||
// items", and the reason the copy is a walk at all.
|
||||
if isCancelled() { throw .cancelled }
|
||||
|
||||
let attributes: [FileAttributeKey: Any]
|
||||
do {
|
||||
attributes = try FileManager.default.attributesOfItem(atPath: entry.path)
|
||||
} catch {
|
||||
throw .failed(url: entry, error: error)
|
||||
}
|
||||
|
||||
let isDirectory = attributes[.type] as? FileAttributeType == .typeDirectory
|
||||
let target = destination.appendingPathComponent(entry.lastPathComponent, isDirectory: isDirectory)
|
||||
|
||||
guard isDirectory else {
|
||||
// Files, symlinks, and whatever else the file system holds: `copyItem` lands the
|
||||
// bytes and the metadata that rides with them, byte-for-byte, unread.
|
||||
do {
|
||||
try FileManager.default.copyItem(at: entry, to: target)
|
||||
} catch {
|
||||
throw .failed(url: entry, error: error)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
do {
|
||||
try createDirectory(at: target)
|
||||
} catch {
|
||||
throw .failed(url: entry, error: error)
|
||||
}
|
||||
try copyContents(
|
||||
of: entry,
|
||||
into: target,
|
||||
excluding: [],
|
||||
carryingFolderAttributes: carriesFolderAttributes,
|
||||
isCancelled: isCancelled
|
||||
)
|
||||
if carriesFolderAttributes {
|
||||
restoreAttributes(from: attributes, onto: target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates `url` as a plain directory, wearing the process's own default permissions until its
|
||||
/// contents have landed (`restoreAttributes(from:onto:)` puts the source's back afterwards,
|
||||
/// where the caller asked for them).
|
||||
///
|
||||
/// **Default permissions first, the source's last**, because a folder is not only a thing being
|
||||
/// copied but the thing being copied *into*: a source folder that is read-only, or unreadable,
|
||||
/// would otherwise be reproduced as a destination this walk cannot write its own children into —
|
||||
/// and, when something later fails, as a partial tree the cleanup cannot remove either. `cp -R`
|
||||
/// defers the mode for the same reason.
|
||||
///
|
||||
/// `withIntermediateDirectories: false` throughout: every parent either exists already (the walk
|
||||
/// just made it) or is the one the user pointed at, and inventing a missing folder would be this
|
||||
/// function deciding where a board lives.
|
||||
static func createDirectory(at url: URL) throws {
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false)
|
||||
}
|
||||
|
||||
/// Puts a folder's POSIX permissions and its creation and modification dates back, **after** its
|
||||
/// contents have landed — writing into a folder is itself a modification, and its mode may be
|
||||
/// what stops the writing (above), so both wait for the subtree to be finished.
|
||||
///
|
||||
/// Best effort by design: a volume that will not take a date back (or a file system with no
|
||||
/// creation dates at all) is not a reason to fail a copy that otherwise worked, and neither a
|
||||
/// folder's timestamp nor its mode is something the storage format reads.
|
||||
static func restoreAttributes(from attributes: [FileAttributeKey: Any], onto url: URL) {
|
||||
var carried: [FileAttributeKey: Any] = [:]
|
||||
if let permissions = attributes[.posixPermissions] {
|
||||
carried[.posixPermissions] = permissions
|
||||
}
|
||||
if let created = attributes[.creationDate] {
|
||||
carried[.creationDate] = created
|
||||
}
|
||||
if let modified = attributes[.modificationDate] {
|
||||
carried[.modificationDate] = modified
|
||||
}
|
||||
guard !carried.isEmpty else { return }
|
||||
try? FileManager.default.setAttributes(carried, ofItemAtPath: url.path)
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,21 @@ import os
|
||||
|
||||
/// The template chooser — File ▸ New Board… (⌥⌘N), 09-templates.md's picker.
|
||||
///
|
||||
/// ### Pages' shape, one card in it
|
||||
/// ### Pages' shape, and the templates are real board folders now
|
||||
///
|
||||
/// A grid of template cards, each showing a **mini per-lane preview** above its name, one selected
|
||||
/// at a time, with Cancel and Choose at the bottom (03-board-ui.md § Welcome screen & templates: "a
|
||||
/// Pages-style chooser with a mini per-lane preview per template"). The grid holds exactly one card
|
||||
/// today because exactly one template exists (`BoardTemplate`); everything about the layout is
|
||||
/// already the plural case, so the m9 inventory drops in without the surface changing shape.
|
||||
/// Pages-style chooser with a mini per-lane preview per template"). The grid is filled by
|
||||
/// `TemplateEngine.bundledTemplates()` — the app bundle's `Templates/` folder, each entry loaded
|
||||
/// through the ordinary `BoardLoader` — so name, blurb, icon and preview all come off the template
|
||||
/// board's own `index.md` rather than from a Swift catalog.
|
||||
///
|
||||
// m9-templates: the full chooser is its own card. What is still missing here is the *user* tier
|
||||
// (`TemplateEngine.userStore`, listed after the bundled ones), the unloadable-template row, Reveal
|
||||
// in Finder, and the in-progress row with Cancel that copy-shaped work is owed (02-architecture.md
|
||||
// § The banner surface) — this window has no banner surface to host one yet. The engine already
|
||||
// takes the cancellation seam (`TemplateEngine.instantiate(…, isCancelled:)`); this view runs the
|
||||
// copy off the main actor so that row has something to spin over when it arrives.
|
||||
///
|
||||
/// ### Choosing is three steps, and the middle one is a save panel
|
||||
///
|
||||
@@ -35,12 +43,17 @@ struct TemplateChooserView: View {
|
||||
@Environment(AppModel.self) private var appModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var selection: BoardTemplate.ID = BoardTemplate.basic.id
|
||||
/// Discovered when the window appears, and not again: the bundle's `Templates/` folder cannot
|
||||
/// change under a running app, and discovery *loads every template board* — a default-value
|
||||
/// initializer would re-run it each time SwiftUI rebuilt this struct.
|
||||
@State private var templates: [BoardTemplate] = []
|
||||
|
||||
@State private var selection: BoardTemplate.ID?
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "templates")
|
||||
|
||||
private var selected: BoardTemplate? {
|
||||
BoardTemplate.all.first { $0.id == selection }
|
||||
templates.first { $0.id == selection } ?? templates.first
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -52,6 +65,10 @@ struct TemplateChooserView: View {
|
||||
footer
|
||||
}
|
||||
.frame(width: 620, height: 460)
|
||||
.onAppear {
|
||||
guard templates.isEmpty else { return }
|
||||
templates = TemplateEngine.bundledTemplates()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Header
|
||||
@@ -73,13 +90,13 @@ struct TemplateChooserView: View {
|
||||
private var grid: some View {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 170), spacing: 20)], spacing: 20) {
|
||||
ForEach(BoardTemplate.all) { template in
|
||||
TemplateCard(template: template, isSelected: template.id == selection)
|
||||
ForEach(templates) { template in
|
||||
TemplateCard(template: template, isSelected: template.id == selected?.id)
|
||||
.onTapGesture { selection = template.id }
|
||||
// The list convention welcome's recents use, for the same reason: a
|
||||
// double click is how a chooser is answered without reaching for a button.
|
||||
.onTapGesture(count: 2) { choose() }
|
||||
.accessibilityAddTraits(template.id == selection ? [.isSelected] : [])
|
||||
.accessibilityAddTraits(template.id == selected?.id ? [.isSelected] : [])
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
@@ -113,42 +130,58 @@ struct TemplateChooserView: View {
|
||||
|
||||
/// Panel, instantiate, open — and only then dismiss, so a cancelled panel leaves the chooser
|
||||
/// exactly as the user left it.
|
||||
///
|
||||
/// The copy runs in a detached task, `DuplicateBoardCommand`'s reasoning at a smaller scale: a
|
||||
/// user template can be a real board with real attachments, and a main thread blocked inside a
|
||||
/// tree copy is a frozen window. Detached rather than a child task so its cancellation is only
|
||||
/// ever the one a Cancel affordance hands it, never something inherited.
|
||||
private func choose() {
|
||||
guard let template = selected, let url = Self.chooseLocation(for: template) else { return }
|
||||
let title = TemplateEngine.documentName(of: url)
|
||||
|
||||
do {
|
||||
try template.instantiate(at: url)
|
||||
} catch {
|
||||
Self.logger.error("template instantiation failed: \(error.description, privacy: .public)")
|
||||
Self.present(error)
|
||||
return
|
||||
Task { @MainActor in
|
||||
let outcome = await Task.detached(priority: .userInitiated) {
|
||||
() -> Result<URL, TemplateEngine.Failure> in
|
||||
do throws(TemplateEngine.Failure) {
|
||||
return .success(try TemplateEngine.instantiate(template: template, to: url, title: title))
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
}.value
|
||||
|
||||
switch outcome {
|
||||
case .success:
|
||||
dismiss()
|
||||
// The ordinary open path, so the new board joins recents, gets its bookmark, and
|
||||
// closes welcome on the way in exactly like a board opened from a row.
|
||||
appModel.openBoard(at: url)
|
||||
case .failure(.cancelled):
|
||||
// Nothing was created and nothing failed, so nothing is said — the duplicate rule.
|
||||
Self.logger.notice("template instantiation cancelled — the partial board was removed")
|
||||
case let .failure(.failed(error)):
|
||||
Self.logger.error("template instantiation failed: \(error.description, privacy: .public)")
|
||||
Self.present(error)
|
||||
}
|
||||
}
|
||||
|
||||
dismiss()
|
||||
// The ordinary open path, so the new board joins recents, gets its bookmark, and closes
|
||||
// welcome on the way in exactly like a board opened from a row.
|
||||
appModel.openBoard(at: url)
|
||||
}
|
||||
|
||||
/// The save panel — where the board goes and what it is called.
|
||||
///
|
||||
/// `"Untitled.kanban"` is the suggestion; the package extension is visible and editable, because
|
||||
/// an extension-less board folder is equally legal (01-storage-format.md § Document packaging)
|
||||
/// and deleting the suffix should therefore work rather than be silently undone.
|
||||
///
|
||||
// m9-templates: 09 ▸ Instantiation seeds this name from the template's own title once templates
|
||||
// have titles of their own ("Basic.kanban", "Bug Tracker.kanban"). With one stub template a
|
||||
// suggestion of "Basic" would name the *template*, not the user's board, which is worse than
|
||||
// Untitled.
|
||||
/// **The suggestion is the template's own title** (`"Basic.kanban"`, `"Bug Tracker.kanban"`) —
|
||||
/// 09 ▸ Instantiation: "seed the save panel's suggested name from the template title". Whatever
|
||||
/// the user types instead becomes the new board's `title` as well as its folder name, so the two
|
||||
/// start out matching (01-storage-format.md § Board naming). The package extension is visible and
|
||||
/// editable, because an extension-less board folder is equally legal (§ Document packaging) and
|
||||
/// deleting the suffix should therefore work rather than be silently undone.
|
||||
///
|
||||
/// A name that already exists gets the panel's own replace prompt; agreeing to it does not delete
|
||||
/// anything (the panel never does), so `BoardWriter.createBoard`'s refusal to clobber an existing
|
||||
/// board is what the user sees — as an alert, naming the path. That is the honest outcome: this
|
||||
/// flow is a *create*, and quietly replacing a board with an empty one is not a thing it should
|
||||
/// be able to do.
|
||||
/// anything (the panel never does), so the engine's refusal to clobber an existing board is what
|
||||
/// the user sees — as an alert, naming the path. That is the honest outcome: this flow is a
|
||||
/// *create*, and quietly replacing a board with an empty one is not a thing it should be able to
|
||||
/// do.
|
||||
private static func chooseLocation(for template: BoardTemplate) -> URL? {
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = "Untitled.kanban"
|
||||
panel.nameFieldStringValue = TemplateEngine.suggestedFileName(for: template)
|
||||
panel.canCreateDirectories = true
|
||||
panel.isExtensionHidden = false
|
||||
panel.allowsOtherFileTypes = true
|
||||
@@ -202,15 +235,19 @@ private struct TemplateCard: View {
|
||||
/// The mini per-lane preview: one column per lane, each a title bar over a couple of card shapes.
|
||||
///
|
||||
/// Deliberately abstract — no text, because the point is the *shape* of the board and legible lane
|
||||
/// names at this size are not available. It renders from `laneTitles` only for the count and the
|
||||
/// stable identity of each column.
|
||||
/// names at this size are not available. It renders **from the template's loaded `BoardModel`**
|
||||
/// (09-templates.md ▸ Why this format: "The picker's mini per-lane preview renders from a real
|
||||
/// `BoardModel` via the normal loader"), taking the lane count and each lane's identity from it.
|
||||
///
|
||||
// m9-templates: the card shapes are still decoration — a lane's *real* starter cards (templates
|
||||
// "may contain starter cards") should be what the column draws, once the chooser card gets to it.
|
||||
private struct TemplatePreview: View {
|
||||
|
||||
let template: BoardTemplate
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 6) {
|
||||
ForEach(Array(template.laneTitles.enumerated()), id: \.offset) { index, _ in
|
||||
ForEach(Array(template.lanes.enumerated()), id: \.element.id) { index, _ in
|
||||
VStack(spacing: 4) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Color.accentColor.opacity(0.65))
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user