Realign code with the 2026-07-31 rulings
The trash sorts by modified descending — the arrival rank mint retires (Ranks.isOrderedForTrash one comparator, loader + merged order agree; the legacy deleted: migration stamps modified from the tombstone timestamp where parseable; delete undo steps validate existence-only; agent guide v8). Trash selection goes kind-blind — ranges, marquee, Select All, and the successor walk sweep both kinds; the guard moves to the exits (mixed-payload drop refusal, copy/cut validation). The copy stamping preflight widens back to comment depth (load-scoped posture — the board always loads, the gesture refuses whole). Fixes a latent no-op: trashed-lane drag restore never fired (DragSession.beginLanes hard-coded the board container). 2403 tests in 413 suites green. Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
@@ -63,8 +63,13 @@ enum AgentGuide {
|
||||
/// `modified-by` like any content edit, the trash move included, since it's the same rule and
|
||||
/// not a special case — and the card-level `attachments` claimed name
|
||||
/// (01-storage-format.md § Fractal layout ▸ Rules, "level-uniform"): that name belongs to the
|
||||
/// app's own folder, so a *file* by that name is a defect the app displaces on sight.
|
||||
static let version = 7
|
||||
/// app's own folder, so a *file* by that name is a defect the app displaces on sight. **v8
|
||||
/// retires the trash's arrival rank** (01-storage-format.md § Deletion and 03-board-ui.md ▸
|
||||
/// Trash, re-ruled 2026-07-31; 08-agent-integration.md's own line): the trash sorts by `modified`
|
||||
/// descending, so there is no rank to mint on the way in — the guide's smallest-`order`-minus-1024
|
||||
/// formula is replaced by "restamp `modified`, leave `order` alone", which is the same stamp
|
||||
/// discipline v7 already taught, now doing the ordering as well.
|
||||
static let version = 8
|
||||
|
||||
// MARK: - The version marker
|
||||
|
||||
@@ -450,12 +455,14 @@ enum AgentGuide {
|
||||
- **Delete a card or a lane = move its folder into `<board>/.trash/`**:
|
||||
`mv <lane>/<card-uuid> <board>/.trash/`, or `mv <lane-uuid>
|
||||
<board>/.trash/` for a whole lane (create `.trash/` if missing). A lane
|
||||
travels with its cards inside it. Arrivals go on top: set the moved
|
||||
item's `order` to the smallest `order` already in `.trash/` minus 1024
|
||||
(empty trash: any number). It's a container change like any other move
|
||||
(Moving and reordering above): stamp `modified` and re-stamp
|
||||
`modified-by`. Restore is the same move in reverse — a card into a lane,
|
||||
a lane back to board root, with a fresh `order`, stamped the same way.
|
||||
travels with its cards inside it. It's a container change like any other
|
||||
move (Moving and reordering above): stamp `modified` and re-stamp
|
||||
`modified-by` — and here the stamp is also the position. **The trash
|
||||
sorts by `modified`, newest first**, so a restamped arrival lands on top;
|
||||
there is no rank to mint, and you should leave `order` exactly as it is —
|
||||
it rides along for the restore. Restore is the same move in reverse — a
|
||||
card into a lane, a lane back to board root, with a fresh `order`,
|
||||
stamped the same way.
|
||||
- **Stamp `kind: lane` when you trash a lane that lacks it.** `.trash/` is
|
||||
flat, so an empty lane folder looks exactly like a card folder; the `kind`
|
||||
value is what tells them apart in there.
|
||||
|
||||
@@ -317,8 +317,10 @@ public enum BoardLoader: Sendable {
|
||||
var trash: [Card] = []
|
||||
var trashedLanes: [TrashedLane] = []
|
||||
/// Every trash entry as the dedupe needs it, kind-blind — the container is one flat list to
|
||||
/// the identity rule, whatever the snapshot splits it into.
|
||||
var trashEntries: [(id: ItemID, title: String?, order: Double)] = []
|
||||
/// the identity rule, whatever the snapshot splits it into. Carried with the two keys the
|
||||
/// container's order is stated in (`Ranks.isOrderedForTrash`), never `order`: the trash is
|
||||
/// sorted by `modified` descending since 2026-07-31.
|
||||
var trashEntries: [(id: ItemID, title: String?, modified: Date?)] = []
|
||||
var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
|
||||
for entryURL in trashCandidates(in: boardRoot) {
|
||||
let entryName = entryURL.lastPathComponent
|
||||
@@ -360,7 +362,7 @@ public enum BoardLoader: Sendable {
|
||||
)
|
||||
let id = ItemID(rawValue: entryName)
|
||||
trashKinds[id] = kind
|
||||
trashEntries.append((id: id, title: document.title.value, order: order))
|
||||
trashEntries.append((id: id, title: document.title.value, modified: document.modified.value))
|
||||
|
||||
switch kind {
|
||||
case .lane:
|
||||
@@ -372,6 +374,7 @@ public enum BoardLoader: Sendable {
|
||||
id: id,
|
||||
schema: schema,
|
||||
title: document.title,
|
||||
modified: document.modified,
|
||||
order: order,
|
||||
heldCards: children().count,
|
||||
document: document
|
||||
@@ -407,12 +410,19 @@ public enum BoardLoader: Sendable {
|
||||
// Display order is settled here rather than in the `BoardModel` call below, because the
|
||||
// dedupe's last tie-break *is* traversal order and the rule needs the occurrences in it.
|
||||
let orderedLanes = Ranks.sortedForDisplay(walkedLanes, order: \.order, name: \.name)
|
||||
let orderedTrash = Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue })
|
||||
let orderedTrashedLanes = Ranks.sortedForDisplay(trashedLanes, order: \.order, name: { $0.id.rawValue })
|
||||
// The trash's own display order, **both kinds at once** — the column interleaves them by rank
|
||||
// **The trash's arrays are sorted by `modified` descending**, not by `order` (01-storage-format.md
|
||||
// § Deletion, re-ruled 2026-07-31): the trash move rewrites no rank, so the stamp *is* the
|
||||
// position. Each kind is sorted by the same comparator the merged `BoardModel.trashEntries`
|
||||
// applies, which is what makes a kind-narrowed slice of the column agree with the column.
|
||||
let orderedTrash = Ranks.sortedForTrash(
|
||||
trash, modified: { $0.modified.value }, title: { $0.title.value }, name: { $0.id.rawValue })
|
||||
let orderedTrashedLanes = Ranks.sortedForTrash(
|
||||
trashedLanes, modified: { $0.modified.value }, title: { $0.title.value }, name: { $0.id.rawValue })
|
||||
// The trash's own display order, **both kinds at once** — the column interleaves them
|
||||
// (03-board-ui.md § Trash), and the dedupe's last tie-break is stated in traversal order, so
|
||||
// the two kinds are merged before the rule sees them rather than after.
|
||||
let orderedTrashEntries = Ranks.sortedForDisplay(trashEntries, order: \.order, name: { $0.id.rawValue })
|
||||
let orderedTrashEntries = Ranks.sortedForTrash(
|
||||
trashEntries, modified: \.modified, title: \.title, name: { $0.id.rawValue })
|
||||
let verdict = dedupeIdentities(
|
||||
inBoardAt: boardRoot,
|
||||
lanes: orderedLanes,
|
||||
@@ -496,7 +506,7 @@ public enum BoardLoader: Sendable {
|
||||
private static func dedupeIdentities(
|
||||
inBoardAt root: URL,
|
||||
lanes: [WalkedLane],
|
||||
trash: [(id: ItemID, title: String?, order: Double)],
|
||||
trash: [(id: ItemID, title: String?, modified: Date?)],
|
||||
historyRanker: IdentityHistoryRanker?
|
||||
) -> IntegrityRules.DedupeVerdict {
|
||||
typealias Container = IntegrityRules.IdentityOccurrence.Container
|
||||
|
||||
@@ -118,16 +118,17 @@ public struct BoardModel: Sendable, Equatable {
|
||||
/// **The container's other kind is `trashedLanes`** (re-ruled 2026-07-29 — lanes trash too).
|
||||
/// The two are separate arrays rather than one list of a sum type because they are separate
|
||||
/// *things*: a trashed card is an ordinary card that every card-shaped surface already reads,
|
||||
/// and a trashed lane is an opaque row that none of them may. Interleaving the two by trash
|
||||
/// rank is a rendering question (03-board-ui.md § Trash: "lane rows and cards interleave in the
|
||||
/// one trash column purely by trash rank"), and both arrays carry the `order` that answers it.
|
||||
/// and a trashed lane is an opaque row that none of them may. Interleaving the two is a
|
||||
/// rendering question (03-board-ui.md § Trash: "lane rows and cards interleave in the one trash
|
||||
/// column by `modified` descending"), and both arrays carry the stamp that answers it —
|
||||
/// `BoardModel.trashEntries` is the one merge.
|
||||
///
|
||||
/// **Display order is `order` ascending, like any lane's cards** — `Ranks.sortedForDisplay`,
|
||||
/// same folder-name tie-break. Newest-first falls out of ordinary ranks rather than a
|
||||
/// timestamp sort: every arrival mints a rank *above* the current topmost
|
||||
/// (`Ranks.insertAtHead`), so the trash needs no sort rule of its own. There is deliberately
|
||||
/// no `deleted:` key on anything in here — a trashed card is an ordinary card in a special
|
||||
/// place.
|
||||
/// **Display order is `modified` descending** — `Ranks.sortedForTrash`, tie-broken by title then
|
||||
/// folder name (re-ruled 2026-07-31, retiring the arrival rank mint). The trash is the one
|
||||
/// container in the app whose sequence is not a rank sequence: the delete move stamps, and that
|
||||
/// stamp *is* the position, with each entry's `order` riding along untouched for its restore.
|
||||
/// There is deliberately no `deleted:` key on anything in here — a trashed card is an ordinary
|
||||
/// card in a special place.
|
||||
///
|
||||
/// Empty when `.trash/` is absent (the overwhelmingly common case — the folder is minted by
|
||||
/// the first delete), and empty when it holds nothing the loader recognizes as a card.
|
||||
@@ -281,9 +282,17 @@ public struct TrashedLane: Identifiable, Sendable, Equatable {
|
||||
public let schema: Int
|
||||
public let title: FieldValue<String>
|
||||
|
||||
/// Rank within the trash, ascending = top to bottom — the same required, strictly validated
|
||||
/// field a live lane carries (`Lane.order`), and what interleaves this row among the trash's
|
||||
/// cards. Newest-first falls out of it: every arrival mints a rank above the current topmost.
|
||||
/// **When the row entered the trash** — and therefore *where it sits*: the trash sorts by
|
||||
/// `modified` descending (01-storage-format.md § Deletion, re-ruled 2026-07-31), and the trash
|
||||
/// move is the container-changing write that stamps it. Missing on a foreign mover that skipped
|
||||
/// the restamp, which sorts it below every dated sibling (`Ranks.isOrderedForTrash`).
|
||||
public let modified: FieldValue<Date>
|
||||
|
||||
/// The lane's rank **among the board's lanes**, riding along untouched — the trash move rewrites
|
||||
/// no `order` at all, so this is still the strip position a restore would want and the value the
|
||||
/// undo of a restore puts back. It is deliberately *not* what orders this row in the column
|
||||
/// (`modified` is), and the same required, strictly validated field a live lane carries
|
||||
/// (`Lane.order`).
|
||||
public let order: Double
|
||||
|
||||
/// **How many cards the lane is holding** — the row's whole other half ("Doing — 5 cards").
|
||||
|
||||
@@ -94,9 +94,16 @@ public enum BoardWriter: Sendable {
|
||||
/// `nil`, the default, derives it from position (`IntegrityRules.placement`), and stamps
|
||||
/// nothing when position has no answer: a guessed kind on disk would be worse than an absent
|
||||
/// one, because the trash's discriminator trusts what it finds.
|
||||
/// - Parameter stampedAt: the value `modified` takes, for the **one** write whose stamp is not
|
||||
/// "now": the legacy tombstone migration, which stamps from the `deleted:` timestamp it is
|
||||
/// retiring so a board's real deletion order survives into the trash's `modified`-descending
|
||||
/// sort (01-storage-format.md § Deletion). `nil`, the default, is `Date()` — every other
|
||||
/// caller. It is a parameter rather than something the `edits` closure could do because the
|
||||
/// stamps deliberately run *after* `edits` and outrank it.
|
||||
public static func updateIndex(
|
||||
inItemFolder folder: URL,
|
||||
kind: IntegrityRules.ObjectKind? = nil,
|
||||
stampedAt: Date? = nil,
|
||||
operation: WriteOperation,
|
||||
edits: (inout FrontmatterDocument) -> Void
|
||||
) throws(BoardWriteError) {
|
||||
@@ -117,7 +124,7 @@ public enum BoardWriter: Sendable {
|
||||
// the container's own arrangement and leaves both provenance keys exactly as it found them —
|
||||
// a standing `modified-by` survives a reorder, which is the pairing 01 spells out.
|
||||
if !operation.rewritesOrderOnly {
|
||||
document.set(FrontmatterKeys.modified, to: .date(Date()))
|
||||
document.set(FrontmatterKeys.modified, to: .date(stampedAt ?? Date()))
|
||||
document.remove(FrontmatterKeys.modifiedBy)
|
||||
}
|
||||
|
||||
@@ -1036,15 +1043,20 @@ public enum BoardWriter: Sendable {
|
||||
/// **A folder with no `index.md` is not an offense** and is skipped: it is interrupted-create
|
||||
/// residue, the loader skips it too (`.missingIndex`), and there is no contract work to fail.
|
||||
///
|
||||
/// **Comment folders are deliberately outside this preflight** (added 2026-07-30 with the comment
|
||||
/// storage, and worth stating because it is a *narrowing* of a 2026-07-29 ruling): 01's copy
|
||||
/// transaction says "refuses whole, loudly, naming the offending item", and its enhanced-schema
|
||||
/// section says "**comment defects never refuse the board** — worst case is the stray posture …
|
||||
/// a broken leaf annotation must not brick a load; deliberate, proportionate divergence from card
|
||||
/// fail-fast". A ⌘V that refuses because one comment on one card inside a pasted lane has
|
||||
/// hand-broken frontmatter is that divergence read the other way round. So the walk stays
|
||||
/// `identityDescendants`' — cards and lanes — and a comment the contract cannot be applied to is
|
||||
/// copied verbatim with a log line instead (`stampCopiedComment`).
|
||||
/// **The preflight reaches comment depth** (01-storage-format.md § Frontmatter and § Enhanced
|
||||
/// schema, ruled 2026-07-31 — reversing the 2026-07-30 carve-out that kept comments out of it):
|
||||
/// "a comment whose frontmatter cannot take the stamp refuses the copy exactly like a card or
|
||||
/// lane — never-refuse … is a *load* posture, and a user-initiated copy is a transaction, not a
|
||||
/// load". The board still always loads with a broken annotation on it; the *gesture* may refuse,
|
||||
/// and refusing is what keeps the sever rule true — no copy carries a live `remote` claim or a
|
||||
/// stale `modified-by` because one comment could not be rewritten.
|
||||
///
|
||||
/// The walk is therefore `copyContractDescendants`' — every folder `remintDescendants` will
|
||||
/// materialize and `stampCopiedDescendant` will then rewrite, comments included — and the
|
||||
/// refusal names the comment's *card*, which is what `operation.withTitle` produces here: the
|
||||
/// enrichment reads the offending file's own title, and a comment has none, so the operation
|
||||
/// keeps the copy root's. (The error always carries the path, which is what points at the
|
||||
/// annotation itself.)
|
||||
///
|
||||
/// **Internal rather than `private`**: template instantiation preflights its own tree with this,
|
||||
/// for `remintDescendants`' reason — one definition of what a copy owes its descendants.
|
||||
@@ -1052,7 +1064,7 @@ public enum BoardWriter: Sendable {
|
||||
of folder: URL,
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) {
|
||||
for descendant in identityDescendants(of: folder) {
|
||||
for descendant in copyContractDescendants(of: folder) {
|
||||
let indexURL = descendant.appendingPathComponent(BoardLoader.indexFileName)
|
||||
guard FileManager.default.fileExists(atPath: indexURL.path) else { continue }
|
||||
let document = try readDocument(at: indexURL, operation: operation)
|
||||
@@ -1062,9 +1074,9 @@ public enum BoardWriter: Sendable {
|
||||
|
||||
/// Every **card or lane** beneath `folder`, depth first — `remintDescendants`' recursion with the
|
||||
/// renaming taken out, so the preflight and the remint cannot disagree about which folders a copy
|
||||
/// materializes as *items*. Comment folders are not here, by the carve-out
|
||||
/// `checkCopiedDescendantsAreStampable` states; it is also exactly the right reach for
|
||||
/// `stripCommentTrash`, since a thread lives under a card and nowhere else.
|
||||
/// materializes as *items*. Comment folders are not here (they hang off `comments/`, which is not
|
||||
/// identity-shaped), which is exactly the right reach for `stripCommentTrash`, since a thread
|
||||
/// lives under a card and nowhere else.
|
||||
private static func identityDescendants(of folder: URL) -> [URL] {
|
||||
var found: [URL] = []
|
||||
for child in childCandidates(of: folder) {
|
||||
@@ -1074,6 +1086,26 @@ public enum BoardWriter: Sendable {
|
||||
return found
|
||||
}
|
||||
|
||||
/// **Every folder below `folder` whose `index.md` the copy contract rewrites** — the items *and*
|
||||
/// their comments, which is `remintDescendants`' own reach read as a list.
|
||||
///
|
||||
/// The root's own thread is in it: `checkIndexIsRewritable` preflights the copy root itself, but
|
||||
/// nothing preflights the comments hanging off it, and the copy stamps those too. Two walks that
|
||||
/// disagreed about which folders a copy touches is exactly the drift a mid-flight failure after a
|
||||
/// clean preflight would be.
|
||||
private static func copyContractDescendants(of folder: URL) -> [URL] {
|
||||
func comments(of item: URL) -> [URL] {
|
||||
childCandidates(of: item.appendingPathComponent(
|
||||
IntegrityRules.commentsFolderName, isDirectory: true))
|
||||
}
|
||||
var found = comments(of: folder)
|
||||
for item in identityDescendants(of: folder) {
|
||||
found.append(item)
|
||||
found.append(contentsOf: comments(of: item))
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/// Stamps one copied folder below the root — **strictly**, since the preflight has already cleared
|
||||
/// the whole subtree (`checkCopiedDescendantsAreStampable`): an `index.md` that cannot be read or
|
||||
/// edited here is a disk failure between the two reads, not a shape to tolerate, and it fails the
|
||||
@@ -1087,6 +1119,12 @@ public enum BoardWriter: Sendable {
|
||||
/// A folder with **no `index.md`** is still skipped, and for a different reason entirely: there is
|
||||
/// nothing there to stamp (interrupted-create residue, which the loader skips too).
|
||||
///
|
||||
/// **A comment takes the same strict path as a card** (ruled 2026-07-31): the former lenient
|
||||
/// branch — stamp best-effort, log, copy verbatim on failure — retired with the preflight's
|
||||
/// comment carve-out, because the preflight now clears comments too and a failure here is a disk
|
||||
/// failure at either depth. `kind` is left to position (`IntegrityRules.placement`), which
|
||||
/// answers `.comment` for a folder under a card's `comments/`.
|
||||
///
|
||||
/// **Internal rather than `private`**, with `remintDescendants` and for its reason: an
|
||||
/// instantiated board's lanes and cards are stamped by this exact rule.
|
||||
static func stampCopiedDescendant(
|
||||
@@ -1098,35 +1136,11 @@ public enum BoardWriter: Sendable {
|
||||
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
guard FileManager.default.fileExists(atPath: indexURL.path) else { return }
|
||||
|
||||
// The one lenient branch, and the preflight's own carve-out read from the write side: a
|
||||
// comment was never checked, so a comment that cannot be stamped is a *tolerated* defect
|
||||
// rather than a disk failure — it copies verbatim, keeping whatever `remote` and
|
||||
// `modified-by` it carried, and says so in the log (01-storage-format.md § Enhanced schema,
|
||||
// "comment defects never refuse … tolerated, logged").
|
||||
guard !isCommentFolder(folder) else {
|
||||
do {
|
||||
try updateIndex(inItemFolder: folder, kind: .comment, operation: operation) { document in
|
||||
applyCopyContract(to: &document, stamps: stamps, now: now)
|
||||
}
|
||||
} catch {
|
||||
logger.warning(
|
||||
"\(folder.path, privacy: .public): copied comment left unstamped — \(error.description, privacy: .public)"
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try updateIndex(inItemFolder: folder, operation: operation) { document in
|
||||
applyCopyContract(to: &document, stamps: stamps, now: now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `folder` is a comment — its parent is a card's `comments/`. Position, like every other
|
||||
/// kind question here (`IntegrityRules.placement`).
|
||||
static func isCommentFolder(_ folder: URL) -> Bool {
|
||||
IntegrityRules.placement(ofFolder: folder) == .comment
|
||||
}
|
||||
|
||||
static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "writer")
|
||||
|
||||
// MARK: - The materialized trash
|
||||
@@ -1157,22 +1171,23 @@ public enum BoardWriter: Sendable {
|
||||
/// meet it here.
|
||||
/// 4. **Move the folder.** Nothing beneath it is read or rewritten, so `attachments/`, strays
|
||||
/// and every byte arrive unchanged, exactly as in an ordinary move.
|
||||
/// 5. **Rewrite `order` to `order`, and stamp.**
|
||||
/// 5. **Stamp** — and nothing else.
|
||||
///
|
||||
/// **`order` is the caller's, always** — deliberately not defaulted and deliberately not
|
||||
/// computed here. Entry is at the *top* ("every arrival … lands at the trash's topmost
|
||||
/// position, minting an `order` rank above the current top"), which is
|
||||
/// `Ranks.insertAtHead(ofVisible:)` over the trash's current ranks — a question about the
|
||||
/// *snapshot*, which the store holds and this stateless layer does not. Value-passing keeps
|
||||
/// the seam: the Writer takes a rank, the store computes it.
|
||||
/// **No rank is minted, and `order` is not rewritten at all** (01-storage-format.md § Deletion,
|
||||
/// re-ruled 2026-07-31, retiring the arrival rank mint; 03-board-ui.md § Trash): the trash sorts
|
||||
/// by `modified` descending, "and that stamp *is* the position — newest-first falls out with no
|
||||
/// `order` rewrite at all; a delete is a pure folder move plus the stamp, and the item's `order`
|
||||
/// key rides along untouched (which is also what undo's move-back restores for free)". The
|
||||
/// parameter this call used to take is gone with the rule, and so is the store-side ladder that
|
||||
/// computed it: there is no snapshot question left for the caller to answer.
|
||||
///
|
||||
/// **The `modified` stamp is the point, not a side effect** — and it needs no exception to earn
|
||||
/// it. The trash move changes the card's *container*, which is the whole predicate
|
||||
/// **The `modified` stamp is therefore the whole write** — and it needs no exception to earn it.
|
||||
/// The trash move changes the card's *container*, which is the whole predicate
|
||||
/// (`WriteOperation.rewritesOrderOnly`, refined 2026-07-30): "deletion is an edit to the item's
|
||||
/// story", so it stamps exactly as a cross-lane move does, and the stamp is what a future
|
||||
/// age-based auto-purge reads. It falls out of `updateIndex` here rather than being asked for,
|
||||
/// which is why there is nothing extra in step 5 — and why the reorders-don't-stamp rule needs no
|
||||
/// trash carve-out to coexist with this call.
|
||||
/// story", so it stamps exactly as a cross-lane move does. It falls out of `updateIndex` rather
|
||||
/// than being asked for, which is why step 5 carries no argument — and why the
|
||||
/// reorders-don't-stamp rule needs no trash carve-out to coexist with this call. Its second job
|
||||
/// is the `kind` the flat container needs, backfilled on the same touch.
|
||||
///
|
||||
/// **Collision inside `.trash/` is impossible by construction**, and it is checked anyway. The
|
||||
/// card is a resident of this very board, and board-wide uniqueness now spans lanes *and* the
|
||||
@@ -1186,14 +1201,12 @@ public enum BoardWriter: Sendable {
|
||||
@discardableResult
|
||||
public static func deleteCardToTrash(
|
||||
at cardFolder: URL,
|
||||
inBoard boardRoot: URL,
|
||||
order: Double
|
||||
inBoard boardRoot: URL
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
try moveIntoTrash(
|
||||
at: cardFolder,
|
||||
inBoard: boardRoot,
|
||||
kind: .card,
|
||||
order: order,
|
||||
operation: .delete(title: nil),
|
||||
removingLegacyKey: false
|
||||
)
|
||||
@@ -1208,12 +1221,12 @@ public enum BoardWriter: Sendable {
|
||||
///
|
||||
/// - **The guard is `checkIsLaneFolder`** — UUID-shaped directly under a board root, which
|
||||
/// refuses a card, a board root, a stray, and notably a folder already in `.trash/`.
|
||||
/// - **`kind: lane` is stamped**, not derived. The rank rewrite is a `updateIndex` on a folder
|
||||
/// that is by then *inside* `.trash/`, where position cannot answer and shape would answer
|
||||
/// *wrongly* for the one lane that most needs the key: an **empty** lane is shape-identical to
|
||||
/// a card (01's own "honest limit"). The caller knows what it moved, so it says so — which is
|
||||
/// also the backfill the ruling asks of this write ("`kind: lane` … backfilled on touch when
|
||||
/// absent … the trash move's rank mint included").
|
||||
/// - **`kind: lane` is stamped**, not derived. The stamping rewrite is an `updateIndex` on a
|
||||
/// folder that is by then *inside* `.trash/`, where position cannot answer and shape would
|
||||
/// answer *wrongly* for the one lane that most needs the key: an **empty** lane is
|
||||
/// shape-identical to a card (01's own "honest limit"). The caller knows what it moved, so it
|
||||
/// says so — which is also the backfill the ruling asks of this write ("`kind: lane` …
|
||||
/// backfilled on touch when absent … the trash move's `modified` stamp included").
|
||||
///
|
||||
/// **The subtree rides along untouched**: nothing beneath the lane is read or rewritten, so its
|
||||
/// cards, their `attachments/` and every stray arrive byte-identical and come back with it on
|
||||
@@ -1224,14 +1237,12 @@ public enum BoardWriter: Sendable {
|
||||
@discardableResult
|
||||
public static func deleteLaneToTrash(
|
||||
at laneFolder: URL,
|
||||
inBoard boardRoot: URL,
|
||||
order: Double
|
||||
inBoard boardRoot: URL
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
try moveIntoTrash(
|
||||
at: laneFolder,
|
||||
inBoard: boardRoot,
|
||||
kind: .lane,
|
||||
order: order,
|
||||
operation: .delete(title: nil),
|
||||
removingLegacyKey: false
|
||||
)
|
||||
@@ -1249,19 +1260,25 @@ public enum BoardWriter: Sendable {
|
||||
/// The removal is `FrontmatterDocument.remove`, so it takes **every** occurrence of the key:
|
||||
/// a hand-duplicated `deleted:` line cannot leave a twin behind that would re-migrate the card
|
||||
/// on the next load. Nothing else in the file is touched — unknown keys, comments, blank lines,
|
||||
/// line endings and the body are the same bytes they were, and `order` and the stamps are the
|
||||
/// only writes, exactly as for an ordinary delete.
|
||||
/// line endings and the body are the same bytes they were, and the stamps are the only other
|
||||
/// writes, exactly as for an ordinary delete.
|
||||
///
|
||||
/// **The stamp is the retiring key's own timestamp where it parses** (01-storage-format.md
|
||||
/// § Deletion, re-ruled 2026-07-31: "the migration move stamps `modified` **from the legacy
|
||||
/// `deleted:` timestamp** where parseable — the deletion time is when the card entered the
|
||||
/// trash, so real deletion order survives into the `modified`-descending sort — and from
|
||||
/// migration time otherwise"). It is the one write in the app whose `modified` is not "now",
|
||||
/// and it is why the legacy value is read *before* the move rather than left to the closure:
|
||||
/// `updateIndex`'s stamps deliberately outrank anything `edits` does.
|
||||
@discardableResult
|
||||
public static func migrateTombstonedCard(
|
||||
at cardFolder: URL,
|
||||
inBoard boardRoot: URL,
|
||||
order: Double
|
||||
inBoard boardRoot: URL
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
try moveIntoTrash(
|
||||
at: cardFolder,
|
||||
inBoard: boardRoot,
|
||||
kind: .card,
|
||||
order: order,
|
||||
operation: .migrateTombstone(title: nil),
|
||||
removingLegacyKey: true
|
||||
)
|
||||
@@ -1278,7 +1295,6 @@ public enum BoardWriter: Sendable {
|
||||
at itemFolder: URL,
|
||||
inBoard boardRoot: URL,
|
||||
kind: IntegrityRules.ObjectKind,
|
||||
order: Double,
|
||||
operation initialOperation: WriteOperation,
|
||||
removingLegacyKey: Bool
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
@@ -1293,7 +1309,17 @@ public enum BoardWriter: Sendable {
|
||||
case .lane: try checkIsLaneFolder(itemFolder, operation: operation)
|
||||
case .card, .board, .comment: try checkIsCardFolder(itemFolder, operation: operation)
|
||||
}
|
||||
operation = try checkIndexIsRewritable(inItemFolder: itemFolder, operation: operation)
|
||||
// `checkIndexIsRewritable`'s two checks, spelled out rather than called, for one reason: the
|
||||
// migration needs the *document* this read produces — the legacy `deleted:` timestamp it is
|
||||
// about to remove becomes the `modified` stamp (`migrateTombstonedCard`), and it has to be
|
||||
// read while the file is still at its old path.
|
||||
let sourceIndex = itemFolder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
let source = try readDocument(at: sourceIndex, operation: operation)
|
||||
operation = operation.withTitle(source.title.value)
|
||||
try checkEditable(source, at: sourceIndex, operation: operation)
|
||||
// Parseable legacy stamp → the deletion time it recorded; malformed, absent, or an ordinary
|
||||
// delete → `nil`, which is `updateIndex`'s "now".
|
||||
let stamp = removingLegacyKey ? source.deleted.value : nil
|
||||
|
||||
let trash = trashFolder(inBoard: boardRoot)
|
||||
do {
|
||||
@@ -1322,8 +1348,10 @@ public enum BoardWriter: Sendable {
|
||||
// absence where the item was, the shown-trash side sees an arrival where it went.
|
||||
EchoLedger.current?.recordMove(from: itemFolder, to: arrived)
|
||||
|
||||
try updateIndex(inItemFolder: arrived, kind: kind, operation: operation) { document in
|
||||
document.set(FrontmatterKeys.order, to: .double(order))
|
||||
// The whole of the arrival write: the stamp (which is the row's position), the `kind` the
|
||||
// flat container discriminates on, and — for the migration alone — the retiring key. `order`
|
||||
// is deliberately absent; it rides along as the item left it.
|
||||
try updateIndex(inItemFolder: arrived, kind: kind, stampedAt: stamp, operation: operation) { document in
|
||||
if removingLegacyKey {
|
||||
document.remove(FrontmatterKeys.deleted)
|
||||
}
|
||||
|
||||
@@ -143,6 +143,66 @@ enum Ranks: Sendable {
|
||||
items.sorted { isOrderedForDisplay($0, before: $1, order: order, name: name) }
|
||||
}
|
||||
|
||||
// MARK: - The trash's own order
|
||||
|
||||
/// The trash's order: **`modified` descending**, ties broken by title
|
||||
/// (case-insensitive), then folder name (01-storage-format.md § Deletion,
|
||||
/// re-ruled 2026-07-31, retiring the arrival rank mint; 03-board-ui.md §
|
||||
/// Trash: "the trash sorts by `modified` descending … Ties break by title
|
||||
/// (case-insensitive), then folder name — the deterministic tail").
|
||||
///
|
||||
/// **`order` is not consulted at all in here**, which is the whole of the
|
||||
/// ruling: a delete is a pure folder move plus the stamp, and the item's
|
||||
/// `order` key rides along untouched for its eventual restore. The trash is
|
||||
/// the one container in the app whose sequence is not a rank sequence.
|
||||
///
|
||||
/// **An undated entry sorts after every dated one** — the comment thread's
|
||||
/// own rule for a missing `created` ("ties and missing/malformed … sort
|
||||
/// after dated siblings"), read one container over: a stamp the app always
|
||||
/// writes is missing only on a hand-made or foreign entry, and a missing
|
||||
/// value must never outrank a real deletion. The tail then separates them
|
||||
/// exactly as it separates two equal stamps.
|
||||
///
|
||||
/// Titles compare with `localizedCaseInsensitiveCompare`, the same
|
||||
/// comparison the rest of the app's user-facing sorting uses, and an
|
||||
/// untitled entry compares as the empty string — it sorts first among its
|
||||
/// stamp-mates, which is deterministic and is all the tail owes.
|
||||
static func isOrderedForTrash<T>(
|
||||
_ lhs: T, before rhs: T,
|
||||
modified: (T) -> Date?, title: (T) -> String?, name: (T) -> String
|
||||
) -> Bool {
|
||||
let lhsModified = modified(lhs)
|
||||
let rhsModified = modified(rhs)
|
||||
if lhsModified != rhsModified {
|
||||
// Newest first; an absent stamp is older than every present one.
|
||||
switch (lhsModified, rhsModified) {
|
||||
case let (.some(left), .some(right)): return left > right
|
||||
case (.some, .none): return true
|
||||
case (.none, .some): return false
|
||||
case (.none, .none): break
|
||||
}
|
||||
}
|
||||
let lhsTitle = title(lhs) ?? ""
|
||||
let rhsTitle = title(rhs) ?? ""
|
||||
let comparison = lhsTitle.localizedCaseInsensitiveCompare(rhsTitle)
|
||||
guard comparison == .orderedSame else { return comparison == .orderedAscending }
|
||||
return name(lhs) < name(rhs)
|
||||
}
|
||||
|
||||
/// Sorts the trash's entries into column order — `isOrderedForTrash`'s rule,
|
||||
/// applied by the loader to each kind's array and by `BoardModel.trashEntries`
|
||||
/// to the merged sequence, so the two can never disagree about what "the row
|
||||
/// below this one" is (03-board-ui.md § Trash: "The merged order is one
|
||||
/// derivation").
|
||||
static func sortedForTrash<T>(
|
||||
_ items: [T],
|
||||
modified: (T) -> Date?,
|
||||
title: (T) -> String?,
|
||||
name: (T) -> String
|
||||
) -> [T] {
|
||||
items.sorted { isOrderedForTrash($0, before: $1, modified: modified, title: title, name: name) }
|
||||
}
|
||||
|
||||
// MARK: - Tombstone exclusion
|
||||
|
||||
/// `append(toVisible:)`, ignoring tombstoned siblings. Tombstones are
|
||||
|
||||
Reference in New Issue
Block a user