import Foundation /// Turns a mutation into a filesystem operation — the single point through which every write /// the app makes reaches disk (02-architecture.md § Layering ▸ Components). Stateless by /// construction: there is no in-flight buffer, no queue, no coalescing. **A write is done when /// the rename completes**, and a failed write is a failure the caller sees, so views — which /// render only what is on disk — can never show phantom state (02-architecture.md § /// Write-failure surfacing). /// /// Four rules from 01-storage-format.md live here and are not negotiable per call site: /// /// - **Atomic writes** (§ Fractal layout ▸ Rules): temp file, rename over `index.md`. Every /// write, no exceptions — a reader (this app's watcher, an agent, git) never sees a partial /// file, and a crash mid-write leaves the previous content intact. /// - **Round-trip, never re-serialize**: mutations go through `FrontmatterDocument`, which /// edits by line span, so unknown keys and their order, comments, blank lines, line endings, /// and the body survive every write by construction rather than by remembering to preserve /// them. /// - **`modified` stamped, `modified-by` cleared** (§ Frontmatter): on every app-mediated write /// path. Absence of `modified-by` means "the board's user, via the app"; the file is being /// rewritten anyway, so clearing an external writer's self-reported stamp costs nothing. /// - **Encoding** (§ Fractal layout ▸ Rules): writes are BOM-less UTF-8; reads are strict /// UTF-8, and a file that does not decode is a loud, specific error rather than a /// lossy best guess. public enum BoardWriter: Sendable { // MARK: - The uniform per-file mutation /// Rewrites one item's `index.md`: read fresh, refuse what cannot be edited, apply `edits`, /// stamp, write atomically. /// /// The order of the four steps is the contract: /// /// 1. **Read fresh from disk**, never from a snapshot. The snapshot a caller is holding may /// be seconds stale — an agent or a hand-editor may have rewritten the file since — and /// the round-trip guarantee is only worth anything against the bytes actually there. /// 2. **Refuse an uneditable shape before `edits` runs** (`FrontmatterDocument.uneditableShape`): /// the settled readable-but-uneditable rule. Such a file loads and renders fine, but a /// surgical edit of it cannot be expressed, so the write fails loudly instead of /// corrupting it. Refusing up front also means `edits` never observes a document it /// cannot affect. /// 3. **`edits`, then the stamps** — `modified` set and `modified-by` removed *after* the /// caller's closure, so the stamp always wins over anything the closure did with those /// two keys, and no call site has to remember them. /// 4. **Atomic replace.** /// /// The **one path that deliberately bypasses this** is the card window's raw-source Apply /// (05-card-window.md): it writes the user's text byte-for-byte and does *not* clear a /// `modified-by` the user typed or kept — the validated-then-verbatim contract outranks the /// clearing rule (01-storage-format.md § Frontmatter). That path goes through /// `atomicReplace` directly; it does not belong here. public static func updateIndex( inItemFolder folder: URL, operation: String, edits: (inout FrontmatterDocument) -> Void ) throws(BoardWriteError) { let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) var document = try readDocument(at: indexURL, operation: operation) try checkEditable(document, at: indexURL, operation: operation) edits(&document) document.set(FrontmatterKeys.modified, to: .date(Date())) document.remove(FrontmatterKeys.modifiedBy) try atomicReplace(text: document.serialized(), at: indexURL, operation: operation) } // MARK: - Atomic replace /// Writes `text` over `fileURL` atomically: a hidden temp file in the **same directory**, /// then a rename over the destination. /// /// Same directory because a rename is only atomic within one filesystem — a temp in /// `NSTemporaryDirectory()` could land on another volume and degrade to a copy. Dot-prefixed /// (`.index.md.lanework-`) 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: String) throws(BoardWriteError) { let directory = fileURL.deletingLastPathComponent() let tempURL = directory.appendingPathComponent(".\(fileURL.lastPathComponent).lanework-\(UUID().uuidString)") do { try Data(text.utf8).write(to: tempURL) } catch { try? FileManager.default.removeItem(at: tempURL) throw BoardWriteError( operation: operation, path: fileURL.path, reason: .io(message: "could not write temporary file: \(error.localizedDescription)") ) } // POSIX `rename` rather than `FileManager.replaceItemAt`: it atomically overwrites an // existing destination *and* handles one that does not exist yet (the create paths), // without inventing a second temp file of its own. let status = tempURL.withUnsafeFileSystemRepresentation { source in fileURL.withUnsafeFileSystemRepresentation { destination in guard let source, let destination else { return EINVAL } return rename(source, destination) == 0 ? 0 : errno } } guard status == 0 else { try? FileManager.default.removeItem(at: tempURL) throw BoardWriteError( operation: operation, path: fileURL.path, reason: .io(message: "could not replace file: \(String(cString: strerror(status)))") ) } } // MARK: - Create /// Creates a new board: the folder (if it does not already exist) and its `index.md` — the /// write side of `.kanban` packaging (01-storage-format.md § Document packaging). Two /// callers: template instantiation, which passes the user-chosen document name as `title` /// so the window title and the Finder name start out matching (§ Board naming), and a bare /// "New Board" flow, which passes `nil` and lets the UI fall back to the folder name. /// /// - `rootURL` is created if missing (`withIntermediateDirectories: true`); an existing /// *empty* folder is simply filled in, since that is exactly what an interrupted create or /// a hand-made folder looks like. /// - **Refuses to clobber an existing board**: if `index.md` is already there, the call /// fails `.io` naming the path and the existing file is never touched — a create must /// never overwrite a board that already exists. /// - `title == nil` writes no `title` key at all — the folder-name fallback (§ Board /// naming) is a *missing* key, not an empty string, which would be a real (if blank) title. /// - Key order: `schema`, `title` (only when supplied), `created`, `modified`. No `order` — /// that field belongs to lanes and cards, never the board root. /// - Extension-less board folders are exactly as legal a target as a `.kanban`-suffixed one /// (§ Document packaging, "Extension-less board folders still open") — this call never /// looks at `rootURL`'s extension. public static func createBoard(at rootURL: URL, title: String?) throws(BoardWriteError) { let operation = "create board" let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName) guard !FileManager.default.fileExists(atPath: indexURL.path) else { throw BoardWriteError(operation: operation, path: indexURL.path, reason: .io(message: "a board already exists here")) } do { try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) } catch { throw BoardWriteError( operation: operation, path: rootURL.path, reason: .io(message: "could not create board folder: \(error.localizedDescription)") ) } try atomicReplace(text: newDocumentText(title: title, order: nil), at: indexURL, operation: operation) } /// Creates a lane in a board: mints a fresh lowercase-UUIDv4 folder directly under /// `rootURL`, appends it after the board's current visible lanes, and writes its /// `index.md`. Returns the new identity. See `createChild(inParent:title:operation:)` for /// the shared mechanics. public static func createLane(inBoard rootURL: URL, title: String?) throws(BoardWriteError) -> ItemID { try createChild(inParent: rootURL, title: title, operation: "create lane") } /// Creates a card in a lane: mints a fresh lowercase-UUIDv4 folder directly under /// `laneURL`, appends it after the lane's current visible cards, and writes its `index.md`. /// Returns the new identity. See `createChild(inParent:title:operation:)` for the shared /// mechanics. public static func createCard(inLane laneURL: URL, title: String?) throws(BoardWriteError) -> ItemID { try createChild(inParent: laneURL, title: title, operation: "create card") } /// The shared body of `createLane`/`createCard` — a lane under a board and a card under a /// lane are the same operation one level apart (01-storage-format.md § Fractal layout, /// "Level is position"): check the parent, place the new identity after the current visible /// siblings, mint it, write its file. /// /// - **The parent must already exist as a directory.** Anything else — missing, or a file /// where a folder belongs — is a loud `.unreadable` naming `parentFolder` up front, never /// a silent `mkdir` into a broken location. /// - **Order is `max + 1024` among visible siblings** (`Ranks.append(toVisible:)`), read by /// `visibleSiblings(of:operation:)` — the identical strict scan `renumberVisibleChildren` /// uses, so the two can never disagree about who counts as a sibling. An empty parent's /// first child lands at `1024`, the board convention (01-storage-format.md § Ordering). /// - **Two-step creation is inherent, not a shortcut taken here**: the folder is created, /// then `index.md` is written, as two separate filesystem operations — there is no atomic /// "create a directory with content already in it" primitive to reach for. A crash between /// the two leaves a UUID-shaped folder with no `index.md`, exactly the shape /// `BoardLoader`'s `.missingIndex` skip-and-warn rule (and `visibleSiblings`'s own /// `fileExists` check) already tolerate — see that type's doc comment. private static func createChild( inParent parentFolder: URL, title: String?, operation: String ) throws(BoardWriteError) -> ItemID { try checkIsDirectory(parentFolder, describedAs: "parent folder", operation: operation) let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false) let order = Ranks.append(toVisible: siblings.map(\.order)) let folder = try mintUUIDFolder(in: parentFolder, operation: operation) let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) try atomicReplace(text: newDocumentText(title: title, order: order), at: indexURL, operation: operation) return ItemID(rawValue: folder.lastPathComponent) } /// The frontmatter text for a file the app is minting outright — never a rewrite, so this /// goes through `FrontmatterDocument(body:)` and `set`, not `updateIndex`: there is no prior /// document to read fresh, refuse an uneditable shape from, or stamp over. `created` and /// `modified` share one `Date()` so the two stamps are identical, not merely close; /// `modified-by` is never written, matching the engine's absence-means-app-authored /// convention (§ Frontmatter). Key order — `schema`, `title` (only when supplied), `order` /// (only when supplied — `nil` for a board, always present for a lane/card), `created`, /// `modified` — is simply the order `set` is called in, since each call appends a fresh key /// before the closing delimiter of an otherwise-empty document. private static func newDocumentText(title: String?, order: Double?) -> String { var document = FrontmatterDocument(body: "") document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema)) if let title { document.set(FrontmatterKeys.title, to: .string(title)) } if let order { document.set(FrontmatterKeys.order, to: .double(order)) } let now = Date() document.set(FrontmatterKeys.created, to: .date(now)) document.set(FrontmatterKeys.modified, to: .date(now)) return document.serialized() } /// A fresh lowercase-UUIDv4 folder under `parentFolder` — the create path's mint, which /// materializes the folder as well as naming it. The naming rule itself lives in /// `freshUUIDName(in:avoiding:)`, shared with the move and copy paths so every identity the /// app mints is minted one way. private static func mintUUIDFolder(in parentFolder: URL, operation: String) 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 what makes the name match /// `BoardLoader.isUUIDShaped`, which is case-sensitive by design. /// /// 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. 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 { 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: String ) throws(BoardWriteError) { var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "\(role) does not exist")) } guard isDirectory.boolValue else { throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "\(role) is not a directory")) } } // MARK: - Renumber /// Renumbers a parent's visible children to whole multiples of 1024 — the renumber fallback /// for exhausted midpoint precision (01-storage-format.md § Ordering), and **the sole /// exception to "a reorder rewrites only the moved item"**. Uniform across levels: the /// parent is a lane (renumbering its cards) or the board root (renumbering its lanes). /// /// - **Runs over loaded, valid children** (`visibleSiblings(of:operation:)`): one that fails /// to parse, or that lacks a usable `order`, fails the whole operation before anything is /// written. A renumber is bookkeeping inside a user action that already succeeded in /// principle — it must not be the thing that discovers a broken sibling halfway through /// rewriting the lane. /// - **Display order is the assignment order** (`Ranks.isOrderedForDisplay`: `order` /// ascending, folder name breaking ties) — the same rule the loader sorts by, so a /// renumber is guaranteed to be sequence-preserving: nothing visibly moves. /// /// Each child's rewrite is atomic; the batch is not. An interrupted renumber leaves some /// siblings renumbered and some not — every `order` still a valid float, display order /// still deterministic, and the next renumber finishes the job. That is the accepted cost /// noted in § Ordering, which the deterministic tie-break exists to make harmless. public static func renumberVisibleChildren(of parentFolder: URL) throws(BoardWriteError) { let operation = "renumber children" let visible = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: true) let ordered = Ranks.sortedForDisplay(visible, order: { $0.order }, name: { $0.folder.lastPathComponent }) for (child, rank) in zip(ordered, Ranks.renumbered(count: ordered.count)) { try updateIndex(inItemFolder: child.folder, operation: operation) { document in document.set(FrontmatterKeys.order, to: .double(rank)) } } } /// The strict visible-sibling scan shared by `renumberVisibleChildren` and the create /// operations' order assignment — extracted into one place so the two can never disagree /// about who counts as a sibling. Walks `parentFolder`'s UUID-shaped children holding an /// `index.md` (`BoardLoader.directoryCandidates`/`isUUIDShaped` — the same level-detection /// rule the loader uses), each parsed strictly: /// /// - **Tombstones are inert to ordering** (§ Deletion): a child whose `deleted` key is /// *present* is not counted — presence, not validity, is the test, exactly as /// `Lane`/`Card.isDeleted` reads it (an explicit `deleted: null` is absence to both). /// - **Strays are untouched**: non-UUID-shaped folders, and UUID-shaped folders without an /// `index.md` (an interrupted two-step create — the loader's own `.missingIndex` warning /// tolerates exactly this), are skipped here for the same reasons `BoardLoader` skips them. /// - A visible sibling's missing or malformed `order` fails the *whole* operation, naming /// that sibling's file, before anything is written — the same discover-before-you-write /// guarantee `renumberVisibleChildren`'s batch depends on. /// - `requireEditable` scopes the readable-but-uneditable pre-flight to the caller that /// will actually *rewrite* the siblings: renumber passes `true` (it must not discover an /// unwritable sibling halfway through the batch), the creates pass `false` — a create /// only *reads* its siblings' orders, and a flow-mapping sibling that loads and renders /// normally (01-storage-format.md § Frontmatter) must not block creating a new item /// beside it. private static func visibleSiblings( of parentFolder: URL, operation: String, requireEditable: Bool ) throws(BoardWriteError) -> [(folder: URL, order: Double)] { let candidates: [URL] do { candidates = try BoardLoader.directoryCandidates(in: parentFolder) } catch { throw BoardWriteError( operation: operation, path: parentFolder.path, reason: .unreadable(message: error.description) ) } var visible: [(folder: URL, order: Double)] = [] for folder in candidates where BoardLoader.isUUIDShaped(folder.lastPathComponent) { let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) guard FileManager.default.fileExists(atPath: indexURL.path) else { continue } let document = try readDocument(at: indexURL, operation: operation) guard document.deleted.isMissing else { continue } if requireEditable { try checkEditable(document, at: indexURL, operation: operation) } switch document.order { case .missing: throw BoardWriteError( operation: operation, path: indexURL.path, reason: .unreadable(message: "missing required 'order' field") ) case let .malformed(raw): throw BoardWriteError( operation: operation, path: indexURL.path, reason: .unreadable(message: "malformed 'order' field: \(raw)") ) case let .valid(order): visible.append((folder: folder, order: order)) } } return visible } // MARK: - Move /// Moves a lane or card — and the whole tree beneath it — under another parent, in the same /// board or across boards. **A move is a physical folder move and the UUID travels /// unchanged** (01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle: moves /// keep the UUID, copies mint fresh ones"): nothing below the moved root is read or /// rewritten, so nested cards, `attachments/`, and strays arrive byte-identical by /// construction rather than by copying carefully. /// /// Exactly one file is rewritten — the moved root's `index.md`, and only to carry its new /// `order` (§ Ordering, "a reorder rewrites only the moved item's `index.md`"), stamped and /// `modified-by`-cleared like every other app write. `order` is the caller's explicit rank /// (a drop between two siblings), or `nil` to append after the destination's visible /// siblings. /// /// **The import boundary is the one place within-board uniqueness is enforced** (§ Fractal /// layout ▸ Rules). `sourceBoardRoot` and `destinationBoardRoot` are compared by resolved, /// standardized path; when they differ, this is an import, and an arriving UUID that already /// exists anywhere in the destination board — **tombstones included, because they are on /// disk** — is degraded to a copy: a fresh UUID is minted for that folder and nothing else. /// Degradation is **per folder, at the finest grain**: a lane arriving with one colliding /// card is still a lane *move*, and only that card arrives reminted. A remint is an identity /// repair, not an edit — the folder is renamed and its files' bytes are never touched, so /// the reminted item's `modified`, `modified-by`, and history are exactly what the source /// had. Every repair is reported in `MoveResult.reminted`; a same-board move never remints, /// and the same UUID living in two boards is not an error anywhere else — boards are /// **independent identity namespaces**, and forks only ever meet here. /// /// The order of operations is the contract: /// /// 1. **Validate before anything else**: source and destination parent must be existing /// directories, and the source must be UUID-shaped — this writer moves lanes and cards, /// and a stray is not one (§ Fractal layout ▸ Rules, "Name shape gates level detection"). /// 2. **Pre-flight the moved root's editability** (`readDocument` + `checkEditable`): the /// move must rewrite that file at the destination, so a file that cannot be round-tripped /// refuses *before* the folder moves — discover-before-you-write, the same guarantee /// `renumberVisibleChildren` depends on. Only the root needs it: a move never rewrites a /// nested file, so an uneditable card inside a moved lane is none of this call's business. /// 3. **Compute the destination `order` before the move**, off the destination's visible /// siblings — while the moved item is still elsewhere and so cannot count itself. /// 4. **Scan the destination board's identities** (depth 1 and 2, `directoryCandidates` + /// `isUUIDShaped`; strays skipped, tombstones kept) — but only on an import, since a /// same-board move cannot collide with anything but itself. /// 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 operation = "move item" try checkIsDirectory(sourceFolder, describedAs: "item folder", operation: operation) try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation) try checkIsUUIDShaped(sourceFolder, operation: operation) try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation) let sourceName = sourceFolder.lastPathComponent if isSameLocation(sourceFolder.deletingLastPathComponent(), destinationParent) { let rank: Double if let order { rank = order } else { let siblings = try visibleSiblings(of: destinationParent, operation: operation, requireEditable: false) rank = Ranks.append(toVisible: siblings.filter { $0.folder.lastPathComponent != sourceName }.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(sourceName) { arrivedName = freshUUIDName(in: destinationParent, avoiding: reserved) reserved.insert(arrivedName) reminted.append(MoveResult.Remint(from: ItemID(rawValue: sourceName), to: ItemID(rawValue: arrivedName))) } let arrivedRoot = destinationParent.appendingPathComponent(arrivedName, isDirectory: true) do { try FileManager.default.moveItem(at: sourceFolder, to: arrivedRoot) } catch { throw BoardWriteError( operation: operation, path: sourceFolder.path, reason: .io(message: "could not move folder: \(error.localizedDescription)") ) } if isImport { let children = childCandidates(of: arrivedRoot) reserved.formUnion(children.map(\.lastPathComponent)) for child in children where existing.contains(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. private static func identities(inBoard boardRoot: URL) -> Set { var identities: Set = [] for lane in childCandidates(of: boardRoot) { identities.insert(lane.lastPathComponent) for card in childCandidates(of: lane) { identities.insert(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: String ) throws(BoardWriteError) { let destination = folder.deletingLastPathComponent().appendingPathComponent(name, isDirectory: true) do { try FileManager.default.moveItem(at: folder, to: destination) } catch { throw BoardWriteError( operation: operation, path: folder.path, reason: .io(message: "could not rename folder: \(error.localizedDescription)") ) } } /// Whether two URLs name the same place on disk — symlinks resolved, `..`/`.` standardized /// away. The import boundary turns on this one comparison, so it is deliberately about /// *location*, not spelling: `/tmp/B.kanban` and `/private/tmp/B.kanban/.` are one board, /// and treating them as two would remint every arriving item for nothing. private static func isSameLocation(_ lhs: URL, _ rhs: URL) -> Bool { lhs.resolvingSymlinksInPath().standardizedFileURL.path == rhs.resolvingSymlinksInPath().standardizedFileURL.path } // MARK: - Copy /// Copies a lane or card — and the whole tree beneath it — under another parent, minting /// **fresh UUIDs for every folder it materializes** (01-storage-format.md § Fractal layout ▸ /// Rules, "Identity lifecycle: moves keep the UUID, copies mint fresh ones"). The copy is a /// new identity at every level: a copied lane's cards are new cards, not the same cards seen /// twice. Returns the new root's identity. This is the item-level copy — ⌘C/⌘V, ⌥-drag /// duplicate, the cross-board drag default; whole-board copies (Save as Template, File ▸ /// Duplicate) keep their GUIDs and are not this call. /// /// Everything travels verbatim: `attachments/` and its subfolders, strays, bodies, unknown /// keys, comments, line endings. Only the schema's provenance stamps move, per `stamps`: /// /// - `.fork` — an ordinary copy **keeps `created`** (it is a fork of something that really /// was created then) while `modified` is stamped and `modified-by` cleared, because a /// paste or a duplicate is an app write (§ Frontmatter). /// - `.born` — template instantiation stamps `created` **and** `modified` fresh, from one /// `Date` for the whole tree: a card made from a template is born today, not forked from /// the template (09-templates.md). /// /// Two deliberate leniencies below the root, both of them "what a hand copy would do": /// /// - A nested `index.md` that is **readable-but-uneditable** (§ Frontmatter), or that cannot /// be read at all, is copied byte-verbatim and simply not stamped. Refusing an entire copy /// because one nested card is a flow mapping would be hostile, and the file arrives /// *exactly* as it was rather than corrupted — its stale `modified-by` attribution /// surviving is the self-reported-provenance honest limit § Frontmatter already /// acknowledges. The **root** gets no such leniency: it must be rewritten (it needs its /// new `order`), so an unreadable or uneditable root refuses the copy up front, before /// anything is materialized. /// - A nested UUID-shaped folder with **no `index.md`** — interrupted-create residue — is /// copied and reminted like any other, and not rewritten: the same skip the loader applies /// to it (`.missingIndex`). /// /// **All-or-nothing at the destination**, unlike a move: any failure once copying has begun /// removes the partially copied tree best-effort and rethrows, because a half-copied item is /// pure residue — nothing was there before, so there is no true state for a reload to show. /// The source is never touched on any path. public static func copyItem( at sourceFolder: URL, toParent destinationParent: URL, order: Double?, stamps: CopyStamps ) throws(BoardWriteError) -> ItemID { let operation = "copy item" try checkIsDirectory(sourceFolder, describedAs: "item folder", operation: operation) try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation) try checkIsUUIDShaped(sourceFolder, operation: operation) try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation) let rank = try destinationOrder(order, inParent: destinationParent, operation: operation) let rootName = freshUUIDName(in: destinationParent, avoiding: []) let root = destinationParent.appendingPathComponent(rootName, isDirectory: true) do { try FileManager.default.copyItem(at: sourceFolder, to: root) } catch { try? FileManager.default.removeItem(at: root) throw BoardWriteError( operation: operation, path: sourceFolder.path, reason: .io(message: "could not copy folder: \(error.localizedDescription)") ) } do { var copied: [URL] = [] try remintDescendants(of: root, collecting: &copied, operation: operation) let now = Date() try updateIndex(inItemFolder: root, operation: operation) { document in if case .born = stamps { document.set(FrontmatterKeys.created, to: .date(now)) } document.set(FrontmatterKeys.order, to: .double(rank)) } for folder in copied { try stampCopiedDescendant(at: folder, stamps: stamps, now: now, operation: operation) } } catch { try? FileManager.default.removeItem(at: root) throw error } return ItemID(rawValue: rootName) } /// Renames every UUID-shaped folder beneath `folder` to a fresh mint, depth first, and /// collects where they ended up — "copies mint fresh UUIDs for **every** folder they /// materialize", which for a copied lane means its cards too. Recursion rather than a /// two-level walk because the rule is about folders, not levels; non-UUID-shaped folders /// (`attachments/` and anything under it, strays) are neither renamed nor descended into, /// exactly as the loader treats them. /// /// Each child is renamed *before* being descended into, so the collected URLs are the final /// ones — a caller can stamp them without re-deriving any path. The mint only has to avoid /// what is on disk in that folder: the siblings not yet renamed are still there under their /// original names, and `freshUUIDName` checks for exactly that. private static func remintDescendants( of folder: URL, collecting copied: inout [URL], operation: String ) throws(BoardWriteError) { for child in childCandidates(of: folder) { let fresh = freshUUIDName(in: folder, avoiding: []) try renameFolder(child, toSiblingNamed: fresh, operation: operation) let renamed = folder.appendingPathComponent(fresh, isDirectory: true) copied.append(renamed) try remintDescendants(of: renamed, collecting: &copied, operation: operation) } } /// Stamps one copied folder below the root — best-effort by design (see `copyItem`): a /// missing, unreadable, or uneditable `index.md` is left exactly as the copy found it rather /// than failing the gesture. `order` is not touched: a nested item keeps its rank among its /// own siblings, which travelled with it. private static func stampCopiedDescendant( at folder: URL, stamps: CopyStamps, now: Date, operation: String ) throws(BoardWriteError) { let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) guard FileManager.default.fileExists(atPath: indexURL.path), let copied = try? readDocument(at: indexURL, operation: operation), copied.uneditableShape == nil else { return } try updateIndex(inItemFolder: folder, operation: operation) { document in if case .born = stamps { document.set(FrontmatterKeys.created, to: .date(now)) } } } // MARK: - Tombstone /// Tombstones a lane or card in place: writes `deleted: ` into its own `index.md` — /// the whole of a delete (01-storage-format.md § Deletion). The folder never moves, never /// renames, and nothing beneath it is touched: hiding the subtree is the renderer's /// ancestor walk, not a stored flag, so deleting a lane rewrites *only* the lane's own /// file — its cards' files are exactly as they were. /// /// **Board-root deletion is structurally unreachable at this layer**: `checkIsUUIDShaped` /// — the same guard `moveItem`/`copyItem` lean on — refuses any folder whose name isn't /// UUID-shaped, and a board root never is (§ Board naming). A board-level `deleted:` key /// is legal-but-meaningless per the frontmatter table (the loader ignores and warns on /// it), but this call is simply never able to *produce* one: it has no board-root code /// path to fall through, only a refusal. /// /// Deleting an **already-tombstoned** item is not refused — it just refreshes the /// timestamp, a harmless rewrite (the gesture happened again; this layer does not police /// liveness, the store's UI does). Goes through `updateIndex`, so the usual contract /// applies: fresh read, refuse an uneditable shape, `modified` stamped and `modified-by` /// cleared, atomic replace. public static func deleteItem(at itemFolder: URL) throws(BoardWriteError) { let operation = "delete item" try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation) try checkIsUUIDShaped(itemFolder, operation: operation) try updateIndex(inItemFolder: itemFolder, operation: operation) { document in document.set(FrontmatterKeys.deleted, to: .date(Date())) } } /// Put Back: removes the `deleted` key, undoing exactly what `deleteItem` wrote. /// **Position-perfect by construction** — the folder never moved, so the item simply /// re-enters the visible set at its recorded `order` among its current siblings /// (01-storage-format.md § Deletion). `FrontmatterDocument.remove` takes *every* /// occurrence of the key, so a hand-duplicated `deleted` line cannot resurrect the /// tombstone the instant the winning occurrence is gone. /// /// Restoring an item that **isn't** tombstoned is not refused — it is a harmless stamped /// rewrite, the same shrug `deleteItem` gives an already-deleted item: this layer does not /// police liveness (a second, independent liveness check here could only drift from the /// store UI's own, which is what actually decides whether Put Back is offered at all). public static func restoreItem(at itemFolder: URL) throws(BoardWriteError) { let operation = "restore item" try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation) try checkIsUUIDShaped(itemFolder, operation: operation) try updateIndex(inItemFolder: itemFolder, operation: operation) { document in document.remove(FrontmatterKeys.deleted) } } /// Physical removal — Delete Immediately / Empty Trash (03-board-ui.md): deletes the /// folder tree from disk. Irreversible, and distinct from tombstoning — this call does /// **not** require the item to be tombstoned first, since Delete Immediately skips the /// tombstone stage by design. /// /// **A folder that is already gone is success, not an error** — checked first, before the /// shape guard below. A Finder deletion converges on exactly the end state a purge would /// produce (01-storage-format.md § Deletion, "a folder that disappears without a /// tombstone... is also a delete"), so there is nothing left here to distinguish: a stray /// path that never existed and a once-real item someone already threw away in Finder both /// purge cleanly, silently, without inspecting what used to be there. /// /// When the folder *does* exist, `checkIsUUIDShaped` guards the same unreachability /// `deleteItem`/`restoreItem` rely on: a board root or a stray never purges through this /// call, only a lane or a card. public static func purgeItem(at itemFolder: URL) throws(BoardWriteError) { let operation = "purge item" guard FileManager.default.fileExists(atPath: itemFolder.path) else { return } try checkIsUUIDShaped(itemFolder, operation: operation) do { try FileManager.default.removeItem(at: itemFolder) } catch { throw BoardWriteError( operation: operation, path: itemFolder.path, reason: .io(message: "could not remove folder: \(error.localizedDescription)") ) } } // MARK: - 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: String ) 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/`, an uppercase /// 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: String) 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. private static func checkIndexIsRewritable(inItemFolder folder: URL, operation: String) throws(BoardWriteError) { let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) let document = try readDocument(at: indexURL, operation: operation) try checkEditable(document, at: indexURL, operation: 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: String) 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: String ) 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: - 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 { /// An imperative human phrase for what was being attempted — "reorder card", "renumber /// children" — supplied by the call site, because only it knows what the user asked for. public let operation: String /// 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): \(path): \(reason.description)" } public enum Reason: Sendable, Equatable, CustomStringConvertible { /// The file is missing, is not UTF-8, or its frontmatter does not parse — `message` /// carries the specifics. A rewrite the app cannot round-trip is not attempted. case unreadable(message: String) /// The settled readable-but-uneditable refusal (01-storage-format.md § Frontmatter): /// the file loads and renders, but its frontmatter has a shape the surgical editor /// cannot address, so writing it would risk corruption. Names the shape. case uneditableFrontmatter(FrontmatterDocument.UneditableShape) /// Any I/O failure writing the temp file or renaming it into place — disk full, /// permissions, volume error. The destination still holds its previous bytes. case io(message: String) public var description: String { switch self { case let .unreadable(message): "unreadable: \(message)" case let .uneditableFrontmatter(shape): "frontmatter cannot be edited in place: \(shape.description)" case let .io(message): message } } } }