import Foundation /// **The board as one Markdown file** — the Obsidian Kanban plugin's flavour and the plain outline, /// which are one serializer because they are one document (15-import-export.md ▸ The formats). /// /// ### The shape /// /// ```markdown /// --- /// /// kanban-plugin: board /// /// --- /// /// ## To Do /// /// - [ ] Fix login /// The button does nothing on the second click. /// - [ ] Ship the beta /// /// ## Done /// /// - [ ] Write the changelog /// ``` /// /// Lanes are `##` headings **in lane order**, cards are list items **in card order**, and that is the /// entire ordering contract: document position *is* the rank, so nothing is written down and nothing has /// to be read back. A multi-line body rides as continuation lines indented two spaces under its item, /// which is ordinary Markdown list continuation and what the plugin itself writes. /// /// ### The two flavours, and their one difference each /// /// - **`.obsidianKanban`** opens with the plugin's frontmatter marker, spelled the way the plugin spells /// it (blank lines inside the delimiters), and writes checkbox items `- [ ]`. It writes **no `#` /// board title**: the plugin has no such concept — a board's name is its file's name — so the save /// panel's chosen filename is the title, and an H1 would show up in Obsidian as a stray card-less /// heading. /// - **`.markdownOutline`** writes the board's title as an `#` H1 and plain `- ` items. No frontmatter /// at all, because the point of this flavour is a document that reads as itself wherever it is pasted. /// /// ### Everything is unchecked, on purpose /// /// Lanework has no done flag — a card is where it is, and that is the whole model — so **every exported /// item is `- [ ]`**, including cards in a lane called Done. Inferring checked state from a lane title /// would be the exporter inventing data out of a string match, and the inverse (importing `- [x]` as /// something) has nowhere to land. The import side ignores the marker for the same reason. Stated as a /// limitation in 15 rather than hidden here. /// /// ### What it does not write /// /// The plugin's trailing `%% kanban:settings %%` block (lane widths, per-lane "complete" flags, its own /// display preferences) and its `## Archive` section. Both are the plugin's state about a board rather /// than the board, this app has no equivalent of either, and a settings block synthesized from defaults /// would be this app asserting preferences on the user's behalf in another app's file. A board exported /// from here opens in the plugin with the plugin's own defaults, which is the honest outcome. public enum MarkdownBoardWriter { /// The whole document, LF-terminated (01-storage-format.md § Encoding and line endings — the app's /// own rule, and Obsidian's own convention besides). public static func text(for board: InterchangeBoard, flavor: InterchangeFormat) -> String { var lines: [String] = [] switch flavor { case .obsidianKanban: // The plugin's own spelling, blank lines and all — a file byte-shaped like one the plugin // wrote is a file it will never have an opinion about. lines += ["---", "", "kanban-plugin: board", "", "---", ""] case .markdownOutline: if let title = singleLine(board.title), !title.isEmpty { lines += ["# \(title)", ""] } case .csv: // Not this writer's format. Answering with the outline rather than trapping keeps the // function total for a caller that routed wrong; the export command never does. return text(for: board, flavor: .markdownOutline) } for (index, lane) in board.lanes.enumerated() { // A blank line *before* each lane after the first, rather than after each lane's items: // an empty lane then costs one blank line like every other, instead of two. if index > 0 { lines.append("") } lines.append("## \(singleLine(lane.title) ?? "")") guard !lane.cards.isEmpty else { continue } lines.append("") for card in lane.cards { lines += itemLines(for: card, flavor: flavor) } } return lines.joined(separator: "\n") + "\n" } /// One card: its marker line, then its body indented under it. /// /// **An untitled card writes a bare marker** (`- [ ]`, no trailing space) rather than the word /// "Untitled": the placeholder is a rendering and never a value (03-board-ui.md § Card face), and /// writing it would export a card nobody named as one somebody did. The parse of a bare marker is a /// card with a `nil` title, so the pair round-trips. /// /// **A blank line inside a body stays blank** — no two-space indent on an empty line, since trailing /// whitespace is litter, and the parser reads a blank line followed by more indented content as part /// of the item it is inside. The cost is that a multi-paragraph card renders as a *loose* list item /// in a Markdown viewer; the alternative — collapsing the blank line — would silently reflow the /// user's prose. static func itemLines(for card: InterchangeCard, flavor: InterchangeFormat) -> [String] { let marker = flavor == .obsidianKanban ? "- [ ]" : "-" let label = singleLine(card.title) ?? "" var lines = [label.isEmpty ? marker : "\(marker) \(label)"] for line in bodyLines(of: card.body) { lines.append(line.isEmpty ? "" : "\(continuationIndent)\(line)") } return lines } /// Two spaces — the plugin's own continuation indent, and the narrowest one Markdown accepts under a /// `- ` item, which keeps a body's own nested lists readable in the exported file. static let continuationIndent = " " /// A body split into lines, with line endings normalized and a trailing newline dropped. /// /// The trailing drop matters for the round trip: a body stored as `"text\n"` and one stored as /// `"text"` are the same document, and emitting the first as an item followed by a blank line would /// make the two export differently. private static func bodyLines(of body: String) -> [String] { var normalized = MarkdownBoardParser.normalized(body) while normalized.hasSuffix("\n") { normalized.removeLast() } guard !normalized.isEmpty else { return [] } return normalized.components(separatedBy: "\n") } /// A title flattened to one line, because a list item's label and a heading are both one line. /// /// Only reachable for a title hand-written as a YAML block scalar — nothing in the app can produce /// one — so this is a totality guard rather than a routine transform, and it is spelled as a /// substitution rather than a truncation so no character is lost. private static func singleLine(_ text: String?) -> String? { guard let text else { return nil } return MarkdownBoardParser.normalized(text) .replacingOccurrences(of: "\n", with: " ") .trimmingCharacters(in: .whitespaces) } }