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: - 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") ) } } }