Accept liberally, emit conservatively (764a4d4): the gate is 8-4-4-4-12
hex in any case and any UUID version — uuidgen and UUID().uuidString
print uppercase, and a strict lowercase gate would silently stray an
agent's standard-tool card. Identity comparison is UUID-value equality
everywhere: ItemID keeps its byte-faithful rawValue but equates and
hashes on the lowercased canonical form, and the writer's
import-boundary collision check canonicalizes, so a same-UUID arrival
spelled in another case remints instead of slipping past. The app still
mints only lowercase v4 and never renames to canonicalize.
Full suite 342 tests in 63 suites green. Two findings filed.
Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
184 lines
9.1 KiB
Swift
184 lines
9.1 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`. 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<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 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 }
|
|
}
|