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