import Foundation /// **Everything a Quick Look preview knows about a board** — its name, its tint, and each lane's /// first few card titles — read straight off the folder tree with no store, no watcher and no /// window behind it. /// /// ### Why this is not `BoardLoader.load` /// /// The snapshot the app opens a board with is the wrong shape for a preview in three ways, and each /// one is a reason rather than a preference: /// /// - **It fails.** `BoardLoader` collects fail-fast defects and throws a `BoardLoadFailure` for a /// missing root `index.md`, an unreadable `schema`, a lane whose YAML will not parse /// (01-storage-format.md § Malformed input). That is exactly right for opening a board — the user /// is about to edit it — and exactly wrong for pressing Space in Finder, where the honest answer to /// a half-broken board is *the part that reads*. Nothing in this file throws: every failure /// degrades to the empty answer for the thing that failed and the walk carries on. /// - **It is unbounded.** The loader visits every card, and inside each one it lists `attachments/` /// and counts `comments/` (`BoardModel.Card`) — three directory reads per card, plus a parse, on /// a board that may hold thousands. A preview has a few hundred milliseconds and no second /// chance, so this walk is *capped at every level* (`BoardOutlineLimits`) and opens no file it /// does not put on screen. /// - **It carries what a preview cannot use.** Trash, tombstone migration, the identity dedupe, the /// parse memo, the integrity defect stream, the coerce-tier trace — none of it renders, and all of /// it costs. /// /// What this does reuse is everything that decides *what the answer is*: `FrontmatterDocument` for /// the parse and every field reading, `IntegrityRules.isIdentityShaped` for what counts as a lane or /// a card, `BoardLoader.directoryCandidates` for the stray tolerance (hidden entries and symlinks /// out, folder-name order in), and `Ranks` for display order. A second walker that re-derived any of /// those would be a second storage format; this one only decides *how far to look*. /// /// ### The name /// /// "Outline", not "summary": `KanbanMobile/Cloud/BoardSummary.swift` already owns that word for the /// phone's board *list* row — a name, a size, a download state, nothing about the contents — and the /// phone compiles this directory into its own module, so the two would collide outright. The words /// are worth keeping apart anyway: that one summarizes a board's *file*, and this one outlines its /// *contents*. public struct BoardOutline: Sendable, Equatable { /// The board's name — its `title`, falling back to the folder name with `.kanban` stripped /// (01-storage-format.md § Board naming). Non-optional, deliberately: this is /// `AppModel.displayName(of:)`'s rule read one layer down, and "Untitled" appears nowhere on a /// board because a board always has a folder (BoardInfoPopover ▸ Rename — "boards do not have /// it"). Contrast `LaneOutline.title`. public let title: String /// The `background` mapping's colour as written — a palette name or a `#RRGGBB[AA]` hex — or /// `nil` for a board that carries none. /// /// **The value, not a colour**: resolving it is the renderer's job through `Palette`, exactly as /// it is in the app, so an unrecognized value degrades to "no tint" at the one place that knows /// what no tint looks like (Palette.swift — "Lenient, never an error"). public let background: String? /// The board's `icon` (an SF Symbol name) and `iconColor`, both as written and both lenient in /// the same way `background` is: a name the running system cannot draw is the renderer's problem /// to degrade, not this walk's to validate. public let icon: String? public let iconColor: String? /// The lanes the preview shows, in display order — **a prefix**, capped by /// `BoardOutlineLimits.lanes`. `hiddenLaneCount` is the rest. public let lanes: [LaneOutline] /// Every lane the walk found, shown or not — the number the preview's header line states. /// /// `laneCountIsCapped` marks the walk that stopped counting (`BoardOutlineLimits.laneScan`), so /// a renderer can say "100+" rather than a number it would be lying about. On every board that /// has ever existed this is `false`; it is here because a preview must have a bounded worst case /// even on a folder nobody meant to be a board. public let laneCount: Int public let laneCountIsCapped: Bool /// The lanes counted but not shown — the "+N more lanes" the preview ends on, and `0` on a board /// whose lanes all fit. public var hiddenLaneCount: Int { max(0, laneCount - lanes.count) } public init( title: String, background: String? = nil, icon: String? = nil, iconColor: String? = nil, lanes: [LaneOutline] = [], laneCount: Int = 0, laneCountIsCapped: Bool = false ) { self.title = title self.background = background self.icon = icon self.iconColor = iconColor self.lanes = lanes self.laneCount = laneCount self.laneCountIsCapped = laneCountIsCapped } } /// One lane in a board summary: a title, a colour, a count, and the first few card titles. public struct LaneOutline: Sendable, Equatable { /// The lane's `title` as written, or `nil` where it carries none — **"Untitled" is a rendering, /// never a value** (03-board-ui.md § Card face; the same rule `Lane`/`Card` follow throughout the /// app), so the placeholder is applied where the text is drawn and never stored here. An empty /// or whitespace-only title reads as `nil` for the same reason a blank lane header does. public let title: String? /// The lane's `background` colour as written — see `BoardOutline.background`; a lane's is an edge /// accent rather than a backdrop (03-board-ui.md § Styling ▸ Capabilities). public let background: String? /// The first few cards' titles, in display order, each `nil` where the card carries no title. /// Capped by `BoardOutlineLimits.cardTitles`. /// /// **In display order over the *scanned* cards**, which is every card in the overwhelming case /// and the first `BoardOutlineLimits.cardScan` of them otherwise — see that field for the cost /// the cap accepts. public let cardTitles: [String?] /// How many cards the lane holds — a `readdir` plus one `stat` per child, never a parse, which is /// `BoardLoader.commentCount(in:)`'s own arrangement one level up: a card is an identity-shaped /// child carrying its own `index.md`, and whether that file *parses* costs a read this count does /// not pay. public let cardCount: Int /// Whether the count stopped at `BoardOutlineLimits.cardCount` — a lane holding more cards than /// the walk was willing to stat, which a renderer states as "2000+" rather than as a number. public let cardCountIsCapped: Bool /// The cards counted but not titled — the "+N more" under a lane's last shown title. public var hiddenCardCount: Int { max(0, cardCount - cardTitles.count) } public init( title: String? = nil, background: String? = nil, cardTitles: [String?] = [], cardCount: Int = 0, cardCountIsCapped: Bool = false ) { self.title = title self.background = background self.cardTitles = cardTitles self.cardCount = cardCount self.cardCountIsCapped = cardCountIsCapped } } /// **How far the summary walk looks** — the whole of what makes a preview bounded. /// /// Quick Look gives an extension a short budget and no way to say "still working"; a board is a /// folder tree of unknown size that a user can point Finder at. So every level of the walk has a /// ceiling, and the ceilings are here rather than scattered through the walk so the answer to "what /// does a preview cost, worst case?" is one struct: at most `laneScan` lane `index.md` parses, plus /// per shown lane at most `cardCount` stats and `cardScan` card `index.md` parses. /// /// The defaults are `preview`'s. Every one is generous enough that a real board is summarized /// exactly — the caps exist for the pathological folder, not for the ordinary one. public struct BoardOutlineLimits: Sendable, Equatable { /// Lanes rendered, in display order. The rest become `BoardOutline.hiddenLaneCount`. public var lanes: Int /// Root children the walk will consider a lane at all. Past this the count itself stops /// (`BoardOutline.laneCountIsCapped`) — it is the one cap that bounds the lane parse, since /// display order cannot be known without reading every sibling's `order`. public var laneScan: Int /// Card titles shown per lane. The rest become `LaneOutline.hiddenCardCount`. public var cardTitles: Int /// Card `index.md` files parsed per lane, taken in folder-name order. /// /// **The one cap with a visible cost**, stated plainly: a card's rank lives in its own file, so /// the true first-`cardTitles` of a lane cannot be known without reading all of them. A lane /// holding more cards than this shows the top of the *scanned* subset instead — its **count /// stays exact**, and the titles are still cards in that lane, just not provably the topmost. /// Set high enough that no lane a person has ever built reaches it. public var cardScan: Int /// Children stat'd per lane while counting. Past this the count stops /// (`LaneOutline.cardCountIsCapped`). public var cardCount: Int public init(lanes: Int, laneScan: Int, cardTitles: Int, cardScan: Int, cardCount: Int) { self.lanes = lanes self.laneScan = laneScan self.cardTitles = cardTitles self.cardScan = cardScan self.cardCount = cardCount } /// The Quick Look preview's own ceilings — the only set anything but a test uses. public static let preview = BoardOutlineLimits( lanes: 12, laneScan: 100, cardTitles: 6, cardScan: 200, cardCount: 2000 ) } // MARK: - The walk extension BoardOutline { /// Summarizes the board rooted at `boardRoot` — **total, and never throwing**. /// /// A preview has no error surface: there is no alert to raise, no Repair sheet to offer and no /// second attempt, so every failure this walk can meet degrades in place. A root that is not a /// directory, an `index.md` that is missing or unparseable or not UTF-8, a lane folder that /// vanishes mid-walk, a permissions race on a directory listing — each yields the empty answer /// for that one thing and nothing else changes. The floor is a summary carrying the folder's own /// name and no lanes, which is still a truthful preview of a folder that is not a board. /// /// A lane or card whose `index.md` will not parse is **kept, not skipped**: it reads as untitled /// and order-less (append-at-end, `Ranks.resolvedOrders`), so a board with one broken file /// previews with one blank title rather than with a lane silently missing and a count that /// disagrees with Finder. public static func read(boardRoot: URL, limits: BoardOutlineLimits = .preview) -> BoardOutline { let document = parsedIndex(in: boardRoot) // The lane ceiling is applied to the *name-shaped* children, before the per-child `stat` that // asks whether each holds an `index.md` — `readLane`'s ordering, and for its reason. let identityShaped = directoryCandidates(in: boardRoot) .filter { IntegrityRules.isIdentityShaped($0.lastPathComponent) } let considered = identityShaped.prefix(max(0, limits.laneScan)) let candidates = considered.filter(hasIndex) let walked = candidates.map { (url: $0, document: parsedIndex(in: $0)) } let orders = Ranks.resolvedOrders( of: walked, stored: { $0.document?.order.value }, name: { $0.url.lastPathComponent } ) let ranked = Array(zip(walked, orders)) let ordered = Ranks.sortedForDisplay( ranked, order: { $0.1 }, name: { $0.0.url.lastPathComponent } ) let shown = ordered.prefix(max(0, limits.lanes)) return BoardOutline( title: text(document?.title) ?? boardRoot.deletingPathExtension().lastPathComponent, background: text(document?.background), icon: text(document?.icon), iconColor: text(document?.iconColor), lanes: shown.map { readLane(at: $0.0.url, document: $0.0.document, limits: limits) }, laneCount: ordered.count, laneCountIsCapped: identityShaped.count > considered.count ) } /// One lane: its own fields off an already-parsed `index.md`, then its cards — counted by /// listing, titled by parsing, and both capped. private static func readLane( at laneURL: URL, document: FrontmatterDocument?, limits: BoardOutlineLimits ) -> LaneOutline { // The count's two halves, in this order on purpose: the name-shape filter is a string test // and free, so the ceiling is applied to *it* and the per-child `stat` below never runs more // than `limits.cardCount` times. let identityShaped = directoryCandidates(in: laneURL) .filter { IntegrityRules.isIdentityShaped($0.lastPathComponent) } let considered = identityShaped.prefix(max(0, limits.cardCount)) let cards = considered.filter(hasIndex) let scanned = cards.prefix(max(0, limits.cardScan)) let walked = scanned.map { (url: $0, document: parsedIndex(in: $0)) } let orders = Ranks.resolvedOrders( of: walked, stored: { $0.document?.order.value }, name: { $0.url.lastPathComponent } ) let ordered = Ranks.sortedForDisplay( Array(zip(walked, orders)), order: { $0.1 }, name: { $0.0.url.lastPathComponent } ) return LaneOutline( title: text(document?.title), background: text(document?.background), cardTitles: ordered.prefix(max(0, limits.cardTitles)).map { text($0.0.document?.title) }, cardCount: cards.count, cardCountIsCapped: identityShaped.count > considered.count ) } // MARK: - Filesystem helpers /// `BoardLoader.directoryCandidates(in:)` with its typed throw flattened to the empty answer — /// the function never actually throws (an unlistable folder already degrades to `[]` inside it), /// and this walk has nowhere to put an error even if it did. private static func directoryCandidates(in folder: URL) -> [URL] { (try? BoardLoader.directoryCandidates(in: folder)) ?? [] } private static func hasIndex(_ folder: URL) -> Bool { FileManager.default.fileExists( atPath: folder.appendingPathComponent(IntegrityRules.indexFileName).path ) } /// A folder's `index.md`, parsed — `nil` for every way that can fail: no file, unreadable bytes, /// not UTF-8, unparseable YAML. The strict UTF-8 decode is `BoardLoader.parseDocument`'s, so a /// BOM'd file reads here exactly as it loads there: as nothing. private static func parsedIndex(in folder: URL) -> FrontmatterDocument? { let url = folder.appendingPathComponent(IntegrityRules.indexFileName) guard let data = try? Data(contentsOf: url), let text = String(validating: data, as: UTF8.self) else { return nil } return try? FrontmatterDocument.parse(text) } /// A lenient string field's text, with **blank read as absent** — a title of `""` or of spaces is /// a key that says nothing, and the summary's whole vocabulary for "says nothing" is `nil` /// (`AppModel.displayName(of:)` applies the same `!title.isEmpty` test to a board's own name). /// A missing key and a malformed one collapse to `nil` here too, exactly as they do at every /// other lenient call site in the app. private static func text(_ field: FieldValue?) -> String? { guard let value = field?.value else { return nil } return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : value } }