The phone joins the format — KanbanMobile MVP: shared storage verbatim over an iCloud container
A second product, not a second edition: dev.rzen.indie.KanbanMobile (iOS 26,
iPhone-only) compiles Kanban/Storage as source files, so a format change that
breaks the phone breaks this build the day it's made. Boards live in
iCloud.dev.rzen.indie.Kanban — named after the Mac bundle id so the Mac app can
adopt the container later without a migration; until then the folder is
"Lanework" in iCloud Drive and the Mac opens boards there through the open
panel.
EchoLedger grows #if os(macOS) gates around its three consumer surfaces
(verdicts/BoardDiff, harvest/HarvestedReceipt, comment retirement/CommentPath)
— the recording side BoardWriter stamps compiles on every platform, and the
gates are the seam a future phone verdict surface lands behind. AgentGuide
stays Mac-only.
The phone's watcher is NSMetadataQuery: BoardIndexStore (one query, package
UTI export makes a .kanban directory one item, equality-gated rescans,
download kicks per pass), BoardSession (materialization sweep before every
fail-fast walk, NSFileCoordinator brackets, perform{} = coordinated write then
awaited reload, ParseMemo threaded), CloudHome (off-main container resolution,
LANEWORK_LOCAL_ROOT DEBUG override for simulator work without an account).
Screens: Boards -> lanes -> cards -> card editor, value-routed by ItemID with
every screen re-reading the live snapshot; leading swipe moves a card via
confirmationDialog, trailing swipe sends it to .trash/; the editor commits
title through the Mac's canonical rename path and body through writeBody, with
drafts that survive reloads; attributes are the three typed style fields
(icon, iconColor, background) — labels is a reserved unknown-field key and
deliberately has no editor. Settings carries IndieBackup (backup root = the
container's Documents, restore rebuild = an index rescan, controller
constructed only once the home resolves).
Arbiter: KanbanMobile green for iOS Simulator, Kanban green for macOS, 3006
unit tests / 517 suites passed (PointerLatencyTests excluded — mid-rework
uncommitted in a parallel session).
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
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 `<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
|
||||
}
|
||||
|
||||
/// 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 `<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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user