The card face becomes real: leading SF Symbol (card default doc.text, tinted by a valid hand-written iconColor — schema yes, control no), title or the quiet untitled placeholder, and a quiet paperclip when the card has attachments — title-only by design, no body excerpt. Color is the settled K1 edge accent, not a fill: background paints a 4pt stripe down the left edge, resolved through the ported pathfinder palette (12 icon tints + 12 backgrounds carried over verbatim, plus raw #RRGGBB[AA]); anything unresolvable paints nothing and stays on disk exactly as written. The snapshot now carries each card's flat attachment names — the loader's one read inside a card folder, shared with the Writer's listing so the m5 carousel and m6 sidebar can never disagree on order (Finder order, the Writer's existing comparator). The face keeps its top-aligned structure so the sole-selection carousel can expand inside the card without moving masonry neighbors. 18 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
205 lines
11 KiB
Swift
205 lines
11 KiB
Swift
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<ItemID>` 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. Computed rather than stored so `ItemID` stays
|
|
/// one string wide and `rawValue` remains the single source of truth for what is on disk.
|
|
/// `lowercased()` is locale-independent, and every identity-shaped name is ASCII, so this is
|
|
/// UUID-value canonicalization and nothing more.
|
|
var canonicalValue: String { rawValue.lowercased() }
|
|
|
|
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: `<root>/index.md` plus every lane beneath it. `<root>` 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<String>
|
|
public let created: FieldValue<Date>
|
|
public let modified: FieldValue<Date>
|
|
public let modifiedBy: FieldValue<String>
|
|
|
|
/// 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<Date>
|
|
|
|
public let background: FieldValue<String>
|
|
public let icon: FieldValue<String>
|
|
public let iconColor: FieldValue<String>
|
|
|
|
/// `{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: `<root>/<guid>/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<String>
|
|
public let created: FieldValue<Date>
|
|
public let modified: FieldValue<Date>
|
|
public let modifiedBy: FieldValue<String>
|
|
public let deleted: FieldValue<Date>
|
|
public let background: FieldValue<String>
|
|
public let icon: FieldValue<String>
|
|
public let iconColor: FieldValue<String>
|
|
|
|
/// 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<Double>`.
|
|
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<Int>
|
|
|
|
/// 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: `<root>/<guid>/<guid>/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<String>
|
|
public let created: FieldValue<Date>
|
|
public let modified: FieldValue<Date>
|
|
public let modifiedBy: FieldValue<String>
|
|
public let deleted: FieldValue<Date>
|
|
public let background: FieldValue<String>
|
|
public let icon: FieldValue<String>
|
|
public let iconColor: FieldValue<String>
|
|
|
|
/// 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 two board-window consumers only need to know *whether*, *how many*,
|
|
/// and in *what order* — the face's quiet paperclip indicator (03-board-ui.md § Card face)
|
|
/// and the sole-selected card's attachment carousel. 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.
|
|
public var isDeleted: Bool { !deleted.isMissing }
|
|
}
|