Implement create operations for board, lane, and card

createBoard fills or mints a .kanban (or extension-less) folder and
refuses to clobber an existing board; createLane/createCard mint fresh
lowercase-UUIDv4 folders and append at max+1024 among visible siblings
via the same strict scan renumber uses (extracted, shared). New files
carry schema/title?/order?/created/modified — created==modified, no
modified-by — written LF through the same atomic temp+rename path.
The editability pre-flight is now scoped to operations that rewrite
siblings: renumber refuses on an uneditable sibling, a create beside
one proceeds. Schema constant unified on BoardLoader.supportedSchema.
Info.plist declares the .kanban document type (UTI conforming to
com.apple.package) so Finder treats a board as one document.

20 new unit tests; 196 total green.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
2026-07-26 17:01:09 -04:00
parent 19f67bdb78
commit 8c0cec06ee
4 changed files with 567 additions and 22 deletions
+5 -3
View File
@@ -30,9 +30,11 @@ import os
public enum BoardLoader: Sendable {
/// Schema version this app understands; anything higher fails fast
/// (01-storage-format.md § Malformed input). `fileprivate` rather than `private`: also
/// read by `BoardLoadError.Reason.description` below, in this same file.
fileprivate static let supportedSchema = 1
/// (01-storage-format.md § Malformed input). Internal rather than `private`: read by
/// `BoardLoadError.Reason.description` below, and it is also the version `BoardWriter`
/// stamps into files it creates one symbol, so the app can never write a file its own
/// loader would reject as newer-than-supported.
static let supportedSchema = 1
/// Board-level key for `BoardModel.template` not schema-owned in the engine's sense
/// (`FrontmatterKeys.schemaOwned`), because its value is opaque and read raw here rather
+201 -19
View File
@@ -114,6 +114,163 @@ public enum BoardWriter: Sendable {
}
}
// MARK: - Create
/// Creates a new board: the folder (if it does not already exist) and its `index.md` the
/// write side of `.kanban` packaging (01-storage-format.md § Document packaging). Two
/// callers: template instantiation, which passes the user-chosen document name as `title`
/// so the window title and the Finder name start out matching (§ Board naming), and a bare
/// "New Board" flow, which passes `nil` and lets the UI fall back to the folder name.
///
/// - `rootURL` is created if missing (`withIntermediateDirectories: true`); an existing
/// *empty* folder is simply filled in, since that is exactly what an interrupted create or
/// a hand-made folder looks like.
/// - **Refuses to clobber an existing board**: if `index.md` is already there, the call
/// fails `.io` naming the path and the existing file is never touched a create must
/// never overwrite a board that already exists.
/// - `title == nil` writes no `title` key at all the folder-name fallback (§ Board
/// naming) is a *missing* key, not an empty string, which would be a real (if blank) title.
/// - Key order: `schema`, `title` (only when supplied), `created`, `modified`. No `order`
/// that field belongs to lanes and cards, never the board root.
/// - Extension-less board folders are exactly as legal a target as a `.kanban`-suffixed one
/// (§ Document packaging, "Extension-less board folders still open") this call never
/// looks at `rootURL`'s extension.
public static func createBoard(at rootURL: URL, title: String?) throws(BoardWriteError) {
let operation = "create board"
let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName)
guard !FileManager.default.fileExists(atPath: indexURL.path) else {
throw BoardWriteError(operation: operation, path: indexURL.path, reason: .io(message: "a board already exists here"))
}
do {
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
} catch {
throw BoardWriteError(
operation: operation,
path: rootURL.path,
reason: .io(message: "could not create board folder: \(error.localizedDescription)")
)
}
try atomicReplace(text: newDocumentText(title: title, order: nil), at: indexURL, operation: operation)
}
/// Creates a lane in a board: mints a fresh lowercase-UUIDv4 folder directly under
/// `rootURL`, appends it after the board's current visible lanes, and writes its
/// `index.md`. Returns the new identity. See `createChild(inParent:title:operation:)` for
/// the shared mechanics.
public static func createLane(inBoard rootURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
try createChild(inParent: rootURL, title: title, operation: "create lane")
}
/// Creates a card in a lane: mints a fresh lowercase-UUIDv4 folder directly under
/// `laneURL`, appends it after the lane's current visible cards, and writes its `index.md`.
/// Returns the new identity. See `createChild(inParent:title:operation:)` for the shared
/// mechanics.
public static func createCard(inLane laneURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
try createChild(inParent: laneURL, title: title, operation: "create card")
}
/// The shared body of `createLane`/`createCard` a lane under a board and a card under a
/// lane are the same operation one level apart (01-storage-format.md § Fractal layout,
/// "Level is position"): check the parent, place the new identity after the current visible
/// siblings, mint it, write its file.
///
/// - **The parent must already exist as a directory.** Anything else missing, or a file
/// where a folder belongs is a loud `.unreadable` naming `parentFolder` up front, never
/// a silent `mkdir` into a broken location.
/// - **Order is `max + 1024` among visible siblings** (`Ranks.append(toVisible:)`), read by
/// `visibleSiblings(of:operation:)` the identical strict scan `renumberVisibleChildren`
/// uses, so the two can never disagree about who counts as a sibling. An empty parent's
/// first child lands at `1024`, the board convention (01-storage-format.md § Ordering).
/// - **Two-step creation is inherent, not a shortcut taken here**: the folder is created,
/// then `index.md` is written, as two separate filesystem operations there is no atomic
/// "create a directory with content already in it" primitive to reach for. A crash between
/// the two leaves a UUID-shaped folder with no `index.md`, exactly the shape
/// `BoardLoader`'s `.missingIndex` skip-and-warn rule (and `visibleSiblings`'s own
/// `fileExists` check) already tolerate see that type's doc comment.
private static func createChild(
inParent parentFolder: URL,
title: String?,
operation: String
) throws(BoardWriteError) -> ItemID {
try checkIsDirectory(parentFolder, operation: operation)
let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false)
let order = Ranks.append(toVisible: siblings.map(\.order))
let folder = try mintUUIDFolder(in: parentFolder, operation: operation)
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
try atomicReplace(text: newDocumentText(title: title, order: order), at: indexURL, operation: operation)
return ItemID(rawValue: folder.lastPathComponent)
}
/// The frontmatter text for a file the app is minting outright never a rewrite, so this
/// goes through `FrontmatterDocument(body:)` and `set`, not `updateIndex`: there is no prior
/// document to read fresh, refuse an uneditable shape from, or stamp over. `created` and
/// `modified` share one `Date()` so the two stamps are identical, not merely close;
/// `modified-by` is never written, matching the engine's absence-means-app-authored
/// convention (§ Frontmatter). Key order `schema`, `title` (only when supplied), `order`
/// (only when supplied `nil` for a board, always present for a lane/card), `created`,
/// `modified` is simply the order `set` is called in, since each call appends a fresh key
/// before the closing delimiter of an otherwise-empty document.
private static func newDocumentText(title: String?, order: Double?) -> String {
var document = FrontmatterDocument(body: "")
document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema))
if let title {
document.set(FrontmatterKeys.title, to: .string(title))
}
if let order {
document.set(FrontmatterKeys.order, to: .double(order))
}
let now = Date()
document.set(FrontmatterKeys.created, to: .date(now))
document.set(FrontmatterKeys.modified, to: .date(now))
return document.serialized()
}
/// A fresh lowercase-UUIDv4 folder under `parentFolder` the folder-naming convention
/// itself (01-storage-format.md § Fractal layout Rules, "Folder names are lowercase
/// UUIDv4"). `UUID().uuidString` is uppercase; `.lowercased()` is what makes the name match
/// `BoardLoader.isUUIDShaped`, which is case-sensitive by design. A freshly minted UUID
/// already existing is astronomically unlikely 122 bits of randomness per mint but
/// checked for and re-minted anyway rather than assumed away; the loop body is trivial
/// precisely because the case it handles essentially never fires.
private static func mintUUIDFolder(in parentFolder: URL, operation: String) throws(BoardWriteError) -> URL {
var candidate: URL
repeat {
candidate = parentFolder.appendingPathComponent(UUID().uuidString.lowercased(), isDirectory: true)
} while FileManager.default.fileExists(atPath: candidate.path)
do {
try FileManager.default.createDirectory(at: candidate, withIntermediateDirectories: false)
} catch {
throw BoardWriteError(
operation: operation,
path: candidate.path,
reason: .io(message: "could not create folder: \(error.localizedDescription)")
)
}
return candidate
}
/// The parent-exists check `createChild` runs before touching the filesystem any further
/// shared instead of folded into `createChild` because "does this folder exist" has nothing
/// to do with siblings or minting, and inlining it would bury the one thing a caller most
/// needs to see at a glance: a missing or wrong-shaped parent is rejected before anything
/// else happens.
private static func checkIsDirectory(_ url: URL, operation: String) throws(BoardWriteError) {
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else {
throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "parent folder does not exist"))
}
guard isDirectory.boolValue else {
throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "parent is not a directory"))
}
}
// MARK: - Renumber
/// Renumbers a parent's visible children to whole multiples of 1024 the renumber fallback
@@ -121,20 +278,14 @@ public enum BoardWriter: Sendable {
/// exception to "a reorder rewrites only the moved item"**. Uniform across levels: the
/// parent is a lane (renumbering its cards) or the board root (renumbering its lanes).
///
/// - **Runs over loaded, valid children.** Every UUID-shaped child folder holding an
/// `index.md` is parsed first; one that fails to parse, or that lacks a usable `order`,
/// fails the whole operation before anything is written. A renumber is bookkeeping inside
/// a user action that already succeeded in principle it must not be the thing that
/// discovers a broken sibling halfway through rewriting the lane.
/// - **Tombstones are inert to ordering** (§ Deletion): a child whose `deleted` key is
/// *present* is not counted, not sorted, and not rewritten presence, not validity, is
/// the test, exactly as `Lane`/`Card.isDeleted` reads it (an explicit `deleted: null` is
/// absence to both).
/// - **Runs over loaded, valid children** (`visibleSiblings(of:operation:)`): one that fails
/// to parse, or that lacks a usable `order`, fails the whole operation before anything is
/// written. A renumber is bookkeeping inside a user action that already succeeded in
/// principle it must not be the thing that discovers a broken sibling halfway through
/// rewriting the lane.
/// - **Display order is the assignment order** (`Ranks.isOrderedForDisplay`: `order`
/// ascending, folder name breaking ties) the same rule the loader sorts by, so a
/// renumber is guaranteed to be sequence-preserving: nothing visibly moves.
/// - **Strays are untouched**: non-UUID-shaped folders and UUID-shaped folders without an
/// `index.md` are skipped here for the same reasons `BoardLoader` skips them.
///
/// Each child's rewrite is atomic; the batch is not. An interrupted renumber leaves some
/// siblings renumbered and some not every `order` still a valid float, display order
@@ -142,7 +293,42 @@ public enum BoardWriter: Sendable {
/// noted in § Ordering, which the deterministic tie-break exists to make harmless.
public static func renumberVisibleChildren(of parentFolder: URL) throws(BoardWriteError) {
let operation = "renumber children"
let visible = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: true)
let ordered = Ranks.sortedForDisplay(visible, order: { $0.order }, name: { $0.folder.lastPathComponent })
for (child, rank) in zip(ordered, Ranks.renumbered(count: ordered.count)) {
try updateIndex(inItemFolder: child.folder, operation: operation) { document in
document.set(FrontmatterKeys.order, to: .double(rank))
}
}
}
/// The strict visible-sibling scan shared by `renumberVisibleChildren` and the create
/// operations' order assignment extracted into one place so the two can never disagree
/// about who counts as a sibling. Walks `parentFolder`'s UUID-shaped children holding an
/// `index.md` (`BoardLoader.directoryCandidates`/`isUUIDShaped` the same level-detection
/// rule the loader uses), each parsed strictly:
///
/// - **Tombstones are inert to ordering** (§ Deletion): a child whose `deleted` key is
/// *present* is not counted presence, not validity, is the test, exactly as
/// `Lane`/`Card.isDeleted` reads it (an explicit `deleted: null` is absence to both).
/// - **Strays are untouched**: non-UUID-shaped folders, and UUID-shaped folders without an
/// `index.md` (an interrupted two-step create the loader's own `.missingIndex` warning
/// tolerates exactly this), are skipped here for the same reasons `BoardLoader` skips them.
/// - A visible sibling's missing or malformed `order` fails the *whole* operation, naming
/// that sibling's file, before anything is written the same discover-before-you-write
/// guarantee `renumberVisibleChildren`'s batch depends on.
/// - `requireEditable` scopes the readable-but-uneditable pre-flight to the caller that
/// will actually *rewrite* the siblings: renumber passes `true` (it must not discover an
/// unwritable sibling halfway through the batch), the creates pass `false` a create
/// only *reads* its siblings' orders, and a flow-mapping sibling that loads and renders
/// normally (01-storage-format.md § Frontmatter) must not block creating a new item
/// beside it.
private static func visibleSiblings(
of parentFolder: URL,
operation: String,
requireEditable: Bool
) throws(BoardWriteError) -> [(folder: URL, order: Double)] {
let candidates: [URL]
do {
candidates = try BoardLoader.directoryCandidates(in: parentFolder)
@@ -161,7 +347,9 @@ public enum BoardWriter: Sendable {
let document = try readDocument(at: indexURL, operation: operation)
guard document.deleted.isMissing else { continue }
try checkEditable(document, at: indexURL, operation: operation)
if requireEditable {
try checkEditable(document, at: indexURL, operation: operation)
}
switch document.order {
case .missing:
@@ -180,13 +368,7 @@ public enum BoardWriter: Sendable {
visible.append((folder: folder, order: order))
}
}
let ordered = Ranks.sortedForDisplay(visible, order: { $0.order }, name: { $0.folder.lastPathComponent })
for (child, rank) in zip(ordered, Ranks.renumbered(count: ordered.count)) {
try updateIndex(inItemFolder: child.folder, operation: operation) { document in
document.set(FrontmatterKeys.order, to: .double(rank))
}
}
return visible
}
// MARK: - Reading