The lane title bar becomes real: leading SF Symbol (hand-written names render leniently, unknown ones fall back to the level default), title or secondary untitled placeholder, a quiet count badge that counts exactly the cards the body renders (so the m5 search filter is followed by construction), and a new-card button. The whole bar is the reorder drag surface — no grip — with click-vs-movement splitting select from drag; a pure proposal function maps the drag to an insertion index and release commits through the Writer's same-parent degenerate reorder, compacting and retrying when midpoint precision runs out. Clicking never edits: inline rename is Return on the sole selected card or Board > Rename for either kind, a third transient editor beside the placeholder that tracks its target by UUID, commits on focus loss, discards silently when the target vanishes, and removes the title key on an empty commit. The new-card placeholder renders at last — the settled Cmd-N target rule (pure, tested) files it after the anchor card, at a selected lane's bottom, or into the last-active lane; Return commits and re-selects the lane, Cmd-Return also opens the card window, and a failed create discards the overlay. New Card / New Lane / Rename land in the menus with focused-editor and read-only validation; rename gets its own WriteOperation case in the banner vocabulary. 59 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
1312 lines
74 KiB
Swift
1312 lines
74 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))
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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: - 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
|
|
}
|
|
|
|
/// 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.
|
|
public static func listAttachments(ofCard cardFolder: URL) throws(BoardWriteError) -> [String] {
|
|
let operation = WriteOperation.listAttachments
|
|
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
|
|
|
let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
|
|
guard let entries = try? FileManager.default.contentsOfDirectory(
|
|
at: attachmentsFolder,
|
|
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
|
|
options: [.skipsHiddenFiles]
|
|
) else {
|
|
return []
|
|
}
|
|
|
|
let files = entries.filter { url in
|
|
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else {
|
|
return false
|
|
}
|
|
return values.isRegularFile == true && values.isSymbolicLink != true
|
|
}
|
|
return files.map(\.lastPathComponent).sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
|
}
|
|
|
|
// 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?)
|
|
case importAttachment(filename: String)
|
|
case listAttachments
|
|
case renumberChildren // order-maintenance sweep (compaction)
|
|
|
|
/// 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:
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// 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 .importAttachment(filename): "import attachment '\(filename)'"
|
|
case .listAttachments: "list attachments"
|
|
case .renumberChildren: "renumber children"
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|