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