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
111 lines
6.0 KiB
Swift
111 lines
6.0 KiB
Swift
import Foundation
|
|
|
|
/// **The three formats v1 converts** (15-import-export.md ▸ The formats) — the export menu's three
|
|
/// rows, and the three answers an import's sniff can give.
|
|
///
|
|
/// Both directions in one enum rather than an export set and an import set, because the set is the
|
|
/// same set: every v1 format round-trips, which is what makes the round-trip suite the feature's
|
|
/// primary proof.
|
|
public enum InterchangeFormat: String, Sendable, Equatable, CaseIterable {
|
|
|
|
/// The Obsidian Kanban plugin's board file (mgmeyers/obsidian-kanban): one `.md`, `kanban-plugin:
|
|
/// board` frontmatter, `##` lanes, `- [ ]` cards.
|
|
case obsidianKanban
|
|
|
|
/// The same document minus the plugin marker — a plain Markdown outline anybody can read, paste
|
|
/// into a PR, or hand-write.
|
|
case markdownOutline
|
|
|
|
/// One row per card: `lane,title,body,created,modified`, RFC 4180.
|
|
case csv
|
|
|
|
/// The name the menu row and the banner both use. Sentence-cased for the banner's mid-sentence
|
|
/// position; the menu rows spell their own titles, because a menu title is API
|
|
/// (`KanbanApp.menuCommands`) and must not be derived from anything that could be reworded.
|
|
public var displayName: String {
|
|
switch self {
|
|
case .obsidianKanban: "Obsidian Kanban Markdown"
|
|
case .markdownOutline: "Markdown outline"
|
|
case .csv: "CSV"
|
|
}
|
|
}
|
|
|
|
/// The extension a save panel suggests, and the one an exported file lands with.
|
|
public var fileExtension: String {
|
|
switch self {
|
|
case .obsidianKanban, .markdownOutline: "md"
|
|
case .csv: "csv"
|
|
}
|
|
}
|
|
|
|
// MARK: - Detection
|
|
|
|
/// **Which format a file is, decided from its bytes and its name** — the whole of File ▸ Import
|
|
/// Board…'s format question, which the user is never asked (15 ▸ One import row, no format picker).
|
|
///
|
|
/// The ladder, in order, and every rung is a fact rather than a guess where it can be:
|
|
///
|
|
/// 1. **A `kanban-plugin` key in the leading frontmatter block** is the plugin's own marker and is
|
|
/// conclusive — this is the one positive identification any of the three formats offers.
|
|
/// 2. **A `.csv` extension** is the user's own statement about their file, and outranks the shape
|
|
/// sniff below for the case the sniff is worst at: a one-column CSV with no commas in it.
|
|
/// 3. **A Markdown extension** (`.md`, `.markdown`, `.mdown`, `.txt`) means outline, so a Markdown
|
|
/// document whose prose happens to be comma-heavy is never mistaken for a table.
|
|
/// 4. **The shape sniff** — for a file with no useful extension at all (an emailed attachment, a
|
|
/// pasted-together dump): CSV only when the content has no Markdown structure *and* parses as a
|
|
/// consistent multi-column table (`looksLikeCSV`).
|
|
/// 5. **Outline otherwise**, which is the lenient parser and therefore the safe default: it never
|
|
/// fails, and the worst it does with a genuinely odd file is land everything in one lane.
|
|
///
|
|
/// Nothing here distinguishes `.obsidianKanban` from `.markdownOutline` beyond rung 1, and nothing
|
|
/// needs to: the two parsers *are* one parser (`MarkdownBoardParser`), because the plugin's document
|
|
/// is a plain outline wearing a frontmatter marker. The distinction is kept because the caller
|
|
/// reports which format it detected and because the export side genuinely differs.
|
|
public static func detect(text: String, fileName: String?) -> InterchangeFormat {
|
|
if frontmatterCarriesKanbanPlugin(text) { return .obsidianKanban }
|
|
|
|
let fileExtension = (fileName as NSString?)?.pathExtension.lowercased() ?? ""
|
|
if fileExtension == "csv" { return .csv }
|
|
if markdownExtensions.contains(fileExtension) { return .markdownOutline }
|
|
|
|
return looksLikeCSV(text) ? .csv : .markdownOutline
|
|
}
|
|
|
|
private static let markdownExtensions: Set<String> = ["md", "markdown", "mdown", "mkd", "txt"]
|
|
|
|
/// Whether the document opens with a YAML frontmatter block carrying a `kanban-plugin` key.
|
|
///
|
|
/// **Scanned as lines, not parsed as YAML.** The plugin writes its block with blank lines inside the
|
|
/// delimiters (`---`, ``, `kanban-plugin: board``, ``, `---`), a file may carry keys this app has
|
|
/// never heard of, and a foreign document's frontmatter is under nobody's obligation to be
|
|
/// well-formed. `FrontmatterDocument.parse` would answer this question by *refusing* the file, which
|
|
/// is exactly the wrong outcome for a detector whose next fallback is a parser that never fails.
|
|
static func frontmatterCarriesKanbanPlugin(_ text: String) -> Bool {
|
|
guard let block = MarkdownBoardParser.frontmatterBlock(in: MarkdownBoardParser.lines(of: text)) else {
|
|
return false
|
|
}
|
|
return block.contains { line in
|
|
let key = line.prefix { $0 != ":" }
|
|
return key.trimmingCharacters(in: .whitespaces) == "kanban-plugin"
|
|
}
|
|
}
|
|
|
|
/// The shape sniff — deliberately narrow, because its only job is to catch an extension-less table
|
|
/// and its cost of being wrong is a Markdown document shredded into one card per line.
|
|
///
|
|
/// Three conditions, all required: **no Markdown structure at all** (no ATX heading and no bullet at
|
|
/// column 0 — either one means outline, whatever else is in the file), **at least two records**, and
|
|
/// **a consistent field count of two or more** across every record. A single-column list of words,
|
|
/// a prose paragraph, and an empty file all fail it and fall through to the outline parser, which
|
|
/// handles each of them sensibly.
|
|
static func looksLikeCSV(_ text: String) -> Bool {
|
|
let lines = MarkdownBoardParser.lines(of: text)
|
|
for line in lines where MarkdownBoardParser.isMarkdownStructure(line) {
|
|
return false
|
|
}
|
|
let records = CSVDocument.decode(text)
|
|
guard records.count >= 2, let width = records.first?.count, width >= 2 else { return false }
|
|
return records.allSatisfy { $0.count == width }
|
|
}
|
|
}
|