Files
lanework/Kanban/Interchange/BoardInterchange.swift
T
rzen b0c134a896 A board leaves as one file and comes back as one — headings are lanes, rows are cards, position is the order
File ▸ Export ▸ writes the frontmost board as Obsidian Kanban Markdown, a
plain Markdown outline, or RFC 4180 CSV; File ▸ Import Board… reads any of
the three back into a fresh board, format detected rather than asked. Every
format encodes order as document position, so an export writes no ranks and
an import mints them in parse order on the ordinary create path. Lossy
exports post a warning-tone loss row naming the comments and attachments the
destination cannot carry. Convert-once: nothing watches, nothing merges.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 08:38:06 -04:00

162 lines
7.5 KiB
Swift

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