Board search reaches comment bodies through a search-owned transient
index: the first live-query keystroke sweeps comments/*/index.md
off-actor (.draft and comments/.trash excluded), keystrokes re-filter
in memory, the index discards on clear — the snapshot stays O(cards).
⌘F routes by focus: the comments pane gets an app-owned find bar
spanning the whole rendered thread (next/prev cross rows with
wraparound); body and composer keep NSTextFinder; Find Next/Previous
graduate from FutureCommands. Foreign comment changes speak
path-shaped beside the announcer's ladder ("New comment on 'X'",
plural folds), narrowed by EchoLedger receipts consumed through
CommentPath.classify — and that read fixed a latent footprint bug
where a comment receipt resolved against the card's attachment
listing, read .absent, and classified the user's own write as
foreign. The pane completes its a11y story: flattened comment
elements with Edit/Delete/Reveal custom actions (un-flattening
during inline edit), phrase-table vocabulary, labeled composer and
sort control, and an audit over the open pane on a comment-seeded
fixture (runnable only where automation permission exists).
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
397 lines
21 KiB
Swift
397 lines
21 KiB
Swift
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<Int>
|
|
|
|
/// 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<String>
|
|
|
|
/// 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<Date>
|
|
public let modified: FieldValue<Date>
|
|
public let modifiedBy: FieldValue<String>
|
|
|
|
/// 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
|
|
|
|
/// `<card>/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)
|
|
}
|
|
|
|
/// `<card>/comments/.draft/` — the card's single draft.
|
|
public static func draftFolder(inCard cardFolder: URL) -> URL {
|
|
folder(inCard: cardFolder)
|
|
.appendingPathComponent(IntegrityRules.commentDraftFolderName, isDirectory: true)
|
|
}
|
|
|
|
/// `<card>/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)
|
|
}
|
|
|
|
/// `<card>/comments/<id>/` — one posted comment.
|
|
public static func commentFolder(_ id: ItemID, inCard cardFolder: URL) -> URL {
|
|
folder(inCard: cardFolder).appendingPathComponent(id.rawValue, isDirectory: true)
|
|
}
|
|
|
|
/// `<card>/comments/.trash/<id>/` — 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 (`<lane>/<card>`, or
|
|
/// `.trash/<card>` 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)
|
|
}
|
|
}
|