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. /// 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. /// /// The walk is one level deep because the tree is (01-storage-format.md § Fractal layout), and /// the branch on `lane.isDeleted` *is* the ancestor walk: a tombstoned lane contributes exactly /// one entry and its cards contribute none, whatever their own flags say. public static func entries(of snapshot: BoardModel) -> [TrashEntry] { var rows: [Row] = [] for lane in snapshot.lanes { if lane.isDeleted { // 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 )) continue } for card in lane.cards where card.isDeleted { rows.append(Row( entry: .card(card, laneID: lane.id), deleted: card.deleted.value, name: card.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). /// /// Cheaper than building the entries and used where only the answer matters. 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 **effective** liveness side. /// /// The universe rule is `ItemReferenceSet.idUniverse`'s, spelled here in terms of paths rather /// than ids because a write needs to know *where* the item is: a lane contributes itself on the /// side its own flag names, and a card on the side `lane.isDeleted || card.isDeleted` names. /// /// 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). public static func paths(of ids: Set, on side: Liveness, in snapshot: BoardModel) -> [ItemPath] { guard !ids.isEmpty else { return [] } var result: [ItemPath] = [] for lane in snapshot.lanes { let laneSide = Liveness(isDeleted: lane.isDeleted) if laneSide == side, ids.contains(lane.id) { result.append(ItemPath(laneID: lane.id, cardID: nil)) } for card in lane.cards where Liveness(isDeleted: lane.isDeleted || card.isDeleted) == side && ids.contains(card.id) { 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). /// /// **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). public static func emptyTrashTargets(in snapshot: BoardModel) -> [ItemPath] { var result: [ItemPath] = [] for lane in snapshot.lanes { if lane.isDeleted { result.append(ItemPath(laneID: lane.id, cardID: nil)) continue } for card in lane.cards where card.isDeleted { 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 } }