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