Files
lanework/Kanban/Storage/BoardWriter.swift
T
rzen e989c1f26e Build Edit mode with debounced, byte-honest saves
The editing surface: the same hosted TextKit-1 text view gains an
editable branch with a per-keystroke line-scanner highlighter — chosen
over a parser re-parse because a mid-typing buffer is usually invalid
Markdown and 05 wants the delimiters themselves dimmed; apply only sets
attributes, so presentation-never-transforms is structural. Saves ride
a ~700ms injectable debounce through BoardWriter.writeBody —
toggleTaskMarker's idiom widened to the body span, frontmatter bytes
untouched, refusing to write when disk already holds that body, which
enforces all three gates (untouched, reverted, echo) at the layer that
owns the bytes with one isDirty predicate above it. Mode grammar lands
whole: ⌘E toggles with a checkmark, Return in Preview enters, Escape
returns, and every flip flushes first; window close flushes through
the existing retry/save-copy/discard modal, and the dismissal flush
deliberately reaches a tombstoned card. Dirty-buffer-wins: disk always
follows the snapshot, the buffer only when clean, both surfaces render
the buffer. Undo is the editor's own session-scoped NSUndoManager;
endEditSession names the pro-m1 one-commit-per-session boundary.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 10:46:02 -04:00

1765 lines
101 KiB
Swift

import Foundation
/// Turns a mutation into a filesystem operation — the single point through which every write
/// the app makes reaches disk (02-architecture.md § Layering ▸ Components). Stateless by
/// construction: there is no in-flight buffer, no queue, no coalescing. **A write is done when
/// the rename completes**, and a failed write is a failure the caller sees, so views — which
/// render only what is on disk — can never show phantom state (02-architecture.md §
/// Write-failure surfacing).
///
/// Four rules from 01-storage-format.md live here and are not negotiable per call site:
///
/// - **Atomic writes** (§ Fractal layout ▸ Rules): temp file, rename over `index.md`. Every
/// write, no exceptions — a reader (this app's watcher, an agent, git) never sees a partial
/// file, and a crash mid-write leaves the previous content intact.
/// - **Round-trip, never re-serialize**: mutations go through `FrontmatterDocument`, which
/// edits by line span, so unknown keys and their order, comments, blank lines, line endings,
/// and the body survive every write by construction rather than by remembering to preserve
/// them.
/// - **`modified` stamped, `modified-by` cleared** (§ Frontmatter): on every app-mediated write
/// path. Absence of `modified-by` means "the board's user, via the app"; the file is being
/// rewritten anyway, so clearing an external writer's self-reported stamp costs nothing.
/// - **Encoding** (§ Fractal layout ▸ Rules): writes are BOM-less UTF-8; reads are strict
/// UTF-8, and a file that does not decode is a loud, specific error rather than a
/// lossy best guess.
public enum BoardWriter: Sendable {
// MARK: - The uniform per-file mutation
/// Rewrites one item's `index.md`: read fresh, refuse what cannot be edited, apply `edits`,
/// stamp, write atomically.
///
/// The order of the four steps is the contract:
///
/// 1. **Read fresh from disk**, never from a snapshot. The snapshot a caller is holding may
/// be seconds stale — an agent or a hand-editor may have rewritten the file since — and
/// the round-trip guarantee is only worth anything against the bytes actually there.
/// 2. **Refuse an uneditable shape before `edits` runs** (`FrontmatterDocument.uneditableShape`):
/// the settled readable-but-uneditable rule. Such a file loads and renders fine, but a
/// surgical edit of it cannot be expressed, so the write fails loudly instead of
/// corrupting it. Refusing up front also means `edits` never observes a document it
/// cannot affect.
/// 3. **`edits`, then the stamps** — `modified` set and `modified-by` removed *after* the
/// caller's closure, so the stamp always wins over anything the closure did with those
/// two keys, and no call site has to remember them.
/// 4. **Atomic replace.**
///
/// The **one path that deliberately bypasses this** is the card window's raw-source Apply
/// (05-card-window.md): it writes the user's text byte-for-byte and does *not* clear a
/// `modified-by` the user typed or kept — the validated-then-verbatim contract outranks the
/// clearing rule (01-storage-format.md § Frontmatter). That path goes through
/// `atomicReplace` directly; it does not belong here.
public static func updateIndex(
inItemFolder folder: URL,
operation: WriteOperation,
edits: (inout FrontmatterDocument) -> Void
) throws(BoardWriteError) {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
var document = try readDocument(at: indexURL, operation: operation)
// The read just above is *the* title-enrichment point for every case that funnels
// through here (renumber, delete, restore, style, and the tail of move/copy): shadow
// the parameter so every failure from here on — the uneditable-shape refusal, the
// atomic replace — names the item.
let operation = operation.withTitle(document.title.value)
try checkEditable(document, at: indexURL, operation: operation)
edits(&document)
document.set(FrontmatterKeys.modified, to: .date(Date()))
document.remove(FrontmatterKeys.modifiedBy)
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
}
// MARK: - Atomic replace
/// Writes `text` over `fileURL` atomically: a hidden temp file in the **same directory**,
/// then a rename over the destination.
///
/// Same directory because a rename is only atomic within one filesystem — a temp in
/// `NSTemporaryDirectory()` could land on another volume and degrade to a copy. Dot-prefixed
/// (`.index.md.lanework-<uuid>`) because `BoardLoader.directoryCandidates` skips hidden
/// entries: residue from a crashed write is invisible to a load rather than a stray warning
/// or, worse, a candidate. The UUID keeps concurrent writers off each other's temp file.
///
/// `Data(text.utf8)` is BOM-less UTF-8 by construction — the encoding contract, with no
/// encoder to configure and no failure case to handle. On any failure the temp file is
/// removed best-effort and `.io` is thrown: the destination is either the old bytes or the
/// new ones, never a mix, and never a directory littered with half-written files.
static func atomicReplace(text: String, at fileURL: URL, operation: WriteOperation) throws(BoardWriteError) {
let directory = fileURL.deletingLastPathComponent()
let tempURL = directory.appendingPathComponent(".\(fileURL.lastPathComponent).lanework-\(UUID().uuidString)")
do {
try Data(text.utf8).write(to: tempURL)
} catch {
try? FileManager.default.removeItem(at: tempURL)
throw BoardWriteError(
operation: operation,
path: fileURL.path,
reason: .io(message: "could not write temporary file: \(error.localizedDescription)")
)
}
// POSIX `rename` rather than `FileManager.replaceItemAt`: it atomically overwrites an
// existing destination *and* handles one that does not exist yet (the create paths),
// without inventing a second temp file of its own.
let status = tempURL.withUnsafeFileSystemRepresentation { source in
fileURL.withUnsafeFileSystemRepresentation { destination in
guard let source, let destination else { return EINVAL }
return rename(source, destination) == 0 ? 0 : errno
}
}
guard status == 0 else {
try? FileManager.default.removeItem(at: tempURL)
throw BoardWriteError(
operation: operation,
path: fileURL.path,
reason: .io(message: "could not replace file: \(String(cString: strerror(status)))")
)
}
}
// 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 = WriteOperation.createBoard
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: .createLane)
}
/// 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: .createCard)
}
/// 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: WriteOperation
) throws(BoardWriteError) -> ItemID {
try checkIsDirectory(parentFolder, describedAs: "parent folder", 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 create path's mint, which
/// materializes the folder as well as naming it. The naming rule itself lives in
/// `freshUUIDName(in:avoiding:)`, shared with the move and copy paths so every identity the
/// app mints is minted one way.
private static func mintUUIDFolder(in parentFolder: URL, operation: WriteOperation) throws(BoardWriteError) -> URL {
let candidate = parentFolder.appendingPathComponent(
freshUUIDName(in: parentFolder, avoiding: []),
isDirectory: true
)
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
}
/// A fresh lowercase-UUIDv4 folder *name* for `parentFolder` — the folder-naming convention
/// itself (01-storage-format.md § Fractal layout ▸ Rules, "Folder names are lowercase
/// UUIDv4"). `UUID().uuidString` is uppercase; `.lowercased()` is the app's **emission**
/// rule — accept liberally, emit conservatively. The loader's gate
/// (`BoardLoader.isUUIDShaped`) accepts either case, so this lowercasing is a convention the
/// app holds itself to, not something a reader depends on.
///
/// Two exclusions, both re-minted rather than assumed away: a name already on disk in
/// `parentFolder` (so the caller's `createDirectory`/`moveItem`/`copyItem` cannot lose a
/// race with an existing entry), and any name in `taken` — the identities a collision repair
/// is minting *away* from, which are not necessarily on disk here. **`taken` is canonical**
/// (lowercased, `canonicalIdentity`), which is what makes the `contains` a UUID-*value*
/// probe: the minted name is lowercase, so it can only match a canonical set. A freshly
/// minted UUID hitting either is astronomically unlikely — 122 bits of randomness per mint —
/// but the loop body is trivial precisely because the case it handles essentially never fires.
private static func freshUUIDName(in parentFolder: URL, avoiding taken: Set<String>) -> String {
var name: String
repeat {
name = UUID().uuidString.lowercased()
} while taken.contains(name)
|| FileManager.default.fileExists(atPath: parentFolder.appendingPathComponent(name).path)
return name
}
/// The folder-exists check every operation runs before touching the filesystem any further —
/// shared instead of folded into its callers because "does this folder exist" has nothing to
/// do with siblings, minting, or moving, and inlining it would bury the one thing a caller
/// most needs to see at a glance: a missing or wrong-shaped folder is rejected before
/// anything else happens. `role` names it the way the failing user action would ("parent
/// folder", "item folder") — the error goes straight into the write-failure banner.
private static func checkIsDirectory(
_ url: URL,
describedAs role: String,
operation: WriteOperation
) 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: "\(role) does not exist"))
}
guard isDirectory.boolValue else {
throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "\(role) is not a directory"))
}
}
// MARK: - Renumber
/// Renumbers a parent's visible children to whole multiples of 1024 — the renumber fallback
/// for exhausted midpoint precision (01-storage-format.md § Ordering), and **the sole
/// 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** (`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.
///
/// 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
/// still deterministic, and the next renumber finishes the job. That is the accepted cost
/// noted in § Ordering, which the deterministic tie-break exists to make harmless.
public static func renumberVisibleChildren(of parentFolder: URL) throws(BoardWriteError) {
let operation = WriteOperation.renumberChildren
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: WriteOperation,
requireEditable: Bool
) throws(BoardWriteError) -> [(folder: URL, order: Double)] {
let candidates: [URL]
do {
candidates = try BoardLoader.directoryCandidates(in: parentFolder)
} catch {
throw BoardWriteError(
operation: operation,
path: parentFolder.path,
reason: .unreadable(message: error.description)
)
}
var visible: [(folder: URL, order: Double)] = []
for folder in candidates where BoardLoader.isUUIDShaped(folder.lastPathComponent) {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
guard FileManager.default.fileExists(atPath: indexURL.path) else { continue }
let document = try readDocument(at: indexURL, operation: operation)
guard document.deleted.isMissing else { continue }
if requireEditable {
try checkEditable(document, at: indexURL, operation: operation)
}
switch document.order {
case .missing:
throw BoardWriteError(
operation: operation,
path: indexURL.path,
reason: .unreadable(message: "missing required 'order' field")
)
case let .malformed(raw):
throw BoardWriteError(
operation: operation,
path: indexURL.path,
reason: .unreadable(message: "malformed 'order' field: \(raw)")
)
case let .valid(order):
visible.append((folder: folder, order: order))
}
}
return visible
}
// MARK: - Move
/// Moves a lane or card — and the whole tree beneath it — under another parent, in the same
/// board or across boards. **A move is a physical folder move and the UUID travels
/// unchanged** (01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle: moves
/// keep the UUID, copies mint fresh ones"): nothing below the moved root is read or
/// rewritten, so nested cards, `attachments/`, and strays arrive byte-identical by
/// construction rather than by copying carefully.
///
/// Exactly one file is rewritten — the moved root's `index.md`, and only to carry its new
/// `order` (§ Ordering, "a reorder rewrites only the moved item's `index.md`"), stamped and
/// `modified-by`-cleared like every other app write. `order` is the caller's explicit rank
/// (a drop between two siblings), or `nil` to append after the destination's visible
/// siblings.
///
/// **The import boundary is the one place within-board uniqueness is enforced** (§ Fractal
/// layout ▸ Rules). `sourceBoardRoot` and `destinationBoardRoot` are compared by resolved,
/// standardized path; when they differ, this is an import, and an arriving UUID that already
/// exists anywhere in the destination board — **tombstones included, because they are on
/// disk** — is degraded to a copy: a fresh UUID is minted for that folder and nothing else.
/// Degradation is **per folder, at the finest grain**: a lane arriving with one colliding
/// card is still a lane *move*, and only that card arrives reminted. A remint is an identity
/// repair, not an edit — the folder is renamed and its files' bytes are never touched, so
/// the reminted item's `modified`, `modified-by`, and history are exactly what the source
/// had. Every repair is reported in `MoveResult.reminted`; a same-board move never remints,
/// and the same UUID living in two boards is not an error anywhere else — boards are
/// **independent identity namespaces**, and forks only ever meet here.
///
/// The order of operations is the contract:
///
/// 1. **Validate before anything else**: source and destination parent must be existing
/// directories, and the source must be UUID-shaped — this writer moves lanes and cards,
/// and a stray is not one (§ Fractal layout ▸ Rules, "Name shape gates level detection").
/// 2. **Pre-flight the moved root's editability** (`readDocument` + `checkEditable`): the
/// move must rewrite that file at the destination, so a file that cannot be round-tripped
/// refuses *before* the folder moves — discover-before-you-write, the same guarantee
/// `renumberVisibleChildren` depends on. Only the root needs it: a move never rewrites a
/// nested file, so an uneditable card inside a moved lane is none of this call's business.
/// 3. **Compute the destination `order` before the move**, off the destination's visible
/// siblings — while the moved item is still elsewhere and so cannot count itself.
/// 4. **Scan the destination board's identities** (depth 1 and 2, `directoryCandidates` +
/// `isUUIDShaped`; strays skipped, tombstones kept) — but only on an import, since a
/// same-board move cannot collide with anything but itself. The scan and every probe
/// against it are **by UUID value, not spelling** (`identities(inBoard:)` /
/// `canonicalIdentity`): an arriving `55555555-…` collides with a resident `55555555-…`
/// spelled in uppercase, because those are one identity (§ Fractal layout ▸ Rules).
/// 5. **Move the folder** (`FileManager.moveItem`, which degrades to copy+remove across
/// volumes). A colliding *root* is renamed by moving it straight to its minted name
/// rather than moving and then renaming: one filesystem operation instead of two, and it
/// is also the only way the repair works when the collision is a sibling sitting in the
/// very destination parent — a plain move onto an existing name fails outright.
/// 6. **Repair the arrived tree's children** — the moved root's direct UUID-shaped children,
/// which is every level a compound arrival can carry (a lane's cards; a card has no
/// UUID-shaped children).
/// 7. **Rewrite the moved root's `index.md`** with the rank from step 3.
///
/// **Atomic per filesystem operation, not per gesture** — the accepted edge. A rename that
/// fails mid-repair, or the `updateIndex` that fails once the folder has already arrived,
/// leaves the item at the destination with a stale `order` and possibly a partly repaired
/// tree. Every value on disk is still valid, the failure is surfaced verbatim through the
/// thrown error (02-architecture.md § Write-failure surfacing), and the caller's reload
/// shows the true state — nothing is silently retried or rolled back behind the user's back.
///
/// **A move whose destination is the item's current parent degrades to a plain reorder** —
/// no folder to move, no boundary to cross, just the `order` rewrite (step 7) with the
/// appended rank computed over the siblings *excluding the item itself*, which unlike every
/// other move is already there to be miscounted. A drop that lands back in its own lane is
/// the same gesture as one that lands elsewhere; the writer doing the right degenerate
/// thing keeps the API total instead of leaking a `FileManager` name collision.
public static func moveItem(
at sourceFolder: URL,
toParent destinationParent: URL,
sourceBoardRoot: URL,
destinationBoardRoot: URL,
order: Double?
) throws(BoardWriteError) -> MoveResult {
let sourceName = sourceFolder.lastPathComponent
// Which case this is — an ordinary move, or the same-parent degenerate reorder — is
// decided from the two URLs alone, before anything on disk is even looked at, so the
// right vocabulary word is in hand for every pre-flight check that follows rather than
// being retrofitted once the branch below is reached.
let isReorder = isSameLocation(sourceFolder.deletingLastPathComponent(), destinationParent)
var operation: WriteOperation = isReorder ? .reorder(title: nil) : .move(title: nil)
try checkIsDirectory(sourceFolder, describedAs: "item folder", operation: operation)
try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
try checkIsUUIDShaped(sourceFolder, operation: operation)
// The pre-flight's own read is where this move/reorder learns the moved root's title;
// every failure from here on in this call reuses the enriched value.
operation = try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation)
if isReorder {
let rank: Double
if let order {
rank = order
} else {
let siblings = try visibleSiblings(of: destinationParent, operation: operation, requireEditable: false)
// "Which sibling is the item itself" is an identity question, so it is asked by
// UUID value (`canonicalIdentity`), not by spelling: the caller's URL and the
// directory listing can disagree in case for one and the same folder.
let selfIdentity = canonicalIdentity(sourceName)
rank = Ranks.append(
toVisible: siblings
.filter { canonicalIdentity($0.folder.lastPathComponent) != selfIdentity }
.map(\.order)
)
}
try updateIndex(inItemFolder: sourceFolder, operation: operation) { document in
document.set(FrontmatterKeys.order, to: .double(rank))
}
return MoveResult(id: ItemID(rawValue: sourceName), reminted: [])
}
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
let isImport = !isSameLocation(sourceBoardRoot, destinationBoardRoot)
let existing = isImport ? identities(inBoard: destinationBoardRoot) : []
var reserved = existing
var reminted: [MoveResult.Remint] = []
var arrivedName = sourceName
if existing.contains(canonicalIdentity(sourceName)) {
arrivedName = freshUUIDName(in: destinationParent, avoiding: reserved)
reserved.insert(arrivedName)
reminted.append(MoveResult.Remint(from: ItemID(rawValue: sourceName), to: ItemID(rawValue: arrivedName)))
}
let arrivedRoot = destinationParent.appendingPathComponent(arrivedName, isDirectory: true)
do {
try FileManager.default.moveItem(at: sourceFolder, to: arrivedRoot)
} catch {
throw BoardWriteError(
operation: operation,
path: sourceFolder.path,
reason: .io(message: "could not move folder: \(error.localizedDescription)")
)
}
if isImport {
let children = childCandidates(of: arrivedRoot)
reserved.formUnion(children.map { canonicalIdentity($0.lastPathComponent) })
for child in children where existing.contains(canonicalIdentity(child.lastPathComponent)) {
let fresh = freshUUIDName(in: arrivedRoot, avoiding: reserved)
reserved.insert(fresh)
try renameFolder(child, toSiblingNamed: fresh, operation: operation)
reminted.append(MoveResult.Remint(
from: ItemID(rawValue: child.lastPathComponent),
to: ItemID(rawValue: fresh)
))
}
}
try updateIndex(inItemFolder: arrivedRoot, operation: operation) { document in
document.set(FrontmatterKeys.order, to: .double(rank))
}
return MoveResult(id: ItemID(rawValue: arrivedName), reminted: reminted)
}
/// Every identity a board already holds: its UUID-shaped lane folders and their UUID-shaped
/// card folders — the two depths where identity lives (§ Fractal layout, "Level is
/// position"). **Tombstoned items count**: a tombstone is a live folder on disk carrying a
/// `deleted:` key, and arriving on top of one would be exactly the duplicate the import
/// boundary exists to prevent (a Put Back would then resurrect a twin). Strays do not count
/// — they are not identities at all — and neither does anything deeper: a card has no
/// UUID-shaped children, and a UUID-shaped folder under `attachments/` is content, not a
/// level.
///
/// Reads through the loader's own door (`directoryCandidates`), so the writer's idea of what
/// a board contains can never drift from the loader's. An unreadable folder yields no
/// candidates rather than failing: the same degradation the loader applies below the root,
/// and the conservative direction here — a missed identity remints nothing, and a duplicate
/// UUID in one board is the unspecified-behavior case the design already names, not a
/// corruption.
/// **Canonical, not verbatim**: every name is lowercased on the way in (`canonicalIdentity`),
/// and every probe against the returned set must be too. Identity comparison is UUID-*value*
/// equality, never string equality (§ Fractal layout ▸ Rules, settled) — an arriving
/// `55555555-…` and a resident `55555555-…` spelled uppercase are **one** identity, and a
/// verbatim set would miss exactly that collision and let a duplicate UUID into the board.
private static func identities(inBoard boardRoot: URL) -> Set<String> {
var identities: Set<String> = []
for lane in childCandidates(of: boardRoot) {
identities.insert(canonicalIdentity(lane.lastPathComponent))
for card in childCandidates(of: lane) {
identities.insert(canonicalIdentity(card.lastPathComponent))
}
}
return identities
}
/// A folder name reduced to its identity *value* — the same canonicalization `ItemID`'s
/// `==`/`hash(into:)` use (`BoardModel.swift`), applied where this writer must compare names
/// as strings because it is working with paths rather than model values. Every identity-shaped
/// name is ASCII hex and hyphens, so case folding is UUID-value canonicalization exactly.
private static func canonicalIdentity(_ folderName: String) -> String {
folderName.lowercased()
}
/// A folder's UUID-shaped subfolders in deterministic order — `directoryCandidates` (hidden
/// entries and symlinks already excluded) narrowed by `isUUIDShaped`, which is the loader's
/// level-detection rule and therefore the only definition of "an identity-bearing child"
/// this writer is allowed to have.
private static func childCandidates(of folder: URL) -> [URL] {
let candidates = (try? BoardLoader.directoryCandidates(in: folder)) ?? []
return candidates.filter { BoardLoader.isUUIDShaped($0.lastPathComponent) }
}
/// Renames a folder in place, keeping its parent — the whole of an identity repair, and of a
/// copy's remint. The folder's contents, `index.md` included, are never opened.
private static func renameFolder(
_ folder: URL,
toSiblingNamed name: String,
operation: WriteOperation
) throws(BoardWriteError) {
let destination = folder.deletingLastPathComponent().appendingPathComponent(name, isDirectory: true)
do {
try FileManager.default.moveItem(at: folder, to: destination)
} catch {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .io(message: "could not rename folder: \(error.localizedDescription)")
)
}
}
/// Whether two URLs name the same place on disk — symlinks resolved, `..`/`.` standardized
/// away. The import boundary turns on this one comparison, so it is deliberately about
/// *location*, not spelling: `/tmp/B.kanban` and `/private/tmp/B.kanban/.` are one board,
/// and treating them as two would remint every arriving item for nothing.
private static func isSameLocation(_ lhs: URL, _ rhs: URL) -> Bool {
lhs.resolvingSymlinksInPath().standardizedFileURL.path
== rhs.resolvingSymlinksInPath().standardizedFileURL.path
}
// MARK: - Copy
/// Copies a lane or card — and the whole tree beneath it — under another parent, minting
/// **fresh UUIDs for every folder it materializes** (01-storage-format.md § Fractal layout ▸
/// Rules, "Identity lifecycle: moves keep the UUID, copies mint fresh ones"). The copy is a
/// new identity at every level: a copied lane's cards are new cards, not the same cards seen
/// twice. Returns the new root's identity. This is the item-level copy — ⌘C/⌘V, ⌥-drag
/// duplicate, the cross-board drag default; whole-board copies (Save as Template, File ▸
/// Duplicate) keep their GUIDs and are not this call.
///
/// Everything travels verbatim: `attachments/` and its subfolders, strays, bodies, unknown
/// keys, comments, line endings. Only the schema's provenance stamps move, per `stamps`:
///
/// - `.fork` — an ordinary copy **keeps `created`** (it is a fork of something that really
/// was created then) while `modified` is stamped and `modified-by` cleared, because a
/// paste or a duplicate is an app write (§ Frontmatter).
/// - `.born` — template instantiation stamps `created` **and** `modified` fresh, from one
/// `Date` for the whole tree: a card made from a template is born today, not forked from
/// the template (09-templates.md).
///
/// Two deliberate leniencies below the root, both of them "what a hand copy would do":
///
/// - A nested `index.md` that is **readable-but-uneditable** (§ Frontmatter), or that cannot
/// be read at all, is copied byte-verbatim and simply not stamped. Refusing an entire copy
/// because one nested card is a flow mapping would be hostile, and the file arrives
/// *exactly* as it was rather than corrupted — its stale `modified-by` attribution
/// surviving is the self-reported-provenance honest limit § Frontmatter already
/// acknowledges. The **root** gets no such leniency: it must be rewritten (it needs its
/// new `order`), so an unreadable or uneditable root refuses the copy up front, before
/// anything is materialized.
/// - A nested UUID-shaped folder with **no `index.md`** — interrupted-create residue — is
/// copied and reminted like any other, and not rewritten: the same skip the loader applies
/// to it (`.missingIndex`).
///
/// **All-or-nothing at the destination**, unlike a move: any failure once copying has begun
/// removes the partially copied tree best-effort and rethrows, because a half-copied item is
/// pure residue — nothing was there before, so there is no true state for a reload to show.
/// The source is never touched on any path.
public static func copyItem(
at sourceFolder: URL,
toParent destinationParent: URL,
order: Double?,
stamps: CopyStamps
) throws(BoardWriteError) -> ItemID {
var operation: WriteOperation = .copy(title: nil)
try checkIsDirectory(sourceFolder, describedAs: "item folder", operation: operation)
try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
try checkIsUUIDShaped(sourceFolder, operation: operation)
// The pre-flight's own read is where this copy learns the source root's title; every
// failure from here on in this call — including inside the materialized-but-not-yet-
// stamped tree below — reuses the enriched value.
operation = try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation)
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
let rootName = freshUUIDName(in: destinationParent, avoiding: [])
let root = destinationParent.appendingPathComponent(rootName, isDirectory: true)
do {
try FileManager.default.copyItem(at: sourceFolder, to: root)
} catch {
try? FileManager.default.removeItem(at: root)
throw BoardWriteError(
operation: operation,
path: sourceFolder.path,
reason: .io(message: "could not copy folder: \(error.localizedDescription)")
)
}
do {
var copied: [URL] = []
try remintDescendants(of: root, collecting: &copied, operation: operation)
let now = Date()
try updateIndex(inItemFolder: root, operation: operation) { document in
if case .born = stamps {
document.set(FrontmatterKeys.created, to: .date(now))
}
document.set(FrontmatterKeys.order, to: .double(rank))
}
for folder in copied {
try stampCopiedDescendant(at: folder, stamps: stamps, now: now, operation: operation)
}
} catch {
try? FileManager.default.removeItem(at: root)
throw error
}
return ItemID(rawValue: rootName)
}
/// Renames every UUID-shaped folder beneath `folder` to a fresh mint, depth first, and
/// collects where they ended up — "copies mint fresh UUIDs for **every** folder they
/// materialize", which for a copied lane means its cards too. Recursion rather than a
/// two-level walk because the rule is about folders, not levels; non-UUID-shaped folders
/// (`attachments/` and anything under it, strays) are neither renamed nor descended into,
/// exactly as the loader treats them.
///
/// Each child is renamed *before* being descended into, so the collected URLs are the final
/// 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(
of folder: URL,
collecting copied: inout [URL],
operation: WriteOperation
) throws(BoardWriteError) {
for child in childCandidates(of: folder) {
let fresh = freshUUIDName(in: folder, avoiding: [])
try renameFolder(child, toSiblingNamed: fresh, operation: operation)
let renamed = folder.appendingPathComponent(fresh, isDirectory: true)
copied.append(renamed)
try remintDescendants(of: renamed, collecting: &copied, operation: operation)
}
}
/// Stamps one copied folder below the root — best-effort by design (see `copyItem`): a
/// 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(
at folder: URL,
stamps: CopyStamps,
now: Date,
operation: WriteOperation
) throws(BoardWriteError) {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
guard FileManager.default.fileExists(atPath: indexURL.path),
let copied = try? readDocument(at: indexURL, operation: operation),
copied.uneditableShape == nil
else { return }
try updateIndex(inItemFolder: folder, operation: operation) { document in
if case .born = stamps {
document.set(FrontmatterKeys.created, to: .date(now))
}
}
}
/// Physically removes every tombstoned card from a **just-copied** lane, and reports which —
/// the tail of a lane copy (04-interactions.md ▸ Drag and drop: "A lane copy **strips
/// tombstoned cards**: the copy transfers content, and trash isn't content"; the same rule
/// governs a pasted lane copy).
///
/// **Removed, not tombstoned.** These folders were minted seconds ago by `copyItem` and were
/// never content in this board, so there is nothing here for a Put Back to recover and no
/// tombstone to leave standing — the tombstoned *originals* stay recoverable in the source
/// board, which is where the recovery story lives. A lane **move** carries them whole and never
/// calls this: the folder travels as-is and its tombstones land in the destination's trash by
/// rendering.
///
/// **Only ever pointed at a fresh copy.** `copyItem` has no filter hook — it copies the tree
/// verbatim by design, which is what makes attachments and strays arrive byte-identical — so
/// the strip is a second step rather than a parameter, and a caller that aimed it at a lane the
/// user actually owns would be destroying their trash. Every call site in the app is the line
/// after a `copyItem` that materialized the folder.
///
/// A child whose `index.md` is missing or unreadable is **left alone**: the liveness question
/// cannot be answered for it, and the conservative direction is to keep the folder — the same
/// leniency `copyItem` extends below its root. Liveness is read exactly as the loader reads it
/// (a present `deleted` key, malformed or not).
///
/// The operation vocabulary is `.copy`, not `.purge`: the user pressed nothing called "delete",
/// and a failure here must say the app could not copy the lane (02-architecture.md §
/// Write-failure surfacing).
@discardableResult
public static func stripTombstonedChildren(of laneFolder: URL) throws(BoardWriteError) -> [ItemID] {
let operation = WriteOperation.copy(title: nil)
try checkIsDirectory(laneFolder, describedAs: "lane folder", operation: operation)
try checkIsUUIDShaped(laneFolder, operation: operation)
var removed: [ItemID] = []
for child in childCandidates(of: laneFolder) {
let indexURL = child.appendingPathComponent(BoardLoader.indexFileName)
guard FileManager.default.fileExists(atPath: indexURL.path),
let document = try? readDocument(at: indexURL, operation: operation),
!document.deleted.isMissing
else { continue }
do {
try FileManager.default.removeItem(at: child)
} catch {
throw BoardWriteError(
operation: operation,
path: child.path,
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
removed.append(ItemID(rawValue: child.lastPathComponent))
}
return removed
}
/// Materializes an item from **supplied `index.md` text** rather than from a folder on disk —
/// the clipboard's staging-less fallback (04-interactions.md ▸ Clipboard: "if the staged
/// snapshot is missing or unreadable at paste time, paste falls back to the embedded
/// `index.md` — content intact, attachments absent").
///
/// **The text is written byte-faithfully, because it *is* the source bytes.** It was captured
/// verbatim at copy time and travels through the manifest untouched, so this call writes it as
/// given — unknown keys, comments, blank lines, line endings, body and all — rather than
/// re-serializing anything. That is the round-trip guarantee applied to a file the app is
/// minting from bytes it was handed (01-storage-format.md § Fractal layout ▸ Rules).
///
/// **Fresh identity, fork stamps** — the same semantics `copyItem` gives an ordinary copy, and
/// necessarily so: this is a copy that happened to arrive as text. Every folder is a fresh mint,
/// `created` survives in the supplied bytes (a duplicate is a fork), and the root's `order` and
/// `modified` are rewritten by the closing `updateIndex`, which also clears `modified-by`.
///
/// `children` are a **lane's** cards, each its own supplied `index.md`, materialized under the
/// new root in the order given and deliberately *not* rewritten: "a nested item keeps its rank
/// among its own siblings, which travelled with it" (`copyItem`'s rule). A card passes none.
///
/// **The root gets `copyItem`'s strictness and the children get its leniency.** The root must be
/// rewritten — it needs its new `order` — so unparseable or uneditable text fails the call;
/// a child is never rewritten, so whatever it is arrives exactly as it was.
///
/// **All-or-nothing at the destination**, `copyItem`'s rule for its reason: any failure once the
/// folder exists removes the partial tree best-effort and rethrows, because a half-materialized
/// item is pure residue — nothing was there before.
public static func materializeItem(
inParent destinationParent: URL,
indexText: String,
children: [String] = [],
order: Double?
) throws(BoardWriteError) -> ItemID {
let operation = WriteOperation.copy(title: nil)
try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
// The same mint the create path uses, so every identity this app materializes is materialized
// one way — fresh lowercase UUIDv4, folder and all.
let root = try mintUUIDFolder(in: destinationParent, operation: operation)
do throws(BoardWriteError) {
try atomicReplace(
text: indexText,
at: root.appendingPathComponent(BoardLoader.indexFileName),
operation: operation
)
for child in children {
let childFolder = try mintUUIDFolder(in: root, operation: operation)
try atomicReplace(
text: child,
at: childFolder.appendingPathComponent(BoardLoader.indexFileName),
operation: operation
)
}
try updateIndex(inItemFolder: root, operation: operation) { document in
document.set(FrontmatterKeys.order, to: .double(rank))
}
} catch {
try? FileManager.default.removeItem(at: root)
throw error
}
return ItemID(rawValue: root.lastPathComponent)
}
// MARK: - Tombstone
/// Tombstones a lane or card in place: writes `deleted: <now>` into its own `index.md` —
/// the whole of a delete (01-storage-format.md § Deletion). The folder never moves, never
/// renames, and nothing beneath it is touched: hiding the subtree is the renderer's
/// ancestor walk, not a stored flag, so deleting a lane rewrites *only* the lane's own
/// file — its cards' files are exactly as they were.
///
/// **Board-root deletion is structurally unreachable at this layer**: `checkIsUUIDShaped`
/// — the same guard `moveItem`/`copyItem` lean on — refuses any folder whose name isn't
/// UUID-shaped, and a board root never is (§ Board naming). A board-level `deleted:` key
/// is legal-but-meaningless per the frontmatter table (the loader ignores and warns on
/// it), but this call is simply never able to *produce* one: it has no board-root code
/// path to fall through, only a refusal.
///
/// Deleting an **already-tombstoned** item is not refused — it just refreshes the
/// timestamp, a harmless rewrite (the gesture happened again; this layer does not police
/// liveness, the store's UI does). Goes through `updateIndex`, so the usual contract
/// applies: fresh read, refuse an uneditable shape, `modified` stamped and `modified-by`
/// cleared, atomic replace.
public static func deleteItem(at itemFolder: URL) throws(BoardWriteError) {
let operation = WriteOperation.delete(title: nil)
try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation)
try checkIsUUIDShaped(itemFolder, operation: operation)
try updateIndex(inItemFolder: itemFolder, operation: operation) { document in
document.set(FrontmatterKeys.deleted, to: .date(Date()))
}
}
/// Put Back: removes the `deleted` key, undoing exactly what `deleteItem` wrote.
/// **Position-perfect by construction** — the folder never moved, so the item simply
/// re-enters the visible set at its recorded `order` among its current siblings
/// (01-storage-format.md § Deletion). `FrontmatterDocument.remove` takes *every*
/// occurrence of the key, so a hand-duplicated `deleted` line cannot resurrect the
/// tombstone the instant the winning occurrence is gone.
///
/// Restoring an item that **isn't** tombstoned is not refused — it is a harmless stamped
/// rewrite, the same shrug `deleteItem` gives an already-deleted item: this layer does not
/// police liveness (a second, independent liveness check here could only drift from the
/// store UI's own, which is what actually decides whether Put Back is offered at all).
public static func restoreItem(at itemFolder: URL) throws(BoardWriteError) {
let operation = WriteOperation.restore(title: nil)
try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation)
try checkIsUUIDShaped(itemFolder, operation: operation)
try updateIndex(inItemFolder: itemFolder, operation: operation) { document in
document.remove(FrontmatterKeys.deleted)
}
}
/// Physical removal — Delete Immediately / Empty Trash (03-board-ui.md): deletes the
/// folder tree from disk. Irreversible, and distinct from tombstoning — this call does
/// **not** require the item to be tombstoned first, since Delete Immediately skips the
/// tombstone stage by design.
///
/// **A folder that is already gone is success, not an error** — checked first, before the
/// shape guard below. A Finder deletion converges on exactly the end state a purge would
/// produce (01-storage-format.md § Deletion, "a folder that disappears without a
/// tombstone... is also a delete"), so there is nothing left here to distinguish: a stray
/// path that never existed and a once-real item someone already threw away in Finder both
/// purge cleanly, silently, without inspecting what used to be there.
///
/// When the folder *does* exist, `checkIsUUIDShaped` guards the same unreachability
/// `deleteItem`/`restoreItem` rely on: a board root or a stray never purges through this
/// call, only a lane or a card.
public static func purgeItem(at itemFolder: URL) throws(BoardWriteError) {
let operation = WriteOperation.purge(title: nil)
guard FileManager.default.fileExists(atPath: itemFolder.path) else { return }
try checkIsUUIDShaped(itemFolder, operation: operation)
do {
try FileManager.default.removeItem(at: itemFolder)
} catch {
throw BoardWriteError(
operation: operation,
path: itemFolder.path,
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
}
// MARK: - Task checkboxes
/// Flips one task-list checkbox in an item's body — **a single-byte edit, and the only write
/// in the app that touches the body at all** (05-card-window.md ▸ Preview: "clicking a
/// `- [ ]` / `- [x]` checkbox flips exactly that marker in the source — a single-character
/// textual edit; every other byte of the body is untouched").
///
/// It is `updateIndex`'s four steps with one addition, and the addition is why it is written
/// out here rather than expressed as an `edits` closure: the flip can **refuse**, and
/// `updateIndex`'s closure cannot. Everything else is identical and deliberately so — read
/// fresh from disk, refuse an uneditable frontmatter shape, edit, stamp `modified` and clear
/// `modified-by`, replace atomically. A toggle is "an ordinary user edit — the standard atomic
/// write, auto-committed and undoable on git boards" (05), not a special case of anything.
///
/// ### The offset, and why it is re-checked
///
/// `bodyOffset` is a UTF-8 byte offset into the **body** (`FrontmatterDocument.body`, the text
/// after the closing delimiter) naming the character *between* the brackets — the offset
/// `BodyTask.markerOffset` carried out of the parse that drew the box the user clicked.
///
/// That parse ran against a snapshot; this call reads disk. In between, an agent, a hand edit
/// or a pull may have rewritten the file — the same staleness `updateIndex`'s read-fresh rule
/// exists for, except that here the *target* is a byte offset rather than a key, and a stale
/// key merely rewrites the wrong value while a stale offset would drop an `x` into the middle
/// of a sentence. So `BodyMarkup.flippingTaskMarker` re-verifies the brackets, the marker, and
/// the state the user saw before anything is written, and `.staleTarget` refuses when any of
/// the three has moved. The refusal is loud (the banner) rather than silent: the user clicked
/// a box and it did not tick.
///
/// **`checked` is what the user saw, not what they want** — the flip's direction is derived
/// from it, which is what makes a double-click land on one net toggle instead of racing.
public static func toggleTaskMarker(
inItemFolder folder: URL,
bodyOffset: Int,
checked: Bool
) throws(BoardWriteError) {
var operation = WriteOperation.toggleTask(title: nil)
try checkIsDirectory(folder, describedAs: "item folder", operation: operation)
// Lanes and cards both have bodies; a board root's is its description, and no surface
// previews it. The same shape guard every other item write leans on keeps this call off it.
try checkIsUUIDShaped(folder, operation: operation)
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
var document = try readDocument(at: indexURL, operation: operation)
operation = operation.withTitle(document.title.value)
try checkEditable(document, at: indexURL, operation: operation)
guard let flipped = BodyMarkup.flippingTaskMarker(in: document.body, at: bodyOffset, expecting: checked) else {
throw BoardWriteError(
operation: operation,
path: indexURL.path,
reason: .staleTarget(message: "this checkbox is no longer where it was — the file changed")
)
}
document.body = flipped
document.set(FrontmatterKeys.modified, to: .date(Date()))
document.remove(FrontmatterKeys.modifiedBy)
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
}
// MARK: - Card body
/// Replaces an item's **body span** — everything after the frontmatter's closing delimiter — and
/// leaves every frontmatter byte exactly as it was. The card window's Edit buffer landing on
/// disk (05-card-window.md ▸ Edit: "Saved on a ~700 ms debounce; flushed on leaving Edit,
/// entering source mode, and window close").
///
/// ### Byte-honest by the same construction as everything else here
///
/// `FrontmatterDocument` keeps the file's raw text and re-emits it as
/// `openingDelimiter + spans + closingDelimiter + body`, so assigning `body` is *only* a
/// replacement of the body span: unknown keys, their order, comments, blank lines and line
/// endings above the delimiter are the same bytes they were, because nothing re-serialized them.
/// That is `toggleTaskMarker`'s idiom exactly — this call is that one widened from a single
/// character to the whole span, and it shares its four steps: read fresh from disk, refuse an
/// uneditable frontmatter shape, edit, stamp `modified` and clear `modified-by`, replace
/// atomically.
///
/// **The stamp is not optional and not a policy choice here**: a body rewrite *is* an `index.md`
/// rewrite, and every app-mediated `index.md` rewrite stamps (01-storage-format.md §
/// Frontmatter). The raw-source Apply is the one path that keeps a `modified-by`, and it does
/// not come through here.
///
/// ### The gate, and why it lives in the Writer as well as in the session
///
/// **An untouched body is never re-serialized** (05 ▸ Write rules): if the text on disk already
/// equals `body`, this returns `false` having opened the file and touched nothing — no stamp, no
/// temp file, no rename, and therefore an untouched `mtime`. The card window's Edit session
/// gates on the same comparison before it ever calls (its three gates: untouched, reverted, and
/// the echo of an external edit), so in practice this one never fires; it is here because the
/// guarantee is about *bytes on disk*, and the layer that owns the bytes is the layer that can
/// promise it against every caller, including a future one.
///
/// **It is not a staleness check.** A body that changed under the buffer is written over
/// deliberately — "dirty buffer wins ... deliberate last-writer-wins" (05 ▸ Write rules) — which
/// is why nothing here compares against what the caller last saw. Only *equality* refuses, and
/// equality refuses because the write would be a no-op that stamped `modified` anyway.
///
/// - Returns: `true` when bytes were written, `false` when the body on disk already matched.
@discardableResult
public static func writeBody(inItemFolder folder: URL, body: String) throws(BoardWriteError) -> Bool {
var operation = WriteOperation.editBody(title: nil)
try checkIsDirectory(folder, describedAs: "item folder", operation: operation)
// The same shape guard `toggleTaskMarker` leans on, for its reason: a board root's body is
// its description and no editor in this app opens it, so only lanes and cards are reachable.
try checkIsUUIDShaped(folder, operation: operation)
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
var document = try readDocument(at: indexURL, operation: operation)
operation = operation.withTitle(document.title.value)
try checkEditable(document, at: indexURL, operation: operation)
guard document.body != body else { return false }
document.body = body
document.set(FrontmatterKeys.modified, to: .date(Date()))
document.remove(FrontmatterKeys.modifiedBy)
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
return true
}
// MARK: - Attachments
/// The one folder this app ever creates under a card — every other subfolder under
/// `attachments/` is tolerated but never made or named by the app (01-storage-format.md §
/// Attachments). Internal rather than `private`: `importAttachments` and `listAttachments`
/// must never disagree about which folder holds a card's files.
static let attachmentsFolderName = "attachments"
/// Imports files into a card's `attachments/` folder, creating it on first import — the
/// write side of 01-storage-format.md § Attachments. **Never refuses the drop**: a name
/// already taken is auto-renamed Finder-style (`shot.png` → `shot 2.png` → `shot 3.png`, …)
/// rather than overwritten or bounced.
///
/// A multi-file drop imports **in order, one finished copy at a time** — not a transaction
/// across the batch: the first failure stops it and throws naming the failing source file,
/// but every file already imported stays landed, because each import already completed as
/// its own write before the next one starts. Returns the landed names, in input order, for
/// exactly the files that made it in.
///
/// The order of checks is the contract:
///
/// 1. **`cardFolder` must be an existing, UUID-shaped directory** — attachments belong to
/// cards, the same shape guard `deleteItem`/`restoreItem`/`purgeItem` lean on
/// (`checkIsUUIDShaped`): a lane or a board root is refused before anything else happens.
/// 2. **`attachments/` is created if missing** (`.io` naming `cardFolder` on failure) — the
/// one exception to "subfolders are never created by the app" (§ Attachments); every
/// other folder under it is the user's or a hand-editor's, left alone.
/// 3. **Each source is validated before it is touched**: must exist and be a regular file —
/// not a directory, which is out of this call's scope entirely — refused `.unreadable`
/// naming the source *before* any copy for that file is attempted, so a batch never
/// partially copies something it was about to refuse.
/// 4. **The collision-free name is decided, then copied to directly** — no temp name and
/// rename, unlike `atomicReplace`: `FileManager.copyItem` lands the bytes straight under
/// the final name. `copyItem` is not atomic, so on any failure the partial destination is
/// removed best-effort and `.io` is thrown naming the source file — "no half-copied
/// attachment is ever left in `attachments/`" (02-architecture.md § Write-failure
/// surfacing) is a promise about the *failure path*, not a stronger atomicity claim
/// `copyItem` cannot make. A crash mid-copy (process death, not a caught `Error`) can
/// still leave a partial file on disk — the accepted limit of a non-atomic copy, the same
/// one an ordinary Finder copy has.
public static func importAttachments(
_ sourceURLs: [URL],
intoCard cardFolder: URL
) throws(BoardWriteError) -> [ImportedAttachment] {
// Before the per-file loop starts, no single source is implicated yet — the first
// source's own (original, pre-collision-rename) name stands in for the batch; an empty
// `sourceURLs` (nothing was actually dropped) falls back to the empty string rather than
// crashing on `first!`.
let batchOperation = WriteOperation.importAttachment(filename: sourceURLs.first?.lastPathComponent ?? "")
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: batchOperation)
try checkIsUUIDShaped(cardFolder, operation: batchOperation)
let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
do {
try FileManager.default.createDirectory(at: attachmentsFolder, withIntermediateDirectories: true)
} catch {
throw BoardWriteError(
operation: batchOperation,
path: cardFolder.path,
reason: .io(message: "could not create attachments folder: \(error.localizedDescription)")
)
}
var landed: [ImportedAttachment] = []
for sourceURL in sourceURLs {
// Each source names itself — the ORIGINAL filename, not the Finder-style renamed one
// decided a few lines down, because the operation describes what the user dropped.
let operation = WriteOperation.importAttachment(filename: sourceURL.lastPathComponent)
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory) else {
throw BoardWriteError(
operation: operation,
path: sourceURL.path,
reason: .unreadable(message: "file does not exist")
)
}
guard !isDirectory.boolValue else {
throw BoardWriteError(
operation: operation,
path: sourceURL.path,
reason: .unreadable(message: "is a folder, not a file — importing a folder is not supported")
)
}
let name = freshAttachmentName(for: sourceURL.lastPathComponent, in: attachmentsFolder)
let destinationURL = attachmentsFolder.appendingPathComponent(name)
do {
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
} catch {
try? FileManager.default.removeItem(at: destinationURL)
throw BoardWriteError(
operation: operation,
path: sourceURL.path,
reason: .io(message: "could not copy file: \(error.localizedDescription)")
)
}
landed.append(ImportedAttachment(sourceURL: sourceURL, fileName: name))
}
return landed
}
/// Moves loose files out of a card folder and into its `attachments/` — the **write half** of
/// 01-storage-format.md's loose-file carve-out (§ Fractal layout ▸ Rules, settled 2026-07-28,
/// "Lanework-owns-the-board"): "a regular file sitting beside a card's `index.md` … belongs in
/// `attachments/`, and the app moves it there — Finder-style rename on collision".
///
/// **A move, not a copy** — the file is not being imported from somewhere else, it is being put
/// where it already belonged, and leaving a second copy beside `index.md` would leave the very
/// thing this call exists to clear. `FileManager.moveItem` within one folder is a `rename(2)`:
/// atomic, and byte-preserving by not touching bytes at all.
///
/// **`index.md` is never opened.** Relocating a stray says nothing about the card's content, so
/// no `modified` stamp is written and no frontmatter is read — which is also why a card whose
/// frontmatter is uneditable (a flow mapping) still gets its files tidied.
///
/// `names` is the caller's list — the loader's `looseFileNames` at the store, or this file's own
/// `normalizeLooseFiles(inCard:)` at the import boundary — and **every name is re-checked
/// against disk before it is touched** (`isRelocatable`). A name that has stopped being a plain
/// non-hidden regular file since it was listed, or that names a reserved child, or that is not a
/// bare filename at all, is **skipped silently**: the reload is the authority on what is there,
/// and a file the user deleted between the walk and the write is not a failure to report. That
/// re-check is also what makes the rule "folders and symlinks are never relocated" a property of
/// this call rather than of its callers.
///
/// The batch is `importAttachments`' shape exactly: in order, one finished move at a time, the
/// first failure stopping it and throwing while everything already moved stays moved. Returns
/// what actually landed, in input order — `sourceURL` naming the file where it sat, `fileName`
/// the (possibly Finder-renamed) name it took inside `attachments/`.
///
/// `attachments/` is created only when something is actually going to move into it, so a card
/// whose loose files all vanished under the write is left exactly as it was — no empty folder
/// minted for nothing.
///
/// **`cardFolder` must really be a card** (`checkIsCardFolder`, which is stricter than the
/// UUID-shape guard the rest of this file uses): a lane's own loose files keep the verbatim
/// posture, and no other write in the app has to tell the two levels apart.
@discardableResult
public static func relocateLooseFiles(
_ names: [String],
inCard cardFolder: URL
) throws(BoardWriteError) -> [ImportedAttachment] {
guard !names.isEmpty else { return [] }
// Before the per-file loop starts no single file is implicated yet — the first name stands
// in for the batch, exactly as `importAttachments` lets its first source name it.
let batchOperation = WriteOperation.relocateLooseFile(filename: names[0])
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: batchOperation)
try checkIsCardFolder(cardFolder, operation: batchOperation)
let relocatable = names.filter { isRelocatable($0, in: cardFolder) }
guard !relocatable.isEmpty else { return [] }
let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
do {
try FileManager.default.createDirectory(at: attachmentsFolder, withIntermediateDirectories: true)
} catch {
throw BoardWriteError(
operation: batchOperation,
path: cardFolder.path,
reason: .io(message: "could not create attachments folder: \(error.localizedDescription)")
)
}
var moved: [ImportedAttachment] = []
for name in relocatable {
// Each file names itself — the ORIGINAL name, not the Finder-style renamed one decided
// on the next line, for `importAttachments`' reason: the operation describes the file
// the user (or their agent) actually wrote.
let operation = WriteOperation.relocateLooseFile(filename: name)
let sourceURL = cardFolder.appendingPathComponent(name)
let landed = freshAttachmentName(for: name, in: attachmentsFolder)
do {
try FileManager.default.moveItem(at: sourceURL, to: attachmentsFolder.appendingPathComponent(landed))
} catch {
throw BoardWriteError(
operation: operation,
path: sourceURL.path,
reason: .io(message: "could not move file into attachments: \(error.localizedDescription)")
)
}
moved.append(ImportedAttachment(sourceURL: sourceURL, fileName: landed))
}
return moved
}
/// Discovers *and* relocates — the loose-file rule applied at an **import boundary**, where
/// there is no loader round trip to discover through (04-interactions.md ▸ Clipboard, settled
/// 2026-07-28: "A paste is an import boundary, so normalization applies … loose files the staged
/// snapshot carries beside a card's `index.md` land in the pasted card's `attachments/`,
/// Finder-renamed on collision — nothing the snapshot preserved is dropped on arrival").
///
/// The pasted card therefore lands **already normalized**, rather than arriving loose and being
/// tidied a reload later: the write is happening anyway, and one that leaves work behind for the
/// carve-out to find would also post the carve-out's notice — a warning row about a mess the
/// user's own paste made and the app immediately cleaned up.
///
/// A card with nothing loose is one directory listing and no write at all.
@discardableResult
public static func normalizeLooseFiles(inCard cardFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] {
try relocateLooseFiles(BoardLoader.looseFileNames(in: cardFolder), inCard: cardFolder)
}
/// The lane-level face of the same import-boundary normalization: every card of an arriving
/// lane, in folder order.
///
/// Children are `childCandidates` — the loader's own level detection — so a stray *folder*
/// inside the arriving lane is neither descended into nor tidied, and nothing below a card is
/// reached: the carve-out is card-level and one level deep, exactly as 01 states it.
@discardableResult
public static func normalizeLooseFiles(inLane laneFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] {
var moved: [ImportedAttachment] = []
for card in childCandidates(of: laneFolder) {
moved.append(contentsOf: try normalizeLooseFiles(inCard: card))
}
return moved
}
/// Refuses any folder that is not a **card**: UUID-shaped, *under* a UUID-shaped parent.
///
/// `checkIsUUIDShaped` is the guard every other item write leans on, and it is the wrong one
/// here because it cannot tell a lane from a card — both are UUID-shaped, which is exactly the
/// distinction the carve-out turns on ("everything at board or lane level keeps the verbatim
/// posture"; "board/lane-level strays … are legitimate residents"). Pointing the relocation at a
/// lane would sweep a hand-editor's `notes.txt` into an `attachments/` folder no lane should
/// ever have.
///
/// The parent test is exact rather than heuristic because 01-storage-format.md § Fractal layout
/// fixes the depth: a card is `<root>/<lane>/<card>` and a lane is `<root>/<lane>`, so a
/// UUID-shaped folder whose parent is *also* UUID-shaped is a card and nothing else. It is the
/// same reading `BoardStore.boardRoot(ofCardFolder:)` already derives a root from.
private static func checkIsCardFolder(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) {
try checkIsUUIDShaped(folder, operation: operation)
guard BoardLoader.isUUIDShaped(folder.deletingLastPathComponent().lastPathComponent) else {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .unreadable(message: "folder is not a card: only a card's own files are relocated")
)
}
}
/// Whether `name` inside `cardFolder` is a file this app may relocate: a bare filename (never a
/// path), not hidden, not one of the reserved card-level names, and — read from disk, at write
/// time — a regular file that is not a symlink.
///
/// The four name rules restate the loader's listing exclusions rather than trusting them,
/// because `relocateLooseFiles` takes a caller's list: this is where "the carve-out is exactly
/// that narrow" stops being a convention and becomes something the filesystem-touching code
/// enforces on its own.
private static func isRelocatable(_ name: String, in cardFolder: URL) -> Bool {
guard !name.isEmpty,
!name.hasPrefix("."),
!name.contains("/"),
!BoardLoader.reservedCardChildNames.contains(name.lowercased()),
let values = try? cardFolder
.appendingPathComponent(name)
.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
else {
return false
}
return values.isRegularFile == true && values.isSymbolicLink != true
}
/// The Finder-style collision-free name for `originalName` landing in `folder`: the name
/// itself when nothing on disk claims it yet, else the base name suffixed `" 2"`, `" 3"`, …
/// — counting up from 2 against what is on disk *at decision time*, one collision at a time.
///
/// Splits `originalName` the way `URL` itself does (`deletingPathExtension`/
/// `pathExtension`), which is also exactly how Finder does it: an extension-less name
/// suffixes directly (`"notes"` → `"notes 2"`), and a multi-dot name splits after the
/// *last* dot (`"archive.tar.gz"` → `"archive.tar 2.gz"`, not `"archive 2.tar.gz"`) — both
/// accepted as what Finder itself produces, not worked around.
///
/// `fileExists` is the one test, and it is true for a directory as much as a file — a
/// same-named *subfolder* blocks the name exactly like a file would, so an import never
/// overwrites, renames, or descends into one; it just renames the incoming file instead.
private static func freshAttachmentName(for originalName: String, in folder: URL) -> String {
guard FileManager.default.fileExists(atPath: folder.appendingPathComponent(originalName).path) else {
return originalName
}
let nameURL = URL(fileURLWithPath: originalName)
let base = nameURL.deletingPathExtension().lastPathComponent
let ext = nameURL.pathExtension
var counter = 2
while true {
let candidate = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)"
guard FileManager.default.fileExists(atPath: folder.appendingPathComponent(candidate).path) else {
return candidate
}
counter += 1
}
}
/// The card's flat attachment listing (01-storage-format.md § Attachments, "the app's
/// attachment surfaces … are flat: top-level files only"): the top-level *files* of
/// `attachments/`, subfolders and hidden files excluded, symlinks excluded, sorted
/// `localizedStandardCompare` — Finder order, so `"shot 2.png"` sorts before `"shot
/// 10.png"` rather than after it.
///
/// A **missing** `attachments/` lists as `[]`, not an error: nothing has been imported yet
/// is an ordinary state, not a malformed one. `cardFolder` itself missing, or not a
/// directory, *is* `.unreadable` — that is a caller asking about a card that isn't there,
/// not an empty listing. Purely a read: nothing here ever creates `attachments/` or
/// disturbs anything inside it, subfolders included.
///
/// The listing itself is `BoardLoader.attachmentNames(in:)`, which is also what fills
/// `Card.attachments` for the board window's face indicator. **One function, so the sidebar
/// and the face can never disagree** about a card's attachments or their order.
/// What this call adds over that one is the card-folder guard and this API's error
/// vocabulary — the difference between a surface that *acts* on the files and two that
/// merely show them.
public static func listAttachments(ofCard cardFolder: URL) throws(BoardWriteError) -> [String] {
let operation = WriteOperation.listAttachments
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
return BoardLoader.attachmentNames(in: cardFolder)
}
// MARK: - Move/copy pre-flight
/// The rank a moved or copied root lands on: the caller's explicit value — a drop between
/// two siblings, whose midpoint only the caller knows — or `max + 1024` over the
/// destination's visible siblings when it has none (`Ranks.append(toVisible:)`, tombstones
/// inert). Computed *before* the item arrives, so it cannot count itself as its own sibling.
///
/// `requireEditable: false` for the same reason the creates pass it: this only *reads* the
/// siblings' orders. A flow-mapping sibling that loads and renders normally must not block
/// dropping an item beside it.
private static func destinationOrder(
_ order: Double?,
inParent parentFolder: URL,
operation: WriteOperation
) throws(BoardWriteError) -> Double {
if let order { return order }
let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false)
return Ranks.append(toVisible: siblings.map(\.order))
}
/// Refuses a folder that is not a lane or a card. Level detection is by name shape
/// (01-storage-format.md § Fractal layout ▸ Rules), so a stray — `notes/`, a truncated or
/// non-hex UUID, a hand-made folder — is not an item, and moving, copying, deleting, restoring, or
/// purging one as if it were would invent (or destroy) an identity the loader would
/// otherwise just ignore. Shared by every operation that must never reach a board root: a
/// board root's folder name is never UUID-shaped (§ Board naming), so this one check is
/// what makes board-root deletion/restore/purge structurally unreachable at this layer.
private static func checkIsUUIDShaped(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) {
guard BoardLoader.isUUIDShaped(folder.lastPathComponent) else {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .unreadable(message: "folder name is not UUID-shaped: only lanes and cards are valid here")
)
}
}
/// The discover-before-you-write pre-flight `moveItem` and `copyItem` both run on the root
/// they are about to relocate: that file *will* be rewritten at the destination (its `order`,
/// plus the stamps), so one that cannot be read or cannot be edited in place refuses the
/// whole gesture while nothing has happened yet — rather than after the folder has already
/// travelled, or with a copy already materialized.
///
/// Returns `operation` enriched with the title this same read just learned
/// (`WriteOperation.withTitle`) — the caller's *only* read of the moved/copied root before it
/// travels, so this is the one place `moveItem`/`copyItem` can learn it at all. Returned
/// rather than discarded so every failure after the pre-flight passes (the `FileManager`
/// move/copy itself, the post-arrival `updateIndex`) also names the item.
private static func checkIndexIsRewritable(
inItemFolder folder: URL,
operation: WriteOperation
) throws(BoardWriteError) -> WriteOperation {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
let document = try readDocument(at: indexURL, operation: operation)
let operation = operation.withTitle(document.title.value)
try checkEditable(document, at: indexURL, operation: operation)
return operation
}
// MARK: - Reading
/// Reads and parses an `index.md` for rewriting. **Strict, byte-faithful UTF-8**:
/// `String(validating:as:)` rejects malformed sequences outright and — unlike Foundation's
/// NSString-backed decoders — does not silently swallow a leading BOM, which would turn a
/// rewrite of a BOM'd file into a whole-file byte change. A file that does not decode, or
/// whose frontmatter does not parse, is `.unreadable` with the specifics: the app declines
/// to write a file it cannot round-trip (01-storage-format.md § Fractal layout ▸ Rules).
private static func readDocument(at url: URL, operation: WriteOperation) throws(BoardWriteError) -> FrontmatterDocument {
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
throw BoardWriteError(
operation: operation,
path: url.path,
reason: .unreadable(message: "could not read file: \(error.localizedDescription)")
)
}
guard let text = String(validating: data, as: UTF8.self) else {
throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "file is not UTF-8"))
}
do {
return try FrontmatterDocument.parse(text)
} catch {
throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: error.description))
}
}
private static func checkEditable(
_ document: FrontmatterDocument,
at url: URL,
operation: WriteOperation
) throws(BoardWriteError) {
if let shape = document.uneditableShape {
throw BoardWriteError(operation: operation, path: url.path, reason: .uneditableFrontmatter(shape))
}
}
}
// MARK: - Move and copy vocabulary
/// What a move did to identity: where the item ended up, plus every remint the import boundary
/// performed on the way in (01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle").
///
/// `id` is the source identity unchanged for every ordinary move — same board or not — and the
/// minted one when the arriving root itself collided. `reminted` is empty for every move that
/// crossed no import boundary, and for every import that found no collision; when it is not,
/// each entry is a folder whose UUID the destination board already held, repaired in place.
/// Callers need both: `id` to select or scroll to the moved item afterwards, `reminted` to
/// re-point anything that was holding the old identities (a selection, an open card window).
///
/// Content is not part of this: a remint renames a folder and touches no bytes, so nothing in
/// here implies an edit.
public struct MoveResult: Sendable, Equatable {
public let id: ItemID
public let reminted: [Remint]
public struct Remint: Sendable, Equatable {
public let from: ItemID
public let to: ItemID
}
}
/// How a copy stamps the files it materializes — the one axis on which the two kinds of copy
/// differ (01-storage-format.md § Fractal layout ▸ Rules; § Frontmatter).
public enum CopyStamps: Sendable {
/// An ordinary copy — paste, duplicate, a cross-board drag: **keeps `created`**, because a
/// fork really was created when its original was, stamps `modified`, and clears
/// `modified-by` (a copy is an app write).
case fork
/// Template instantiation (09-templates.md): stamps `created` **and** `modified` fresh and
/// strips `modified-by` — the result is born today, not forked from the template.
case born
}
// MARK: - Attachment vocabulary
/// One imported attachment: where it came from and the name it landed under. `fileName` is the
/// post-collision-rename name — `sourceURL.lastPathComponent` when nothing on disk claimed it,
/// the Finder-style `"… 2"` (or higher) variant otherwise (`importAttachments`). `sourceURL` is
/// carried through unchanged so a caller can report the batch (which files came from where)
/// without re-deriving it from input order.
public struct ImportedAttachment: Sendable, Equatable {
public let sourceURL: URL
public let fileName: String
}
// MARK: - Write operation vocabulary
/// What the Writer was doing when it failed — a closed vocabulary, not a string (settled,
/// 02-architecture.md § Write-failure surfacing). The banner layer switches exhaustively over
/// this to phrase user-facing text, so a new operation here is a compile-time hole there, never
/// a silent default. `title` is the affected item's title where the operation learned it before
/// failing (nil when the failure struck before the title could be read).
public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case createBoard
case createLane
case createCard
case move(title: String?)
case reorder(title: String?)
case copy(title: String?)
case delete(title: String?) // tombstone
case restore(title: String?)
case purge(title: String?)
case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md)
case resize(title: String?) // a lane's `width` — the edge drag and the stepper alike (03-board-ui.md § Lane)
/// An inline title editor's commit — the third inline editor's write (04-interactions.md ▸
/// Grammar). Its own case rather than a fold into `.style`: "the vocabulary grows with the
/// surfaces" is settled (02-architecture.md § Write-failure surfacing, which names
/// "Couldn't rename 'Fix login'…" verbatim), and a rename that failed must not tell the user
/// the app could not *restyle* something. `title` is the item's title as it stood **before**
/// the edit — `updateIndex` enriches it off the document it just read — which is the name the
/// user is still looking at when the banner appears.
case rename(title: String?)
/// File ▸ Duplicate — the whole-board copy (03-board-ui.md § Welcome screen & templates). Its
/// own case rather than a fold into `.copy`, on `.rename`'s reasoning: the user pressed
/// *Duplicate*, and a banner telling them the app could not "copy the item" would name neither
/// the command nor the thing. `title` is the board's display name.
case duplicateBoard(title: String?)
case importAttachment(filename: String)
case listAttachments
case renumberChildren // order-maintenance sweep (compaction)
/// A loose file being moved out of a card folder into its `attachments/` — the loose-file
/// carve-out's write (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28).
///
/// Its own case rather than a fold into `.importAttachment`, on `.rename`'s and
/// `.duplicateBoard`'s reasoning: nothing was *imported* — no file crossed into the board, the
/// user dropped nothing, and a banner saying the app "couldn't import 'notes.txt'" would
/// describe a gesture that never happened. `filename` is the name as it sat beside `index.md`,
/// never the Finder-renamed one it would have landed under.
case relocateLooseFile(filename: String)
/// A Preview task-list checkbox being ticked or unticked (05-card-window.md ▸ Preview).
///
/// Its own case rather than a fold into `.rename`'s or `.style`'s neighbourhood, on the
/// vocabulary's standing reasoning: the user clicked a checkbox, and a banner telling them the
/// app could not "restyle" or "rename" the card would name a gesture that never happened. It is
/// also the app's only *body* write, which is worth being able to see in a log at a glance.
case toggleTask(title: String?)
/// The card window's Edit buffer being saved — the debounced tick, the flush that leaves Edit,
/// and the flush that closes the window (05-card-window.md ▸ Edit).
///
/// Its own case beside `.toggleTask` rather than folded into it, on the vocabulary's standing
/// reasoning: both write a body, but one is a checkbox the user ticked and the other is prose
/// they typed, and a banner telling someone the app "couldn't tick the checkbox" after they
/// wrote three paragraphs would name a gesture that never happened. `title` is the card's title
/// as the read that preceded the write found it — the name on the window they are typing in.
case editBody(title: String?)
/// Fills in the title once the Writer has read it off the document the operation is acting
/// on — identity for the six cases with no title slot at all: `createBoard`/`createLane`/
/// `createCard` are minting a file, not reading one; `importAttachment`'s "title" is the
/// filename it already carries; `listAttachments` and `renumberChildren` name no single item.
/// Called once, right where the operation's `readDocument` succeeds — `updateIndex` itself
/// (which covers every case that funnels through it: renumber, delete, restore, style, and
/// the tail end of move/copy) and the move/copy pre-flight, before the folder travels or the
/// copy materializes. Every failure past that point reuses the enriched value, because the
/// case is immutable once a caller has it in hand — there is nothing to "forget" later.
public func withTitle(_ title: String?) -> WriteOperation {
switch self {
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
.renumberChildren, .relocateLooseFile:
self
case .move: .move(title: title)
case .reorder: .reorder(title: title)
case .copy: .copy(title: title)
case .delete: .delete(title: title)
case .restore: .restore(title: title)
case .purge: .purge(title: title)
case .style: .style(title: title)
case .resize: .resize(title: title)
case .rename: .rename(title: title)
case .duplicateBoard: .duplicateBoard(title: title)
case .toggleTask: .toggleTask(title: title)
case .editBody: .editBody(title: title)
}
}
/// A short imperative phrase — `"move 'Fix login'"`, `"create card"`, `"import attachment
/// 'photo.png'"` — for logs and diagnostics **only**: `BoardWriteError.description` (test
/// failures, `po error`, console output), never the banner's text. The banner owns every
/// word a user sees and switches exhaustively over the case itself to produce it
/// (02-architecture.md § Write-failure surfacing); this description exists purely so a
/// developer reading a raw error gets English without the banner layer's help.
public var description: String {
switch self {
case .createBoard: "create board"
case .createLane: "create lane"
case .createCard: "create card"
case let .move(title): Self.phrase("move", title)
case let .reorder(title): Self.phrase("reorder", title)
case let .copy(title): Self.phrase("copy", title)
case let .delete(title): Self.phrase("delete", title)
case let .restore(title): Self.phrase("restore", title)
case let .purge(title): Self.phrase("purge", title)
case let .style(title): Self.phrase("style", title)
case let .resize(title): Self.phrase("resize", title)
case let .rename(title): Self.phrase("rename", title)
case let .duplicateBoard(title): Self.phrase("duplicate board", title)
case let .importAttachment(filename): "import attachment '\(filename)'"
case .listAttachments: "list attachments"
case .renumberChildren: "renumber children"
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
case let .editBody(title): Self.phrase("save the body of", title)
}
}
private static func phrase(_ verb: String, _ title: String?) -> String {
guard let title else { return verb }
return "\(verb) '\(title)'"
}
}
// MARK: - Error
/// A write that did not happen, said out loud: which operation, which file, and why —
/// the vocabulary 02-architecture.md § Write-failure surfacing renders in the banner
/// ("Couldn't move 'Fix login' — disk full"). Nothing here is swallowed or retried behind the
/// user's back; a one-shot action fails once and waits for them to act again.
public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertible {
/// What was being attempted when the write failed — the closed `WriteOperation` vocabulary,
/// not a string (settled, 02-architecture.md § Write-failure surfacing): the banner switches
/// exhaustively over this case to produce its user-facing phrasing, so this field carries no
/// English of its own — that lives only in `WriteOperation.description`, for logs.
public let operation: WriteOperation
/// The file or folder involved. Absolute at this layer: the writer works in URLs and has no
/// board root to be relative to (contrast `BoardLoadError.path`, which is root-relative).
public let path: String
public let reason: Reason
public var description: String { "\(operation.description): \(path): \(reason.description)" }
public enum Reason: Sendable, Equatable, CustomStringConvertible {
/// The file is missing, is not UTF-8, or its frontmatter does not parse — `message`
/// carries the specifics. A rewrite the app cannot round-trip is not attempted.
case unreadable(message: String)
/// The settled readable-but-uneditable refusal (01-storage-format.md § Frontmatter):
/// the file loads and renders, but its frontmatter has a shape the surgical editor
/// cannot address, so writing it would risk corruption. Names the shape.
case uneditableFrontmatter(FrontmatterDocument.UneditableShape)
/// Any I/O failure writing the temp file or renaming it into place — disk full,
/// permissions, volume error. The destination still holds its previous bytes.
case io(message: String)
/// **The bytes the edit aimed at are not what the caller was shown.** The file read
/// cleanly and its frontmatter parsed — this is not `.unreadable` — but the surgical
/// target moved: the checkbox at that offset is gone, or is already in the state the
/// click would have produced (05-card-window.md ▸ Preview, `toggleTaskMarker`).
///
/// Its own reason because the app's one *offset-addressed* write is the one place where
/// "read fresh from disk" is not enough on its own: every other write names a key, and a
/// key that moved is still the same key.
case staleTarget(message: String)
public var description: String {
switch self {
case let .unreadable(message):
"unreadable: \(message)"
case let .uneditableFrontmatter(shape):
"frontmatter cannot be edited in place: \(shape.description)"
case let .io(message):
message
case let .staleTarget(message):
message
}
}
}
}