import Foundation // MARK: - TrashEntry /// One row of the trash quasi-lane (03-board-ui.md § Trash). /// /// **Two cases, not one**, because a tombstoned lane is not a tombstoned card wearing a different /// symbol: it is "a single restorable entry" that *subsumes* everything beneath it, and Put Back on /// it "returns [the lane] whole, cards and all" (04-interactions.md ▸ The trash). The card count it /// carries is part of the entry rather than something the row re-derives, because the rule for what /// that number means is subtle enough to want one home — see `TrashModel.entries(of:)`. /// /// The card case carries its lane's id for the same reason `BoardStore.liveItem` returns two /// components rather than a URL: the row's folder is `//`, and the root is the /// store's to supply — a mid-session folder rename may have moved it. public enum TrashEntry: Identifiable, Sendable, Equatable { /// A card carrying its own `deleted:` under a **live** lane. A card whose lane is tombstoned /// never becomes one of these — see `TrashModel.entries(of:)`'s ancestor walk. case card(Card, laneID: ItemID) /// A tombstoned lane, and how many of its cards Put Back would return to the board. case lane(Lane, returningCardCount: Int) public var id: ItemID { switch self { case let .card(card, _): card.id case let .lane(lane, _): lane.id } } /// The title as written, or `nil` for an untitled item — "Untitled" is a rendering, never a /// value (03-board-ui.md § Card face). public var title: String? { switch self { case let .card(card, _): card.title.value case let .lane(lane, _): lane.title.value } } /// Which kind of row this is — the axis 04-interactions.md ▸ The trash makes a selection /// homogeneous over ("a selection never mixes card entries and lane entries"). public var isLaneEntry: Bool { if case .lane = self { return true } return false } /// The `icon` field this row renders, so the row's symbol obeys the same lenient rule the board /// face does (`ItemSymbol`). public var icon: FieldValue { switch self { case let .card(card, _): card.icon case let .lane(lane, _): lane.icon } } /// Where this row's folder sits under the board root. public var path: TrashModel.ItemPath { switch self { case let .card(card, laneID): TrashModel.ItemPath(laneID: laneID, cardID: card.id) case let .lane(lane, _): TrashModel.ItemPath(laneID: lane.id, cardID: nil) } } } // MARK: - TrashModel /// The trash quasi-lane's contents, as a pure function of a snapshot (`TrashModelTests`) — /// 03-board-ui.md § Trash's Contents rules with nothing else mixed in. /// /// **Pure because the trash is a pure view.** "Tombstoned cards keep their `deleted:` key and stay /// exactly where they are on disk; nothing about the storage schema is trash-specific", so there is /// no trash *state* anywhere — only this derivation, re-run against whatever snapshot is current. /// A reload therefore rebuilds the rows for free, exactly as it rebuilds the lanes. /// /// The three rules it owns, each of which the design settles explicitly: /// /// 1. **The absolute ancestor walk.** A tombstoned lane's entry subsumes everything beneath it — "a /// card that carries its own `deleted:` under a tombstoned lane has **no row of its own**". There /// is no trash carve-out from 01-storage-format.md's consumer rule. This rule is not spelled /// here: it is `Liveness.walk`'s, because the trashed **universe** every item-referencing set is /// held to *is* this row set — "universe and rows are one function" (02-architecture.md § Changes /// from Kanban, settled). Everything below that asks what is in the trash asks that walk, so the /// rows a user sees and the ids a selection may hold cannot drift apart. /// 2. **The returning count.** A lane entry's number "counts what Put Back returns to the board — /// cards without their own flag; individually tombstoned descendants aren't in that number, since /// they come back to the *trash*". /// 3. **The deterministic sort.** Newest `deleted` first; ties by folder name ascending; an /// unparseable stamp sorts as *oldest*, after every dated entry, folder-name-ordered among its /// kind; lane entries interleave in the same single ordering by their own stamp. The order is /// load-bearing for input — "arrow walks, ⇧-ranges, and the rubber band all read it" — so it is /// total, not merely stable. public enum TrashModel { // MARK: - Where a row lives /// An item's folder, as its identity components rather than as a URL. /// /// Same shape and same reasoning as `BoardStore.liveItem`'s return: 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). public struct ItemPath: Sendable, Equatable { public let laneID: ItemID /// `nil` for a lane — the path is then the lane folder itself. public let cardID: ItemID? public init(laneID: ItemID, cardID: ItemID?) { self.laneID = laneID self.cardID = cardID } public var isLane: Bool { cardID == nil } /// This path resolved under a board root. public func folder(under root: URL) -> URL { var url = root.appendingPathComponent(laneID.rawValue, isDirectory: true) if let cardID { url.append(component: cardID.rawValue, directoryHint: .isDirectory) } return url } } // MARK: - Entries /// The trash's rows, in the order the quasi-lane shows them. /// /// **Which items are rows is not decided here** — it is `Liveness.trashed.walk`, the same walk /// `ItemReferenceSet.idUniverse(of:on:)` reads, because the trashed universe and this row set are /// one function. Its `continue` on a tombstoned lane is the absolute ancestor walk: such a lane /// contributes exactly one entry and its cards contribute none, whatever their own flags say. /// /// What is left for this function is what a row *carries* — the returning count and the sort /// inputs — and the order it shows in, neither of which any other caller of the walk wants. public static func entries(of snapshot: BoardModel) -> [TrashEntry] { var rows: [Row] = [] Liveness.trashed.walk(snapshot) { lane, card in if let card { rows.append(Row( entry: .card(card, laneID: lane.id), deleted: card.deleted.value, name: card.id.rawValue )) } else { // Rule 2: only the cards *without* their own flag come back with the lane. The ones // that carry a flag stay tombstoned and get their rows back in the trash — which is // why Put Back on such a card is deliberately two steps. let returning = lane.cards.filter { !$0.isDeleted }.count rows.append(Row( entry: .lane(lane, returningCardCount: returning), deleted: lane.deleted.value, name: lane.id.rawValue )) } } return rows.sorted(by: isOrdered).map(\.entry) } /// Whether the board has anything in its trash at all — the "non-empty" half of Empty Trash…'s /// menu validation, which "reads the board's tombstones, not the filtered view" (03 ▸ Trash). /// /// The walk's non-emptiness in **short-circuit form**, which is the one place the rule is /// restated and only because stopping early is the whole point: a tombstoned lane is a row /// outright, and under a live lane any own-flagged card is one. There is deliberately no third /// clause for a card beneath a tombstoned lane — the lane has already answered `true` for it. /// `TrashModelTests` pins the equivalence to `entries(of:).isEmpty` so the shortcut cannot drift. public static func isEmpty(_ snapshot: BoardModel) -> Bool { !snapshot.lanes.contains { lane in lane.isDeleted || lane.cards.contains(where: \.isDeleted) } } /// One entry's sort inputs, kept beside it so the comparator never re-reads the model. private struct Row { let entry: TrashEntry /// The parsed `deleted` stamp, or `nil` when the value is present but unparseable — /// 01-storage-format.md's unusable-timestamp rule, which still deletes (presence, not /// validity) but supplies no position in time. let deleted: Date? /// The folder name, byte-for-byte — the tie-break the loader's display order already uses /// (`Ranks.sortedForDisplay`, `name: { $0.id.rawValue }`), so the trash breaks ties the same /// way the board does. let name: String } /// The sort, stated once: newest first among dated entries, then every undated entry. /// /// **Undated sorts oldest, not first.** "A corrupt stamp must not outrank fresh deletions for the /// trash's most prominent rows" — so an unparseable value loses to every real timestamp, however /// old, and orders by folder name among its own kind. private static func isOrdered(_ lhs: Row, _ rhs: Row) -> Bool { switch (lhs.deleted, rhs.deleted) { case let (left?, right?): return left == right ? lhs.name < rhs.name : left > right case (.some, nil): return true case (nil, .some): return false case (nil, nil): return lhs.name < rhs.name } } // MARK: - Paths for the trash's writes /// The folders `ids` names, in display order, restricted to one liveness side. /// /// **The membership rule is not restated here** — it is `Liveness.walk`'s, the same one /// `ItemReferenceSet.idUniverse` and `entries(of:)` read — spelled in *paths* rather than ids /// because a write needs to know where the item is. So the trashed side is the trash's rows and /// nothing besides: a card beneath a tombstoned lane is not individually addressable, which costs /// nothing (it has no row for a user to act on, so no command can name it) and buys the guarantee /// that every path this hands a writer names something the board would draw. /// /// **A tombstoned lane still takes its subtree with it**, and that is subsumption rather than /// omission: its path is the lane *folder*, and removing a folder removes what is inside it. Put /// Back on it restores the lane and every card that rode along; Delete Immediately on it purges /// the whole tree, own-flag cards included — the outcome the lane entry's confirmation sentence /// exists to warn about (`message(lanes:unrecoverable:)`). /// /// Display order — lanes left to right, each lane then its cards — 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 walk /// visits in exactly that order, so this is a filter over it and never a sort. public static func paths(of ids: Set, on side: Liveness, in snapshot: BoardModel) -> [ItemPath] { guard !ids.isEmpty else { return [] } var result: [ItemPath] = [] side.walk(snapshot) { lane, card in guard ids.contains(card?.id ?? lane.id) else { return } result.append(ItemPath(laneID: lane.id, cardID: card?.id)) } return result } /// Every folder Empty Trash removes — "emptying purges every tombstone on the board, filter or /// no filter" (03 ▸ Trash). /// /// `paths(of:on:in:)` on the trashed side with **no id filter at all**, which is the strongest /// form of that guarantee: the command's scope is the trashed universe itself, so it cannot /// narrow to a selection any more than it can narrow to the search. /// /// **A tombstoned lane contributes only itself**, and that is not an omission: removing the lane /// folder removes the tree beneath it, own-flag cards included. Listing those cards as well would /// be redundant purges of paths the first removal already took (harmless — `purgeItem` treats a /// folder that is already gone as success — but noise) and would require a second, broader /// definition of "in the trash" than the one every other caller reads. public static func emptyTrashTargets(in snapshot: BoardModel) -> [ItemPath] { var result: [ItemPath] = [] Liveness.trashed.walk(snapshot) { lane, card in result.append(ItemPath(laneID: lane.id, cardID: card?.id)) } return result } // MARK: - Counts and phrasing /// How many lane entries and card entries a set of entries holds — the confirmation dialogs' /// only input beyond the item titles. public struct EntryCounts: Sendable, Equatable { public var lanes: Int = 0 public var cards: Int = 0 public var total: Int { lanes + cards } public var isEmpty: Bool { total == 0 } } public static func counts(of entries: [TrashEntry]) -> EntryCounts { var counts = EntryCounts() for entry in entries { if entry.isLaneEntry { counts.lanes += 1 } else { counts.cards += 1 } } return counts } /// The counts a set of `ItemPath`s describes — the same two numbers, from the shape the write /// path actually carries. public static func counts(of paths: [ItemPath]) -> EntryCounts { var counts = EntryCounts() for path in paths { if path.isLane { counts.lanes += 1 } else { counts.cards += 1 } } return counts } /// "2 lanes and 3 cards", "41 cards", "1 lane" — 06-history-undo.md's **plural folding** applied /// to a mixed trash selection. /// /// An empty count reads "nothing", which no caller renders: both confirmations refuse to open on /// an empty scope. It is spelled anyway so the function is total. public static func phrase(_ counts: EntryCounts) -> String { switch (counts.lanes, counts.cards) { case (0, 0): "nothing" case let (0, cards): plural(cards, "card") case let (lanes, 0): plural(lanes, "lane") case let (lanes, cards): "\(plural(lanes, "lane")) and \(plural(cards, "card"))" } } private static func plural(_ count: Int, _ noun: String) -> String { "\(count) \(noun)\(count == 1 ? "" : "s")" } // 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, the /// lane caveat, and whether the loss is actually irreversible — are testable without an alert on /// screen (`TrashModelTests`). public struct PurgePrompt: Sendable, Equatable { public let title: String public let message: String public let confirmTitle: String } /// Delete Immediately's alert — "the alert stands between one keystroke and unrecoverable /// deletion" (03-board-ui.md § Trash). /// /// A sole item is **named**; several fold into counts. `nil` when the ids name nothing /// tombstoned, 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, in snapshot: BoardModel, unrecoverable: Bool ) -> PurgePrompt? { let targets = paths(of: ids, on: .trashed, in: snapshot) guard !targets.isEmpty else { return nil } let counts = counts(of: targets) let subject: String if targets.count == 1, let only = targets.first { subject = "\u{201C}\(displayName(of: only, in: snapshot))\u{201D}" } else { subject = phrase(counts) } return PurgePrompt( title: "Permanently delete \(subject)?", message: message(lanes: counts.lanes, unrecoverable: unrecoverable), confirmTitle: "Delete" ) } /// Empty Trash…'s alert — **always shown** ("bulk scope, not per-item recoverability, is what it /// guards"), and always naming the **true count**: every tombstone on the board, never the /// filtered view. /// /// Counts rather than names even for a single entry, because the command is about the trash /// rather than about an item: "Permanently delete 41 cards" is the design's own example phrasing /// (06-history-undo.md's plural folding). public static func emptyTrashPrompt(in snapshot: BoardModel, unrecoverable: Bool) -> PurgePrompt? { let counts = counts(of: emptyTrashTargets(in: snapshot)) guard !counts.isEmpty else { return nil } return PurgePrompt( title: "Permanently delete \(phrase(counts))?", message: message(lanes: counts.lanes, unrecoverable: unrecoverable), confirmTitle: "Delete" ) } /// The alert's body: what a lane takes with it, and whether any of it comes back. /// /// The lane sentence is not decoration — a lane entry's row says "3 cards" (what Put Back would /// return), while purging the lane folder takes *every* card inside it, individually tombstoned /// ones included. That gap is exactly what a confirmation is for. private static func message(lanes: Int, unrecoverable: Bool) -> String { var parts: [String] = [] if lanes > 0 { parts.append("Deleting a lane also deletes every card inside it.") } // m7-git: on a git board the content stays reachable in history, so the second sentence is // the honest one — and Delete Immediately does not confirm there at all // (`BoardStore.purgeIsUnrecoverable`). parts.append(unrecoverable ? "This can\u{2019}t be undone." : "The board\u{2019}s history still has them.") return parts.joined(separator: " ") } /// 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 { guard let lane = snapshot.lanes.first(where: { $0.id == path.laneID }) else { return "Untitled" } guard let cardID = path.cardID else { return lane.title.value ?? "Untitled" } return lane.cards.first { $0.id == cardID }?.title.value ?? "Untitled" } // MARK: - Menu validation /// Whether File ▸ Delete has something to tombstone — a **live**, non-empty selection that still /// names something the board renders. /// /// The liveness side is the whole of the binary: "menu validation stays binary — Delete for live /// selections, Put Back / Delete Immediately for tombstoned ones" (04 ▸ The trash). The /// resolution against the snapshot is what keeps a selection the next reload will drop from /// enabling an item that would write nothing. public static func canDelete(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool { selection.liveness == .live && !paths(of: selection.ids, on: .live, in: snapshot).isEmpty } /// Whether File ▸ Put Back and File ▸ Delete Immediately have something to act on — the exact /// mirror of `canDelete`, which is what makes the two ⌘⌫ twins enable exactly one of themselves. public static func canActOnTrash(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool { selection.liveness == .trashed && !paths(of: selection.ids, on: .trashed, in: snapshot).isEmpty } }