Build the template chooser and Save as Template
The chooser completes its three tiers: bundled by template order, then keyed user templates, then keyless boards by display name — and a malformed user template still lists, by folder name with the loader's own sentence on the row, never failing its neighbours. The store is re-scanned on every presentation and on app activation, the Reveal round trip made honest without watching a folder 09 deliberately leaves unwatched; Reveal lives in the chooser's header and mints the store on first press. Save as Template repeats Duplicate's sequence — progress row with Cancel, flush, detached cancellable copy — through the engine: mint the store, read the next user order before the copy can count itself, Finder-ladder the name, copy excluding .git and .trash/, then stamp the whole template: mapping on the landed copy through updateIndex, with no bracket because the copy lives outside every watched board. Folder attributes deliberately don't carry — the one lock the command stays live under is the read-only-DMG one, and carrying its mode bits would mint a read-only template in the user's own store; the command gates instead on the real hazard, unsaved card content. A signpost names the template only when the ladder renamed it. One name ladder now serves Duplicate and the store. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -59,6 +59,15 @@ import os
|
||||
/// about a mess the instantiation itself made. Board- and lane-level strays keep the verbatim
|
||||
/// posture, exactly as the carve-out is scoped.
|
||||
///
|
||||
/// ### What a save is
|
||||
///
|
||||
/// Save as Template is the same walk pointed the other way — a board copied *into* the store, minus
|
||||
/// the same two exclusions — plus one write the app owes: the `template:` key, stamped on the copy
|
||||
/// through `BoardWriter.updateIndex`. That write is the whole of "the app never stamps a key into
|
||||
/// store files it didn't write itself … the one writer of keyed files is Save as Template" (09
|
||||
/// ▸ Storage): discovery, listing and instantiation are all reads, so a hand-dropped board in the
|
||||
/// store can never gain a key by being looked at. See `saveAsTemplate(boardAt:titled:into:)`.
|
||||
///
|
||||
/// ### 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
|
||||
@@ -167,22 +176,85 @@ enum TemplateEngine {
|
||||
return sortedForChooser(templates)
|
||||
}
|
||||
|
||||
/// Every folder in `store` as a **chooser row** — loaded templates and unloadable ones alike,
|
||||
/// in folder-name order (the caller sorts).
|
||||
///
|
||||
/// This is 09's one-bad-template-never-fails-the-chooser clause, and it is the whole of it: the
|
||||
/// walk cannot fail as a walk (a missing store is an empty list), and a folder the loader rejects
|
||||
/// becomes a row carrying the error rather than an omission or a thrown failure. Nothing here
|
||||
/// writes: **listing a store never stamps a key into it** — "a hand-dropped board is never
|
||||
/// touched" (09 ▸ Storage).
|
||||
static func rows(in store: URL, origin: BoardTemplate.Origin) -> [TemplateRow] {
|
||||
templateFolders(in: store).map { folder in
|
||||
switch load(templateAt: folder, origin: origin) {
|
||||
case let .success(template):
|
||||
.template(template)
|
||||
case let .failure(error):
|
||||
.unloadable(TemplateRow.Unloadable(url: folder, error: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The user tier, in 09's within-tier order — every board folder in the user store, whether or
|
||||
/// not it loads and whether or not it carries a `template:` key ("a board folder dropped in
|
||||
/// becomes a template — **no `template:` key required**").
|
||||
static func userRows(in store: URL = userStore) -> [TemplateRow] {
|
||||
sortedForChooser(rows(in: store, origin: .user))
|
||||
}
|
||||
|
||||
/// **The chooser's whole list**, in 09 ▸ Storage's three-tier order:
|
||||
///
|
||||
/// > Chooser order: bundled templates by `template.order`, then keyed user templates by
|
||||
/// > `template.order`, then keyless user boards last, sorted by display name.
|
||||
///
|
||||
/// The tiers are concatenated rather than sorted together, which is what makes "then" mean
|
||||
/// *then*: a user template carrying `order: 1` still lists after every bundled one, because the
|
||||
/// store it came from is the sort's outermost key. Within each tier the same two-step rule runs
|
||||
/// (`sortedForChooser`), so the tier boundary is the only thing this function decides.
|
||||
///
|
||||
/// **Re-read on every call**, and the chooser calls it on every presentation: the store is not
|
||||
/// watched (09 asks for a Reveal in Finder affordance, not a live folder), so a board dropped in
|
||||
/// while the chooser is open appears the next time the chooser is opened — or, since the drop
|
||||
/// usually happens in the Finder window Reveal just opened, when the app comes back to the
|
||||
/// front. `TemplateChooserView` wires both.
|
||||
static func chooserRows(userStore store: URL = userStore) -> [TemplateRow] {
|
||||
bundledTemplates().map(TemplateRow.template) + userRows(in: store)
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
chooserSorted(templates, order: \.order, name: \.name)
|
||||
}
|
||||
|
||||
/// The same rule over chooser rows, which is where it actually meets 09's keyless tier: an
|
||||
/// unloadable row reports no order at all, so it sorts by folder name among the keyless boards
|
||||
/// without this comparison having to know what an unloadable row is.
|
||||
static func sortedForChooser(_ rows: [TemplateRow]) -> [TemplateRow] {
|
||||
chooserSorted(rows, order: \.order, name: \.name)
|
||||
}
|
||||
|
||||
/// The comparison itself, once: keyed before keyless, `order` ascending among the keyed, name
|
||||
/// among the rest — and name as the tie-break between equal orders, so a hand-edited store with
|
||||
/// two `order: 100`s still lists in a stable, explicable sequence.
|
||||
private static func chooserSorted<Item>(
|
||||
_ items: [Item],
|
||||
order: (Item) -> Double?,
|
||||
name: (Item) -> String
|
||||
) -> [Item] {
|
||||
items.sorted { left, right in
|
||||
switch (order(left), order(right)) {
|
||||
case let (leftOrder?, rightOrder?):
|
||||
leftOrder == rightOrder
|
||||
? left.name.localizedStandardCompare(right.name) == .orderedAscending
|
||||
? name(left).localizedStandardCompare(name(right)) == .orderedAscending
|
||||
: leftOrder < rightOrder
|
||||
case (.some, .none):
|
||||
true
|
||||
case (.none, .some):
|
||||
false
|
||||
case (.none, .none):
|
||||
left.name.localizedStandardCompare(right.name) == .orderedAscending
|
||||
name(left).localizedStandardCompare(name(right)) == .orderedAscending
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,6 +417,194 @@ enum TemplateEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Save as Template
|
||||
|
||||
/// The spacing between user templates' `template.order` values — the bundled store's own
|
||||
/// spacing (100, 200, … 1000), so a hand-editor moving one template between two others has room
|
||||
/// to write a number in the gap.
|
||||
static let userOrderStep: Double = 100
|
||||
|
||||
/// The order the next Save as Template takes: **appended after existing user templates** (09
|
||||
/// ▸ Save as Template), which is the highest `template.order` in the store plus one step.
|
||||
///
|
||||
/// Read off the store's own rows rather than off a counter, because the store is hand-editable
|
||||
/// and a counter would be a second opinion about it. Keyless boards contribute nothing — they
|
||||
/// sort by name in their own tier and have no position to be appended after — and neither does
|
||||
/// an unloadable folder, which has no key to read.
|
||||
static func nextUserOrder(in store: URL = userStore) -> Double {
|
||||
guard let highest = rows(in: store, origin: .user).compactMap(\.order).max() else {
|
||||
return userOrderStep
|
||||
}
|
||||
return highest + userOrderStep
|
||||
}
|
||||
|
||||
/// Creates the user store if it is not there, and answers it.
|
||||
///
|
||||
/// **The store's two minters are Save as Template and Reveal in Finder** (see `userStore`, which
|
||||
/// only names it): a store that exists because the app made it on the off-chance would be an
|
||||
/// empty folder in Application Support for a user who never used the feature, while a Reveal
|
||||
/// that opened nothing — or a save that failed because its own home was missing — would be the
|
||||
/// app being pedantic about a directory it owns.
|
||||
@discardableResult
|
||||
static func createUserStore(at store: URL = userStore) throws -> URL {
|
||||
try FileManager.default.createDirectory(at: store, withIntermediateDirectories: true)
|
||||
return store
|
||||
}
|
||||
|
||||
/// Copies the board at `rootURL` into the user templates store and answers where it landed —
|
||||
/// 09 ▸ Save as Template, whose whole contract is the four rules below.
|
||||
///
|
||||
/// - **`.git` and `.trash/` are dropped** — the same two top-level exclusions instantiation
|
||||
/// uses, for two different halves of one reason: a template is content, not history ("copying
|
||||
/// it would embed the board's full repo, every attachment version included, in the template
|
||||
/// store"), and a template is not a fork, so the board's trash is not part of what is being
|
||||
/// saved. This is where Save as Template and File ▸ Duplicate part company — Duplicate carries
|
||||
/// both, because a duplicate *is* a fork (03-board-ui.md).
|
||||
/// - **Everything else copies verbatim**: "Strays copy through … `CLAUDE.user.md`, a seeded
|
||||
/// `.gitignore`, and other non-schema files carry through Save as Template *and* instantiation
|
||||
/// alike", along with GUIDs and timestamps — both inert, since instantiation remints and
|
||||
/// restamps at its own boundary.
|
||||
/// - **A `template:` key is written on the copy** with an order appended after the existing user
|
||||
/// templates, overwriting a stale one the board carried in from its own instantiation.
|
||||
/// - **Store collisions auto-rename, Finder-style**, never overwrite and never refuse
|
||||
/// (`BoardDuplicator.uncollidedURL(named:extension:in:)`, the ladder Duplicate seeds
|
||||
/// differently).
|
||||
///
|
||||
/// **The close flush is the caller's**, not this function's: it needs a window session, and 09
|
||||
/// states the rule where the command lives (`SaveAsTemplateCommand`, mirroring Duplicate's
|
||||
/// sequence exactly).
|
||||
///
|
||||
/// `isCancelled` is read between items and nowhere else — `BoardDuplicator`'s seam, and the
|
||||
/// in-progress row's Cancel at the other end of it. Cancelled or failed, the partial store entry
|
||||
/// goes: nothing was there before, so there is no true state for a half-copied template to be.
|
||||
@discardableResult
|
||||
static func saveAsTemplate(
|
||||
boardAt rootURL: URL,
|
||||
titled title: String?,
|
||||
into store: URL = userStore,
|
||||
isCancelled: () -> Bool = { Task.isCancelled }
|
||||
) throws(Failure) -> URL {
|
||||
let operation = WriteOperation.saveAsTemplate(title: title)
|
||||
|
||||
func failure(at url: URL, _ message: String) -> Failure {
|
||||
.failed(BoardWriteError(operation: operation, path: url.path, reason: .io(message: message)))
|
||||
}
|
||||
|
||||
// The store is minted here — first save, first folder.
|
||||
do {
|
||||
try createUserStore(at: store)
|
||||
} catch {
|
||||
throw failure(at: store, "could not create the templates folder: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
// **Read before the copy lands**, so the scan that decides "after the existing user
|
||||
// templates" cannot see the template being appended and count it as existing.
|
||||
let order = nextUserOrder(in: store)
|
||||
|
||||
let destination = BoardDuplicator.uncollidedURL(
|
||||
named: rootURL.deletingPathExtension().lastPathComponent,
|
||||
extension: rootURL.pathExtension,
|
||||
in: store
|
||||
)
|
||||
|
||||
// Cancelled before it began is still cancelled — answered before anything is created.
|
||||
if isCancelled() { throw .cancelled }
|
||||
|
||||
do {
|
||||
try BoardTreeCopy.createDirectory(at: destination)
|
||||
} catch {
|
||||
throw failure(at: destination, "could not create the template folder: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
do {
|
||||
try copyBoard(at: rootURL, into: destination, operation: operation, isCancelled: isCancelled)
|
||||
try stampTemplateKey(at: destination, order: order, operation: operation)
|
||||
} catch {
|
||||
// The ladder made this name and this call made this folder, so removing it destroys
|
||||
// nothing that was the user's — the instantiation cleanup's reasoning, at the store.
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
throw error
|
||||
}
|
||||
|
||||
return destination
|
||||
}
|
||||
|
||||
/// The copy half of a save: the board's tree minus the two exclusions.
|
||||
///
|
||||
/// **Folder attributes are not carried** (unlike Duplicate, which forks them). The store is
|
||||
/// specified as "plain board folders, hand-editable and agent-writable" (09 ▸ Storage), and the
|
||||
/// one lock Save as Template stays live under is the *unwritable-location* one — a board on a
|
||||
/// read-only DMG being archived (02-architecture.md ▸ Live-reload resilience). Carrying that
|
||||
/// board's mode bits inward would mint a read-only template in the user's own store, which is
|
||||
/// precisely the thing the store is not. Folder timestamps go with them and are inert: the
|
||||
/// timestamps 09 keeps are the frontmatter's, and those ride inside files copied byte for byte.
|
||||
private static func copyBoard(
|
||||
at rootURL: URL,
|
||||
into destination: URL,
|
||||
operation: WriteOperation,
|
||||
isCancelled: () -> Bool
|
||||
) throws(Failure) {
|
||||
do throws(BoardTreeCopy.Stop) {
|
||||
try BoardTreeCopy.copy(
|
||||
contentsOf: rootURL,
|
||||
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: operation,
|
||||
path: url.path,
|
||||
reason: .io(message: "could not copy the board: \(underlying.localizedDescription)")
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `template: {order: N}` on the copy's own `index.md`, through the Writer's ordinary
|
||||
/// `updateIndex` — the round-trip guarantee, the unknown-key preservation and the atomic replace
|
||||
/// all come along, and the body (the board's description, which is about to be the chooser's
|
||||
/// blurb) is never rewritten.
|
||||
///
|
||||
/// ### On the copy, after it lands
|
||||
///
|
||||
/// The only alternative — stamping the source board and copying the result — would write a
|
||||
/// `template:` key into the user's *board*, which is not what was asked for. So the write
|
||||
/// happens here, on a tree that is already in the store, and it needs **no write bracket**:
|
||||
/// brackets exist to keep a watched board's live snapshot honest (02-architecture.md), and this
|
||||
/// path is outside every watched board — the store is not watched and the source was only read.
|
||||
///
|
||||
/// ### The whole mapping is rewritten, and that is 09's shape
|
||||
///
|
||||
/// "**`order` (display position in the chooser) is its only subkey**" (09 ▸ Definition format),
|
||||
/// so replacing the mapping loses nothing that can exist today; a stale order from the board's
|
||||
/// own instantiation is overwritten, which is exactly what 09 asks for. If the key ever grows a
|
||||
/// second subkey, this is the one place that has to learn to merge — nowhere else writes it.
|
||||
private static func stampTemplateKey(
|
||||
at root: URL,
|
||||
order: Double,
|
||||
operation: WriteOperation
|
||||
) throws(Failure) {
|
||||
do throws(BoardWriteError) {
|
||||
try BoardWriter.updateIndex(inItemFolder: root, operation: operation) { document in
|
||||
document.set(BoardLoader.templateKey, to: .raw("{order: \(orderText(order))}"))
|
||||
}
|
||||
} catch {
|
||||
throw .failed(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// `100` rather than `100.0` for a whole number, so the file reads like the bundled templates'
|
||||
/// own `template: {order: 100}` — the same rounding `FrontmatterValue` applies to a `.double`.
|
||||
private static func orderText(_ order: Double) -> String {
|
||||
order == order.rounded() && abs(order) < 1e15 ? String(Int64(order)) : String(order)
|
||||
}
|
||||
|
||||
// MARK: - Naming
|
||||
|
||||
/// The document name behind a chosen URL — `~/Boards/Roadmap.kanban` → `Roadmap`.
|
||||
|
||||
Reference in New Issue
Block a user