Comments, phase 1 — storage, writer primitives, and the undo inventory
The kind: comment field table lands in IntegrityRules (the per-kind hook's first exercise), CommentThread reads one card's thread window-scoped (the board walk stays O(cards)), and CommentWriter gains the five gestures: draft save, post (rename .draft to a fresh UUID, created/modified restamped in the bracket), edit, delete into comments/.trash/, and the purge with its crash-residue memo. Post and delete register move-based undo steps; draft saves, edits, and the purge deliberately register nothing (13's no-capture rule). Copy boundaries strip comments/.trash, carry .draft verbatim, and remint threads; comments graduates to a displacing claimed name, with .draft, .trash, and a comment's attachments claimed one level down. CommentPath classifies changed paths into the 06 verb family for later announcer/composer wiring. One stated narrowing pending a ruling (filed on the findings board): the copy transaction's refuse-whole preflight stays cards-and-lanes — an unstampable copied comment copies verbatim with a log line, because comment defects never refuse. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -106,8 +106,23 @@ enum BoardTreeCopy {
|
||||
throw .failed(url: source, error: error)
|
||||
}
|
||||
|
||||
// The one exclusion this walk makes without being asked (01-storage-format.md § Enhanced
|
||||
// schema: `comments/.trash/` is "**stripped at every copy boundary** (clipboard staging,
|
||||
// Duplicate, Save as Template) — a copy must not carry ghosts no window session will ever
|
||||
// purge"). Unconditional because all three of this walk's callers *are* copy boundaries, and
|
||||
// an opt-in flag would be three call sites agreeing to one rule instead of one rule.
|
||||
//
|
||||
// It is the one depth-sensitive exclusion, hence the parent check rather than a name in
|
||||
// `excluded`: `.trash` at a board root is the board's own and carries or not per each flow's
|
||||
// stated exclusions, while `.trash` inside a `comments/` is undo's backing store and never
|
||||
// travels.
|
||||
let isCommentThread = source.lastPathComponent.lowercased() == IntegrityRules.commentsFolderName
|
||||
|
||||
for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
|
||||
guard !excluded.contains(entry.lastPathComponent.lowercased()) else { continue }
|
||||
guard !isCommentThread
|
||||
|| entry.lastPathComponent.lowercased() != IntegrityRules.commentTrashFolderName
|
||||
else { continue }
|
||||
|
||||
// Between items, never mid-item: this is the whole of "checks cancellation between
|
||||
// items", and the reason the copy is a walk at all.
|
||||
|
||||
@@ -477,6 +477,13 @@ public final class ClipboardStore {
|
||||
/// makes the next paste **refuse whole**, naming it (`perform`'s preflight), rather than
|
||||
/// materializing it hollow from the manifest's embedded `index.md`. Failing to stage is therefore
|
||||
/// as loud as it should be, one gesture later.
|
||||
/// **Staging is a copy boundary**, so `comments/.trash/` does not survive it (01-storage-format.md
|
||||
/// § Enhanced schema: "stripped at every copy boundary (clipboard staging, Duplicate, Save as
|
||||
/// Template)"). Stripped from the *snapshot* rather than skipped during it, because the snapshot is
|
||||
/// one monolithic `copyItem` — and stripped through the Writer's own call so the rule is one
|
||||
/// function (`BoardWriter.stripCommentTrash`), not a second reading of it here. Best-effort like
|
||||
/// the copy above it: a snapshot that could not be tidied is still a snapshot, and the paste that
|
||||
/// materializes from it strips again at its own boundary.
|
||||
private func stage(_ jobs: [StagingJob], into stagingDir: URL) {
|
||||
enqueue { [jobs, stagingDir] in
|
||||
guard (try? FileManager.default.createDirectory(
|
||||
@@ -485,6 +492,7 @@ public final class ClipboardStore {
|
||||
)) != nil else { return }
|
||||
for job in jobs {
|
||||
try? FileManager.default.copyItem(at: job.source, to: job.destination)
|
||||
try? BoardWriter.stripCommentTrash(under: job.destination, operation: .copy(title: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,12 +64,17 @@ public enum HistoryPhrase {
|
||||
case card
|
||||
case lane
|
||||
case board
|
||||
/// One comment. Only `.delete` reaches it: posting has its own phrase (`comment`, below), and
|
||||
/// the draft save, the inline edit and the trash purge register no step at all
|
||||
/// (13-native-undo.md — no byte capture in any tier, and the permanent-delete posture).
|
||||
case comment
|
||||
|
||||
var singular: String {
|
||||
switch self {
|
||||
case .card: "Card"
|
||||
case .lane: "Lane"
|
||||
case .board: "Board"
|
||||
case .comment: "Comment"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,10 +83,22 @@ public enum HistoryPhrase {
|
||||
case .card: "Cards"
|
||||
case .lane: "Lanes"
|
||||
case .board: "Board"
|
||||
case .comment: "Comments"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: The comment family
|
||||
|
||||
/// **The post's phrase** — 06-history-undo.md's path-shaped "Comment on '⟨card⟩'" as a menu
|
||||
/// title, which is the verb on its own.
|
||||
///
|
||||
/// Not `name(_:kind:count:)` with a `Verb.comment`, because the composition would read "Comment
|
||||
/// Comment": here the verb already names its object, which is `Kind.board`'s count-less rule
|
||||
/// arriving from the other direction. The destination clause a commit subject carries ("on 'Fix
|
||||
/// login'") is dropped exactly as every other phrase drops it — a menu row has to stay short.
|
||||
public static let comment = "Comment"
|
||||
|
||||
// MARK: Composition
|
||||
|
||||
/// The phrase for one gesture: `"Move Card"`, `"Move 3 Cards"`, `"Restyle Board"`.
|
||||
|
||||
@@ -848,6 +848,31 @@ public final class BannerCenter {
|
||||
// The buffer is still on screen: the banner says the app could not put those bytes on
|
||||
// disk, not that they are gone.
|
||||
if let title { "Couldn't apply source changes to '\(title)'" } else { "Couldn't apply the source changes" }
|
||||
|
||||
// **The comment family names the card, because that is the title these operations carry** —
|
||||
// a comment has none of its own (01-storage-format.md § Enhanced schema: "No `title`, no
|
||||
// `order`"), and the card is the thing on the window in front of them. Each sentence is the
|
||||
// gesture's own word, the vocabulary's standing rule: they pressed ⌘↩, or Delete, or nothing
|
||||
// at all in the draft's case.
|
||||
case let .saveCommentDraft(title):
|
||||
// **"draft", never "save the comment"**: nothing has been posted, and a banner claiming a
|
||||
// comment could not be saved would name one that does not exist yet. The typed text is
|
||||
// still in the composer — this says the app could not put it on disk.
|
||||
if let title { "Couldn't save your draft comment on '\(title)'" } else { "Couldn't save your draft comment" }
|
||||
case let .postComment(title):
|
||||
// The Comment button's own word (05-card-window.md ▸ The comments column, "⌘↩ posts").
|
||||
// The draft is untouched on disk, which is what makes this a retry rather than a loss.
|
||||
if let title { "Couldn't post your comment on '\(title)'" } else { "Couldn't post your comment" }
|
||||
case let .editComment(title):
|
||||
if let title { "Couldn't save your edit to a comment on '\(title)'" } else { "Couldn't save your edit to the comment" }
|
||||
case let .deleteComment(title):
|
||||
if let title { "Couldn't delete a comment on '\(title)'" } else { "Couldn't delete the comment" }
|
||||
case .purgeCommentTrash:
|
||||
// No title, and no mention of a trash the user has never seen: `comments/.trash/` is
|
||||
// "never a UI surface", so the sentence is about the *card's* files being tidied — the
|
||||
// agent guide's posture, one level down. Nothing is lost either way; the next window
|
||||
// open sweeps again.
|
||||
"Couldn't tidy up deleted comments"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - The comment gestures
|
||||
|
||||
/// **The comment thread at the store boundary** — every comment write the app makes, bracketed, with
|
||||
/// its inverse registered where 13-native-undo.md says one belongs.
|
||||
///
|
||||
/// An extension in its own file for `BoardStoreHistory.swift`'s reason, and with its reason for
|
||||
/// living on `BoardStore` at all: comments are read by the *card window* and written through the
|
||||
/// *board's* store, because "one stack per board, owned by the board session. Not per-window: every
|
||||
/// window over a board (board window, its card windows) shares the store and shares the stack"
|
||||
/// (13 ▸ Rules). A comment posted in a card window is undone by ⌘Z in the board window, which is only
|
||||
/// true if the step was registered here.
|
||||
///
|
||||
/// ### What registers a step, and what deliberately does not
|
||||
///
|
||||
/// Two of the five (13 ▸ Interaction with the trash, the comment clause; 13 ▸ Rules):
|
||||
///
|
||||
/// - **`postComment`** — inverse: the rename back to `.draft`. Move-based, no capture.
|
||||
/// - **`deleteComment`** — inverse: the move back out of `comments/.trash/`. Move-based, no capture.
|
||||
/// - **`saveCommentDraft` and `editComment` register nothing**, and that is the no-capture rule
|
||||
/// rather than a deferral: undoing a body edit means holding the prior bytes, which 13 forbids in
|
||||
/// every tier. An inline edit's revert is its *session*'s (Cancel/Escape reverts to session-start
|
||||
/// bytes — 05-card-window.md), which is a live buffer, not a stack entry.
|
||||
/// - **`purgeCommentTrash` registers nothing** — the permanent-delete posture. It is also what makes
|
||||
/// the interaction with the stack correct for free: leftover comment steps go stale after a purge
|
||||
/// and skip with the ordinary info-tone banner, because the folders their expectations name are
|
||||
/// gone. **The expectations validate disk, never the snapshot** (`HistoryStaleness`), which is
|
||||
/// exactly why this works — comments are not in the snapshot at all.
|
||||
@MainActor
|
||||
extension BoardStore {
|
||||
|
||||
// MARK: Reading
|
||||
|
||||
/// One card's thread, read fresh from disk — **window-scoped**, never cached on the store and
|
||||
/// never part of `snapshot` (01-storage-format.md § Enhanced schema: "the board snapshot never
|
||||
/// loads comment content", so the board walk stays O(cards)).
|
||||
///
|
||||
/// `.empty` for an id that names no card on the board — the same vanished-target answer every
|
||||
/// other card-scoped call gives (`boardItem`).
|
||||
public func commentThread(inCard id: ItemID) -> CommentThread {
|
||||
guard let card = commentSubject(id) else { return .empty }
|
||||
return CommentThread.load(inCard: card.folder, path: card.path)
|
||||
}
|
||||
|
||||
// MARK: The draft
|
||||
|
||||
/// Saves the composer's draft — one bracket, no step.
|
||||
///
|
||||
/// - Returns: what landed, or `nil` when the write failed (the banner is already
|
||||
/// `performWrite`'s) or the card is gone.
|
||||
@discardableResult
|
||||
public func saveCommentDraft(inCard id: ItemID, body: String) -> CommentDraftOutcome? {
|
||||
guard let card = commentSubject(id) else { return nil }
|
||||
return try? performWrite { () throws(BoardWriteError) -> CommentDraftOutcome in
|
||||
try BoardWriter.saveCommentDraft(inCard: card.folder, body: body, cardTitle: card.title)
|
||||
}
|
||||
}
|
||||
|
||||
/// **Posts the draft, and registers the one step the gesture owes** (⌘↩ or the Comment button).
|
||||
///
|
||||
/// The step's predicate is the brief the ruling gives it: the undo needs the posted folder still
|
||||
/// at its path **and `.draft` still absent** — a draft the user has started typing since is not
|
||||
/// this step's to overwrite — and the redo needs the mirror. Both are `HistoryExpectation`s over
|
||||
/// disk, and the `.absent` half is the same one an undone create uses.
|
||||
///
|
||||
/// **The redo replays the captured identity and the captured instant**, never fresh ones: a redo
|
||||
/// that re-minted would post a *different* comment, and any step registered above this one naming
|
||||
/// the posted id would name nothing.
|
||||
@discardableResult
|
||||
public func postComment(inCard id: ItemID) -> ItemID? {
|
||||
guard let card = commentSubject(id) else { return nil }
|
||||
guard let posted = try? performWrite({ () throws(BoardWriteError) -> PostedComment in
|
||||
try BoardWriter.postComment(inCard: card.folder, cardTitle: card.title)
|
||||
}) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let folder = card.folder
|
||||
let title = card.title
|
||||
let draftFolder = CommentThread.draftFolder(inCard: folder)
|
||||
let postedFolder = CommentThread.commentFolder(posted.id, inCard: folder)
|
||||
registerStep(
|
||||
HistoryPhrase.comment,
|
||||
subject: title,
|
||||
undoExpects: [.present(postedFolder), .absent(draftFolder)],
|
||||
redoExpects: [.present(draftFolder), .absent(postedFolder)]
|
||||
) { _ in
|
||||
try BoardWriter.unpostComment(posted.id, inCard: folder, cardTitle: title)
|
||||
} redo: { _ in
|
||||
try BoardWriter.repostComment(
|
||||
as: posted.id,
|
||||
inCard: folder,
|
||||
stamping: posted.posted,
|
||||
cardTitle: title
|
||||
)
|
||||
}
|
||||
return posted.id
|
||||
}
|
||||
|
||||
// MARK: Editing
|
||||
|
||||
/// An inline edit session's save — one bracket, **no step** (13's no-capture rule; the session
|
||||
/// owns Cancel).
|
||||
///
|
||||
/// - Returns: whether bytes were written; `false` also for a card or comment that is gone.
|
||||
@discardableResult
|
||||
public func editComment(_ commentID: ItemID, inCard id: ItemID, body: String) -> Bool {
|
||||
guard let card = commentSubject(id) else { return false }
|
||||
let folder = CommentThread.commentFolder(commentID, inCard: card.folder)
|
||||
let wrote = try? performWrite { () throws(BoardWriteError) -> Bool in
|
||||
try BoardWriter.editComment(at: folder, body: body, cardTitle: card.title)
|
||||
}
|
||||
return wrote ?? false
|
||||
}
|
||||
|
||||
// MARK: Delete and its inverse
|
||||
|
||||
/// **Deletes a comment — a move into `comments/.trash/`, immediate, no confirm, undoable**
|
||||
/// (01-storage-format.md § Enhanced schema; 05-card-window.md ▸ The comments column).
|
||||
///
|
||||
/// The step is the move read backwards, `moveToTrash`'s registration one level down and without
|
||||
/// its rank half: a comment's trash has no order, so there is no `.order` after-value to compare
|
||||
/// and existence is the whole predicate. The container rides in the path exactly as it does at
|
||||
/// board level — the undo expects the comment in `comments/.trash/`, the redo expects it back in
|
||||
/// the thread — so a foreign restore or a foreign re-delete skips the right half by itself.
|
||||
@discardableResult
|
||||
public func deleteComment(_ commentID: ItemID, inCard id: ItemID) -> Bool {
|
||||
guard let card = commentSubject(id) else { return false }
|
||||
let folder = card.folder
|
||||
let title = card.title
|
||||
let live = CommentThread.commentFolder(commentID, inCard: folder)
|
||||
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
_ = try BoardWriter.deleteComment(at: live, cardTitle: title)
|
||||
}
|
||||
guard landed != nil else { return false }
|
||||
|
||||
let trashed = CommentThread.trashedCommentFolder(commentID, inCard: folder)
|
||||
registerStep(
|
||||
HistoryPhrase.name(.delete, kind: .comment),
|
||||
subject: title,
|
||||
undoExpects: [.present(trashed)],
|
||||
redoExpects: [.present(live)]
|
||||
) { _ in
|
||||
try BoardWriter.restoreComment(commentID, inCard: folder, cardTitle: title)
|
||||
} redo: { _ in
|
||||
_ = try BoardWriter.deleteComment(at: live, cardTitle: title)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: The purge, and the crash residue it leaves
|
||||
|
||||
/// **Empties one card's `comments/.trash/`** — the card window's close flush (01-storage-format.md
|
||||
/// § Enhanced schema: "purged when the card window closes (rides the close flush)").
|
||||
///
|
||||
/// One bracket, no step. Leftover comment steps on the board's stack are not pruned here and must
|
||||
/// not be: invalidation is lazy (13 ▸ Rules), so they stay on the stack, look full, and skip with
|
||||
/// the ordinary info-tone banner the first time one is crossed.
|
||||
public func purgeCommentTrash(inCard id: ItemID) {
|
||||
guard let card = commentSubject(id) else { return }
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
_ = try BoardWriter.purgeCommentTrash(inCard: card.folder)
|
||||
}
|
||||
}
|
||||
|
||||
/// **The crash-residue sweep**, run when a card window opens (§ Enhanced schema: "crash residue
|
||||
/// sweeps at the next card-window open, armed-then-cleared like every heal memo").
|
||||
///
|
||||
/// The same six steps every scheduled heal gets, through the same engine: **rest** when the trash
|
||||
/// is empty (which is every open on a board that closed cleanly, and costs no bracket at all),
|
||||
/// defer under a read-only lock, compare the signature, arm before attempting, one bracket, clear
|
||||
/// on success.
|
||||
///
|
||||
/// **Silent** — `HealNotice.none`. `comments/.trash/` is "never a UI surface", and the residue is
|
||||
/// the app's own leftovers from a session that died; there is nothing here a user could act on.
|
||||
///
|
||||
/// The memo is board-wide and keyed by class, so two cards' residue swept in turn re-arm each
|
||||
/// other's picture. That is harmless rather than tolerated: the picture *is* the work, the write
|
||||
/// half re-verifies against disk, and a sweep with nothing to remove is a no-op.
|
||||
public func sweepCommentTrashResidue(inCard id: ItemID) {
|
||||
guard let card = commentSubject(id) else { return }
|
||||
let folder = card.folder
|
||||
let residue = CommentThread.trashedCommentIDs(inCard: folder)
|
||||
heals.run(
|
||||
.commentTrashResidue,
|
||||
signature: Set(residue.map { "comment-trash:\(card.path)/\($0.rawValue)" }),
|
||||
on: self
|
||||
) { () throws(BoardWriteError) -> Void in
|
||||
_ = try BoardWriter.purgeCommentTrash(inCard: folder)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: The thread's claimed names
|
||||
|
||||
/// Displaces the claimed names one thread read found squatted — `comments/.draft`,
|
||||
/// `comments/.trash`, and a comment's own `attachments` (01-storage-format.md § Fractal layout
|
||||
/// ▸ Rules, the level-uniform displacement, applied at the two levels the thread adds).
|
||||
///
|
||||
/// **One bracket over the batch, and no memo**, which is the one place this heal differs from its
|
||||
/// board-level twin (`displaceClaimedNames`) — deliberately, and worth stating:
|
||||
/// `HealScheduler`'s memo is keyed by defect *class*, and these squatters are the same class as
|
||||
/// the board walk's. Sharing the key would have one card's thread picture overwrite the board's
|
||||
/// and back again on every reload, so the two would spend their memos fighting instead of guarding.
|
||||
/// What replaces the memo here is the trigger: a thread is re-read when its window opens or its
|
||||
/// files change, not on a timer, so there is no hot loop for a memo to break.
|
||||
///
|
||||
/// - Parameter squatters: the defects a `CommentThread` read reported (its `defects`, filtered).
|
||||
/// - Returns: what was actually moved aside, for the caller's warning-tone notice — the Writer
|
||||
/// re-verifies each against disk and answers `nil` for one that freed itself.
|
||||
@discardableResult
|
||||
public func displaceCommentClaimedNames(_ squatters: [ClaimedNameSquatter]) -> [BannerCenter.Displacement] {
|
||||
guard !squatters.isEmpty else { return [] }
|
||||
let root = rootURL
|
||||
var displaced: [BannerCenter.Displacement] = []
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
for squatter in squatters {
|
||||
guard let freed = try BoardWriter.displaceClaimedName(squatter, atBoardRoot: root) else {
|
||||
continue
|
||||
}
|
||||
displaced.append(BannerCenter.Displacement(name: squatter.name, movedTo: freed))
|
||||
}
|
||||
}
|
||||
return displaced
|
||||
}
|
||||
|
||||
// MARK: - Resolving the card
|
||||
|
||||
/// Where a card's thread lives and what to call the gesture — the three facts every comment write
|
||||
/// needs, resolved off the snapshot the way every other card-scoped call resolves them
|
||||
/// (`boardItem`): `nil` for an id that names no live card, which is the vanished-target guard.
|
||||
///
|
||||
/// The path is rebuilt from `rootURL` rather than remembered, so a board renamed mid-session
|
||||
/// writes at its new location.
|
||||
private func commentSubject(_ id: ItemID) -> (folder: URL, path: String, title: String?)? {
|
||||
guard let item = Self.boardItem(id, in: snapshot), let cardID = item.cardID else { return nil }
|
||||
return (
|
||||
folder: ItemPath.card(lane: item.laneID, id: cardID).folder(under: rootURL),
|
||||
path: "\(item.laneID.rawValue)/\(cardID.rawValue)",
|
||||
title: item.title
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - CommentPath
|
||||
|
||||
/// **What a changed path under a card's `comments/` is** — the pure classification the announcer and
|
||||
/// the commit-message composer read (01-storage-format.md § Enhanced schema: "foreign comment changes
|
||||
/// are described by **path shape** — the 'Update agent guide (vN)' mechanism: a changed path under
|
||||
/// `…/comments/<uuid>/` composes 'Comment on ⟨card title⟩' / 'Edit comment on…' / 'Delete comment
|
||||
/// on…', and the announcer speaks arrivals the same way").
|
||||
///
|
||||
/// ### Why a path, and not the snapshot
|
||||
///
|
||||
/// Comments are window-scoped and the board snapshot never carries their content, so the two
|
||||
/// consumers that describe *change* have no diff to read: the composer is "a pure snapshot diff" and
|
||||
/// the snapshot has nothing to say here, exactly as it has nothing to say about `CLAUDE.md`. Path
|
||||
/// shape is what is left, and it is enough — the verb family is a function of *where* a file sits,
|
||||
/// not of what it contains.
|
||||
///
|
||||
/// ### It classifies, it does not name
|
||||
///
|
||||
/// The shape says comment / draft / trashed and which card. Which *verb* that composes needs one more
|
||||
/// fact the path cannot carry — whether the folder is an arrival or a change to one already there —
|
||||
/// and that belongs to the caller with the before-and-after in hand. This type stays a pure function
|
||||
/// of a string so both consumers can share it without sharing anything else.
|
||||
///
|
||||
/// Homed beside `EchoLedger` and `BoardDiff`, which are the two things that turn observed paths into
|
||||
/// described events.
|
||||
public struct CommentPath: Sendable, Equatable {
|
||||
|
||||
/// The **card**'s path relative to the board root — `<lane>/<card>`, or `.trash/<card>` for a
|
||||
/// trashed card, which carries its thread like any other content.
|
||||
public let cardPath: String
|
||||
|
||||
/// Which of the thread's three homes the path is in.
|
||||
public let kind: Kind
|
||||
|
||||
public enum Kind: Sendable, Equatable {
|
||||
/// A posted comment — `<card>/comments/<uuid>/…`. The thread's own content.
|
||||
case comment(ItemID)
|
||||
/// The card's single draft — `<card>/comments/.draft/…`. Composes the quiet
|
||||
/// "Draft comment on '⟨card⟩'".
|
||||
case draft
|
||||
/// A deleted comment waiting for the close purge — `<card>/comments/.trash/<uuid>/…`.
|
||||
case trashed(ItemID)
|
||||
}
|
||||
|
||||
/// The comment's identity, or `nil` for the draft — which has none, and is the one member of the
|
||||
/// thread that is a name rather than an id.
|
||||
public var id: ItemID? {
|
||||
switch kind {
|
||||
case let .comment(id), let .trashed(id): id
|
||||
case .draft: nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifies one **root-relative, `/`-separated** path, or `nil` when it is not inside a thread.
|
||||
///
|
||||
/// The rule is one index: a thread lives at `<lane>/<card>/comments/` and a trashed card's at
|
||||
/// `.trash/<card>/comments/`, so `comments` is always the third component and the card is always
|
||||
/// the second — one check covers both containers without either being spelled twice.
|
||||
///
|
||||
/// Everything else answers `nil`, including the paths that are *nearly* one: `comments/` itself
|
||||
/// (a container, never an event), a stray folder inside it, `comments/.trash` with no entry under
|
||||
/// it. A `nil` is not a defect — it is this function saying the path is somebody else's to
|
||||
/// describe.
|
||||
public static func classify(_ relativePath: String) -> CommentPath? {
|
||||
let components = relativePath.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
|
||||
guard components.count >= 4,
|
||||
components[2].lowercased() == IntegrityRules.commentsFolderName,
|
||||
IntegrityRules.isIdentityShaped(components[1])
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let cardPath = components[0] + "/" + components[1]
|
||||
|
||||
let entry = components[3]
|
||||
if entry.lowercased() == IntegrityRules.commentDraftFolderName {
|
||||
return CommentPath(cardPath: cardPath, kind: .draft)
|
||||
}
|
||||
if entry.lowercased() == IntegrityRules.commentTrashFolderName {
|
||||
guard components.count >= 5, IntegrityRules.isIdentityShaped(components[4]) else { return nil }
|
||||
return CommentPath(cardPath: cardPath, kind: .trashed(ItemID(rawValue: components[4])))
|
||||
}
|
||||
guard IntegrityRules.isIdentityShaped(entry) else { return nil }
|
||||
return CommentPath(cardPath: cardPath, kind: .comment(ItemID(rawValue: entry)))
|
||||
}
|
||||
}
|
||||
@@ -376,9 +376,10 @@ public enum BoardLoader: Sendable {
|
||||
heldCards: children().count,
|
||||
document: document
|
||||
))
|
||||
case .card, .board:
|
||||
// `kind: board` never reaches here as itself — `trashKind` treats it as unrecognized
|
||||
// and answers by shape — so this arm is the card answer and nothing else.
|
||||
case .card, .board, .comment:
|
||||
// Neither `kind: board` nor `kind: comment` reaches here as itself — `trashKind`
|
||||
// treats both as unrecognized and answers by shape — so this arm is the card answer
|
||||
// and nothing else.
|
||||
trash.append(Card(
|
||||
id: id,
|
||||
schema: schema,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Turns a mutation into a filesystem operation — the single point through which every write
|
||||
/// the app makes reaches disk (02-architecture.md § Layering ▸ Components). Stateless by
|
||||
@@ -133,14 +134,15 @@ public enum BoardWriter: Sendable {
|
||||
/// a kind onto whatever was pointed at, which is the one thing the value-names-the-kind posture
|
||||
/// cannot afford.
|
||||
private static func derivedKind(ofItemFolder folder: URL) -> IntegrityRules.ObjectKind? {
|
||||
switch IntegrityRules.placement(
|
||||
ofFolderNamed: folder.lastPathComponent,
|
||||
inParentNamed: folder.deletingLastPathComponent().lastPathComponent
|
||||
) {
|
||||
// The URL form, deliberately: it is the only one that can tell the board's `.trash/` from a
|
||||
// comment thread's own `comments/.trash/`, which share a name.
|
||||
switch IntegrityRules.placement(ofFolder: folder) {
|
||||
case .card:
|
||||
return .card
|
||||
case .lane:
|
||||
return .lane
|
||||
case .comment:
|
||||
return .comment
|
||||
case .insideTrash:
|
||||
// The value cannot have answered — a document carrying `kind` is never backfilled, so
|
||||
// this is only reached for one that does not — which is precisely when shape decides.
|
||||
@@ -386,7 +388,9 @@ public enum BoardWriter: Sendable {
|
||||
/// probe: the minted name is lowercase, so it can only match a canonical set. 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 {
|
||||
/// Internal rather than `private`, with `renameFolder` and for its reason: a posted comment's
|
||||
/// identity is minted by this exact rule.
|
||||
static func freshUUIDName(in parentFolder: URL, avoiding taken: Set<String>) -> String {
|
||||
var name: String
|
||||
repeat {
|
||||
name = UUID().uuidString.lowercased()
|
||||
@@ -401,7 +405,9 @@ public enum BoardWriter: Sendable {
|
||||
/// 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(
|
||||
/// **Internal rather than `private`**: the comment writer (`CommentWriter.swift`) is this
|
||||
/// file's own extension one level down and runs the identical pre-flights.
|
||||
static func checkIsDirectory(
|
||||
_ url: URL,
|
||||
describedAs role: String,
|
||||
operation: WriteOperation
|
||||
@@ -746,14 +752,19 @@ public enum BoardWriter: Sendable {
|
||||
/// 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] {
|
||||
/// Internal rather than `private`: a comment thread's own children are enumerated by the same
|
||||
/// rule, one level down.
|
||||
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(
|
||||
///
|
||||
/// Internal rather than `private`: posting a comment is this rename and nothing else
|
||||
/// (`comments/.draft/` → a fresh identity), and its undo is the same rename read backwards.
|
||||
static func renameFolder(
|
||||
_ folder: URL,
|
||||
toSiblingNamed name: String,
|
||||
operation: WriteOperation
|
||||
@@ -777,7 +788,8 @@ public enum BoardWriter: Sendable {
|
||||
/// 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 {
|
||||
/// Internal rather than `private`: the comment purge checks its container the same way.
|
||||
static func isSameLocation(_ lhs: URL, _ rhs: URL) -> Bool {
|
||||
lhs.resolvingSymlinksInPath().standardizedFileURL.path
|
||||
== rhs.resolvingSymlinksInPath().standardizedFileURL.path
|
||||
}
|
||||
@@ -867,6 +879,12 @@ public enum BoardWriter: Sendable {
|
||||
}
|
||||
|
||||
do {
|
||||
// **`comments/.trash/` never crosses a copy boundary** (01-storage-format.md § Enhanced
|
||||
// schema: "stripped at every copy boundary … a copy must not carry ghosts no window
|
||||
// session will ever purge"). Before the remint, so the strip walks the paths the source
|
||||
// had rather than minted ones nobody has seen.
|
||||
try stripCommentTrash(under: root, operation: operation)
|
||||
|
||||
var copied: [URL] = []
|
||||
try remintDescendants(of: root, collecting: &copied, operation: operation)
|
||||
|
||||
@@ -903,6 +921,16 @@ public enum BoardWriter: Sendable {
|
||||
/// ▸ Instantiation) is `copyItem`'s rule applied to a whole board rather than to one item, and
|
||||
/// pointing this at a copied *board root* is literally that. A second implementation of "which
|
||||
/// folders are identities" is exactly what must not exist.
|
||||
/// **The thread comes too** (01-storage-format.md § Enhanced schema: "**Copies carry the thread**
|
||||
/// — a copy is a fork, and dropping a subtree would be the one place a copy loses content; comment
|
||||
/// folders remint like every copied folder"). `comments/` is not identity-shaped, so the recursion
|
||||
/// above cannot reach through it; the second loop is that one step, and it does not recurse
|
||||
/// because a comment has no identity-bearing children of its own (flat, this iteration).
|
||||
///
|
||||
/// **`comments/.draft/` is not reminted, and that is not an omission**: it is a dot-named folder,
|
||||
/// so `directoryCandidates` never offers it, and it has no identity to mint away from. It carries
|
||||
/// verbatim, which is the ruling ("copies and the trash carry it like any comment folder
|
||||
/// (fork-lossless)"). `comments/.trash/` is gone before this runs (`stripCommentTrash`).
|
||||
static func remintDescendants(
|
||||
of folder: URL,
|
||||
collecting copied: inout [URL],
|
||||
@@ -916,6 +944,44 @@ public enum BoardWriter: Sendable {
|
||||
copied.append(renamed)
|
||||
try remintDescendants(of: renamed, collecting: &copied, operation: operation)
|
||||
}
|
||||
|
||||
let thread = folder.appendingPathComponent(IntegrityRules.commentsFolderName, isDirectory: true)
|
||||
for comment in childCandidates(of: thread) {
|
||||
let fresh = freshUUIDName(in: thread, avoiding: [])
|
||||
try renameFolder(comment, toSiblingNamed: fresh, operation: operation)
|
||||
copied.append(thread.appendingPathComponent(fresh, isDirectory: true))
|
||||
}
|
||||
}
|
||||
|
||||
/// **Removes every `comments/.trash/` in a copied tree** — the copy boundary's strip
|
||||
/// (01-storage-format.md § Enhanced schema, ruled 2026-07-29). Called on the *destination*, so
|
||||
/// nothing a user still owns is ever removed by it; the source's thread trash stays exactly where
|
||||
/// it is, waiting for its own window's close purge.
|
||||
///
|
||||
/// The reach is the root plus its identity-bearing descendants, which is every folder that can
|
||||
/// have a thread: a comment has none, and a board root's own `comments/` would be a stray.
|
||||
///
|
||||
/// **Internal rather than `private`**: the clipboard's staging snapshot is a copy boundary the
|
||||
/// Writer does not perform (`ClipboardStore.stage`) and strips through this same call — "stripped
|
||||
/// at every copy boundary (clipboard staging, Duplicate, Save as Template)" is only one rule if it
|
||||
/// is one function.
|
||||
static func stripCommentTrash(under folder: URL, operation: WriteOperation) throws(BoardWriteError) {
|
||||
for item in [folder] + identityDescendants(of: folder) {
|
||||
let trash = item
|
||||
.appendingPathComponent(IntegrityRules.commentsFolderName, isDirectory: true)
|
||||
.appendingPathComponent(IntegrityRules.commentTrashFolderName, isDirectory: true)
|
||||
guard IntegrityRules.node(at: trash) != nil else { continue }
|
||||
do {
|
||||
try FileManager.default.removeItem(at: trash)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: trash.path,
|
||||
reason: .io(message: "could not remove the copied comment trash: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
EchoLedger.current?.recordDeletion(at: trash)
|
||||
}
|
||||
}
|
||||
|
||||
/// **The copy contract's frontmatter edits**, applied to every folder an item-level copy
|
||||
@@ -968,10 +1034,17 @@ public enum BoardWriter: Sendable {
|
||||
/// carries — is then the whole of what can honestly be said about it.
|
||||
///
|
||||
/// **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. The
|
||||
/// walk is `identityDescendants`', which is `remintDescendants`' own reach — so the set checked
|
||||
/// here is exactly the set that will be stamped, never a superset that could refuse a copy over a
|
||||
/// file nobody was going to touch.
|
||||
/// 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`).
|
||||
///
|
||||
/// **Internal rather than `private`**: template instantiation preflights its own tree with this,
|
||||
/// for `remintDescendants`' reason — one definition of what a copy owes its descendants.
|
||||
@@ -987,9 +1060,11 @@ public enum BoardWriter: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every identity-bearing folder beneath `folder`, depth first — `remintDescendants`' walk with
|
||||
/// the renaming taken out, so the preflight and the remint can never disagree about which folders
|
||||
/// a copy materializes as items.
|
||||
/// 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.
|
||||
private static func identityDescendants(of folder: URL) -> [URL] {
|
||||
var found: [URL] = []
|
||||
for child in childCandidates(of: folder) {
|
||||
@@ -1023,11 +1098,37 @@ 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
|
||||
|
||||
/// `<boardRoot>/.trash/` — the board's trash container, named but not created.
|
||||
@@ -1185,8 +1286,12 @@ public enum BoardWriter: Sendable {
|
||||
try checkIsDirectory(itemFolder, describedAs: kind == .lane ? "lane folder" : "card folder", operation: operation)
|
||||
try checkIsDirectory(boardRoot, describedAs: "board folder", operation: operation)
|
||||
switch kind {
|
||||
// `.board` and `.comment` are unreachable — the three callers pass `.card` or `.lane`, and
|
||||
// neither a board nor a comment is a thing the *board's* trash ever holds (a deleted comment
|
||||
// moves into its own thread's `comments/.trash/`). Both take the card guard, which refuses
|
||||
// them both loudly rather than letting an unexpected caller through.
|
||||
case .lane: try checkIsLaneFolder(itemFolder, operation: operation)
|
||||
case .card, .board: try checkIsCardFolder(itemFolder, operation: operation)
|
||||
case .card, .board, .comment: try checkIsCardFolder(itemFolder, operation: operation)
|
||||
}
|
||||
operation = try checkIndexIsRewritable(inItemFolder: itemFolder, operation: operation)
|
||||
|
||||
@@ -1949,7 +2054,9 @@ public enum BoardWriter: Sendable {
|
||||
/// 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) {
|
||||
/// Internal rather than `private`: every comment write is *about* a card, and reaches it
|
||||
/// through this same guard.
|
||||
static func checkIsCardFolder(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) {
|
||||
try checkIsUUIDShaped(folder, operation: operation)
|
||||
guard BoardLoader.isUUIDShaped(folder.deletingLastPathComponent().lastPathComponent) else {
|
||||
throw BoardWriteError(
|
||||
@@ -2287,7 +2394,8 @@ public enum BoardWriter: Sendable {
|
||||
/// travels, so this is the one place `moveItem`/`copyItem` can learn it at all. Returned
|
||||
/// rather than discarded so every failure after the pre-flight passes (the `FileManager`
|
||||
/// move/copy itself, the post-arrival `updateIndex`) also names the item.
|
||||
private static func checkIndexIsRewritable(
|
||||
/// Internal rather than `private`: the comment writer's own discover-before-you-write pre-flight.
|
||||
static func checkIndexIsRewritable(
|
||||
inItemFolder folder: URL,
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) -> WriteOperation {
|
||||
@@ -2306,7 +2414,9 @@ public enum BoardWriter: Sendable {
|
||||
/// rewrite of a BOM'd file into a whole-file byte change. A file that does not decode, or
|
||||
/// whose frontmatter does not parse, is `.unreadable` with the specifics: the app declines
|
||||
/// to write a file it cannot round-trip (01-storage-format.md § Fractal layout ▸ Rules).
|
||||
private static func readDocument(at url: URL, operation: WriteOperation) throws(BoardWriteError) -> FrontmatterDocument {
|
||||
/// Internal rather than `private`: the comment writer reads through the same door, so the two
|
||||
/// cannot disagree about what "could not be read" means.
|
||||
static func readDocument(at url: URL, operation: WriteOperation) throws(BoardWriteError) -> FrontmatterDocument {
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
@@ -2329,7 +2439,8 @@ public enum BoardWriter: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private static func checkEditable(
|
||||
/// Internal rather than `private`, with `readDocument` and for its reason.
|
||||
static func checkEditable(
|
||||
_ document: FrontmatterDocument,
|
||||
at url: URL,
|
||||
operation: WriteOperation
|
||||
@@ -2565,6 +2676,49 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// alert rather than the banner, so the Apply phrasing is never shown for one.
|
||||
case rawSource(title: String?)
|
||||
|
||||
// MARK: The comment family
|
||||
//
|
||||
// Five operations, one path-shaped verb family (06-history-undo.md ▸ Commit messages, the
|
||||
// vocabulary 01-storage-format.md § Enhanced schema names: "Comment on '⟨card title⟩'" / "Edit
|
||||
// comment on…" / "Delete comment on…" / "Draft comment on '⟨card⟩'"). **Every one of them carries
|
||||
// the *card's* title, not the comment's** — a comment has no `title` key at all (§ Enhanced
|
||||
// schema: "No `title`, no `order`"), and the thing a user recognizes is the card they are
|
||||
// commenting on. That is also why all five are identity in `withTitle`: there is no document to
|
||||
// enrich from, and the value arrives already filled in from the window the gesture came from.
|
||||
|
||||
/// The composer's slow-cadence save into `comments/.draft/` — blur, window close, quit, and the
|
||||
/// ~30 s tick (05-card-window.md ▸ The comments column: "Draft saves are slow-cadence, never
|
||||
/// prompted"). Its own case rather than a fold into `.editComment`, on the vocabulary's standing
|
||||
/// reasoning: a draft is not yet a comment, and telling someone the app "couldn't edit a comment"
|
||||
/// after they typed one that has never been posted would name a thing that does not exist.
|
||||
case saveCommentDraft(title: String?)
|
||||
|
||||
/// ⌘↩ — the draft renamed to a fresh identity and restamped, one bracket (§ Enhanced schema:
|
||||
/// "posting renames it to a fresh lowercase UUID and restamps `created`/`modified` in the same
|
||||
/// bracket — chronology is post time, not drafting time — one commit").
|
||||
case postComment(title: String?)
|
||||
|
||||
/// An inline comment edit session's save — the body-edit session in miniature (05 ▸ The comments
|
||||
/// column). Its own case beside `.editBody` for that case's reason: both write a body, but one is
|
||||
/// the card the window is about and the other is one annotation on it.
|
||||
case editComment(title: String?)
|
||||
|
||||
/// A comment moving into `comments/.trash/` — "delete = move into `comments/.trash/`", immediate,
|
||||
/// no confirm, undone by the ordinary move back (§ Enhanced schema; 13-native-undo.md).
|
||||
///
|
||||
/// **The inverse rides this same case**, deliberately: the restore is a move with no gesture of
|
||||
/// its own — the user pressed ⌘Z on a delete — and `.delete`'s own "there is no `restore` case"
|
||||
/// note is the precedent one level up.
|
||||
case deleteComment(title: String?)
|
||||
|
||||
/// `comments/.trash/` emptied — at card-window close, and as the crash-residue sweep at the next
|
||||
/// open (§ Enhanced schema).
|
||||
///
|
||||
/// **No payload, unlike its four siblings**, and for `.agentGuide`'s reason: this is bookkeeping
|
||||
/// the app does on its own over a folder that is "never a UI surface", with one outcome nobody
|
||||
/// asked for and nothing to name. The path-shaped verb family has no word for it either.
|
||||
case purgeCommentTrash
|
||||
|
||||
/// Fills in the title once the Writer has read it off the document the operation is acting
|
||||
/// on — identity for the cases with no title slot at all: `createBoard`/`createLane`/
|
||||
/// `createCard` are minting a file, not reading one; `importAttachment`, `removeAttachment` and
|
||||
@@ -2581,9 +2735,13 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
// `.repairDuplicateID` is identity here even though it carries a title: the remint never
|
||||
// opens an `index.md` — it is a rename — so there is no `readDocument` to enrich from, and
|
||||
// its title arrives already filled in from the load that detected the duplicate.
|
||||
// The comment family is identity for `.repairDuplicateID`'s reason, doubled: a comment's
|
||||
// `index.md` carries no `title` to enrich from, and the title these five hold is the *card's*,
|
||||
// filled in by the caller from the window the gesture came from.
|
||||
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
|
||||
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide,
|
||||
.displaceClaimedName, .repairDuplicateID:
|
||||
.displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment,
|
||||
.editComment, .deleteComment, .purgeCommentTrash:
|
||||
self
|
||||
case .move: .move(title: title)
|
||||
case .reorder: .reorder(title: title)
|
||||
@@ -2638,10 +2796,15 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
switch self {
|
||||
case .reorder, .renumberChildren:
|
||||
true
|
||||
// The comment family writes content, never a rank: a comment has no `order` at all
|
||||
// (01-storage-format.md § Enhanced schema), so there is nothing here for the order-only
|
||||
// reading to be about — and `.deleteComment`'s move into `comments/.trash/` stamps for the
|
||||
// plain container reason its board-level twin does.
|
||||
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
|
||||
.style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment,
|
||||
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .displaceClaimedName,
|
||||
.repairDuplicateID, .toggleTask, .editBody, .rawSource:
|
||||
.repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment,
|
||||
.editComment, .deleteComment, .purgeCommentTrash:
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -2680,6 +2843,12 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
|
||||
case let .editBody(title): Self.phrase("save the body of", title)
|
||||
case let .rawSource(title): Self.phrase("apply source changes to", title)
|
||||
// The card's title, never the comment's — see the family's own note above.
|
||||
case let .saveCommentDraft(title): Self.phrase("save the comment draft on", title)
|
||||
case let .postComment(title): Self.phrase("post a comment on", title)
|
||||
case let .editComment(title): Self.phrase("edit a comment on", title)
|
||||
case let .deleteComment(title): Self.phrase("delete a comment on", title)
|
||||
case .purgeCommentTrash: "purge deleted comments"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
// MARK: - Comment
|
||||
|
||||
/// One comment: a UUID-named folder under a card's `comments/`, holding `index.md` and optionally
|
||||
/// `attachments/` — "a card's anatomy one level down" (01-storage-format.md § Enhanced schema,
|
||||
/// storage specified 2026-07-29).
|
||||
///
|
||||
/// The field table is the common schema minus two and plus one: **no `title`, no `order`**, and
|
||||
/// `author` — self-reported *content* that survives every app write, unlike `modified-by`.
|
||||
///
|
||||
/// `Card`'s shape deliberately, one level down: identity, the lenient fields as `FieldValue`s, the
|
||||
/// attachment listing, and the whole parsed document so unknown and reserved keys ride along
|
||||
/// uninterpreted.
|
||||
public struct Comment: Identifiable, Sendable, Equatable {
|
||||
public let id: ItemID
|
||||
|
||||
/// The schema number as read. Lenient here, unlike every other level: a comment defect never
|
||||
/// refuses anything (§ Enhanced schema), so a missing or unreadable `schema` costs the thread a
|
||||
/// rendered comment, never a load.
|
||||
public let schema: FieldValue<Int>
|
||||
|
||||
/// Who says they wrote it — the app writes the macOS account's full name, agents write their
|
||||
/// own, tracker sync writes the remote author verbatim. **Missing renders unattributed**; there
|
||||
/// is no identity system behind it and none is implied.
|
||||
public let author: FieldValue<String>
|
||||
|
||||
/// Load-bearing, unlike anywhere else in the schema: the thread's order *is* `created`
|
||||
/// ascending (§ Enhanced schema — "Ordering is chronology, not ranks").
|
||||
public let created: FieldValue<Date>
|
||||
public let modified: FieldValue<Date>
|
||||
public let modifiedBy: FieldValue<String>
|
||||
|
||||
/// The comment's attachment file names — flat, top-level regular files, Finder order, through
|
||||
/// the same enumeration a card's listing uses (`BoardLoader.attachmentNames`), so a chip row and
|
||||
/// a card's sidebar can never disagree about what a folder holds.
|
||||
public let attachments: [String]
|
||||
|
||||
/// The full parsed `index.md`; unknown and reserved keys ride along uninterpreted.
|
||||
public let document: FrontmatterDocument
|
||||
|
||||
/// The comment's Markdown — the card-body subset. Equivalent to `document.body`.
|
||||
public var body: String { document.body }
|
||||
|
||||
/// **The edited indicator is `modified` differing from `created`, and no extra field**
|
||||
/// (§ Enhanced schema). A post writes both from one `Date`, so a comment that has never been
|
||||
/// edited reads `false` by construction; one of the pair missing is not evidence of an edit.
|
||||
public var isEdited: Bool {
|
||||
guard let created = created.value, let modified = modified.value else { return false }
|
||||
return modified != created
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CommentThread
|
||||
|
||||
/// A card's comment thread, read from disk — **window-scoped, outside the board snapshot**
|
||||
/// (01-storage-format.md § Enhanced schema: "the walk stays O(cards): the card window reads its own
|
||||
/// thread … and the board snapshot never loads comment content").
|
||||
///
|
||||
/// ### It never refuses
|
||||
///
|
||||
/// There is no `throws` anywhere in this type, and that is the ruling rather than convenience:
|
||||
/// "**Comment defects never refuse the board** — worst case is the stray posture (tolerated, logged,
|
||||
/// unrendered): a broken leaf annotation must not brick a load; deliberate, proportionate divergence
|
||||
/// from card fail-fast". A folder that is not identity-shaped, one with no `index.md`, one whose
|
||||
/// frontmatter does not parse, one that is not UTF-8 — each is skipped with a log line, preserved
|
||||
/// verbatim on disk, and the rest of the thread renders.
|
||||
///
|
||||
/// ### The two dot-named folders are not comments
|
||||
///
|
||||
/// `comments/.draft/` and `comments/.trash/` are excluded from the listing (§ Enhanced schema), and
|
||||
/// they are excluded *for free*: both are dot-prefixed, and `BoardLoader.directoryCandidates` skips
|
||||
/// hidden entries. The exclusion is stated in the enumeration's own rule rather than re-implemented
|
||||
/// here, exactly as `.trash/` is at board level.
|
||||
public struct CommentThread: Sendable, Equatable {
|
||||
|
||||
/// The thread in display order — `created` ascending (see `sorted(_:)` for the fallback).
|
||||
public let comments: [Comment]
|
||||
|
||||
/// Folders under `comments/` that are not comments — reported for the log's sake and rendered by
|
||||
/// nothing. The tolerate tier, one level down.
|
||||
public let strays: [Stray]
|
||||
|
||||
/// Pending work and coerce-tier observations this read found — claimed names squatted inside
|
||||
/// `comments/` or inside one comment, and every lenient field that had no sensible reading. The
|
||||
/// same typed stream the board walk fills (`IntegrityRules.Defect`), so the heal engine needs no
|
||||
/// second vocabulary for a thread.
|
||||
public let defects: [IntegrityRules.Defect]
|
||||
|
||||
/// Whether the card has a draft on disk. The composer reads its bytes itself; what a *thread*
|
||||
/// needs to know is only that one exists, which is the answer restore-on-reopen turns on.
|
||||
public let hasDraft: Bool
|
||||
|
||||
/// One folder under `comments/` the thread would not render, and why — never an error, always a
|
||||
/// log line.
|
||||
public struct Stray: Sendable, Equatable {
|
||||
public enum Reason: Sendable, Equatable {
|
||||
/// Not identity-shaped: a hand-made folder, a nested clone. The shape-only identity
|
||||
/// predicate one level down.
|
||||
case notIdentityShaped
|
||||
/// Identity-shaped with no `index.md` — two-step-create tolerance, verbatim from the
|
||||
/// card rule (01-storage-format.md § Fractal layout ▸ Rules).
|
||||
case missingIndex
|
||||
/// The bytes are there and could not be read as a comment: not UTF-8, frontmatter that
|
||||
/// does not parse. The one reason a *card* would have failed the whole load.
|
||||
case unreadable(message: String)
|
||||
}
|
||||
|
||||
public let name: String
|
||||
public let reason: Reason
|
||||
}
|
||||
|
||||
public static let empty = CommentThread(comments: [], strays: [], defects: [], hasDraft: false)
|
||||
|
||||
// MARK: - Where a thread lives
|
||||
|
||||
/// `<card>/comments/` — named but never created here. One place, so the loader's read and every
|
||||
/// write in `CommentWriter.swift` can never disagree about where a thread is
|
||||
/// (`BoardWriter.trashFolder(inBoard:)`'s precedent).
|
||||
public static func folder(inCard cardFolder: URL) -> URL {
|
||||
cardFolder.appendingPathComponent(IntegrityRules.commentsFolderName, isDirectory: true)
|
||||
}
|
||||
|
||||
/// `<card>/comments/.draft/` — the card's single draft.
|
||||
public static func draftFolder(inCard cardFolder: URL) -> URL {
|
||||
folder(inCard: cardFolder)
|
||||
.appendingPathComponent(IntegrityRules.commentDraftFolderName, isDirectory: true)
|
||||
}
|
||||
|
||||
/// `<card>/comments/.trash/` — undo's backing store, purged at window close.
|
||||
public static func trashFolder(inCard cardFolder: URL) -> URL {
|
||||
folder(inCard: cardFolder)
|
||||
.appendingPathComponent(IntegrityRules.commentTrashFolderName, isDirectory: true)
|
||||
}
|
||||
|
||||
/// `<card>/comments/<id>/` — one posted comment.
|
||||
public static func commentFolder(_ id: ItemID, inCard cardFolder: URL) -> URL {
|
||||
folder(inCard: cardFolder).appendingPathComponent(id.rawValue, isDirectory: true)
|
||||
}
|
||||
|
||||
/// `<card>/comments/.trash/<id>/` — one deleted comment, waiting for the close purge or a ⌘Z.
|
||||
public static func trashedCommentFolder(_ id: ItemID, inCard cardFolder: URL) -> URL {
|
||||
trashFolder(inCard: cardFolder).appendingPathComponent(id.rawValue, isDirectory: true)
|
||||
}
|
||||
|
||||
/// The identities currently sitting in `comments/.trash/` — what a close purge would remove, and
|
||||
/// what the crash-residue sweep signs its memo with.
|
||||
///
|
||||
/// The listing rule is the thread's own (`directoryCandidates` narrowed by the identity
|
||||
/// predicate), so a stray a hand-editor put in there is neither counted nor purged — the same
|
||||
/// honesty `emptyTrash` keeps at board level.
|
||||
public static func trashedCommentIDs(inCard cardFolder: URL) -> [ItemID] {
|
||||
((try? BoardLoader.directoryCandidates(in: trashFolder(inCard: cardFolder))) ?? [])
|
||||
.filter { IntegrityRules.isIdentityShaped($0.lastPathComponent) }
|
||||
.map { ItemID(rawValue: $0.lastPathComponent) }
|
||||
}
|
||||
|
||||
// MARK: - Reading
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "comments")
|
||||
|
||||
/// Reads one card's thread. Total: a card with no `comments/`, an unreadable one, or one held by
|
||||
/// a file answers `.empty`, which is what "no comments yet" looks like and is not a defect this
|
||||
/// read has any business inventing — the squatted-name case *is* reported, as work.
|
||||
///
|
||||
/// - Parameter path: the card folder's path relative to the board root (`<lane>/<card>`, or
|
||||
/// `.trash/<card>` for a trashed one — "a trashed card carries its `comments/`"). Carried into
|
||||
/// every defect so the heal lands wherever the board lives at write time, and into the log
|
||||
/// lines so a stray names something a human can find.
|
||||
public static func load(inCard cardFolder: URL, path: String) -> CommentThread {
|
||||
let threadFolder = folder(inCard: cardFolder)
|
||||
var defects: [IntegrityRules.Defect] = []
|
||||
|
||||
switch IntegrityRules.node(at: threadFolder) {
|
||||
case nil:
|
||||
return .empty
|
||||
case .directory:
|
||||
break
|
||||
case .file, .symlink:
|
||||
// The claimed name held by the wrong kind of node. Detection only, like every other
|
||||
// defect in this app — the displacement is the store's, through the Writer. Nothing else
|
||||
// about the thread can be read while a file wears the name, so the listing is empty and
|
||||
// the work is the whole answer.
|
||||
return CommentThread(
|
||||
comments: [],
|
||||
strays: [],
|
||||
defects: IntegrityRules.squattedClaimedNames(inCardAt: cardFolder, path: path)
|
||||
.map(IntegrityRules.Defect.claimedNameSquatted),
|
||||
hasDraft: false
|
||||
)
|
||||
}
|
||||
|
||||
for squatter in IntegrityRules.squattedClaimedNames(inCommentThreadAt: threadFolder, cardPath: path) {
|
||||
defects.append(.claimedNameSquatted(squatter))
|
||||
logger.warning(
|
||||
"\(path, privacy: .public)/comments/\(squatter.name, privacy: .public): claimed name held by \(squatter.found.description, privacy: .public) — to be displaced"
|
||||
)
|
||||
}
|
||||
|
||||
var comments: [Comment] = []
|
||||
var strays: [Stray] = []
|
||||
// Hidden entries and symlinks are already out, which is exactly how `.draft` and `.trash` are
|
||||
// excluded from the thread — see the type's own note.
|
||||
for commentURL in (try? BoardLoader.directoryCandidates(in: threadFolder)) ?? [] {
|
||||
let name = commentURL.lastPathComponent
|
||||
let commentPath = path + "/" + IntegrityRules.commentsFolderName + "/" + name
|
||||
|
||||
guard IntegrityRules.isIdentityShaped(name) else {
|
||||
strays.append(Stray(name: name, reason: .notIdentityShaped))
|
||||
logger.warning("\(commentPath, privacy: .public): not a comment identity — ignored")
|
||||
continue
|
||||
}
|
||||
let indexURL = commentURL.appendingPathComponent(IntegrityRules.indexFileName)
|
||||
guard let data = try? Data(contentsOf: indexURL) else {
|
||||
strays.append(Stray(name: name, reason: .missingIndex))
|
||||
logger.warning("\(commentPath, privacy: .public): no index.md — ignored")
|
||||
continue
|
||||
}
|
||||
let document: FrontmatterDocument
|
||||
do {
|
||||
document = try BoardLoader.parseDocument(data, path: commentPath)
|
||||
} catch {
|
||||
strays.append(Stray(name: name, reason: .unreadable(message: error.reason.description)))
|
||||
logger.warning(
|
||||
"\(commentPath, privacy: .public): \(error.reason.description, privacy: .public) — ignored"
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
let fields = document.coercedFields
|
||||
if !fields.isEmpty {
|
||||
let indexPath = commentPath + "/" + IntegrityRules.indexFileName
|
||||
defects.append(.coercedFrontmatter(CoercedFrontmatter(path: indexPath, fields: fields)))
|
||||
for field in fields {
|
||||
logger.info(
|
||||
"\(indexPath, privacy: .public): '\(field.key, privacy: .public)' has no sensible reading — \(field.raw, privacy: .public) — rendering the field's default"
|
||||
)
|
||||
}
|
||||
}
|
||||
for squatter in IntegrityRules.squattedClaimedNames(inCommentAt: commentURL, path: commentPath) {
|
||||
defects.append(.claimedNameSquatted(squatter))
|
||||
logger.warning(
|
||||
"\(commentPath, privacy: .public)/\(squatter.name, privacy: .public): claimed name held by \(squatter.found.description, privacy: .public) — to be displaced"
|
||||
)
|
||||
}
|
||||
|
||||
comments.append(Comment(
|
||||
id: ItemID(rawValue: name),
|
||||
schema: document.schema,
|
||||
author: document.author,
|
||||
created: document.created,
|
||||
modified: document.modified,
|
||||
modifiedBy: document.modifiedBy,
|
||||
attachments: BoardLoader.attachmentNames(in: commentURL),
|
||||
document: document
|
||||
))
|
||||
}
|
||||
|
||||
return CommentThread(
|
||||
comments: sorted(comments),
|
||||
strays: strays,
|
||||
defects: defects,
|
||||
hasDraft: IntegrityRules.node(at: draftFolder(inCard: cardFolder)) == .directory
|
||||
)
|
||||
}
|
||||
|
||||
/// **Chronology, with the undated after the dated** (01-storage-format.md § Enhanced schema,
|
||||
/// ruled 2026-07-29): "the thread sorts by `created` ascending … ties and missing/malformed
|
||||
/// `created` (coerce-tier fallback, logged) sort after dated siblings, folder-name order".
|
||||
///
|
||||
/// The folder-name tie-break compares the **canonical lowercase spelling**, the corpus-wide rule
|
||||
/// for every folder-name tie-break (§ Ordering: "ordering follows the value-based identity model,
|
||||
/// never an uppercase folder's ASCII accident") — an agent's uppercase `uuidgen` output must not
|
||||
/// sort into a different place than the same identity spelled lowercase.
|
||||
static func sorted(_ comments: [Comment]) -> [Comment] {
|
||||
comments.sorted { lhs, rhs in
|
||||
switch (lhs.created.value, rhs.created.value) {
|
||||
case let (left?, right?):
|
||||
left == right ? isOrderedByName(lhs, rhs) : left < right
|
||||
case (.some, .none):
|
||||
true
|
||||
case (.none, .some):
|
||||
false
|
||||
case (.none, .none):
|
||||
isOrderedByName(lhs, rhs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func isOrderedByName(_ lhs: Comment, _ rhs: Comment) -> Bool {
|
||||
IntegrityRules.canonicalIdentity(lhs.id.rawValue)
|
||||
< IntegrityRules.canonicalIdentity(rhs.id.rawValue)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Outcomes
|
||||
|
||||
/// What one draft save produced on disk — the composer's slow cadence has four possible answers and
|
||||
/// the caller needs to tell them apart (the create is what a first keystroke does, the delete is the
|
||||
/// emptied-draft rule firing, and `.unchanged` is the ~30 s tick finding nothing to do).
|
||||
public enum CommentDraftOutcome: Sendable, Equatable {
|
||||
/// `comments/.draft/` did not exist and now does, carrying the ordinary comment schema.
|
||||
case created
|
||||
/// Its body span was replaced and `modified` stamped.
|
||||
case updated
|
||||
/// The bytes on disk already said this. Nothing was written and no `mtime` moved.
|
||||
case unchanged
|
||||
/// **The emptied-draft rule**: no text, no attachments, so the folder is gone
|
||||
/// (01-storage-format.md § Enhanced schema — "a draft emptied of text with no attachments is
|
||||
/// deleted by the app, never litter").
|
||||
case deleted
|
||||
}
|
||||
|
||||
/// What a post landed: the fresh identity, and the instant `created` and `modified` were both
|
||||
/// stamped with. Both halves are what a redo replays (13-native-undo.md — a redo repeats the values
|
||||
/// its gesture wrote, never re-derives them).
|
||||
public struct PostedComment: Sendable, Equatable {
|
||||
public let id: ItemID
|
||||
/// One `Date` for both stamps, so a freshly posted comment reads "not edited" by construction
|
||||
/// rather than by rounding (`Comment.isEdited`).
|
||||
public let posted: Date
|
||||
}
|
||||
|
||||
// MARK: - The comment writer
|
||||
|
||||
/// **The comment thread's write primitives** — `BoardWriter`'s own vocabulary one level down
|
||||
/// (01-storage-format.md § Enhanced schema, storage specified 2026-07-29; 05-card-window.md ▸ The
|
||||
/// comments column).
|
||||
///
|
||||
/// An extension in its own file for `BoardStoreHistory.swift`'s reason: this is the same type, and
|
||||
/// the rules it obeys are that type's rules — atomic temp+rename through `atomicReplace`, surgical
|
||||
/// span edits through `FrontmatterDocument`, `modified` stamped and `modified-by` cleared on every
|
||||
/// content write, receipts dropped in the EchoLedger by the four disk primitives — but the *subject*
|
||||
/// is one thing, and a reader looking for what a comment write does should find it in one place.
|
||||
///
|
||||
/// ### What is different one level down, and why
|
||||
///
|
||||
/// - **The title every operation carries is the card's.** A comment has no `title` key at all, and
|
||||
/// the path-shaped verb family names the card ("Comment on '⟨card⟩'"), so each entry point takes
|
||||
/// `cardTitle` and hands it to its `WriteOperation`. `withTitle` is identity for all five.
|
||||
/// - **`author` survives.** `updateIndex` clears `modified-by` and only that, so the lenient
|
||||
/// self-reported `author` rides through every rewrite untouched, which is the field table's whole
|
||||
/// point ("unlike `modified-by` it survives app writes").
|
||||
/// - **Nothing here has an `order`.** Chronology is the thread's order, so there are no ranks to
|
||||
/// mint, no ladder to thread, and no renumber to fall back on.
|
||||
/// - **No byte capture, anywhere** (13-native-undo.md): the delete is a move, its inverse is the
|
||||
/// move back, and the post's inverse is the rename back. Nothing in this file reads a body in
|
||||
/// order to hold it.
|
||||
extension BoardWriter {
|
||||
|
||||
// MARK: - The draft
|
||||
|
||||
/// **Saves the card's single draft** into `comments/.draft/` — create on the first save, a body
|
||||
/// span replacement on every one after (05-card-window.md ▸ The comments column: "The composer
|
||||
/// edits `comments/.draft/`", written "on composer blur, window close, quit, and a lazy interval
|
||||
/// (~30 s)").
|
||||
///
|
||||
/// The sequence:
|
||||
///
|
||||
/// 1. **It must be a card** (`checkIsCardFolder`) — a thread hangs off a card and nothing else.
|
||||
/// 2. **The emptied-draft rule first**, before anything is created: a save with no text and no
|
||||
/// attachments *removes* the folder, and a save with no text against a draft that does not
|
||||
/// exist creates nothing at all. Typing one character and deleting it must not leave a folder
|
||||
/// behind ("never litter").
|
||||
/// 3. **Create**, minting `comments/` on the way if the card has never had one: `schema`,
|
||||
/// `author`, `created`, `modified`, `kind: comment`. The two stamps share one `Date`.
|
||||
/// 4. **Update**: the body span and the `modified` stamp, with the identical-bytes gate
|
||||
/// `writeBody` has and for its reason — the ~30 s tick fires whether or not anything changed,
|
||||
/// and a no-op save that stamped `modified` would make an untouched draft look edited every
|
||||
/// half minute (and, on a Pro board, commit).
|
||||
///
|
||||
/// **"No attachments" is the listing the user sees** (`BoardLoader.attachmentNames`): top-level
|
||||
/// regular files in `attachments/`, which is exactly the set the composer renders as chips. A
|
||||
/// draft holding only an empty `attachments/` folder, or only a subfolder, has nothing to lose
|
||||
/// and is deleted — the chips are the promise, not the directory.
|
||||
@discardableResult
|
||||
public static func saveCommentDraft(
|
||||
inCard cardFolder: URL,
|
||||
body: String,
|
||||
cardTitle: String?
|
||||
) throws(BoardWriteError) -> CommentDraftOutcome {
|
||||
let operation = WriteOperation.saveCommentDraft(title: cardTitle)
|
||||
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
||||
try checkIsCardFolder(cardFolder, operation: operation)
|
||||
|
||||
let draft = CommentThread.draftFolder(inCard: cardFolder)
|
||||
let exists = IntegrityRules.node(at: draft) == .directory
|
||||
|
||||
if body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
!exists || BoardLoader.attachmentNames(in: draft).isEmpty {
|
||||
guard exists else { return .unchanged }
|
||||
do {
|
||||
try FileManager.default.removeItem(at: draft)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: draft.path,
|
||||
reason: .io(message: "could not remove the emptied draft: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
EchoLedger.current?.recordDeletion(at: draft)
|
||||
return .deleted
|
||||
}
|
||||
|
||||
let indexURL = draft.appendingPathComponent(BoardLoader.indexFileName)
|
||||
// A folder with no `index.md` takes the create path rather than failing: that is the shape an
|
||||
// interrupted create leaves, and two-step-create tolerance is one of the fractal rules that
|
||||
// apply here verbatim (01-storage-format.md § Enhanced schema).
|
||||
guard exists, FileManager.default.fileExists(atPath: indexURL.path) else {
|
||||
if !exists {
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: draft, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: draft.path,
|
||||
reason: .io(message: "could not create the draft folder: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
}
|
||||
try atomicReplace(text: newCommentText(body: body), at: indexURL, operation: operation)
|
||||
return .created
|
||||
}
|
||||
|
||||
return try writeCommentBody(at: draft, body: body, operation: operation) ? .updated : .unchanged
|
||||
}
|
||||
|
||||
/// **Posts the draft**: `comments/.draft/` renamed to a fresh lowercase UUID, `created` and
|
||||
/// `modified` restamped, **in one bracket** (01-storage-format.md § Enhanced schema, ruled
|
||||
/// 2026-07-29: "posting renames it to a fresh lowercase UUID and restamps `created`/`modified` in
|
||||
/// the same bracket — chronology is post time, not drafting time — one commit").
|
||||
///
|
||||
/// **One `Date` for both stamps**, `newDocumentText`'s convention and here it is load-bearing:
|
||||
/// the edited indicator is `modified` differing from `created`, so two `Date()` calls straddling
|
||||
/// a second boundary would post a comment that renders as already edited.
|
||||
///
|
||||
/// **The rename is the identity mint**, so nothing is copied and no bytes move: `attachments/`,
|
||||
/// strays and every unknown key arrive exactly as the draft held them. The `author` the draft was
|
||||
/// created with rides through the restamp untouched, which is what makes posting a draft written
|
||||
/// last week still attributed to whoever wrote it.
|
||||
///
|
||||
/// A draft folder with no readable `index.md` is minted one first (the create path's tolerance);
|
||||
/// a draft whose frontmatter cannot be round-tripped refuses the post before the rename —
|
||||
/// discover-before-you-write, `moveItem`'s rule, and the gesture the user pressed is the thing
|
||||
/// that failed rather than some other comment being tolerated.
|
||||
public static func postComment(
|
||||
inCard cardFolder: URL,
|
||||
cardTitle: String?
|
||||
) throws(BoardWriteError) -> PostedComment {
|
||||
let operation = WriteOperation.postComment(title: cardTitle)
|
||||
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
||||
try checkIsCardFolder(cardFolder, operation: operation)
|
||||
|
||||
let thread = CommentThread.folder(inCard: cardFolder)
|
||||
let draft = CommentThread.draftFolder(inCard: cardFolder)
|
||||
guard IntegrityRules.node(at: draft) == .directory else {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: draft.path,
|
||||
reason: .unreadable(message: "there is no draft to post")
|
||||
)
|
||||
}
|
||||
|
||||
let draftIndexURL = draft.appendingPathComponent(BoardLoader.indexFileName)
|
||||
if !FileManager.default.fileExists(atPath: draftIndexURL.path) {
|
||||
try atomicReplace(text: newCommentText(body: ""), at: draftIndexURL, operation: operation)
|
||||
}
|
||||
_ = try checkIndexIsRewritable(inItemFolder: draft, operation: operation)
|
||||
|
||||
let name = freshUUIDName(in: thread, avoiding: [])
|
||||
try renameFolder(draft, toSiblingNamed: name, operation: operation)
|
||||
let posted = thread.appendingPathComponent(name, isDirectory: true)
|
||||
|
||||
let now = Date()
|
||||
try restampComment(at: posted, to: now, operation: operation)
|
||||
return PostedComment(id: ItemID(rawValue: name), posted: now)
|
||||
}
|
||||
|
||||
// MARK: - Editing
|
||||
|
||||
/// **An inline comment edit's save** — the body-edit session in miniature (05-card-window.md ▸
|
||||
/// The comments column: "debounced saves to the comment's own file keep it crash-safe, Save (or
|
||||
/// ⌘↩) ends the session as its commit point").
|
||||
///
|
||||
/// `writeBody`'s three properties, one level down: the body span and nothing else changes, the
|
||||
/// bytes above the closing delimiter are the bytes they were, and **identical bytes write
|
||||
/// nothing** (returning `false` with an untouched `mtime`). `modified` is stamped and
|
||||
/// `modified-by` cleared; `author` and `created` are not touched, which is exactly how "· edited"
|
||||
/// comes to be true without a field existing for it.
|
||||
///
|
||||
/// **Cancel is not here.** The session reverts to its own start-of-session bytes, which is the
|
||||
/// UI session's business (phase 2) and not a Writer primitive: there is no capture in this file.
|
||||
///
|
||||
/// - Returns: `true` when bytes were written, `false` when the body on disk already matched.
|
||||
@discardableResult
|
||||
public static func editComment(
|
||||
at commentFolder: URL,
|
||||
body: String,
|
||||
cardTitle: String?
|
||||
) throws(BoardWriteError) -> Bool {
|
||||
let operation = WriteOperation.editComment(title: cardTitle)
|
||||
try checkIsCommentFolder(commentFolder, operation: operation)
|
||||
return try writeCommentBody(at: commentFolder, body: body, operation: operation)
|
||||
}
|
||||
|
||||
// MARK: - Delete, restore, purge
|
||||
|
||||
/// **Deleting a comment is a move into `comments/.trash/`** (01-storage-format.md § Enhanced
|
||||
/// schema, re-ruled 2026-07-29: "the materialized-trash pattern one level down, joining `.draft`
|
||||
/// in the claimed names, excluded from the thread, never a UI surface").
|
||||
///
|
||||
/// `deleteCardToTrash`'s body one level down, minus the thing a comment does not have: there is
|
||||
/// no rank to mint, because the trash a comment lands in has no order at all — it is undo's
|
||||
/// backing store for the life of one window, not a browsable column.
|
||||
///
|
||||
/// **The stamp is the container rule's plainest instance, again**: the move changes the comment's
|
||||
/// container, so `updateIndex` stamps `modified` and clears `modified-by` without a trash branch
|
||||
/// existing anywhere (§ Enhanced schema: "The container-change stamping rule applies — the move
|
||||
/// stamps `modified`"). `kind: .comment` is passed rather than derived so the on-touch backfill
|
||||
/// cannot mistake a folder inside a `.trash` for a board-trash resident.
|
||||
///
|
||||
/// **No confirm, no capture** — undo is the net, and its inverse is `restoreComment`.
|
||||
@discardableResult
|
||||
public static func deleteComment(
|
||||
at commentFolder: URL,
|
||||
cardTitle: String?
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
let operation = WriteOperation.deleteComment(title: cardTitle)
|
||||
try checkIsCommentFolder(commentFolder, operation: operation)
|
||||
// The move rewrites this file at the destination, so a file that cannot be round-tripped
|
||||
// refuses before the folder travels (`moveItem`'s discover-before-you-write rule).
|
||||
_ = try checkIndexIsRewritable(inItemFolder: commentFolder, operation: operation)
|
||||
|
||||
let trash = commentFolder
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent(IntegrityRules.commentTrashFolderName, isDirectory: true)
|
||||
try moveComment(commentFolder, into: trash, operation: operation)
|
||||
return ItemID(rawValue: commentFolder.lastPathComponent)
|
||||
}
|
||||
|
||||
/// **The delete's inverse: the move back out** (13-native-undo.md ▸ Interaction with the trash,
|
||||
/// the comment clause: "a comment delete is a move into the card's `comments/.trash/` … so its
|
||||
/// inverse is the ordinary move back").
|
||||
///
|
||||
/// It stamps for the reason the forward move does — the container changed again — and it takes
|
||||
/// `.deleteComment`'s own vocabulary word, `.delete`'s "there is no `restore` case" precedent one
|
||||
/// level down: a failed restore is a comment that could not be moved, and inventing a sixth
|
||||
/// operation to say so would name a gesture the user never pressed (they pressed ⌘Z).
|
||||
public static func restoreComment(
|
||||
_ id: ItemID,
|
||||
inCard cardFolder: URL,
|
||||
cardTitle: String?
|
||||
) throws(BoardWriteError) {
|
||||
let operation = WriteOperation.deleteComment(title: cardTitle)
|
||||
let trashed = CommentThread.trashedCommentFolder(id, inCard: cardFolder)
|
||||
try checkIsDirectory(trashed, describedAs: "comment folder", operation: operation)
|
||||
try moveComment(trashed, into: CommentThread.folder(inCard: cardFolder), operation: operation)
|
||||
}
|
||||
|
||||
/// **Empties one card's `comments/.trash/`** — at card-window close, and as the crash-residue
|
||||
/// sweep at the next open (01-storage-format.md § Enhanced schema: "purged when the card window
|
||||
/// closes (rides the close flush; crash residue sweeps at the next card-window open,
|
||||
/// armed-then-cleared like every heal memo)").
|
||||
///
|
||||
/// `emptyTrash`'s rules, one level down and for its reasons:
|
||||
///
|
||||
/// - **The entries, not the container.** Only identity-shaped children are removed; a stray a
|
||||
/// hand-editor put in there keeps the verbatim posture, and the emptied folder is left standing
|
||||
/// because the next delete would only recreate it.
|
||||
/// - Removal is per entry, in order; a failure stops the batch and throws, and everything already
|
||||
/// removed stays removed.
|
||||
/// - A card with no thread trash removes nothing and answers `[]`.
|
||||
///
|
||||
/// **It registers no undo step** — the permanent-delete posture (13-native-undo.md), which is
|
||||
/// also what makes the leftover comment steps on the board stack go stale and skip with a banner
|
||||
/// rather than resurrect a folder that is gone.
|
||||
@discardableResult
|
||||
public static func purgeCommentTrash(inCard cardFolder: URL) throws(BoardWriteError) -> [ItemID] {
|
||||
let operation = WriteOperation.purgeCommentTrash
|
||||
var purged: [ItemID] = []
|
||||
for entry in childCandidates(of: CommentThread.trashFolder(inCard: cardFolder)) {
|
||||
do {
|
||||
try FileManager.default.removeItem(at: entry)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: entry.path,
|
||||
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
EchoLedger.current?.recordDeletion(at: entry)
|
||||
purged.append(ItemID(rawValue: entry.lastPathComponent))
|
||||
}
|
||||
return purged
|
||||
}
|
||||
|
||||
// MARK: - The post's inverse family
|
||||
|
||||
/// **The post's inverse: the posted folder renamed back to `.draft`** (13-native-undo.md, the
|
||||
/// comment clause: "post-undo naturally rides the same rail" — the move-based inverse family,
|
||||
/// with no byte capture in any tier).
|
||||
///
|
||||
/// **A rename and nothing else, so it stamps nothing.** The folder keeps its parent, so no
|
||||
/// container changed, and the existing write discipline answers without a rule of its own: "a
|
||||
/// heal that only renames or relocates folders and files never opens `index.md` and stamps
|
||||
/// nothing" (§ Validation and healing) — an identity change, not an edit. The post-time
|
||||
/// `created`/`modified` therefore survive on the un-posted draft, and the next post restamps them
|
||||
/// anyway, which is the whole reason the post restamps at all.
|
||||
///
|
||||
/// **It refuses a `.draft` that is already there**, rather than clobbering it: two drafts cannot
|
||||
/// exist, and one the user has typed since is not this step's to overwrite. The step's own
|
||||
/// staleness predicate expects exactly that absence, so in practice the refusal is unreachable —
|
||||
/// it stands because the Writer owns the bytes and promises this against every caller.
|
||||
public static func unpostComment(
|
||||
_ id: ItemID,
|
||||
inCard cardFolder: URL,
|
||||
cardTitle: String?
|
||||
) throws(BoardWriteError) {
|
||||
let operation = WriteOperation.postComment(title: cardTitle)
|
||||
let posted = CommentThread.commentFolder(id, inCard: cardFolder)
|
||||
try checkIsDirectory(posted, describedAs: "comment folder", operation: operation)
|
||||
|
||||
let draft = CommentThread.draftFolder(inCard: cardFolder)
|
||||
guard IntegrityRules.node(at: draft) == nil else {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: draft.path,
|
||||
reason: .io(message: "a draft is already here")
|
||||
)
|
||||
}
|
||||
try renameFolder(posted, toSiblingNamed: IntegrityRules.commentDraftFolderName, operation: operation)
|
||||
}
|
||||
|
||||
/// **The post, replayed** — the same captured identity and the same captured instant
|
||||
/// (13-native-undo.md: a redo repeats the values its gesture wrote). Re-minting here would produce
|
||||
/// a *different* comment, and every step registered above this one on the stack that names the
|
||||
/// posted id would then name nothing — `recreateItem`'s reasoning, one level down.
|
||||
public static func repostComment(
|
||||
as id: ItemID,
|
||||
inCard cardFolder: URL,
|
||||
stamping instant: Date,
|
||||
cardTitle: String?
|
||||
) throws(BoardWriteError) {
|
||||
let operation = WriteOperation.postComment(title: cardTitle)
|
||||
let draft = CommentThread.draftFolder(inCard: cardFolder)
|
||||
try checkIsDirectory(draft, describedAs: "draft folder", operation: operation)
|
||||
|
||||
let posted = CommentThread.commentFolder(id, inCard: cardFolder)
|
||||
guard IntegrityRules.node(at: posted) == nil else {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: posted.path,
|
||||
reason: .io(message: "something already exists here")
|
||||
)
|
||||
}
|
||||
try renameFolder(draft, toSiblingNamed: id.rawValue, operation: operation)
|
||||
try restampComment(at: posted, to: instant, operation: operation)
|
||||
}
|
||||
|
||||
// MARK: - Shared mechanics
|
||||
|
||||
/// The frontmatter text for a comment the app is minting outright — `newDocumentText`'s twin, and
|
||||
/// its key order minus the two fields a comment has not got.
|
||||
///
|
||||
/// `schema`, `author`, `created`, `modified`, `kind` — simply the order `set` is called in, with
|
||||
/// `kind` last where the common table puts it and where the on-touch backfill would append one.
|
||||
///
|
||||
/// **`author` is the macOS account's full name** (01-storage-format.md § Enhanced schema: "the
|
||||
/// app writes the macOS account's full name (the identity 06-history-undo.md's derived default
|
||||
/// already uses)"). `NSFullUserName()` is that identity; 06's derived commit default is Pro's and
|
||||
/// is not coded yet, so this is the first place the app spells it. An empty answer — a stripped
|
||||
/// account record — writes **no key at all** rather than `author: ""`: missing renders
|
||||
/// unattributed, and an empty string is a real (if blank) author, the `title` rule's precedent.
|
||||
private static func newCommentText(body: String) -> String {
|
||||
var document = FrontmatterDocument(body: body)
|
||||
document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema))
|
||||
let author = NSFullUserName()
|
||||
if !author.isEmpty {
|
||||
document.set(FrontmatterKeys.author, to: .string(author))
|
||||
}
|
||||
let now = Date()
|
||||
document.set(FrontmatterKeys.created, to: .date(now))
|
||||
document.set(FrontmatterKeys.modified, to: .date(now))
|
||||
document.set(FrontmatterKeys.kind, to: .string(IntegrityRules.ObjectKind.comment.rawValue))
|
||||
return document.serialized()
|
||||
}
|
||||
|
||||
/// Replaces a comment's body span and stamps — `writeBody`'s four steps against a folder that is
|
||||
/// not identity-shaped when it is the draft, which is the only reason this is not that call.
|
||||
private static func writeCommentBody(
|
||||
at folder: URL,
|
||||
body: String,
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) -> Bool {
|
||||
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
var document = try readDocument(at: indexURL, operation: operation)
|
||||
try checkEditable(document, at: indexURL, operation: operation)
|
||||
|
||||
guard document.body != body else { return false }
|
||||
|
||||
document.body = body
|
||||
IntegrityRules.healOnTouch(&document, kind: .comment)
|
||||
document.set(FrontmatterKeys.modified, to: .date(Date()))
|
||||
document.remove(FrontmatterKeys.modifiedBy)
|
||||
|
||||
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Sets `created` and `modified` to one instant — the post's whole frontmatter edit, spelled
|
||||
/// directly rather than through `updateIndex` for one reason: `updateIndex` stamps `modified`
|
||||
/// with a `Date()` of its own, and a post whose two stamps came from two clock reads could land
|
||||
/// either side of a second boundary and render as edited the moment it appeared.
|
||||
///
|
||||
/// Everything else `updateIndex` would have done is here in its order: refuse an uneditable
|
||||
/// shape, run the on-touch backfill, clear `modified-by`, replace atomically.
|
||||
private static func restampComment(
|
||||
at folder: URL,
|
||||
to instant: Date,
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) {
|
||||
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
var document = try readDocument(at: indexURL, operation: operation)
|
||||
try checkEditable(document, at: indexURL, operation: operation)
|
||||
|
||||
IntegrityRules.healOnTouch(&document, kind: .comment)
|
||||
document.set(FrontmatterKeys.created, to: .date(instant))
|
||||
document.set(FrontmatterKeys.modified, to: .date(instant))
|
||||
document.remove(FrontmatterKeys.modifiedBy)
|
||||
|
||||
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
|
||||
}
|
||||
|
||||
/// The physical half of both container-changing comment moves — into `comments/.trash/` and back
|
||||
/// out of it. `moveIntoTrash`'s shape: mint the destination if absent, move, then rewrite the
|
||||
/// arrived `index.md` so the container change stamps.
|
||||
private static func moveComment(
|
||||
_ folder: URL,
|
||||
into destination: URL,
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) {
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: destination.path,
|
||||
reason: .io(message: "could not create the folder: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
|
||||
let arrived = destination.appendingPathComponent(folder.lastPathComponent, isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.moveItem(at: folder, to: arrived)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: folder.path,
|
||||
reason: .io(message: "could not move the comment: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
// The move pair reads correctly from either end, `moveIntoTrash`'s receipt and for its
|
||||
// reason: the thread sees an absence, the trash sees an arrival.
|
||||
EchoLedger.current?.recordMove(from: folder, to: arrived)
|
||||
|
||||
try updateIndex(inItemFolder: arrived, kind: .comment, operation: operation) { _ in }
|
||||
}
|
||||
|
||||
/// Refuses any folder that is not a **posted comment**: identity-shaped, directly under a card's
|
||||
/// `comments/`.
|
||||
///
|
||||
/// `checkIsCardFolder`'s mirror one level down, and it deliberately refuses the two dot-named
|
||||
/// folders as well as everything else: `.draft` is the composer's, reached through
|
||||
/// `saveCommentDraft`, and a folder inside `comments/.trash/` is undo's — neither is editable or
|
||||
/// deletable as a comment, and letting either through this door would put a surface with no
|
||||
/// window behind it on disk (`readRawSource`'s rule).
|
||||
private static func checkIsCommentFolder(
|
||||
_ folder: URL,
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) {
|
||||
try checkIsDirectory(folder, describedAs: "comment folder", operation: operation)
|
||||
// The parent is named explicitly rather than asked of `placement`, which answers `.comment`
|
||||
// for a folder inside `comments/.trash/` too — correct for the *kind* it stamps, and exactly
|
||||
// the case this guard has to keep out.
|
||||
guard IntegrityRules.isIdentityShaped(folder.lastPathComponent),
|
||||
folder.deletingLastPathComponent().lastPathComponent.lowercased()
|
||||
== IntegrityRules.commentsFolderName
|
||||
else {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: folder.path,
|
||||
reason: .unreadable(message: "folder is not a comment")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -589,6 +589,22 @@ public enum FrontmatterKeys {
|
||||
public static let remote = "remote"
|
||||
public static let remoteState = "remote-state"
|
||||
|
||||
/// **A comment's self-reported author** (01-storage-format.md § Enhanced schema, the
|
||||
/// `kind: comment` field table): "lenient — self-reported *content*, not overlay: **unlike
|
||||
/// `modified-by` it survives app writes**; the app writes the macOS account's full name, agents
|
||||
/// write their own, tracker sync writes the remote author verbatim, and missing renders
|
||||
/// unattributed".
|
||||
///
|
||||
/// **Named here without joining `schemaOwned`**, `remote`'s precedent and its reason turned
|
||||
/// around: the key belongs to the comment field table and to no other kind, so on a board, a lane
|
||||
/// or a card it is an ordinary unknown key — and `schemaOwned` is exactly the set the card
|
||||
/// window's Details section subtracts. Listing it would *hide* a hand-added `author:` from the one
|
||||
/// surface that exists to show hand-added keys; a comment has no such surface to be affected
|
||||
/// either way.
|
||||
///
|
||||
/// Nothing clears it, anywhere: `updateIndex` clears `modified-by` and only that.
|
||||
public static let author = "author"
|
||||
|
||||
public static let schemaOwned: Set<String> = [
|
||||
schema, title, order, width, created, modified, modifiedBy, deleted, background, icon,
|
||||
iconColor, kind,
|
||||
|
||||
@@ -95,6 +95,7 @@ extension FrontmatterDocument {
|
||||
record(FrontmatterKeys.created, created)
|
||||
record(FrontmatterKeys.modified, modified)
|
||||
record(FrontmatterKeys.modifiedBy, modifiedBy)
|
||||
record(FrontmatterKeys.author, author)
|
||||
record(FrontmatterKeys.background, background)
|
||||
record(FrontmatterKeys.icon, icon)
|
||||
record(FrontmatterKeys.iconColor, iconColor)
|
||||
@@ -158,6 +159,13 @@ extension FrontmatterDocument {
|
||||
/// Schema-owned, not an unknown key: the app clears it on every app-mediated write.
|
||||
public var modifiedBy: FieldValue<String> { read(FrontmatterKeys.modifiedBy, Self.string) }
|
||||
|
||||
/// A comment's self-reported `author` — lenient like every other string field, and **content**
|
||||
/// rather than overlay: no app write ever clears it (01-storage-format.md § Enhanced schema; see
|
||||
/// `FrontmatterKeys.author`). On any other kind the key is an ordinary unknown one; reading it
|
||||
/// here costs the same one pass and keeps the coerce tier's trace complete for the one kind whose
|
||||
/// field table names it.
|
||||
public var author: FieldValue<String> { read(FrontmatterKeys.author, Self.string) }
|
||||
|
||||
/// The object's kind as written — `board`, `lane`, `card` (01-storage-format.md § Frontmatter,
|
||||
/// re-ruled 2026-07-29). Lenient like every other string field: any scalar coerces to the text
|
||||
/// the author typed, and **the value is never policed** — a reading outside the schema's three
|
||||
|
||||
@@ -110,21 +110,41 @@ public enum IntegrityRules: Sendable {
|
||||
public static let trashFolderName = ".trash"
|
||||
|
||||
/// A card's attachment folder (01-storage-format.md § Attachments) — the one folder the app
|
||||
/// ever creates under a card.
|
||||
/// ever creates under a card. **Fractal**: a comment folder has one too (§ Enhanced schema).
|
||||
public static let attachmentsFolderName = "attachments"
|
||||
|
||||
/// The file every level's content lives in.
|
||||
public static let indexFileName = "index.md"
|
||||
|
||||
/// A card's **comment thread** (01-storage-format.md § Enhanced schema) — a plain reserved
|
||||
/// child, never a level and never identity; the identities are the UUID folders inside it.
|
||||
public static let commentsFolderName = "comments"
|
||||
|
||||
/// The card's single comment draft, inside `comments/` — "a reserved dot-named folder holding
|
||||
/// ordinary comment schema … excluded from the thread listing" (§ Enhanced schema, ruled
|
||||
/// 2026-07-29).
|
||||
public static let commentDraftFolderName = ".draft"
|
||||
|
||||
/// The thread's own trash — **the board's name one level down**, deliberately the same spelling:
|
||||
/// "the materialized-trash pattern one level down, joining `.draft` in the claimed names"
|
||||
/// (§ Enhanced schema). Undo's backing store, never a UI surface.
|
||||
public static let commentTrashFolderName = trashFolderName
|
||||
|
||||
/// **The card-level reserved names** (01-storage-format.md § Fractal layout ▸ Rules): 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.
|
||||
/// card's own `index.md` plus the two reserved children.
|
||||
///
|
||||
/// **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/`.
|
||||
public static let reservedCardChildNames: Set<String> = [
|
||||
indexFileName, attachmentsFolderName, "comments",
|
||||
indexFileName, attachmentsFolderName, commentsFolderName,
|
||||
]
|
||||
|
||||
/// **The same table inside one comment folder** — "a card's anatomy one level down, so the
|
||||
/// fractal rules apply verbatim" (01-storage-format.md § Enhanced schema). A comment has no
|
||||
/// `comments/` of its own: replies are deliberately deferred and flat is this iteration's rule.
|
||||
public static let reservedCommentChildNames: Set<String> = [
|
||||
indexFileName, attachmentsFolderName,
|
||||
]
|
||||
|
||||
/// What kind of node a name is allowed to be.
|
||||
@@ -188,14 +208,13 @@ public enum IntegrityRules: Sendable {
|
||||
/// name) displaces by the same ladder (`attachments` → `attachments 2`), so imports, Finder drops,
|
||||
/// and the sidebar listing never fail one gesture at a time against a squatted name").
|
||||
///
|
||||
/// **`attachments` displaces; `comments` does not**, and the split is the *timing principle*
|
||||
/// rather than a hedge — 01 calls the reserved-but-unconsumed `comments` "the timing principle's own
|
||||
/// illustration": nothing reads that name until the tracker era, so a wrong-kind holder degrades no
|
||||
/// behavior while it stands and stays a **tolerated stray** today, joining the scheduled class the
|
||||
/// day the name becomes load-bearing. `attachments`, by contrast, is load-bearing now: while a file
|
||||
/// wears the name, every import into that card, every Finder drop on it, and the card window's
|
||||
/// listing are broken — which is exactly the "proactive when the defect is load-bearing now"
|
||||
/// condition (§ Validation and healing).
|
||||
/// **`comments` graduated with the feature** (the timing principle, run forwards): 01 called the
|
||||
/// reserved-but-unconsumed name "the timing principle's own illustration" — "a wrong-kind holder is
|
||||
/// a tolerated stray today and joins the scheduled class **the day the name becomes load-bearing**".
|
||||
/// That day is this one: a file or symlink wearing `comments` now breaks the card window's whole
|
||||
/// thread — no draft can be saved, no comment posted, nothing read — which is exactly the
|
||||
/// "proactive when the defect is load-bearing now" condition (§ Validation and healing).
|
||||
/// `attachments` was load-bearing already and displaced from the start.
|
||||
///
|
||||
/// `index.md` is deliberately not here. It is not a *reserved child* the app protects from
|
||||
/// squatters — it is the card's content, and a directory named `index.md` makes the folder an
|
||||
@@ -203,7 +222,26 @@ public enum IntegrityRules: Sendable {
|
||||
/// would mean the app deciding a folder's content is a squatter.
|
||||
public static let claimedCardChildNames: [ClaimedName] = [
|
||||
ClaimedName(name: attachmentsFolderName, expected: .directory, displacesSquatters: true),
|
||||
ClaimedName(name: "comments", expected: .directory, displacesSquatters: false),
|
||||
ClaimedName(name: commentsFolderName, expected: .directory, displacesSquatters: true),
|
||||
]
|
||||
|
||||
/// **The names claimed inside a card's `comments/`** — the thread's two lifecycle folders
|
||||
/// (01-storage-format.md § Enhanced schema: "`.draft` joins the claimed names (a wrong-kind node
|
||||
/// squatting it displaces by the ladder)", and `comments/.trash/` "joining `.draft` in the claimed
|
||||
/// names").
|
||||
///
|
||||
/// Both displace: a file wearing `.draft` makes the composer unsaveable and a file wearing
|
||||
/// `.trash` makes every comment delete fail, so neither is latent while it stands.
|
||||
public static let claimedCommentThreadNames: [ClaimedName] = [
|
||||
ClaimedName(name: commentDraftFolderName, expected: .directory, displacesSquatters: true),
|
||||
ClaimedName(name: commentTrashFolderName, expected: .directory, displacesSquatters: true),
|
||||
]
|
||||
|
||||
/// **The card's table, one level down** — a comment's own `attachments`, claimed exactly as a
|
||||
/// card's is (§ Enhanced schema: "displacement of a squatted `attachments`" is named among the
|
||||
/// fractal rules that "apply verbatim").
|
||||
public static let claimedCommentChildNames: [ClaimedName] = [
|
||||
ClaimedName(name: attachmentsFolderName, expected: .directory, displacesSquatters: true),
|
||||
]
|
||||
|
||||
/// The claimed names as the lane walk needs them: lowercased, for a `contains` against a
|
||||
@@ -262,9 +300,43 @@ public enum IntegrityRules: Sendable {
|
||||
/// - Parameter path: the card folder's path **relative to the board root**, carried into the defect
|
||||
/// so the write lands wherever the board lives at heal time (`LooseCardFiles`' convention).
|
||||
public static func squattedClaimedNames(inCardAt cardFolder: URL, path: String) -> [ClaimedNameSquatter] {
|
||||
claimedCardChildNames.compactMap { claimed in
|
||||
squatters(among: claimedCardChildNames, in: cardFolder, at: .card(path: path))
|
||||
}
|
||||
|
||||
/// The claimed-name defects inside one card's **`comments/`** — `.draft` and `.trash`
|
||||
/// (`claimedCommentThreadNames`, 01-storage-format.md § Enhanced schema).
|
||||
///
|
||||
/// **Window-scoped, unlike its card-level twin**: the board walk stays O(cards) and never opens a
|
||||
/// thread (§ Enhanced schema — "Comments are window-scoped, outside the board snapshot"), so this
|
||||
/// is asked by the card window's own thread read, not by the loader.
|
||||
///
|
||||
/// - Parameter cardPath: the card folder's path relative to the board root, so the heal lands
|
||||
/// wherever the board lives at write time (`LooseCardFiles`' convention).
|
||||
public static func squattedClaimedNames(
|
||||
inCommentThreadAt threadFolder: URL,
|
||||
cardPath: String
|
||||
) -> [ClaimedNameSquatter] {
|
||||
squatters(among: claimedCommentThreadNames, in: threadFolder, at: .commentThread(cardPath: cardPath))
|
||||
}
|
||||
|
||||
/// The claimed-name defects inside **one comment folder** — its `attachments`, the card's rule
|
||||
/// read one level down (`claimedCommentChildNames`). Window-scoped, for its sibling's reason.
|
||||
///
|
||||
/// - Parameter path: the comment folder's path relative to the board root.
|
||||
public static func squattedClaimedNames(inCommentAt commentFolder: URL, path: String) -> [ClaimedNameSquatter] {
|
||||
squatters(among: claimedCommentChildNames, in: commentFolder, at: .comment(path: path))
|
||||
}
|
||||
|
||||
/// The shared body of the three plural detections: one table, one folder, one location. The board
|
||||
/// root's singular answer stays its own, for the reason its doc comment gives.
|
||||
private static func squatters(
|
||||
among table: [ClaimedName],
|
||||
in folder: URL,
|
||||
at location: ClaimedNameSquatter.Location
|
||||
) -> [ClaimedNameSquatter] {
|
||||
table.compactMap { claimed in
|
||||
guard claimed.displacesSquatters,
|
||||
let found = node(at: cardFolder.appendingPathComponent(claimed.name)),
|
||||
let found = node(at: folder.appendingPathComponent(claimed.name)),
|
||||
found != claimed.expected
|
||||
else {
|
||||
return nil
|
||||
@@ -273,7 +345,7 @@ public enum IntegrityRules: Sendable {
|
||||
name: claimed.name,
|
||||
found: found,
|
||||
expected: claimed.expected,
|
||||
location: .card(path: path)
|
||||
location: location
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -281,13 +353,14 @@ public enum IntegrityRules: Sendable {
|
||||
// MARK: - Object kinds
|
||||
|
||||
/// The kinds the schema knows (01-storage-format.md § Frontmatter ▸ Common to all levels, the
|
||||
/// `kind` row). `comment` is reserved with the enhanced schema and deliberately absent until it
|
||||
/// lands — an unrecognized value on disk is trusted as itself and never policed, so nothing here
|
||||
/// has to anticipate it.
|
||||
/// `kind` row). `comment` joined them with the enhanced schema's storage (§ Enhanced schema, the
|
||||
/// `kind: comment` field table) — the first kind whose position is not a *level*: a comment lives
|
||||
/// under a card's `comments/`, which is a reserved child rather than a depth.
|
||||
public enum ObjectKind: String, Sendable, Equatable, CaseIterable {
|
||||
case board
|
||||
case lane
|
||||
case card
|
||||
case comment
|
||||
}
|
||||
|
||||
/// Where a folder's **position** places it — "level is position" (01-storage-format.md
|
||||
@@ -298,6 +371,9 @@ public enum IntegrityRules: Sendable {
|
||||
case card
|
||||
/// Identity-shaped, under something that is neither a lane nor the trash — a lane.
|
||||
case lane
|
||||
/// Under a card's `comments/` — a posted comment, or the `.draft` that is one in every
|
||||
/// respect but its name (01-storage-format.md § Enhanced schema: "ordinary comment schema").
|
||||
case comment
|
||||
/// Inside `.trash/`, where the container is flat and **position cannot answer**: use
|
||||
/// `trashKind(kindValue:hasIdentityShapedChildIndex:)`.
|
||||
case insideTrash
|
||||
@@ -313,13 +389,37 @@ public enum IntegrityRules: Sendable {
|
||||
/// The trash check sits *between* the two identity checks deliberately: a trashed card and a
|
||||
/// live lane are both identity-shaped folders whose parent is not, and the container is the only
|
||||
/// thing that tells them apart.
|
||||
///
|
||||
/// **The `comments/` check sits ahead of the trash's**, and ahead of the lane's for the reason
|
||||
/// that matters: a posted comment is an identity-shaped folder whose parent is not identity-shaped
|
||||
/// — shape-identical to a lane — so without it every comment would read as a lane and the on-touch
|
||||
/// backfill would stamp `kind: lane` into a thread.
|
||||
///
|
||||
/// **One ambiguity two names cannot resolve**: `comments/.trash/<uuid>` answers `.insideTrash`
|
||||
/// here, because `.trash` is the board's spelling one level down. `placement(ofFolder:)` is the
|
||||
/// form that can tell them apart, and every caller holding a URL should use it.
|
||||
public static func placement(ofFolderNamed name: String, inParentNamed parent: String) -> Placement {
|
||||
if isIdentityShaped(parent) { return .card }
|
||||
if parent.lowercased() == commentsFolderName { return .comment }
|
||||
if parent.lowercased() == trashFolderName { return .insideTrash }
|
||||
if isIdentityShaped(name) { return .lane }
|
||||
return .unknown
|
||||
}
|
||||
|
||||
/// The same rule read off a **URL** — the only form that can tell the board's `.trash/` from a
|
||||
/// comment thread's own one level down, since the two share a name and the two-name rule sees
|
||||
/// only the name.
|
||||
public static func placement(ofFolder url: URL) -> Placement {
|
||||
let parent = url.deletingLastPathComponent()
|
||||
let placement = placement(ofFolderNamed: url.lastPathComponent, inParentNamed: parent.lastPathComponent)
|
||||
guard placement == .insideTrash,
|
||||
parent.deletingLastPathComponent().lastPathComponent.lowercased() == commentsFolderName
|
||||
else {
|
||||
return placement
|
||||
}
|
||||
return .comment
|
||||
}
|
||||
|
||||
/// **The trash's `kind` discriminator** (01-storage-format.md § Deletion, re-ruled 2026-07-29 —
|
||||
/// the value-names-the-kind posture): depth defines meaning on the live board, but the trash is
|
||||
/// flat, and an empty lane folder is shape-identical to a card folder.
|
||||
@@ -330,9 +430,10 @@ public enum IntegrityRules: Sendable {
|
||||
/// identity-shaped children with their own `index.md` → lane (the key backfills on the next
|
||||
/// touch), else card.
|
||||
///
|
||||
/// `kind: board` in the trash is *not* a third answer: a board is not a thing that can be
|
||||
/// trashed, so the value is unrecognized here and shape decides — the same shrug an arbitrary
|
||||
/// string gets.
|
||||
/// `kind: board` in the trash is *not* a third answer, and neither is `kind: comment`: neither is
|
||||
/// a thing that can be in the *board's* trash (a deleted comment moves into its own thread's
|
||||
/// `comments/.trash/`, 01-storage-format.md § Enhanced schema), so both values are unrecognized
|
||||
/// here and shape decides — the same shrug an arbitrary string gets.
|
||||
///
|
||||
/// The shape half is `@autoclosure` so that the rule stays a pure function of two facts while
|
||||
/// its caller pays for the directory listing **only when the value did not answer** — which on
|
||||
@@ -344,7 +445,7 @@ public enum IntegrityRules: Sendable {
|
||||
switch kindValue.flatMap(ObjectKind.init(rawValue:)) {
|
||||
case .lane: return .lane
|
||||
case .card: return .card
|
||||
case .board, nil: return hasIdentityShapedChildIndex() ? .lane : .card
|
||||
case .board, .comment, nil: return hasIdentityShapedChildIndex() ? .lane : .card
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,9 +489,14 @@ public enum IntegrityRules: Sendable {
|
||||
|
||||
/// Whether an object of `kind` must carry `order` — the per-kind field table, as a rule rather
|
||||
/// than as two hand-written call sites in the loader's walk.
|
||||
///
|
||||
/// **A comment carries none, and never gains one** (01-storage-format.md § Enhanced schema:
|
||||
/// "**No `title`, no `order`**" — "Ordering is chronology, not ranks", because a conversation's
|
||||
/// semantics *are* chronology and tracker-synced comments carry independent clocks where minted
|
||||
/// ranks would interleave arbitrarily).
|
||||
public static func requiresOrder(_ kind: ObjectKind) -> Bool {
|
||||
switch kind {
|
||||
case .board: false
|
||||
case .board, .comment: false
|
||||
case .lane, .card: true
|
||||
}
|
||||
}
|
||||
@@ -533,12 +639,20 @@ public enum IntegrityRules: Sendable {
|
||||
/// read at the moment of healing (`AgentGuide.inspect`), not something a tree walk reports.
|
||||
/// It is a class here because the engine treats it exactly like the others — same gates,
|
||||
/// same memo, same clear-on-success.
|
||||
///
|
||||
/// `commentTrashResidue` is the second such class, for the same reason one level down: the
|
||||
/// residue is whatever a crashed session left in one card's `comments/.trash/`, read at the
|
||||
/// moment the card window opens (01-storage-format.md § Enhanced schema — "purged when the
|
||||
/// card window closes; crash residue sweeps at the next card-window open, armed-then-cleared
|
||||
/// like every heal memo"). The board walk never opens a thread, so no tree walk could report
|
||||
/// it.
|
||||
public enum Class: Sendable, Equatable, Hashable, CaseIterable {
|
||||
case looseCardFiles
|
||||
case legacyTombstone
|
||||
case claimedNameSquatted
|
||||
case duplicateIdentity
|
||||
case staleAgentGuide
|
||||
case commentTrashResidue
|
||||
}
|
||||
|
||||
/// The scheduled-heal class this defect belongs to, or **`nil` where there is no heal** — the
|
||||
@@ -899,21 +1013,35 @@ public struct ClaimedNameSquatter: Sendable, Equatable {
|
||||
public enum Location: Sendable, Equatable {
|
||||
case boardRoot
|
||||
case card(path: String)
|
||||
/// A card's `comments/` container — where `.draft` and `.trash` are claimed
|
||||
/// (01-storage-format.md § Enhanced schema). `cardPath` names the *card*, so the one thing a
|
||||
/// caller has to know is where the card is, exactly as `.card` asks.
|
||||
case commentThread(cardPath: String)
|
||||
/// One comment's own folder — where `attachments` is claimed, the card's rule read one level
|
||||
/// down. `path` is the comment folder's, root-relative.
|
||||
case comment(path: String)
|
||||
|
||||
/// The folder the claimed name lives in, under `root`.
|
||||
public func folder(under root: URL) -> URL {
|
||||
switch self {
|
||||
case .boardRoot: root
|
||||
case let .card(path): root.appendingPathComponent(path, isDirectory: true)
|
||||
case .boardRoot:
|
||||
root
|
||||
case let .card(path), let .comment(path):
|
||||
root.appendingPathComponent(path, isDirectory: true)
|
||||
case let .commentThread(cardPath):
|
||||
root
|
||||
.appendingPathComponent(cardPath, isDirectory: true)
|
||||
.appendingPathComponent(IntegrityRules.commentsFolderName, isDirectory: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// The location as a signature component — `""` for the board root, so the existing root-level
|
||||
/// signature spelling is unchanged and only a card-level defect adds a path segment.
|
||||
/// signature spelling is unchanged and only a nested defect adds path segments.
|
||||
var signatureComponent: String {
|
||||
switch self {
|
||||
case .boardRoot: ""
|
||||
case let .card(path): path + "/"
|
||||
case let .card(path), let .comment(path): path + "/"
|
||||
case let .commentThread(cardPath): cardPath + "/" + IntegrityRules.commentsFolderName + "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user