import Foundation /// The immutable snapshot shape `BoardLoader` produces and every view/store reads /// (02-architecture.md § Layering). `BoardModel`, `Lane`, and `Card` are plain value types — /// no class, no shared mutable state — encoding the frontmatter tables from /// 01-storage-format.md § Frontmatter as data. Level is position (root = board, depth 1 = /// lane, depth 2 = card); there is deliberately no `type`/`level` discriminator field — /// the three distinct Swift types encode it structurally. /// The immutable identity of a lane or card folder: its exact name, byte-for-byte — compared as /// a UUID *value*. /// /// **`rawValue` is the folder's exact spelling.** It builds URLs, it must round-trip /// byte-perfect (it is the primary key on disk), and it is the display-order tie-break /// (`Ranks.sortedForDisplay`), so it is never normalized on the way in. Deliberately **not** /// Foundation's `UUID`, which re-renders as uppercase and would silently corrupt that /// round-trip. /// /// **Equality and hashing are by UUID value, not by spelling** (01-storage-format.md § Fractal /// layout ▸ Rules, settled): *two case-spellings of one UUID are one identity everywhere* — /// selection membership, the import-boundary collision check, every `Set`/`Dictionary` keyed by /// this type. That matches default-APFS case-insensitivity, where the two spellings name one /// folder anyway. Lowercasing `rawValue` *is* the canonical form: `BoardLoader` only ever mints /// an `ItemID` for a folder name that passed the shape gate (`isUUIDShaped` — hex, `8-4-4-4-12`, /// any case, any version), and `BoardWriter` only ever mints one for a name it just wrote, so /// the string is always ASCII hex and hyphens, where case folding is exactly UUID-value /// canonicalization. Nothing asserts that — the type stays total on any string a caller hands it; /// off-shape input simply compares by its own lowercasing, which is the harmless reading. /// /// Consequences, all intended: `RawRepresentable` is unaffected — only `==` and `hash(into:)` /// are hand-written, and `rawValue` still reads back exactly as it was stored; /// SwiftUI `Identifiable` diffing keys on this same value equality, so a case-respelled folder /// is the *same* row rather than a delete plus an insert; and a `Set` holding both /// spellings collapses them to one member, keeping whichever arrived first. /// /// Board roots don't get one of these: a board's folder name is a human/Finder-assigned /// `.kanban` package name, not a UUID (01-storage-format.md § Board naming) — its identity is /// `BoardModel.rootURL`, resolved elsewhere via a security-scoped bookmark /// (02-architecture.md § Per-board app state), not a folder-name key. public struct ItemID: Hashable, Sendable, RawRepresentable { public let rawValue: String public init(rawValue: String) { self.rawValue = rawValue } /// The comparison key: `rawValue` case-folded, through the **one** canonicalization /// (`IntegrityRules.canonicalIdentity`, settled 2026-07-29 — the fold). Computed rather than /// stored so `ItemID` stays one string wide and `rawValue` remains the single source of truth /// for what is on disk. /// /// The Writer compares *paths* rather than model values and reaches the same function directly; /// before the fold it carried a private copy of this line, which is one line too many for a rule /// that decides whether two folders are the same item. var canonicalValue: String { IntegrityRules.canonicalIdentity(rawValue) } public static func == (lhs: ItemID, rhs: ItemID) -> Bool { lhs.canonicalValue == rhs.canonicalValue } public func hash(into hasher: inout Hasher) { hasher.combine(canonicalValue) } } extension ItemID: CustomStringConvertible { public var description: String { rawValue } } /// A board: `/index.md` plus every lane beneath it. `` is the `.kanban` package /// (or an extension-less folder — both open). public struct BoardModel: Sendable, Equatable { /// The board's identity — see `ItemID`'s doc comment for why boards don't have one of /// those instead. public let rootURL: URL public let schema: Int public let title: FieldValue public let created: FieldValue public let modified: FieldValue public let modifiedBy: FieldValue /// Legal per the common frontmatter table but meaningless at board level — a board can't /// tombstone itself out of its own window (01-storage-format.md § Deletion). `BoardLoader` /// ignores it (and emits a `LoadWarning`) rather than acting on it; the model keeps the /// field only so the value still round-trips. There is deliberately no `isDeleted` here — /// contrast `Lane`/`Card`. public let deleted: FieldValue public let background: FieldValue public let icon: FieldValue public let iconColor: FieldValue /// `{order: N}` — picker position when this board lives in a template store /// (09-templates.md). Opaque by design: exposed as the engine's raw `YAMLValue`, never /// parsed into a dedicated Swift shape, so future subkeys need no model change. public let template: YAMLValue? /// Lanes in display order (`Ranks.sortedForDisplay`, folder-name tie-break). /// /// **A lane carrying a legacy `deleted:` key is here, live** (01-storage-format.md § Deletion, /// lane clause re-ruled 2026-07-29): the key is ignored outright — no migration, no notice, no /// write — so `Lane.isDeleted` describes the bytes and decides nothing. A *card* carrying one /// still rides along flagged until its migration relocates it (`LoadResult.legacyTombstones` — /// see `BoardLoader`'s "The migration window" note). public let lanes: [Lane] /// The board's **materialized trash**, card side: the card folders sitting directly in /// `/.trash/`, in display order (01-storage-format.md § Deletion, resettled /// 2026-07-28; 03-board-ui.md § Trash). /// /// **A sibling container of `lanes`, not a lane.** `.trash/` is a reserved, app-claimed name /// at board root that "holds card and lane folders interleaved directly, no `index.md` of its /// own", so it has no identity, no title, no `order`, and no frontmatter to model: the /// container *is* the list. That is why this is `[Card]` rather than a `Lane` or a `Trash` /// struct — there is nothing for either to carry that this array does not. /// /// **The container's other kind is `trashedLanes`** (re-ruled 2026-07-29 — lanes trash too). /// The two are separate arrays rather than one list of a sum type because they are separate /// *things*: a trashed card is an ordinary card that every card-shaped surface already reads, /// and a trashed lane is an opaque row that none of them may. Interleaving the two is a /// rendering question (03-board-ui.md § Trash: "lane rows and cards interleave in the one trash /// column by `modified` descending"), and both arrays carry the stamp that answers it — /// `BoardModel.trashEntries` is the one merge. /// /// **Display order is `modified` descending** — `Ranks.sortedForTrash`, tie-broken by title then /// folder name (re-ruled 2026-07-31, retiring the arrival rank mint). The trash is the one /// container in the app whose sequence is not a rank sequence: the delete move stamps, and that /// stamp *is* the position, with each entry's `order` riding along untouched for its restore. /// There is deliberately no `deleted:` key on anything in here — a trashed card is an ordinary /// card in a special place. /// /// Empty when `.trash/` is absent (the overwhelmingly common case — the folder is minted by /// the first delete), and empty when it holds nothing the loader recognizes as a card. /// /// **Defaulted so a snapshot can be built without one.** The one construction site is /// `BoardLoader.load`, which always supplies it; the default keeps the memberwise initializer /// usable from tests and future fixtures that have no trash to describe. public var trash: [Card] = [] /// The board's materialized trash, **lane side**: the lane folders sitting directly in /// `/.trash/`, in display order (03-board-ui.md § Trash, re-ruled 2026-07-29 — "Lanes /// trash too", retiring the design's sole destructive delete). /// /// See `TrashedLane` for why a trashed lane is not a `Lane`, and `trash` for why the container's /// two kinds are two arrays. public var trashedLanes: [TrashedLane] = [] /// The full parsed `index.md`. Unknown/reserved keys (`labels`, `assignees`, `due`, /// `remote`, …) ride along uninterpreted via `document.unknownFields` so a future writer /// can round-trip them without this model knowing what they mean. public let document: FrontmatterDocument /// The board description (free Markdown) — equivalent to `document.body`. public var body: String { document.body } } /// A lane: `//index.md` plus every card beneath it. public struct Lane: Identifiable, Sendable, Equatable { public let id: ItemID public let schema: Int public let title: FieldValue public let created: FieldValue public let modified: FieldValue public let modifiedBy: FieldValue public let deleted: FieldValue public let background: FieldValue public let icon: FieldValue public let iconColor: FieldValue /// Rank among lanes, ascending = left-to-right. Strict, per the frontmatter table's /// required field: `BoardLoader` fails the whole load (`missingOrder`/`malformedOrder`) /// rather than construct a `Lane` with a bad `order` — by the time one exists here it is /// always valid. Validity is the loader's job, not this type's; that is why it is a plain /// `Double` and not `FieldValue`. public let order: Double /// Width multiplier ≥ 1 (default 1 when missing or malformed). Lenient — it styles layout, /// it isn't structure — so a malformed value is preserved (`.malformed`) rather than /// failing the load (01-storage-format.md § Frontmatter). public let width: FieldValue /// Cards in this lane, in display order (`Ranks.sortedForDisplay`, folder-name tie-break). /// A card still carrying a legacy `deleted:` key rides along flagged (`Card.isDeleted`) until /// its migration relocates it into `BoardModel.trash` — see `BoardModel.lanes`. public let cards: [Card] /// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted. public let document: FrontmatterDocument /// The lane description / WIP policy / notes — equivalent to `document.body`. public var body: String { document.body } /// Whether this lane carries a legacy `deleted:` key — presence, not validity /// (`FieldValue.isMissing` already treats an explicit `deleted: null` as absent). /// /// **Retired, and on a lane the key is now inert** (01-storage-format.md § Deletion, lane clause /// re-ruled 2026-07-29): the app never writes `deleted:`, and a lane found carrying one "simply /// loads live with the key ignored — no migration machinery, no key-strip write, no notice", /// preserved verbatim like any unhandled key and logged (`LoadWarning.laneLevelDeletedIgnored`). /// So this reads the key without meaning anything by it: nothing hides a lane, nothing rewrites /// it, and the field survives only so the value round-trips. public var isDeleted: Bool { !deleted.isMissing } } /// A card: `///index.md`, plus the *names* of its attachments. Structurally /// still a leaf — `comments/` (future, out-of-scope) and the attachment files' contents live /// alongside `index.md` on disk and are not modeled here; `attachments` is the one thing the /// snapshot reaches inside a card folder for, because two board-window surfaces need it before /// any card window exists (see its own doc comment). public struct Card: Identifiable, Sendable, Equatable { public let id: ItemID public let schema: Int public let title: FieldValue public let created: FieldValue public let modified: FieldValue public let modifiedBy: FieldValue public let deleted: FieldValue public let background: FieldValue public let icon: FieldValue public let iconColor: FieldValue /// Rank within its lane, ascending = top-to-bottom. Strict — see `Lane.order`'s doc /// comment; the same reasoning applies here. public let order: Double /// The card's attachment file names — **flat: top-level regular files only, in Finder /// order** (01-storage-format.md § Attachments: "the app's attachment surfaces … are flat: /// top-level files only", and "subfolders are tolerated, preserved verbatim, never created /// by the app, and not surfaced"). Empty when the card has no `attachments/` folder, and /// empty when it has one that couldn't be listed — the field is cosmetic, so a /// directory-listing race degrades to "nothing to show" rather than failing a load. /// /// Names, not URLs: the board-window consumer only needs to know *whether*, *how many*, and /// in *what order* — the face's quiet paperclip indicator (03-board-ui.md § Card face). The /// card window's sidebar does its own listing through `BoardWriter.listAttachments`, since it /// acts on the files rather than showing them; that call answers through the very same /// enumeration (`BoardLoader.attachmentNames(in:)`), so the two surfaces agree by /// construction. /// /// Freshness is the watcher's, by construction: it reloads on any change anywhere under the /// board root, so an attachment added in Finder rebuilds the snapshot exactly like an edited /// `index.md` does — no separate invalidation path to keep honest. public let attachments: [String] /// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted. public let document: FrontmatterDocument /// The card's content — the whole point. Equivalent to `document.body`. public var body: String { document.body } /// A tombstoned card. See `Lane.isDeleted`'s doc comment — the same "presence, not /// validity" rule applies here. A card's key is still migration input (a card carrying it /// relocates into `.trash/` with the key removed), which is the one half of the legacy rule /// that survives; a trashed card carries no `deleted:` key at all, so this reads `false` for /// every card in the container that replaced the flag. public var isDeleted: Bool { !deleted.isMissing } } /// A lane in the board's trash: `/.trash//index.md` — **an opaque unit** /// (03-board-ui.md § Trash, re-ruled 2026-07-29: "A trashed lane is an opaque unit: one distinct /// dimmed row showing its title and held-card count … never expandable; its cards are invisible to /// search and not individually addressable — it restores whole or purges whole"). /// /// ### Why it is not a `Lane` /// /// A `Lane` carries its cards, its styling and its width because the board renders all three. None /// of that is true here: the row shows a title and a count, takes no styling accents, and its /// subtree is deliberately **not walked into the snapshot** — the loader stops at the trash entry /// exactly as it stops at a card under a lane. A `Lane` with an empty `cards` array would be a lie /// the first consumer to read it would believe; this type can only answer what the design says the /// row knows. /// /// The kind itself is `kind:`'s to answer, never position's: the trash is flat, and an empty lane /// folder is shape-identical to a card folder (01-storage-format.md § Deletion — /// `IntegrityRules.trashKind`). public struct TrashedLane: Identifiable, Sendable, Equatable { public let id: ItemID public let schema: Int public let title: FieldValue /// **When the row entered the trash** — and therefore *where it sits*: the trash sorts by /// `modified` descending (01-storage-format.md § Deletion, re-ruled 2026-07-31), and the trash /// move is the container-changing write that stamps it. Missing on a foreign mover that skipped /// the restamp, which sorts it below every dated sibling (`Ranks.isOrderedForTrash`). public let modified: FieldValue /// The lane's rank **among the board's lanes**, riding along untouched — the trash move rewrites /// no `order` at all, so this is still the strip position a restore would want and the value the /// undo of a restore puts back. It is deliberately *not* what orders this row in the column /// (`modified` is), and the same required, strictly validated field a live lane carries /// (`Lane.order`). public let order: Double /// **How many cards the lane is holding** — the row's whole other half ("Doing — 5 cards"). /// /// Counted from disk at load, never derived from a walked subtree: the count is the one fact /// about the freight the snapshot carries, and counting is what keeps the entry opaque. The /// unit counted is what the loader *would* render as a card — an identity-shaped child holding /// its own `index.md` — so the row's number and a restore's outcome agree. public let heldCards: Int /// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted, so a restore /// (an ordinary move out) returns the lane exactly as it went in. public let document: FrontmatterDocument }