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
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user