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 = ["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 } } }