import Foundation /// **The format-neutral board** — what every exporter writes from and every importer produces /// (15-import-export.md). /// /// ### Why a value in the middle /// /// Three formats times two directions is six converters; routed through one intermediate shape it is /// three writers and three parsers, none of which knows about the others, and — the part that matters /// for testing — none of which needs a board on disk, a store, or a window to exercise. It is /// `PrintSource`'s seam one door over, and for the same reason that type gives: what crosses into the /// conversion layer should be the smallest thing that can answer every question the formats ask, /// because anything richer invites a serializer to start making decisions the extraction should have /// made. /// /// ### What it deliberately cannot hold /// /// Comments, attachments, colours, icons, widths, collapsed state, ids, `modified-by`, and every /// reserved key. **None of the v1 formats can carry any of them** (15 ▸ The formats), so a shape that /// carried them would be a promise three writers would have to break one by one. The export commands /// account for the two the user would actually miss — comments and attachments — separately, off the /// snapshot, and say so out loud (`InterchangeOmissions`). /// /// ### Order is position /// /// Lanes and cards are **arrays in display order**, and that is the whole of the ordering contract in /// both directions: every format below writes them out in sequence, and document position is what a /// parse reads back. Nothing here carries a rank — an import mints fresh ones in parse order /// (`BoardImporter`), which is exactly where `Ranks.append(toVisible:)` would have put them. public struct InterchangeBoard: Sendable, Equatable { /// The board's display name — `AppModel.displayName(of:)`'s answer on the way out, and the /// document's own `#` heading (when it has one) on the way in. `nil` for an import that found no /// title, which is the importer's cue to fall back to the source file's name. public var title: String? public var lanes: [InterchangeLane] public init(title: String? = nil, lanes: [InterchangeLane] = []) { self.title = title self.lanes = lanes } /// Every card in the board, lane by lane — the count the CSV writer's row loop and the tests both /// want, without either re-deriving the walk. public var cards: [InterchangeCard] { lanes.flatMap(\.cards) } // MARK: Extraction /// **A live board, narrowed** — `snapshot.lanes` in display order, each lane's `cards` in display /// order, exactly as the loader ranked them (`Ranks.sortedForDisplay`). /// /// **The trash is excluded by construction rather than by a filter**, which is `PrintSource.board`'s /// own note: `BoardModel.trash` and `trashedLanes` are sibling containers of `lanes`, not members of /// it, so a walk of `lanes` cannot reach them. An export is an export of the board; deleted cards /// are deleted. public static func from(_ snapshot: BoardModel, titled boardTitle: String) -> InterchangeBoard { InterchangeBoard( title: boardTitle, lanes: snapshot.lanes.map { lane in InterchangeLane( title: lane.title.value, cards: lane.cards.map { card in InterchangeCard( title: card.title.value, body: card.body, created: card.created.value, modified: card.modified.value ) } ) } ) } } /// One lane: a heading and its cards, top to bottom. /// /// **No body.** A lane's `index.md` body is its description or WIP policy, and none of the three v1 /// formats has anywhere to put it: a Markdown outline's lane *is* the heading line, and CSV's grain is /// one row per card. Carrying it here would mean three writers each deciding to drop it. It is listed /// with the rest of the omissions in 15 rather than accounted for in a banner, because unlike comments /// and attachments it is a field most boards leave empty. public struct InterchangeLane: Sendable, Equatable { /// The lane's title as written, or `nil` for an untitled lane. **The placeholder is never stored** — /// "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so an untitled lane /// exports as an empty heading rather than as a lane somebody named that. public var title: String? public var cards: [InterchangeCard] public init(title: String? = nil, cards: [InterchangeCard] = []) { self.title = title self.cards = cards } } /// One card: its title, its Markdown body, and the two stamps CSV has columns for. public struct InterchangeCard: Sendable, Equatable { /// The title as written, `nil` for an untitled card — `InterchangeLane.title`'s rule exactly. public var title: String? /// The card's body, verbatim, with `\n` line endings. Empty for a card that has none. public var body: String /// **Export-only, both of them.** The CSV writer has a column for each; no importer reads either, /// because an imported board is **born today** — the tree is materialized by the ordinary Writer, /// whose creation path stamps `created`/`modified` from one fresh `Date` like every other board the /// app makes (`BoardWriter.createBoard`, and 09-templates.md's instantiation rule read one boundary /// over: "a new board is born today, not forked"). Backdating an import would claim a provenance the /// app cannot verify from a CSV cell. public var created: Date? public var modified: Date? public init(title: String? = nil, body: String = "", created: Date? = nil, modified: Date? = nil) { self.title = title self.body = body self.created = created self.modified = modified } } // MARK: - What an export leaves behind /// **What the board holds that the exported document cannot** — the two counts an export owes the user /// a sentence about (15 ▸ Lossy exports say so). /// /// Counted off the `BoardModel` rather than off the `InterchangeBoard`, and that is the point: the /// intermediate shape has already dropped both, so anything derived from it could only ever report /// zero. `Card.commentCount` is a readdir the snapshot already paid for and `Card.attachments` is a /// listing it already holds, so this walk costs nothing beyond the addition. /// /// **The trash is excluded**, on `InterchangeBoard.from`'s reasoning: a notice counting comments on /// deleted cards would be reporting content the export was never going to include for a second, /// unrelated reason. public struct InterchangeOmissions: Sendable, Equatable { public var comments: Int public var attachments: Int public init(comments: Int = 0, attachments: Int = 0) { self.comments = comments self.attachments = attachments } /// Nothing was left behind — the export that says nothing at all. public var isEmpty: Bool { comments == 0 && attachments == 0 } public static func of(_ snapshot: BoardModel) -> InterchangeOmissions { var omissions = InterchangeOmissions() for lane in snapshot.lanes { for card in lane.cards { omissions.comments += card.commentCount omissions.attachments += card.attachments.count } } return omissions } }