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:
@@ -320,6 +320,15 @@ public final class ClipboardStore {
|
||||
/// does rather than earning a sub-rule for the one case where the items were already on this
|
||||
/// board. It is cleared here rather than at ⌘V so the two staleness guards keep their meaning: a
|
||||
/// paste the pasteboard moved under lands nothing, and so clears nothing.
|
||||
///
|
||||
/// **A paste is an import boundary, so normalization applies** (04 ▸ Clipboard, settled
|
||||
/// 2026-07-28 — 01-storage-format.md's loose-file carve-out): every arrival below passes
|
||||
/// `normalizingLooseFiles: true`, so a loose file the staged snapshot faithfully carried beside
|
||||
/// a card's `index.md` lands inside the pasted card's `attachments/`, Finder-renamed on
|
||||
/// collision. Both branches and both operations, unqualified, because 04's sentence is
|
||||
/// unqualified. Nothing is dropped and nothing is announced: the snapshot preserved the file,
|
||||
/// the paste kept it, and it is where the schema says it belongs — the carve-out's notice is for
|
||||
/// files the app moves *without* being asked, which is the loader's path, not this one.
|
||||
private func perform(_ manifest: ClipboardManifest, plan: Plan, into store: BoardStore) {
|
||||
refresh()
|
||||
// The pasteboard moved under this paste (another app copied while the chain settled): the
|
||||
@@ -338,10 +347,17 @@ public final class ClipboardStore {
|
||||
operation: .move,
|
||||
toLane: target.laneID,
|
||||
at: target.index,
|
||||
clearingTombstones: false
|
||||
clearingTombstones: false,
|
||||
normalizingLooseFiles: true
|
||||
)
|
||||
case let .lanes(index):
|
||||
store.receiveLanes(sources, operation: .move, at: index, clearingTombstones: false)
|
||||
store.receiveLanes(
|
||||
sources,
|
||||
operation: .move,
|
||||
at: index,
|
||||
clearingTombstones: false,
|
||||
normalizingLooseFiles: true
|
||||
)
|
||||
}
|
||||
consumeCut()
|
||||
return
|
||||
@@ -383,14 +399,16 @@ public final class ClipboardStore {
|
||||
operation: .copy,
|
||||
toLane: target.laneID,
|
||||
at: target.index,
|
||||
clearingTombstones: clearingTombstones
|
||||
clearingTombstones: clearingTombstones,
|
||||
normalizingLooseFiles: true
|
||||
)
|
||||
case let .lanes(index):
|
||||
store.receiveLanes(
|
||||
sources,
|
||||
operation: .copy,
|
||||
at: index,
|
||||
clearingTombstones: clearingTombstones
|
||||
clearingTombstones: clearingTombstones,
|
||||
normalizingLooseFiles: true
|
||||
)
|
||||
}
|
||||
store.banners.postDegradedPaste(losses)
|
||||
|
||||
@@ -359,6 +359,44 @@ public final class BannerCenter {
|
||||
postLoss(message)
|
||||
}
|
||||
|
||||
/// One card whose loose files were relocated into `attachments/` — what
|
||||
/// `relocatedLooseFilesMessage(for:)` names.
|
||||
///
|
||||
/// `fileNames` are the names the files had **beside `index.md`**, not the Finder-renamed ones
|
||||
/// they may have landed under: those are the names the user or their agent wrote, and the one
|
||||
/// they would recognize in a sentence (`WriteOperation.importAttachment`'s own rule, read for
|
||||
/// the relocation). `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).
|
||||
public struct Relocation: Sendable, Equatable {
|
||||
public let title: String?
|
||||
public let fileNames: [String]
|
||||
|
||||
public init(title: String?, fileNames: [String]) {
|
||||
self.title = title
|
||||
self.fileNames = fileNames
|
||||
}
|
||||
}
|
||||
|
||||
/// **The loose-file relocation** (01-storage-format.md § Fractal layout ▸ Rules, settled
|
||||
/// 2026-07-28): a file was sitting beside a card's `index.md`, the app moved it into that card's
|
||||
/// `attachments/`, and this is the row that says so — "surfacing a graceful warning-tone notice
|
||||
/// naming the card and files".
|
||||
///
|
||||
/// **A loss row, though nothing was lost.** The class is the vocabulary's warning-tone,
|
||||
/// user-dismissed, never-expiring one — `LossBanner`'s "their future kin" — and this is exactly
|
||||
/// that shape read once more: the app did something to the user's files that they did not ask
|
||||
/// for, so it must be said out loud, it must not evaporate unread, and it must not rank as an
|
||||
/// error, because no action failed. `signpost` would be too quiet (it ranks last and may
|
||||
/// collapse behind "+N more"); `oneShot` would be a lie (it carries a `BoardWriteError`, and
|
||||
/// the write succeeded). The name of the class is about its *lifecycle and tone*, not about
|
||||
/// loss being the only thing it can report.
|
||||
///
|
||||
/// A relocation that moved nothing posts nothing.
|
||||
public func postRelocatedLooseFiles(_ relocations: [Relocation]) {
|
||||
guard let message = Self.relocatedLooseFilesMessage(for: relocations) else { return }
|
||||
postLoss(message)
|
||||
}
|
||||
|
||||
/// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its
|
||||
/// folders (04-interactions.md ▸ Selection, drag & drop, "Folders are refused at hover"): "a
|
||||
/// mixed drag proposes for its files only, and the drop imports the files while a one-shot
|
||||
@@ -595,6 +633,14 @@ public final class BannerCenter {
|
||||
"Couldn't read this card's attachments"
|
||||
case .renumberChildren:
|
||||
"Couldn't renumber cards"
|
||||
case let .relocateLooseFile(filename):
|
||||
// The verb matches the successful notice's ("Moved 'notes.txt' into attachments"), so
|
||||
// the failure reads as the same sentence negated rather than as a different event.
|
||||
// It stays in the ordinary one-shot precedence class rather than joining the attachment
|
||||
// imports at the bottom: the relocation is work the *app* started on its own, and a
|
||||
// failure the user did not provoke is exactly the one they have no other way to learn
|
||||
// about.
|
||||
"Couldn't move '\(filename)' into attachments"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,6 +725,44 @@ public final class BannerCenter {
|
||||
"Folders can't be attached — \(count) skipped"
|
||||
}
|
||||
|
||||
/// The loose-file relocation's line — 01-storage-format.md's own example sentence, "Moved
|
||||
/// 'notes.txt' into attachments — 'Fix login'", generalized over the two axes it varies on.
|
||||
///
|
||||
/// **Plurals fold twice**, which is the design's word for it ("plurals fold"; "multiple files
|
||||
/// one card → 'Moved 3 files into attachments — Fix login'"):
|
||||
///
|
||||
/// - **One card, one file** names the file *and* the card, which is the sentence the design
|
||||
/// wrote: both facts fit, so both are said.
|
||||
/// - **One card, several files** drops the filenames for their count. A banner is one line, and
|
||||
/// a list of names would be the first thing to truncate; the card is still named, which is
|
||||
/// what makes the notice actionable — the user knows exactly which `attachments/` to look in.
|
||||
/// - **Several cards** folds again, to two counts: "Moved 5 files into attachments — 3 cards"
|
||||
/// (settled here, the judgment 01 leaves to the implementation). It is the degraded paste's
|
||||
/// own shape — "Pasted 2 items without their 5 files" — and for its reason: the true total
|
||||
/// plus the true item count is the honest summary where a truncated list of titles would not
|
||||
/// be. This case is the whole-board sweep (a board opened after an agent scattered files
|
||||
/// across it), where naming three cards of eleven would read as a bug.
|
||||
///
|
||||
/// The multi-card branch never has to spell a singular: two cards carry at least two files.
|
||||
///
|
||||
/// `nil` when nothing moved — a relocation that relocated nothing is not news. Entries with no
|
||||
/// filenames are dropped first, so a caller need not filter its own list.
|
||||
public nonisolated static func relocatedLooseFilesMessage(for relocations: [Relocation]) -> String? {
|
||||
let cards = relocations.filter { !$0.fileNames.isEmpty }
|
||||
guard let only = cards.first else { return nil }
|
||||
|
||||
let total = cards.reduce(0) { $0 + $1.fileNames.count }
|
||||
guard cards.count == 1 else {
|
||||
return "Moved \(total) files into attachments — \(cards.count) cards"
|
||||
}
|
||||
|
||||
let subject = only.title.map { "'\($0)'" } ?? "an untitled card"
|
||||
guard total == 1, let name = only.fileNames.first else {
|
||||
return "Moved \(total) files into attachments — \(subject)"
|
||||
}
|
||||
return "Moved '\(name)' into attachments — \(subject)"
|
||||
}
|
||||
|
||||
/// The suspended-history line. It names the *consequence* the user cares about — undo and the
|
||||
/// flush-before-overwrite guarantee are degraded — rather than the git mechanics, and carries
|
||||
/// the diagnosis as its tail.
|
||||
|
||||
@@ -147,6 +147,16 @@ public final class BoardStore {
|
||||
/// describe the tree currently on screen.
|
||||
public private(set) var loadWarnings: [LoadWarning]
|
||||
|
||||
/// The cards the load that produced `snapshot` found holding loose files, exactly as the loader
|
||||
/// reported them — the loose-file carve-out's detection channel (01-storage-format.md § Fractal
|
||||
/// layout ▸ Rules, settled 2026-07-28). Replaced with the snapshot, like `loadWarnings`, so it
|
||||
/// always describes the tree currently on screen.
|
||||
///
|
||||
/// **Nothing renders it.** A loose file is not content — it reaches no view, and the card it
|
||||
/// sits in draws exactly as it would without it. Its one consumer is
|
||||
/// `relocateLooseCardFiles()`, immediately below the reload that produced it.
|
||||
public private(set) var looseCardFiles: [LooseCardFiles]
|
||||
|
||||
/// The standing read-side condition: the error from the last reload that failed, `nil` when the
|
||||
/// board is healthy. `BoardLoadError` already carries fail-fast's specifics — the offending path
|
||||
/// and what is wrong with it — which is the whole of what the banner needs to render
|
||||
@@ -302,6 +312,12 @@ public final class BoardStore {
|
||||
@ObservationIgnored
|
||||
private var quiescenceWaiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
/// The loose-file set the last relocation attempt was made against — the loop guard
|
||||
/// `relocateLooseCardFiles()` documents. Empty means "nothing has been attempted against the
|
||||
/// current picture", which is both the opening state and what a clean board resets it to.
|
||||
@ObservationIgnored
|
||||
private var attemptedRelocation: Set<String> = []
|
||||
|
||||
/// Awaited off the main actor **after** a tree walk finishes and **before** its result is
|
||||
/// applied — the one seam this type keeps, `nil` in production.
|
||||
///
|
||||
@@ -327,11 +343,19 @@ public final class BoardStore {
|
||||
///
|
||||
/// The walk is synchronous because the caller has nothing to render until it lands; the
|
||||
/// asynchronous, off-main pipeline starts with the first reload.
|
||||
///
|
||||
/// **It writes nothing, the opened board's loose files included.** `looseCardFiles` is recorded
|
||||
/// here and acted on by whoever wired this store up — `BoardStoreRegistry.acquire` calls
|
||||
/// `relocateLooseCardFiles()` once the watcher and the brackets exist, so the relocation is a
|
||||
/// bracketed write with a reload behind it rather than a write into a board nothing is watching
|
||||
/// yet. A store built directly (a test, a storeless consumer) relocates when it is asked to, and
|
||||
/// on every reload thereafter.
|
||||
public init(rootURL: URL) throws(BoardLoadError) {
|
||||
let result = try BoardLoader.load(boardRoot: rootURL)
|
||||
self.rootURL = rootURL
|
||||
self.snapshot = result.model
|
||||
self.loadWarnings = result.warnings
|
||||
self.looseCardFiles = result.looseCardFiles
|
||||
self.reloadFailure = nil
|
||||
self.readOnlyLock = nil
|
||||
self.transient = TransientBoardState()
|
||||
@@ -478,6 +502,7 @@ public final class BoardStore {
|
||||
// Breakage always heals on a success — it *is* the claim "the last reload failed", and
|
||||
// this one did not.
|
||||
reloadFailure = nil
|
||||
looseCardFiles = result.looseCardFiles
|
||||
clearLockIfDisproved(by: origin)
|
||||
// The registry write-through, for the same "not board structure" reason the lock
|
||||
// clearing sits out here: whether this board's row needs a new title, icon, or
|
||||
@@ -485,6 +510,11 @@ public final class BoardStore {
|
||||
// guard), not a decision this store makes by comparing against its own prior
|
||||
// snapshot.
|
||||
displayStateDelegate?()
|
||||
// Last, and after `clearLockIfDisproved` deliberately: this is the seam the deferred
|
||||
// relocation is armed on. A board that was locked read-only tolerated its loose files
|
||||
// for exactly as long as the lock stood, and the reload that clears the lock is the
|
||||
// reload that lets them move — see `relocateLooseCardFiles()`.
|
||||
relocateLooseCardFiles()
|
||||
|
||||
case let .failure(error):
|
||||
// `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload
|
||||
@@ -1455,7 +1485,14 @@ public final class BoardStore {
|
||||
/// finest grain (01-storage-format.md's per-folder degradation, which is `moveItem`'s own
|
||||
/// behaviour rather than something this method arranges).
|
||||
public func receiveCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) {
|
||||
receive(sources.map(ItemSource.folder), operation: operation, toLane: laneID, at: index, clearingTombstones: false)
|
||||
receive(
|
||||
sources.map(ItemSource.folder),
|
||||
operation: operation,
|
||||
toLane: laneID,
|
||||
at: index,
|
||||
clearingTombstones: false,
|
||||
normalizingLooseFiles: false
|
||||
)
|
||||
}
|
||||
|
||||
/// **The clipboard's card arrival** — `receiveCards`/`receiveRestoredCards` with the two axes a
|
||||
@@ -1467,14 +1504,35 @@ public final class BoardStore {
|
||||
/// is stripped **at materialization**". A cut is live-only (⌘X is disabled in the trash), so the
|
||||
/// two flags never both fire; the parameter is not narrowed for that, because which of them is
|
||||
/// reachable is the *clipboard's* rule and this method's job is only to obey both.
|
||||
///
|
||||
/// `normalizingLooseFiles` is the third axis: **"a paste is an import boundary, so normalization
|
||||
/// applies"** (04-interactions.md ▸ Clipboard, settled 2026-07-28 — 01-storage-format.md's
|
||||
/// loose-file rule). Loose files the staged snapshot carries beside a card's `index.md` land in
|
||||
/// the pasted card's `attachments/`, Finder-renamed on collision, so "nothing the snapshot
|
||||
/// preserved is dropped on arrival" *and* nothing arrives out of place.
|
||||
///
|
||||
/// It has **no default**, here and on `receiveLanes`, so every arrival path states which side of
|
||||
/// the import boundary it is on rather than inheriting an answer. The clipboard passes `true`
|
||||
/// (both operations: 04 says "a paste is an import boundary" unqualified, and an armed cut's
|
||||
/// move is a paste); the drag passes `false` and leaves its arrivals to the destination board's
|
||||
/// own carve-out, which relocates on the next reload with the notice a user-initiated paste has
|
||||
/// no need of.
|
||||
public func receiveCards(
|
||||
_ sources: [ItemSource],
|
||||
operation: TransferOperation,
|
||||
toLane laneID: ItemID,
|
||||
at index: Int,
|
||||
clearingTombstones: Bool
|
||||
clearingTombstones: Bool,
|
||||
normalizingLooseFiles: Bool
|
||||
) {
|
||||
receive(sources, operation: operation, toLane: laneID, at: index, clearingTombstones: clearingTombstones)
|
||||
receive(
|
||||
sources,
|
||||
operation: operation,
|
||||
toLane: laneID,
|
||||
at: index,
|
||||
clearingTombstones: clearingTombstones,
|
||||
normalizingLooseFiles: normalizingLooseFiles
|
||||
)
|
||||
}
|
||||
|
||||
/// The cross-board half of drag-to-restore (04-interactions.md ▸ The trash): tombstoned rows
|
||||
@@ -1495,7 +1553,14 @@ public final class BoardStore {
|
||||
/// because `restoreItem` is already the one expression in the app for "remove the `deleted:`
|
||||
/// key" — the bytes are never rewritten any other way.
|
||||
public func receiveRestoredCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) {
|
||||
receive(sources.map(ItemSource.folder), operation: operation, toLane: laneID, at: index, clearingTombstones: true)
|
||||
receive(
|
||||
sources.map(ItemSource.folder),
|
||||
operation: operation,
|
||||
toLane: laneID,
|
||||
at: index,
|
||||
clearingTombstones: true,
|
||||
normalizingLooseFiles: false
|
||||
)
|
||||
}
|
||||
|
||||
private func receive(
|
||||
@@ -1503,7 +1568,8 @@ public final class BoardStore {
|
||||
operation: TransferOperation,
|
||||
toLane laneID: ItemID,
|
||||
at index: Int,
|
||||
clearingTombstones: Bool
|
||||
clearingTombstones: Bool,
|
||||
normalizingLooseFiles: Bool
|
||||
) {
|
||||
guard !sources.isEmpty,
|
||||
let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted })
|
||||
@@ -1535,8 +1601,14 @@ public final class BoardStore {
|
||||
sourceBoardRoot: Self.boardRoot(ofCardFolder:),
|
||||
order: rank
|
||||
) else { continue }
|
||||
let cardFolder = laneFolder.appendingPathComponent(arrived.rawValue, isDirectory: true)
|
||||
// Inside the same bracket, so the card lands normalized in one round trip rather
|
||||
// than appearing loose for a reload and being tidied afterwards.
|
||||
if normalizingLooseFiles {
|
||||
try BoardWriter.normalizeLooseFiles(inCard: cardFolder)
|
||||
}
|
||||
guard clearingTombstones else { continue }
|
||||
try BoardWriter.restoreItem(at: laneFolder.appendingPathComponent(arrived.rawValue, isDirectory: true))
|
||||
try BoardWriter.restoreItem(at: cardFolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1600,7 +1672,13 @@ public final class BoardStore {
|
||||
/// not exist by drag at all (⌥ is ignored on lane drags; the clipboard is that operation's one
|
||||
/// home), so this method is cross-board by construction.
|
||||
public func receiveLanes(_ sources: [URL], operation: TransferOperation, at stripIndex: Int) {
|
||||
receiveLanes(sources.map(ItemSource.folder), operation: operation, at: stripIndex, clearingTombstones: false)
|
||||
receiveLanes(
|
||||
sources.map(ItemSource.folder),
|
||||
operation: operation,
|
||||
at: stripIndex,
|
||||
clearingTombstones: false,
|
||||
normalizingLooseFiles: false
|
||||
)
|
||||
}
|
||||
|
||||
/// **The clipboard's lane arrival** — `receiveLanes` with the staging-less fallback and the
|
||||
@@ -1614,11 +1692,16 @@ public final class BoardStore {
|
||||
/// destination — the lane-level twin of `receiveRestoredCards`, and the reason the strip runs
|
||||
/// first is that the two writes touch different files and the strip's target list is the one that
|
||||
/// must be read before anything is rewritten.
|
||||
///
|
||||
/// `normalizingLooseFiles` is the import boundary's, exactly as on `receiveCards` and with the
|
||||
/// same no-default rule; at lane level it reaches each arriving lane's **cards**, which is the
|
||||
/// only level the carve-out has (a lane's own loose files keep the verbatim posture).
|
||||
public func receiveLanes(
|
||||
_ sources: [ItemSource],
|
||||
operation: TransferOperation,
|
||||
at stripIndex: Int,
|
||||
clearingTombstones: Bool
|
||||
clearingTombstones: Bool,
|
||||
normalizingLooseFiles: Bool
|
||||
) {
|
||||
guard !sources.isEmpty else { return }
|
||||
|
||||
@@ -1654,6 +1737,11 @@ public final class BoardStore {
|
||||
if operation == .copy {
|
||||
try BoardWriter.stripTombstonedChildren(of: laneFolder)
|
||||
}
|
||||
// After the strip, so a tombstoned card the copy is about to remove is not tidied
|
||||
// on its way to being deleted.
|
||||
if normalizingLooseFiles {
|
||||
try BoardWriter.normalizeLooseFiles(inLane: laneFolder)
|
||||
}
|
||||
if clearingTombstones {
|
||||
try BoardWriter.restoreItem(at: laneFolder)
|
||||
}
|
||||
@@ -1703,6 +1791,95 @@ public final class BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The loose-file carve-out
|
||||
|
||||
/// Moves every loose file the last applied snapshot found beside a card's `index.md` into that
|
||||
/// card's `attachments/`, and posts one notice naming what moved — the **act** half of
|
||||
/// 01-storage-format.md's loose-file carve-out (§ Fractal layout ▸ Rules, settled 2026-07-28,
|
||||
/// "Lanework-owns-the-board"; the loader's `looseCardFiles` is the notice half).
|
||||
///
|
||||
/// **It is an ordinary app write and nothing more.** One `performWrite` bracket over the whole
|
||||
/// board's worth of relocation, so the churn rounds back as a single app-mediated reload and (on
|
||||
/// git boards) a single commit — the style batch's rule, applied to a batch the app started
|
||||
/// itself. The snapshot is not touched here any more than it is anywhere else: the files move,
|
||||
/// the watcher notices, the reload lands.
|
||||
///
|
||||
/// ### The read-only lock defers it, it does not cancel it
|
||||
///
|
||||
/// "The relocation … waits out any read-only lock — strays stay tolerated until it clears." A
|
||||
/// locked board returns here having written nothing **and having remembered nothing**, so the
|
||||
/// next attempt is a fresh one. The arming seam is `land(_:generation:origin:)`: every lock
|
||||
/// clears on a successful reload and nowhere else, and this runs at the end of every successful
|
||||
/// reload, after `clearLockIfDisproved` — so the reload that lifts the lock is the reload that
|
||||
/// performs the relocation, with no timer, no queue, and no second state to keep in step.
|
||||
///
|
||||
/// ### It cannot hot-loop
|
||||
///
|
||||
/// The relocation's own reload re-walks the tree, which is the loop the guard exists for. After
|
||||
/// a success the walk finds nothing loose, `looseCardFiles` empties, and the memo below is
|
||||
/// cleared — the ordinary resting state. After a *failure* the walk finds the same files again,
|
||||
/// and an unguarded call would fail again, forever, at the speed of a directory walk. So an
|
||||
/// attempt is made only when the loose-file set **differs from the last one attempted**: one
|
||||
/// failure, one banner row, then silence until the picture on disk actually changes (a file
|
||||
/// added, removed, or partially moved by the failed attempt itself — each of which is a
|
||||
/// different set and so a fresh attempt).
|
||||
///
|
||||
/// The failure is the banner's already: `performWrite` posts every `BoardWriteError` before it
|
||||
/// rethrows, and the rethrow is swallowed here like every other gesture with nothing else to do
|
||||
/// about it. Files moved before the failure stay moved, and the notice names exactly those.
|
||||
public func relocateLooseCardFiles() {
|
||||
let work = looseCardFiles
|
||||
guard !work.isEmpty else {
|
||||
// The resting state, and the memo's reset: a board with nothing loose has nothing to
|
||||
// remember having tried.
|
||||
attemptedRelocation = []
|
||||
return
|
||||
}
|
||||
// Deferred, not abandoned — and deliberately *before* the memo is written, so the attempt
|
||||
// this lock refused is not the attempt the guard below remembers.
|
||||
guard readOnlyLock == nil else {
|
||||
Self.logger.debug("loose-file relocation deferred — the board is read-only")
|
||||
return
|
||||
}
|
||||
let signature = Self.relocationSignature(of: work)
|
||||
guard signature != attemptedRelocation else { return }
|
||||
attemptedRelocation = signature
|
||||
|
||||
let root = rootURL
|
||||
var relocated: [BannerCenter.Relocation] = []
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for card in work {
|
||||
let folder = root
|
||||
.appendingPathComponent(card.laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(card.cardID.rawValue, isDirectory: true)
|
||||
let moved = try BoardWriter.relocateLooseFiles(card.fileNames, inCard: folder)
|
||||
// A card whose files all vanished under the write contributes no line: the Writer
|
||||
// skipped them because they are gone, and nothing was moved to report.
|
||||
guard !moved.isEmpty else { continue }
|
||||
relocated.append(BannerCenter.Relocation(
|
||||
title: card.title,
|
||||
fileNames: moved.map { $0.sourceURL.lastPathComponent }
|
||||
))
|
||||
}
|
||||
}
|
||||
banners.postRelocatedLooseFiles(relocated)
|
||||
}
|
||||
|
||||
/// The loose-file picture as a comparable value: one entry per file, keyed by where it sits.
|
||||
///
|
||||
/// A `Set` rather than the array itself because the *identity* of the work is what matters, not
|
||||
/// the order the walk happened to meet it in — and because two loads of an unchanged tree must
|
||||
/// compare equal even if a lane's folder-name ordering shifted underneath them.
|
||||
nonisolated static func relocationSignature(of work: [LooseCardFiles]) -> Set<String> {
|
||||
var signature: Set<String> = []
|
||||
for card in work {
|
||||
for name in card.fileNames {
|
||||
signature.insert("\(card.laneID.rawValue)/\(card.cardID.rawValue)/\(name)")
|
||||
}
|
||||
}
|
||||
return signature
|
||||
}
|
||||
|
||||
/// Creates one card per file at `index` in `laneID`, each titled with its filename minus the
|
||||
/// extension and carrying that file as its attachment — the drop-into-a-lane half.
|
||||
///
|
||||
|
||||
@@ -204,6 +204,19 @@ public final class BoardStoreRegistry {
|
||||
bookmark: bookmark,
|
||||
lastKnownRoot: rootURL
|
||||
)
|
||||
|
||||
// The loose-file carve-out's first firing (01-storage-format.md § Fractal layout ▸ Rules,
|
||||
// settled 2026-07-28): files an agent or a hand-editor left beside a card's `index.md`
|
||||
// while this board was closed are relocated into `attachments/` now, with the notice.
|
||||
//
|
||||
// **Here rather than in `BoardStore.init`**, and last rather than first: the store's own
|
||||
// init is one tree walk and no writes, and a relocation written before the brackets and the
|
||||
// watcher exist would be a write nothing is watching — landing on disk with the snapshot
|
||||
// above it left one reload stale. By this line the pair is wired, so it is an ordinary
|
||||
// bracketed app write whose echo reload refreshes the board like any other. Every reload
|
||||
// thereafter re-fires it from `BoardStore.land`; this call is only the one the opening walk
|
||||
// would otherwise have no reload behind.
|
||||
store.relocateLooseCardFiles()
|
||||
return store
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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