Implement move and copy identity semantics
moveItem: physical folder move, UUID and created travel unchanged; exactly one file rewritten (the moved root's order, stamped). Import boundary detected by resolved board-root comparison; on a cross-board move-in, arriving UUIDs colliding with any identity in the destination board (tombstones included) are reminted per folder at the finest grain — folder rename only, file bytes untouched, every repair reported in MoveResult. A colliding root moves straight to its minted name. A same-parent move degrades to a plain reorder, self excluded from the appended-rank scan. copyItem: whole-tree copy minting fresh UUIDs at every depth; .fork keeps created while stamping modified and clearing modified-by, .born (template instantiation) stamps created fresh too. Nested uneditable or unreadable files copy byte-verbatim rather than blocking the gesture; the root must be rewritable. All-or-nothing at the destination — any failure removes the partial tree. 24 new unit tests; 220 total green. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
@@ -195,7 +195,7 @@ public enum BoardWriter: Sendable {
|
||||
title: String?,
|
||||
operation: String
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
try checkIsDirectory(parentFolder, operation: operation)
|
||||
try checkIsDirectory(parentFolder, describedAs: "parent folder", operation: operation)
|
||||
|
||||
let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false)
|
||||
let order = Ranks.append(toVisible: siblings.map(\.order))
|
||||
@@ -231,19 +231,15 @@ public enum BoardWriter: Sendable {
|
||||
return document.serialized()
|
||||
}
|
||||
|
||||
/// A fresh lowercase-UUIDv4 folder under `parentFolder` — the folder-naming convention
|
||||
/// itself (01-storage-format.md § Fractal layout ▸ Rules, "Folder names are lowercase
|
||||
/// UUIDv4"). `UUID().uuidString` is uppercase; `.lowercased()` is what makes the name match
|
||||
/// `BoardLoader.isUUIDShaped`, which is case-sensitive by design. A freshly minted UUID
|
||||
/// already existing is astronomically unlikely — 122 bits of randomness per mint — but
|
||||
/// checked for and re-minted anyway rather than assumed away; the loop body is trivial
|
||||
/// precisely because the case it handles essentially never fires.
|
||||
/// A fresh lowercase-UUIDv4 folder under `parentFolder` — the create path's mint, which
|
||||
/// materializes the folder as well as naming it. The naming rule itself lives in
|
||||
/// `freshUUIDName(in:avoiding:)`, shared with the move and copy paths so every identity the
|
||||
/// app mints is minted one way.
|
||||
private static func mintUUIDFolder(in parentFolder: URL, operation: String) throws(BoardWriteError) -> URL {
|
||||
var candidate: URL
|
||||
repeat {
|
||||
candidate = parentFolder.appendingPathComponent(UUID().uuidString.lowercased(), isDirectory: true)
|
||||
} while FileManager.default.fileExists(atPath: candidate.path)
|
||||
|
||||
let candidate = parentFolder.appendingPathComponent(
|
||||
freshUUIDName(in: parentFolder, avoiding: []),
|
||||
isDirectory: true
|
||||
)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: candidate, withIntermediateDirectories: false)
|
||||
} catch {
|
||||
@@ -256,18 +252,43 @@ public enum BoardWriter: Sendable {
|
||||
return candidate
|
||||
}
|
||||
|
||||
/// The parent-exists check `createChild` runs before touching the filesystem any further —
|
||||
/// shared instead of folded into `createChild` because "does this folder exist" has nothing
|
||||
/// to do with siblings or minting, and inlining it would bury the one thing a caller most
|
||||
/// needs to see at a glance: a missing or wrong-shaped parent is rejected before anything
|
||||
/// else happens.
|
||||
private static func checkIsDirectory(_ url: URL, operation: String) throws(BoardWriteError) {
|
||||
/// A fresh lowercase-UUIDv4 folder *name* for `parentFolder` — the folder-naming convention
|
||||
/// itself (01-storage-format.md § Fractal layout ▸ Rules, "Folder names are lowercase
|
||||
/// UUIDv4"). `UUID().uuidString` is uppercase; `.lowercased()` is what makes the name match
|
||||
/// `BoardLoader.isUUIDShaped`, which is case-sensitive by design.
|
||||
///
|
||||
/// Two exclusions, both re-minted rather than assumed away: a name already on disk in
|
||||
/// `parentFolder` (so the caller's `createDirectory`/`moveItem`/`copyItem` cannot lose a
|
||||
/// race with an existing entry), and any name in `taken` — the identities a collision repair
|
||||
/// is minting *away* from, which are not necessarily on disk here. A freshly minted UUID
|
||||
/// hitting either is astronomically unlikely — 122 bits of randomness per mint — but the
|
||||
/// loop body is trivial precisely because the case it handles essentially never fires.
|
||||
private static func freshUUIDName(in parentFolder: URL, avoiding taken: Set<String>) -> String {
|
||||
var name: String
|
||||
repeat {
|
||||
name = UUID().uuidString.lowercased()
|
||||
} while taken.contains(name)
|
||||
|| FileManager.default.fileExists(atPath: parentFolder.appendingPathComponent(name).path)
|
||||
return name
|
||||
}
|
||||
|
||||
/// The folder-exists check every operation runs before touching the filesystem any further —
|
||||
/// shared instead of folded into its callers because "does this folder exist" has nothing to
|
||||
/// do with siblings, minting, or moving, and inlining it would bury the one thing a caller
|
||||
/// most needs to see at a glance: a missing or wrong-shaped folder is rejected before
|
||||
/// anything else happens. `role` names it the way the failing user action would ("parent
|
||||
/// folder", "item folder") — the error goes straight into the write-failure banner.
|
||||
private static func checkIsDirectory(
|
||||
_ url: URL,
|
||||
describedAs role: String,
|
||||
operation: String
|
||||
) throws(BoardWriteError) {
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else {
|
||||
throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "parent folder does not exist"))
|
||||
throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "\(role) does not exist"))
|
||||
}
|
||||
guard isDirectory.boolValue else {
|
||||
throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "parent is not a directory"))
|
||||
throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "\(role) is not a directory"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,6 +392,391 @@ public enum BoardWriter: Sendable {
|
||||
return visible
|
||||
}
|
||||
|
||||
// MARK: - Move
|
||||
|
||||
/// Moves a lane or card — and the whole tree beneath it — under another parent, in the same
|
||||
/// board or across boards. **A move is a physical folder move and the UUID travels
|
||||
/// unchanged** (01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle: moves
|
||||
/// keep the UUID, copies mint fresh ones"): nothing below the moved root is read or
|
||||
/// rewritten, so nested cards, `attachments/`, and strays arrive byte-identical by
|
||||
/// construction rather than by copying carefully.
|
||||
///
|
||||
/// Exactly one file is rewritten — the moved root's `index.md`, and only to carry its new
|
||||
/// `order` (§ Ordering, "a reorder rewrites only the moved item's `index.md`"), stamped and
|
||||
/// `modified-by`-cleared like every other app write. `order` is the caller's explicit rank
|
||||
/// (a drop between two siblings), or `nil` to append after the destination's visible
|
||||
/// siblings.
|
||||
///
|
||||
/// **The import boundary is the one place within-board uniqueness is enforced** (§ Fractal
|
||||
/// layout ▸ Rules). `sourceBoardRoot` and `destinationBoardRoot` are compared by resolved,
|
||||
/// standardized path; when they differ, this is an import, and an arriving UUID that already
|
||||
/// exists anywhere in the destination board — **tombstones included, because they are on
|
||||
/// disk** — is degraded to a copy: a fresh UUID is minted for that folder and nothing else.
|
||||
/// Degradation is **per folder, at the finest grain**: a lane arriving with one colliding
|
||||
/// card is still a lane *move*, and only that card arrives reminted. A remint is an identity
|
||||
/// repair, not an edit — the folder is renamed and its files' bytes are never touched, so
|
||||
/// the reminted item's `modified`, `modified-by`, and history are exactly what the source
|
||||
/// had. Every repair is reported in `MoveResult.reminted`; a same-board move never remints,
|
||||
/// and the same UUID living in two boards is not an error anywhere else — boards are
|
||||
/// **independent identity namespaces**, and forks only ever meet here.
|
||||
///
|
||||
/// The order of operations is the contract:
|
||||
///
|
||||
/// 1. **Validate before anything else**: source and destination parent must be existing
|
||||
/// directories, and the source must be UUID-shaped — this writer moves lanes and cards,
|
||||
/// and a stray is not one (§ Fractal layout ▸ Rules, "Name shape gates level detection").
|
||||
/// 2. **Pre-flight the moved root's editability** (`readDocument` + `checkEditable`): the
|
||||
/// move must rewrite that file at the destination, so a file that cannot be round-tripped
|
||||
/// refuses *before* the folder moves — discover-before-you-write, the same guarantee
|
||||
/// `renumberVisibleChildren` depends on. Only the root needs it: a move never rewrites a
|
||||
/// nested file, so an uneditable card inside a moved lane is none of this call's business.
|
||||
/// 3. **Compute the destination `order` before the move**, off the destination's visible
|
||||
/// siblings — while the moved item is still elsewhere and so cannot count itself.
|
||||
/// 4. **Scan the destination board's identities** (depth 1 and 2, `directoryCandidates` +
|
||||
/// `isUUIDShaped`; strays skipped, tombstones kept) — but only on an import, since a
|
||||
/// same-board move cannot collide with anything but itself.
|
||||
/// 5. **Move the folder** (`FileManager.moveItem`, which degrades to copy+remove across
|
||||
/// volumes). A colliding *root* is renamed by moving it straight to its minted name
|
||||
/// rather than moving and then renaming: one filesystem operation instead of two, and it
|
||||
/// is also the only way the repair works when the collision is a sibling sitting in the
|
||||
/// very destination parent — a plain move onto an existing name fails outright.
|
||||
/// 6. **Repair the arrived tree's children** — the moved root's direct UUID-shaped children,
|
||||
/// which is every level a compound arrival can carry (a lane's cards; a card has no
|
||||
/// UUID-shaped children).
|
||||
/// 7. **Rewrite the moved root's `index.md`** with the rank from step 3.
|
||||
///
|
||||
/// **Atomic per filesystem operation, not per gesture** — the accepted edge. A rename that
|
||||
/// fails mid-repair, or the `updateIndex` that fails once the folder has already arrived,
|
||||
/// leaves the item at the destination with a stale `order` and possibly a partly repaired
|
||||
/// tree. Every value on disk is still valid, the failure is surfaced verbatim through the
|
||||
/// thrown error (02-architecture.md § Write-failure surfacing), and the caller's reload
|
||||
/// shows the true state — nothing is silently retried or rolled back behind the user's back.
|
||||
///
|
||||
/// **A move whose destination is the item's current parent degrades to a plain reorder** —
|
||||
/// no folder to move, no boundary to cross, just the `order` rewrite (step 7) with the
|
||||
/// appended rank computed over the siblings *excluding the item itself*, which unlike every
|
||||
/// other move is already there to be miscounted. A drop that lands back in its own lane is
|
||||
/// the same gesture as one that lands elsewhere; the writer doing the right degenerate
|
||||
/// thing keeps the API total instead of leaking a `FileManager` name collision.
|
||||
public static func moveItem(
|
||||
at sourceFolder: URL,
|
||||
toParent destinationParent: URL,
|
||||
sourceBoardRoot: URL,
|
||||
destinationBoardRoot: URL,
|
||||
order: Double?
|
||||
) throws(BoardWriteError) -> MoveResult {
|
||||
let operation = "move item"
|
||||
try checkIsDirectory(sourceFolder, describedAs: "item folder", operation: operation)
|
||||
try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
|
||||
try checkIsUUIDShaped(sourceFolder, operation: operation)
|
||||
try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation)
|
||||
|
||||
let sourceName = sourceFolder.lastPathComponent
|
||||
|
||||
if isSameLocation(sourceFolder.deletingLastPathComponent(), destinationParent) {
|
||||
let rank: Double
|
||||
if let order {
|
||||
rank = order
|
||||
} else {
|
||||
let siblings = try visibleSiblings(of: destinationParent, operation: operation, requireEditable: false)
|
||||
rank = Ranks.append(toVisible: siblings.filter { $0.folder.lastPathComponent != sourceName }.map(\.order))
|
||||
}
|
||||
try updateIndex(inItemFolder: sourceFolder, operation: operation) { document in
|
||||
document.set(FrontmatterKeys.order, to: .double(rank))
|
||||
}
|
||||
return MoveResult(id: ItemID(rawValue: sourceName), reminted: [])
|
||||
}
|
||||
|
||||
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
|
||||
|
||||
let isImport = !isSameLocation(sourceBoardRoot, destinationBoardRoot)
|
||||
let existing = isImport ? identities(inBoard: destinationBoardRoot) : []
|
||||
var reserved = existing
|
||||
var reminted: [MoveResult.Remint] = []
|
||||
|
||||
var arrivedName = sourceName
|
||||
if existing.contains(sourceName) {
|
||||
arrivedName = freshUUIDName(in: destinationParent, avoiding: reserved)
|
||||
reserved.insert(arrivedName)
|
||||
reminted.append(MoveResult.Remint(from: ItemID(rawValue: sourceName), to: ItemID(rawValue: arrivedName)))
|
||||
}
|
||||
let arrivedRoot = destinationParent.appendingPathComponent(arrivedName, isDirectory: true)
|
||||
|
||||
do {
|
||||
try FileManager.default.moveItem(at: sourceFolder, to: arrivedRoot)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: sourceFolder.path,
|
||||
reason: .io(message: "could not move folder: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
|
||||
if isImport {
|
||||
let children = childCandidates(of: arrivedRoot)
|
||||
reserved.formUnion(children.map(\.lastPathComponent))
|
||||
for child in children where existing.contains(child.lastPathComponent) {
|
||||
let fresh = freshUUIDName(in: arrivedRoot, avoiding: reserved)
|
||||
reserved.insert(fresh)
|
||||
try renameFolder(child, toSiblingNamed: fresh, operation: operation)
|
||||
reminted.append(MoveResult.Remint(
|
||||
from: ItemID(rawValue: child.lastPathComponent),
|
||||
to: ItemID(rawValue: fresh)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
try updateIndex(inItemFolder: arrivedRoot, operation: operation) { document in
|
||||
document.set(FrontmatterKeys.order, to: .double(rank))
|
||||
}
|
||||
|
||||
return MoveResult(id: ItemID(rawValue: arrivedName), reminted: reminted)
|
||||
}
|
||||
|
||||
/// Every identity a board already holds: its UUID-shaped lane folders and their UUID-shaped
|
||||
/// card folders — the two depths where identity lives (§ Fractal layout, "Level is
|
||||
/// position"). **Tombstoned items count**: a tombstone is a live folder on disk carrying a
|
||||
/// `deleted:` key, and arriving on top of one would be exactly the duplicate the import
|
||||
/// boundary exists to prevent (a Put Back would then resurrect a twin). Strays do not count
|
||||
/// — they are not identities at all — and neither does anything deeper: a card has no
|
||||
/// UUID-shaped children, and a UUID-shaped folder under `attachments/` is content, not a
|
||||
/// level.
|
||||
///
|
||||
/// Reads through the loader's own door (`directoryCandidates`), so the writer's idea of what
|
||||
/// a board contains can never drift from the loader's. An unreadable folder yields no
|
||||
/// candidates rather than failing: the same degradation the loader applies below the root,
|
||||
/// and the conservative direction here — a missed identity remints nothing, and a duplicate
|
||||
/// UUID in one board is the unspecified-behavior case the design already names, not a
|
||||
/// corruption.
|
||||
private static func identities(inBoard boardRoot: URL) -> Set<String> {
|
||||
var identities: Set<String> = []
|
||||
for lane in childCandidates(of: boardRoot) {
|
||||
identities.insert(lane.lastPathComponent)
|
||||
for card in childCandidates(of: lane) {
|
||||
identities.insert(card.lastPathComponent)
|
||||
}
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
/// A folder's UUID-shaped subfolders in deterministic order — `directoryCandidates` (hidden
|
||||
/// entries and symlinks already excluded) narrowed by `isUUIDShaped`, which is the loader's
|
||||
/// level-detection rule and therefore the only definition of "an identity-bearing child"
|
||||
/// this writer is allowed to have.
|
||||
private static func childCandidates(of folder: URL) -> [URL] {
|
||||
let candidates = (try? BoardLoader.directoryCandidates(in: folder)) ?? []
|
||||
return candidates.filter { BoardLoader.isUUIDShaped($0.lastPathComponent) }
|
||||
}
|
||||
|
||||
/// Renames a folder in place, keeping its parent — the whole of an identity repair, and of a
|
||||
/// copy's remint. The folder's contents, `index.md` included, are never opened.
|
||||
private static func renameFolder(
|
||||
_ folder: URL,
|
||||
toSiblingNamed name: String,
|
||||
operation: String
|
||||
) throws(BoardWriteError) {
|
||||
let destination = folder.deletingLastPathComponent().appendingPathComponent(name, isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.moveItem(at: folder, to: destination)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: folder.path,
|
||||
reason: .io(message: "could not rename folder: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two URLs name the same place on disk — symlinks resolved, `..`/`.` standardized
|
||||
/// away. The import boundary turns on this one comparison, so it is deliberately about
|
||||
/// *location*, not spelling: `/tmp/B.kanban` and `/private/tmp/B.kanban/.` are one board,
|
||||
/// and treating them as two would remint every arriving item for nothing.
|
||||
private static func isSameLocation(_ lhs: URL, _ rhs: URL) -> Bool {
|
||||
lhs.resolvingSymlinksInPath().standardizedFileURL.path
|
||||
== rhs.resolvingSymlinksInPath().standardizedFileURL.path
|
||||
}
|
||||
|
||||
// MARK: - Copy
|
||||
|
||||
/// Copies a lane or card — and the whole tree beneath it — under another parent, minting
|
||||
/// **fresh UUIDs for every folder it materializes** (01-storage-format.md § Fractal layout ▸
|
||||
/// Rules, "Identity lifecycle: moves keep the UUID, copies mint fresh ones"). The copy is a
|
||||
/// new identity at every level: a copied lane's cards are new cards, not the same cards seen
|
||||
/// twice. Returns the new root's identity. This is the item-level copy — ⌘C/⌘V, ⌥-drag
|
||||
/// duplicate, the cross-board drag default; whole-board copies (Save as Template, File ▸
|
||||
/// Duplicate) keep their GUIDs and are not this call.
|
||||
///
|
||||
/// Everything travels verbatim: `attachments/` and its subfolders, strays, bodies, unknown
|
||||
/// keys, comments, line endings. Only the schema's provenance stamps move, per `stamps`:
|
||||
///
|
||||
/// - `.fork` — an ordinary copy **keeps `created`** (it is a fork of something that really
|
||||
/// was created then) while `modified` is stamped and `modified-by` cleared, because a
|
||||
/// paste or a duplicate is an app write (§ Frontmatter).
|
||||
/// - `.born` — template instantiation stamps `created` **and** `modified` fresh, from one
|
||||
/// `Date` for the whole tree: a card made from a template is born today, not forked from
|
||||
/// the template (09-templates.md).
|
||||
///
|
||||
/// Two deliberate leniencies below the root, both of them "what a hand copy would do":
|
||||
///
|
||||
/// - A nested `index.md` that is **readable-but-uneditable** (§ Frontmatter), or that cannot
|
||||
/// be read at all, is copied byte-verbatim and simply not stamped. Refusing an entire copy
|
||||
/// because one nested card is a flow mapping would be hostile, and the file arrives
|
||||
/// *exactly* as it was rather than corrupted — its stale `modified-by` attribution
|
||||
/// surviving is the self-reported-provenance honest limit § Frontmatter already
|
||||
/// acknowledges. The **root** gets no such leniency: it must be rewritten (it needs its
|
||||
/// new `order`), so an unreadable or uneditable root refuses the copy up front, before
|
||||
/// anything is materialized.
|
||||
/// - A nested UUID-shaped folder with **no `index.md`** — interrupted-create residue — is
|
||||
/// copied and reminted like any other, and not rewritten: the same skip the loader applies
|
||||
/// to it (`.missingIndex`).
|
||||
///
|
||||
/// **All-or-nothing at the destination**, unlike a move: any failure once copying has begun
|
||||
/// removes the partially copied tree best-effort and rethrows, because a half-copied item is
|
||||
/// pure residue — nothing was there before, so there is no true state for a reload to show.
|
||||
/// The source is never touched on any path.
|
||||
public static func copyItem(
|
||||
at sourceFolder: URL,
|
||||
toParent destinationParent: URL,
|
||||
order: Double?,
|
||||
stamps: CopyStamps
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
let operation = "copy item"
|
||||
try checkIsDirectory(sourceFolder, describedAs: "item folder", operation: operation)
|
||||
try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
|
||||
try checkIsUUIDShaped(sourceFolder, operation: operation)
|
||||
try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation)
|
||||
|
||||
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
|
||||
|
||||
let rootName = freshUUIDName(in: destinationParent, avoiding: [])
|
||||
let root = destinationParent.appendingPathComponent(rootName, isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.copyItem(at: sourceFolder, to: root)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: sourceFolder.path,
|
||||
reason: .io(message: "could not copy folder: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
var copied: [URL] = []
|
||||
try remintDescendants(of: root, collecting: &copied, operation: operation)
|
||||
|
||||
let now = Date()
|
||||
try updateIndex(inItemFolder: root, operation: operation) { document in
|
||||
if case .born = stamps {
|
||||
document.set(FrontmatterKeys.created, to: .date(now))
|
||||
}
|
||||
document.set(FrontmatterKeys.order, to: .double(rank))
|
||||
}
|
||||
for folder in copied {
|
||||
try stampCopiedDescendant(at: folder, stamps: stamps, now: now, operation: operation)
|
||||
}
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
throw error
|
||||
}
|
||||
|
||||
return ItemID(rawValue: rootName)
|
||||
}
|
||||
|
||||
/// Renames every UUID-shaped folder beneath `folder` to a fresh mint, depth first, and
|
||||
/// collects where they ended up — "copies mint fresh UUIDs for **every** folder they
|
||||
/// materialize", which for a copied lane means its cards too. Recursion rather than a
|
||||
/// two-level walk because the rule is about folders, not levels; non-UUID-shaped folders
|
||||
/// (`attachments/` and anything under it, strays) are neither renamed nor descended into,
|
||||
/// exactly as the loader treats them.
|
||||
///
|
||||
/// Each child is renamed *before* being descended into, so the collected URLs are the final
|
||||
/// ones — a caller can stamp them without re-deriving any path. The mint only has to avoid
|
||||
/// what is on disk in that folder: the siblings not yet renamed are still there under their
|
||||
/// original names, and `freshUUIDName` checks for exactly that.
|
||||
private static func remintDescendants(
|
||||
of folder: URL,
|
||||
collecting copied: inout [URL],
|
||||
operation: String
|
||||
) throws(BoardWriteError) {
|
||||
for child in childCandidates(of: folder) {
|
||||
let fresh = freshUUIDName(in: folder, avoiding: [])
|
||||
try renameFolder(child, toSiblingNamed: fresh, operation: operation)
|
||||
|
||||
let renamed = folder.appendingPathComponent(fresh, isDirectory: true)
|
||||
copied.append(renamed)
|
||||
try remintDescendants(of: renamed, collecting: &copied, operation: operation)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stamps one copied folder below the root — best-effort by design (see `copyItem`): a
|
||||
/// missing, unreadable, or uneditable `index.md` is left exactly as the copy found it rather
|
||||
/// than failing the gesture. `order` is not touched: a nested item keeps its rank among its
|
||||
/// own siblings, which travelled with it.
|
||||
private static func stampCopiedDescendant(
|
||||
at folder: URL,
|
||||
stamps: CopyStamps,
|
||||
now: Date,
|
||||
operation: String
|
||||
) throws(BoardWriteError) {
|
||||
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
guard FileManager.default.fileExists(atPath: indexURL.path),
|
||||
let copied = try? readDocument(at: indexURL, operation: operation),
|
||||
copied.uneditableShape == nil
|
||||
else { return }
|
||||
|
||||
try updateIndex(inItemFolder: folder, operation: operation) { document in
|
||||
if case .born = stamps {
|
||||
document.set(FrontmatterKeys.created, to: .date(now))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Move/copy pre-flight
|
||||
|
||||
/// The rank a moved or copied root lands on: the caller's explicit value — a drop between
|
||||
/// two siblings, whose midpoint only the caller knows — or `max + 1024` over the
|
||||
/// destination's visible siblings when it has none (`Ranks.append(toVisible:)`, tombstones
|
||||
/// inert). Computed *before* the item arrives, so it cannot count itself as its own sibling.
|
||||
///
|
||||
/// `requireEditable: false` for the same reason the creates pass it: this only *reads* the
|
||||
/// siblings' orders. A flow-mapping sibling that loads and renders normally must not block
|
||||
/// dropping an item beside it.
|
||||
private static func destinationOrder(
|
||||
_ order: Double?,
|
||||
inParent parentFolder: URL,
|
||||
operation: String
|
||||
) throws(BoardWriteError) -> Double {
|
||||
if let order { return order }
|
||||
let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false)
|
||||
return Ranks.append(toVisible: siblings.map(\.order))
|
||||
}
|
||||
|
||||
/// Refuses a folder that is not a lane or a card. Level detection is by name shape
|
||||
/// (01-storage-format.md § Fractal layout ▸ Rules), so a stray — `notes/`, an uppercase
|
||||
/// UUID, a hand-made folder — is not an item, and moving or copying one as if it were would
|
||||
/// invent an identity the loader would then ignore.
|
||||
private static func checkIsUUIDShaped(_ folder: URL, operation: String) throws(BoardWriteError) {
|
||||
guard BoardLoader.isUUIDShaped(folder.lastPathComponent) else {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: folder.path,
|
||||
reason: .unreadable(message: "folder name is not UUID-shaped: only lanes and cards move and copy")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The discover-before-you-write pre-flight `moveItem` and `copyItem` both run on the root
|
||||
/// they are about to relocate: that file *will* be rewritten at the destination (its `order`,
|
||||
/// plus the stamps), so one that cannot be read or cannot be edited in place refuses the
|
||||
/// whole gesture while nothing has happened yet — rather than after the folder has already
|
||||
/// travelled, or with a copy already materialized.
|
||||
private static func checkIndexIsRewritable(inItemFolder folder: URL, operation: String) throws(BoardWriteError) {
|
||||
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
let document = try readDocument(at: indexURL, operation: operation)
|
||||
try checkEditable(document, at: indexURL, operation: operation)
|
||||
}
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
/// Reads and parses an `index.md` for rewriting. **Strict, byte-faithful UTF-8**:
|
||||
@@ -413,6 +819,43 @@ public enum BoardWriter: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Move and copy vocabulary
|
||||
|
||||
/// What a move did to identity: where the item ended up, plus every remint the import boundary
|
||||
/// performed on the way in (01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle").
|
||||
///
|
||||
/// `id` is the source identity unchanged for every ordinary move — same board or not — and the
|
||||
/// minted one when the arriving root itself collided. `reminted` is empty for every move that
|
||||
/// crossed no import boundary, and for every import that found no collision; when it is not,
|
||||
/// each entry is a folder whose UUID the destination board already held, repaired in place.
|
||||
/// Callers need both: `id` to select or scroll to the moved item afterwards, `reminted` to
|
||||
/// re-point anything that was holding the old identities (a selection, an open card window).
|
||||
///
|
||||
/// Content is not part of this: a remint renames a folder and touches no bytes, so nothing in
|
||||
/// here implies an edit.
|
||||
public struct MoveResult: Sendable, Equatable {
|
||||
public let id: ItemID
|
||||
public let reminted: [Remint]
|
||||
|
||||
public struct Remint: Sendable, Equatable {
|
||||
public let from: ItemID
|
||||
public let to: ItemID
|
||||
}
|
||||
}
|
||||
|
||||
/// How a copy stamps the files it materializes — the one axis on which the two kinds of copy
|
||||
/// differ (01-storage-format.md § Fractal layout ▸ Rules; § Frontmatter).
|
||||
public enum CopyStamps: Sendable {
|
||||
/// An ordinary copy — paste, duplicate, a cross-board drag: **keeps `created`**, because a
|
||||
/// fork really was created when its original was, stamps `modified`, and clears
|
||||
/// `modified-by` (a copy is an app write).
|
||||
case fork
|
||||
|
||||
/// Template instantiation (09-templates.md): stamps `created` **and** `modified` fresh and
|
||||
/// strips `modified-by` — the result is born today, not forked from the template.
|
||||
case born
|
||||
}
|
||||
|
||||
// MARK: - Error
|
||||
|
||||
/// A write that did not happen, said out loud: which operation, which file, and why —
|
||||
|
||||
Reference in New Issue
Block a user