Phase 1 of the lanes-in-trash card (2026-07-29 ruling, docs led the
code): lane delete is a move into .trash/ with the subtree intact,
arriving at top trash rank — no destructive delete remains outside
the trash.
TrashedLane opaque unit (id/schema/title/order/heldCards) beside
trash cards — deliberately not a Lane, so no card-shaped surface can
believe an empty subtree. Loader's trash walk trusts the kind VALUE
(lane → opaque unit w/ held-card count counted at the loader's own
unit; card → ordinary card; absent/unrecognized → UUID-children
shape, empty-kindless falls to card per 01's honest limit). Writer:
moveIntoTrash generalized with kind passed never derived (an empty
lane would re-derive as card), deleteLaneToTrash mints against the
whole-container rank ladder. Retired: migrateTombstonedLane (lane
deleted: now ignored — loads live, bytes inert, tolerate-tier
warning), removeLane, captureSubtree/recreateSubtree and the
subtree-snapshot machinery. Undo inverse = move back to captured
strip position, redo replays at captured trash rank. Purge walks
lane subtrees; TrashModel.Freight phrases confirms with lane freight
("…and its 5 cards"). ItemPath gains .trashLane; resolve interleaves
the trash by rank; SearchFilter matches lane rows by title only.
Trashed-lane card windows dismiss and pending cuts void via the
ordinary vanish rule — no new plumbing.
Phase 2 (rendering, selection grammar, drag, a11y, agent guide)
follows. Both schemes 1858 tests / 318 suites green.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2777 lines
164 KiB
Swift
2777 lines
164 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 that rewrites *content*. 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. The one class of write that does neither is the **order-only rewrite**
|
|
/// (`WriteOperation.rewritesOrderOnly` — the reorders-don't-stamp rule).
|
|
/// - **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 reorders-don't-stamp predicate
|
|
///
|
|
/// Step 3 is **skipped for an order-only rewrite** (01-storage-format.md § Frontmatter ▸
|
|
/// `modified`'s scope — ruled 2026-07-29 as moves-don't-stamp, refined 2026-07-30 to the
|
|
/// container-change predicate). `order` is logically the *container's* property — a relationship
|
|
/// among a lane's members that the format happens to store inside each member's file — so a
|
|
/// rewrite that only restates position touches no content: no `modified` stamp, and no
|
|
/// `modified-by` clear (the two are paired; attribution cannot change when content didn't).
|
|
///
|
|
/// **The predicate is the operation's, not a parameter** (`WriteOperation.rewritesOrderOnly`):
|
|
/// the vocabulary already draws the line this rule needs — `.reorder` is by construction the
|
|
/// same-container case (`moveItem` decides it from the two URLs before it touches disk) and
|
|
/// `.renumberChildren` is the whole-lane rescale. Deriving it here rather than asking each call
|
|
/// site means no caller can forget, and the rule stays one exhaustive switch a suite can pin
|
|
/// without a filesystem.
|
|
///
|
|
/// **There is no trash branch anywhere**, deliberately: a move into or out of `.trash/` changes
|
|
/// the item's container, so it stamps for the same reason a cross-lane move does. The trash move
|
|
/// is the container rule's plainest instance rather than an exception to a rule about moves.
|
|
///
|
|
/// 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.
|
|
///
|
|
/// ## The on-touch heal seam
|
|
///
|
|
/// **This is where latent defects are fixed** (01-storage-format.md § Validation and healing;
|
|
/// 02-architecture.md ▸ Components ▸ HealScheduler: "on-touch heals live at the Writer's
|
|
/// `updateIndex` seam, which consults IntegrityRules for pending on-touch work on the file it is
|
|
/// rewriting"). Between `edits` and the stamps, `IntegrityRules.healOnTouch` backfills a missing
|
|
/// `kind` — the file is being rewritten anyway, so the fix costs nothing and rides the host
|
|
/// write's single atomic rewrite, its `modified` stamp, and its commit. There is deliberately no
|
|
/// scheduled sweep for it (re-ruled 2026-07-29): outside `.trash/` the key is redundant with
|
|
/// position, and rewriting a whole board to add one would be churn for nothing.
|
|
///
|
|
/// The other two members of that class need no line here because they are already the editor's:
|
|
/// `FrontmatterDocument.set` collapses duplicate-key twins on every key it writes, and
|
|
/// `FrontmatterValue.emitScalar` quotes a value that needs it on first write. They are *named*
|
|
/// in `IntegrityRules.OnTouchHeal` rather than re-implemented — same class, no behavior change.
|
|
///
|
|
/// - Parameter kind: the object's kind where the caller knows it and position cannot answer —
|
|
/// **the board root**, whose folder name is a Finder document name rather than an identity.
|
|
/// `nil`, the default, derives it from position (`IntegrityRules.placement`), and stamps
|
|
/// nothing when position has no answer: a guessed kind on disk would be worse than an absent
|
|
/// one, because the trash's discriminator trusts what it finds.
|
|
public static func updateIndex(
|
|
inItemFolder folder: URL,
|
|
kind: IntegrityRules.ObjectKind? = nil,
|
|
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)
|
|
// After `edits`, so a caller that wrote its own `kind` is left alone, and before the stamps,
|
|
// which outrank everything for their own reason.
|
|
IntegrityRules.healOnTouch(&document, kind: kind ?? derivedKind(ofItemFolder: folder))
|
|
// The reorders-don't-stamp predicate, read off the operation. An order-only rewrite restates
|
|
// the container's own arrangement and leaves both provenance keys exactly as it found them —
|
|
// a standing `modified-by` survives a reorder, which is the pairing 01 spells out.
|
|
if !operation.rewritesOrderOnly {
|
|
document.set(FrontmatterKeys.modified, to: .date(Date()))
|
|
document.remove(FrontmatterKeys.modifiedBy)
|
|
}
|
|
|
|
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
|
|
}
|
|
|
|
/// The kind of the item in `folder`, read off its **position** — "level is position"
|
|
/// (01-storage-format.md § Fractal layout), with the trash's flat container falling through to
|
|
/// the discriminator that exists for exactly that (`IntegrityRules.trashKind`).
|
|
///
|
|
/// `nil` where position has no answer, which on a real board is only the board root (whose
|
|
/// writers name their kind outright) and off it is any hand-named folder — a test fixture, a
|
|
/// caller pointed at something that is not a level. Answering "board" there instead would stamp
|
|
/// a kind onto whatever was pointed at, which is the one thing the value-names-the-kind posture
|
|
/// cannot afford.
|
|
private static func derivedKind(ofItemFolder folder: URL) -> IntegrityRules.ObjectKind? {
|
|
switch IntegrityRules.placement(
|
|
ofFolderNamed: folder.lastPathComponent,
|
|
inParentNamed: folder.deletingLastPathComponent().lastPathComponent
|
|
) {
|
|
case .card:
|
|
return .card
|
|
case .lane:
|
|
return .lane
|
|
case .insideTrash:
|
|
// The value cannot have answered — a document carrying `kind` is never backfilled, so
|
|
// this is only reached for one that does not — which is precisely when shape decides.
|
|
return IntegrityRules.trashKind(
|
|
kindValue: nil,
|
|
hasIdentityShapedChildIndex: childCandidates(of: folder).contains {
|
|
FileManager.default.fileExists(
|
|
atPath: $0.appendingPathComponent(BoardLoader.indexFileName).path
|
|
)
|
|
}
|
|
)
|
|
case .unknown:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// 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)))")
|
|
)
|
|
}
|
|
// **The receipt, dropped after the bytes land and before the call returns** (the
|
|
// EchoLedger's contract, 02-architecture.md ▸ Components). This one line covers every
|
|
// `index.md` in the app: `updateIndex` funnels here, and so do create, materialize,
|
|
// recreate, the task-marker flip, the body save and the raw-source Apply.
|
|
EchoLedger.current?.recordWrite(at: fileURL, text: text)
|
|
}
|
|
|
|
// 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, kind: .board),
|
|
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, kind: .lane, 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, kind: .card, 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?,
|
|
kind: IntegrityRules.ObjectKind,
|
|
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, kind: kind),
|
|
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`, `kind` — is simply the order `set` is called in, since each call appends a fresh
|
|
/// key before the closing delimiter of an otherwise-empty document. `kind` goes last because
|
|
/// that is where the common table puts it, and because it is where the on-touch backfill appends
|
|
/// one on an older file: a created object and a healed one end up spelled the same way.
|
|
///
|
|
/// **`kind` is written at creation of every object** (01-storage-format.md § Frontmatter,
|
|
/// re-ruled 2026-07-29 — "consistency across the schema, even where position already answers").
|
|
/// Bundled and user templates carry files without it; they gain it lazily through the on-touch
|
|
/// backfill on the first write that rewrites them, which is exactly what that heal is for — no
|
|
/// template migration.
|
|
private static func newDocumentText(
|
|
title: String?,
|
|
order: Double?,
|
|
kind: IntegrityRules.ObjectKind
|
|
) -> 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))
|
|
document.set(FrontmatterKeys.kind, to: .string(kind.rawValue))
|
|
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, `IntegrityRules.canonicalIdentity` — the one canonicalization, shared with `ItemID`), 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.
|
|
/// - **Nothing is stamped.** A rescale is order-only, so no sibling's `modified` moves and no
|
|
/// sibling's `modified-by` is cleared (01-storage-format.md § Ordering, verbatim: "order-only
|
|
/// rewrites, so no `modified` stamp and no `modified-by` clear"). That falls out of
|
|
/// `.renumberChildren` answering `rewritesOrderOnly` rather than being arranged here — which is
|
|
/// what keeps a whole lane's worth of bookkeeping from looking like a whole lane's worth of
|
|
/// edits to the card window, to a future auto-purge, and to an agent's own attribution.
|
|
///
|
|
/// 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`"). `order` is the
|
|
/// caller's explicit rank (a drop between two siblings), or `nil` to append after the
|
|
/// destination's visible siblings.
|
|
///
|
|
/// **Whether that rewrite stamps is the container question** (§ Frontmatter ▸ `modified`'s scope,
|
|
/// refined 2026-07-30 — `WriteOperation.rewritesOrderOnly`), and this call is where it is
|
|
/// answered for every move in the app: the same-parent degenerate path below is a `.reorder` and
|
|
/// rewrites `order` alone, while a real move — cross-lane, cross-board, into or out of `.trash/`
|
|
/// — is a `.move` and stamps `modified` and clears `modified-by` like any content write. The
|
|
/// branch that already exists for the *rank arithmetic* is therefore the whole of the stamping
|
|
/// rule too; there is no second test, and pointedly no trash case.
|
|
///
|
|
/// **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:)` /
|
|
/// `IntegrityRules.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 = IntegrityRules.canonicalIdentity(sourceName)
|
|
rank = Ranks.append(
|
|
toVisible: siblings
|
|
.filter { IntegrityRules.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(IntegrityRules.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)")
|
|
)
|
|
}
|
|
// The old→new pair. The `updateIndex` below then supersedes the arrived `index.md`'s own
|
|
// receipt, which is the truth about the file the reload will read.
|
|
EchoLedger.current?.recordMove(from: sourceFolder, to: arrivedRoot)
|
|
|
|
if isImport {
|
|
let children = childCandidates(of: arrivedRoot)
|
|
reserved.formUnion(children.map { IntegrityRules.canonicalIdentity($0.lastPathComponent) })
|
|
for child in children where existing.contains(IntegrityRules.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 (`IntegrityRules.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> {
|
|
Set(identityOccurrences(inBoard: boardRoot))
|
|
}
|
|
|
|
/// The same walk as a **bag rather than a set** — every identity-bearing folder's canonical name,
|
|
/// duplicates included, which is what lets the duplicate-id remint ask "does anything else still
|
|
/// carry this identity" instead of merely "is it present" (`remintDuplicateIdentity`).
|
|
///
|
|
/// The two exist as one walk deliberately: a re-verify that read the board differently from the
|
|
/// collision probe would be a second definition of "what this board contains".
|
|
private static func identityOccurrences(inBoard boardRoot: URL) -> [String] {
|
|
var identities: [String] = []
|
|
for lane in childCandidates(of: boardRoot) {
|
|
identities.append(IntegrityRules.canonicalIdentity(lane.lastPathComponent))
|
|
for card in childCandidates(of: lane) {
|
|
identities.append(IntegrityRules.canonicalIdentity(card.lastPathComponent))
|
|
}
|
|
}
|
|
// **The trash counts.** Board-wide uniqueness spans both containers (01-storage-format.md
|
|
// § Fractal layout ▸ Rules — "Duplicate ids within a board are never tolerated"), and a
|
|
// trashed card is an ordinary card in a special place: arriving on top of one would put
|
|
// two folders with one identity in the board, and the moment the user dragged the trashed
|
|
// one back out the snapshot would carry the duplicate the loader is forbidden to hold.
|
|
// This is also what makes `deleteCardToTrash`'s "collision is impossible" true rather than
|
|
// hopeful: an import that would have produced the twin was reminted before it landed.
|
|
for card in childCandidates(of: trashFolder(inBoard: boardRoot)) {
|
|
identities.append(IntegrityRules.canonicalIdentity(card.lastPathComponent))
|
|
}
|
|
return identities
|
|
}
|
|
|
|
/// 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)")
|
|
)
|
|
}
|
|
// A remint is a folder move like any other as far as provenance goes — the identity
|
|
// changed, so the arriving item is a different item, and the pair says where it came from.
|
|
EchoLedger.current?.recordMove(from: folder, to: destination)
|
|
}
|
|
|
|
/// 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).
|
|
///
|
|
/// Plus the tracker sever, which is the copy contract's third clause: every folder this
|
|
/// materializes drops the reserved `remote`/`remote-state` keys, at every level
|
|
/// (`applyCopyContract`).
|
|
///
|
|
/// ## A copy is a transaction (ruled 2026-07-29)
|
|
///
|
|
/// **The whole subtree is preflighted for stampability before anything is materialized**, and a
|
|
/// copy that cannot honor the contract on one nested card refuses whole, loudly, naming that card
|
|
/// (01-storage-format.md § Frontmatter: "every copy flow that rewrites descendants' `index.md` …
|
|
/// preflights the entire subtree and refuses whole, loudly, naming the offending item — never a
|
|
/// partial copy, never a silently unstamped descendant").
|
|
///
|
|
/// This **retired the former root-strict/nested-lenient split**, which copied an unreadable or
|
|
/// readable-but-uneditable nested `index.md` byte-verbatim and simply skipped its stamp. The
|
|
/// leniency read as kindness and was in fact the one verdict 01's doctrine forbids: "leniency is
|
|
/// recovering recoverable issues through reliable heuristics — never accepting loss that could
|
|
/// surprise the user … 'proceed partially, lose a little' is never a verdict". A silently
|
|
/// unstamped descendant carries a stale `modified-by` and, since 2026-07-29, a *live tracker
|
|
/// claim* into a second local object — which is exactly the surprise. The finest-grain precedent
|
|
/// covers identity collisions (a per-folder repair that loses nothing), not skipped contract work.
|
|
///
|
|
/// The preflight runs over the **source**, so a refusal costs nothing on disk: nothing is copied,
|
|
/// nothing is renamed, nothing has to be cleaned up. A folder with **no `index.md`** —
|
|
/// interrupted-create residue — is not an offense: it is copied and reminted like any other and
|
|
/// simply has nothing to stamp, 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. After
|
|
/// a clean preflight the only thing left to fail is disk, and that is what this catch is for.
|
|
/// 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)
|
|
// The transaction's preflight, over the *source*: a subtree that cannot honor the copy
|
|
// contract refuses here, where nothing has been materialized and there is nothing to undo.
|
|
try checkCopiedDescendantsAreStampable(of: 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
|
|
applyCopyContract(to: &document, stamps: stamps, now: 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.
|
|
///
|
|
/// **Internal rather than `private`**: template instantiation (`TemplateEngine`) is the same
|
|
/// remint one level up — "mint fresh GUIDs for every lane/card folder" (09-templates.md
|
|
/// ▸ Instantiation) is `copyItem`'s rule applied to a whole board rather than to one item, and
|
|
/// pointing this at a copied *board root* is literally that. A second implementation of "which
|
|
/// folders are identities" is exactly what must not exist.
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// **The copy contract's frontmatter edits**, applied to every folder an item-level copy
|
|
/// materializes — root and descendants alike, in one place so the two can never disagree about
|
|
/// what a copy owes.
|
|
///
|
|
/// Two clauses, and `updateIndex` adds the third:
|
|
///
|
|
/// - **`created` per `stamps`** — `.fork` keeps it (a copy really was created when its original
|
|
/// was), `.born` restamps it, because a board or card made from a template is born today
|
|
/// (09-templates.md).
|
|
/// - **The reserved tracker keys go** — `remote` and `remote-state`, at every level
|
|
/// (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29: "**Item-level copies sever
|
|
/// tracker identity** … because two local objects must never both claim to be the same remote
|
|
/// object"). Content preserved, mapping severed. Nothing reads the keys until Teams, and that is
|
|
/// the argument rather than an objection: the copies made today are the boards Teams will meet,
|
|
/// so the sever costs nothing now and spares a stale double-claim later. `FrontmatterDocument.remove`
|
|
/// takes every occurrence, so a hand-duplicated key cannot leave a twin behind to resurrect the
|
|
/// claim.
|
|
/// - **`modified` stamped and `modified-by` cleared** come from `updateIndex`, because a copy is an
|
|
/// app write like any other (§ Frontmatter) — not something this function has to remember.
|
|
///
|
|
/// `order` is deliberately absent: the copied *root* takes its new rank from its caller, and a
|
|
/// nested item keeps its rank among its own siblings, which travelled with it.
|
|
///
|
|
/// **Whole-board forks do not call this at all** — Duplicate and Save as Template carry bytes
|
|
/// verbatim, GUIDs, timestamps and tracker keys included (01 ▸ Identity lifecycle's carve-out).
|
|
static func applyCopyContract(
|
|
to document: inout FrontmatterDocument,
|
|
stamps: CopyStamps,
|
|
now: Date
|
|
) {
|
|
if case .born = stamps {
|
|
document.set(FrontmatterKeys.created, to: .date(now))
|
|
}
|
|
document.remove(FrontmatterKeys.remote)
|
|
document.remove(FrontmatterKeys.remoteState)
|
|
}
|
|
|
|
/// **The copy transaction's preflight**: every identity-bearing folder beneath `folder` whose
|
|
/// `index.md` the copy contract will rewrite, checked for readability and editability *before*
|
|
/// anything is materialized — and the first offender refuses the whole copy, named
|
|
/// (01-storage-format.md § Frontmatter, ruled 2026-07-29: "preflights the entire subtree and
|
|
/// refuses whole, loudly, naming the offending item").
|
|
///
|
|
/// **Naming the offender is the point**, so the thrown error is re-enriched with *that* item's
|
|
/// title rather than the copy root's: a refusal reading "Couldn't copy 'Sprint 12'" when the
|
|
/// unwritable file is one card inside it would send the user looking in the wrong place. A file
|
|
/// that cannot be read at all has no title to offer, and its path — which the error always
|
|
/// carries — is then the whole of what can honestly be said about it.
|
|
///
|
|
/// **A folder with no `index.md` is not an offense** and is skipped: it is interrupted-create
|
|
/// residue, the loader skips it too (`.missingIndex`), and there is no contract work to fail. The
|
|
/// walk is `identityDescendants`', which is `remintDescendants`' own reach — so the set checked
|
|
/// here is exactly the set that will be stamped, never a superset that could refuse a copy over a
|
|
/// file nobody was going to touch.
|
|
///
|
|
/// **Internal rather than `private`**: template instantiation preflights its own tree with this,
|
|
/// for `remintDescendants`' reason — one definition of what a copy owes its descendants.
|
|
static func checkCopiedDescendantsAreStampable(
|
|
of folder: URL,
|
|
operation: WriteOperation
|
|
) throws(BoardWriteError) {
|
|
for descendant in identityDescendants(of: folder) {
|
|
let indexURL = descendant.appendingPathComponent(BoardLoader.indexFileName)
|
|
guard FileManager.default.fileExists(atPath: indexURL.path) else { continue }
|
|
let document = try readDocument(at: indexURL, operation: operation)
|
|
try checkEditable(document, at: indexURL, operation: operation.withTitle(document.title.value))
|
|
}
|
|
}
|
|
|
|
/// Every identity-bearing folder beneath `folder`, depth first — `remintDescendants`' walk with
|
|
/// the renaming taken out, so the preflight and the remint can never disagree about which folders
|
|
/// a copy materializes as items.
|
|
private static func identityDescendants(of folder: URL) -> [URL] {
|
|
var found: [URL] = []
|
|
for child in childCandidates(of: folder) {
|
|
found.append(child)
|
|
found.append(contentsOf: identityDescendants(of: child))
|
|
}
|
|
return found
|
|
}
|
|
|
|
/// Stamps one copied folder below the root — **strictly**, since the preflight has already cleared
|
|
/// the whole subtree (`checkCopiedDescendantsAreStampable`): an `index.md` that cannot be read or
|
|
/// edited here is a disk failure between the two reads, not a shape to tolerate, and it fails the
|
|
/// copy like any other mid-flight failure (whose partial result the caller removes wholesale).
|
|
///
|
|
/// The former best-effort posture — copy it verbatim, skip its stamp — is retired with the
|
|
/// root-strict/nested-lenient split (see `copyItem`): a silently unstamped descendant is a
|
|
/// descendant still carrying somebody else's `modified-by` and, since 2026-07-29, somebody else's
|
|
/// tracker claim.
|
|
///
|
|
/// A folder with **no `index.md`** is still skipped, and for a different reason entirely: there is
|
|
/// nothing there to stamp (interrupted-create residue, which the loader skips too).
|
|
///
|
|
/// **Internal rather than `private`**, with `remintDescendants` and for its reason: an
|
|
/// instantiated board's lanes and cards are stamped by this exact rule.
|
|
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) else { return }
|
|
|
|
try updateIndex(inItemFolder: folder, operation: operation) { document in
|
|
applyCopyContract(to: &document, stamps: stamps, now: now)
|
|
}
|
|
}
|
|
|
|
// MARK: - The materialized trash
|
|
|
|
/// `<boardRoot>/.trash/` — the board's trash container, named but not created.
|
|
/// One place, so the loader's walk and every write below can never disagree about where the
|
|
/// trash is (the name itself is `BoardLoader.trashFolderName`, which is where the reserved-name
|
|
/// rule lives).
|
|
static func trashFolder(inBoard boardRoot: URL) -> URL {
|
|
boardRoot.appendingPathComponent(BoardLoader.trashFolderName, isDirectory: true)
|
|
}
|
|
|
|
/// **Deleting a card: a physical move into `<board-root>/.trash/`** (01-storage-format.md
|
|
/// § Deletion, resettled 2026-07-28; 03-board-ui.md § Trash). The tombstone is retired — no
|
|
/// key is written, nothing is flagged, and the card becomes "an ordinary card in a special
|
|
/// place".
|
|
///
|
|
/// The sequence, which is the contract:
|
|
///
|
|
/// 1. **`cardFolder` must be a card** (`checkIsCardFolder`, the stricter guard: UUID-shaped
|
|
/// *under* a UUID-shaped parent). A board root and a stray are refused here, and a lane takes
|
|
/// the sibling door (`deleteLaneToTrash`) — same move, different guard and a different `kind`
|
|
/// to stamp, which is exactly the pair `.trash/`'s flat container needs told apart.
|
|
/// 2. **Pre-flight the card's `index.md`** (`checkIndexIsRewritable`) — the move rewrites it
|
|
/// at the destination, so a file that cannot be round-tripped refuses *before* the folder
|
|
/// travels. `moveItem`'s discover-before-you-write rule, for its reason.
|
|
/// 3. **`.trash/` is created if absent** — it is minted by the first delete, so most boards
|
|
/// meet it here.
|
|
/// 4. **Move the folder.** Nothing beneath it is read or rewritten, so `attachments/`, strays
|
|
/// and every byte arrive unchanged, exactly as in an ordinary move.
|
|
/// 5. **Rewrite `order` to `order`, and stamp.**
|
|
///
|
|
/// **`order` is the caller's, always** — deliberately not defaulted and deliberately not
|
|
/// computed here. Entry is at the *top* ("every arrival … lands at the trash's topmost
|
|
/// position, minting an `order` rank above the current top"), which is
|
|
/// `Ranks.insertAtHead(ofVisible:)` over the trash's current ranks — a question about the
|
|
/// *snapshot*, which the store holds and this stateless layer does not. Value-passing keeps
|
|
/// the seam: the Writer takes a rank, the store computes it.
|
|
///
|
|
/// **The `modified` stamp is the point, not a side effect** — and it needs no exception to earn
|
|
/// it. The trash move changes the card's *container*, which is the whole predicate
|
|
/// (`WriteOperation.rewritesOrderOnly`, refined 2026-07-30): "deletion is an edit to the item's
|
|
/// story", so it stamps exactly as a cross-lane move does, and the stamp is what a future
|
|
/// age-based auto-purge reads. It falls out of `updateIndex` here rather than being asked for,
|
|
/// which is why there is nothing extra in step 5 — and why the reorders-don't-stamp rule needs no
|
|
/// trash carve-out to coexist with this call.
|
|
///
|
|
/// **Collision inside `.trash/` is impossible by construction**, and it is checked anyway. The
|
|
/// card is a resident of this very board, and board-wide uniqueness now spans lanes *and* the
|
|
/// trash (`identities(inBoard:)`), so no folder of that name can already be in there — the
|
|
/// import boundary reminted any arriving twin before it ever landed. Should one exist regardless
|
|
/// (a hand copy, an interrupted move), the move fails loudly through `FileManager` rather than
|
|
/// clobbering it: this call never remints, because the identity is exactly what a later restore
|
|
/// and the undo stack are holding on to.
|
|
///
|
|
/// - Returns: the card's identity, unchanged — a delete moves a folder, it does not rename one.
|
|
@discardableResult
|
|
public static func deleteCardToTrash(
|
|
at cardFolder: URL,
|
|
inBoard boardRoot: URL,
|
|
order: Double
|
|
) throws(BoardWriteError) -> ItemID {
|
|
try moveIntoTrash(
|
|
at: cardFolder,
|
|
inBoard: boardRoot,
|
|
kind: .card,
|
|
order: order,
|
|
operation: .delete(title: nil),
|
|
removingLegacyKey: false
|
|
)
|
|
}
|
|
|
|
/// **Deleting a lane: the same physical move into `<board-root>/.trash/`** (01-storage-format.md
|
|
/// § Deletion, lanes joined 2026-07-29 — "retiring the design's sole destructive delete";
|
|
/// 03-board-ui.md § Trash: "deleting a lane moves its folder — subtree intact — into `.trash/`,
|
|
/// exactly as a card moves").
|
|
///
|
|
/// `deleteCardToTrash`'s body with two differences, and they are the whole of what a lane is:
|
|
///
|
|
/// - **The guard is `checkIsLaneFolder`** — UUID-shaped directly under a board root, which
|
|
/// refuses a card, a board root, a stray, and notably a folder already in `.trash/`.
|
|
/// - **`kind: lane` is stamped**, not derived. The rank rewrite is a `updateIndex` on a folder
|
|
/// that is by then *inside* `.trash/`, where position cannot answer and shape would answer
|
|
/// *wrongly* for the one lane that most needs the key: an **empty** lane is shape-identical to
|
|
/// a card (01's own "honest limit"). The caller knows what it moved, so it says so — which is
|
|
/// also the backfill the ruling asks of this write ("`kind: lane` … backfilled on touch when
|
|
/// absent … the trash move's rank mint included").
|
|
///
|
|
/// **The subtree rides along untouched**: nothing beneath the lane is read or rewritten, so its
|
|
/// cards, their `attachments/` and every stray arrive byte-identical and come back with it on
|
|
/// restore — which is what makes the inverse an ordinary move rather than a replay of captured
|
|
/// bytes (13-native-undo.md ▸ Interaction with the trash).
|
|
///
|
|
/// - Returns: the lane's identity, unchanged.
|
|
@discardableResult
|
|
public static func deleteLaneToTrash(
|
|
at laneFolder: URL,
|
|
inBoard boardRoot: URL,
|
|
order: Double
|
|
) throws(BoardWriteError) -> ItemID {
|
|
try moveIntoTrash(
|
|
at: laneFolder,
|
|
inBoard: boardRoot,
|
|
kind: .lane,
|
|
order: order,
|
|
operation: .delete(title: nil),
|
|
removingLegacyKey: false
|
|
)
|
|
}
|
|
|
|
/// **Migrating a legacy tombstoned card**: the same physical move into `.trash/`, plus the
|
|
/// surgical removal of the `deleted:` key that put it there (01-storage-format.md § Deletion:
|
|
/// "a card carrying `deleted:` is relocated into `.trash/` (key removed)").
|
|
///
|
|
/// `deleteCardToTrash` with one extra edit, and written as such rather than as a parameter on
|
|
/// the public delete: the two are different events with different vocabulary — one is the user
|
|
/// pressing ⌫, the other is the app tidying a board written by an older version — and a
|
|
/// failure must say which (`WriteOperation.migrateTombstone`).
|
|
///
|
|
/// The removal is `FrontmatterDocument.remove`, so it takes **every** occurrence of the key:
|
|
/// a hand-duplicated `deleted:` line cannot leave a twin behind that would re-migrate the card
|
|
/// on the next load. Nothing else in the file is touched — unknown keys, comments, blank lines,
|
|
/// line endings and the body are the same bytes they were, and `order` and the stamps are the
|
|
/// only writes, exactly as for an ordinary delete.
|
|
@discardableResult
|
|
public static func migrateTombstonedCard(
|
|
at cardFolder: URL,
|
|
inBoard boardRoot: URL,
|
|
order: Double
|
|
) throws(BoardWriteError) -> ItemID {
|
|
try moveIntoTrash(
|
|
at: cardFolder,
|
|
inBoard: boardRoot,
|
|
kind: .card,
|
|
order: order,
|
|
operation: .migrateTombstone(title: nil),
|
|
removingLegacyKey: true
|
|
)
|
|
}
|
|
|
|
/// The shared body of the three moves into `.trash/` — `deleteCardToTrash`, `deleteLaneToTrash`
|
|
/// and `migrateTombstonedCard`. See the first for the sequence, the second for what a lane's
|
|
/// `kind` is doing here, and the third for what `removingLegacyKey` adds.
|
|
///
|
|
/// **`kind` is both the guard and the stamp**: it picks which shape check the item must pass on
|
|
/// the way out, and it is handed to `updateIndex` so the arrived entry's `kind` is written from
|
|
/// what the caller *moved* rather than guessed from what the flat container makes it look like.
|
|
private static func moveIntoTrash(
|
|
at itemFolder: URL,
|
|
inBoard boardRoot: URL,
|
|
kind: IntegrityRules.ObjectKind,
|
|
order: Double,
|
|
operation initialOperation: WriteOperation,
|
|
removingLegacyKey: Bool
|
|
) throws(BoardWriteError) -> ItemID {
|
|
var operation = initialOperation
|
|
try checkIsDirectory(itemFolder, describedAs: kind == .lane ? "lane folder" : "card folder", operation: operation)
|
|
try checkIsDirectory(boardRoot, describedAs: "board folder", operation: operation)
|
|
switch kind {
|
|
case .lane: try checkIsLaneFolder(itemFolder, operation: operation)
|
|
case .card, .board: try checkIsCardFolder(itemFolder, operation: operation)
|
|
}
|
|
operation = try checkIndexIsRewritable(inItemFolder: itemFolder, operation: operation)
|
|
|
|
let trash = trashFolder(inBoard: boardRoot)
|
|
do {
|
|
try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true)
|
|
} catch {
|
|
throw BoardWriteError(
|
|
operation: operation,
|
|
path: trash.path,
|
|
reason: .io(message: "could not create the trash folder: \(error.localizedDescription)")
|
|
)
|
|
}
|
|
|
|
let name = itemFolder.lastPathComponent
|
|
let arrived = trash.appendingPathComponent(name, isDirectory: true)
|
|
do {
|
|
try FileManager.default.moveItem(at: itemFolder, to: arrived)
|
|
} catch {
|
|
throw BoardWriteError(
|
|
operation: operation,
|
|
path: itemFolder.path,
|
|
reason: .io(message: "could not move folder into the trash: \(error.localizedDescription)")
|
|
)
|
|
}
|
|
// A delete is a move into `.trash/` on disk (01-storage-format.md § Deletion), so the
|
|
// receipt is the move pair — and it reads correctly from either end: the board side sees an
|
|
// absence where the item was, the shown-trash side sees an arrival where it went.
|
|
EchoLedger.current?.recordMove(from: itemFolder, to: arrived)
|
|
|
|
try updateIndex(inItemFolder: arrived, kind: kind, operation: operation) { document in
|
|
document.set(FrontmatterKeys.order, to: .double(order))
|
|
if removingLegacyKey {
|
|
document.remove(FrontmatterKeys.deleted)
|
|
}
|
|
}
|
|
if removingLegacyKey {
|
|
// Heal-marked: the migration is work the app started on its own, and its paths commit
|
|
// separately (06-history-undo.md ▸ Commit messages, ruled 2026-07-29). The ordinary
|
|
// delete this shares a body with is a *gesture* and is deliberately not marked.
|
|
EchoLedger.current?.markHeal(at: arrived)
|
|
EchoLedger.current?.markHeal(at: arrived.appendingPathComponent(BoardLoader.indexFileName))
|
|
}
|
|
|
|
return ItemID(rawValue: name)
|
|
}
|
|
|
|
/// Permanently removes one **entry** from the trash — the trash's own **Delete**
|
|
/// (03-board-ui.md § Trash: "on a trash selection, Delete (⌫/⌘⌫) is permanent … in the trash it
|
|
/// removes the folder").
|
|
///
|
|
/// `purgeItem` with the container checked: the folder must actually sit in this board's
|
|
/// `.trash/`, so a mis-aimed permanent delete cannot reach a live item. `purgeItem`
|
|
/// (unconstrained) is for the create-undo's own removal, not this call.
|
|
///
|
|
/// **A trashed lane purges whole, freight and all** (lanes joined the trash 2026-07-29): the
|
|
/// removal is recursive, so "permanent delete … walks lane subtrees" needs no walk of its own
|
|
/// here — what the confirmation must *count* before this runs is `TrashModel`'s job, off the
|
|
/// snapshot. Which kind the entry is therefore never comes up: the container and the shape are
|
|
/// the whole check, exactly as they were when only cards lived here.
|
|
///
|
|
/// An already-gone folder is success, `purgeItem`'s rule.
|
|
public static func purgeTrashEntry(at entryFolder: URL, inBoard boardRoot: URL) throws(BoardWriteError) {
|
|
let operation = WriteOperation.purge(title: nil)
|
|
guard FileManager.default.fileExists(atPath: entryFolder.path) else { return }
|
|
|
|
try checkIsUUIDShaped(entryFolder, operation: operation)
|
|
guard isSameLocation(entryFolder.deletingLastPathComponent(), trashFolder(inBoard: boardRoot)) else {
|
|
throw BoardWriteError(
|
|
operation: operation,
|
|
path: entryFolder.path,
|
|
reason: .unreadable(message: "folder is not in this board's trash")
|
|
)
|
|
}
|
|
|
|
do {
|
|
try FileManager.default.removeItem(at: entryFolder)
|
|
} catch {
|
|
throw BoardWriteError(
|
|
operation: operation,
|
|
path: entryFolder.path,
|
|
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
|
|
)
|
|
}
|
|
EchoLedger.current?.recordDeletion(at: entryFolder)
|
|
}
|
|
|
|
/// **Empty Trash** (⇧⌘⌫, 03-board-ui.md § Trash): permanently removes every entry in
|
|
/// `<board-root>/.trash/` — cards and trashed lanes alike, **lane subtrees walked** by the
|
|
/// recursive removal. Returns what it removed, in folder-name order.
|
|
///
|
|
/// **The entry folders, not the container.** The design says it "purges the whole `.trash/`",
|
|
/// and the entries are the whole of it in every board the app produces — but the container is a
|
|
/// real folder a hand-editor can put things in, and stray tolerance ("preserved verbatim,
|
|
/// never rendered") does not stop applying because the folder is the app's. Removing only what
|
|
/// the loader recognizes as an entry keeps the count honest (the confirmation names cards and
|
|
/// lane freight) and keeps this command from being the one place in the app that destroys a file
|
|
/// nobody ever saw. The emptied container is left standing; the next delete would only recreate
|
|
/// it.
|
|
///
|
|
/// **Search-independent**, by construction: this walks the folder, never a filtered view.
|
|
///
|
|
/// Removal is per entry, in order, and a failure stops the batch and throws — everything
|
|
/// already removed stays removed, `importAttachments`' rule. A board with no trash at all
|
|
/// removes nothing and returns `[]`.
|
|
@discardableResult
|
|
public static func emptyTrash(inBoard boardRoot: URL) throws(BoardWriteError) -> [ItemID] {
|
|
let operation = WriteOperation.purge(title: nil)
|
|
var purged: [ItemID] = []
|
|
for entry in childCandidates(of: trashFolder(inBoard: boardRoot)) {
|
|
do {
|
|
try FileManager.default.removeItem(at: entry)
|
|
} catch {
|
|
throw BoardWriteError(
|
|
operation: operation,
|
|
path: entry.path,
|
|
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
|
|
)
|
|
}
|
|
EchoLedger.current?.recordDeletion(at: entry)
|
|
purged.append(ItemID(rawValue: entry.lastPathComponent))
|
|
}
|
|
return purged
|
|
}
|
|
|
|
/// Refuses any folder that is not a **lane**: UUID-shaped, directly under a board root.
|
|
///
|
|
/// The mirror of `checkIsCardFolder`, and it needs one clause that one does not. A lane is
|
|
/// `<root>/<lane>` and a card is `<root>/<lane>/<card>`, so "parent is not UUID-shaped" tells
|
|
/// the two apart — except that a **trash card** is `<root>/.trash/<card>`, whose parent is not
|
|
/// UUID-shaped either. Naming the container explicitly is what keeps a permanent delete of a
|
|
/// trashed card from being reachable through the lane-delete door.
|
|
private static func checkIsLaneFolder(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) {
|
|
try checkIsUUIDShaped(folder, operation: operation)
|
|
let parentName = folder.deletingLastPathComponent().lastPathComponent
|
|
guard !BoardLoader.isUUIDShaped(parentName), parentName != BoardLoader.trashFolderName else {
|
|
throw BoardWriteError(
|
|
operation: operation,
|
|
path: folder.path,
|
|
reason: .unreadable(message: "folder is not a lane: only a lane is deleted whole")
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Physical removal — the create-undo's own primitive (13-native-undo.md: "create → remove the
|
|
/// created folder"): deletes the folder tree from disk. Irreversible, and distinct from the
|
|
/// ordinary delete, which is a *move* into `.trash/` — this call does **not** require the item to
|
|
/// be in the trash first, since an undone create's folder was never trashed to begin with.
|
|
///
|
|
/// **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 ... 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
|
|
/// `moveItem`/`copyItem` 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)")
|
|
)
|
|
}
|
|
EchoLedger.current?.recordDeletion(at: itemFolder)
|
|
}
|
|
|
|
// MARK: - Undoing a create
|
|
|
|
/// An item's `index.md` as literal text — the bytes a create hands to its own redo
|
|
/// (13-native-undo.md ▸ Rules: "create → remove the created folder").
|
|
///
|
|
/// **Why the create path reads back what it just wrote.** The inverse of a create is a physical
|
|
/// removal, so the only way ⇧⌘Z can put the item back *with its identity* is for the step to be
|
|
/// holding the file's bytes — captured at the moment of the create, which is the write redo
|
|
/// re-performs (13: the redo closure replays the original). `readRawSource(ofCard:)` is the same
|
|
/// read one level narrower — it is the raw-source outlet's, and refuses anything that is not a
|
|
/// card folder — so this one exists rather than widening that contract for a caller with a
|
|
/// different reason.
|
|
///
|
|
/// Strict UTF-8 like every read here: a file that does not decode is a loud error, never a lossy
|
|
/// guess. `operation` is the create being captured for, so a failure names that gesture.
|
|
public static func readIndexText(
|
|
ofItem itemFolder: URL,
|
|
operation: WriteOperation
|
|
) throws(BoardWriteError) -> String {
|
|
let indexURL = itemFolder.appendingPathComponent(BoardLoader.indexFileName)
|
|
let data: Data
|
|
do {
|
|
data = try Data(contentsOf: indexURL)
|
|
} catch {
|
|
throw BoardWriteError(
|
|
operation: operation,
|
|
path: indexURL.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: indexURL.path, reason: .unreadable(message: "file is not UTF-8"))
|
|
}
|
|
return text
|
|
}
|
|
|
|
/// Puts a removed item's folder back, at its own path and with its own bytes — the redo half of
|
|
/// an undone create (13-native-undo.md ▸ Rules).
|
|
///
|
|
/// **The identity is the point.** `createLane`/`createCard` mint a fresh UUID, so they cannot
|
|
/// replay a create: redoing through them
|
|
/// would produce a *different* item, and every step registered above this one on the stack
|
|
/// (a rename, a move, a body edit of that very card) would then name nothing. This call takes the
|
|
/// path as given.
|
|
///
|
|
/// - **The parent must already exist** (`withIntermediateDirectories: false`): a redo whose lane
|
|
/// has since been purged must fail rather than conjure a bare lane folder around the card.
|
|
/// - **It refuses to clobber**, `createBoard`'s rule: a folder already at this path fails loudly
|
|
/// rather than overwriting whatever is living there now.
|
|
/// - **The bytes are written verbatim**, raw-source Apply's rule and for its reason: they are not
|
|
/// composed here, they are replayed, so nothing is stamped and nothing is re-serialized.
|
|
///
|
|
/// `operation` is the caller's own vocabulary word — `.createLane` or `.createCard`, whichever
|
|
/// create is being replayed — so a failure banners as the gesture the user is redoing.
|
|
public static func recreateItem(
|
|
at itemFolder: URL,
|
|
indexText: String,
|
|
operation: WriteOperation
|
|
) throws(BoardWriteError) {
|
|
try checkIsUUIDShaped(itemFolder, operation: operation)
|
|
guard !FileManager.default.fileExists(atPath: itemFolder.path) else {
|
|
throw BoardWriteError(
|
|
operation: operation,
|
|
path: itemFolder.path,
|
|
reason: .io(message: "something already exists here")
|
|
)
|
|
}
|
|
do {
|
|
try FileManager.default.createDirectory(at: itemFolder, withIntermediateDirectories: false)
|
|
} catch {
|
|
throw BoardWriteError(
|
|
operation: operation,
|
|
path: itemFolder.path,
|
|
reason: .io(message: "could not create folder: \(error.localizedDescription)")
|
|
)
|
|
}
|
|
do throws(BoardWriteError) {
|
|
try atomicReplace(
|
|
text: indexText,
|
|
at: itemFolder.appendingPathComponent(BoardLoader.indexFileName),
|
|
operation: operation
|
|
)
|
|
} catch {
|
|
// `copyItem`'s all-or-nothing rule: a half-made folder is pure residue, since
|
|
// nothing was there before.
|
|
try? FileManager.default.removeItem(at: itemFolder)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// 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: - Raw source
|
|
|
|
/// Reads a card's `index.md` as **literal text** — what the raw-source outlet opens
|
|
/// (05-card-window.md ▸ Raw source outlet: "swaps the entire content area … for the literal
|
|
/// on-disk `index.md` (frontmatter and all)"; "Entering source mode flushes any pending title/body
|
|
/// edits first, then reads the file fresh from disk").
|
|
///
|
|
/// **Fresh from disk, never from a snapshot** — the same rule `updateIndex` opens with, and here
|
|
/// it is the feature rather than a precaution: the user asked to see the file, and a stale
|
|
/// in-memory rendering of it is the one thing this surface must never show.
|
|
///
|
|
/// **It does not parse.** Every other read in this file parses because it is about to edit a
|
|
/// document; this one hands the bytes to a text editor. A file whose frontmatter an agent has
|
|
/// just broken is exactly what the outlet exists to let a human fix, and refusing to *open* it
|
|
/// would close the only door to the repair. Apply validates on the way back out.
|
|
///
|
|
/// The one refusal is **encoding**: bytes that are not UTF-8 cannot be shown as text without
|
|
/// inventing characters, and applying that invention would rewrite the file into a transcoding
|
|
/// the user never asked for. Strict `String(validating:as:)`, so a BOM survives into the buffer
|
|
/// visibly rather than being silently swallowed and silently re-added (01-storage-format.md §
|
|
/// Fractal layout ▸ Rules).
|
|
public static func readRawSource(ofCard cardFolder: URL) throws(BoardWriteError) -> String {
|
|
let operation = WriteOperation.rawSource(title: nil)
|
|
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
|
// A card's `index.md` and only a card's: the outlet is the card window's, and a lane or a
|
|
// board root reached through it would put a surface with no window behind it on disk.
|
|
try checkIsCardFolder(cardFolder, operation: operation)
|
|
|
|
let indexURL = cardFolder.appendingPathComponent(BoardLoader.indexFileName)
|
|
let data: Data
|
|
do {
|
|
data = try Data(contentsOf: indexURL)
|
|
} catch {
|
|
throw BoardWriteError(
|
|
operation: operation,
|
|
path: indexURL.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: indexURL.path, reason: .unreadable(message: "file is not UTF-8"))
|
|
}
|
|
return text
|
|
}
|
|
|
|
/// Writes a card's `index.md` **byte-for-byte from the user's text** — the raw-source Apply
|
|
/// (05-card-window.md ▸ Raw source outlet), and the one write in this app that is not a document
|
|
/// edit at all.
|
|
///
|
|
/// ### Validated, then verbatim
|
|
///
|
|
/// 1. **Validate through the loader's own fail-fast parse** (`BoardLoader.validateCardIndex`):
|
|
/// decode, parse, `schema`, `order`. The card window has already run this to raise its alert;
|
|
/// it runs again here for `writeBody`'s reason — the layer that owns the bytes is the layer
|
|
/// that can promise the app never writes a file its own loader would refuse to load, against
|
|
/// every caller including a future one.
|
|
/// 2. **Write the user's text exactly.** `atomicReplace` emits `Data(text.utf8)`, so what lands is
|
|
/// the buffer's own bytes: unknown keys, comments, key order, blank lines, line endings and a
|
|
/// missing final newline all survive because nothing re-serialized them — not because anything
|
|
/// here remembered to preserve them.
|
|
///
|
|
/// ### No stamps. Deliberately, and stated twice in the design
|
|
///
|
|
/// This path does **not** set `modified` and does **not** clear `modified-by`, and it is the only
|
|
/// write in the app of which both are true. 01-storage-format.md § Frontmatter settles each
|
|
/// explicitly: `modified` "updates on every app write that rewrites the item's `index.md`, and
|
|
/// only those … Two designed app writes therefore don't bump it, deliberately: **raw-source
|
|
/// Apply** writes the validated buffer byte-for-byte (the verbatim contract outranks stamping)";
|
|
/// and `modified-by` gets "One carve-out: **raw-source Apply** … writes byte-for-byte and does
|
|
/// *not* clear a stamp the user typed or kept". 05 says the same from the other side ("including
|
|
/// a `modified-by` stamp the user typed or kept — Apply is the one app write that doesn't clear
|
|
/// it").
|
|
///
|
|
/// The reasoning is worth keeping next to the code: every other write here is *composed* by the
|
|
/// app — the user asked for a rename, a move, a body edit, and the app decided which bytes express
|
|
/// it, so stamping is the app reporting its own authorship. Here the user typed the bytes. A stamp
|
|
/// would be the app editing a file it was told to write literally, and "byte-for-byte" would be
|
|
/// false in the one place the whole feature rests on it.
|
|
///
|
|
/// ### The equality gate
|
|
///
|
|
/// Text identical to what is already on disk writes nothing and returns `false` — `writeBody`'s
|
|
/// untouched gate, applied to the whole file instead of the body span. Apply on a buffer the user
|
|
/// only read must not churn `mtime`, wake every watcher, and (on git boards) mint an empty commit.
|
|
///
|
|
/// - Returns: `true` when bytes were written, `false` when the file already read exactly like
|
|
/// `text`.
|
|
@discardableResult
|
|
public static func writeRawSource(inCard cardFolder: URL, text: String) throws(BoardWriteError) -> Bool {
|
|
var operation = WriteOperation.rawSource(title: nil)
|
|
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
|
try checkIsCardFolder(cardFolder, operation: operation)
|
|
|
|
let indexURL = cardFolder.appendingPathComponent(BoardLoader.indexFileName)
|
|
// Best-effort, and both of its uses tolerate failure: the equality gate treats "cannot read"
|
|
// as "not equal" (write it), and the title enrichment falls back to the untitled phrasing.
|
|
let current = try? Data(contentsOf: indexURL)
|
|
if let current, let document = try? BoardLoader.parseDocument(current, path: BoardLoader.indexFileName) {
|
|
// The title as the file *currently* reads, not as the buffer proposes it — `.rename`'s
|
|
// rule: a failed write names the card the user is still looking at in the title bar,
|
|
// rather than a name that never landed.
|
|
operation = operation.withTitle(document.title.value)
|
|
}
|
|
|
|
let proposed = Data(text.utf8)
|
|
do throws(BoardLoadError) {
|
|
_ = try BoardLoader.validateCardIndex(proposed, path: BoardLoader.indexFileName)
|
|
} catch {
|
|
throw BoardWriteError(operation: operation, path: indexURL.path, reason: .invalidSource(error))
|
|
}
|
|
|
|
guard current != proposed else { return false }
|
|
|
|
try atomicReplace(text: text, 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. The name itself is
|
|
/// `IntegrityRules`', with the rest of the reserved-name tables.
|
|
static let attachmentsFolderName = IntegrityRules.attachmentsFolderName
|
|
|
|
/// 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 `moveItem`/`copyItem`/`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)
|
|
// "Attachment imports hash during the copy" — see `EchoLedger.recordImport(at:)`
|
|
// for the one place that phrase and `FileManager.copyItem` do not quite meet.
|
|
EchoLedger.current?.recordImport(at: 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.
|
|
///
|
|
/// - Parameter healMarked: whether the receipts this drops are marked as a heal's
|
|
/// (06-history-undo.md ▸ Commit messages, ruled 2026-07-29). `true` for the scheduled
|
|
/// relocation, which is app-initiated work that commits separately; `false` for the
|
|
/// import-boundary normalization below, which is **inline** — it batches with the gesture that
|
|
/// triggered it and belongs in that gesture's commit, not in a heal's.
|
|
@discardableResult
|
|
public static func relocateLooseFiles(
|
|
_ names: [String],
|
|
inCard cardFolder: URL,
|
|
healMarked: Bool = true
|
|
) 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)
|
|
let landedURL = attachmentsFolder.appendingPathComponent(landed)
|
|
do {
|
|
try FileManager.default.moveItem(at: sourceURL, to: landedURL)
|
|
// A move pair, not a write: the bytes were not touched, only their place — and the
|
|
// pair is what tells the classifier the loose file's disappearance was the app's.
|
|
// Heal-marked: the relocation is app-initiated work whose paths commit separately
|
|
// (06-history-undo.md ▸ Commit messages, ruled 2026-07-29).
|
|
EchoLedger.current?.recordMove(from: sourceURL, to: landedURL)
|
|
if healMarked { EchoLedger.current?.markHeal(at: landedURL) }
|
|
} 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] {
|
|
// `healMarked: false` — an **inline** heal batches with the gesture that triggered it
|
|
// (01-storage-format.md § Validation and healing), so its paths are the paste's, not a
|
|
// heal's, and splitting them out would name a commit for work the user asked for.
|
|
try relocateLooseFiles(
|
|
BoardLoader.looseFileNames(in: cardFolder),
|
|
inCard: cardFolder,
|
|
healMarked: false
|
|
)
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// **The ladder itself is `freshName(for:in:)`**, shared with the claimed-name displacement
|
|
/// (`.trash` → `.trash 2`, ruled 2026-07-29): "Finder-style rename on collision" is one rule
|
|
/// wherever the app has to find a free name, and two copies of it would be two ladders to keep
|
|
/// climbing the same way.
|
|
private static func freshAttachmentName(for originalName: String, in folder: URL) -> String {
|
|
freshName(for: originalName, in: folder)
|
|
}
|
|
|
|
/// The Finder-style collision-free name for `originalName` inside `folder` — see
|
|
/// `freshAttachmentName(for:in:)` for the splitting rules, which are Finder's own.
|
|
///
|
|
/// **`lstat` semantics, not `stat`**: a *dangling symlink* holds its name as firmly as any other
|
|
/// node, and `fileExists` — which follows links — would call that name free and hand the caller
|
|
/// a move that fails. `IntegrityRules.node(at:)` is the one probe, which is also what makes this
|
|
/// safe for the displacement, whose whole subject may itself be a symlink.
|
|
static func freshName(for originalName: String, in folder: URL) -> String {
|
|
guard IntegrityRules.node(at: folder.appendingPathComponent(originalName)) != nil 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 IntegrityRules.node(at: folder.appendingPathComponent(candidate)) != nil else {
|
|
return candidate
|
|
}
|
|
counter += 1
|
|
}
|
|
}
|
|
|
|
/// **Displaces a squatter off a claimed name** — the write half of the claimed-names ruling
|
|
/// (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29: "A claimed name held by the
|
|
/// wrong kind of node is Lanework's to heal — by displacement").
|
|
///
|
|
/// A rename and nothing else: the node moves to its Finder-ladder name in the same folder
|
|
/// (`.trash` → `.trash 2`), **preserved verbatim, never destroyed** — the invariant that survives
|
|
/// the ruling is displacement-never-destruction. Its contents are never opened, and a symlink is
|
|
/// moved *as a link*, never followed (`FileManager.moveItem` renames the link itself).
|
|
///
|
|
/// **It re-verifies against disk** (§ Validation and healing: "every scheduled heal re-verifies
|
|
/// its defect against disk at write time and no-ops when it is gone"): `nil` when the name is
|
|
/// free again, or when the right kind of node is now sitting there — losing the race to a
|
|
/// foreign fix is success, never an error.
|
|
///
|
|
/// **It stamps nothing.** A heal that only renames never opens an `index.md`, so the existing
|
|
/// write discipline decides and there is no rule to add (§ Validation and healing).
|
|
///
|
|
/// **Level-uniform** (extended 2026-07-29): the squatter's own `location` names the folder the
|
|
/// claimed name lives in — the board root, or a card's folder for a file wearing `attachments`. That
|
|
/// is the whole of the difference, which is the point of the ruling: one ladder, one notice, one
|
|
/// write, whichever level the name is claimed at. A card whose folder has since gone takes the
|
|
/// re-verification's `nil` path like any other vanished defect.
|
|
///
|
|
/// - Parameter root: the board root. The squatter's location is resolved against it, so a board
|
|
/// renamed since the load heals at its new location.
|
|
/// - Returns: the name the squatter now has, or `nil` when the defect was already gone.
|
|
@discardableResult
|
|
public static func displaceClaimedName(
|
|
_ squatter: ClaimedNameSquatter,
|
|
atBoardRoot root: URL
|
|
) throws(BoardWriteError) -> String? {
|
|
let operation = WriteOperation.displaceClaimedName(name: squatter.name)
|
|
let container = squatter.location.folder(under: root)
|
|
let occupied = container.appendingPathComponent(squatter.name)
|
|
guard let found = IntegrityRules.node(at: occupied), found != squatter.expected else {
|
|
return nil
|
|
}
|
|
|
|
let freed = freshName(for: squatter.name, in: container)
|
|
let destination = container.appendingPathComponent(freed)
|
|
do {
|
|
try FileManager.default.moveItem(at: occupied, to: destination)
|
|
} catch {
|
|
throw BoardWriteError(
|
|
operation: operation,
|
|
path: occupied.path,
|
|
reason: .io(message: "could not move it aside to '\(freed)': \(error.localizedDescription)")
|
|
)
|
|
}
|
|
// A folder move like any other as far as provenance goes — and heal-marked, because the app
|
|
// started this on its own (06-history-undo.md ▸ Commit messages, the heal class).
|
|
EchoLedger.current?.recordMove(from: occupied, to: destination)
|
|
EchoLedger.current?.markHeal(at: destination)
|
|
return freed
|
|
}
|
|
|
|
/// **Remints a withheld duplicate identity** — the write half of the duplicate-id heal
|
|
/// (01-storage-format.md § Fractal layout ▸ Rules, re-ruled 2026-07-29: "the heal gives each
|
|
/// withheld occurrence the fresh identity the import boundary would have minted").
|
|
///
|
|
/// Copy semantics applied at detection: a hand copy in Finder *was* a copy, so it gets what a
|
|
/// copy gets — a fresh lowercase v4 folder name, minted away from every identity in the board.
|
|
/// After it lands, the folder renders as an ordinary item.
|
|
///
|
|
/// **A rename and nothing else.** The folder keeps its parent; its `index.md`, its frontmatter,
|
|
/// its children, its attachments and its strays are never opened. That is not a carve-out but the
|
|
/// existing write discipline answering: a heal that only renames stamps nothing — no `modified`,
|
|
/// no cleared `modified-by` — because this is an identity repair, not an edit (§ Validation and
|
|
/// healing).
|
|
///
|
|
/// **It re-verifies against disk, twice over** (§ Validation and healing: "every scheduled heal
|
|
/// re-verifies its defect against disk at write time and no-ops when it is gone"), and each check
|
|
/// answers `nil` — success, never an error:
|
|
///
|
|
/// 1. The folder is still there, still a directory, and still spelled with the identity the
|
|
/// detection named. A folder already reminted (this heal running twice, another device's heal
|
|
/// arriving first) fails here.
|
|
/// 2. **Something else still carries that identity.** The winner may have been hand-deleted or
|
|
/// moved out since the load, in which case this folder is no longer a duplicate of anything and
|
|
/// reminting it would change an identity for no reason at all — the vanished-duplicate race,
|
|
/// read from the surviving side.
|
|
///
|
|
/// **Heal-marked**, because the app started it on its own: the receipt is what splits the remint
|
|
/// into its own commit on git boards, named for the Repair verb (06-history-undo.md ▸ Commit
|
|
/// messages). Undo never sees it — heals are not gestures (13-native-undo.md).
|
|
///
|
|
/// - Parameter duplicate: the withheld occurrence, `path` relative to `boardRoot` so the write
|
|
/// lands wherever the board lives *now*.
|
|
/// - Returns: the fresh identity, or `nil` when the defect was already gone.
|
|
@discardableResult
|
|
public static func remintDuplicateIdentity(
|
|
_ duplicate: DuplicateIdentity,
|
|
inBoard boardRoot: URL
|
|
) throws(BoardWriteError) -> ItemID? {
|
|
let operation = WriteOperation.repairDuplicateID(title: duplicate.title)
|
|
let folder = boardRoot.appendingPathComponent(duplicate.path, isDirectory: true)
|
|
|
|
// 1. Still there, still a folder, still carrying the identity that lost.
|
|
guard IntegrityRules.node(at: folder) == .directory,
|
|
IntegrityRules.canonicalIdentity(folder.lastPathComponent) == duplicate.identity
|
|
else {
|
|
return nil
|
|
}
|
|
// 2. Still a duplicate *of something*. One occurrence is this folder itself, so the identity
|
|
// has to appear at least twice for the defect to still stand.
|
|
let occurrences = identityOccurrences(inBoard: boardRoot)
|
|
guard occurrences.filter({ $0 == duplicate.identity }).count > 1 else { return nil }
|
|
|
|
// Minted away from every identity in the board, not merely from this parent's children: the
|
|
// point of the remint is board-wide uniqueness, and a fresh name colliding with a folder two
|
|
// lanes over would trade one duplicate for another.
|
|
let fresh = freshUUIDName(in: folder.deletingLastPathComponent(), avoiding: Set(occurrences))
|
|
try renameFolder(folder, toSiblingNamed: fresh, operation: operation)
|
|
// The move pair is `renameFolder`'s; the heal mark is this call's, because the remint is
|
|
// app-initiated work whose paths commit separately (06-history-undo.md ▸ Commit messages).
|
|
EchoLedger.current?.markHeal(
|
|
at: folder.deletingLastPathComponent().appendingPathComponent(fresh, isDirectory: true)
|
|
)
|
|
return ItemID(rawValue: fresh)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// Moves one of a card's attachments to the **system** Trash — the attachment row's Remove and
|
|
/// its ⌫ twin (05-card-window.md ▸ Attachments: "context menu Open / Reveal in Finder / Remove
|
|
/// (moves to the **system** Trash, never hard-deletes …)").
|
|
///
|
|
/// ### `trashItem`, and never `removeItem`
|
|
///
|
|
/// The design says *system* Trash and means it: a board's own tombstone quasi-lane is a different
|
|
/// trash with a different vocabulary (03-board-ui.md § Trash's naming constraint), and an
|
|
/// attachment removed here is recoverable exactly the way a file dragged out of a Finder window
|
|
/// is — by the user, in Finder, with no help from this app. `FileManager.trashItem` is that
|
|
/// sentence; `removeItem` would be a hard delete of the user's own file, which this app never
|
|
/// does to an attachment.
|
|
///
|
|
/// ### The listing is the guard
|
|
///
|
|
/// `name` is checked against `BoardLoader.attachmentNames(in:)` — **the very set the sidebar
|
|
/// shows** — before anything is touched, which is what makes the whole class of "the caller
|
|
/// passed something else" unreachable in one line rather than four: a path (`../index.md`), a
|
|
/// subfolder, a hidden file, a symlink, and the empty string are all simply not in the listing,
|
|
/// and none of them can be trashed through this call. It is also `relocateLooseFiles`' re-check
|
|
/// rule applied here — the caller's name is re-read against disk at write time, not trusted from
|
|
/// whenever the row was drawn.
|
|
///
|
|
/// ### A name that is no longer there is not a failure
|
|
///
|
|
/// It returns `false` and writes nothing, `relocateLooseFiles`' rule again: "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". A ⌫ racing an external delete of the same file is exactly that race, and
|
|
/// a banner for it would name a removal the user got anyway.
|
|
///
|
|
/// - Returns: where the file now sits **inside the Trash**, or `nil` when there was nothing to
|
|
/// move. The URL is returned rather than discarded because it is the only proof this call
|
|
/// makes that the file still exists at all — the difference between the promise ("never
|
|
/// hard-deletes") and a `removeItem` that would look identical from `attachments/`.
|
|
@discardableResult
|
|
public static func removeAttachment(
|
|
named name: String,
|
|
fromCard cardFolder: URL
|
|
) throws(BoardWriteError) -> URL? {
|
|
let operation = WriteOperation.removeAttachment(filename: name)
|
|
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
|
try checkIsUUIDShaped(cardFolder, operation: operation)
|
|
|
|
guard BoardLoader.attachmentNames(in: cardFolder).contains(name) else { return nil }
|
|
|
|
let fileURL = cardFolder
|
|
.appendingPathComponent(attachmentsFolderName, isDirectory: true)
|
|
.appendingPathComponent(name)
|
|
var trashedURL: NSURL?
|
|
do {
|
|
try FileManager.default.trashItem(at: fileURL, resultingItemURL: &trashedURL)
|
|
// Where the file went is the *system* Trash, outside any board — so from this board's
|
|
// point of view it is an absence, exactly as a purge is.
|
|
EchoLedger.current?.recordDeletion(at: fileURL)
|
|
} catch {
|
|
throw BoardWriteError(
|
|
operation: operation,
|
|
path: fileURL.path,
|
|
reason: .io(message: "could not move file to the Trash: \(error.localizedDescription)")
|
|
)
|
|
}
|
|
return trashedURL as URL?
|
|
}
|
|
|
|
// 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?)
|
|
|
|
/// ⌘V — **a paste that refused before it wrote anything** (04-interactions.md ▸ Clipboard,
|
|
/// re-ruled 2026-07-29: "A paste whose staged snapshot is missing or unreadable refuses loudly —
|
|
/// never degrades … the paste produces **nothing**, and a one-shot failure banner names it from
|
|
/// the manifest's metadata").
|
|
///
|
|
/// **Its own case rather than a fold into `.copy`**, on `.rename`'s and `.duplicateBoard`'s
|
|
/// reasoning: the user pressed *Paste*, and a banner telling them the app "couldn't copy 'Fix
|
|
/// login'" would name a gesture they never made. It is also the one operation in this vocabulary
|
|
/// the Writer itself never performs — a paste's *arrivals* are `.copy` and `.move` — because the
|
|
/// refusal happens at the clipboard's preflight, before any arrival is materialized; the
|
|
/// vocabulary grows with the surfaces, and the surface here is the refusal.
|
|
///
|
|
/// `title` is the offending entry's, read off the manifest's own metadata (which is exactly what
|
|
/// the embedded `index.md` is kept for now that it is never a materialization source), `nil` for
|
|
/// an untitled item.
|
|
case paste(title: String?)
|
|
/// ⌫ / ⌘⌫ — a card (`deleteCardToTrash`) or a lane (`deleteLaneToTrash`) moving into `.trash/`.
|
|
/// One word for one gesture: since lanes rejoined the trash (2026-07-29) both are the same
|
|
/// physical move, and neither is destructive.
|
|
///
|
|
/// **There is no `restore` case**: restoring is an ordinary move out (`moveItem`), so a failed
|
|
/// restore says the app couldn't *move* the card — which is exactly what it couldn't do
|
|
/// (03-board-ui.md § Trash, resettled 2026-07-28 — Put Back is retired with the tombstone model).
|
|
case delete(title: String?)
|
|
case purge(title: String?)
|
|
|
|
/// A legacy `deleted:` key being migrated away — a **card** relocating into `.trash/` with the
|
|
/// key removed (01-storage-format.md § Deletion: "cards migrate, lanes ignore"; the lane half
|
|
/// retired 2026-07-29 with the lane trash, so this operation has one performer left).
|
|
///
|
|
/// Its own case rather than a fold into `.delete` or `.move`, on the vocabulary's standing
|
|
/// reasoning and `.relocateLooseFile`'s in particular: this is work the *app* started on its
|
|
/// own, on a board an older version wrote, and a banner telling the user the app "couldn't
|
|
/// delete 'Fix login'" would name a gesture they never made.
|
|
case migrateTombstone(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?)
|
|
|
|
/// File ▸ Save as Template — the whole-board copy into the user templates store, and the
|
|
/// `template:` key stamped on the copy (09-templates.md ▸ Save as Template). Its own case beside
|
|
/// `.duplicateBoard`, on that case's own reasoning: both copy a board wholesale, but the user
|
|
/// pressed a different command with a different outcome, and a banner saying the app could not
|
|
/// "duplicate" a board when no duplicate was asked for would name a gesture that never happened.
|
|
/// `title` is the board's display name.
|
|
case saveAsTemplate(title: String?)
|
|
|
|
case importAttachment(filename: String)
|
|
case listAttachments
|
|
|
|
/// An attachment being moved to the **system** Trash — the card window's attachment Remove and
|
|
/// its ⌫ twin (05-card-window.md ▸ Attachments).
|
|
///
|
|
/// Its own case rather than a fold into `.delete`, on the vocabulary's standing reasoning and
|
|
/// then some: `.delete` is the board's *tombstone*, and this app has two trashes on purpose —
|
|
/// "Finder's 'Move to Trash' phrasing is reserved for the system Trash; board deletion says
|
|
/// 'Delete'" (03-board-ui.md § Trash). A banner saying the app "couldn't delete 'shot.png'"
|
|
/// would claim the board's own trash took a hand in a file only Finder can give back.
|
|
/// `filename` is the attachment's name as the sidebar shows it.
|
|
case removeAttachment(filename: String)
|
|
|
|
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)
|
|
|
|
/// The board-root `CLAUDE.md` being written or upgraded — and the rescue move that precedes it
|
|
/// when a markerless one has to be displaced to `CLAUDE.user.md` (08-agent-integration.md ▸ The
|
|
/// agent guide; `AgentGuide`).
|
|
///
|
|
/// **No payload**, unlike every other case that names something: there is exactly one guide per
|
|
/// board, its filename is fixed, and it is not an item with a title. Its own case on
|
|
/// `.relocateLooseFile`'s reasoning, doubled — this is work the *app* started on its own, for a
|
|
/// file the user did not create, does not own and may not know exists; folding it into any
|
|
/// gesture's phrasing would name an act that never happened.
|
|
///
|
|
/// One case rather than two (the move and the write) deliberately: they ride one bracket and
|
|
/// have one outcome the user could care about — the board's guide is not up to date — and a
|
|
/// second phrasing for "couldn't move a file you have never seen" would explain nothing.
|
|
case agentGuide
|
|
|
|
/// A wrong-kinded node being moved off a board-root name the app claims — a file or symlink
|
|
/// squatting `.trash` (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29: "moved
|
|
/// aside by a scheduled heal via the Finder-style rename ladder").
|
|
///
|
|
/// Its own case on `.relocateLooseFile`'s and `.agentGuide`'s reasoning: this is work the *app*
|
|
/// started on its own, on a node the user may not know is a problem, and a banner saying the app
|
|
/// "couldn't delete" or "couldn't move" something would name a gesture that never happened.
|
|
/// `name` is the claimed name as the app spells it — the name the user would recognize.
|
|
///
|
|
/// The `CLAUDE.md` squatter takes `.agentGuide` instead, because the guide's own write bracket
|
|
/// owns that file end to end and has one outcome the user could care about.
|
|
case displaceClaimedName(name: String)
|
|
|
|
/// A withheld duplicate id being reminted — the duplicate-id heal's write (01-storage-format.md
|
|
/// § Fractal layout ▸ Rules, re-ruled 2026-07-29 from its former banner gate).
|
|
///
|
|
/// **Named for the Repair verb**, which is 06-history-undo.md's vocabulary for exactly this act
|
|
/// ("Repair duplicate of 'Fix login'" — the heal commit's own name) and survived the re-ruling
|
|
/// intact: what changed is who starts it, not what it is called.
|
|
///
|
|
/// Its own case on `.relocateLooseFile`'s and `.displaceClaimedName`'s reasoning: this is work the
|
|
/// *app* started on its own, on a folder the user copied in Finder without knowing it would
|
|
/// collide, and a banner saying the app "couldn't move the item" would name a gesture that never
|
|
/// happened. `title` is the withheld item's as the load found it — the name the user would
|
|
/// recognize, and the one the successful notice uses.
|
|
case repairDuplicateID(title: 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?)
|
|
|
|
/// The card window's raw-source Apply — the whole `index.md` replaced with the text the user
|
|
/// typed (05-card-window.md ▸ Raw source outlet).
|
|
///
|
|
/// Its own case beside `.editBody`, on the vocabulary's standing reasoning and then some: this is
|
|
/// not a body write and not a frontmatter edit but the *file* being written, and it is the one
|
|
/// operation whose bytes the app did not compose. A banner saying the app "couldn't save the
|
|
/// card" would describe the Edit buffer the user was not in. The word the user pressed is
|
|
/// **Apply**.
|
|
///
|
|
/// `title` is the card's title as the file *currently* reads it — the name on the window — never
|
|
/// the one the buffer proposes, which may be a title that never landed (`.rename`'s rule). It is
|
|
/// also the operation `readRawSource` carries on the way *in*; that read's failures reach an
|
|
/// alert rather than the banner, so the Apply phrasing is never shown for one.
|
|
case rawSource(title: String?)
|
|
|
|
/// Fills in the title once the Writer has read it off the document the operation is acting
|
|
/// on — identity for the cases with no title slot at all: `createBoard`/`createLane`/
|
|
/// `createCard` are minting a file, not reading one; `importAttachment`, `removeAttachment` and
|
|
/// `relocateLooseFile` carry a filename, which is the name the user is looking at and the only
|
|
/// one their banner should say; `listAttachments`, `renumberChildren` and `agentGuide` 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 {
|
|
// `.repairDuplicateID` is identity here even though it carries a title: the remint never
|
|
// opens an `index.md` — it is a rename — so there is no `readDocument` to enrich from, and
|
|
// its title arrives already filled in from the load that detected the duplicate.
|
|
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
|
|
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide,
|
|
.displaceClaimedName, .repairDuplicateID:
|
|
self
|
|
case .move: .move(title: title)
|
|
case .reorder: .reorder(title: title)
|
|
case .copy: .copy(title: title)
|
|
// Identity, like `.repairDuplicateID` and for its reason: a refused paste never opens an
|
|
// `index.md`, so there is no `readDocument` to enrich from — its title arrives already filled
|
|
// in from the manifest entry the refusal names.
|
|
case .paste: self
|
|
case .delete: .delete(title: title)
|
|
case .purge: .purge(title: title)
|
|
case .migrateTombstone: .migrateTombstone(title: title)
|
|
case .style: .style(title: title)
|
|
case .resize: .resize(title: title)
|
|
case .rename: .rename(title: title)
|
|
case .duplicateBoard: .duplicateBoard(title: title)
|
|
case .saveAsTemplate: .saveAsTemplate(title: title)
|
|
case .toggleTask: .toggleTask(title: title)
|
|
case .editBody: .editBody(title: title)
|
|
case .rawSource: .rawSource(title: title)
|
|
}
|
|
}
|
|
|
|
/// **Whether this rewrite only restates the item's position among its container's members** —
|
|
/// the reorders-don't-stamp predicate (01-storage-format.md § Frontmatter ▸ `modified`'s scope,
|
|
/// ruled 2026-07-29 as moves-don't-stamp, refined 2026-07-30).
|
|
///
|
|
/// One question decides it: **does the rewrite change the item's container?** If it does — a
|
|
/// cross-lane move, a cross-board arrival, a move into or out of `.trash/` — the item's story
|
|
/// changed (which lane a card lives in is state) and the write stamps `modified` and clears
|
|
/// `modified-by` like any content write. If it does not — a card reordered among its lane's
|
|
/// siblings, a lane reordered on the board, a renumber's whole-lane rescale — only `order` is
|
|
/// rewritten: no stamp, no clear. Where an item *stands in line* is presentation, and `order` is
|
|
/// logically the container's property that the format happens to store inside the member's file.
|
|
///
|
|
/// **The vocabulary already draws the line**, which is why this is a property here rather than a
|
|
/// flag threaded through `updateIndex`'s call sites:
|
|
///
|
|
/// - `.reorder` is the same-container case by construction — `moveItem` decides it from the source
|
|
/// parent and the destination parent before it touches disk (`isSameLocation`), and the two
|
|
/// inverse paths that rewrite a rank directly (`BoardStore.setOrder`, the within-lane sort) are
|
|
/// same-container for the same reason: a lane's parent is the board root, and a card the sort
|
|
/// permutes never leaves its lane.
|
|
/// - `.renumberChildren` is the midpoint-exhaustion rescale — "order-only rewrites, so no
|
|
/// `modified` stamp and no `modified-by` clear" (01 § Ordering, verbatim).
|
|
/// - **Everything else stamps.** `.move` covers every container change including the trash's, and
|
|
/// there is deliberately **no trash case anywhere**: the trash move stamps because every
|
|
/// container change stamps, so a branch for it would be a second rule saying the same thing.
|
|
///
|
|
/// Exhaustive with no `default`, like every other switch over this enum: a new operation has to
|
|
/// answer "does this rewrite content?" before it compiles.
|
|
public var rewritesOrderOnly: Bool {
|
|
switch self {
|
|
case .reorder, .renumberChildren:
|
|
true
|
|
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
|
|
.style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment,
|
|
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .displaceClaimedName,
|
|
.repairDuplicateID, .toggleTask, .editBody, .rawSource:
|
|
false
|
|
}
|
|
}
|
|
|
|
/// 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 .paste(title): Self.phrase("paste", title)
|
|
case let .delete(title): Self.phrase("delete", title)
|
|
case let .purge(title): Self.phrase("purge", title)
|
|
case let .migrateTombstone(title): Self.phrase("migrate the legacy 'deleted' key on", 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 .saveAsTemplate(title): Self.phrase("save as template", title)
|
|
case let .importAttachment(filename): "import attachment '\(filename)'"
|
|
case .listAttachments: "list attachments"
|
|
case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash"
|
|
case .renumberChildren: "renumber children"
|
|
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
|
|
case .agentGuide: "update the agent guide"
|
|
case let .displaceClaimedName(name): "move a stray '\(name)' aside"
|
|
case let .repairDuplicateID(title): Self.phrase("repair the duplicate id of", title)
|
|
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
|
|
case let .editBody(title): Self.phrase("save the body of", title)
|
|
case let .rawSource(title): Self.phrase("apply source changes to", 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)
|
|
|
|
/// **The text the raw-source outlet was asked to write would not load** (05-card-window.md ▸
|
|
/// Raw source outlet: "Apply validates through the same fail-fast parse the loader uses …
|
|
/// before writing byte-for-byte"). Carries the loader's own error — path, reason and line
|
|
/// number — because the alert that shows it is the design's "detailed alert", and a
|
|
/// re-worded copy would be a second, worse taxonomy.
|
|
///
|
|
/// Distinct from `.unreadable`, which is about the file **on disk**: here disk is fine and
|
|
/// the *proposal* is not, so nothing was attempted and nothing changed.
|
|
case invalidSource(BoardLoadError)
|
|
|
|
/// **The bytes a paste was to reproduce are not there** — the staged clipboard snapshot is
|
|
/// missing or unreadable, so the paste produces nothing rather than a hollowed item
|
|
/// (04-interactions.md ▸ Clipboard, re-ruled 2026-07-29 — Finder's invariant, and 01's
|
|
/// leniency doctrine: "proceed partially, lose a little" is never a verdict).
|
|
///
|
|
/// **Payload-free on purpose.** There is exactly one thing to say about it, the banner owns
|
|
/// the words (`BannerCenter.causePhrase`), and the offending item is already named by the
|
|
/// operation's own title — so a free-form message here could only be a second, worse copy of
|
|
/// a sentence that lives one layer up. That also keeps it distinct from `.unreadable`, whose
|
|
/// message is a developer's diagnostic about a file the app *did* open.
|
|
case clipboardContentGone
|
|
|
|
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
|
|
case let .invalidSource(error):
|
|
"the source text wouldn't load: \(error.description)"
|
|
case .clipboardContentGone:
|
|
"the copied content is no longer staged"
|
|
}
|
|
}
|
|
}
|
|
}
|