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:
@@ -100,18 +100,40 @@ enum BoardDuplicator {
|
||||
/// the sandbox will not let us write is usually one we cannot list either, so the ladder simply
|
||||
/// finds no collision and suggests `"Board copy"` — the right pre-fill, arrived at honestly.
|
||||
static func copyDestination(for rootURL: URL) -> URL {
|
||||
let parent = rootURL.deletingLastPathComponent()
|
||||
let base = rootURL.deletingPathExtension().lastPathComponent
|
||||
let ext = rootURL.pathExtension
|
||||
uncollidedURL(
|
||||
named: "\(rootURL.deletingPathExtension().lastPathComponent) copy",
|
||||
extension: rootURL.pathExtension,
|
||||
in: rootURL.deletingLastPathComponent()
|
||||
)
|
||||
}
|
||||
|
||||
/// **Finder's counting ladder, in one place**: `name`, then `name 2`, `name 3`, … counting up
|
||||
/// from 2 against what is on disk at decision time, one collision at a time.
|
||||
///
|
||||
/// Shared rather than spelled twice, because two flows want the same ladder from different
|
||||
/// starting names: Duplicate seeds it with `"Board copy"` (above), and Save as Template seeds it
|
||||
/// with the board's own name — 09-templates.md ▸ Save as Template's "**Store collisions
|
||||
/// auto-rename, Finder-style** (`Board.kanban` → `Board 2.kanban`) — the 01 import precedent:
|
||||
/// saving never overwrites an existing template and never refuses". One ladder, two seeds, so
|
||||
/// the two can never disagree about what "Finder-style" means.
|
||||
///
|
||||
/// The extension rides on the end (`Board copy.kanban`) and an extension-less board folder
|
||||
/// simply has none to carry — both are shapes a board is allowed to be (01-storage-format.md
|
||||
/// § Document packaging).
|
||||
///
|
||||
/// `fileExists` is the one test, and it is true for a file as much as a folder: anything already
|
||||
/// wearing the name blocks it, which is what keeps either flow from overwriting something. It is
|
||||
/// a decision-time answer, not a reservation — the caller still creates the folder itself and
|
||||
/// still fails rather than clobbers if the name was taken in between.
|
||||
static func uncollidedURL(named base: String, extension ext: String, in parent: URL) -> URL {
|
||||
func candidate(_ name: String) -> URL {
|
||||
parent.appendingPathComponent(ext.isEmpty ? name : "\(name).\(ext)", isDirectory: true)
|
||||
}
|
||||
|
||||
var name = "\(base) copy"
|
||||
var name = base
|
||||
var counter = 2
|
||||
while FileManager.default.fileExists(atPath: candidate(name).path) {
|
||||
name = "\(base) copy \(counter)"
|
||||
name = "\(base) \(counter)"
|
||||
counter += 1
|
||||
}
|
||||
return candidate(name)
|
||||
|
||||
Reference in New Issue
Block a user