From b0c134a896ef4af4b29c29227546f458bae4fc33 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 9 Aug 2026 08:36:22 -0400 Subject: [PATCH] =?UTF-8?q?A=20board=20leaves=20as=20one=20file=20and=20co?= =?UTF-8?q?mes=20back=20as=20one=20=E2=80=94=20headings=20are=20lanes,=20r?= =?UTF-8?q?ows=20are=20cards,=20position=20is=20the=20order?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- DESIGN/15-import-export.md | 102 +++ Kanban/App/InterchangeCommands.swift | 245 +++++++ Kanban/Interchange/BoardExporter.swift | 65 ++ Kanban/Interchange/BoardImporter.swift | 229 ++++++ Kanban/Interchange/BoardInterchange.swift | 161 +++++ Kanban/Interchange/CSVBoard.swift | 159 +++++ Kanban/Interchange/CSVDocument.swift | 127 ++++ Kanban/Interchange/InterchangeFormat.swift | 110 +++ Kanban/Interchange/MarkdownBoardParser.swift | 403 +++++++++++ Kanban/Interchange/MarkdownBoardWriter.swift | 142 ++++ Kanban/KanbanApp.swift | 11 + Kanban/LiveStore/BannerCenter.swift | 59 ++ Kanban/Storage/BoardWriter.swift | 30 +- KanbanTests/BoardImportWriteTests.swift | 319 +++++++++ KanbanTests/InterchangeTests.swift | 690 +++++++++++++++++++ 15 files changed, 2851 insertions(+), 1 deletion(-) create mode 100644 DESIGN/15-import-export.md create mode 100644 Kanban/App/InterchangeCommands.swift create mode 100644 Kanban/Interchange/BoardExporter.swift create mode 100644 Kanban/Interchange/BoardImporter.swift create mode 100644 Kanban/Interchange/BoardInterchange.swift create mode 100644 Kanban/Interchange/CSVBoard.swift create mode 100644 Kanban/Interchange/CSVDocument.swift create mode 100644 Kanban/Interchange/InterchangeFormat.swift create mode 100644 Kanban/Interchange/MarkdownBoardParser.swift create mode 100644 Kanban/Interchange/MarkdownBoardWriter.swift create mode 100644 KanbanTests/BoardImportWriteTests.swift create mode 100644 KanbanTests/InterchangeTests.swift diff --git a/DESIGN/15-import-export.md b/DESIGN/15-import-export.md new file mode 100644 index 0000000..8d62faf --- /dev/null +++ b/DESIGN/15-import-export.md @@ -0,0 +1,102 @@ +# Import and Export + +> **Status: v1 settled and built** (2026-08-09, Pipeline card 332e4377). The format inventory below is the whole of the brainstorm that produced it; the v1 set is shipped, and the deferred and rejected lists are recorded so neither gets re-litigated by accident. + +Lanework's on-disk format is already the most open thing about it: a board is folders and Markdown, and anybody's tools can read it. Import and export are therefore not a data-liberation feature — the data is already liberated — but a **shape** feature. What a foreign format carries is not the bytes of a card but the arrangement of them: which lane, in what order. Everything below follows from that. + +## The formats (v1) + +Three, both directions, all three converting a **whole board** to or from a **single file**. + +### Obsidian Kanban plugin Markdown + +One `.md` in the shape mgmeyers' plugin writes: `kanban-plugin: board` frontmatter, `##` headings as lanes in lane order, `- [ ]` list items as cards in card order, multi-line bodies as continuation lines indented two spaces under their item. + +- The frontmatter is spelled the way the plugin spells it — blank lines inside the `---` delimiters — so a file exported from here is byte-shaped like one the plugin wrote. +- **No `#` board title is written**: the plugin has no such concept (a board's name is its file's name), so an H1 would read in Obsidian as a stray heading with no cards under it. +- **Nothing is exported checked**, including a lane called Done. Lanework has no done flag, and matching lane titles against a word list to synthesize one would be the exporter inventing data. The import side ignores the marker for the same reason: `- [x]` becomes an ordinary card. +- The plugin's trailing `%% kanban:settings %%` block and its `## Archive` section are **not written**. Both are the plugin's own state about a board rather than the board; a settings block synthesized from defaults would be this app asserting preferences inside another app's file. On import both are handled: the comment block is skipped rather than read as a card, and an `## Archive` heading arrives as an ordinary lane named Archive — the honest landing place for cards in an app with no archive. + +### Plain Markdown outline + +The same document minus the plugin marker: an optional `#` board title, `##` lanes, `- ` items, the same indented continuations. The "paste this board into a doc, a PR, or a chat message" format, and nearly free once the flavour above exists. + +### CSV + +RFC 4180, UTF-8, no BOM, CRLF record terminators, header row `lane,title,body,created,modified` — one row per card with the lane title repeated down the column. Bodies are quoted and escaped, so a multi-line card survives the trip. + +CRLF is the one place the app's own LF-everywhere rule (01-storage-format.md § Encoding and line endings) deliberately does not reach: that rule governs the files a *board* is made of, and a CSV is an interchange document written for other people's tools, where the standard names one terminator and every reader takes both. + +The two stamps are **export-only**. Nothing reads them back — see *An imported board is born today*. + +## Order is position, in every direction + +Every one of these formats encodes order as **document position**: heading order, list-item order, row order. So an export writes nothing down about ranks, and an import mints them in parse order — 1024, 2048, 3072 — which is exactly where the app's own append (`Ranks.append(toVisible:)`) would have put them had the user typed the cards in that sequence. The symmetry is most of why all three formats are cheap, and it is why the round-trip suite is this feature's primary proof rather than a nicety. + +## Export is three rows; import is one + +**File ▸ Export ▸ {Obsidian Kanban Markdown…, Markdown Outline…, CSV…}** — the frontmost board, each row opening a save panel already pointed at the right extension. Three named rows rather than one row with a format popup: a popup makes the menu row itself uninformative (nothing about "Export…" says CSV is on offer), and menu titles are API in this app (11-command-nexus.md ▸ Configurable bindings), so a row whose meaning lived in a popup would be one title standing for three commands. + +An export runs the close flush first, like Duplicate, Save as Template and Share before it, so a card window's unsaved keystrokes are on disk before the snapshot is serialized. **An export is a read**: it writes nothing into the board's own tree, so the read-only lock does not close the rows — a board on a read-only volume is exactly the board somebody wants a copy of. The one carve-out kept is the focused-inline-editor rule, since an open rename holds the one pending change no flush can reach. + +**File ▸ Import Board…** — one row, and **no format picker**: the format is detected. The plugin's frontmatter marker identifies an Obsidian Kanban file outright; a `.csv` extension answers for the table; a Markdown extension answers for the outline; a file with no useful extension is sniffed (a consistent multi-column table with no Markdown structure anywhere in it is CSV, everything else is outline). The fallback is the lenient outline parser, which cannot fail. Asking users to classify their own file would make them do the one part of this the app can do reliably. + +The row sits beside File ▸ Open… rather than beside Export, and is available with no board in front: the two rows next to each other are the two ways a board arrives from disk. + +## An import always creates a fresh board + +Open panel (which file), save panel (where the board goes), then the board opens through the ordinary open path — registering, bookmarking and titling itself like any other. **There is no merge-into-the-open-board in v1.** An import that landed lanes inside a live board would need every reconciliation rule the paste path already carries, for a gesture nobody has asked for; and a fresh board keeps the whole feature reversible by deleting one folder. + +The **save panel's name wins** over any title parsed out of the document, so display name and folder name start out matching (01-storage-format.md § Board naming) — the parsed title is what seeded the suggestion, not what overrides the answer. That is 09-templates.md ▸ Instantiation's rule, read one flow over. + +The tree is built by the ordinary Writer (`createBoard`/`createLane`/`createCard`/`writeBody`), so an imported board is indistinguishable from a hand-built one: `schema: 1`, `kind` keys, lowercase-UUID folder names, a seeded `.gitignore`, a current agent guide. Atomicity is the template engine's, verbatim — the destination is created by the import and removed by it on every exit that is not a board, and an occupied name is refused rather than clobbered. + +**An imported board is born today.** No importer reads a `created` or `modified` cell: the create path stamps both from one fresh `Date`, exactly as 09-templates.md rules for instantiation ("a new board is born today, not forked"). Backdating an import would claim a provenance the app cannot verify from a spreadsheet cell. + +## Lossy exports say so + +None of the three formats can carry comments or attachments. So an export that leaves either behind posts a **warning-tone loss row** naming the counts — "Exported without 12 comments and 3 attachments — CSV carries neither" — through the same `BannerCenter` machinery and in the same voice as the relocation and skipped-folder notices (02-architecture.md § The banner surface). + +The class is the right one for the same reason the skipped-folders notice is: the operation succeeded and only the payload the destination cannot hold stayed behind. A signpost would rank last and may collapse behind "+N more", and an export the user believes is complete is exactly the harm; a one-shot would be a lie, since it carries a `BoardWriteError` and the write succeeded. + +**A lossless export says nothing at all.** The file is where the user pointed, and a row confirming that would be noise. + +Two smaller omissions are recorded here rather than counted in a banner, because unlike comments and attachments they are fields most boards leave empty or would not miss: a **lane's body** (its description or WIP policy — a Markdown lane *is* its heading line, and CSV's grain is one row per card), and every styling key (`background`, `icon`, `iconColor`, `width`, `collapsed`) plus the reserved keys. Two lanes that share a title also merge on a CSV re-import, which is an inherent property of a flat table rather than a choice. + +## Convert once; there is no sync + +Every one of these conversions is **one-shot**. Exporting does not create a link, importing does not create a link, and nothing watches an exported file for changes. Editing a board in Obsidian's plugin and reopening it here does not merge — it imports again, as a second board. + +This is ruled out explicitly rather than left unsaid, because a real round trip is a materially bigger feature than an importer and an exporter: it needs a file-watching story, a conflict story, and an identity story for objects that have no stable id in any of these formats (position is the only key a Markdown outline has, and position is exactly what editing changes). Whatever durable two-way story Lanework eventually wants belongs to the sync workstream, not here. + +## Obsidian vault interop is free, and should be said out loud + +**A Lanework board folder is already very nearly a valid Obsidian vault**, and this costs no code at all: + +- Every card is one `index.md` with YAML frontmatter — Obsidian's own file shape. +- Attachment links are relative paths into the card's `attachments/` folder (01-storage-format.md § Attachments), which render in Obsidian's preview exactly as they render here. +- Unknown frontmatter keys ride along untouched in both directions, so Obsidian's properties and this app's schema coexist without either rewriting the other's. +- Nothing in the board tree is a database, a cache, or a binary sidecar that a second editor could corrupt by writing normally. + +What Obsidian does *not* see is the board shape: it reads a flat collection of notes, because lanes and order live in folder names and an `order` key rather than in anything Obsidian models. That is the honest half of the claim, and the Obsidian Kanban plugin export above is what closes it for anyone who wants the board shape inside their vault. + +This is a **positioning** note as much as a technical one. "Files-first" is easy to say; "point Obsidian at your board folder and it works" is the demonstration. + +## Deferred — real, but not v1 + +- **Trello JSON import** — the highest-demand row by a distance (every switcher has a Trello board), and the reason it is not in v1 is that it is not a shape transform: attachments are cloud URLs that need re-downloading and can expire, and comments have to be pulled out of a capped activity log with an authorship question attached. Worth doing after the cheap wins prove the import surface once. +- **Canonical JSON dump** — the only format that could be fully lossless (comments, colours, `modified-by`, timestamps). Cheap once the serialization is being touched anyway, and the natural wire shape for scripting. Sequenced late deliberately: it competes head-on with "just zip the `.kanban` package", which already wins on fidelity for zero app code and which File ▸ Share… already does. +- **TaskPaper and OPML** — both are the outline exporter with different delimiters, so both are nearly free whenever that code is next open; the audience is small and aging. Bundle them together rather than scheduling either alone. +- **Notion import** — a genuine demand pool, with a real risk of overselling: Notion's value is databases and relations that do not map onto lanes and cards, and the importer needs a user-driven mapping step (which property is the board view) that none of the v1 formats need. Wait for somebody to ask. +- **OmniFocus (via OPML) and Reminders (via EventKit)** — plausible and individually cheap, no demand signal. Reminders in particular is a live OS API rather than a file format and probably belongs with a future quick-capture feature rather than here. + +## Rejected + +- **Tracker sync, GitHub Projects included** — 00-vision.md's explicit non-goal for this scope ("No tracker integrations… the schema reserves `remote`/`remote-state`… nothing here is designed for them"). GitHub Projects has no export file at all: it is a GraphQL API behind OAuth, which makes it a live-sync feature, not an import format. It belongs to whatever Teams pass eventually happens. +- **Things (official export)** — there is no first-party export surface to target. The only routes are reading its private SQLite database or asking the user to run a community script first, and neither is a target worth building against. If it is ever asked for, point people at the community TaskPaper exporters and let a deferred TaskPaper importer pick it up secondhand. +- **Print/PDF as an interchange format** — already shipped as presentation (File ▸ Print…, board or card, configurable components, named profiles). Noted here only so it is not re-proposed as an export row. + +## Open questions + +- **The command Nexus owes rows for all four items** — File ▸ Export ▸ (three) and File ▸ Import Board… are not yet in 11-command-nexus.md's inventory. Neither takes a default chord. +- **The v1 rulings on this page were made by the main session on the owner's behalf** (the owner's own word was "proceed as per your recommendation for v1"): the export and import surfaces, the fresh-board-only import, the loss-row posture, the convert-once ruling, and the decision to treat vault interop as documentation. Each is flagged for review on the Pipeline card's comment thread. diff --git a/Kanban/App/InterchangeCommands.swift b/Kanban/App/InterchangeCommands.swift new file mode 100644 index 0000000..ccee346 --- /dev/null +++ b/Kanban/App/InterchangeCommands.swift @@ -0,0 +1,245 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import os + +// MARK: - Export + +/// **File ▸ Export ▸ {Obsidian Kanban Markdown…, Markdown Outline…, CSV…}** — the frontmost board +/// written out as one foreign document (15-import-export.md ▸ Export is three rows). +/// +/// ### Three rows rather than one row with a format popup +/// +/// A format popup inside a save panel is a control the user has to find before they can answer the +/// question the panel is asking, and it makes the *command* ambiguous in the menu — "Export…" gives no +/// hint that CSV is on offer. Three named rows are three discoverable verbs, each of which opens a panel +/// already pointed at the right extension. It also keeps the titles stable, which matters more here than +/// tidiness: **a menu title is API** (`KanbanApp.menuCommands` — the App Shortcuts remap mechanism keys +/// on it), and a row whose meaning lived in a popup would have one title for three commands. +/// +/// ### The sequence +/// +/// 1. **The flush first** — `AppModel.flushPendingWork(for:)`, `DuplicateBoardCommand`'s own opening +/// step, for its reason: an export serializes the snapshot, and a card window holding unsaved +/// keystrokes would export the card as it was before they were typed. +/// 2. **The save panel**, after the flush, so the document the panel is about to write is the one the +/// user just finished editing. Cancelling it says nothing at all — "the user declined, nothing +/// failed" (Duplicate's own rule). +/// 3. **The write**, then the omissions notice — and only when there are any (`BannerCenter +/// .postExportOmissions`). A lossless export is silent: the file is where the user pointed, and a row +/// saying so would be noise. +/// +/// ### Validation is Share…'s, and for Share…'s reasons +/// +/// **An export is a read.** It never writes a byte into the board's own tree, so the read-only lock does +/// not close it — a board on a read-only volume is exactly the board somebody wants a copy of, which is +/// `PrintCommand`'s "a print is a read" posture one command over. The one carve-out kept is the +/// focused-inline-editor half: an open rename or new-card placeholder holds the one pending change no +/// flush can reach, and exporting mid-rename would write out a title the user is still typing. +struct ExportBoardMenu: View { + + let appModel: AppModel + + @FocusedValue(\.boardStore) private var store + @FocusedValue(\.boardWindowRef) private var ref + + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "interchange") + + var body: some View { + Menu("Export") { + Button("Obsidian Kanban Markdown…") { export(.obsidianKanban) } + Button("Markdown Outline…") { export(.markdownOutline) } + Button("CSV…") { export(.csv) } + } + .disabled(!canExport) + } + + /// The submenu's whole decision, as a pure function of the three facts it turns on — + /// `ShareBoardCommand.isEnabled`'s shape and its reason: a rule reachable only through a menu is a + /// rule nobody tests. + static func isEnabled(hasStore: Bool, hasRef: Bool, isEditingInline: Bool) -> Bool { + hasStore && hasRef && !isEditingInline + } + + private var canExport: Bool { + Self.isEnabled( + hasStore: store != nil, + hasRef: ref != nil, + isEditingInline: store?.isEditingInline == true + ) + } + + private func export(_ format: InterchangeFormat) { + guard canExport, let store, let ref else { return } + let name = AppModel.displayName(of: store) + + Task { @MainActor in + await appModel.flushPendingWork(for: ref) + + // Read *after* the flush: that is the whole point of running one. + let snapshot = store.snapshot + guard let url = Self.chooseDestination(boardTitle: name, format: format) else { return } + + let text = BoardExporter.text( + for: InterchangeBoard.from(snapshot, titled: name), + format: format + ) + do throws(BoardWriteError) { + try BoardExporter.write(text, to: url, boardTitle: name) + store.banners.postExportOmissions(InterchangeOmissions.of(snapshot), format: format) + } catch { + Self.logger.error("export failed: \(error.description, privacy: .public)") + store.banners.post(error) + } + } + } + + /// The save panel. `allowsOtherFileTypes` stays on so a user who wants `.markdown` or `.txt` gets it + /// — the panel's answer is honored verbatim, exactly as Duplicate's is, because second-guessing a + /// name the user typed is how a save panel stops being one. + private static func chooseDestination(boardTitle: String, format: InterchangeFormat) -> URL? { + let panel = NSSavePanel() + panel.nameFieldStringValue = BoardExporter.suggestedFileName(boardTitle: boardTitle, format: format) + panel.canCreateDirectories = true + panel.isExtensionHidden = false + panel.allowsOtherFileTypes = true + panel.prompt = "Export" + panel.message = "Choose where to write the \(format.displayName) file." + if let type = UTType(filenameExtension: format.fileExtension) { + panel.allowedContentTypes = [type] + } + + guard panel.runModal() == .OK, let url = panel.url else { return nil } + return url + } +} + +// MARK: - Import + +/// **File ▸ Import Board…** — a foreign document read into a brand-new board (15-import-export.md +/// ▸ Import is one row). +/// +/// ### One row, and no format picker +/// +/// The format is **detected**, never asked (`InterchangeFormat.detect`): the plugin's frontmatter marker +/// identifies an Obsidian Kanban file outright, an extension answers for the rest, and the fallback is +/// the lenient outline parser that cannot fail. Asking the user to classify their own file would be the +/// app making them do the one part of this it can do reliably — and getting it wrong is cheap here, +/// because an import that lands badly is one folder to delete. +/// +/// ### Two panels, in the order the questions arise +/// +/// Open panel (which file), then save panel (where the board goes), then the board opens through the +/// ordinary open path — so it registers, bookmarks and titles itself like any other. The **save panel's +/// name wins**: 01-storage-format.md § Board naming wants display name and folder name matching, and the +/// parsed title is what seeded the suggestion rather than what overrides the answer +/// (09-templates.md ▸ Instantiation, verbatim). +/// +/// ### Available everywhere, like New Board… +/// +/// An import needs no board in front — it *makes* one — so the row never disables. That is also why it +/// sits beside File ▸ Open… rather than beside Export: the two rows next to each other are the two ways +/// a board arrives from disk, while Export belongs with the board-scoped commands that act on the board +/// already in front. +/// +/// ### Failure is an alert, deliberately +/// +/// `TemplateChooserView`'s own reasoning, one flow over: there is no board window yet, so there is no +/// banner strip to carry a row — and the user is mid-conversation with two modal panels, which is where +/// the answer belongs. A **cancelled** import says nothing at all (the partial is already gone, the +/// duplicate rule verbatim). +/// +/// There is no in-progress row for the same reason there is no banner: nothing on screen owns this +/// operation yet. The materialization still runs off the main actor so a large CSV cannot freeze the +/// app while it lands. +struct ImportBoardCommand: View { + + let appModel: AppModel + + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "interchange") + + var body: some View { + Button("Import Board…") { + runImport() + } + } + + private func runImport() { + guard let sourceURL = Self.chooseSource() else { return } + + Task { @MainActor in + do { + let source = try BoardImporter.read(contentsOf: sourceURL) + Self.logger.info("importing as \(source.format.rawValue, privacy: .public)") + + guard let destination = Self.chooseDestination(for: source) else { return } + let title = TemplateEngine.documentName(of: destination) + + // Detached for `DuplicateBoardCommand`'s two-part reason, minus the spinner: a board's + // worth of folder creates is real I/O, and a detached task inherits no cancellation from + // whatever else the enclosing task is doing. + let board = source.board + let landed = try await Task.detached(priority: .userInitiated) { + try BoardImporter.materialize(board, to: destination, title: title) + }.value + + appModel.openBoard(at: landed) + } catch let failure as BoardImporter.Failure { + switch failure { + case .cancelled: + Self.logger.notice("import cancelled — the partial board was removed") + case let .failed(error): + Self.logger.error("import failed: \(error.description, privacy: .public)") + Self.present(error) + } + } catch { + let write = BoardWriteError( + operation: .importBoard(fileName: sourceURL.lastPathComponent), + path: sourceURL.path, + reason: .io(message: error.localizedDescription) + ) + Self.logger.error("import failed: \(write.description, privacy: .public)") + Self.present(write) + } + } + } + + /// The open panel. **No `allowedContentTypes`**, on purpose: detection is by content first and + /// extension second, so an extension-less export somebody emailed is exactly the file this feature + /// should accept — and a filter that hid it would make the detector's leniency unreachable. + private static func chooseSource() -> URL? { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.prompt = "Import" + panel.message = "Choose a Markdown or CSV file to import as a new board." + + guard panel.runModal() == .OK, let url = panel.url else { return nil } + return url + } + + private static func chooseDestination(for source: BoardImporter.Source) -> URL? { + let panel = NSSavePanel() + panel.nameFieldStringValue = BoardImporter.suggestedFileName(for: source) + panel.canCreateDirectories = true + panel.isExtensionHidden = false + panel.allowsOtherFileTypes = true + panel.prompt = "Create" + panel.message = "Choose where to keep the imported board." + + guard panel.runModal() == .OK, let url = panel.url else { return nil } + return url + } + + /// The alert — `TemplateChooserView.present(_:)`'s, verbatim in shape and voice: the banner's own + /// sentence for the failure, the path underneath it, one button. + private static func present(_ error: BoardWriteError) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = BannerCenter.headline(for: error) + alert.informativeText = error.path + alert.addButton(withTitle: "OK") + alert.runModal() + } +} diff --git a/Kanban/Interchange/BoardExporter.swift b/Kanban/Interchange/BoardExporter.swift new file mode 100644 index 0000000..0a2a0b8 --- /dev/null +++ b/Kanban/Interchange/BoardExporter.swift @@ -0,0 +1,65 @@ +import Foundation + +/// **File ▸ Export ▸ …, minus the menu** — which format writes which text, and the one write that puts +/// it where the save panel said (15-import-export.md ▸ Export is three rows). +/// +/// A thin router by design: every rule about *what* a format looks like belongs to that format's own +/// writer, so this file has nothing to get out of step with. Its one piece of real behaviour is the +/// write, and the reason that lives here rather than at the command is that a `BoardWriteError` is what +/// the banner speaks, and composing one is not a view's job. +public enum BoardExporter { + + /// The document, as text. + public static func text(for board: InterchangeBoard, format: InterchangeFormat) -> String { + switch format { + case .obsidianKanban, .markdownOutline: + MarkdownBoardWriter.text(for: board, flavor: format) + case .csv: + CSVBoardWriter.text(for: board) + } + } + + /// Writes the document to the URL the save panel returned. + /// + /// **Atomic, and that is the whole of the ceremony.** `Data.write(options: .atomic)` stages beside + /// the destination and renames, so a failure part-way leaves the previous file — or no file — + /// rather than a truncated one. It deliberately does **not** go through `BoardWriter.atomicReplace`: + /// that path is instrumented for the board's own tree (`EchoLedger`, the `.gitignore`d temp-name + /// pattern, the write bracket's expectations), and an export lands somewhere the app has no + /// relationship with beyond this one grant. + /// + /// **UTF-8, no BOM** (01-storage-format.md § Encoding and line endings) — the same encoding the app + /// writes everything else in, and the one every consumer of these three formats expects. + /// + /// - Parameter boardTitle: for the failure sentence only; the bytes do not depend on it. + public static func write( + _ text: String, + to url: URL, + boardTitle: String? + ) throws(BoardWriteError) { + do { + try Data(text.utf8).write(to: url, options: .atomic) + } catch { + throw BoardWriteError( + operation: .exportBoard(title: boardTitle), + path: url.path, + reason: .io(message: error.localizedDescription) + ) + } + } + + /// The name the save panel opens with — the board's own, with the format's extension. + /// + /// Path separators and colons are replaced rather than stripped: `:` is what the Finder shows as `/` + /// and `/` is what the filesystem refuses, so a board titled "Q3: ship/slip" suggests + /// "Q3- ship-slip.md" instead of a name the panel would reject. The user can rename it to anything + /// they like — this is a suggestion, and the panel's answer is honored verbatim. + public static func suggestedFileName(boardTitle: String, format: InterchangeFormat) -> String { + var name = boardTitle + .replacingOccurrences(of: "/", with: "-") + .replacingOccurrences(of: ":", with: "-") + .trimmingCharacters(in: .whitespacesAndNewlines) + if name.isEmpty { name = "Board" } + return "\(name).\(format.fileExtension)" + } +} diff --git a/Kanban/Interchange/BoardImporter.swift b/Kanban/Interchange/BoardImporter.swift new file mode 100644 index 0000000..522a1fb --- /dev/null +++ b/Kanban/Interchange/BoardImporter.swift @@ -0,0 +1,229 @@ +import Foundation + +/// **File ▸ Import Board…, minus the menu** — read a foreign file, work out what it is, and materialize +/// a real board folder from it (15-import-export.md ▸ Import is one row). +/// +/// ### An import always creates a fresh board +/// +/// There is no merge-into-the-open-board in v1 (15 ▸ Rulings): the user picks a source file, then a +/// destination, and the result opens through the ordinary open path like any other board. That keeps the +/// whole feature reversible by deleting one folder, and it keeps this file out of the reconciliation +/// business — an import that landed lanes into a live board would need every rule the paste path already +/// has, for a gesture nobody has asked for yet. +/// +/// ### The tree is built by the ordinary Writer, not by this file +/// +/// `BoardWriter.createBoard` / `createLane` / `createCard` / `writeBody` — the single write door +/// (02-architecture.md § Layering), which is what makes an imported board indistinguishable from a +/// hand-built one without this file knowing a single thing about frontmatter. Everything comes along for +/// free: `schema: 1`, the `kind` key, one `Date` per file for `created`/`modified`, lowercase-UUID folder +/// names, the seeded `.gitignore`, and the agent guide. +/// +/// **Ranks are minted by the create path itself.** Each `createCard` appends after its lane's current +/// members (`Ranks.append(toVisible:)`), so cards materialized in parse order land at 1024, 2048, 3072 — +/// exactly the gapped ladder the app would have produced had the user typed them in that order. Document +/// position becomes rank without anything here computing one. +/// +/// ### Atomicity is the template engine's, verbatim +/// +/// Construct-then-clean: the destination is created by this call and **removed by this call on every +/// exit that is not a board** — cancellation and failure alike — "because a half-copied board is pure +/// residue: nothing was there before, so there is no true state for a reload to show" +/// (`TemplateEngine`). The one thing never removed is a destination this call did not create: an +/// existing name is the user's. +public enum BoardImporter { + + /// A parsed file, ready to materialize: what it turned out to be, what it said, and what to call the + /// board if the document did not say. + public struct Source: Sendable, Equatable { + public let format: InterchangeFormat + public let board: InterchangeBoard + /// The source file's base name — the fallback title, and the save panel's suggested name. + public let fallbackTitle: String + + public init(format: InterchangeFormat, board: InterchangeBoard, fallbackTitle: String) { + self.format = format + self.board = board + self.fallbackTitle = fallbackTitle + } + + /// The board's title: the document's own when it named one (a Markdown outline's `#` heading), + /// the file's base name otherwise. Both Obsidian Kanban files and CSVs always take the file + /// name, because neither format has anywhere to put a board title. + public var title: String { + guard let title = board.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty else { + return fallbackTitle + } + return title + } + } + + /// The two ways an import ends without a board — `TemplateEngine.Failure`'s shape, and for its + /// reasons: a cancelled import never happened and says nothing, and everything else is an ordinary + /// failure carrying the banner's own vocabulary. + /// + /// **There is no `.refused`**: this flow's destination came from a save panel, and the panel's grant + /// *is* the sandbox's answer — asking the same question again would be a loop. + public enum Failure: Error, Sendable, Equatable { + case cancelled + case failed(BoardWriteError) + } + + // MARK: - Reading + + /// Reads and parses the chosen file. + /// + /// **UTF-8 or nothing.** All three formats are text formats and the app is a UTF-8 app + /// (01-storage-format.md § Encoding and line endings); a file in some other encoding fails here with + /// a sentence rather than importing as mojibake, which is the same posture `readRawSource` takes for + /// the one other place foreign bytes reach the app as text. A UTF-8 BOM is tolerated and dropped + /// (every reader below normalizes it away). + /// + /// The security-scoped dance is the open panel's: a panel-chosen URL is readable without it, but a + /// URL that arrived any other way may not be, and starting a scope that was never needed costs + /// nothing. + public static func read(contentsOf url: URL) throws(Failure) -> Source { + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + throw .failed(BoardWriteError( + operation: .importBoard(fileName: url.lastPathComponent), + path: url.path, + reason: .unreadable(message: error.localizedDescription) + )) + } + + guard let text = String(data: data, encoding: .utf8) else { + throw .failed(BoardWriteError( + operation: .importBoard(fileName: url.lastPathComponent), + path: url.path, + reason: .unreadable(message: "this file isn't UTF-8 text") + )) + } + + return parse(text: text, fileName: url.lastPathComponent) + } + + /// Detect, then parse — the pure half, and the one the suite exercises. + /// + /// **Nothing here can fail.** Detection always answers, and all three parsers are total: the two + /// Markdown flavours share the lenient outline parser (`MarkdownBoardParser` — "it never fails"), and + /// the CSV parser answers an empty board for input it cannot make rows out of. A file that is not any + /// of these three imports as *something* — usually one lane of cards — which is a result the user can + /// look at and delete, where a refusal would be a dead end. + public static func parse(text: String, fileName: String?) -> Source { + let format = InterchangeFormat.detect(text: text, fileName: fileName) + let board = switch format { + case .obsidianKanban, .markdownOutline: MarkdownBoardParser.parse(text) + case .csv: CSVBoardParser.parse(text) + } + let fallback = (fileName as NSString?)?.deletingPathExtension ?? "" + return Source( + format: format, + board: board, + fallbackTitle: fallback.isEmpty ? "Imported Board" : fallback + ) + } + + /// The save panel's suggested name for the new board — the source's title with the package + /// extension, so an import lands as a `.kanban` document by default (`TemplateEngine + /// .suggestedFileName(for:)`'s rule, one flow over). + public static func suggestedFileName(for source: Source) -> String { + let name = source.title + .replacingOccurrences(of: "/", with: "-") + .replacingOccurrences(of: ":", with: "-") + .trimmingCharacters(in: .whitespacesAndNewlines) + return "\(name.isEmpty ? "Imported Board" : name).kanban" + } + + // MARK: - Materializing + + /// Writes `board` as a real board folder at `destination`, titled `title`, and answers where it + /// landed — the caller opens it through the ordinary open path. + /// + /// `title` is the **user-chosen document name** off the save panel's URL, never the parsed one: + /// 01-storage-format.md § Board naming wants display name and folder name to start out matching, and + /// the parsed title is what seeded the panel's *suggested* name rather than what overrides its + /// answer (09-templates.md ▸ Instantiation's own rule). + /// + /// `isCancelled` is read between items and nowhere else, defaulting to the ambient task's own + /// cancellation — `TemplateEngine.instantiate`'s seam, and the in-progress row's Cancel at the other + /// end of it. A five-thousand-row CSV is real work, and 02-architecture.md's Cancel-on-safe-copies + /// rule is about exactly this shape of it. + @discardableResult + public static func materialize( + _ board: InterchangeBoard, + to destination: URL, + title: String, + isCancelled: () -> Bool = { Task.isCancelled } + ) throws(Failure) -> URL { + let operation = WriteOperation.createBoard + + // **Refused, never clobbered**, and checked before anything is created so the cleanup below can + // never reach a destination this call did not make (`TemplateEngine.instantiate`'s guard, + // verbatim — the save panel's replace prompt grants access, it does not delete). + guard !FileManager.default.fileExists(atPath: destination.path) else { + throw .failed(BoardWriteError( + operation: operation, + path: destination.path, + reason: .io(message: "something already exists here") + )) + } + + if isCancelled() { throw .cancelled } + + do { + try build(board, at: destination, title: title, isCancelled: isCancelled) + } catch { + // Cancelled or failed, the partial goes — the whole of this call's atomicity. + try? FileManager.default.removeItem(at: destination) + throw error + } + + return destination + } + + /// The tree, one ordinary create at a time. + private static func build( + _ board: InterchangeBoard, + at destination: URL, + title: String, + isCancelled: () -> Bool + ) throws(Failure) { + do { + try BoardWriter.createBoard(at: destination, title: title) + + for lane in board.lanes { + if isCancelled() { throw CancellationMarker.stop } + let laneID = try BoardWriter.createLane(inBoard: destination, title: lane.title) + let laneFolder = destination.appendingPathComponent(laneID.rawValue, isDirectory: true) + + for card in lane.cards { + if isCancelled() { throw CancellationMarker.stop } + let cardID = try BoardWriter.createCard(inLane: laneFolder, title: card.title) + guard !card.body.isEmpty else { continue } + let cardFolder = laneFolder.appendingPathComponent(cardID.rawValue, isDirectory: true) + // Two writes per bodied card — the mint, then the body — because the create path + // writes frontmatter only. The alternative (a `createCard` that took a body) would + // widen the single write door for one caller's convenience. + try BoardWriter.writeBody(inItemFolder: cardFolder, body: card.body) + } + } + } catch let error as BoardWriteError { + throw .failed(error) + } catch { + throw .cancelled + } + } + + /// The cancel signal, thrown out of the same `do` the Writer's failures leave through. + /// + /// A marker rather than an early `return`, because the unwinding is the point: every exit from + /// `build` that is not a finished board has to reach `materialize`'s `catch`, which is the one place + /// the half-made destination is removed. Two exits, one cleanup. + private enum CancellationMarker: Error { case stop } +} diff --git a/Kanban/Interchange/BoardInterchange.swift b/Kanban/Interchange/BoardInterchange.swift new file mode 100644 index 0000000..e22d534 --- /dev/null +++ b/Kanban/Interchange/BoardInterchange.swift @@ -0,0 +1,161 @@ +import Foundation + +/// **The format-neutral board** — what every exporter writes from and every importer produces +/// (15-import-export.md). +/// +/// ### Why a value in the middle +/// +/// Three formats times two directions is six converters; routed through one intermediate shape it is +/// three writers and three parsers, none of which knows about the others, and — the part that matters +/// for testing — none of which needs a board on disk, a store, or a window to exercise. It is +/// `PrintSource`'s seam one door over, and for the same reason that type gives: what crosses into the +/// conversion layer should be the smallest thing that can answer every question the formats ask, +/// because anything richer invites a serializer to start making decisions the extraction should have +/// made. +/// +/// ### What it deliberately cannot hold +/// +/// Comments, attachments, colours, icons, widths, collapsed state, ids, `modified-by`, and every +/// reserved key. **None of the v1 formats can carry any of them** (15 ▸ The formats), so a shape that +/// carried them would be a promise three writers would have to break one by one. The export commands +/// account for the two the user would actually miss — comments and attachments — separately, off the +/// snapshot, and say so out loud (`InterchangeOmissions`). +/// +/// ### Order is position +/// +/// Lanes and cards are **arrays in display order**, and that is the whole of the ordering contract in +/// both directions: every format below writes them out in sequence, and document position is what a +/// parse reads back. Nothing here carries a rank — an import mints fresh ones in parse order +/// (`BoardImporter`), which is exactly where `Ranks.append(toVisible:)` would have put them. +public struct InterchangeBoard: Sendable, Equatable { + + /// The board's display name — `AppModel.displayName(of:)`'s answer on the way out, and the + /// document's own `#` heading (when it has one) on the way in. `nil` for an import that found no + /// title, which is the importer's cue to fall back to the source file's name. + public var title: String? + + public var lanes: [InterchangeLane] + + public init(title: String? = nil, lanes: [InterchangeLane] = []) { + self.title = title + self.lanes = lanes + } + + /// Every card in the board, lane by lane — the count the CSV writer's row loop and the tests both + /// want, without either re-deriving the walk. + public var cards: [InterchangeCard] { lanes.flatMap(\.cards) } + + // MARK: Extraction + + /// **A live board, narrowed** — `snapshot.lanes` in display order, each lane's `cards` in display + /// order, exactly as the loader ranked them (`Ranks.sortedForDisplay`). + /// + /// **The trash is excluded by construction rather than by a filter**, which is `PrintSource.board`'s + /// own note: `BoardModel.trash` and `trashedLanes` are sibling containers of `lanes`, not members of + /// it, so a walk of `lanes` cannot reach them. An export is an export of the board; deleted cards + /// are deleted. + public static func from(_ snapshot: BoardModel, titled boardTitle: String) -> InterchangeBoard { + InterchangeBoard( + title: boardTitle, + lanes: snapshot.lanes.map { lane in + InterchangeLane( + title: lane.title.value, + cards: lane.cards.map { card in + InterchangeCard( + title: card.title.value, + body: card.body, + created: card.created.value, + modified: card.modified.value + ) + } + ) + } + ) + } +} + +/// One lane: a heading and its cards, top to bottom. +/// +/// **No body.** A lane's `index.md` body is its description or WIP policy, and none of the three v1 +/// formats has anywhere to put it: a Markdown outline's lane *is* the heading line, and CSV's grain is +/// one row per card. Carrying it here would mean three writers each deciding to drop it. It is listed +/// with the rest of the omissions in 15 rather than accounted for in a banner, because unlike comments +/// and attachments it is a field most boards leave empty. +public struct InterchangeLane: Sendable, Equatable { + + /// The lane's title as written, or `nil` for an untitled lane. **The placeholder is never stored** — + /// "Untitled" is a rendering, never a value (03-board-ui.md § Card face), so an untitled lane + /// exports as an empty heading rather than as a lane somebody named that. + public var title: String? + + public var cards: [InterchangeCard] + + public init(title: String? = nil, cards: [InterchangeCard] = []) { + self.title = title + self.cards = cards + } +} + +/// One card: its title, its Markdown body, and the two stamps CSV has columns for. +public struct InterchangeCard: Sendable, Equatable { + + /// The title as written, `nil` for an untitled card — `InterchangeLane.title`'s rule exactly. + public var title: String? + + /// The card's body, verbatim, with `\n` line endings. Empty for a card that has none. + public var body: String + + /// **Export-only, both of them.** The CSV writer has a column for each; no importer reads either, + /// because an imported board is **born today** — the tree is materialized by the ordinary Writer, + /// whose creation path stamps `created`/`modified` from one fresh `Date` like every other board the + /// app makes (`BoardWriter.createBoard`, and 09-templates.md's instantiation rule read one boundary + /// over: "a new board is born today, not forked"). Backdating an import would claim a provenance the + /// app cannot verify from a CSV cell. + public var created: Date? + public var modified: Date? + + public init(title: String? = nil, body: String = "", created: Date? = nil, modified: Date? = nil) { + self.title = title + self.body = body + self.created = created + self.modified = modified + } +} + +// MARK: - What an export leaves behind + +/// **What the board holds that the exported document cannot** — the two counts an export owes the user +/// a sentence about (15 ▸ Lossy exports say so). +/// +/// Counted off the `BoardModel` rather than off the `InterchangeBoard`, and that is the point: the +/// intermediate shape has already dropped both, so anything derived from it could only ever report +/// zero. `Card.commentCount` is a readdir the snapshot already paid for and `Card.attachments` is a +/// listing it already holds, so this walk costs nothing beyond the addition. +/// +/// **The trash is excluded**, on `InterchangeBoard.from`'s reasoning: a notice counting comments on +/// deleted cards would be reporting content the export was never going to include for a second, +/// unrelated reason. +public struct InterchangeOmissions: Sendable, Equatable { + + public var comments: Int + public var attachments: Int + + public init(comments: Int = 0, attachments: Int = 0) { + self.comments = comments + self.attachments = attachments + } + + /// Nothing was left behind — the export that says nothing at all. + public var isEmpty: Bool { comments == 0 && attachments == 0 } + + public static func of(_ snapshot: BoardModel) -> InterchangeOmissions { + var omissions = InterchangeOmissions() + for lane in snapshot.lanes { + for card in lane.cards { + omissions.comments += card.commentCount + omissions.attachments += card.attachments.count + } + } + return omissions + } +} diff --git a/Kanban/Interchange/CSVBoard.swift b/Kanban/Interchange/CSVBoard.swift new file mode 100644 index 0000000..87bfbaf --- /dev/null +++ b/Kanban/Interchange/CSVBoard.swift @@ -0,0 +1,159 @@ +import Foundation + +/// **The board as a table** — one row per card, the lane repeated down the column +/// (15-import-export.md ▸ The formats ▸ CSV). +/// +/// ```csv +/// lane,title,body,created,modified +/// To Do,Fix login,"The button does nothing +/// on the second click.",2026-08-01T09:14:00Z,2026-08-07T11:02:00Z +/// To Do,Ship the beta,,2026-08-02T10:00:00Z,2026-08-02T10:00:00Z +/// Done,Write the changelog,,2026-07-30T08:30:00Z,2026-08-05T16:45:00Z +/// ``` +/// +/// **Order is row order**, exactly as it is line order in the Markdown flavours: lanes appear in board +/// order, and within a lane its cards appear in card order, so a reader that preserves rows preserves +/// the board. Nothing writes a rank down and nothing reads one back. +/// +/// **The lane is a repeated string, not a key.** Two lanes that happen to share a title merge into one +/// on re-import — the one place the CSV round trip is not faithful, and an inherent property of a flat +/// table rather than a choice made here. Recorded as a limitation in 15. +public enum CSVBoardWriter { + + /// The header row, in the order the columns are written. `created` and `modified` are export-only — + /// no importer reads them (`InterchangeCard.created`). + public static let header = ["lane", "title", "body", "created", "modified"] + + public static func text(for board: InterchangeBoard) -> String { + var records: [[String]] = [header] + for lane in board.lanes { + for card in lane.cards { + records.append([ + lane.title ?? "", + card.title ?? "", + card.body, + stamp(card.created), + stamp(card.modified) + ]) + } + } + return CSVDocument.encode(records) + } + + /// ISO 8601 in UTC — `FrontmatterValue.date`'s own rendering, so a stamp reads in the export exactly + /// as it reads in the file it came from. An absent stamp is an empty cell rather than a zero date. + private static func stamp(_ date: Date?) -> String { + date.map { $0.formatted(.iso8601) } ?? "" + } +} + +/// **A table read as a board** — the CSV importer (15 ▸ Import is one row). +/// +/// ### Header sniffing +/// +/// The first record is a header when **any** of its cells names a column this importer knows. Matching +/// is case-insensitive, trims whitespace, and accepts the small synonym set the spreadsheets people +/// actually arrive with use — Asana and monday.com export `Name` and `Notes`, Trello's CSV calls the +/// lane a `List`, and a hand-kept sheet says `Status` or `Column`. Recognizing them costs a few lines +/// and is the difference between the feature working and the feature needing the user to rename their +/// headers first. +/// +/// Columns this importer does not know are **ignored, not refused** — a sheet with a dozen columns +/// imports its three useful ones rather than failing. +/// +/// ### Headerless files +/// +/// A first record with no recognized name at all is data, and then **column 0 is the title** and nothing +/// else is interpreted. Guessing that column 1 is a body would as easily import a due date or an +/// assignee into the card's prose; the minimum honest reading is the one that cannot be wrong about what +/// a cell means. +/// +/// ### The lane column +/// +/// Lanes are created **in first-appearance order** and rows land in the lane their cell names. A row +/// with an empty lane cell — and every row of a file with no lane column — goes to a single lane called +/// "Imported" (`MarkdownBoardParser.defaultLaneTitle`, deliberately the same word the outline parser +/// falls back to). +public enum CSVBoardParser { + + public static func parse(_ text: String) -> InterchangeBoard { + var records = CSVDocument.decode(text).filter { record in + record.contains { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + } + guard !records.isEmpty else { return InterchangeBoard() } + + let columns: Columns + if let header = headerColumns(records[0]) { + columns = header + records.removeFirst() + } else { + // Headerless: the first column is the title, and nothing else is claimed. + columns = Columns(lane: nil, title: 0, body: nil) + } + + var lanes: [InterchangeLane] = [] + var indexByTitle: [String: Int] = [:] + + for record in records { + let laneTitle = columns.lane.flatMap { cell(record, $0) }? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let key = laneTitle.isEmpty ? MarkdownBoardParser.defaultLaneTitle : laneTitle + + // Line endings inside a quoted cell arrive exactly as the source file spelled them, and a + // board's files are LF (01-storage-format.md § Encoding and line endings) — so the fold + // happens here, on the way in, rather than leaving a CRLF body for the Writer to store. + let card = InterchangeCard( + title: columns.title + .flatMap { cell(record, $0) } + .map(MarkdownBoardParser.normalized) + .flatMap { $0.isEmpty ? nil : $0 }, + body: columns.body + .flatMap { cell(record, $0) } + .map(MarkdownBoardParser.normalized) ?? "" + ) + + if let existing = indexByTitle[key] { + lanes[existing].cards.append(card) + } else { + indexByTitle[key] = lanes.count + lanes.append(InterchangeLane(title: key, cards: [card])) + } + } + + return InterchangeBoard(title: nil, lanes: lanes) + } + + /// Which column holds what. Every one is optional: a file may name a lane and no body, a body and no + /// lane, or — the minimum this importer accepts — nothing but a title. + struct Columns: Equatable { + var lane: Int? + var title: Int? + var body: Int? + } + + /// The header record read as column positions, or `nil` when it names nothing recognizable — which + /// is how a headerless file is detected, since there is no other signal in a CSV that could say so. + static func headerColumns(_ record: [String]) -> Columns? { + var columns = Columns() + for (index, cell) in record.enumerated() { + let name = cell.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if columns.lane == nil, laneNames.contains(name) { columns.lane = index; continue } + if columns.title == nil, titleNames.contains(name) { columns.title = index; continue } + if columns.body == nil, bodyNames.contains(name) { columns.body = index; continue } + } + guard columns.lane != nil || columns.title != nil || columns.body != nil else { return nil } + // A header that names a lane and a body but no title still imports: every row becomes an + // untitled card carrying its body, which is content preserved rather than a refusal. + return columns + } + + private static let laneNames: Set = ["lane", "list", "column", "status", "group", "section", "stage"] + private static let titleNames: Set = ["title", "name", "card", "task", "subject", "summary"] + private static let bodyNames: Set = ["body", "description", "notes", "note", "content", "details"] + + /// A cell by position, `nil` for a short row — a ragged table imports its complete columns rather + /// than trapping on the row that stopped early. + private static func cell(_ record: [String], _ index: Int) -> String? { + index < record.count ? record[index] : nil + } +} diff --git a/Kanban/Interchange/CSVDocument.swift b/Kanban/Interchange/CSVDocument.swift new file mode 100644 index 0000000..c5b8e6a --- /dev/null +++ b/Kanban/Interchange/CSVDocument.swift @@ -0,0 +1,127 @@ +import Foundation + +/// **RFC 4180, both directions** — the delimited-text plumbing under the CSV converter, and nothing +/// about boards at all (15-import-export.md ▸ The formats ▸ CSV). +/// +/// Its own type rather than two private helpers because the two halves have to agree exactly, and the +/// agreement is the only thing worth testing here: a field this writer quotes is a field this reader +/// unquotes, at every one of the four characters that force the issue. +/// +/// ### The writer's rules +/// +/// - A field is quoted when it contains a comma, a double quote, CR or LF — and left bare otherwise, so +/// an ordinary export stays readable in a text editor. +/// - An embedded double quote is doubled (`"` → `""`), which is RFC 4180's only escape. +/// - **Records end with CRLF**, which the RFC requires and which is the one place this app's own +/// LF-everywhere rule (01-storage-format.md § Encoding and line endings) deliberately does not reach: +/// that rule is about the files a *board* is made of, and a CSV is an interchange document written for +/// other people's tools. Every reader in the world takes both; the standard names one. +/// - The text is UTF-8 with **no BOM**. Excel on Windows prefers one and every other consumer is worse +/// off for it; the app's own encoding rule breaks the tie. +/// +/// ### The reader's rules +/// +/// Deliberately more forgiving than the writer, because it is reading somebody else's file: LF, CRLF and +/// bare CR all end a record, a BOM is dropped, a quote appearing mid-field is literal text rather than a +/// syntax error, and a final record with no terminator is still a record. A trailing terminator does not +/// produce a phantom empty record. +public enum CSVDocument { + + // MARK: - Write + + /// Records to text. Nothing here inspects the shape — a ragged table encodes as a ragged table, + /// because the caller's rows are the caller's business. + public static func encode(_ records: [[String]]) -> String { + guard !records.isEmpty else { return "" } + return records + .map { $0.map(field(_:)).joined(separator: ",") } + .joined(separator: "\r\n") + "\r\n" + } + + /// One field, quoted only where RFC 4180 requires it. + static func field(_ value: String) -> String { + guard value.contains(where: { $0 == "," || $0 == "\"" || $0 == "\n" || $0 == "\r" }) else { + return value + } + return "\"\(value.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + // MARK: - Read + + /// Text to records — a single character scan, because a line-based reader cannot see a newline + /// inside a quoted field, and a quoted field holding a card body is the whole reason this format + /// can carry bodies at all. + public static func decode(_ text: String) -> [[String]] { + let characters = Array(stripBOM(text)) + var records: [[String]] = [] + var record: [String] = [] + var field = "" + var inQuotes = false + var index = 0 + + func endField() { + record.append(field) + field = "" + } + + func endRecord() { + endField() + records.append(record) + record = [] + } + + while index < characters.count { + let character = characters[index] + + if inQuotes { + if character == "\"" { + // A doubled quote is one literal quote; a lone one closes the field. + if index + 1 < characters.count, characters[index + 1] == "\"" { + field.append("\"") + index += 2 + } else { + inQuotes = false + index += 1 + } + } else { + field.append(character) + index += 1 + } + continue + } + + switch character { + case "\"": + // A quote that *opens* a field opens a quoted field — the shape the writer produces. + // One appearing later is literal text (`say "hi"` written unquoted by a spreadsheet + // that saw no reason to escape it), which is worth reading rather than refusing. + if field.isEmpty { inQuotes = true } else { field.append(character) } + index += 1 + case ",": + endField() + index += 1 + case "\n", "\r", "\r\n": + // **`"\r\n"` is one `Character`**, not two: Swift's grapheme clustering merges a CR + // immediately followed by an LF, so a scan over `[Character]` never sees the pair as a + // sequence and a lookahead for it would never fire. Listing the cluster as its own case + // is the whole of CRLF handling here — and it is why a bare `"\r"` reaching this switch + // is genuinely a lone carriage return (old Mac line endings), never half of a pair. + endRecord() + index += 1 + default: + field.append(character) + index += 1 + } + } + + // A final record with no terminator still counts; a terminator with nothing after it does not. + if !record.isEmpty || !field.isEmpty { + endRecord() + } + return records + } + + private static func stripBOM(_ text: String) -> String { + text.hasPrefix("\u{FEFF}") ? String(text.dropFirst()) : text + } +} diff --git a/Kanban/Interchange/InterchangeFormat.swift b/Kanban/Interchange/InterchangeFormat.swift new file mode 100644 index 0000000..c2fb5ec --- /dev/null +++ b/Kanban/Interchange/InterchangeFormat.swift @@ -0,0 +1,110 @@ +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 } + } +} diff --git a/Kanban/Interchange/MarkdownBoardParser.swift b/Kanban/Interchange/MarkdownBoardParser.swift new file mode 100644 index 0000000..0647415 --- /dev/null +++ b/Kanban/Interchange/MarkdownBoardParser.swift @@ -0,0 +1,403 @@ +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..