From c7cc97aeec7db12a7cd04edfa8d7ad4c982b8165 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 26 Jul 2026 15:40:45 -0400 Subject: [PATCH] Define the board, lane, and card model types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Immutable snapshot value types encoding the frontmatter tables: ItemID wraps the exact folder name (not Foundation UUID, which uppercases); lanes/cards carry FieldValue-typed fields, display-ordered children including flagged tombstones, and their full FrontmatterDocument so unknown and reserved keys ride along uninterpreted. Boards are identified by rootURL — package folders are human-named, not UUIDs. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A --- Kanban/Storage/BoardModel.swift | 151 ++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 Kanban/Storage/BoardModel.swift diff --git a/Kanban/Storage/BoardModel.swift b/Kanban/Storage/BoardModel.swift new file mode 100644 index 0000000..3114e82 --- /dev/null +++ b/Kanban/Storage/BoardModel.swift @@ -0,0 +1,151 @@ +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. +/// +/// Folder names are lowercase UUIDv4 by convention (01-storage-format.md § Fractal layout ▸ +/// Rules) — "lowercase UUIDv4, immutable, never renamed" — but the model stores whatever the +/// folder is actually named. It must round-trip byte-perfect (it is the primary key) and is +/// the display-order tie-break (`Ranks.sortedForDisplay`). Deliberately **not** Foundation's +/// `UUID`, which normalizes to uppercase and would silently corrupt that round-trip. +/// +/// 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 + } +} + +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) — **including + /// tombstoned lanes**, which stay in the snapshot flagged (`Lane.isDeleted`) for the trash + /// view (01-storage-format.md § Deletion). + public let lanes: [Lane] + + /// 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) + /// — **including tombstoned cards**, which stay in the snapshot flagged (`Card.isDeleted`) + /// for the trash view. + 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 } + + /// A tombstoned lane: the `deleted` key is *present* (valid or malformed), not merely + /// absent. Deliberate: a malformed timestamp still hides the lane from the board — the + /// key's presence is what encodes deletion intent, a missing key is the only thing that + /// means "not deleted" (`FieldValue.isMissing` already treats an explicit `deleted: null` + /// as absent, matching the engine's own null-is-missing rule). + public var isDeleted: Bool { !deleted.isMissing } +} + +/// A card: `///index.md`. Leaf of the fractal tree — `attachments/` and the +/// (future, out-of-scope) `comments/` live alongside `index.md` on disk but are not modeled +/// here. +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 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. + public var isDeleted: Bool { !deleted.isMissing } +}