Relocate loose card files into attachments
01's Lanework-owns-the-board carve-out: a regular file beside a card's index.md belongs in attachments/, and the app moves it there. The loader detects read-only — a new LoadResult.looseCardFiles channel, separate from the stray-tolerance warnings because it says the opposite thing — skipping directories, symlinks, hidden entries, and the reserved names compared case-insensitively (on APFS, Index.md IS the index). The relocation rides one performWrite bracket at the tail of every successful reload, which makes lock deferral free: the reload that lifts a read-only lock is the reload that relocates. A lane/card/filename memo keeps a failing relocation from hot-looping — one one-shot, then silence until disk changes. The notice rides the loss-row class, phrasing folded by BannerCenter (one file, one card's files, a multi-card sweep), naming original filenames per the importAttachment rule. Paste normalizes at the import boundary: staged snapshots' loose files land in the pasted card's attachments silently, every arrival path declaring its side via an explicit normalizingLooseFiles parameter — drag paths decline and fall back to the destination's own carve-out. checkIsCardFolder closes the hole where a lane's notes.txt would have been relocated: card depth is exact, UUID under UUID. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -24,12 +24,21 @@ import os
|
||||
/// shape rule — `attachments` and `comments` are non-UUID-shaped and would read as strays, not
|
||||
/// levels, so they never need special-casing against the stray warning.
|
||||
///
|
||||
/// **The one read inside a card folder** is `attachmentNames(in:)`: a single flat listing of
|
||||
/// `attachments/`, feeding `Card.attachments`. It is a *names* read and nothing more — it never
|
||||
/// opens a file, never descends, never warns, and degrades to `[]` on any failure. The board
|
||||
/// window's face needs it before a card window exists (the quiet paperclip indicator —
|
||||
/// 03-board-ui.md § Card face), and the snapshot is where it reads from. Everything else about a
|
||||
/// card folder's contents remains outside this loader's business.
|
||||
/// **Two reads inside a card folder**, both of them flat name listings and nothing more — neither
|
||||
/// opens a file, descends, warns, or fails a load; each degrades to `[]`:
|
||||
///
|
||||
/// - `attachmentNames(in:)` — `attachments/`, feeding `Card.attachments`. The board window's face
|
||||
/// needs it before a card window exists (the quiet paperclip indicator — 03-board-ui.md § Card
|
||||
/// face), and the snapshot is where it reads from.
|
||||
/// - `looseFileNames(in:)` — the card folder *itself*, feeding `LoadResult.looseCardFiles`. This is
|
||||
/// the loose-file carve-out's **detection** half (01-storage-format.md § Fractal layout ▸ Rules,
|
||||
/// settled 2026-07-28): a regular file sitting beside a card's `index.md` belongs in
|
||||
/// `attachments/`, and the app relocates it. Detection stays read-only *here* — this loader is a
|
||||
/// pure function of the tree and writes nothing, ever (the Repair precedent); the relocation is a
|
||||
/// Writer-mediated app write the store schedules off the snapshot
|
||||
/// (`BoardStore.relocateLooseCardFiles`).
|
||||
///
|
||||
/// Everything else about a card folder's contents remains outside this loader's business.
|
||||
///
|
||||
/// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never
|
||||
/// followed, whether it points to a file or a directory — this loader does not resolve
|
||||
@@ -52,6 +61,22 @@ public enum BoardLoader: Sendable {
|
||||
/// the writer must never disagree about which file a folder's content lives in.
|
||||
static let indexFileName = "index.md"
|
||||
|
||||
/// The card-level names the app claims, and therefore the three the loose-file carve-out
|
||||
/// never touches (01-storage-format.md § Fractal layout ▸ Rules: "Reserved card-level names
|
||||
/// … untouched"): the card's own `index.md` plus the two reserved children. `comments` is
|
||||
/// listed because the schema reserves the name, not because anything writes it yet —
|
||||
/// `attachments/` is still the one folder this app ever creates under a card.
|
||||
///
|
||||
/// **Compared lowercased**, because the filesystem this runs on usually is: a file spelled
|
||||
/// `Index.md` *is* the card's index to `fileExists`, and a case-sensitive reservation check
|
||||
/// would hand the loose-file relocation a card's own content to move into `attachments/`.
|
||||
///
|
||||
/// Internal rather than `private`: `BoardWriter.relocateLooseFiles` refuses the same three
|
||||
/// names on its own, so a caller passing a hand-made list cannot reach past this rule.
|
||||
static let reservedCardChildNames: Set<String> = [
|
||||
indexFileName, BoardWriter.attachmentsFolderName, "comments",
|
||||
]
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader")
|
||||
|
||||
// MARK: - Entry point
|
||||
@@ -72,6 +97,10 @@ public enum BoardLoader: Sendable {
|
||||
logger.warning("\(warning.description, privacy: .public)")
|
||||
}
|
||||
|
||||
// The carve-out's detection channel — deliberately *not* `warnings`, which is the
|
||||
// stray-*tolerance* vocabulary (see `LoadResult.looseCardFiles`).
|
||||
var looseCardFiles: [LooseCardFiles] = []
|
||||
|
||||
// Legal per the frontmatter table, meaningless at board level — ignore and log, never
|
||||
// tombstone (01-storage-format.md § Deletion).
|
||||
if !boardDocument.deleted.isMissing {
|
||||
@@ -113,6 +142,18 @@ public enum BoardLoader: Sendable {
|
||||
let cardSchema = try validatedSchema(in: cardDocument, path: cardPath)
|
||||
let cardOrder = try validatedOrder(in: cardDocument, path: cardPath)
|
||||
|
||||
// Noticed, never acted on: the relocation is the store's, through the Writer.
|
||||
let loose = looseFileNames(in: cardURL)
|
||||
if !loose.isEmpty {
|
||||
looseCardFiles.append(LooseCardFiles(
|
||||
laneID: ItemID(rawValue: laneName),
|
||||
cardID: ItemID(rawValue: cardName),
|
||||
title: cardDocument.title.value,
|
||||
fileNames: loose
|
||||
))
|
||||
logger.info("\(cardRelPath, privacy: .public): \(loose.count, privacy: .public) loose file(s) beside index.md — to be relocated into attachments/")
|
||||
}
|
||||
|
||||
cards.append(Card(
|
||||
id: ItemID(rawValue: cardName),
|
||||
schema: cardSchema,
|
||||
@@ -164,7 +205,7 @@ public enum BoardLoader: Sendable {
|
||||
document: boardDocument
|
||||
)
|
||||
|
||||
return LoadResult(model: model, warnings: warnings)
|
||||
return LoadResult(model: model, warnings: warnings, looseCardFiles: looseCardFiles)
|
||||
}
|
||||
|
||||
// MARK: - Filesystem helpers
|
||||
@@ -229,6 +270,57 @@ public enum BoardLoader: Sendable {
|
||||
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// A card folder's **loose top-level files** — the one carve-out to uniform stray tolerance
|
||||
/// (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28, "Lanework-owns-the-board"):
|
||||
/// "a regular file sitting beside a card's `index.md` (not `attachments/`, not a reserved name)
|
||||
/// belongs in `attachments/`, and the app moves it there".
|
||||
///
|
||||
/// **This function only notices.** It opens nothing, moves nothing, and creates nothing; the
|
||||
/// relocation is `BoardWriter.relocateLooseFiles`, run through the store's write bracket. A load
|
||||
/// is a pure function of the tree and stays one.
|
||||
///
|
||||
/// Four exclusions, three of them `attachmentNames(in:)`' own and for its reasons:
|
||||
///
|
||||
/// - **Directories.** The carve-out is exactly *files*. A stray folder in a card — a nested
|
||||
/// clone, a hand-made subfolder — keeps the verbatim posture, because "relocating a directory
|
||||
/// into the flat attachment model would be wrong".
|
||||
/// - **Symlinks**, never touched and never traversed (§ Rules) — the same stance the level walk
|
||||
/// takes. `isSymbolicLink` is checked *beside* `isRegularFile` rather than trusted to imply
|
||||
/// it, exactly as `directoryCandidates` does, so a link pointing at a file is excluded on its
|
||||
/// own account.
|
||||
/// - **Hidden entries.** `.DS_Store` and friends are not the user's files, and relocating one
|
||||
/// would surface it in a card's attachment list — the loudest possible way to be wrong about
|
||||
/// a file nobody wrote on purpose. It is also what keeps a crashed write's dot-prefixed
|
||||
/// residue out of the relocation.
|
||||
/// - **The reserved card-level names** (`reservedCardChildNames`), case-insensitively.
|
||||
///
|
||||
/// Finder order (`localizedStandardCompare`), like every other name listing here, so the notice
|
||||
/// the store posts names files the way the board would sort them.
|
||||
///
|
||||
/// Failure is silent (`[]`): a permissions race here must never be the reason a board refuses
|
||||
/// to open, and "nothing to relocate" is the safe reading of "cannot tell".
|
||||
static func looseFileNames(in cardFolder: URL) -> [String] {
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(
|
||||
at: cardFolder,
|
||||
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return entries
|
||||
.filter { url in
|
||||
guard !reservedCardChildNames.contains(url.lastPathComponent.lowercased()),
|
||||
let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
|
||||
else {
|
||||
return false
|
||||
}
|
||||
return values.isRegularFile == true && values.isSymbolicLink != true
|
||||
}
|
||||
.map(\.lastPathComponent)
|
||||
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// The hex characters `isUUIDShaped` accepts in each `-`-delimited group — **both cases**,
|
||||
/// per the shape-only identity predicate below.
|
||||
private static let uuidGroupCharacters = Set("0123456789abcdefABCDEF")
|
||||
@@ -366,6 +458,49 @@ public enum BoardLoader: Sendable {
|
||||
public struct LoadResult: Sendable {
|
||||
public var model: BoardModel
|
||||
public var warnings: [LoadWarning]
|
||||
|
||||
/// The cards this walk found carrying loose files, in the order the walk met them — the
|
||||
/// loose-file carve-out's detection channel (01-storage-format.md § Fractal layout ▸ Rules,
|
||||
/// settled 2026-07-28).
|
||||
///
|
||||
/// **Its own field rather than a `LoadWarning` case**, because the two say opposite things.
|
||||
/// `warnings` is the *stray-tolerance* vocabulary: "this was ignored, it is staying exactly
|
||||
/// where it is, there is nothing to do". A loose card file is the one thing on a board that is
|
||||
/// **not** tolerated — it is pending work, and the store acts on it. Folding it into the
|
||||
/// warning channel would also mean throwing away everything the act needs (which lane, which
|
||||
/// card, which title, which names) and re-deriving it from a display string.
|
||||
///
|
||||
/// Nothing renders this: a loose file is not content, and it reaches no view. Its one consumer
|
||||
/// is `BoardStore.relocateLooseCardFiles()`, which relocates and posts the notice.
|
||||
///
|
||||
/// Tombstoned cards are included, and cards under tombstoned lanes with them. Where a file
|
||||
/// belongs on disk is a question about the *tree*, not about what the board is currently
|
||||
/// rendering — the same reason the loader flags a tombstoned card at all rather than dropping
|
||||
/// it.
|
||||
public var looseCardFiles: [LooseCardFiles] = []
|
||||
}
|
||||
|
||||
/// One card found holding files that belong in its `attachments/` — everything the relocation and
|
||||
/// its notice need, and nothing more.
|
||||
///
|
||||
/// `title` is the card's as written, `nil` for an untitled one: "Untitled" is a rendering, never a
|
||||
/// value (03-board-ui.md § Card face), so the phrasing layer decides what to call it. The path is
|
||||
/// carried as its two identity components rather than as a URL, `BoardStore.liveItem`'s convention,
|
||||
/// so the write derives its path from the store's *current* root.
|
||||
public struct LooseCardFiles: Sendable, Equatable {
|
||||
public let laneID: ItemID
|
||||
public let cardID: ItemID
|
||||
public let title: String?
|
||||
/// The loose files' names, in Finder order (`BoardLoader.looseFileNames`). Never empty — a card
|
||||
/// with nothing loose contributes no entry at all.
|
||||
public let fileNames: [String]
|
||||
|
||||
public init(laneID: ItemID, cardID: ItemID, title: String?, fileNames: [String]) {
|
||||
self.laneID = laneID
|
||||
self.cardID = cardID
|
||||
self.title = title
|
||||
self.fileNames = fileNames
|
||||
}
|
||||
}
|
||||
|
||||
/// A tolerated anomaly the loader kept going past. Never blocks a load — see `BoardLoadError`
|
||||
|
||||
Reference in New Issue
Block a user