From b6f559375b803ddb0222785c9b51996d288fd134 Mon Sep 17 00:00:00 2001 From: rzen Date: Tue, 28 Jul 2026 18:46:23 -0400 Subject: [PATCH] =?UTF-8?q?Build=20the=20template=20engine=20=E2=80=94=20b?= =?UTF-8?q?oard-as-template=20instantiation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/App/BoardDuplicator.swift | 132 +---- Kanban/App/BoardTemplate.swift | 153 ++--- Kanban/App/BoardTreeCopy.swift | 193 +++++++ Kanban/App/TemplateChooserView.swift | 109 ++-- Kanban/App/TemplateEngine.swift | 365 ++++++++++++ Kanban/Storage/BoardWriter.swift | 13 +- KanbanTests/BoardTemplateTests.swift | 218 ++++--- KanbanTests/TemplateEngineTests.swift | 542 ++++++++++++++++++ README.md | 2 +- .../index.md | 7 + .../index.md | 7 + Templates/basic.kanban/index.md | 10 + project.yml | 11 + 13 files changed, 1446 insertions(+), 316 deletions(-) create mode 100644 Kanban/App/BoardTreeCopy.swift create mode 100644 Kanban/App/TemplateEngine.swift create mode 100644 KanbanTests/TemplateEngineTests.swift create mode 100644 Templates/basic.kanban/3f2a9c41-6b0e-4d7a-9f31-2c8b5e0a7d14/index.md create mode 100644 Templates/basic.kanban/8d3b1f22-4a7c-4e59-8b06-1d9f3c2e6a85/index.md create mode 100644 Templates/basic.kanban/index.md diff --git a/Kanban/App/BoardDuplicator.swift b/Kanban/App/BoardDuplicator.swift index 89c9b9a..ce983d1 100644 --- a/Kanban/App/BoardDuplicator.swift +++ b/Kanban/App/BoardDuplicator.swift @@ -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 diff --git a/Kanban/App/BoardTemplate.swift b/Kanban/App/BoardTemplate.swift index de25994..ad416fd 100644 --- a/Kanban/App/BoardTemplate.swift +++ b/Kanban/App/BoardTemplate.swift @@ -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 `/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 — `/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 } } diff --git a/Kanban/App/BoardTreeCopy.swift b/Kanban/App/BoardTreeCopy.swift new file mode 100644 index 0000000..24a0980 --- /dev/null +++ b/Kanban/App/BoardTreeCopy.swift @@ -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 = [], + 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, + 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) + } +} diff --git a/Kanban/App/TemplateChooserView.swift b/Kanban/App/TemplateChooserView.swift index ccf126e..3bf0635 100644 --- a/Kanban/App/TemplateChooserView.swift +++ b/Kanban/App/TemplateChooserView.swift @@ -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 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)) diff --git a/Kanban/App/TemplateEngine.swift b/Kanban/App/TemplateEngine.swift new file mode 100644 index 0000000..18b26f0 --- /dev/null +++ b/Kanban/App/TemplateEngine.swift @@ -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: `/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: `//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 { + 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" + } +} diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index dc2ed7a..d695c70 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -748,7 +748,13 @@ public enum BoardWriter: Sendable { /// ones — a caller can stamp them without re-deriving any path. The mint only has to avoid /// what is on disk in that folder: the siblings not yet renamed are still there under their /// original names, and `freshUUIDName` checks for exactly that. - private static func remintDescendants( + /// + /// **Internal rather than `private`**: template instantiation (`TemplateEngine`) is the same + /// remint one level up — "mint fresh GUIDs for every lane/card folder" (09-templates.md + /// ▸ Instantiation) is `copyItem`'s rule applied to a whole board rather than to one item, and + /// pointing this at a copied *board root* is literally that. A second implementation of "which + /// folders are identities" is exactly what must not exist. + static func remintDescendants( of folder: URL, collecting copied: inout [URL], operation: WriteOperation @@ -767,7 +773,10 @@ public enum BoardWriter: Sendable { /// missing, unreadable, or uneditable `index.md` is left exactly as the copy found it rather /// than failing the gesture. `order` is not touched: a nested item keeps its rank among its /// own siblings, which travelled with it. - private static func stampCopiedDescendant( + /// + /// **Internal rather than `private`**, with `remintDescendants` and for its reason: an + /// instantiated board's lanes and cards are stamped by this exact rule, leniency included. + static func stampCopiedDescendant( at folder: URL, stamps: CopyStamps, now: Date, diff --git a/KanbanTests/BoardTemplateTests.swift b/KanbanTests/BoardTemplateTests.swift index 221bd7b..a579ea7 100644 --- a/KanbanTests/BoardTemplateTests.swift +++ b/KanbanTests/BoardTemplateTests.swift @@ -2,118 +2,168 @@ import Foundation import Testing @testable import Kanban -/// Template instantiation — the on-disk half of File ▸ New Board… (09-templates.md ▸ Instantiation). +/// Template **discovery** — the bundled store, and what a `BoardTemplate` reads off a real board +/// folder (09-templates.md ▸ Definition format). /// -/// The window flow around it (the chooser, `NSSavePanel`, the open that follows) is untestable -/// without a screen and deliberately holds no rules of its own; everything that *is* a rule — what -/// gets written, what the board is called, and the order the lanes land in — lives in -/// `BoardTemplate.instantiate(at:)` and is checked here against a real temp folder, through the -/// app's own loader. - -// MARK: - Fixtures - -/// An empty temp folder to create *into* — the save panel's answer, minus the panel. -private func temporaryLocation(named name: String) throws -> (url: URL, tearDown: () -> Void) { - let container = FileManager.default.temporaryDirectory - .appendingPathComponent("BoardTemplateTests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: container, withIntermediateDirectories: true) - return (container.appendingPathComponent(name, isDirectory: true), { - try? FileManager.default.removeItem(at: container) - }) -} +/// The instantiation half lives in `TemplateEngineTests.swift`. What this file pins is 09's +/// "testable for free" clause: "template validity is enforced by the loader's own fail-fast rules at +/// test time (walk `Templates/`, load each)". // MARK: - Tests @Suite("BoardTemplate") struct BoardTemplateTests { - @Test("Basic writes a board and its three lanes, in order") - func basicInstantiatesInOrder() throws { - let location = try temporaryLocation(named: "Roadmap.kanban") - defer { location.tearDown() } + @Test("Every bundled template folder loads through the ordinary loader") + func everyBundledTemplateLoads() throws { + let store = try #require(TemplateEngine.bundledStore) + let folders = TemplateEngine.templateFolders(in: store) + #expect(!folders.isEmpty, "the app ships at least one template") - try BoardTemplate.basic.instantiate(at: location.url) - - let result = try BoardLoader.load(boardRoot: location.url) - #expect(result.warnings.isEmpty, "a board this app just wrote must load clean") - #expect(result.model.lanes.map { $0.title.value } == ["To Do", "Doing", "Done"]) - #expect(result.model.lanes.allSatisfy { !$0.isDeleted }) - #expect(result.model.lanes.allSatisfy { $0.cards.isEmpty }, "the Basic scaffold seeds no cards") + for folder in folders { + let result = TemplateEngine.load(templateAt: folder, origin: .bundled) + if case let .failure(error) = result { + Issue.record("bundled template \(folder.lastPathComponent) failed to load: \(error.description)") + } + } + #expect(TemplateEngine.bundledTemplates().count == folders.count) } - @Test("The lanes are ranked by the board convention — 1024 apart, from 1024") - func lanesAreRankedByTheAppendConvention() throws { - let location = try temporaryLocation(named: "Ranked.kanban") - defer { location.tearDown() } + @Test("Basic is bundled, first, and the plain To Do / Done scaffold") + func basicIsTheFirstBundledTemplate() throws { + let basic = try #require(TemplateEngine.bundledTemplates().first) - try BoardTemplate.basic.instantiate(at: location.url) - - let orders = try BoardLoader.load(boardRoot: location.url).model.lanes.map(\.order) - #expect(orders == [1024, 2048, 3072], "each lane appends after the one before it") + #expect(basic.slug == "basic", "09 calls the bundle folder name the template's stable slug") + #expect(basic.origin == .bundled) + #expect(basic.name == "Basic") + #expect(basic.lanes.map { $0.title.value } == ["To Do", "Done"]) + #expect(basic.lanes.allSatisfy { $0.cards.isEmpty }, "the Basic scaffold seeds no cards") } - @Test("The board's title is the document name the user chose, not the template's") - func titleComesFromTheChosenName() throws { - let location = try temporaryLocation(named: "Q3 Planning.kanban") - defer { location.tearDown() } + @Test("Name, blurb, icon and chooser order all come off the template board's own index.md") + func pickerMetadataIsReadFromTheBoard() throws { + let basic = try #require(TemplateEngine.bundledTemplates().first) - try BoardTemplate.basic.instantiate(at: location.url) - - // 01-storage-format.md § Board naming: display name and folder name start out matching. - #expect(try BoardLoader.load(boardRoot: location.url).model.title.value == "Q3 Planning") + #expect(basic.name == basic.model.title.value) + #expect(!basic.blurb.isEmpty, "the body is the picker blurb and the new board's description") + #expect(basic.blurb == basic.model.document.body.trimmingCharacters(in: .whitespacesAndNewlines)) + #expect(basic.icon == basic.model.icon.value) + #expect(basic.iconColor == basic.model.iconColor.value) + #expect(basic.order == 100, "template: {order: 100} is the chooser position") } - @Test("An extension-less location is as legal a board, and keeps its whole name as the title") - func extensionlessLocationWorks() throws { - let location = try temporaryLocation(named: "Plain") - defer { location.tearDown() } + @Test("An icon the running system cannot draw falls back to the board default") + func unknownIconFallsBackToTheDefault() throws { + let fixture = try TemplateFixture(named: "Odd Icon") + defer { fixture.tearDown() } + try fixture.board(icon: "not.a.real.symbol.name") - try BoardTemplate.basic.instantiate(at: location.url) + let template = try fixture.template() - #expect(try BoardLoader.load(boardRoot: location.url).model.title.value == "Plain") + #expect(template.icon == ItemSymbol.board) } - @Test("Every lane folder is a fresh lowercase UUID") - func laneFoldersAreMintedIdentities() throws { - let location = try temporaryLocation(named: "Minted.kanban") - defer { location.tearDown() } + // MARK: template.order - try BoardTemplate.basic.instantiate(at: location.url) + @Test("A keyless board dropped in the store is a template with no order") + func keylessTemplateHasNoOrder() throws { + let fixture = try TemplateFixture(named: "Hand Dropped") + defer { fixture.tearDown() } + try fixture.board() - let ids = try BoardLoader.load(boardRoot: location.url).model.lanes.map(\.id.rawValue) - #expect(ids.count == 3) - #expect(Set(ids).count == 3, "three lanes, three identities") - #expect(ids.allSatisfy { $0 == $0.lowercased() }, "the app emits lowercase UUIDs") - #expect(ids.allSatisfy { UUID(uuidString: $0) != nil }) + #expect(try fixture.template().order == nil, "no template: key is required of a user template") } - @Test("Instantiating over an existing board refuses rather than clobbering it") - func refusesToOverwriteAnExistingBoard() throws { - let location = try temporaryLocation(named: "Taken.kanban") - defer { location.tearDown() } - try BoardTemplate.basic.instantiate(at: location.url) - let before = try Data(contentsOf: location.url.appendingPathComponent("index.md")) + @Test("A malformed template: key reads as keyless rather than as a failure") + func malformedTemplateKeyIsKeyless() throws { + let fixture = try TemplateFixture(named: "Odd Key") + defer { fixture.tearDown() } + try fixture.board(extraKeys: "template: [1, 2]") - let error = writeFailure { try BoardTemplate.basic.instantiate(at: location.url) } - - #expect(error?.operation == .createBoard) - #expect( - try Data(contentsOf: location.url.appendingPathComponent("index.md")) == before, - "the existing board is untouched — a create never replaces one" - ) + #expect(try fixture.template().order == nil) } - @Test("The document name behind a chosen URL is the folder name without its extension") - func documentNameStripsTheExtension() { - #expect(BoardTemplate.documentName(of: URL(fileURLWithPath: "/Boards/Roadmap.kanban")) == "Roadmap") - #expect(BoardTemplate.documentName(of: URL(fileURLWithPath: "/Boards/Roadmap")) == "Roadmap") + @Test("A float order is as good as an integer one") + func floatOrderReads() throws { + let fixture = try TemplateFixture(named: "Float") + defer { fixture.tearDown() } + try fixture.board(extraKeys: "template: {order: 12.5}") + + #expect(try fixture.template().order == 12.5) } - @Test("The chooser offers Basic, and Basic is first") - func inventoryHoldsBasicFirst() { - // m9-templates: this becomes the bundled inventory's ten, with Basic still first - // (09-templates.md ▸ Inventory). - #expect(BoardTemplate.all.first == BoardTemplate.basic) - #expect(BoardTemplate.basic.slug == "basic") + // MARK: Chooser order + + @Test("Chooser order is template.order, then display name for the keyless tier") + func chooserOrderIsKeyedThenNamed() throws { + let fixture = try TemplateFixture(named: "Sorting") + defer { fixture.tearDown() } + + let second = try fixture.freeStandingBoard(named: "b", title: "Second", extraKeys: "template: {order: 200}") + let first = try fixture.freeStandingBoard(named: "a", title: "First", extraKeys: "template: {order: 100}") + let zebra = try fixture.freeStandingBoard(named: "z", title: "Zebra") + let apple = try fixture.freeStandingBoard(named: "y", title: "Apple") + + let sorted = TemplateEngine.sortedForChooser([zebra, second, apple, first]) + + #expect(sorted.map(\.name) == ["First", "Second", "Apple", "Zebra"]) + } +} + +// MARK: - Fixture + +/// A temp store holding hand-written template board folders — the user store's shape, minus +/// Application Support (which no test may touch). +struct TemplateFixture { + + let store: URL + let boardURL: URL + + init(named name: String) throws { + store = FileManager.default.temporaryDirectory + .appendingPathComponent("TemplateTests-\(UUID().uuidString)", isDirectory: true) + boardURL = store.appendingPathComponent("\(name).kanban", isDirectory: true) + try FileManager.default.createDirectory(at: store, withIntermediateDirectories: true) + } + + func tearDown() { + try? FileManager.default.removeItem(at: store) + } + + /// The fixture's own board, with one lane so it is a board worth previewing. + func board(title: String = "Fixture", icon: String? = nil, extraKeys: String = "") throws { + try write(board: boardURL, title: title, icon: icon, extraKeys: extraKeys) + } + + /// A second board in the same store — the sorting tests need several. + func freeStandingBoard(named name: String, title: String, extraKeys: String = "") throws -> BoardTemplate { + let url = store.appendingPathComponent("\(name).kanban", isDirectory: true) + try write(board: url, title: title, icon: nil, extraKeys: extraKeys) + return try loadTemplate(at: url) + } + + func template() throws -> BoardTemplate { + try loadTemplate(at: boardURL) + } + + private func loadTemplate(at url: URL) throws -> BoardTemplate { + switch TemplateEngine.load(templateAt: url, origin: .user) { + case let .success(template): return template + case let .failure(error): throw error + } + } + + private func write(board url: URL, title: String, icon: String?, extraKeys: String) throws { + let lane = url.appendingPathComponent("11111111-1111-4111-8111-111111111111", isDirectory: true) + try FileManager.default.createDirectory(at: lane, withIntermediateDirectories: true) + + var keys = ["schema: 1", "title: \(title)"] + if let icon { keys.append("icon: \(icon)") } + if !extraKeys.isEmpty { keys.append(extraKeys) } + + try Data("---\n\(keys.joined(separator: "\n"))\n---\nBlurb.\n".utf8) + .write(to: url.appendingPathComponent("index.md")) + try Data("---\nschema: 1\ntitle: Lane\norder: 1024\n---\n".utf8) + .write(to: lane.appendingPathComponent("index.md")) } } diff --git a/KanbanTests/TemplateEngineTests.swift b/KanbanTests/TemplateEngineTests.swift new file mode 100644 index 0000000..a786100 --- /dev/null +++ b/KanbanTests/TemplateEngineTests.swift @@ -0,0 +1,542 @@ +import Foundation +import Testing +@testable import Kanban + +/// Template **instantiation** — "create a board from this template", on disk (09-templates.md +/// ▸ Instantiation). +/// +/// The round trip is the heart of it: instantiate, then load the result through the ordinary +/// `BoardLoader` and ask whether what came back is a board born today. Everything 09 promises is an +/// assertion about those bytes — +/// +/// - copied, **minus `.trash/` and `.git`**, and minus nothing else; +/// - **fresh GUIDs** at every level — no identity survives from the template; +/// - **fresh `created`/`modified`**, `modified-by` cleared — born today, not forked; +/// - `title` = the name the user typed into the save panel; +/// - `template:` carried and inert; strays, bodies and attachment bytes verbatim. +/// +/// — plus the promise that makes it safe to run at all: **nothing half-made is ever left at the +/// destination**, whether the user cancelled or the disk said no. + +// MARK: - Helpers + +/// `writeFailure`'s twin for the engine's two-outcome vocabulary. +private func instantiationFailure(_ operation: () throws -> Void) -> TemplateEngine.Failure? { + do { + try operation() + Issue.record("expected the instantiation to fail, but it succeeded") + return nil + } catch let failure as TemplateEngine.Failure { + return failure + } catch { + Issue.record("expected a TemplateEngine.Failure, got \(error)") + return nil + } +} + +/// Every path under `root`, root-relative, hidden entries included. +private func tree(of root: URL) -> Set { + var paths: Set = [] + let walker = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil, options: []) + while let url = walker?.nextObject() as? URL { + paths.insert(url.path.replacingOccurrences(of: root.path + "/", with: "")) + } + return paths +} + +/// Every UUID-shaped folder name under `root` — the identities a copy either kept or reminted. +private func identities(under root: URL) -> Set { + Set(tree(of: root) + .flatMap { $0.split(separator: "/").map(String.init) } + .filter(BoardLoader.isUUIDShaped) + .map { $0.lowercased() }) +} + +/// The two GUIDs the bundled Basic template ships with — hard-coded on purpose: "no id survives +/// from the template" is only an assertion if the test knows the ids it is looking for. +private enum BundledBasic { + static let toDo = "3f2a9c41-6b0e-4d7a-9f31-2c8b5e0a7d14" + static let done = "8d3b1f22-4a7c-4e59-8b06-1d9f3c2e6a85" +} + +private func bundledBasic() throws -> BoardTemplate { + try #require(TemplateEngine.bundledTemplates().first { $0.slug == "basic" }) +} + +// MARK: - The hand-built template + +/// A template carrying everything 09 and 01 have a rule about: the two exclusions, a legacy +/// `deleted:` key, strays at board and lane level, a symlink, an attachment, and a loose file +/// beside a card's `index.md`. +private struct FixtureTemplate { + + let fixture: WriterFixture + let root: URL + + static let name = "Fixture.kanban" + static let blurb = "A fixture blurb, which becomes the new board's description.\n" + + init() throws { + fixture = try WriterFixture() + root = fixture.url(Self.name) + let path = Self.name + + // The board: a template key, an agent overlay, old stamps, and someone else's attribution. + try fixture.item(path, """ + --- + schema: 1 + title: Fixture Template + icon: rectangle.split.3x1 + iconColor: fern + template: {order: 42} + project: lanework + created: 2026-01-01T09:00:00Z + modified: 2026-02-02T09:00:00Z + modified-by: claude + --- + \(Self.blurb) + """) + + // Board-level strays — "the copy is literal apart from the stated exclusions". + try fixture.file("\(path)/CLAUDE.user.md", Data("board instructions\n".utf8)) + try fixture.file("\(path)/.gitignore", Data(".DS_Store\n".utf8)) + + // The two exclusions. + try fixture.file("\(path)/.git/HEAD", Data("ref: refs/heads/main\n".utf8)) + try FileManager.default.createDirectory( + at: root.appendingPathComponent(".git/objects", isDirectory: true), + withIntermediateDirectories: true + ) + try fixture.item("\(path)/.trash/\(Ident.card4)", Item.rich(order: "1024", title: "Thrown Away")) + + // A symlink, never traversed, copied as a link. + try FileManager.default.createSymbolicLink( + atPath: root.appendingPathComponent("link.md").path, + withDestinationPath: "../outside.txt" + ) + + // A lane with a starter card: an attachment, a loose file, and a body. + try fixture.item("\(path)/\(Ident.lane1)", Item.rich(order: "1024", title: "To Do")) + try fixture.item("\(path)/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Starter")) + try fixture.file("\(path)/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01, 0x02, 0x03])) + try fixture.file("\(path)/\(Ident.lane1)/\(Ident.card1)/notes.txt", Data("loose\n".utf8)) + + // A card an older app version tombstoned in place — the legacy `deleted:` key. + try fixture.item("\(path)/\(Ident.lane1)/\(Ident.card2)", """ + --- + schema: 1 + title: Legacy Tombstone + order: 2048 + deleted: 2026-01-01T00:00:00Z + --- + still here + """) + + // A second lane with a stray folder of its own — lane-level strays stay verbatim. + try fixture.item("\(path)/\(Ident.lane2)", Item.rich(order: "2048", title: "Done")) + try fixture.file("\(path)/\(Ident.lane2)/notes/scratch.md", Data("scratch\n".utf8)) + } + + func tearDown() { fixture.tearDown() } + + func template() throws -> BoardTemplate { + switch TemplateEngine.load(templateAt: root, origin: .user) { + case let .success(template): return template + case let .failure(error): throw error + } + } + + /// Where an instantiation lands — a sibling of the template inside the same temp root, which is + /// also what makes "nothing was left behind" a one-line assertion. + func destination(named name: String = "New Board.kanban") -> URL { + fixture.url(name) + } +} + +// MARK: - Round trip + +@Suite("TemplateEngine — the bundled round trip") +struct TemplateEngineRoundTripTests { + + @Test("Instantiating Basic produces a board that loads clean") + func basicLoadsClean() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let destination = fixture.url("Q3 Planning.kanban") + + try TemplateEngine.instantiate(template: try bundledBasic(), to: destination, title: "Q3 Planning") + + let result = try BoardLoader.load(boardRoot: destination) + #expect(result.warnings.isEmpty, "a board the app just instantiated must load without a murmur") + #expect(result.looseCardFiles.isEmpty) + #expect(result.legacyTombstones.isEmpty) + #expect(result.model.lanes.map { $0.title.value } == ["To Do", "Done"]) + #expect(result.model.lanes.map(\.order) == [1024, 2048]) + } + + @Test("The title is the document name the user chose, not the template's") + func titleIsTheChosenName() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let destination = fixture.url("Q3 Planning.kanban") + + try TemplateEngine.instantiate( + template: try bundledBasic(), + to: destination, + title: TemplateEngine.documentName(of: destination) + ) + + // 01-storage-format.md § Board naming: display name and folder name start out matching. + #expect(try BoardLoader.load(boardRoot: destination).model.title.value == "Q3 Planning") + } + + @Test("An extension-less location is as legal a board, and keeps its whole name as the title") + func extensionlessLocationWorks() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let destination = fixture.url("Plain") + + try TemplateEngine.instantiate( + template: try bundledBasic(), + to: destination, + title: TemplateEngine.documentName(of: destination) + ) + + #expect(try BoardLoader.load(boardRoot: destination).model.title.value == "Plain") + } + + @Test("No id survives from the template — every lane folder is a fresh mint") + func everyIdentityIsReminted() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let destination = fixture.url("Minted.kanban") + + try TemplateEngine.instantiate(template: try bundledBasic(), to: destination, title: "Minted") + + let minted = identities(under: destination) + #expect(minted.count == 2) + #expect(minted.isDisjoint(with: [BundledBasic.toDo, BundledBasic.done]), + "template GUIDs are inert — instantiation remints at its own boundary") + #expect(minted.allSatisfy { $0 == $0.lowercased() && UUID(uuidString: $0) != nil }) + } + + @Test("Born today: created and modified are fresh, and modified-by is absent") + func stampsAreFresh() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let destination = fixture.url("Born.kanban") + let start = Date() + + try TemplateEngine.instantiate(template: try bundledBasic(), to: destination, title: "Born") + + let model = try BoardLoader.load(boardRoot: destination).model + let template = try bundledBasic().model + + let boardCreated = try #require(model.created.value) + #expect(boardCreated.timeIntervalSince(start) > -2, "born today, not forked from the template") + #expect(boardCreated != template.created.value) + #expect(try #require(model.modified.value).timeIntervalSince(start) > -2) + #expect(model.modifiedBy.isMissing) + + for lane in model.lanes { + #expect(try #require(lane.created.value).timeIntervalSince(start) > -2) + #expect(try #require(lane.modified.value).timeIntervalSince(start) > -2) + #expect(lane.modifiedBy.isMissing) + } + } + + @Test("The template: key is carried onto the instantiated board, inert") + func templateKeyIsCarriedInert() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let destination = fixture.url("Inert.kanban") + + try TemplateEngine.instantiate(template: try bundledBasic(), to: destination, title: "Inert") + + let model = try BoardLoader.load(boardRoot: destination).model + let template = try bundledBasic().model + #expect(model.template == template.template, + "kept, ignored, and preserved like any unknown key") + #expect(model.document.body == template.document.body, + "the blurb becomes the new board's description, byte for byte") + } + + @Test("The icon and its tint are inherited from the template") + func iconIsInherited() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let destination = fixture.url("Styled.kanban") + let basic = try bundledBasic() + + try TemplateEngine.instantiate(template: basic, to: destination, title: "Styled") + + let model = try BoardLoader.load(boardRoot: destination).model + #expect(model.icon.value == basic.model.icon.value) + #expect(model.iconColor.value == basic.model.iconColor.value) + } + + @Test("The bundled template itself is never touched by instantiating it") + func theTemplateIsReadOnly() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let basic = try bundledBasic() + let before = tree(of: basic.url) + let bytes = try Data(contentsOf: basic.url.appendingPathComponent("index.md")) + + try TemplateEngine.instantiate(template: basic, to: fixture.url("Copy.kanban"), title: "Copy") + + #expect(tree(of: basic.url) == before) + #expect(try Data(contentsOf: basic.url.appendingPathComponent("index.md")) == bytes) + } +} + +// MARK: - The hand-built template + +@Suite("TemplateEngine — a hand-dropped template's edges") +struct TemplateEngineFixtureTests { + + @Test("`.git` and `.trash/` are the two exclusions, and they are the only ones") + func theTwoExclusionsAreExcluded() throws { + let source = try FixtureTemplate() + defer { source.tearDown() } + let destination = source.destination() + + try TemplateEngine.instantiate(template: try source.template(), to: destination, title: "New Board") + + let paths = tree(of: destination) + #expect(!paths.contains { $0 == ".git" || $0.hasPrefix(".git/") }, + "a template is content, not history — no board is silently born in git mode") + #expect(!paths.contains { $0 == ".trash" || $0.hasPrefix(".trash/") }, + "a new board isn't born with trash") + // And everything else did come along — `.gitignore` included, which is a stray the + // exclusion must not swallow: the two exclusions are exact names, not prefixes. + #expect(paths.contains("CLAUDE.user.md")) + #expect(paths.contains(".gitignore")) + #expect(paths.contains("link.md")) + #expect(paths.contains { $0.hasSuffix("notes/scratch.md") }, "a lane-level stray folder is a resident") + } + + @Test("Strays and attachments arrive byte for byte") + func straysAndAttachmentsAreVerbatim() throws { + let source = try FixtureTemplate() + defer { source.tearDown() } + let destination = source.destination() + + try TemplateEngine.instantiate(template: try source.template(), to: destination, title: "New Board") + + #expect(try Data(contentsOf: destination.appendingPathComponent("CLAUDE.user.md")) + == Data("board instructions\n".utf8)) + #expect(try Data(contentsOf: destination.appendingPathComponent(".gitignore")) + == Data(".DS_Store\n".utf8)) + + let card = try #require(cardFolders(under: destination).first { folder in + FileManager.default.fileExists(atPath: folder.appendingPathComponent("attachments/shot.png").path) + }) + #expect(try Data(contentsOf: card.appendingPathComponent("attachments/shot.png")) == Data([0x01, 0x02, 0x03])) + } + + @Test("A card's body survives the fresh stamps — frontmatter is edited, bytes are not rewritten") + func bodiesSurviveTheRestamp() throws { + let source = try FixtureTemplate() + defer { source.tearDown() } + let destination = source.destination() + + try TemplateEngine.instantiate(template: try source.template(), to: destination, title: "New Board") + + let model = try BoardLoader.load(boardRoot: destination).model + let starter = try #require(model.lanes.flatMap(\.cards).first { $0.title.value == "Starter" }) + #expect(starter.document.body == "Starter body — with *markdown*.\n") + #expect(starter.document.value(for: "project") == .string("lanework"), + "unknown keys ride along, comment and all") + #expect(starter.modifiedBy.isMissing, "an app-mediated write clears a foreign attribution") + #expect(try #require(starter.created.value).timeIntervalSinceNow > -60, "born today") + + // The board's own blurb is its description now, untouched. + #expect(model.document.body == FixtureTemplate.blurb) + } + + @Test("A symlink is copied as a link, never traversed") + func symlinksArriveAsLinks() throws { + let source = try FixtureTemplate() + defer { source.tearDown() } + let destination = source.destination() + + try TemplateEngine.instantiate(template: try source.template(), to: destination, title: "New Board") + + let link = destination.appendingPathComponent("link.md") + let values = try link.resourceValues(forKeys: [.isSymbolicLinkKey]) + #expect(values.isSymbolicLink == true) + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: link.path) == "../outside.txt", + "the link itself travels, never its target") + } + + @Test("A loose file beside a card's index.md lands in attachments/ — instantiation is an import boundary") + func looseCardFilesAreNormalizedOnArrival() throws { + let source = try FixtureTemplate() + defer { source.tearDown() } + let destination = source.destination() + + try TemplateEngine.instantiate(template: try source.template(), to: destination, title: "New Board") + + let result = try BoardLoader.load(boardRoot: destination) + #expect(result.looseCardFiles.isEmpty, + "the new board lands already normalized rather than opening with a notice about its own birth") + + let starter = try #require(result.model.lanes.flatMap(\.cards).first { $0.title.value == "Starter" }) + #expect(starter.attachments == ["notes.txt", "shot.png"]) + + let card = try #require(cardFolders(under: destination).first { folder in + FileManager.default.fileExists(atPath: folder.appendingPathComponent("attachments/notes.txt").path) + }) + #expect(!FileManager.default.fileExists(atPath: card.appendingPathComponent("notes.txt").path)) + #expect(try Data(contentsOf: card.appendingPathComponent("attachments/notes.txt")) == Data("loose\n".utf8)) + } + + @Test("A legacy deleted: key copies through and is the store's to migrate, not the engine's") + func legacyTombstonesCopyThroughForTheOneMigrator() throws { + let source = try FixtureTemplate() + defer { source.tearDown() } + let destination = source.destination() + + try TemplateEngine.instantiate(template: try source.template(), to: destination, title: "New Board") + + let result = try BoardLoader.load(boardRoot: destination) + let tombstoned = try #require(result.model.lanes.flatMap(\.cards).first { $0.title.value == "Legacy Tombstone" }) + #expect(tombstoned.isDeleted, "neither stripped (a silent resurrection) nor dropped (destroyed content)") + #expect(result.legacyTombstones.count == 1, + "the new board's first load hands it to 01's one migrator, exactly as any other board's would") + } + + @Test("Every identity in the tree is fresh — cards and strays under a card included") + func everyIdentityIsReminted() throws { + let source = try FixtureTemplate() + defer { source.tearDown() } + let destination = source.destination() + + try TemplateEngine.instantiate(template: try source.template(), to: destination, title: "New Board") + + let minted = identities(under: destination) + #expect(minted.count == 4, "two lanes and two cards — the trashed card was not copied") + #expect(minted.isDisjoint(with: identities(under: source.root))) + } + + @Test("The template survives its own instantiation untouched") + func theTemplateIsNeverWritten() throws { + let source = try FixtureTemplate() + defer { source.tearDown() } + let before = tree(of: source.root) + let boardBytes = try Data(contentsOf: source.root.appendingPathComponent("index.md")) + + try TemplateEngine.instantiate(template: try source.template(), to: source.destination(), title: "New Board") + + #expect(tree(of: source.root) == before, "the loose file was normalized in the copy, not in the template") + #expect(try Data(contentsOf: source.root.appendingPathComponent("index.md")) == boardBytes) + } +} + +// MARK: - Nothing half-made + +@Suite("TemplateEngine — the destination is all or nothing") +struct TemplateEngineAtomicityTests { + + @Test("Cancelling mid-walk leaves nothing at the destination") + func cancellingRemovesThePartialBoard() throws { + let source = try FixtureTemplate() + defer { source.tearDown() } + let destination = source.destination() + + // Trips on the fourth read: the destination exists by then and the walk is inside it, so + // there is a genuine partial tree to remove rather than nothing to clean up. + var reads = 0 + let failure = instantiationFailure { + try TemplateEngine.instantiate( + template: try source.template(), + to: destination, + title: "New Board", + isCancelled: { + reads += 1 + return reads > 3 + } + ) + } + + #expect(failure == TemplateEngine.Failure.cancelled) + #expect(!FileManager.default.fileExists(atPath: destination.path), + "a cancelled create never happened") + #expect(try Set(source.fixture.entryNames("")) == [FixtureTemplate.name]) + } + + @Test("Cancelling before the first item never creates the destination at all") + func cancellingBeforeTheWalkCreatesNothing() throws { + let source = try FixtureTemplate() + defer { source.tearDown() } + + let failure = instantiationFailure { + try TemplateEngine.instantiate( + template: try source.template(), + to: source.destination(), + title: "New Board", + isCancelled: { true } + ) + } + + #expect(failure == TemplateEngine.Failure.cancelled) + #expect(try Set(source.fixture.entryNames("")) == [FixtureTemplate.name]) + } + + @Test("A destination that is already taken is refused cleanly, and never clobbered") + func collisionIsRefusedWithoutTouchingWhatIsThere() throws { + let source = try FixtureTemplate() + defer { source.tearDown() } + let destination = source.destination(named: "Taken.kanban") + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + try Data("mine\n".utf8).write(to: destination.appendingPathComponent("index.md")) + + let failure = instantiationFailure { + try TemplateEngine.instantiate(template: try source.template(), to: destination, title: "Taken") + } + + guard case let .failed(error) = failure else { + Issue.record("expected an ordinary failure, got \(String(describing: failure))") + return + } + #expect(error.operation == .createBoard) + #expect(error.path == destination.path) + #expect(try Data(contentsOf: destination.appendingPathComponent("index.md")) == Data("mine\n".utf8), + "an existing name is the user's — a create never replaces one") + #expect(try Set(FileManager.default.contentsOfDirectory(atPath: destination.path)) == ["index.md"]) + } + + @Test("A template that is not there fails as a create, naming the path") + func aMissingTemplateFailsCleanly() throws { + let source = try FixtureTemplate() + defer { source.tearDown() } + let template = try source.template() + try FileManager.default.removeItem(at: source.root) + let destination = source.destination() + + let failure = instantiationFailure { + try TemplateEngine.instantiate(template: template, to: destination, title: "New Board") + } + + guard case .failed = failure else { + Issue.record("expected an ordinary failure, got \(String(describing: failure))") + return + } + #expect(!FileManager.default.fileExists(atPath: destination.path), + "the destination this call made goes with the failure") + } +} + +// MARK: - Shared + +/// Every card folder under an instantiated board — `//`, by the loader's own level +/// detection, so the tests never hard-code a minted identity they cannot know. +private func cardFolders(under root: URL) -> [URL] { + let lanes = ((try? BoardLoader.directoryCandidates(in: root)) ?? []) + .filter { BoardLoader.isUUIDShaped($0.lastPathComponent) } + return lanes.flatMap { lane in + ((try? BoardLoader.directoryCandidates(in: lane)) ?? []) + .filter { BoardLoader.isUUIDShaped($0.lastPathComponent) } + } +} diff --git a/README.md b/README.md index 8d7b0cc..f4bfc87 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Lanework is in early development. This list tracks what has actually shipped and - **The welcome screen** — branding and two actions on the left, recents on the right: board icon, name, containing folder, and the lane/card counts stamped at last close, newest first. The list never opens a board to build itself, so a huge board or an offline volume costs nothing. Single click selects, double click or Return opens, and a context menu carries Open, Reveal in Finder, and Forget. A board that failed to open or restore says so **on its own row**, in the warning tint, carrying the loader's specifics rather than a modal at launch; a board whose bookmark no longer resolves dims to Unavailable with Open and Reveal off and Forget still live; and a failure naming no known board keeps a list of its own rather than vanishing. File ▸ Open Recent lists the same boards — unavailable ones disabled — with Clear Menu at the bottom, which forgets every record because here the registry *is* the menu. -- **New Board and Duplicate** — File ▸ New Board… (⌥⌘N) opens a Pages-style template chooser with a mini per-lane preview per template, then a save panel for the location; the new board's frontmatter title is the document name you chose, so the window title and the Finder name start out matching. File ▸ Duplicate (⇧⌘S) forks the frontmost board to a Finder-style "Board copy" sibling (then "copy 2", "copy 3"), preceded by the pending-work flush so the copy misses nothing and with the original left open beside it. The copy is literal: every GUID kept — a whole-board copy is a new identity namespace — `.trash/` carried along so the copy matches its own copied history, strays and timestamps untouched. +- **New Board and Duplicate** — File ▸ New Board… (⌥⌘N) opens a Pages-style template chooser with a mini per-lane preview per template, then a save panel seeded with the template's own name; the new board's frontmatter title is the document name you chose, so the window title and the Finder name start out matching. **A template is itself a board** — an ordinary schema-valid folder read by the ordinary loader, so its display name, icon, blurb and lanes all come off its own `index.md`, and authoring one is adding a folder rather than writing code. Creating from one copies that folder: fresh GUIDs for every lane and card, `created`/`modified` stamped today (born, not forked from the template), the blurb becoming the new board's description, attachments and card bodies byte for byte, strays and symlinks carried verbatim — and `.git` and `.trash/` deliberately left behind, so a new board is never silently in git mode and never born with trash. Nothing half-made is ever left where you pointed: a create that fails or is cancelled removes its own partial, and a name already taken is refused rather than replaced. File ▸ Duplicate (⇧⌘S) forks the frontmost board to a Finder-style "Board copy" sibling (then "copy 2", "copy 3"), preceded by the pending-work flush so the copy misses nothing and with the original left open beside it. The copy is literal: every GUID kept — a whole-board copy is a new identity namespace — `.trash/` carried along so the copy matches its own copied history, strays and timestamps untouched. - **The card window** — ⌘↩ or a double-click opens a card in its own window: two full-height, independently scrolling columns — a wide body column and a narrow attributes sidebar whose fixed width is derived from font metrics, so the window's resize flex all goes to the body. The title bar carries the card's title live and subtitles it "⟨board⟩ › ⟨lane⟩", following the card as it moves between lanes and re-reading the board's name as it's renamed. The body column shows the title, a quiet created/modified/by line built from whichever frontmatter keys exist, and the body itself; the sidebar leads with the Attachments section (below) and its remaining sections are stacked headers awaiting their content. Reopening a card focuses the window it already has, and the window closes itself the moment its card stops being on the board — moved to the trash (entering the trash counts as deleted), gone with its deleted lane, purged, or moved to another board; a dirty Edit buffer flushes into the card's new location first, so the keystrokes survive a later restore. diff --git a/Templates/basic.kanban/3f2a9c41-6b0e-4d7a-9f31-2c8b5e0a7d14/index.md b/Templates/basic.kanban/3f2a9c41-6b0e-4d7a-9f31-2c8b5e0a7d14/index.md new file mode 100644 index 0000000..0dffbaf --- /dev/null +++ b/Templates/basic.kanban/3f2a9c41-6b0e-4d7a-9f31-2c8b5e0a7d14/index.md @@ -0,0 +1,7 @@ +--- +schema: 1 +title: To Do +order: 1024 +created: 2026-01-15T09:00:00Z +modified: 2026-01-15T09:00:00Z +--- diff --git a/Templates/basic.kanban/8d3b1f22-4a7c-4e59-8b06-1d9f3c2e6a85/index.md b/Templates/basic.kanban/8d3b1f22-4a7c-4e59-8b06-1d9f3c2e6a85/index.md new file mode 100644 index 0000000..4111075 --- /dev/null +++ b/Templates/basic.kanban/8d3b1f22-4a7c-4e59-8b06-1d9f3c2e6a85/index.md @@ -0,0 +1,7 @@ +--- +schema: 1 +title: Done +order: 2048 +created: 2026-01-15T09:00:00Z +modified: 2026-01-15T09:00:00Z +--- diff --git a/Templates/basic.kanban/index.md b/Templates/basic.kanban/index.md new file mode 100644 index 0000000..ca90393 --- /dev/null +++ b/Templates/basic.kanban/index.md @@ -0,0 +1,10 @@ +--- +schema: 1 +title: Basic +icon: rectangle.split.3x1 +iconColor: deep-sky-blue +template: {order: 100} +created: 2026-01-15T09:00:00Z +modified: 2026-01-15T09:00:00Z +--- +Two lanes to move work through — the plain scaffold. diff --git a/project.yml b/project.yml index b39d4d9..e2a8261 100644 --- a/project.yml +++ b/project.yml @@ -43,6 +43,13 @@ targets: platform: macOS sources: - Kanban + # The bundled template store (09-templates.md ▸ Definition format): real board folders, + # copied into `Contents/Resources/Templates/` verbatim as a folder reference — the same + # arrangement `Fixtures/` uses below, and for the same reason. A template is a board, so it + # must reach the bundle as directories on disk, not as flattened resource files. + - path: Templates + type: folder + buildPhase: resources dependencies: &appDependencies - package: Yams - package: swift-markdown @@ -88,6 +95,10 @@ targets: excludes: - Info.plist - KanbanPro.entitlements + # The same bundled template store — the inventory is the app's, not an edition's. + - path: Templates + type: folder + buildPhase: resources dependencies: *appDependencies postBuildScripts: *updateBuildInfo settings: