import Foundation /// What the board list renders for one `.kanban` package: enough to name it, size it and say whether /// it is here yet — and deliberately nothing more. A summary is never the input to anything that /// edits; opening a board mints a `BoardSession`, which walks the package properly. struct BoardSummary: Identifiable, Sendable, Equatable { /// **The package root is the identity.** A board has no UUID folder name and no id key — its /// identity is where it is (`BoardModel.rootURL` says so), and on the phone that URL is stable /// for as long as nobody renames the document in Files.app. var id: URL { rootURL } let rootURL: URL /// The board `index.md`'s `title:`, falling back to the folder name minus `.kanban` — which is /// exactly the fallback 01-storage-format.md § Board naming states, and the reason a board with /// no `title` key is a normal board rather than an untitled one. let title: String /// Lanes the loader would show. `nil` where the package is not materialized enough to count — /// see `BoardSummaryScanner` for why a number is withheld rather than guessed at zero. let laneCount: Int? /// Cards the loader would show, across every lane. Excludes `/.trash/`. `nil` on the same /// terms as `laneCount`. let cardCount: Int? /// The package's content-change date, as the metadata query reports it. `nil` under the DEBUG /// local root, where it is read from the directory instead, and on any item that has none yet. let modified: Date? let download: BoardDownloadState } /// Whether a board's bytes are on this device — the summary-level reading, from the metadata item's /// own attributes. /// /// **Coarse on purpose.** This drives one row's badge. The authoritative per-file answer, the one a /// load actually depends on, is `PackageMaterialization.sweep` — a package can report `.current` here /// and still be missing a card's `index.md`, which is exactly why the session sweeps rather than /// trusting this. enum BoardDownloadState: Sendable, Equatable { /// A local copy exists. Covers both `…StatusCurrent` and `…StatusDownloaded` (a local copy that /// may be behind the cloud's) — the distinction changes nothing the list can act on. case current /// Bytes are arriving. `fraction` is 0…1 where the daemon reports a percentage. case downloading(fraction: Double?) /// In the cloud, not here, nothing in flight. The index requests a download for every board in /// this state, so it is a transient the list should render as such. case notDownloaded /// No metadata to read: the DEBUG local root, or an item whose attributes have not arrived. case unknown /// Whether the shallow content walk may touch this package's files at all. var isReadable: Bool { switch self { case .current, .unknown: true case .downloading, .notDownloaded: false } } } /// One `.kanban` package as the index found it, before its contents were looked at — the `Sendable` /// hand-off from the main-actor metadata read to the off-main scan. struct BoardIndexEntry: Sendable, Equatable { let rootURL: URL let modified: Date? let download: BoardDownloadState } /// Turns an index entry into a summary by looking, shallowly, at the package. /// /// **A shallow walk, not a load.** `BoardLoader` parses every `index.md` in the tree; a board list /// showing six boards cannot afford six of those on a phone. So this counts folders through the same /// gate the loader counts them by — UUID-shaped name (`IntegrityRules.isIdentityShaped`) holding an /// `index.md` — and reads exactly one file, the board's own `index.md`, for its title. A folder that /// fails the gate is a stray the loader would ignore too, so the counts agree with what the board /// window will show without paying for the agreement. /// /// Two known and accepted divergences from a real load, both in the direction of over-counting by at /// most a hair: a card whose `index.md` is present but malformed is counted here and would be a /// fail-fast defect there, and a card carrying a legacy `deleted:` key is counted here and rides /// along flagged there. Deciding either requires parsing the file, which is the cost this walk /// exists to avoid. /// /// **Uncoordinated, deliberately.** These are display reads that re-run on every metadata update; a /// torn read costs a stale title for one refresh, while an `NSFileCoordinator` bracket per board /// would put a daemon round-trip on the path of drawing a list. The session coordinates; the index /// does not. enum BoardSummaryScanner { /// Blocking — callers run it off the main actor. nonisolated static func scan(_ entry: BoardIndexEntry) -> BoardSummary { let fallbackTitle = entry.rootURL.deletingPathExtension().lastPathComponent // Nothing on disk to read, and reading anyway risks a blocking materialization on whatever // network the phone is on. The name is still known — it is in the URL — so the row is // nameable while it downloads, and the next refresh fills in the rest. guard entry.download.isReadable else { return BoardSummary( rootURL: entry.rootURL, title: fallbackTitle, laneCount: nil, cardCount: nil, modified: entry.modified, download: entry.download ) } let indexURL = entry.rootURL.appendingPathComponent(IntegrityRules.indexFileName) let title = readTitle(at: indexURL) ?? fallbackTitle // An unreadable board `index.md` means this is not a board the loader would open — a package // still arriving, or one whose root file is genuinely broken. Either way a count would be a // fiction, so none is offered. guard FileManager.default.fileExists(atPath: indexURL.path) else { return BoardSummary( rootURL: entry.rootURL, title: title, laneCount: nil, cardCount: nil, modified: entry.modified, download: entry.download ) } var lanes = 0 var cards = 0 for lane in itemFolders(in: entry.rootURL) { lanes += 1 cards += itemFolders(in: lane).count } return BoardSummary( rootURL: entry.rootURL, title: title, laneCount: lanes, cardCount: cards, modified: entry.modified, download: entry.download ) } /// The board title as written, or `nil` where the file is absent, is not UTF-8, has no /// frontmatter, or carries no usable `title:` — every one of which is the folder-name fallback. private nonisolated static func readTitle(at indexURL: URL) -> String? { guard let data = try? Data(contentsOf: indexURL), let text = String(data: data, encoding: .utf8), let document = try? FrontmatterDocument.parse(text), let title = document.title.value, !title.isEmpty else { return nil } return title } /// Direct subfolders that are lanes or cards by the loader's own two gates, reached through the /// loader's own enumeration (`BoardLoader.directoryCandidates`) so the two can never disagree /// about what a candidate is — hidden entries skipped, which is what keeps `/.trash/` out /// of every count here without a second rule. private nonisolated static func itemFolders(in parent: URL) -> [URL] { guard let candidates = try? BoardLoader.directoryCandidates(in: parent) else { return [] } return candidates.filter { folder in IntegrityRules.isIdentityShaped(folder.lastPathComponent) && FileManager.default.fileExists( atPath: folder.appendingPathComponent(IntegrityRules.indexFileName).path ) } } }