import Foundation // MARK: - ItemContainer /// Which of the board's two **card containers** something sits in — the board's lanes, or the /// board's `.trash/`. /// /// **This is the materialized trash's replacement for `Liveness`** (02-architecture.md § Changes /// from Kanban, resettled 2026-07-28; 03-board-ui.md § Trash). Deletion is a *move* now, so there is /// no flag to read, no ancestor to walk, and no "effective liveness" to compute: an item is in a /// container or it is not, and which container is a fact about where its folder sits on disk. The /// tombstone model's two-sided machinery — the absolute ancestor walk, the entry-vs-universe split, /// kind-homogeneity inside the trash — is retired wholesale with it. /// /// **An item-referencing set carries one of these** (`ItemReferenceSet`), because 04-interactions.md /// ▸ The trash keeps exactly one boundary: "a selection never mixes trash cards with board cards — a /// single container rule replacing the old liveness law, because Delete would otherwise mean two /// different things in one gesture (move-to-trash vs permanent)". /// /// **Lanes live only on the board side.** "Cards only. Lanes are never trashed" (03-board-ui.md § /// Trash), so the trash's universe is cards and nothing else — which is why the trash needs no /// kind axis of its own any more. /// /// `String`-backed and `Codable` because the clipboard manifest carries one: a manifest written /// before a quit is decoded after the relaunch, so these raw spellings are pasteboard API, and they /// are the case names so nothing has to remember a second vocabulary. public enum ItemContainer: String, Codable, Sendable, Equatable, CaseIterable { /// The board proper — every lane, and every card inside a lane. case board /// `/.trash/` — the reserved container deletion moves cards into. case trash } extension ItemContainer { /// Every id `snapshot` holds in this container — **the universe** every item-referencing set is /// held to (02-architecture.md § Live-reload resilience: "re-resolution matches UUID *and* /// container side ... presence in the snapshot is the whole question"). /// /// One walk, no filtering: the board side is the lanes plus their cards, the trash side is /// `snapshot.trash`. There is deliberately no liveness predicate anywhere in here — a legacy /// `deleted:` key still riding in from an unmigrated board (`BoardLoader`'s migration window) /// names an ordinary board card until its folder actually moves, which is the safe direction and /// the one the migration then takes (01-storage-format.md § Deletion). public func ids(in snapshot: BoardModel) -> Set { var universe: Set = [] switch self { case .board: for lane in snapshot.lanes { universe.insert(lane.id) for card in lane.cards { universe.insert(card.id) } } case .trash: for card in snapshot.trash { universe.insert(card.id) } } return universe } } // MARK: - ItemPath /// Where an item's folder sits under a board root, as identity components rather than as a URL. /// /// **Components, not a URL**, for the reason every path-shaped value in this app is: the caller /// builds the URL off the store's *current* `rootURL`, so a board renamed or moved mid-session /// writes at the new location (02-architecture.md § Write-failure surfacing). /// /// **Three cases, because the board has exactly three places an identity-bearing folder can be** — /// `/`, `//`, and `/.trash/`. The old two-optional-fields /// shape could spell a fourth thing that does not exist; this cannot. public enum ItemPath: Sendable, Equatable { /// A lane: `//`. case lane(ItemID) /// A card in a lane: `///`. case card(lane: ItemID, id: ItemID) /// A card in the board's trash: `/.trash//`. case trashCard(ItemID) /// The item this path names. public var id: ItemID { switch self { case let .lane(id): id case let .card(_, id): id case let .trashCard(id): id } } public var isLane: Bool { if case .lane = self { return true } return false } /// Which container this path is in — the board for a lane or a lane's card, the trash for a /// trash card. Derived rather than stored: the case *is* the answer. public var container: ItemContainer { if case .trashCard = self { return .trash } return .board } /// This path resolved under a board root. public func folder(under root: URL) -> URL { switch self { case let .lane(id): root.appendingPathComponent(id.rawValue, isDirectory: true) case let .card(lane, id): root .appendingPathComponent(lane.rawValue, isDirectory: true) .appendingPathComponent(id.rawValue, isDirectory: true) case let .trashCard(id): BoardWriter.trashFolder(inBoard: root) .appendingPathComponent(id.rawValue, isDirectory: true) } } } extension ItemPath { /// The folders `ids` names inside one container, **in display order**. /// /// Display order — lanes left to right, each lane then its cards; the trash top to bottom — /// rather than the caller's set iteration order, which is not an order at all: a batch that /// fails partway must fail the same way twice (`BoardStore.styleSubjects` makes the same choice /// for the same reason). /// /// Ids the container does not hold are simply absent, which is every caller's standing posture: /// a selection the next reload will drop writes nothing rather than being refused. public static func resolve( _ ids: Set, in container: ItemContainer, snapshot: BoardModel ) -> [ItemPath] { guard !ids.isEmpty else { return [] } var result: [ItemPath] = [] switch container { case .board: for lane in snapshot.lanes { if ids.contains(lane.id) { result.append(.lane(lane.id)) } for card in lane.cards where ids.contains(card.id) { result.append(.card(lane: lane.id, id: card.id)) } } case .trash: for card in snapshot.trash where ids.contains(card.id) { result.append(.trashCard(card.id)) } } return result } /// Where one id lives, searching both containers — `nil` when the snapshot does not hold it. /// /// The board is searched first because that is where the overwhelming majority of lookups land; /// an id can only be in one container anyway (board-wide uniqueness spans both — /// 01-storage-format.md § Fractal layout ▸ Rules). public static func of(_ id: ItemID, in snapshot: BoardModel) -> ItemPath? { for lane in snapshot.lanes { if lane.id == id { return .lane(id) } if lane.cards.contains(where: { $0.id == id }) { return .card(lane: lane.id, id: id) } } return snapshot.trash.contains { $0.id == id } ? .trashCard(id) : nil } }