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.. ArraySlice { 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) -> 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..