Files
lanework/Kanban/Storage/CommentWriter.swift
T

591 lines
32 KiB
Swift

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/` whole** — and **no production caller does that any
/// more** (13-native-undo.md ▸ Interaction with the trash, ruled 2026-08-06):
///
/// > "**Every purge of `comments/.trash/` is per-entry behind the ownership gate** (ruled
/// > 2026-08-06 — the container-whole retirement purge retires) ... The container-whole purge
/// > assumed one owning step per card's comment trash, and two sessions over the same card broke
/// > it."
///
/// Both consumers — the retirement/no-step-close purge and the open-time residue sweep — now
/// filter by `HistoryProviding.backedContent` and remove per entry through `purgeItem(at:)`
/// (`BoardStore.purgeCommentTrash(inCard:)`, `BoardStore.sweepCommentTrashResidue(inCard:)`). A
/// primitive that empties the container cannot express the gate, so nothing above it may call this.
///
/// It is **kept rather than deleted**, deliberately and narrowly: `WriteOperation.purgeCommentTrash`
/// is the vocabulary's word for this work and owns a bespoke user-facing sentence
/// (`BannerCenter` — "Couldn't tidy up deleted comments") plus rows in three exhaustive switches,
/// and retiring the case would be a ripple across files for no behavioural gain. Read this as
/// legacy: the shape the ruling retired, still exercised by `CommentWriteTests` so the case's
/// plumbing stays honest, and the home of the operation vocabulary the gated purge's per-entry
/// failures do not use.
///
/// `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).
@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: - The authoring surfaces' attachments
/// **Imports files into one comment's (or the draft's) `attachments/`** — the write behind the
/// composer's and the inline editor's drop carve-out and their paperclip affordance
/// (05-card-window.md ▸ The comments column, ruled 2026-07-29: "a file dropped within the
/// composer's bounds imports to the draft's `attachments/` … and the same pair applies within an
/// inline comment edit session, targeting that comment's").
///
/// **It is `importAttachments` with a different shape guard and nothing else** — the collision
/// rename, the per-file validation, the partial cleanup and the import receipt are all
/// `BoardWriter.importFiles`', shared rather than restated, so a file added to a comment behaves
/// exactly like one added to a card.
///
/// **No stamp, deliberately.** `index.md` is never opened: importing a file says nothing about the
/// comment's text, and `relocateLooseFiles` already settles that reading one level up ("relocating
/// a stray says nothing about the card's content, so no `modified` stamp is written"). It also
/// keeps the draft's own rule honest — a draft that has only ever been dropped on still has the
/// `created`/`modified` pair the post is about to restamp.
@discardableResult
public static func importCommentAttachments(
_ sourceURLs: [URL],
intoComment folder: URL
) throws(BoardWriteError) -> [ImportedAttachment] {
let batchOperation = WriteOperation.importAttachment(filename: sourceURLs.first?.lastPathComponent ?? "")
try checkIsAuthoringFolder(folder, operation: batchOperation)
return try importFiles(sourceURLs, intoAttachmentsOf: folder, batchOperation: batchOperation)
}
/// **Moves one of a comment's (or the draft's) attachments to the system Trash** — the authoring
/// chip's Remove (05-card-window.md ▸ The comments column: "Chips on an authoring surface carry
/// remove (to the **system** Trash — the sidebar row's rule); a posted comment's chips are
/// read-only").
///
/// The *posted*-chip half of that sentence is enforced in the view, not here: the Writer's job is
/// that the file goes to the Trash rather than being destroyed, and an inline edit session over a
/// posted comment is an authoring surface too. `BoardWriter.trashAttachment` is the whole body.
@discardableResult
public static func removeCommentAttachment(
named name: String,
fromComment folder: URL
) throws(BoardWriteError) -> URL? {
let operation = WriteOperation.removeAttachment(filename: name)
try checkIsAuthoringFolder(folder, operation: operation)
return try trashAttachment(named: name, fromItemFolder: folder, operation: operation)
}
/// Refuses any folder that is not a place a user can author attachments into: a **posted
/// comment**, or the card's single **draft**.
///
/// `checkIsCommentFolder` widened by exactly one name, and widened here rather than there on
/// purpose: `editComment` and `deleteComment` must keep refusing `.draft` (the composer owns it,
/// and posting is its only way into the thread), while an attachment import has no such
/// asymmetry — the draft is precisely where the composer's chips land. `comments/.trash/` stays
/// out of both, because a deleted comment is undo's and never a surface.
private static func checkIsAuthoringFolder(
_ folder: URL,
operation: WriteOperation
) throws(BoardWriteError) {
try checkIsDirectory(folder, describedAs: "comment folder", operation: operation)
let name = folder.lastPathComponent
guard folder.deletingLastPathComponent().lastPathComponent.lowercased()
== IntegrityRules.commentsFolderName,
IntegrityRules.isIdentityShaped(name)
|| name.lowercased() == IntegrityRules.commentDraftFolderName
else {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .unreadable(message: "folder is not a comment")
)
}
}
// 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")
)
}
}
}