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

404 lines
19 KiB
Swift

import Foundation
/// **One lenient Markdown parser for both Markdown flavours** (15-import-export.md ▸ Import is one row).
///
/// The Obsidian Kanban plugin's document *is* a plain outline wearing a frontmatter marker, so there is
/// no second parser for it — the marker decides what the importer reports it found, not how the file is
/// read.
///
/// ### The rules, and the one promise above them
///
/// **It never fails.** There is no thrown error and no rejected shape: every line of the input ends up
/// somewhere, and the worst a genuinely odd file gets is every card in one lane called "Imported". That
/// is the whole posture — a converter that refuses a file the user is trying to leave behind has failed
/// at the only job it has.
///
/// - **Lane level is whichever heading level the document actually uses.** `##` are lanes when the file
/// has any; otherwise `#` are. A single `#` *above* the first lane is the board's title (the plain
/// outline export's own shape); an `#` after lanes have started is another lane, because by then it is
/// plainly being used as a section break.
/// - **Deeper headings are cards.** `###` under `##` is the outline reading of a sub-item, and it is how
/// a hand-written doc ("### Task A", then notes) imports the way its author meant.
/// - **Bullets are cards**: `-`, `*`, `+`, and ordered `1.` / `1)`, with or without a `[ ]` / `[x]` task
/// marker. **The marker is read and discarded** — Lanework has no done flag, so a checked item imports
/// as an ordinary card rather than as something the app would have to invent a meaning for
/// (`MarkdownBoardWriter`'s own note about the export side of the same rule).
/// - **Indented lines are the current card's body**, dedented by the first continuation line's own
/// indent so nested lists and code keep their relative shape.
/// - **Column-0 prose belongs to the card above it** — to a *bullet's* card only as CommonMark's lazy
/// continuation (no blank line between), and to a *heading's* card until the next heading or bullet,
/// because that is what a section is. Prose that belongs to neither becomes a card of its own.
/// Nothing is dropped in any of the three cases, which is the promise
/// (`OutlineAccumulator.absorbsProse` states the rule once).
/// - **Obsidian `%%` comments and thematic breaks are skipped.** The plugin's trailing
/// `%% kanban:settings %%` block is the reason: read as prose it would become a card holding the
/// plugin's JSON. The plugin's `***` archive separator goes the same way — and the `## Archive`
/// heading below it imports as an ordinary lane named Archive, which is the honest landing place for
/// cards this app has no archive to put in.
/// - **Column-0 fenced code is held together**: a ``` or `~~~ fence toggles a verbatim stretch that
/// rides into the current card's body unchanged, so a code block pasted at the left margin is not
/// shredded into one card per line.
public enum MarkdownBoardParser {
/// The lane an import falls back to when content arrives before any heading does — and the lane a
/// CSV with no lane column lands in (`CSVBoardParser`), deliberately the same word in both places.
public static let defaultLaneTitle = "Imported"
// MARK: - Parse
public static func parse(_ text: String) -> InterchangeBoard {
let all = lines(of: text)
let body = skippingFrontmatter(all)
let laneLevel = laneHeadingLevel(in: body)
var accumulator = OutlineAccumulator()
var commentDepth = 0
var inFence = false
for line in body {
// Obsidian comments first: their content is arbitrary and must never be read as structure.
if commentDepth > 0 {
if isCommentDelimiter(line) { commentDepth = 0 }
continue
}
if !inFence, let single = commentOpener(line) {
if !single { commentDepth = 1 }
continue
}
if isFenceDelimiter(line) {
inFence.toggle()
accumulator.appendVerbatim(line)
continue
}
if inFence {
if line.trimmingCharacters(in: .whitespaces).isEmpty {
accumulator.appendBlank()
} else {
accumulator.appendVerbatim(line)
}
continue
}
if line.trimmingCharacters(in: .whitespaces).isEmpty {
accumulator.appendBlank()
continue
}
if isThematicBreak(line) {
accumulator.closeCard()
continue
}
if let heading = heading(of: line) {
if heading.level == laneLevel {
accumulator.startLane(title: heading.text.isEmpty ? nil : heading.text)
} else if heading.level > laneLevel {
accumulator.startCard(title: heading.text.isEmpty ? nil : heading.text, isSection: true)
} else if accumulator.isEmpty, accumulator.boardTitle == nil {
accumulator.boardTitle = heading.text.isEmpty ? nil : heading.text
} else {
accumulator.startLane(title: heading.text.isEmpty ? nil : heading.text)
}
continue
}
if let label = listItemLabel(of: line) {
accumulator.startCard(title: label.isEmpty ? nil : label, isSection: false)
continue
}
if isIndented(line) {
accumulator.appendContinuation(line)
continue
}
// Column-0 prose: the current card's body when it can still absorb one, a card of its own
// otherwise (`OutlineAccumulator.absorbsProse`).
if accumulator.absorbsProse {
accumulator.appendContinuation(line)
} else {
accumulator.startCard(title: line.trimmingCharacters(in: .whitespaces), isSection: false)
}
}
return accumulator.finish()
}
// MARK: - Text plumbing
/// Line endings folded to `\n` and a UTF-8 BOM dropped — the one normalization every reader here
/// starts from, so no rule below has to spell a `\r` case (01-storage-format.md § Encoding and line
/// endings, read for input the app did not write).
public static func normalized(_ text: String) -> String {
var value = text
if value.hasPrefix("\u{FEFF}") { value.removeFirst() }
return value
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
}
static func lines(of text: String) -> [String] {
normalized(text).components(separatedBy: "\n")
}
/// The inner lines of a leading `---` … `---` frontmatter block, or `nil` when there is none.
///
/// Handed out rather than kept private because the format detector asks the same question of the
/// same block (`InterchangeFormat.frontmatterCarriesKanbanPlugin`) and the two must agree about what
/// counts as one.
static func frontmatterBlock(in lines: [String]) -> [String]? {
guard let first = lines.first, isFrontmatterDelimiter(first) else { return nil }
guard let end = lines.dropFirst().firstIndex(where: isFrontmatterDelimiter) else { return nil }
return Array(lines[1..<end])
}
/// Everything after the frontmatter block, or the whole document when there is none.
static func skippingFrontmatter(_ lines: [String]) -> ArraySlice<String> {
guard let first = lines.first, isFrontmatterDelimiter(first) else { return lines[...] }
guard let end = lines.dropFirst().firstIndex(where: isFrontmatterDelimiter) else { return lines[...] }
return lines[(end + 1)...]
}
private static func isFrontmatterDelimiter(_ line: String) -> Bool {
let trimmed = line.trimmingCharacters(in: .whitespaces)
return trimmed == "---" || trimmed == "..."
}
// MARK: - Line shapes
/// `##` when the document has any H2, `#` when it only has H1s, `##` when it has no headings at all
/// (nothing will match, and every card falls into the default lane — which is the promise).
static func laneHeadingLevel(in lines: some Sequence<String>) -> Int {
var sawH1 = false
for line in lines {
guard let heading = heading(of: line) else { continue }
if heading.level >= 2 { return 2 }
if heading.level == 1 { sawH1 = true }
}
return sawH1 ? 1 : 2
}
/// An ATX heading at column 0 — `#` through `######`, with a space after the run or nothing at all.
/// A run with text jammed against it (`#hashtag`) is not a heading, which is CommonMark's rule and
/// also what keeps a tag line from becoming a lane.
static func heading(of line: String) -> (level: Int, text: String)? {
guard line.hasPrefix("#") else { return nil }
let hashes = line.prefix { $0 == "#" }
guard hashes.count <= 6 else { return nil }
let rest = line.dropFirst(hashes.count)
guard rest.isEmpty || rest.first == " " || rest.first == "\t" else { return nil }
var text = rest.trimmingCharacters(in: .whitespaces)
// A closed ATX heading (`## Done ##`) drops its trailing run — CommonMark's own reading,
// including its condition that the run be preceded by whitespace, so a lane genuinely titled
// "C#" keeps its name.
if text.hasSuffix("#") {
let run = text.reversed().prefix { $0 == "#" }.count
let before = text.dropLast(run)
if before.isEmpty || before.last == " " || before.last == "\t" {
text = String(before)
}
}
return (hashes.count, text.trimmingCharacters(in: .whitespaces))
}
/// A list item at column 0, answered as its label with any task marker already stripped. `nil` for
/// anything that is not one.
static func listItemLabel(of line: String) -> String? {
guard let rest = listItemRemainder(of: line) else { return nil }
return strippingTaskMarker(rest).trimmingCharacters(in: .whitespaces)
}
private static func listItemRemainder(of line: String) -> String? {
guard let first = line.first else { return nil }
if "-*+".contains(first) {
let rest = line.dropFirst()
guard rest.isEmpty || rest.first == " " || rest.first == "\t" else { return nil }
return String(rest)
}
guard first.isNumber else { return nil }
let digits = line.prefix { $0.isNumber }
// At most nine digits is CommonMark's own cap, and it keeps a bare year from opening a list.
guard digits.count <= 9 else { return nil }
let afterDigits = line.dropFirst(digits.count)
guard let delimiter = afterDigits.first, delimiter == "." || delimiter == ")" else { return nil }
let rest = afterDigits.dropFirst()
guard rest.isEmpty || rest.first == " " || rest.first == "\t" else { return nil }
return String(rest)
}
/// `[ ]`, `[x]`, `[X]` and the plugin's own `[X]` variants, removed from the front of a label.
/// **Read and discarded** — see this type's own note about why there is nothing to import it into.
private static func strippingTaskMarker(_ text: String) -> String {
let trimmed = text.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("[") else { return text }
let scalars = Array(trimmed)
guard scalars.count >= 3, scalars[2] == "]" else { return text }
let state = scalars[1]
guard state == " " || state == "x" || state == "X" else { return text }
return String(trimmed.dropFirst(3))
}
static func isIndented(_ line: String) -> Bool {
guard let first = line.first else { return false }
return first == " " || first == "\t"
}
/// `---`, `***`, `___` — three or more of one character, spaces allowed between them.
static func isThematicBreak(_ line: String) -> Bool {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard let first = trimmed.first, "-*_".contains(first) else { return false }
let stripped = trimmed.filter { !$0.isWhitespace }
return stripped.count >= 3 && stripped.allSatisfy { $0 == first }
}
/// A fence line at column 0 — three or more backticks or tildes. The info string is ignored: this
/// only needs to know that a verbatim stretch opened or closed.
static func isFenceDelimiter(_ line: String) -> Bool {
guard let first = line.first, first == "`" || first == "~" else { return false }
return line.prefix { $0 == first }.count >= 3
}
/// Whether the line opens an Obsidian `%%` comment, and whether it also closes it on the same line.
private static func commentOpener(_ line: String) -> Bool? {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("%%") else { return nil }
return trimmed.count > 2 && trimmed.hasSuffix("%%")
}
private static func isCommentDelimiter(_ line: String) -> Bool {
line.trimmingCharacters(in: .whitespaces).hasSuffix("%%")
}
/// **Whether a line carries Markdown structure** — an ATX heading or a list item at column 0. The
/// format detector's veto (`InterchangeFormat.looksLikeCSV`) asks exactly this and nothing else: one
/// such line anywhere in a file is enough to mean the file is an outline, whatever its commas say.
static func isMarkdownStructure(_ line: String) -> Bool {
heading(of: line) != nil || listItemRemainder(of: line) != nil
}
}
// MARK: - The walk's state
/// The parser's running state, extracted so the walk above reads as its own rules rather than as
/// bookkeeping — and so "close the open card, then the open lane, then answer" happens in exactly one
/// place instead of at every branch that ends one.
private struct OutlineAccumulator {
var boardTitle: String?
private var lanes: [InterchangeLane] = []
private var lane: InterchangeLane?
private var cardTitle: String??
/// Whether the open card came from a **heading** rather than a bullet — see `absorbsProse`.
private var cardIsSection = false
private var bodyLines: [String] = []
private var indentPrefix: String?
private var pendingBlanks = 0
/// Nothing has been read yet — the window in which a shallow heading is the board's title rather
/// than a lane.
var isEmpty: Bool { lanes.isEmpty && lane == nil && cardTitle == nil }
/// **Whether column-0 prose belongs to the open card**, which the two kinds of card answer
/// differently — and deliberately so, because Markdown itself does:
///
/// - A card from a **bullet** takes prose only as CommonMark's *lazy continuation*: no blank line
/// between them. A blank line ends the list item, and what follows is its own thing.
/// - A card from a **heading** takes everything until the next heading or bullet, blank lines
/// included, because that is what a heading's section *is*. A `### Task A` followed by a blank
/// line and two paragraphs of notes is one card with a body, and reading the notes as a second
/// card would be the parser ignoring the only structure the document has.
var absorbsProse: Bool { cardTitle != nil && (cardIsSection || pendingBlanks == 0) }
mutating func startLane(title: String?) {
closeLane()
lane = InterchangeLane(title: title)
}
mutating func startCard(title: String?, isSection: Bool) {
closeCard()
cardTitle = .some(title)
cardIsSection = isSection
}
mutating func appendBlank() {
// A blank line before any card is structure, not content: it separates a heading from its
// items and must not become a leading empty line in the next card's body.
guard cardTitle != nil else { return }
pendingBlanks += 1
}
/// An indented (or lazily-continued) body line, dedented by the first continuation's own indent.
///
/// **The first continuation sets the prefix for the whole item**, and a later line that does not
/// carry it is stripped of whatever leading whitespace it has. That keeps a body's *relative*
/// indentation — a nested list, an indented code block — while never leaving a stray two spaces on
/// content the exporter will indent again on the way back out.
mutating func appendContinuation(_ line: String) {
guard cardTitle != nil else {
startCard(title: line.trimmingCharacters(in: .whitespaces), isSection: false)
return
}
if indentPrefix == nil {
indentPrefix = String(line.prefix { $0 == " " || $0 == "\t" })
}
let dedented: String
if let prefix = indentPrefix, !prefix.isEmpty, line.hasPrefix(prefix) {
dedented = String(line.dropFirst(prefix.count))
} else {
dedented = String(line.drop { $0 == " " || $0 == "\t" })
}
flushBlanks()
bodyLines.append(dedented)
}
/// A fenced-code line, kept exactly as it arrived: inside a fence, leading whitespace is content.
mutating func appendVerbatim(_ line: String) {
guard cardTitle != nil else {
cardTitle = .some(nil)
flushBlanks()
bodyLines.append(line)
return
}
flushBlanks()
bodyLines.append(line)
}
mutating func closeCard() {
guard let title = cardTitle else { return }
// Trailing blanks are dropped rather than flushed: they are the separator before whatever comes
// next, not the tail of this body.
let card = InterchangeCard(title: title, body: bodyLines.joined(separator: "\n"))
if lane == nil { lane = InterchangeLane(title: MarkdownBoardParser.defaultLaneTitle) }
lane?.cards.append(card)
cardTitle = nil
cardIsSection = false
bodyLines = []
indentPrefix = nil
pendingBlanks = 0
}
mutating func closeLane() {
closeCard()
guard let lane else { return }
lanes.append(lane)
self.lane = nil
}
mutating func finish() -> InterchangeBoard {
closeLane()
return InterchangeBoard(title: boardTitle, lanes: lanes)
}
/// **Blanks are content only between content.** A blank line before the body's first line is the
/// separator between a card's marker (or heading) and what follows, so it is dropped — the mirror of
/// `closeCard`'s trailing drop, and together they are why a body never starts or ends with an empty
/// line whatever the source file's spacing was.
private mutating func flushBlanks() {
if !bodyLines.isEmpty {
for _ in 0..<pendingBlanks { bodyLines.append("") }
}
pendingBlanks = 0
}
}