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:
@@ -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)'"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user