Files
lanework/Kanban/Storage/BoardLoader.swift
T
rzen 40c0a75c24 Build the Raw Source outlet
The escape hatch: View > Raw Source (opt-cmd-E) unmounts the whole
content area for the literal on-disk index.md in a plain monospaced
editor with Cancel/Apply. Raw source is window-level state, not a third
body mode — entry rides setMode(.preview), which flushes the Edit
session by construction, then reads the file fresh; exit reveals
Preview, and an empty body after Apply does not reopen Edit (openIfNeeded
already ran). Apply validates the proposed bytes through the loader's
own card checks — parseDocument's strict UTF-8/BOM rejection, schema,
order — deliberately skipping the uneditable-shape refusal, since a
flow-mapping card is exactly what the hatch repairs; invalid bytes
alert in place with the loader's own error and no bracket opens. The
write is byte-for-byte with no modified stamp and no modified-by clear,
per 01's explicit carve-out — the verbatim contract outranks stamping —
and identical bytes write nothing. Escape cancels, cmd-Return applies,
toggle-off applies too, and cmd-E disables while raw is active via a
testable predicate. Tombstoned targets refuse as vanished: a foreign
delete is never reverted by a stale buffer.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 11:25:25 -04:00

634 lines
33 KiB
Swift

import Foundation
import os
/// Walks a board's folder tree and produces an immutable `BoardModel` snapshot — a pure
/// function of the tree (02-architecture.md § Layering ▸ Components). Enforces the fractal
/// layout's fail-fast and skip rules (01-storage-format.md § Fractal layout, Malformed input)
/// so a bad file either loudly rejects the whole load or is cleanly ignored — never a silent
/// partial result.
///
/// Level is position: root `index.md` → board, depth-1 folders → lanes, depth-2 folders →
/// cards. **Name shape gates level detection** (01-storage-format.md § Fractal layout ▸
/// Rules): only a folder whose name has a UUID's shape — hex, `8-4-4-4-12`, **any case and any
/// version** — is a lane/card *candidate* at those depths; see `isUUIDShaped` below for exactly
/// what's checked.
/// Anything else — even a directory holding a perfectly valid `index.md` — is a stray: skipped
/// with a `.nonUUIDFolderIgnored` warning, preserved verbatim on disk, and never descended
/// into. A hand-made `notes/` folder (or a broken `index.md` inside one) can never brick a
/// load; only a UUID-shaped candidate that is itself missing `index.md` still gets the older
/// `.missingIndex` warning, and only a UUID-shaped candidate's `index.md` can fail-fast.
///
/// Reserved child names (`attachments/`, `comments/`) only matter as children *of a card*
/// (01-storage-format.md § Fractal layout ▸ Rules), and cards are leaves *structurally*: the
/// walk stops at depth 2, so nothing below a card is ever a level candidate. Doubly so under the
/// shape rule — `attachments` and `comments` are non-UUID-shaped and would read as strays, not
/// levels, so they never need special-casing against the stray warning.
///
/// **Two reads inside a card folder**, both of them flat name listings and nothing more — neither
/// opens a file, descends, warns, or fails a load; each degrades to `[]`:
///
/// - `attachmentNames(in:)` — `attachments/`, feeding `Card.attachments`. The board window's face
/// needs it before a card window exists (the quiet paperclip indicator — 03-board-ui.md § Card
/// face), and the snapshot is where it reads from.
/// - `looseFileNames(in:)` — the card folder *itself*, feeding `LoadResult.looseCardFiles`. This is
/// the loose-file carve-out's **detection** half (01-storage-format.md § Fractal layout ▸ Rules,
/// settled 2026-07-28): a regular file sitting beside a card's `index.md` belongs in
/// `attachments/`, and the app relocates it. Detection stays read-only *here* — this loader is a
/// pure function of the tree and writes nothing, ever (the Repair precedent); the relocation is a
/// Writer-mediated app write the store schedules off the snapshot
/// (`BoardStore.relocateLooseCardFiles`).
///
/// Everything else about a card folder's contents remains outside this loader's business.
///
/// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never
/// followed, whether it points to a file or a directory — this loader does not resolve
/// cross-volume or cyclic trees.
public enum BoardLoader: Sendable {
/// Schema version this app understands; anything higher fails fast
/// (01-storage-format.md § Malformed input). Internal rather than `private`: read by
/// `BoardLoadError.Reason.description` below, and it is also the version `BoardWriter`
/// stamps into files it creates — one symbol, so the app can never write a file its own
/// loader would reject as newer-than-supported.
static let supportedSchema = 1
/// Board-level key for `BoardModel.template` — not schema-owned in the engine's sense
/// (`FrontmatterKeys.schemaOwned`), because its value is opaque and read raw here rather
/// than through a typed `FrontmatterDocument` accessor.
private static let templateKey = "template"
/// Internal rather than `private`: `BoardWriter` names the same file, and the loader and
/// the writer must never disagree about which file a folder's content lives in.
static let indexFileName = "index.md"
/// The card-level names the app claims, and therefore the three the loose-file carve-out
/// never touches (01-storage-format.md § Fractal layout ▸ Rules: "Reserved card-level names
/// … untouched"): the card's own `index.md` plus the two reserved children. `comments` is
/// listed because the schema reserves the name, not because anything writes it yet —
/// `attachments/` is still the one folder this app ever creates under a card.
///
/// **Compared lowercased**, because the filesystem this runs on usually is: a file spelled
/// `Index.md` *is* the card's index to `fileExists`, and a case-sensitive reservation check
/// would hand the loose-file relocation a card's own content to move into `attachments/`.
///
/// Internal rather than `private`: `BoardWriter.relocateLooseFiles` refuses the same three
/// names on its own, so a caller passing a hand-made list cannot reach past this rule.
static let reservedCardChildNames: Set<String> = [
indexFileName, BoardWriter.attachmentsFolderName, "comments",
]
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader")
// MARK: - Entry point
public static func load(boardRoot: URL) throws(BoardLoadError) -> LoadResult {
try checkIsReadableDirectory(boardRoot)
let boardIndexURL = boardRoot.appendingPathComponent(indexFileName)
guard FileManager.default.fileExists(atPath: boardIndexURL.path) else {
throw BoardLoadError(path: indexFileName, reason: .boardRootMissingIndex)
}
let boardDocument = try readDocument(at: boardIndexURL, path: indexFileName)
let boardSchema = try validatedSchema(in: boardDocument, path: indexFileName)
var warnings: [LoadWarning] = []
func warn(_ warning: LoadWarning) {
warnings.append(warning)
logger.warning("\(warning.description, privacy: .public)")
}
// The carve-out's detection channel — deliberately *not* `warnings`, which is the
// stray-*tolerance* vocabulary (see `LoadResult.looseCardFiles`).
var looseCardFiles: [LooseCardFiles] = []
// Legal per the frontmatter table, meaningless at board level — ignore and log, never
// tombstone (01-storage-format.md § Deletion).
if !boardDocument.deleted.isMissing {
warn(.boardLevelDeletedIgnored)
}
var lanes: [Lane] = []
for laneURL in try directoryCandidates(in: boardRoot) {
let laneName = laneURL.lastPathComponent
guard isUUIDShaped(laneName) else {
warn(.nonUUIDFolderIgnored(path: laneName))
continue
}
guard hasIndex(laneURL) else {
warn(.missingIndex(path: laneName))
continue
}
let lanePath = laneName + "/" + indexFileName
let laneDocument = try readDocument(at: laneURL.appendingPathComponent(indexFileName), path: lanePath)
let laneSchema = try validatedSchema(in: laneDocument, path: lanePath)
let laneOrder = try validatedOrder(in: laneDocument, path: lanePath)
var cards: [Card] = []
for cardURL in try directoryCandidates(in: laneURL) {
let cardName = cardURL.lastPathComponent
let cardRelPath = laneName + "/" + cardName
guard isUUIDShaped(cardName) else {
warn(.nonUUIDFolderIgnored(path: cardRelPath))
continue
}
guard hasIndex(cardURL) else {
warn(.missingIndex(path: cardRelPath))
continue
}
let cardPath = cardRelPath + "/" + indexFileName
let cardDocument = try readDocument(at: cardURL.appendingPathComponent(indexFileName), path: cardPath)
let cardSchema = try validatedSchema(in: cardDocument, path: cardPath)
let cardOrder = try validatedOrder(in: cardDocument, path: cardPath)
// Noticed, never acted on: the relocation is the store's, through the Writer.
let loose = looseFileNames(in: cardURL)
if !loose.isEmpty {
looseCardFiles.append(LooseCardFiles(
laneID: ItemID(rawValue: laneName),
cardID: ItemID(rawValue: cardName),
title: cardDocument.title.value,
fileNames: loose
))
logger.info("\(cardRelPath, privacy: .public): \(loose.count, privacy: .public) loose file(s) beside index.md — to be relocated into attachments/")
}
cards.append(Card(
id: ItemID(rawValue: cardName),
schema: cardSchema,
title: cardDocument.title,
created: cardDocument.created,
modified: cardDocument.modified,
modifiedBy: cardDocument.modifiedBy,
deleted: cardDocument.deleted,
background: cardDocument.background,
icon: cardDocument.icon,
iconColor: cardDocument.iconColor,
order: cardOrder,
attachments: attachmentNames(in: cardURL),
document: cardDocument
))
}
lanes.append(Lane(
id: ItemID(rawValue: laneName),
schema: laneSchema,
title: laneDocument.title,
created: laneDocument.created,
modified: laneDocument.modified,
modifiedBy: laneDocument.modifiedBy,
deleted: laneDocument.deleted,
background: laneDocument.background,
icon: laneDocument.icon,
iconColor: laneDocument.iconColor,
order: laneOrder,
width: laneDocument.width,
cards: Ranks.sortedForDisplay(cards, order: \.order, name: { $0.id.rawValue }),
document: laneDocument
))
}
let model = BoardModel(
rootURL: boardRoot,
schema: boardSchema,
title: boardDocument.title,
created: boardDocument.created,
modified: boardDocument.modified,
modifiedBy: boardDocument.modifiedBy,
deleted: boardDocument.deleted,
background: boardDocument.background,
icon: boardDocument.icon,
iconColor: boardDocument.iconColor,
template: boardDocument.value(for: templateKey),
lanes: Ranks.sortedForDisplay(lanes, order: \.order, name: { $0.id.rawValue }),
document: boardDocument
)
return LoadResult(model: model, warnings: warnings, looseCardFiles: looseCardFiles)
}
// MARK: - Filesystem helpers
private static func checkIsReadableDirectory(_ url: URL) throws(BoardLoadError) {
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else {
throw BoardLoadError(path: ".", reason: .unreadableRoot(message: "no such file or directory"))
}
guard isDirectory.boolValue else {
throw BoardLoadError(path: ".", reason: .notADirectory)
}
}
private static func hasIndex(_ folder: URL) -> Bool {
FileManager.default.fileExists(atPath: folder.appendingPathComponent(indexFileName).path)
}
/// The names of `<card>/attachments/`'s **top-level regular files** — the flat view
/// 01-storage-format.md § Attachments specifies ("top-level files only"; "subfolders are
/// tolerated, preserved verbatim … and not surfaced"). `[]` when there is no `attachments/`.
///
/// Three exclusions, the same three `directoryCandidates` makes and for the same reasons:
/// hidden entries (`.DS_Store` and friends are not the user's attachments), directories (a
/// subfolder stays reachable through Reveal in Finder and through body-relative paths, but
/// never appears as an attachment), and symlinks (this loader resolves nothing — the same
/// stance the level walk takes).
///
/// **Finder order** (`localizedStandardCompare`), so `"shot 2.png"` sorts before
/// `"shot 10.png"`: the order has to be stable across loads, and where it is already the
/// sidebar's order it may as well be the same one.
///
/// Failure is silent: an unlistable directory yields `[]`. Fail-fast is reserved for
/// structure (01-storage-format.md § Malformed input), and this field decorates a card — a
/// permissions race here must never be the reason a whole board refuses to open.
///
/// Internal rather than `private`: `BoardWriter.listAttachments` — the card window sidebar's
/// authoritative listing — answers through this same function behind its own card-folder
/// guard, so the face and the sidebar can never disagree about what a card's attachments are.
/// It is also why the folder name is read off `BoardWriter`, which owns it as the one folder
/// the app ever creates under a card.
static func attachmentNames(in cardFolder: URL) -> [String] {
let folder = cardFolder.appendingPathComponent(
BoardWriter.attachmentsFolderName, isDirectory: true
)
guard let entries = try? FileManager.default.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
options: [.skipsHiddenFiles]
) else {
return []
}
return entries
.filter { url in
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else {
return false
}
return values.isRegularFile == true && values.isSymbolicLink != true
}
.map(\.lastPathComponent)
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
/// A card folder's **loose top-level files** — the one carve-out to uniform stray tolerance
/// (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28, "Lanework-owns-the-board"):
/// "a regular file sitting beside a card's `index.md` (not `attachments/`, not a reserved name)
/// belongs in `attachments/`, and the app moves it there".
///
/// **This function only notices.** It opens nothing, moves nothing, and creates nothing; the
/// relocation is `BoardWriter.relocateLooseFiles`, run through the store's write bracket. A load
/// is a pure function of the tree and stays one.
///
/// Four exclusions, three of them `attachmentNames(in:)`' own and for its reasons:
///
/// - **Directories.** The carve-out is exactly *files*. A stray folder in a card — a nested
/// clone, a hand-made subfolder — keeps the verbatim posture, because "relocating a directory
/// into the flat attachment model would be wrong".
/// - **Symlinks**, never touched and never traversed (§ Rules) — the same stance the level walk
/// takes. `isSymbolicLink` is checked *beside* `isRegularFile` rather than trusted to imply
/// it, exactly as `directoryCandidates` does, so a link pointing at a file is excluded on its
/// own account.
/// - **Hidden entries.** `.DS_Store` and friends are not the user's files, and relocating one
/// would surface it in a card's attachment list — the loudest possible way to be wrong about
/// a file nobody wrote on purpose. It is also what keeps a crashed write's dot-prefixed
/// residue out of the relocation.
/// - **The reserved card-level names** (`reservedCardChildNames`), case-insensitively.
///
/// Finder order (`localizedStandardCompare`), like every other name listing here, so the notice
/// the store posts names files the way the board would sort them.
///
/// Failure is silent (`[]`): a permissions race here must never be the reason a board refuses
/// to open, and "nothing to relocate" is the safe reading of "cannot tell".
static func looseFileNames(in cardFolder: URL) -> [String] {
guard let entries = try? FileManager.default.contentsOfDirectory(
at: cardFolder,
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
options: [.skipsHiddenFiles]
) else {
return []
}
return entries
.filter { url in
guard !reservedCardChildNames.contains(url.lastPathComponent.lowercased()),
let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
else {
return false
}
return values.isRegularFile == true && values.isSymbolicLink != true
}
.map(\.lastPathComponent)
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
/// The hex characters `isUUIDShaped` accepts in each `-`-delimited group — **both cases**,
/// per the shape-only identity predicate below.
private static let uuidGroupCharacters = Set("0123456789abcdefABCDEF")
/// Whether `name` has a UUID's shape — hex, `8-4-4-4-12`, **any case and any version** —
/// gating lane/card level detection (01-storage-format.md § Fractal layout ▸ Rules, "Name
/// shape gates level detection"). This is *the* identity predicate, and it is deliberately
/// **shape-only**: lowercase v4 is the app's emission rule, not the gate.
///
/// - **Any case.** `uuidgen(1)` and Swift's own `UUID().uuidString` both print *uppercase*,
/// so a strict lowercase gate would turn an agent's standard-tool card into a silently
/// skipped stray — the worst failure mode for a files-first app. Accept liberally, emit
/// conservatively: `BoardWriter` still writes only lowercase v4 and never renames an
/// existing folder to canonicalize it.
/// - **Any version.** The version (13th hex digit) and variant (17th hex digit) nibbles are
/// **not** validated: they protect no invariant here — an agent's v7 is exactly as unique
/// as a v4 — and the loader's job is recognizing the folder-naming *convention*, not
/// re-deriving RFC 4122 conformance every load.
///
/// Equivalent to "does `UUID(uuidString:)` parse it", which is how 01-storage-format.md
/// states the rule; kept as a manual scan because that is the cheaper answer on the hot path
/// (every folder of every load) and needs no bridging.
///
/// Recognizing a name is not the same as *comparing* two of them: identity comparison is
/// UUID-*value* equality, so two case-spellings of one UUID are one identity everywhere —
/// see `ItemID` (`BoardModel.swift`), which stores the folder's exact spelling but compares
/// canonically.
///
/// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` walks the same
/// candidates the loader walked, and level detection has to be one rule, not two.
static func isUUIDShaped(_ name: String) -> Bool {
let groups = name.split(separator: "-", omittingEmptySubsequences: false)
guard groups.map(\.count) == [8, 4, 4, 4, 12] else { return false }
return groups.allSatisfy { $0.allSatisfy(uuidGroupCharacters.contains) }
}
/// Direct subdirectories of `folder`, in deterministic (folder-name) order, excluding
/// hidden entries (`.DS_Store`, `.git`, …) and symlinks — the loader's uniform stray
/// tolerance (01-storage-format.md § Fractal layout ▸ Rules). Stray *files* are excluded
/// here too: only directories are level candidates at all, and the caller further narrows
/// those to actual lane/card candidates by name shape (`isUUIDShaped`) before doing
/// anything else with them.
///
/// An unreadable non-root folder (permission changed mid-walk, races) degrades to "no
/// candidates" rather than failing the whole load — fail-fast is reserved for the board
/// root and for malformed `index.md` content, not transient directory-listing races below
/// it.
/// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` enumerates
/// siblings through this same door, so the writer's idea of "the children" can never drift
/// from the loader's. It is also why `BoardWriter`'s temp files are dot-prefixed — the
/// `.skipsHiddenFiles` here is what makes a crashed write's residue invisible to a load.
static func directoryCandidates(in folder: URL) throws(BoardLoadError) -> [URL] {
guard let entries = try? FileManager.default.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
options: [.skipsHiddenFiles]
) else {
return []
}
return entries
.filter { url in
guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else {
return false
}
return values.isDirectory == true && values.isSymbolicLink != true
}
.sorted { $0.lastPathComponent < $1.lastPathComponent }
}
// MARK: - Document reading + field validation
/// **Strict, byte-faithful UTF-8** — the same decode `BoardWriter` uses, and for the same
/// reason: Foundation's NSString-backed `String(contentsOf:encoding:)` silently strips a
/// leading BOM, which would let a BOM'd file *load* here and then refuse every write over
/// in `BoardWriter` — a baffling split. 01-storage-format.md § Fractal layout ▸ Rules is
/// explicit that a BOM'd file is rejected at load (it fails the frontmatter delimiter);
/// decoding byte-faithfully is what makes that stated rejection actually happen.
private static func readDocument(at url: URL, path: String) throws(BoardLoadError) -> FrontmatterDocument {
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
throw BoardLoadError(
path: path,
reason: .unparseableYAML(message: "could not read file: \(error.localizedDescription)", line: nil)
)
}
return try parseDocument(data, path: path)
}
/// The decode-and-parse half of `readDocument(at:path:)`, over bytes rather than a URL.
///
/// Split out for the raw-source outlet, which validates bytes that are **not on disk yet**
/// (`validateCardIndex`) — and split rather than copied on purpose: "Apply validates through the
/// same fail-fast parse the loader uses" (05-card-window.md ▸ Raw source outlet) is only true if
/// it is literally the same function. The strict UTF-8 decode is half of what that buys — a BOM'd
/// or non-UTF-8 proposal is rejected here by the same two lines that reject one on disk
/// (01-storage-format.md § Fractal layout ▸ Rules).
static func parseDocument(_ data: Data, path: String) throws(BoardLoadError) -> FrontmatterDocument {
guard let text = String(validating: data, as: UTF8.self) else {
throw BoardLoadError(path: path, reason: .unparseableYAML(message: "file is not UTF-8", line: nil))
}
do {
return try FrontmatterDocument.parse(text)
} catch {
let line: Int? = if case let .unparseableYAML(_, line) = error { line } else { nil }
throw BoardLoadError(path: path, reason: .unparseableYAML(message: error.description, line: line))
}
}
/// Whether `data` would load as a **card's** `index.md` — the raw-source Apply's gate
/// (05-card-window.md ▸ Raw source outlet: "Apply validates through the same fail-fast parse the
/// loader uses (detailed alert on error, stays in source mode) before writing byte-for-byte").
///
/// **Exactly the three checks `load(boardRoot:)` runs on a card**, in its order and through its
/// own functions: decode + parse (`parseDocument`), then `schema` (present, well-formed, not
/// newer than this app) and `order` (present, well-formed) — the two fields a card must carry.
/// Nothing card-shaped is checked beyond that, because nothing else *is*: `title` is optional,
/// unknown keys are the whole point of the outlet, and the body is free text.
///
/// It deliberately does **not** check `uneditableShape`: that refusal exists for surgical
/// span edits (`BoardWriter.updateIndex`), and raw source replaces the whole file — a flow-mapping
/// frontmatter is precisely one of the things the escape hatch exists to let a user rewrite.
///
/// The error is the loader's own, undiluted, so the alert can show the taxonomy's display text
/// (line numbers included) rather than a re-worded copy.
///
/// - Parameter path: what the error names — `indexFileName` from every call site today, which is
/// what the card window's alert is about.
public static func validateCardIndex(_ data: Data, path: String) throws(BoardLoadError) -> FrontmatterDocument {
let document = try parseDocument(data, path: path)
_ = try validatedSchema(in: document, path: path)
_ = try validatedOrder(in: document, path: path)
return document
}
private static func validatedSchema(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Int {
switch document.schema {
case .missing:
throw BoardLoadError(path: path, reason: .missingSchema)
case let .malformed(raw):
throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw))
case let .valid(value):
guard value <= supportedSchema else {
throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value))
}
return value
}
}
private static func validatedOrder(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Double {
switch document.order {
case .missing:
throw BoardLoadError(path: path, reason: .missingOrder)
case let .malformed(raw):
throw BoardLoadError(path: path, reason: .malformedOrder(raw: raw))
case let .valid(value):
return value
}
}
}
// MARK: - Result
/// A successful load: the snapshot plus anything tolerated-but-notable encountered along the
/// way. `warnings` is also logged as it accumulates (`os.Logger(subsystem: "dev.rzen.indie.Kanban",
/// category: "loader")`) so it shows up in Console even if a caller never inspects it.
public struct LoadResult: Sendable {
public var model: BoardModel
public var warnings: [LoadWarning]
/// The cards this walk found carrying loose files, in the order the walk met them — the
/// loose-file carve-out's detection channel (01-storage-format.md § Fractal layout ▸ Rules,
/// settled 2026-07-28).
///
/// **Its own field rather than a `LoadWarning` case**, because the two say opposite things.
/// `warnings` is the *stray-tolerance* vocabulary: "this was ignored, it is staying exactly
/// where it is, there is nothing to do". A loose card file is the one thing on a board that is
/// **not** tolerated — it is pending work, and the store acts on it. Folding it into the
/// warning channel would also mean throwing away everything the act needs (which lane, which
/// card, which title, which names) and re-deriving it from a display string.
///
/// Nothing renders this: a loose file is not content, and it reaches no view. Its one consumer
/// is `BoardStore.relocateLooseCardFiles()`, which relocates and posts the notice.
///
/// Tombstoned cards are included, and cards under tombstoned lanes with them. Where a file
/// belongs on disk is a question about the *tree*, not about what the board is currently
/// rendering — the same reason the loader flags a tombstoned card at all rather than dropping
/// it.
public var looseCardFiles: [LooseCardFiles] = []
}
/// One card found holding files that belong in its `attachments/` — everything the relocation and
/// its notice need, and nothing more.
///
/// `title` is the card's as written, `nil` for an untitled one: "Untitled" is a rendering, never a
/// value (03-board-ui.md § Card face), so the phrasing layer decides what to call it. The path is
/// carried as its two identity components rather than as a URL, `BoardStore.liveItem`'s convention,
/// so the write derives its path from the store's *current* root.
public struct LooseCardFiles: Sendable, Equatable {
public let laneID: ItemID
public let cardID: ItemID
public let title: String?
/// The loose files' names, in Finder order (`BoardLoader.looseFileNames`). Never empty — a card
/// with nothing loose contributes no entry at all.
public let fileNames: [String]
public init(laneID: ItemID, cardID: ItemID, title: String?, fileNames: [String]) {
self.laneID = laneID
self.cardID = cardID
self.title = title
self.fileNames = fileNames
}
}
/// A tolerated anomaly the loader kept going past. Never blocks a load — see `BoardLoadError`
/// for what does.
public enum LoadWarning: Sendable, Equatable, CustomStringConvertible {
/// A **UUID-shaped** folder below the board root has no `index.md` — skipped, not
/// fail-fast (an interrupted two-step create must not brick the board). `path` is relative
/// to the board root. Only reachable for a folder that passed `isUUIDShaped`; a
/// non-UUID-shaped folder missing `index.md` gets `.nonUUIDFolderIgnored` instead, never
/// this case.
case missingIndex(path: String)
/// A lane/card-depth folder whose name doesn't have a UUID's shape (`isUUIDShaped` — hex,
/// `8-4-4-4-12`, any case, any version) —
/// skipped, not fail-fast, regardless of whether it holds a valid `index.md`, a broken one,
/// or none at all (01-storage-format.md § Fractal layout ▸ Rules, "Name shape gates level
/// detection"). Preserved verbatim on disk, never descended into. `path` is relative to the
/// board root.
case nonUUIDFolderIgnored(path: String)
/// A board-level `deleted:` key is legal per the frontmatter table but meaningless
/// (01-storage-format.md § Deletion) — ignored, never tombstones the board.
case boardLevelDeletedIgnored
public var description: String {
switch self {
case let .missingIndex(path):
"\(path): folder has no index.md, skipped"
case let .nonUUIDFolderIgnored(path):
"\(path): folder name is not UUID-shaped, ignored as a stray"
case .boardLevelDeletedIgnored:
"index.md: board-level 'deleted' key is meaningless, ignored"
}
}
}
// MARK: - Error
/// A fail-fast structural failure loading a board — loud and specific: `path` (relative to
/// the board root where one exists) plus `reason` says exactly what's wrong. No partial
/// loads: throwing this means `BoardLoader.load` produced nothing at all.
public struct BoardLoadError: Error, Sendable, Equatable, CustomStringConvertible {
public let path: String
public let reason: Reason
public var description: String { "\(path): \(reason.description)" }
public enum Reason: Sendable, Equatable, CustomStringConvertible {
/// The board root itself has no `index.md` — unlike every level below it, this is not
/// skip-and-warn: there is no board without one.
case boardRootMissingIndex
/// Wraps any `FrontmatterError` from parsing — bad delimiters, bad YAML, a
/// frontmatter block that isn't a mapping. `line` is 1-based within the file when the
/// underlying error carries one.
case unparseableYAML(message: String, line: Int?)
case missingSchema
case malformedSchema(raw: String)
/// `schema` is present, valid, and greater than this app's `supportedSchema`.
case schemaNewerThanApp(found: Int)
/// `order` is required on lanes and cards, never on the board itself.
case missingOrder
case malformedOrder(raw: String)
/// The board root exists but is a file, not a directory.
case notADirectory
/// The board root doesn't exist, or its contents couldn't be listed.
case unreadableRoot(message: String)
public var description: String {
switch self {
case .boardRootMissingIndex:
"board root is missing index.md"
case let .unparseableYAML(message, line):
if let line {
"unparseable YAML at line \(line): \(message)"
} else {
"unparseable YAML: \(message)"
}
case .missingSchema:
"missing required 'schema' field"
case let .malformedSchema(raw):
"malformed 'schema' field: \(raw)"
case let .schemaNewerThanApp(found):
"schema \(found) is newer than this app supports (schema \(BoardLoader.supportedSchema))"
case .missingOrder:
"missing required 'order' field"
case let .malformedOrder(raw):
"malformed 'order' field: \(raw)"
case .notADirectory:
"board root is not a directory"
case let .unreadableRoot(message):
"board root is unreadable: \(message)"
}
}
}
}