Boards, lanes, and cards all show their frontmatter icon leading the row, tinted through the palette with a secondary fallback; the board scanner now reads icon and iconColor from the same one-file parse as the title. Lane and card lists gain the segmented Manual/Name/Recent control — display-only sorting layered over the rank order, never rewriting what's on disk. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
266 lines
12 KiB
Swift
266 lines
12 KiB
Swift
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
|
|
|
|
/// The board `index.md`'s `icon:`, verbatim — an SF Symbol name, or `nil` when unset or the file
|
|
/// couldn't be read. Unlike `title`, there is no fallback: a board with no icon shows none.
|
|
let icon: String?
|
|
|
|
/// The board `index.md`'s `iconColor:`, verbatim — a `CardPalette` swatch name, or `nil` on the
|
|
/// same terms as `icon`.
|
|
let iconColor: 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 `<root>/.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
|
|
|
|
/// Which of the phone's two homes this board is in.
|
|
let location: BoardLocation
|
|
}
|
|
|
|
/// How the boards tab orders its merged list. Raw values are persisted (`AppStorage`), so they are
|
|
/// API — changing a case's raw value would silently reset every user's remembered choice.
|
|
enum BoardSortOrder: String, CaseIterable, Sendable {
|
|
case name
|
|
case recent
|
|
}
|
|
|
|
extension BoardSortOrder {
|
|
/// Orders `boards` for display without touching the two-home merge that produced them.
|
|
///
|
|
/// **`.name` is a no-op.** `BoardIndexStore` already publishes its list in title order —
|
|
/// case/diacritic-insensitive, path as the tie-break — so re-sorting here would just repeat work
|
|
/// already done upstream.
|
|
///
|
|
/// **`.recent` sorts by `modified` descending, with `nil` last.** Ties — equal dates, and every
|
|
/// `nil` among them — keep the incoming order: Swift's `sort` is not a stable sort, so the
|
|
/// comparator breaks ties on each board's original offset rather than leaving equal rows free to
|
|
/// swap places on every refresh.
|
|
nonisolated func sorted(_ boards: [BoardSummary]) -> [BoardSummary] {
|
|
switch self {
|
|
case .name:
|
|
return boards
|
|
|
|
case .recent:
|
|
return boards.enumerated().sorted { lhs, rhs in
|
|
switch (lhs.element.modified, rhs.element.modified) {
|
|
case let (l?, r?) where l != r:
|
|
return l > r
|
|
case (nil, .some):
|
|
return false
|
|
case (.some, nil):
|
|
return true
|
|
default:
|
|
return lhs.offset < rhs.offset
|
|
}
|
|
}.map(\.element)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Where a board is stored, and therefore whether it syncs.
|
|
///
|
|
/// **Told, never sniffed.** A location is what the home an entry was enumerated from *is*, so the
|
|
/// index tags it at the point where that is a fact (`BoardIndexStore`, which holds both roots) and
|
|
/// everything downstream carries the tag. Deriving it from the URL instead would mean deciding
|
|
/// whether a path is inside a ubiquity container by looking at it — a question that has no stable
|
|
/// answer across the real container, the DEBUG stand-in root and a test's scratch directories.
|
|
enum BoardLocation: Sendable, Equatable {
|
|
/// The `CloudHome` — syncs to the Mac and to every other device on the account.
|
|
case icloud
|
|
|
|
/// The `DeviceHomeResolver` home — this phone, and nowhere else.
|
|
case local
|
|
|
|
/// The one word the list marker, the settings sheet and its move button all name it by.
|
|
var name: String {
|
|
switch self {
|
|
case .icloud: "iCloud"
|
|
case .local: "Local"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
|
|
/// Set by whoever produced the entry, from the home it enumerated — see `BoardLocation`.
|
|
let location: BoardLocation
|
|
}
|
|
|
|
/// 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,
|
|
icon: nil,
|
|
iconColor: nil,
|
|
laneCount: nil,
|
|
cardCount: nil,
|
|
modified: entry.modified,
|
|
download: entry.download,
|
|
location: entry.location
|
|
)
|
|
}
|
|
|
|
let indexURL = entry.rootURL.appendingPathComponent(IntegrityRules.indexFileName)
|
|
let fields = readIndexFields(at: indexURL)
|
|
let title = fields.title ?? 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,
|
|
icon: fields.icon,
|
|
iconColor: fields.iconColor,
|
|
laneCount: nil,
|
|
cardCount: nil,
|
|
modified: entry.modified,
|
|
download: entry.download,
|
|
location: entry.location
|
|
)
|
|
}
|
|
|
|
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,
|
|
icon: fields.icon,
|
|
iconColor: fields.iconColor,
|
|
laneCount: lanes,
|
|
cardCount: cards,
|
|
modified: entry.modified,
|
|
download: entry.download,
|
|
location: entry.location
|
|
)
|
|
}
|
|
|
|
/// The board `index.md`'s `title:`/`icon:`/`iconColor:`, read together since they come from the
|
|
/// same one-file parse. `title` is `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 in
|
|
/// `scan`. `icon`/`iconColor` are `nil` on the same unreadable-file terms, and also whenever the
|
|
/// document simply doesn't set them — there is no fallback for either.
|
|
private nonisolated static func readIndexFields(
|
|
at indexURL: URL
|
|
) -> (title: String?, icon: String?, iconColor: String?) {
|
|
guard let data = try? Data(contentsOf: indexURL),
|
|
let text = String(data: data, encoding: .utf8),
|
|
let document = try? FrontmatterDocument.parse(text)
|
|
else {
|
|
return (nil, nil, nil)
|
|
}
|
|
let title = document.title.value
|
|
return (title?.isEmpty == false ? title : nil, document.icon.value, document.iconColor.value)
|
|
}
|
|
|
|
/// 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 `<root>/.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
|
|
)
|
|
}
|
|
}
|
|
}
|