Files
lanework/Kanban/Interchange/BoardExporter.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

66 lines
3.2 KiB
Swift

import Foundation
/// **File ▸ Export ▸ …, minus the menu** — which format writes which text, and the one write that puts
/// it where the save panel said (15-import-export.md ▸ Export is three rows).
///
/// A thin router by design: every rule about *what* a format looks like belongs to that format's own
/// writer, so this file has nothing to get out of step with. Its one piece of real behaviour is the
/// write, and the reason that lives here rather than at the command is that a `BoardWriteError` is what
/// the banner speaks, and composing one is not a view's job.
public enum BoardExporter {
/// The document, as text.
public static func text(for board: InterchangeBoard, format: InterchangeFormat) -> String {
switch format {
case .obsidianKanban, .markdownOutline:
MarkdownBoardWriter.text(for: board, flavor: format)
case .csv:
CSVBoardWriter.text(for: board)
}
}
/// Writes the document to the URL the save panel returned.
///
/// **Atomic, and that is the whole of the ceremony.** `Data.write(options: .atomic)` stages beside
/// the destination and renames, so a failure part-way leaves the previous file — or no file —
/// rather than a truncated one. It deliberately does **not** go through `BoardWriter.atomicReplace`:
/// that path is instrumented for the board's own tree (`EchoLedger`, the `.gitignore`d temp-name
/// pattern, the write bracket's expectations), and an export lands somewhere the app has no
/// relationship with beyond this one grant.
///
/// **UTF-8, no BOM** (01-storage-format.md § Encoding and line endings) — the same encoding the app
/// writes everything else in, and the one every consumer of these three formats expects.
///
/// - Parameter boardTitle: for the failure sentence only; the bytes do not depend on it.
public static func write(
_ text: String,
to url: URL,
boardTitle: String?
) throws(BoardWriteError) {
do {
try Data(text.utf8).write(to: url, options: .atomic)
} catch {
throw BoardWriteError(
operation: .exportBoard(title: boardTitle),
path: url.path,
reason: .io(message: error.localizedDescription)
)
}
}
/// The name the save panel opens with — the board's own, with the format's extension.
///
/// Path separators and colons are replaced rather than stripped: `:` is what the Finder shows as `/`
/// and `/` is what the filesystem refuses, so a board titled "Q3: ship/slip" suggests
/// "Q3- ship-slip.md" instead of a name the panel would reject. The user can rename it to anything
/// they like — this is a suggestion, and the panel's answer is honored verbatim.
public static func suggestedFileName(boardTitle: String, format: InterchangeFormat) -> String {
var name = boardTitle
.replacingOccurrences(of: "/", with: "-")
.replacingOccurrences(of: ":", with: "-")
.trimmingCharacters(in: .whitespacesAndNewlines)
if name.isEmpty { name = "Board" }
return "\(name).\(format.fileExtension)"
}
}