import Foundation import os // MARK: - Comment /// One comment: a UUID-named folder under a card's `comments/`, holding `index.md` and optionally /// `attachments/` — "a card's anatomy one level down" (01-storage-format.md § Enhanced schema, /// storage specified 2026-07-29). /// /// The field table is the common schema minus two and plus one: **no `title`, no `order`**, and /// `author` — self-reported *content* that survives every app write, unlike `modified-by`. /// /// `Card`'s shape deliberately, one level down: identity, the lenient fields as `FieldValue`s, the /// attachment listing, and the whole parsed document so unknown and reserved keys ride along /// uninterpreted. public struct Comment: Identifiable, Sendable, Equatable { public let id: ItemID /// The schema number as read. Lenient here, unlike every other level: a comment defect never /// refuses anything (§ Enhanced schema), so a missing or unreadable `schema` costs the thread a /// rendered comment, never a load. public let schema: FieldValue /// Who says they wrote it — the app writes the macOS account's full name, agents write their /// own, tracker sync writes the remote author verbatim. **Missing renders unattributed**; there /// is no identity system behind it and none is implied. public let author: FieldValue /// Load-bearing, unlike anywhere else in the schema: the thread's order *is* `created` /// ascending (§ Enhanced schema — "Ordering is chronology, not ranks"). public let created: FieldValue public let modified: FieldValue public let modifiedBy: FieldValue /// The comment's attachment file names — flat, top-level regular files, Finder order, through /// the same enumeration a card's listing uses (`BoardLoader.attachmentNames`), so a chip row and /// a card's sidebar can never disagree about what a folder holds. public let attachments: [String] /// The full parsed `index.md`; unknown and reserved keys ride along uninterpreted. public let document: FrontmatterDocument /// The comment's Markdown — the card-body subset. Equivalent to `document.body`. public var body: String { document.body } /// **The edited indicator is `modified` differing from `created`, and no extra field** /// (§ Enhanced schema). A post writes both from one `Date`, so a comment that has never been /// edited reads `false` by construction; one of the pair missing is not evidence of an edit. public var isEdited: Bool { guard let created = created.value, let modified = modified.value else { return false } return modified != created } } // MARK: - CommentDraft /// The card's single draft, as the composer needs it: the text to restore, and the chips to draw. /// /// **Two fields and no identity**, which is the difference between a draft and a comment stated as a /// type: `comments/.draft/` is a claimed name the composer edits in place, and it becomes a `Comment` /// only at the post, where the rename mints the id (`BoardWriter.postComment`). public struct CommentDraft: Sendable, Equatable { /// `index.md`'s body — what the composer shows when the window opens. public let body: String /// The draft's `attachments/`, through the same enumeration a comment's listing uses, so the /// composer's chips and a posted comment's chips can never disagree about what a folder holds. public let attachments: [String] public init(body: String, attachments: [String]) { self.body = body self.attachments = attachments } /// Whether a save of `body` would delete the folder — **the emptied-draft rule, asked before the /// write** (01-storage-format.md § Enhanced schema; `BoardWriter.saveCommentDraft`'s own gate). /// /// It is the composer's Post validation read backwards: there is nothing to post exactly when /// there would be nothing to keep. public var isEmpty: Bool { body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && attachments.isEmpty } } // MARK: - CommentThread /// A card's comment thread, read from disk — **window-scoped, outside the board snapshot** /// (01-storage-format.md § Enhanced schema: "the walk stays O(cards): the card window reads its own /// thread … and the board snapshot never loads comment content"). /// /// ### It never refuses /// /// There is no `throws` anywhere in this type, and that is the ruling rather than convenience: /// "**Comment defects never refuse the board** — worst case is the stray posture (tolerated, logged, /// unrendered): a broken leaf annotation must not brick a load; deliberate, proportionate divergence /// from card fail-fast". A folder that is not identity-shaped, one with no `index.md`, one whose /// frontmatter does not parse, one that is not UTF-8 — each is skipped with a log line, preserved /// verbatim on disk, and the rest of the thread renders. /// /// ### The two dot-named folders are not comments /// /// `comments/.draft/` and `comments/.trash/` are excluded from the listing (§ Enhanced schema), and /// they are excluded *for free*: both are dot-prefixed, and `BoardLoader.directoryCandidates` skips /// hidden entries. The exclusion is stated in the enumeration's own rule rather than re-implemented /// here, exactly as `.trash/` is at board level. public struct CommentThread: Sendable, Equatable { /// The thread in display order — `created` ascending (see `sorted(_:)` for the fallback). public let comments: [Comment] /// Folders under `comments/` that are not comments — reported for the log's sake and rendered by /// nothing. The tolerate tier, one level down. public let strays: [Stray] /// Pending work and coerce-tier observations this read found — claimed names squatted inside /// `comments/` or inside one comment, and every lenient field that had no sensible reading. The /// same typed stream the board walk fills (`IntegrityRules.Defect`), so the heal engine needs no /// second vocabulary for a thread. public let defects: [IntegrityRules.Defect] /// Whether the card has a draft on disk. The composer reads its bytes itself; what a *thread* /// needs to know is only that one exists, which is the answer restore-on-reopen turns on. public let hasDraft: Bool /// One folder under `comments/` the thread would not render, and why — never an error, always a /// log line. public struct Stray: Sendable, Equatable { public enum Reason: Sendable, Equatable { /// Not identity-shaped: a hand-made folder, a nested clone. The shape-only identity /// predicate one level down. case notIdentityShaped /// Identity-shaped with no `index.md` — two-step-create tolerance, verbatim from the /// card rule (01-storage-format.md § Fractal layout ▸ Rules). case missingIndex /// The bytes are there and could not be read as a comment: not UTF-8, frontmatter that /// does not parse. The one reason a *card* would have failed the whole load. case unreadable(message: String) } public let name: String public let reason: Reason } public static let empty = CommentThread(comments: [], strays: [], defects: [], hasDraft: false) // MARK: - Where a thread lives /// `/comments/` — named but never created here. One place, so the loader's read and every /// write in `CommentWriter.swift` can never disagree about where a thread is /// (`BoardWriter.trashFolder(inBoard:)`'s precedent). public static func folder(inCard cardFolder: URL) -> URL { cardFolder.appendingPathComponent(IntegrityRules.commentsFolderName, isDirectory: true) } /// `/comments/.draft/` — the card's single draft. public static func draftFolder(inCard cardFolder: URL) -> URL { folder(inCard: cardFolder) .appendingPathComponent(IntegrityRules.commentDraftFolderName, isDirectory: true) } /// `/comments/.trash/` — undo's backing store, purged at window close. public static func trashFolder(inCard cardFolder: URL) -> URL { folder(inCard: cardFolder) .appendingPathComponent(IntegrityRules.commentTrashFolderName, isDirectory: true) } /// `/comments//` — one posted comment. public static func commentFolder(_ id: ItemID, inCard cardFolder: URL) -> URL { folder(inCard: cardFolder).appendingPathComponent(id.rawValue, isDirectory: true) } /// `/comments/.trash//` — one deleted comment, waiting for the close purge or a ⌘Z. public static func trashedCommentFolder(_ id: ItemID, inCard cardFolder: URL) -> URL { trashFolder(inCard: cardFolder).appendingPathComponent(id.rawValue, isDirectory: true) } /// The identities currently sitting in `comments/.trash/` — what a close purge would remove, and /// what the crash-residue sweep signs its memo with. /// /// The listing rule is the thread's own (`directoryCandidates` narrowed by the identity /// predicate), so a stray a hand-editor put in there is neither counted nor purged — the same /// honesty `emptyTrash` keeps at board level. public static func trashedCommentIDs(inCard cardFolder: URL) -> [ItemID] { ((try? BoardLoader.directoryCandidates(in: trashFolder(inCard: cardFolder))) ?? []) .filter { IntegrityRules.isIdentityShaped($0.lastPathComponent) } .map { ItemID(rawValue: $0.lastPathComponent) } } // MARK: - Reading private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "comments") /// Reads one card's thread. Total: a card with no `comments/`, an unreadable one, or one held by /// a file answers `.empty`, which is what "no comments yet" looks like and is not a defect this /// read has any business inventing — the squatted-name case *is* reported, as work. /// /// - Parameter path: the card folder's path relative to the board root (`/`, or /// `.trash/` for a trashed one — "a trashed card carries its `comments/`"). Carried into /// every defect so the heal lands wherever the board lives at write time, and into the log /// lines so a stray names something a human can find. public static func load(inCard cardFolder: URL, path: String) -> CommentThread { let threadFolder = folder(inCard: cardFolder) var defects: [IntegrityRules.Defect] = [] switch IntegrityRules.node(at: threadFolder) { case nil: return .empty case .directory: break case .file, .symlink: // The claimed name held by the wrong kind of node. Detection only, like every other // defect in this app — the displacement is the store's, through the Writer. Nothing else // about the thread can be read while a file wears the name, so the listing is empty and // the work is the whole answer. return CommentThread( comments: [], strays: [], defects: IntegrityRules.squattedClaimedNames(inCardAt: cardFolder, path: path) .map(IntegrityRules.Defect.claimedNameSquatted), hasDraft: false ) } for squatter in IntegrityRules.squattedClaimedNames(inCommentThreadAt: threadFolder, cardPath: path) { defects.append(.claimedNameSquatted(squatter)) logger.warning( "\(path, privacy: .public)/comments/\(squatter.name, privacy: .public): claimed name held by \(squatter.found.description, privacy: .public) — to be displaced" ) } var comments: [Comment] = [] var strays: [Stray] = [] // Hidden entries and symlinks are already out, which is exactly how `.draft` and `.trash` are // excluded from the thread — see the type's own note. for commentURL in (try? BoardLoader.directoryCandidates(in: threadFolder)) ?? [] { let name = commentURL.lastPathComponent let commentPath = path + "/" + IntegrityRules.commentsFolderName + "/" + name guard IntegrityRules.isIdentityShaped(name) else { strays.append(Stray(name: name, reason: .notIdentityShaped)) logger.warning("\(commentPath, privacy: .public): not a comment identity — ignored") continue } let indexURL = commentURL.appendingPathComponent(IntegrityRules.indexFileName) guard let data = try? Data(contentsOf: indexURL) else { strays.append(Stray(name: name, reason: .missingIndex)) logger.warning("\(commentPath, privacy: .public): no index.md — ignored") continue } let document: FrontmatterDocument do { document = try BoardLoader.parseDocument(data, path: commentPath) } catch { strays.append(Stray(name: name, reason: .unreadable(message: error.reason.description))) logger.warning( "\(commentPath, privacy: .public): \(error.reason.description, privacy: .public) — ignored" ) continue } let fields = document.coercedFields if !fields.isEmpty { let indexPath = commentPath + "/" + IntegrityRules.indexFileName defects.append(.coercedFrontmatter(CoercedFrontmatter(path: indexPath, fields: fields))) for field in fields { logger.info( "\(indexPath, privacy: .public): '\(field.key, privacy: .public)' has no sensible reading — \(field.raw, privacy: .public) — rendering the field's default" ) } } for squatter in IntegrityRules.squattedClaimedNames(inCommentAt: commentURL, path: commentPath) { defects.append(.claimedNameSquatted(squatter)) logger.warning( "\(commentPath, privacy: .public)/\(squatter.name, privacy: .public): claimed name held by \(squatter.found.description, privacy: .public) — to be displaced" ) } comments.append(Comment( id: ItemID(rawValue: name), schema: document.schema, author: document.author, created: document.created, modified: document.modified, modifiedBy: document.modifiedBy, attachments: BoardLoader.attachmentNames(in: commentURL), document: document )) } return CommentThread( comments: sorted(comments), strays: strays, defects: defects, hasDraft: IntegrityRules.node(at: draftFolder(inCard: cardFolder)) == .directory ) } /// **The composer's own file, read** — `comments/.draft/`, which the thread listing deliberately /// excludes (see the type's note) and which the composer therefore has to ask for by name. /// /// It answers a pair rather than a `Comment` because a draft has **no identity**: it is a claimed /// *name*, not a UUID (`CommentPath.Kind.draft` — "the one member of the thread that is a name /// rather than an id"), and minting an `ItemID` for it here would put a lie in the one type whose /// whole job is that a comment's folder name *is* its id. /// /// Total, like `load(inCard:path:)`: a card with no draft, an unreadable one, or a `.draft` held /// by a file answers `nil` — which is what "nothing to restore" looks like and is not a defect /// this read invents (the squatted-name case is reported by the thread read beside it). /// /// **This is the whole of restore-on-reopen**: the composer's backing file is the draft, so /// reading it at open is all the mechanism there is (05-card-window.md ▸ The comments column). public static func loadDraft(inCard cardFolder: URL) -> CommentDraft? { let folder = draftFolder(inCard: cardFolder) guard IntegrityRules.node(at: folder) == .directory else { return nil } let indexURL = folder.appendingPathComponent(IntegrityRules.indexFileName) let attachments = BoardLoader.attachmentNames(in: folder) // A folder with no readable `index.md` is a draft that exists — the two-step-create shape, and // the shape a drop-before-a-keystroke leaves. Its body is empty, its chips are real, and // reporting `nil` would hide files the user can see in Finder. guard let data = try? Data(contentsOf: indexURL), let document = try? BoardLoader.parseDocument(data, path: IntegrityRules.commentDraftFolderName) else { return CommentDraft(body: "", attachments: attachments) } return CommentDraft(body: document.body, attachments: attachments) } /// **Board search's read** — every posted comment's body under `cardFolder`, and nothing else /// (04-interactions.md ▸ Search: "an async sweep of `comments/*/index.md` bodies (`.draft` and /// `comments/.trash/` excluded)"). /// /// It is a second entry point rather than `load(inCard:path:)` reused, and the difference is /// deliberately narrow: **it is silent and it reports nothing**. The thread read logs every stray, /// records every coerce-tier field and hands back the claimed-name work a heal will act on — all of /// which is right for a window opening a thread and wrong for a sweep that re-runs whenever a /// reload lands under a live query. A warning per stray per keystroke's re-sweep would be a log a /// human could not read, and defects surfaced from a *search* would be repaired by a gesture the /// user never made. /// /// `nonisolated` and total: this runs on a detached task (`CommentSearchIndex.sweep`), and a card /// with no `comments/`, one whose thread is held by a file, and one whose comments are all /// unreadable each answer `[]` — the same shrug the thread read gives, minus the paperwork. /// /// The two exclusions are the enumeration's, exactly as in `load`: `.draft` and `.trash` are /// dot-prefixed, and `BoardLoader.directoryCandidates` skips hidden entries. public static func searchableBodies(inCard cardFolder: URL) -> [String] { let threadFolder = folder(inCard: cardFolder) guard IntegrityRules.node(at: threadFolder) == .directory else { return [] } var bodies: [String] = [] for commentURL in (try? BoardLoader.directoryCandidates(in: threadFolder)) ?? [] { guard IntegrityRules.isIdentityShaped(commentURL.lastPathComponent), let data = try? Data(contentsOf: commentURL.appendingPathComponent(IntegrityRules.indexFileName)), // A malformed comment is *tolerated*, which here means unsearched: a body the parser // could not find is not a body a query can honestly be said to miss, and searching // the raw bytes would let a query match frontmatter the thread never renders. let document = try? BoardLoader.parseDocument(data, path: commentURL.lastPathComponent), !document.body.isEmpty else { continue } bodies.append(document.body) } return bodies } /// **Chronology, with the undated after the dated** (01-storage-format.md § Enhanced schema, /// ruled 2026-07-29): "the thread sorts by `created` ascending … ties and missing/malformed /// `created` (coerce-tier fallback, logged) sort after dated siblings, folder-name order". /// /// The folder-name tie-break compares the **canonical lowercase spelling**, the corpus-wide rule /// for every folder-name tie-break (§ Ordering: "ordering follows the value-based identity model, /// never an uppercase folder's ASCII accident") — an agent's uppercase `uuidgen` output must not /// sort into a different place than the same identity spelled lowercase. static func sorted(_ comments: [Comment]) -> [Comment] { comments.sorted { lhs, rhs in switch (lhs.created.value, rhs.created.value) { case let (left?, right?): left == right ? isOrderedByName(lhs, rhs) : left < right case (.some, .none): true case (.none, .some): false case (.none, .none): isOrderedByName(lhs, rhs) } } } private static func isOrderedByName(_ lhs: Comment, _ rhs: Comment) -> Bool { IntegrityRules.canonicalIdentity(lhs.id.rawValue) < IntegrityRules.canonicalIdentity(rhs.id.rawValue) } }