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:
2026-07-28 09:09:31 -04:00
parent 5c0c0e5619
commit af1860debf
10 changed files with 1434 additions and 22 deletions
+142 -7
View File
@@ -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`
+175 -1
View File
@@ -1080,6 +1080,168 @@ public enum BoardWriter: Sendable {
return landed
}
/// Moves loose files out of a card folder and into its `attachments/` the **write half** of
/// 01-storage-format.md's loose-file carve-out (§ Fractal layout Rules, settled 2026-07-28,
/// "Lanework-owns-the-board"): "a regular file sitting beside a card's `index.md` belongs in
/// `attachments/`, and the app moves it there Finder-style rename on collision".
///
/// **A move, not a copy** the file is not being imported from somewhere else, it is being put
/// where it already belonged, and leaving a second copy beside `index.md` would leave the very
/// thing this call exists to clear. `FileManager.moveItem` within one folder is a `rename(2)`:
/// atomic, and byte-preserving by not touching bytes at all.
///
/// **`index.md` is never opened.** Relocating a stray says nothing about the card's content, so
/// no `modified` stamp is written and no frontmatter is read which is also why a card whose
/// frontmatter is uneditable (a flow mapping) still gets its files tidied.
///
/// `names` is the caller's list the loader's `looseFileNames` at the store, or this file's own
/// `normalizeLooseFiles(inCard:)` at the import boundary and **every name is re-checked
/// against disk before it is touched** (`isRelocatable`). A name that has stopped being a plain
/// non-hidden regular file since it was listed, or that names a reserved child, or that is not a
/// bare filename at all, is **skipped silently**: the reload is the authority on what is there,
/// and a file the user deleted between the walk and the write is not a failure to report. That
/// re-check is also what makes the rule "folders and symlinks are never relocated" a property of
/// this call rather than of its callers.
///
/// The batch is `importAttachments`' shape exactly: in order, one finished move at a time, the
/// first failure stopping it and throwing while everything already moved stays moved. Returns
/// what actually landed, in input order `sourceURL` naming the file where it sat, `fileName`
/// the (possibly Finder-renamed) name it took inside `attachments/`.
///
/// `attachments/` is created only when something is actually going to move into it, so a card
/// whose loose files all vanished under the write is left exactly as it was no empty folder
/// minted for nothing.
///
/// **`cardFolder` must really be a card** (`checkIsCardFolder`, which is stricter than the
/// UUID-shape guard the rest of this file uses): a lane's own loose files keep the verbatim
/// posture, and no other write in the app has to tell the two levels apart.
@discardableResult
public static func relocateLooseFiles(
_ names: [String],
inCard cardFolder: URL
) throws(BoardWriteError) -> [ImportedAttachment] {
guard !names.isEmpty else { return [] }
// Before the per-file loop starts no single file is implicated yet the first name stands
// in for the batch, exactly as `importAttachments` lets its first source name it.
let batchOperation = WriteOperation.relocateLooseFile(filename: names[0])
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: batchOperation)
try checkIsCardFolder(cardFolder, operation: batchOperation)
let relocatable = names.filter { isRelocatable($0, in: cardFolder) }
guard !relocatable.isEmpty else { return [] }
let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
do {
try FileManager.default.createDirectory(at: attachmentsFolder, withIntermediateDirectories: true)
} catch {
throw BoardWriteError(
operation: batchOperation,
path: cardFolder.path,
reason: .io(message: "could not create attachments folder: \(error.localizedDescription)")
)
}
var moved: [ImportedAttachment] = []
for name in relocatable {
// Each file names itself the ORIGINAL name, not the Finder-style renamed one decided
// on the next line, for `importAttachments`' reason: the operation describes the file
// the user (or their agent) actually wrote.
let operation = WriteOperation.relocateLooseFile(filename: name)
let sourceURL = cardFolder.appendingPathComponent(name)
let landed = freshAttachmentName(for: name, in: attachmentsFolder)
do {
try FileManager.default.moveItem(at: sourceURL, to: attachmentsFolder.appendingPathComponent(landed))
} catch {
throw BoardWriteError(
operation: operation,
path: sourceURL.path,
reason: .io(message: "could not move file into attachments: \(error.localizedDescription)")
)
}
moved.append(ImportedAttachment(sourceURL: sourceURL, fileName: landed))
}
return moved
}
/// Discovers *and* relocates the loose-file rule applied at an **import boundary**, where
/// there is no loader round trip to discover through (04-interactions.md Clipboard, settled
/// 2026-07-28: "A paste is an import boundary, so normalization applies loose files the staged
/// snapshot carries beside a card's `index.md` land in the pasted card's `attachments/`,
/// Finder-renamed on collision nothing the snapshot preserved is dropped on arrival").
///
/// The pasted card therefore lands **already normalized**, rather than arriving loose and being
/// tidied a reload later: the write is happening anyway, and one that leaves work behind for the
/// carve-out to find would also post the carve-out's notice a warning row about a mess the
/// user's own paste made and the app immediately cleaned up.
///
/// A card with nothing loose is one directory listing and no write at all.
@discardableResult
public static func normalizeLooseFiles(inCard cardFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] {
try relocateLooseFiles(BoardLoader.looseFileNames(in: cardFolder), inCard: cardFolder)
}
/// The lane-level face of the same import-boundary normalization: every card of an arriving
/// lane, in folder order.
///
/// Children are `childCandidates` the loader's own level detection so a stray *folder*
/// inside the arriving lane is neither descended into nor tidied, and nothing below a card is
/// reached: the carve-out is card-level and one level deep, exactly as 01 states it.
@discardableResult
public static func normalizeLooseFiles(inLane laneFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] {
var moved: [ImportedAttachment] = []
for card in childCandidates(of: laneFolder) {
moved.append(contentsOf: try normalizeLooseFiles(inCard: card))
}
return moved
}
/// Refuses any folder that is not a **card**: UUID-shaped, *under* a UUID-shaped parent.
///
/// `checkIsUUIDShaped` is the guard every other item write leans on, and it is the wrong one
/// here because it cannot tell a lane from a card both are UUID-shaped, which is exactly the
/// distinction the carve-out turns on ("everything at board or lane level keeps the verbatim
/// posture"; "board/lane-level strays are legitimate residents"). Pointing the relocation at a
/// lane would sweep a hand-editor's `notes.txt` into an `attachments/` folder no lane should
/// ever have.
///
/// The parent test is exact rather than heuristic because 01-storage-format.md § Fractal layout
/// fixes the depth: a card is `<root>/<lane>/<card>` and a lane is `<root>/<lane>`, so a
/// UUID-shaped folder whose parent is *also* UUID-shaped is a card and nothing else. It is the
/// same reading `BoardStore.boardRoot(ofCardFolder:)` already derives a root from.
private static func checkIsCardFolder(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) {
try checkIsUUIDShaped(folder, operation: operation)
guard BoardLoader.isUUIDShaped(folder.deletingLastPathComponent().lastPathComponent) else {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .unreadable(message: "folder is not a card: only a card's own files are relocated")
)
}
}
/// Whether `name` inside `cardFolder` is a file this app may relocate: a bare filename (never a
/// path), not hidden, not one of the reserved card-level names, and read from disk, at write
/// time a regular file that is not a symlink.
///
/// The four name rules restate the loader's listing exclusions rather than trusting them,
/// because `relocateLooseFiles` takes a caller's list: this is where "the carve-out is exactly
/// that narrow" stops being a convention and becomes something the filesystem-touching code
/// enforces on its own.
private static func isRelocatable(_ name: String, in cardFolder: URL) -> Bool {
guard !name.isEmpty,
!name.hasPrefix("."),
!name.contains("/"),
!BoardLoader.reservedCardChildNames.contains(name.lowercased()),
let values = try? cardFolder
.appendingPathComponent(name)
.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
else {
return false
}
return values.isRegularFile == true && values.isSymbolicLink != true
}
/// The Finder-style collision-free name for `originalName` landing in `folder`: the name
/// itself when nothing on disk claims it yet, else the base name suffixed `" 2"`, `" 3"`,
/// counting up from 2 against what is on disk *at decision time*, one collision at a time.
@@ -1324,6 +1486,16 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case listAttachments
case renumberChildren // order-maintenance sweep (compaction)
/// A loose file being moved out of a card folder into its `attachments/` the loose-file
/// carve-out's write (01-storage-format.md § Fractal layout Rules, settled 2026-07-28).
///
/// Its own case rather than a fold into `.importAttachment`, on `.rename`'s and
/// `.duplicateBoard`'s reasoning: nothing was *imported* no file crossed into the board, the
/// user dropped nothing, and a banner saying the app "couldn't import 'notes.txt'" would
/// describe a gesture that never happened. `filename` is the name as it sat beside `index.md`,
/// never the Finder-renamed one it would have landed under.
case relocateLooseFile(filename: String)
/// Fills in the title once the Writer has read it off the document the operation is acting
/// on identity for the six cases with no title slot at all: `createBoard`/`createLane`/
/// `createCard` are minting a file, not reading one; `importAttachment`'s "title" is the
@@ -1335,7 +1507,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// case is immutable once a caller has it in hand there is nothing to "forget" later.
public func withTitle(_ title: String?) -> WriteOperation {
switch self {
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments, .renumberChildren:
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
.renumberChildren, .relocateLooseFile:
self
case .move: .move(title: title)
case .reorder: .reorder(title: title)
@@ -1374,6 +1547,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .importAttachment(filename): "import attachment '\(filename)'"
case .listAttachments: "list attachments"
case .renumberChildren: "renumber children"
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
}
}