import Foundation // MARK: - The leaves /// One comment as paper needs it: who, when, and what they said — `Comment` with everything a /// *window* needs stripped out (its identity, its attachments, its edited flag, its whole parsed /// document). /// /// The narrowing is the point. A print is a snapshot taken once and then re-laid-out several times as /// the user tries options in the panel, so what crosses into the printing layer should be the smallest /// thing that can answer every option — anything richer invites the renderer to start making decisions /// the builder should have made. public struct PrintComment: Sendable, Equatable { /// `nil` renders unattributed, exactly as the pane does — "**Missing renders unattributed**; there /// is no identity system behind it and none is implied" (`Comment.author`). public var author: String? /// `nil` for a comment whose `created` was missing or unreadable — a lenient field, and the byline /// simply says less rather than the print refusing. public var created: Date? public var body: String public init(author: String? = nil, created: Date? = nil, body: String) { self.author = author self.created = created self.body = body } /// A thread, flattened — **in the thread's own order** (`CommentThread.sorted`: `created` /// ascending, undated after dated, folder-name tie-break). /// /// The order arrives already correct and is never re-derived here: `PrintCommentSort.newestFirst` /// *reverses* this sequence rather than sorting by a key of its own, which is the same discipline /// the comments pane keeps ("The header's sort control reverses it for display and never re-sorts" /// — `CardComments.thread`). A second sort would be a second chance to disagree with the format's /// own chronology rule about what an undated comment means. public static func list(of thread: CommentThread) -> [PrintComment] { thread.comments.map { PrintComment(author: $0.author.value, created: $0.created.value, body: $0.body) } } } /// One card as paper needs it — the four things the component toggles can ask for, and nothing else. public struct PrintCard: Sendable, Equatable { /// The title as written, or `nil` for an untitled card. The **placeholder is the builder's** /// (`PrintDocumentBuilder.untitled`), never stored here: "Untitled" is a rendering, never a value /// (03-board-ui.md § Card face), and putting the word in this struct would make it indistinguishable /// from a card someone actually named that. public var title: String? /// The card's `icon` — a name already resolved against the running system, or `nil` when the field /// named no symbol this OS can draw. Resolution happens at extraction (`ItemSymbol`), so the /// renderer never has to ask whether a glyph exists and the print of a hand-typed typo silently /// omits the glyph rather than drawing an empty box. public var icon: String? /// The reserved `labels` key, flattened to strings (`PrintCard.labels(of:)`). public var labels: [String] public var body: String /// The card's thread, in chronological order. Empty both for a card with no comments and for a /// print that never asked for them — the extraction reads a thread only when the options want one /// (`PrintSource.board(_:titled:comments:)`), which is what keeps a comment-less board print from /// paying a disk read per card. public var comments: [PrintComment] public init(title: String? = nil, icon: String? = nil, labels: [String] = [], body: String = "", comments: [PrintComment] = []) { self.title = title self.icon = icon self.labels = labels self.body = body self.comments = comments } // MARK: Extraction /// A snapshot card, narrowed — with its thread supplied by the caller, because a thread is a disk /// read and this type is a value. public static func from(_ card: Card, comments: [PrintComment] = []) -> PrintCard { PrintCard( title: card.title.value, // `nil` rather than the level default: a print is a document, and a `doc.text` glyph in // front of every single card is furniture rather than information. A card whose author // *chose* an icon gets it; the board's own defaults stay on screen where they help // scanning (03-board-ui.md § Card face). icon: card.icon.value.flatMap { ItemSymbol.exists($0) ? $0 : nil }, labels: labels(of: card.document), body: card.body, comments: comments ) } /// **The reserved `labels` key, read as a list of words** — and the only place in the app that /// interprets it at all. /// /// 01-storage-format.md § Frontmatter reserves `labels` for the tracker-integration story and this /// version gives it no life: it is an ordinary unknown key, shown verbatim in the card window's /// Details rows and searched by nothing (04 ▸ Search: "labels/tags and their kin are reserved, /// inert keys this version"). Printing is the one surface that asks for it by name, because the /// card that specifies this feature asks for it by name. /// /// So the reading is deliberately shallow and deliberately lenient — it is a *display* of bytes, /// not the activation of a field: /// /// - A **sequence** is its scalar members, in order; nested collections are skipped rather than /// flattened, since a list of lists is not a label row. /// - A **single scalar** is one label, *except* that a comma-separated one splits — `labels: bug, /// ui` is YAML's one string `"bug, ui"` and is overwhelmingly likely to be a hand-written pair. /// This is the one inference here, and it is the friendly reading of the shape a human types. /// - Anything else (a mapping, a null, an empty string) contributes nothing. /// /// Blank members are dropped and the rest keep their bytes exactly. Nothing here can throw and /// nothing can fail — `CardDetails.display`'s posture, one key narrower. public static func labels(of document: FrontmatterDocument) -> [String] { guard let value = document.value(for: labelsKey) else { return [] } switch value { case let .sequence(members): return members.compactMap(scalarText(of:)).filter { !$0.isEmpty } case .mapping, .null: return [] default: guard let text = scalarText(of: value), !text.isEmpty else { return [] } return text .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } } } /// The reserved key's spelling, in one place — 01's own name for it. static let labelsKey = "labels" /// A scalar's text, or `nil` for a shape that is not a scalar. `YAMLValue.description` is the /// engine's own rendering and is the right answer for every scalar case (a bare `2026-01-01` label /// reads as its ISO form, which is what the file means); the collection cases are excluded here /// rather than described, since their `description` is diagnostic syntax nobody wants on paper. private static func scalarText(of value: YAMLValue) -> String? { switch value { case .null, .sequence, .mapping: nil default: value.description.trimmingCharacters(in: .whitespacesAndNewlines) } } } /// One lane as paper needs it: its heading and its cards, top to bottom. public struct PrintLane: Sendable, Equatable { /// The lane's title, or `nil` for an untitled lane — the placeholder is the builder's, exactly as /// a card's is. public var title: String? public var cards: [PrintCard] public init(title: String? = nil, cards: [PrintCard]) { self.title = title self.cards = cards } } // MARK: - PrintSource /// **What is being printed, as one immutable value** — the whole input to `PrintDocumentBuilder`, and /// the seam between the app and the printing layer. /// /// ### Why a snapshot of a snapshot /// /// `BoardModel` is already immutable, so copying out of it looks redundant until you count what a /// print does with it: the panel's preview re-lays-out the document every time the user flips a /// toggle, and the board underneath may reload (an agent writing, a sync landing) at any point during /// that dialog. A print that re-read the live store between preview refreshes would show one document /// and put another on paper. So the source is taken **once**, when ⌘P is pressed, and the print is of /// the board as it was at that moment — which is also what a user means by "print this". /// /// It is also what makes the printing layer testable end to end without a board on disk, a store, or /// a window: every rule below `PrintSource` is a function of this value and `PrintOptions`. public struct PrintSource: Sendable, Equatable { /// Which of the two ⌘P targets produced this — the board window's, or one card window's. /// /// The builder needs to know, and cannot infer it: a one-lane board with one card is shape-identical /// to a printed card, and the two documents differ (a board print names the board and its lanes; a /// card print is the card). public enum Scope: Sendable, Equatable { case board case card } public var scope: Scope /// The board's display name — `AppModel.displayName(of:)`'s answer, which falls back to the folder /// name for an untitled board. Carried for both scopes: a printed card's running head names the /// board it came from, which is the one piece of context a loose sheet needs. public var boardTitle: String /// For `.board`, the live lanes in display order. For `.card`, exactly one lane — the card's own, /// carried so the print can say which lane it came from and so the two scopes share one shape. public var lanes: [PrintLane] public init(scope: Scope, boardTitle: String, lanes: [PrintLane]) { self.scope = scope self.boardTitle = boardTitle self.lanes = lanes } // MARK: Extraction /// **A whole board, lane by lane, card by card** — `snapshot.lanes` in display order, each lane's /// `cards` in display order, exactly as the loader ranked them (`Ranks.sortedForDisplay`). The /// left-to-right strip becomes a top-to-bottom document by reading it in the order it is already /// stored in; nothing here sorts anything. /// /// **The trash is excluded**, and by construction rather than by a filter: `BoardModel.trash` and /// `trashedLanes` are sibling containers of `lanes`, not members of it (`BoardModel.trash`'s own /// note — "A sibling container of `lanes`, not a lane"), so a walk of `lanes` cannot reach them. A /// board print is a print of the board; deleted cards are deleted. /// /// - Parameter comments: the thread read, per card — supplied by the caller so it can be skipped /// entirely when the options do not want comments, and memoized when they do. Comments are /// window-scoped and outside the snapshot (01 § Enhanced schema), so there is no board-level /// reading of them to inherit; this closure is that read. public static func board( _ snapshot: BoardModel, titled boardTitle: String, comments: (Card) -> [PrintComment] = { _ in [] } ) -> PrintSource { PrintSource( scope: .board, boardTitle: boardTitle, lanes: snapshot.lanes.map { lane in PrintLane( title: lane.title.value, cards: lane.cards.map { PrintCard.from($0, comments: comments($0)) } ) } ) } /// **One card**, wrapped in its lane so both scopes share one shape. public static func card( _ card: Card, laneTitle: String?, boardTitle: String, comments: [PrintComment] = [] ) -> PrintSource { PrintSource( scope: .card, boardTitle: boardTitle, lanes: [PrintLane(title: laneTitle, cards: [PrintCard.from(card, comments: comments)])] ) } }