import Foundation // MARK: - TrashModel /// What is left of the trash as a *model* once the trash became a folder — 03-board-ui.md § Trash's /// **materialized** container (resettled 2026-07-28). /// /// ### Almost nothing, and that is the point of the pivot /// /// The tombstone model needed a whole derivation layer: an entry type, an absolute ancestor walk, a /// returning-card count, a deterministic `deleted`-timestamp sort, and a paths function that had to /// restate which items were addressable. All of it is gone. A trashed card is "an ordinary card in a /// special place", so the trash's contents *are* `snapshot.trash` and `snapshot.trashedLanes` — /// already parsed by the loader, already in the column's own order, already newest-first because /// every arrival stamps `modified` and the container sorts by it descending (re-ruled 2026-07-31). /// There is nothing to derive, and no second definition to keep in step with the loader's. /// /// What genuinely remains is what the *commands* need and no view can answer: the two purge /// confirmations' phrasing — which since lanes rejoined the trash (2026-07-29) has to **count the /// freight** a trashed lane carries — and the menu validation that stages Delete by place. Both are /// pure functions of a snapshot and a selection (`TrashModelTests`), so an alert's sentence is /// testable without an alert on screen. The one number that is not derivable from the snapshot's /// own shape is the freight itself, and it does not need deriving: the loader counted it at load /// (`TrashedLane.heldCards`), because the subtree it counts is deliberately not in the snapshot. public enum TrashModel { // MARK: - Counts and phrasing /// "41 cards", "1 card" — 06-history-undo.md's **plural folding**. public static func phrase(_ count: Int) -> String { "\(count) card\(count == 1 ? "" : "s")" } /// "2 lanes", "1 lane" — the same folding for the container's other kind (03-board-ui.md /// § Trash, lanes rejoined 2026-07-29). public static func lanePhrase(_ count: Int) -> String { "\(count) lane\(count == 1 ? "" : "s")" } /// **What a purge is about to destroy**, counted honestly: the entries themselves, and the cards /// a trashed lane is carrying (03-board-ui.md § Trash: "Confirms name the freight honestly"). /// /// A value rather than three returns because every phrasing below asks the same three questions, /// and because "how many cards does this lose" is `cards + freight` in one place rather than at /// each call site. public struct Freight: Sendable, Equatable { /// Trash entries that are cards. public let cards: Int /// Trash entries that are lanes. public let lanes: Int /// The cards those lanes are holding — `TrashedLane.heldCards`, summed. public let heldCards: Int public var isEmpty: Bool { cards == 0 && lanes == 0 } } /// The freight of a set of resolved trash paths. public static func freight(of paths: [ItemPath], in snapshot: BoardModel) -> Freight { var cards = 0 var lanes = 0 var heldCards = 0 for path in paths { switch path { case .trashCard: cards += 1 case .trashLane: lanes += 1 heldCards += snapshot.trashedLanes.first { $0.id == path.id }?.heldCards ?? 0 case .lane, .card: // A board path is not this command's business; the callers resolve in `.trash` and // never produce one. Counted as nothing rather than refused, the vanished-target // shrug this file gives everywhere. continue } } return Freight(cards: cards, lanes: lanes, heldCards: heldCards) } /// **The aggregate subject both confirmations share** — 03-board-ui.md § Trash's own example /// phrasings, with 06-history-undo.md's plural folding: "41 cards", "41 cards and 2 lanes /// containing 9 more cards". /// /// The lane clause says **"more"** only when cards were already counted, because that is the /// only reading in which the word means anything; a lane holding nothing contributes no clause /// of its own, since "2 lanes containing 0 cards" says less than "2 lanes". public static func subject(for freight: Freight) -> String { var clauses: [String] = [] if freight.cards > 0 { clauses.append(phrase(freight.cards)) } if freight.lanes > 0 { var clause = lanePhrase(freight.lanes) if freight.heldCards > 0 { let more = freight.cards > 0 ? "more " : "" clause += " containing \(freight.heldCards) \(more)card\(freight.heldCards == 1 ? "" : "s")" } clauses.append(clause) } return clauses.joined(separator: " and ") } // MARK: - Confirmations /// A purge confirmation's three strings, built once and rendered by the window's alert. /// /// A value rather than a view so the phrasing rules — plural folding, naming a sole item, and /// whether the loss is actually irreversible — are testable without an alert on screen. public struct PurgePrompt: Sendable, Equatable { public let title: String public let message: String public let confirmTitle: String } /// The alert in front of a **permanent** delete — the trash's own ⌫/⌘⌫ (03-board-ui.md § /// Trash: "confirms exactly where the loss is real: the alert stands between one keystroke and /// unrecoverable deletion"). /// /// A sole entry is **named**; several fold into a count. A sole trashed **lane** names its /// freight as well — "Permanently delete lane 'Doing' and its 5 cards" is the design's own /// phrasing, and the word *lane* is in it because an opaque row's title alone would not say what /// the extra five cards are doing in the sentence. /// /// **A mixed set is not a gesture this app can produce** — a selection is kind-homogeneous on /// both axes (04-interactions.md ▸ The trash) — but the aggregate phrasing covers one anyway /// rather than picking a kind to lie about: it is the same sentence Empty Trash builds. /// /// `nil` when the ids name nothing in the trash, which is also the command's own refusal — so /// the prompt and the action can never disagree about whether there is anything to purge. public static func purgePrompt( for ids: Set, snapshot: BoardModel, unrecoverable: Bool ) -> PurgePrompt? { let targets = ItemPath.resolve(ids, in: .trash, snapshot: snapshot) guard !targets.isEmpty else { return nil } let subject: String if targets.count == 1, let only = targets.first { let name = "\u{201C}\(displayName(of: only, in: snapshot))\u{201D}" if case let .trashLane(id) = only { let held = snapshot.trashedLanes.first { $0.id == id }?.heldCards ?? 0 subject = held > 0 ? "lane \(name) and its \(phrase(held))" : "lane \(name)" } else { subject = name } } else { subject = self.subject(for: freight(of: targets, in: snapshot)) } return PurgePrompt( title: "Permanently delete \(subject)?", message: message(unrecoverable: unrecoverable), confirmTitle: "Delete" ) } /// Empty Trash…'s alert — **always shown** ("Empty Trash… confirms everywhere"), and always /// naming the **true count**: every entry in `.trash/`, never the filtered view (03-board-ui.md § /// Trash: "search-independent, the confirmation naming the full count"). /// /// Counts rather than names even for a single card, because the command is about the trash /// rather than about an item: "Permanently delete 41 cards" and "… 41 cards and 2 lanes /// containing 9 more cards" are the design's own example phrasings, and the second is why the /// lane freight is counted rather than left implied — a bulk permanent delete must not /// understate what it takes. public static func emptyTrashPrompt(in snapshot: BoardModel, unrecoverable: Bool) -> PurgePrompt? { let freight = Freight( cards: snapshot.trash.count, lanes: snapshot.trashedLanes.count, heldCards: snapshot.trashedLanes.reduce(0) { $0 + $1.heldCards } ) guard !freight.isEmpty else { return nil } return PurgePrompt( title: "Permanently delete \(subject(for: freight))?", message: message(unrecoverable: unrecoverable), confirmTitle: "Delete" ) } /// The alert's body: whether any of it comes back. /// /// The tombstone era's second sentence — "Deleting a lane also deletes every card inside it" — /// stays gone, and now for a better reason than "no purge reaches a lane": the *title* carries /// the freight explicitly ("and its 5 cards"), which is 03's own phrasing and says the same /// thing where the user is already reading. private static func message(unrecoverable: Bool) -> String { // m7-git: on a git board the content stays reachable in history, so the second sentence is // the honest one — and the trash's own Delete does not confirm there at all // (`BoardStore.purgeIsUnrecoverable`). unrecoverable ? "This can\u{2019}t be undone." : "The board\u{2019}s history still has them." } /// What to call an item in a prompt — its title, or the "Untitled" rendering. /// /// Total by construction: a path whose item has gone since the prompt was asked for reads /// "Untitled" rather than failing, which is the same shrug every other vanished-target rule in /// the app gives. private static func displayName(of path: ItemPath, in snapshot: BoardModel) -> String { switch path { case let .lane(id): return snapshot.lanes.first { $0.id == id }?.title.value ?? "Untitled" case let .card(lane, id): return snapshot.lanes.first { $0.id == lane }? .cards.first { $0.id == id }?.title.value ?? "Untitled" case let .trashCard(id): return snapshot.trash.first { $0.id == id }?.title.value ?? "Untitled" case let .trashLane(id): return snapshot.trashedLanes.first { $0.id == id }?.title.value ?? "Untitled" } } // MARK: - Menu validation /// Whether File ▸ Delete has something to act on — **staged by place, but validated once** /// (04-interactions.md ▸ The map, resettled 2026-07-28: "File ▸ Delete is the chord's only /// owner — no twin menu items, no shared-equivalent routing"). /// /// One predicate for both stagings, because there is only one item now: a board selection moves /// into the trash, a trash selection deletes permanently, and the command is enabled whenever /// either names something the board still holds. The old mirror-image pair /// (which existed to make two ⌘⌫ twins enable exactly one of themselves) retired with Put Back. public static func canDelete(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool { !ItemPath.resolve(selection.ids, in: selection.container, snapshot: snapshot).isEmpty } }