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)". /// /// **Both kinds live on both sides** (03-board-ui.md § Trash, re-ruled 2026-07-29 — "Lanes trash /// too"): the trash's universe is its cards *and* its trashed lanes, flat and interleaved by rank. /// What the tombstone model needed and this does not is the old two-sided machinery — the ancestor /// walk, effective liveness, the entry-vs-universe split — not the kind axis, which is the board's /// own cards-XOR-lanes rule reaching a second container. /// /// `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 its /// cards **and its trashed lanes** — every id the container holds, which is what a universe is. /// A trashed lane's own cards are deliberately not in it: they are not in the snapshot at all /// (the entry is opaque — 03-board-ui.md § Trash), so a selected or cut card whose lane was /// trashed leaves every referencing set by the ordinary vanish rule, with no clause of its own. /// /// 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) } for lane in snapshot.trashedLanes { universe.insert(lane.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). /// /// **Four cases, because the board has exactly four kinds of place an identity-bearing folder can /// be** — `/`, `//`, and, since lanes rejoined the trash (2026-07-29), /// `/.trash/` and `/.trash/`. The old two-optional-fields shape could spell /// things that do not exist; this cannot. /// /// **The trash's two cases share a path *shape* and differ in kind**, which is exactly the fact /// `kind:` exists to record (01-storage-format.md § Deletion): `.trash/` is flat, so the path alone /// cannot say what an entry is, and a caller that has resolved one through the snapshot knows — /// which is why the kind is in the case rather than re-derived from disk at every use. 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) /// A lane in the board's trash: `/.trash//` — an opaque unit, subtree intact /// (03-board-ui.md § Trash). case trashLane(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 case let .trashLane(id): id } } /// Whether this path names a lane, **in either container** — the kind axis, which the board's /// cards-XOR-lanes rule asks about on both sides of the container boundary. public var isLane: Bool { switch self { case .lane, .trashLane: true case .card, .trashCard: false } } /// Which container this path is in — the board for a lane or a lane's card, the trash for /// either kind of trash entry. Derived rather than stored: the case *is* the answer. public var container: ItemContainer { switch self { case .trashCard, .trashLane: .trash case .lane, .card: .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), let .trashLane(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). /// /// **The trash's order interleaves its two kinds by rank** (03-board-ui.md § Trash: "lane rows /// and cards interleave in the one trash column purely by trash rank"), which is why the two /// arrays are merged here rather than concatenated: the column's order is the batch's order, and /// a selection is kind-homogeneous anyway, so the merge costs nothing the one time it matters. /// /// 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: let entries = snapshot.trash.map { (order: $0.order, path: ItemPath.trashCard($0.id)) } + snapshot.trashedLanes.map { (order: $0.order, path: ItemPath.trashLane($0.id)) } for entry in Ranks.sortedForDisplay(entries, order: \.order, name: { $0.path.id.rawValue }) where ids.contains(entry.path.id) { result.append(entry.path) } } 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) } } if snapshot.trash.contains(where: { $0.id == id }) { return .trashCard(id) } return snapshot.trashedLanes.contains { $0.id == id } ? .trashLane(id) : nil } }