A board leaves as one file and comes back as one — headings are lanes, rows are cards, position is the order
File ▸ Export ▸ writes the frontmost board as Obsidian Kanban Markdown, a plain Markdown outline, or RFC 4180 CSV; File ▸ Import Board… reads any of the three back into a fresh board, format detected rather than asked. Every format encodes order as document position, so an export writes no ranks and an import mints them in parse order on the ordinary create path. Lossy exports post a warning-tone loss row naming the comments and attachments the destination cannot carry. Convert-once: nothing watches, nothing merges. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -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.
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<String> = ["lane", "list", "column", "status", "group", "section", "stage"]
|
||||
private static let titleNames: Set<String> = ["title", "name", "card", "task", "subject", "summary"]
|
||||
private static let bodyNames: Set<String> = ["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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<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 }
|
||||
}
|
||||
}
|
||||
@@ -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..<end])
|
||||
}
|
||||
|
||||
/// Everything after the frontmatter block, or the whole document when there is none.
|
||||
static func skippingFrontmatter(_ lines: [String]) -> ArraySlice<String> {
|
||||
guard let first = lines.first, isFrontmatterDelimiter(first) else { return lines[...] }
|
||||
guard let end = lines.dropFirst().firstIndex(where: isFrontmatterDelimiter) else { return lines[...] }
|
||||
return lines[(end + 1)...]
|
||||
}
|
||||
|
||||
private static func isFrontmatterDelimiter(_ line: String) -> Bool {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
return trimmed == "---" || trimmed == "..."
|
||||
}
|
||||
|
||||
// MARK: - Line shapes
|
||||
|
||||
/// `##` when the document has any H2, `#` when it only has H1s, `##` when it has no headings at all
|
||||
/// (nothing will match, and every card falls into the default lane — which is the promise).
|
||||
static func laneHeadingLevel(in lines: some Sequence<String>) -> Int {
|
||||
var sawH1 = false
|
||||
for line in lines {
|
||||
guard let heading = heading(of: line) else { continue }
|
||||
if heading.level >= 2 { return 2 }
|
||||
if heading.level == 1 { sawH1 = true }
|
||||
}
|
||||
return sawH1 ? 1 : 2
|
||||
}
|
||||
|
||||
/// An ATX heading at column 0 — `#` through `######`, with a space after the run or nothing at all.
|
||||
/// A run with text jammed against it (`#hashtag`) is not a heading, which is CommonMark's rule and
|
||||
/// also what keeps a tag line from becoming a lane.
|
||||
static func heading(of line: String) -> (level: Int, text: String)? {
|
||||
guard line.hasPrefix("#") else { return nil }
|
||||
let hashes = line.prefix { $0 == "#" }
|
||||
guard hashes.count <= 6 else { return nil }
|
||||
let rest = line.dropFirst(hashes.count)
|
||||
guard rest.isEmpty || rest.first == " " || rest.first == "\t" else { return nil }
|
||||
var text = rest.trimmingCharacters(in: .whitespaces)
|
||||
// A closed ATX heading (`## Done ##`) drops its trailing run — CommonMark's own reading,
|
||||
// including its condition that the run be preceded by whitespace, so a lane genuinely titled
|
||||
// "C#" keeps its name.
|
||||
if text.hasSuffix("#") {
|
||||
let run = text.reversed().prefix { $0 == "#" }.count
|
||||
let before = text.dropLast(run)
|
||||
if before.isEmpty || before.last == " " || before.last == "\t" {
|
||||
text = String(before)
|
||||
}
|
||||
}
|
||||
return (hashes.count, text.trimmingCharacters(in: .whitespaces))
|
||||
}
|
||||
|
||||
/// A list item at column 0, answered as its label with any task marker already stripped. `nil` for
|
||||
/// anything that is not one.
|
||||
static func listItemLabel(of line: String) -> String? {
|
||||
guard let rest = listItemRemainder(of: line) else { return nil }
|
||||
return strippingTaskMarker(rest).trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
|
||||
private static func listItemRemainder(of line: String) -> String? {
|
||||
guard let first = line.first else { return nil }
|
||||
if "-*+".contains(first) {
|
||||
let rest = line.dropFirst()
|
||||
guard rest.isEmpty || rest.first == " " || rest.first == "\t" else { return nil }
|
||||
return String(rest)
|
||||
}
|
||||
guard first.isNumber else { return nil }
|
||||
let digits = line.prefix { $0.isNumber }
|
||||
// At most nine digits is CommonMark's own cap, and it keeps a bare year from opening a list.
|
||||
guard digits.count <= 9 else { return nil }
|
||||
let afterDigits = line.dropFirst(digits.count)
|
||||
guard let delimiter = afterDigits.first, delimiter == "." || delimiter == ")" else { return nil }
|
||||
let rest = afterDigits.dropFirst()
|
||||
guard rest.isEmpty || rest.first == " " || rest.first == "\t" else { return nil }
|
||||
return String(rest)
|
||||
}
|
||||
|
||||
/// `[ ]`, `[x]`, `[X]` and the plugin's own `[X]` variants, removed from the front of a label.
|
||||
/// **Read and discarded** — see this type's own note about why there is nothing to import it into.
|
||||
private static func strippingTaskMarker(_ text: String) -> String {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespaces)
|
||||
guard trimmed.hasPrefix("[") else { return text }
|
||||
let scalars = Array(trimmed)
|
||||
guard scalars.count >= 3, scalars[2] == "]" else { return text }
|
||||
let state = scalars[1]
|
||||
guard state == " " || state == "x" || state == "X" else { return text }
|
||||
return String(trimmed.dropFirst(3))
|
||||
}
|
||||
|
||||
static func isIndented(_ line: String) -> Bool {
|
||||
guard let first = line.first else { return false }
|
||||
return first == " " || first == "\t"
|
||||
}
|
||||
|
||||
/// `---`, `***`, `___` — three or more of one character, spaces allowed between them.
|
||||
static func isThematicBreak(_ line: String) -> Bool {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
guard let first = trimmed.first, "-*_".contains(first) else { return false }
|
||||
let stripped = trimmed.filter { !$0.isWhitespace }
|
||||
return stripped.count >= 3 && stripped.allSatisfy { $0 == first }
|
||||
}
|
||||
|
||||
/// A fence line at column 0 — three or more backticks or tildes. The info string is ignored: this
|
||||
/// only needs to know that a verbatim stretch opened or closed.
|
||||
static func isFenceDelimiter(_ line: String) -> Bool {
|
||||
guard let first = line.first, first == "`" || first == "~" else { return false }
|
||||
return line.prefix { $0 == first }.count >= 3
|
||||
}
|
||||
|
||||
/// Whether the line opens an Obsidian `%%` comment, and whether it also closes it on the same line.
|
||||
private static func commentOpener(_ line: String) -> Bool? {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
guard trimmed.hasPrefix("%%") else { return nil }
|
||||
return trimmed.count > 2 && trimmed.hasSuffix("%%")
|
||||
}
|
||||
|
||||
private static func isCommentDelimiter(_ line: String) -> Bool {
|
||||
line.trimmingCharacters(in: .whitespaces).hasSuffix("%%")
|
||||
}
|
||||
|
||||
/// **Whether a line carries Markdown structure** — an ATX heading or a list item at column 0. The
|
||||
/// format detector's veto (`InterchangeFormat.looksLikeCSV`) asks exactly this and nothing else: one
|
||||
/// such line anywhere in a file is enough to mean the file is an outline, whatever its commas say.
|
||||
static func isMarkdownStructure(_ line: String) -> Bool {
|
||||
heading(of: line) != nil || listItemRemainder(of: line) != nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The walk's state
|
||||
|
||||
/// The parser's running state, extracted so the walk above reads as its own rules rather than as
|
||||
/// bookkeeping — and so "close the open card, then the open lane, then answer" happens in exactly one
|
||||
/// place instead of at every branch that ends one.
|
||||
private struct OutlineAccumulator {
|
||||
|
||||
var boardTitle: String?
|
||||
|
||||
private var lanes: [InterchangeLane] = []
|
||||
private var lane: InterchangeLane?
|
||||
private var cardTitle: String??
|
||||
/// Whether the open card came from a **heading** rather than a bullet — see `absorbsProse`.
|
||||
private var cardIsSection = false
|
||||
private var bodyLines: [String] = []
|
||||
private var indentPrefix: String?
|
||||
private var pendingBlanks = 0
|
||||
|
||||
/// Nothing has been read yet — the window in which a shallow heading is the board's title rather
|
||||
/// than a lane.
|
||||
var isEmpty: Bool { lanes.isEmpty && lane == nil && cardTitle == nil }
|
||||
|
||||
/// **Whether column-0 prose belongs to the open card**, which the two kinds of card answer
|
||||
/// differently — and deliberately so, because Markdown itself does:
|
||||
///
|
||||
/// - A card from a **bullet** takes prose only as CommonMark's *lazy continuation*: no blank line
|
||||
/// between them. A blank line ends the list item, and what follows is its own thing.
|
||||
/// - A card from a **heading** takes everything until the next heading or bullet, blank lines
|
||||
/// included, because that is what a heading's section *is*. A `### Task A` followed by a blank
|
||||
/// line and two paragraphs of notes is one card with a body, and reading the notes as a second
|
||||
/// card would be the parser ignoring the only structure the document has.
|
||||
var absorbsProse: Bool { cardTitle != nil && (cardIsSection || pendingBlanks == 0) }
|
||||
|
||||
mutating func startLane(title: String?) {
|
||||
closeLane()
|
||||
lane = InterchangeLane(title: title)
|
||||
}
|
||||
|
||||
mutating func startCard(title: String?, isSection: Bool) {
|
||||
closeCard()
|
||||
cardTitle = .some(title)
|
||||
cardIsSection = isSection
|
||||
}
|
||||
|
||||
mutating func appendBlank() {
|
||||
// A blank line before any card is structure, not content: it separates a heading from its
|
||||
// items and must not become a leading empty line in the next card's body.
|
||||
guard cardTitle != nil else { return }
|
||||
pendingBlanks += 1
|
||||
}
|
||||
|
||||
/// An indented (or lazily-continued) body line, dedented by the first continuation's own indent.
|
||||
///
|
||||
/// **The first continuation sets the prefix for the whole item**, and a later line that does not
|
||||
/// carry it is stripped of whatever leading whitespace it has. That keeps a body's *relative*
|
||||
/// indentation — a nested list, an indented code block — while never leaving a stray two spaces on
|
||||
/// content the exporter will indent again on the way back out.
|
||||
mutating func appendContinuation(_ line: String) {
|
||||
guard cardTitle != nil else {
|
||||
startCard(title: line.trimmingCharacters(in: .whitespaces), isSection: false)
|
||||
return
|
||||
}
|
||||
if indentPrefix == nil {
|
||||
indentPrefix = String(line.prefix { $0 == " " || $0 == "\t" })
|
||||
}
|
||||
let dedented: String
|
||||
if let prefix = indentPrefix, !prefix.isEmpty, line.hasPrefix(prefix) {
|
||||
dedented = String(line.dropFirst(prefix.count))
|
||||
} else {
|
||||
dedented = String(line.drop { $0 == " " || $0 == "\t" })
|
||||
}
|
||||
flushBlanks()
|
||||
bodyLines.append(dedented)
|
||||
}
|
||||
|
||||
/// A fenced-code line, kept exactly as it arrived: inside a fence, leading whitespace is content.
|
||||
mutating func appendVerbatim(_ line: String) {
|
||||
guard cardTitle != nil else {
|
||||
cardTitle = .some(nil)
|
||||
flushBlanks()
|
||||
bodyLines.append(line)
|
||||
return
|
||||
}
|
||||
flushBlanks()
|
||||
bodyLines.append(line)
|
||||
}
|
||||
|
||||
mutating func closeCard() {
|
||||
guard let title = cardTitle else { return }
|
||||
// Trailing blanks are dropped rather than flushed: they are the separator before whatever comes
|
||||
// next, not the tail of this body.
|
||||
let card = InterchangeCard(title: title, body: bodyLines.joined(separator: "\n"))
|
||||
if lane == nil { lane = InterchangeLane(title: MarkdownBoardParser.defaultLaneTitle) }
|
||||
lane?.cards.append(card)
|
||||
cardTitle = nil
|
||||
cardIsSection = false
|
||||
bodyLines = []
|
||||
indentPrefix = nil
|
||||
pendingBlanks = 0
|
||||
}
|
||||
|
||||
mutating func closeLane() {
|
||||
closeCard()
|
||||
guard let lane else { return }
|
||||
lanes.append(lane)
|
||||
self.lane = nil
|
||||
}
|
||||
|
||||
mutating func finish() -> InterchangeBoard {
|
||||
closeLane()
|
||||
return InterchangeBoard(title: boardTitle, lanes: lanes)
|
||||
}
|
||||
|
||||
/// **Blanks are content only between content.** A blank line before the body's first line is the
|
||||
/// separator between a card's marker (or heading) and what follows, so it is dropped — the mirror of
|
||||
/// `closeCard`'s trailing drop, and together they are why a body never starts or ends with an empty
|
||||
/// line whatever the source file's spacing was.
|
||||
private mutating func flushBlanks() {
|
||||
if !bodyLines.isEmpty {
|
||||
for _ in 0..<pendingBlanks { bodyLines.append("") }
|
||||
}
|
||||
pendingBlanks = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import Foundation
|
||||
|
||||
/// **The board as one Markdown file** — the Obsidian Kanban plugin's flavour and the plain outline,
|
||||
/// which are one serializer because they are one document (15-import-export.md ▸ The formats).
|
||||
///
|
||||
/// ### The shape
|
||||
///
|
||||
/// ```markdown
|
||||
/// ---
|
||||
///
|
||||
/// kanban-plugin: board
|
||||
///
|
||||
/// ---
|
||||
///
|
||||
/// ## To Do
|
||||
///
|
||||
/// - [ ] Fix login
|
||||
/// The button does nothing on the second click.
|
||||
/// - [ ] Ship the beta
|
||||
///
|
||||
/// ## Done
|
||||
///
|
||||
/// - [ ] Write the changelog
|
||||
/// ```
|
||||
///
|
||||
/// Lanes are `##` headings **in lane order**, cards are list items **in card order**, and that is the
|
||||
/// entire ordering contract: document position *is* the rank, so nothing is written down and nothing has
|
||||
/// to be read back. A multi-line body rides as continuation lines indented two spaces under its item,
|
||||
/// which is ordinary Markdown list continuation and what the plugin itself writes.
|
||||
///
|
||||
/// ### The two flavours, and their one difference each
|
||||
///
|
||||
/// - **`.obsidianKanban`** opens with the plugin's frontmatter marker, spelled the way the plugin spells
|
||||
/// it (blank lines inside the delimiters), and writes checkbox items `- [ ]`. It writes **no `#`
|
||||
/// board title**: the plugin has no such concept — a board's name is its file's name — so the save
|
||||
/// panel's chosen filename is the title, and an H1 would show up in Obsidian as a stray card-less
|
||||
/// heading.
|
||||
/// - **`.markdownOutline`** writes the board's title as an `#` H1 and plain `- ` items. No frontmatter
|
||||
/// at all, because the point of this flavour is a document that reads as itself wherever it is pasted.
|
||||
///
|
||||
/// ### Everything is unchecked, on purpose
|
||||
///
|
||||
/// Lanework has no done flag — a card is where it is, and that is the whole model — so **every exported
|
||||
/// item is `- [ ]`**, including cards in a lane called Done. Inferring checked state from a lane title
|
||||
/// would be the exporter inventing data out of a string match, and the inverse (importing `- [x]` as
|
||||
/// something) has nowhere to land. The import side ignores the marker for the same reason. Stated as a
|
||||
/// limitation in 15 rather than hidden here.
|
||||
///
|
||||
/// ### What it does not write
|
||||
///
|
||||
/// The plugin's trailing `%% kanban:settings %%` block (lane widths, per-lane "complete" flags, its own
|
||||
/// display preferences) and its `## Archive` section. Both are the plugin's state about a board rather
|
||||
/// than the board, this app has no equivalent of either, and a settings block synthesized from defaults
|
||||
/// would be this app asserting preferences on the user's behalf in another app's file. A board exported
|
||||
/// from here opens in the plugin with the plugin's own defaults, which is the honest outcome.
|
||||
public enum MarkdownBoardWriter {
|
||||
|
||||
/// The whole document, LF-terminated (01-storage-format.md § Encoding and line endings — the app's
|
||||
/// own rule, and Obsidian's own convention besides).
|
||||
public static func text(for board: InterchangeBoard, flavor: InterchangeFormat) -> String {
|
||||
var lines: [String] = []
|
||||
|
||||
switch flavor {
|
||||
case .obsidianKanban:
|
||||
// The plugin's own spelling, blank lines and all — a file byte-shaped like one the plugin
|
||||
// wrote is a file it will never have an opinion about.
|
||||
lines += ["---", "", "kanban-plugin: board", "", "---", ""]
|
||||
case .markdownOutline:
|
||||
if let title = singleLine(board.title), !title.isEmpty {
|
||||
lines += ["# \(title)", ""]
|
||||
}
|
||||
case .csv:
|
||||
// Not this writer's format. Answering with the outline rather than trapping keeps the
|
||||
// function total for a caller that routed wrong; the export command never does.
|
||||
return text(for: board, flavor: .markdownOutline)
|
||||
}
|
||||
|
||||
for (index, lane) in board.lanes.enumerated() {
|
||||
// A blank line *before* each lane after the first, rather than after each lane's items:
|
||||
// an empty lane then costs one blank line like every other, instead of two.
|
||||
if index > 0 { lines.append("") }
|
||||
lines.append("## \(singleLine(lane.title) ?? "")")
|
||||
guard !lane.cards.isEmpty else { continue }
|
||||
lines.append("")
|
||||
for card in lane.cards {
|
||||
lines += itemLines(for: card, flavor: flavor)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.joined(separator: "\n") + "\n"
|
||||
}
|
||||
|
||||
/// One card: its marker line, then its body indented under it.
|
||||
///
|
||||
/// **An untitled card writes a bare marker** (`- [ ]`, no trailing space) rather than the word
|
||||
/// "Untitled": the placeholder is a rendering and never a value (03-board-ui.md § Card face), and
|
||||
/// writing it would export a card nobody named as one somebody did. The parse of a bare marker is a
|
||||
/// card with a `nil` title, so the pair round-trips.
|
||||
///
|
||||
/// **A blank line inside a body stays blank** — no two-space indent on an empty line, since trailing
|
||||
/// whitespace is litter, and the parser reads a blank line followed by more indented content as part
|
||||
/// of the item it is inside. The cost is that a multi-paragraph card renders as a *loose* list item
|
||||
/// in a Markdown viewer; the alternative — collapsing the blank line — would silently reflow the
|
||||
/// user's prose.
|
||||
static func itemLines(for card: InterchangeCard, flavor: InterchangeFormat) -> [String] {
|
||||
let marker = flavor == .obsidianKanban ? "- [ ]" : "-"
|
||||
let label = singleLine(card.title) ?? ""
|
||||
var lines = [label.isEmpty ? marker : "\(marker) \(label)"]
|
||||
for line in bodyLines(of: card.body) {
|
||||
lines.append(line.isEmpty ? "" : "\(continuationIndent)\(line)")
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
/// Two spaces — the plugin's own continuation indent, and the narrowest one Markdown accepts under a
|
||||
/// `- ` item, which keeps a body's own nested lists readable in the exported file.
|
||||
static let continuationIndent = " "
|
||||
|
||||
/// A body split into lines, with line endings normalized and a trailing newline dropped.
|
||||
///
|
||||
/// The trailing drop matters for the round trip: a body stored as `"text\n"` and one stored as
|
||||
/// `"text"` are the same document, and emitting the first as an item followed by a blank line would
|
||||
/// make the two export differently.
|
||||
private static func bodyLines(of body: String) -> [String] {
|
||||
var normalized = MarkdownBoardParser.normalized(body)
|
||||
while normalized.hasSuffix("\n") { normalized.removeLast() }
|
||||
guard !normalized.isEmpty else { return [] }
|
||||
return normalized.components(separatedBy: "\n")
|
||||
}
|
||||
|
||||
/// A title flattened to one line, because a list item's label and a heading are both one line.
|
||||
///
|
||||
/// Only reachable for a title hand-written as a YAML block scalar — nothing in the app can produce
|
||||
/// one — so this is a totality guard rather than a routine transform, and it is spelled as a
|
||||
/// substitution rather than a truncation so no character is lost.
|
||||
private static func singleLine(_ text: String?) -> String? {
|
||||
guard let text else { return nil }
|
||||
return MarkdownBoardParser.normalized(text)
|
||||
.replacingOccurrences(of: "\n", with: " ")
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
}
|
||||
}
|
||||
@@ -206,6 +206,12 @@ struct KanbanApp: App {
|
||||
}
|
||||
.keyboardShortcut("o", modifiers: .command)
|
||||
|
||||
// **Beside Open…, not beside Export** (15-import-export.md): the two rows next to each
|
||||
// other are the two ways a board arrives from disk, and like Open… this one needs no board
|
||||
// in front — it makes one. Export lives further down with the board-scoped commands that
|
||||
// act on the board already there. No default chord, like Welcome's row.
|
||||
ImportBoardCommand(appModel: appModel)
|
||||
|
||||
OpenRecentMenu(appModel: appModel)
|
||||
|
||||
Divider()
|
||||
@@ -217,6 +223,11 @@ struct KanbanApp: App {
|
||||
DuplicateBoardCommand(appModel: appModel)
|
||||
SaveAsTemplateCommand(appModel: appModel)
|
||||
ShareBoardCommand(appModel: appModel)
|
||||
// **Export ▸ joined 2026-08-09** (15-import-export.md) as the fourth row in a row that
|
||||
// takes the whole board somewhere else — a sibling folder, the templates store, a share
|
||||
// sheet, and now one foreign document. Three named rows rather than one row with a format
|
||||
// popup; see `ExportBoardMenu` for why, and for why the titles matter.
|
||||
ExportBoardMenu(appModel: appModel)
|
||||
RevealInFinderCommand()
|
||||
AddAttachmentCommand()
|
||||
// The attachment row's hero pair, as the menu-bar twins 11-command-nexus.md's
|
||||
|
||||
@@ -659,6 +659,24 @@ public final class BannerCenter {
|
||||
nonisolated static let mixedTrashDragMessage =
|
||||
"Cards and lanes leave the trash separately \u{2014} restore one kind at a time"
|
||||
|
||||
/// **The lossy export's notice** (15-import-export.md ▸ Lossy exports say so): the document landed
|
||||
/// where the user asked, and this is the row naming what none of the three v1 formats can carry.
|
||||
///
|
||||
/// **A loss row, on `postSkippedFolders`' exact reasoning** — the clearest existing member of the
|
||||
/// class, and the same shape of event: the operation succeeded and only the payload the destination
|
||||
/// cannot hold stayed behind. It must be said out loud (an export the user believes is complete is
|
||||
/// the harm), it must not evaporate unread, and it must not rank as an error, because nothing
|
||||
/// failed. A `signpost` would be too quiet — it ranks last and may collapse behind "+N more" — and
|
||||
/// a `oneShot` would be a lie, since it carries a `BoardWriteError` and the write succeeded.
|
||||
///
|
||||
/// **An export that left nothing behind says nothing at all.** A board with no comments and no
|
||||
/// attachments exports losslessly, and a row confirming that would be noise on top of a file the
|
||||
/// user is already looking at in Finder.
|
||||
public func postExportOmissions(_ omissions: InterchangeOmissions, format: InterchangeFormat) {
|
||||
guard let message = Self.exportOmissionsMessage(omissions, format: format) else { return }
|
||||
postLoss(message)
|
||||
}
|
||||
|
||||
/// Removes a dismissable row: a one-shot failure, a loss row, or a signpost.
|
||||
/// **An id that names an in-progress operation is ignored** rather than ending it, because
|
||||
/// "dismiss" and "cancel" are different promises and a row that offers one must never quietly do
|
||||
@@ -923,6 +941,17 @@ public final class BannerCenter {
|
||||
// so the sentence is about the staged copy that never reached the picker, not about
|
||||
// this board's own files.
|
||||
if let title { "Couldn't share '\(title)'" } else { "Couldn't share the board" }
|
||||
case let .exportBoard(title):
|
||||
// The command's own word (File ▸ Export ▸ …), on `.shareBoard`'s reasoning exactly: the
|
||||
// board is untouched by an export that failed to write, so the sentence is about the
|
||||
// document that never landed. It names no format — the user chose one row out of three a
|
||||
// moment ago and does not need telling which.
|
||||
if let title { "Couldn't export '\(title)'" } else { "Couldn't export the board" }
|
||||
case let .importBoard(fileName):
|
||||
// **The file, not a board**: there is no board yet, and naming one would name something
|
||||
// that does not exist. This sentence covers the read and the parse only — once the tree
|
||||
// starts being written, the ordinary create operations speak for themselves.
|
||||
"Couldn't import '\(fileName)'"
|
||||
case let .importAttachment(filename):
|
||||
"Couldn't import '\(filename)'"
|
||||
case .listAttachments:
|
||||
@@ -1255,6 +1284,36 @@ public final class BannerCenter {
|
||||
return "Repaired duplicate id — \(sole(only))"
|
||||
}
|
||||
|
||||
/// The lossy export's line, in the relocation family's voice — the act first, the cause after an
|
||||
/// em dash, plurals folded into their counts.
|
||||
///
|
||||
/// - **Both**: "Exported without 12 comments and 3 attachments — CSV carries neither".
|
||||
/// - **One kind**: "Exported without 12 comments — CSV can't carry them".
|
||||
///
|
||||
/// **The tail names the format**, which is the whole explanation the row owes: nothing is wrong with
|
||||
/// the board and nothing failed — the destination simply has no place to put these — and a sentence
|
||||
/// without it would read as something the app decided to leave out. The format is named rather than
|
||||
/// described because the user picked it by name one dialog ago.
|
||||
///
|
||||
/// The counts carry their own plurality ("1 comment", "12 comments") while the tail stays invariant:
|
||||
/// "them" reads correctly after either count, and one sentence shape is one thing to keep true.
|
||||
///
|
||||
/// `nil` when nothing was left behind — a lossless export is not news.
|
||||
public nonisolated static func exportOmissionsMessage(
|
||||
_ omissions: InterchangeOmissions,
|
||||
format: InterchangeFormat
|
||||
) -> String? {
|
||||
guard !omissions.isEmpty else { return nil }
|
||||
let comments = omissions.comments == 1 ? "1 comment" : "\(omissions.comments) comments"
|
||||
let attachments = omissions.attachments == 1 ? "1 attachment" : "\(omissions.attachments) attachments"
|
||||
|
||||
if omissions.comments > 0, omissions.attachments > 0 {
|
||||
return "Exported without \(comments) and \(attachments) — \(format.displayName) carries neither"
|
||||
}
|
||||
let subject = omissions.comments > 0 ? comments : attachments
|
||||
return "Exported without \(subject) — \(format.displayName) can't carry them"
|
||||
}
|
||||
|
||||
/// A sole migrated item's name: its title in quotes, or the untitled rendering the relocation
|
||||
/// line already uses ("an untitled card" / "an untitled lane" are one phrase here, because the
|
||||
/// clause it sits in already says which level it is).
|
||||
|
||||
@@ -3018,6 +3018,27 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// never happened. `title` is the board's display name.
|
||||
case shareBoard(title: String?)
|
||||
|
||||
/// File ▸ Export ▸ … — the board written out as one foreign document (15-import-export.md;
|
||||
/// `BoardExporter.write`). Its own case beside the three copy-the-whole-board commands above, on
|
||||
/// their standing reasoning: a fourth command produces a fourth kind of artifact, and a banner
|
||||
/// saying the app could not "share" or "duplicate" a board nobody asked to share or duplicate would
|
||||
/// name a gesture that never happened.
|
||||
///
|
||||
/// **It is the one operation in this vocabulary that writes outside a board**, which is also why it
|
||||
/// says nothing about the board's own files: the export failed, the board is exactly as it was.
|
||||
/// `title` is the board's display name.
|
||||
case exportBoard(title: String?)
|
||||
|
||||
/// File ▸ Import Board… — a foreign document read, and the fresh board built from it
|
||||
/// (15-import-export.md; `BoardImporter`). It covers the *read* half only: once the parse succeeds,
|
||||
/// the tree is built by the ordinary create path and its failures speak as `.createBoard`,
|
||||
/// `.createLane` and the rest, which is the honest account of what went wrong.
|
||||
///
|
||||
/// **It carries a filename rather than a title**, on `.importAttachment`'s precedent and for its
|
||||
/// reason: the thing the user picked is a file, they are looking at its name in the panel they just
|
||||
/// dismissed, and the board it was going to become does not exist yet to have a title.
|
||||
case importBoard(fileName: String)
|
||||
|
||||
case importAttachment(filename: String)
|
||||
case listAttachments
|
||||
|
||||
@@ -3231,7 +3252,10 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
// at all, and one with no `schema` is the file the walk just refused.
|
||||
// `.setBoardBackground` joins them on `.mintBoardIndex`'s reasoning: it carries no title
|
||||
// slot, and the board it writes to is the one the user is looking at.
|
||||
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
|
||||
// `.importBoard` joins them on `.importAttachment`'s reasoning: it carries a filename, which is
|
||||
// the name the user is looking at and the only one its banner should say — and there is no
|
||||
// document to enrich from anyway, since the failure it describes is the read of one.
|
||||
case .createBoard, .createLane, .createCard, .importAttachment, .importBoard, .listAttachments,
|
||||
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .seedGitignore,
|
||||
.mintBoardIndex, .stampSchema, .setBoardBackground,
|
||||
.displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment,
|
||||
@@ -3255,6 +3279,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .duplicateBoard: .duplicateBoard(title: title)
|
||||
case .saveAsTemplate: .saveAsTemplate(title: title)
|
||||
case .shareBoard: .shareBoard(title: title)
|
||||
case .exportBoard: .exportBoard(title: title)
|
||||
case .toggleTask: .toggleTask(title: title)
|
||||
case .editBody: .editBody(title: title)
|
||||
case .rawSource: .rawSource(title: title)
|
||||
@@ -3299,6 +3324,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
// plain container reason its board-level twin does.
|
||||
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
|
||||
.style, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .shareBoard, .paste,
|
||||
.exportBoard, .importBoard,
|
||||
.importAttachment,
|
||||
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .seedGitignore,
|
||||
.mintBoardIndex, .stampSchema, .setBoardBackground,
|
||||
@@ -3336,6 +3362,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case let .duplicateBoard(title): Self.phrase("duplicate board", title)
|
||||
case let .saveAsTemplate(title): Self.phrase("save as template", title)
|
||||
case let .shareBoard(title): Self.phrase("share board", title)
|
||||
case let .exportBoard(title): Self.phrase("export board", title)
|
||||
case let .importBoard(fileName): "import board from '\(fileName)'"
|
||||
case let .importAttachment(filename): "import attachment '\(filename)'"
|
||||
case .listAttachments: "list attachments"
|
||||
case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash"
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **The disk half of File ▸ Import Board…** (15-import-export.md) — a parsed `InterchangeBoard`
|
||||
/// materialized into a real `.kanban` folder, read back through the ordinary loader.
|
||||
///
|
||||
/// The pure half (parsers, serializers, detection) is `InterchangeTests.swift`. What this file pins is
|
||||
/// everything that only becomes true once the Writer has run: the rank ladder the create path mints, the
|
||||
/// board conventions an import gets for free, and the construct-then-clean atomicity it borrows from
|
||||
/// `TemplateEngine`.
|
||||
|
||||
@Suite("Board import — materialization")
|
||||
struct BoardImportWriteTests {
|
||||
|
||||
// MARK: Fixture
|
||||
|
||||
/// A scratch directory, removed on the way out.
|
||||
private struct Scratch {
|
||||
let root: URL
|
||||
|
||||
init() throws {
|
||||
root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("BoardImportTests-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
func tearDown() {
|
||||
try? FileManager.default.removeItem(at: root)
|
||||
}
|
||||
|
||||
func url(_ name: String) -> URL {
|
||||
root.appendingPathComponent(name, isDirectory: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func sample() -> InterchangeBoard {
|
||||
InterchangeBoard(title: "Parsed Title", lanes: [
|
||||
InterchangeLane(title: "To Do", cards: [
|
||||
InterchangeCard(title: "Fix login", body: "The button does nothing."),
|
||||
InterchangeCard(title: "Ship the beta"),
|
||||
InterchangeCard(title: nil, body: "an untitled card")
|
||||
]),
|
||||
InterchangeLane(title: "Done")
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
@Test("An imported board loads as an ordinary board, in parse order")
|
||||
func materializedBoardLoads() throws {
|
||||
let scratch = try Scratch()
|
||||
defer { scratch.tearDown() }
|
||||
|
||||
let destination = scratch.url("Imported.kanban")
|
||||
try BoardImporter.materialize(sample(), to: destination, title: "Imported")
|
||||
|
||||
let model = try BoardLoader.load(boardRoot: destination).model
|
||||
#expect(model.schema == BoardLoader.supportedSchema)
|
||||
// **The save panel's name wins over the parsed one** (01-storage-format.md § Board naming).
|
||||
#expect(model.title.value == "Imported")
|
||||
|
||||
#expect(model.lanes.map(\.title.value) == ["To Do", "Done"])
|
||||
#expect(model.lanes[0].cards.map(\.title.value) == ["Fix login", "Ship the beta", nil])
|
||||
#expect(model.lanes[0].cards[0].body == "The button does nothing.")
|
||||
#expect(model.lanes[0].cards[1].body.isEmpty)
|
||||
#expect(model.lanes[0].cards[2].body == "an untitled card")
|
||||
#expect(model.lanes[1].cards.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Ranks are the create path's own 1024 ladder, in parse order")
|
||||
func ranksAreMintedWithGaps() throws {
|
||||
let scratch = try Scratch()
|
||||
defer { scratch.tearDown() }
|
||||
|
||||
let destination = scratch.url("Ranks.kanban")
|
||||
try BoardImporter.materialize(sample(), to: destination, title: "Ranks")
|
||||
|
||||
let model = try BoardLoader.load(boardRoot: destination).model
|
||||
#expect(model.lanes.map(\.order) == [1024, 2048])
|
||||
#expect(model.lanes[0].cards.map(\.order) == [1024, 2048, 3072])
|
||||
}
|
||||
|
||||
@Test("Folder names are fresh lowercase UUIDs at every level")
|
||||
func identitiesAreMinted() throws {
|
||||
let scratch = try Scratch()
|
||||
defer { scratch.tearDown() }
|
||||
|
||||
let destination = scratch.url("Ids.kanban")
|
||||
try BoardImporter.materialize(sample(), to: destination, title: "Ids")
|
||||
|
||||
let model = try BoardLoader.load(boardRoot: destination).model
|
||||
var seen = Set<String>()
|
||||
for lane in model.lanes {
|
||||
#expect(BoardLoader.isUUIDShaped(lane.id.rawValue))
|
||||
#expect(lane.id.rawValue == lane.id.rawValue.lowercased())
|
||||
#expect(seen.insert(lane.id.rawValue).inserted)
|
||||
for card in lane.cards {
|
||||
#expect(BoardLoader.isUUIDShaped(card.id.rawValue))
|
||||
#expect(card.id.rawValue == card.id.rawValue.lowercased())
|
||||
#expect(seen.insert(card.id.rawValue).inserted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test("An imported board is born with the conventions every new board gets")
|
||||
func bornWithTheUsualFurniture() throws {
|
||||
let scratch = try Scratch()
|
||||
defer { scratch.tearDown() }
|
||||
|
||||
let destination = scratch.url("Furniture.kanban")
|
||||
try BoardImporter.materialize(sample(), to: destination, title: "Furniture")
|
||||
|
||||
let manager = FileManager.default
|
||||
#expect(manager.fileExists(atPath: destination.appendingPathComponent(".gitignore").path))
|
||||
#expect(manager.fileExists(atPath: destination.appendingPathComponent("CLAUDE.md").path))
|
||||
#expect(!manager.fileExists(atPath: destination.appendingPathComponent(".trash").path))
|
||||
}
|
||||
|
||||
@Test("An occupied destination is refused, never clobbered")
|
||||
func occupiedDestinationRefuses() throws {
|
||||
let scratch = try Scratch()
|
||||
defer { scratch.tearDown() }
|
||||
|
||||
let destination = scratch.url("Taken.kanban")
|
||||
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
|
||||
let marker = destination.appendingPathComponent("mine.txt")
|
||||
try Data("keep me".utf8).write(to: marker)
|
||||
|
||||
#expect(throws: BoardImporter.Failure.self) {
|
||||
try BoardImporter.materialize(sample(), to: destination, title: "Taken")
|
||||
}
|
||||
// The refusal never touched what was there.
|
||||
#expect(try Data(contentsOf: marker) == Data("keep me".utf8))
|
||||
#expect(!FileManager.default.fileExists(atPath: destination.appendingPathComponent("index.md").path))
|
||||
}
|
||||
|
||||
@Test("A cancelled import leaves nothing behind")
|
||||
func cancelledImportRemovesThePartial() throws {
|
||||
let scratch = try Scratch()
|
||||
defer { scratch.tearDown() }
|
||||
|
||||
let destination = scratch.url("Cancelled.kanban")
|
||||
// False for the pre-flight and the first lane, true from the first card on — so the board
|
||||
// folder and one lane really are on disk when the cancel lands.
|
||||
var calls = 0
|
||||
let isCancelled = { () -> Bool in
|
||||
calls += 1
|
||||
return calls > 2
|
||||
}
|
||||
|
||||
var caught: BoardImporter.Failure?
|
||||
do {
|
||||
try BoardImporter.materialize(sample(), to: destination, title: "Cancelled", isCancelled: isCancelled)
|
||||
} catch {
|
||||
caught = error
|
||||
}
|
||||
#expect(caught == .cancelled)
|
||||
#expect(!FileManager.default.fileExists(atPath: destination.path))
|
||||
}
|
||||
|
||||
@Test("A board with no lanes still materializes as a board")
|
||||
func emptyBoardMaterializes() throws {
|
||||
let scratch = try Scratch()
|
||||
defer { scratch.tearDown() }
|
||||
|
||||
let destination = scratch.url("Empty.kanban")
|
||||
try BoardImporter.materialize(InterchangeBoard(), to: destination, title: "Empty")
|
||||
|
||||
let model = try BoardLoader.load(boardRoot: destination).model
|
||||
#expect(model.lanes.isEmpty)
|
||||
#expect(model.title.value == "Empty")
|
||||
}
|
||||
|
||||
// MARK: Reading
|
||||
|
||||
@Test("A file read end to end lands as the board its bytes describe")
|
||||
func readParsesTheFile() throws {
|
||||
let scratch = try Scratch()
|
||||
defer { scratch.tearDown() }
|
||||
|
||||
let file = scratch.root.appendingPathComponent("Team Board.md")
|
||||
try Data("---\n\nkanban-plugin: board\n\n---\n\n## To Do\n\n- [ ] A\n".utf8).write(to: file)
|
||||
|
||||
let source = try BoardImporter.read(contentsOf: file)
|
||||
#expect(source.format == .obsidianKanban)
|
||||
#expect(source.title == "Team Board")
|
||||
#expect(source.board.lanes.map(\.title) == ["To Do"])
|
||||
}
|
||||
|
||||
@Test("Bytes that are not UTF-8 are refused with a sentence, not imported as mojibake")
|
||||
func nonUTF8IsRefused() throws {
|
||||
let scratch = try Scratch()
|
||||
defer { scratch.tearDown() }
|
||||
|
||||
let file = scratch.root.appendingPathComponent("latin.csv")
|
||||
try Data([0xFF, 0xFE, 0x41, 0x00]).write(to: file)
|
||||
|
||||
var caught: BoardImporter.Failure?
|
||||
do {
|
||||
_ = try BoardImporter.read(contentsOf: file)
|
||||
} catch {
|
||||
caught = error
|
||||
}
|
||||
guard case let .failed(error) = caught else {
|
||||
Issue.record("expected a failure, got \(String(describing: caught))")
|
||||
return
|
||||
}
|
||||
#expect(error.operation == .importBoard(fileName: "latin.csv"))
|
||||
#expect(BannerCenter.headline(for: error).hasPrefix("Couldn't import 'latin.csv'"))
|
||||
}
|
||||
|
||||
@Test("A missing file is a failure, not a crash")
|
||||
func missingFileIsRefused() throws {
|
||||
let scratch = try Scratch()
|
||||
defer { scratch.tearDown() }
|
||||
|
||||
#expect(throws: BoardImporter.Failure.self) {
|
||||
_ = try BoardImporter.read(contentsOf: scratch.root.appendingPathComponent("nope.md"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: The whole trip
|
||||
|
||||
@Test("Export a real board, import it back, and the lanes and cards survive")
|
||||
func exportThenImportThroughDisk() throws {
|
||||
let scratch = try Scratch()
|
||||
defer { scratch.tearDown() }
|
||||
|
||||
// Build a real board the ordinary way, so the export reads a genuine snapshot.
|
||||
let origin = scratch.url("Origin.kanban")
|
||||
try BoardWriter.createBoard(at: origin, title: "Origin")
|
||||
let todo = try BoardWriter.createLane(inBoard: origin, title: "To Do")
|
||||
let done = try BoardWriter.createLane(inBoard: origin, title: "Done")
|
||||
let first = try BoardWriter.createCard(
|
||||
inLane: origin.appendingPathComponent(todo.rawValue, isDirectory: true),
|
||||
title: "Fix login"
|
||||
)
|
||||
try BoardWriter.writeBody(
|
||||
inItemFolder: origin
|
||||
.appendingPathComponent(todo.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(first.rawValue, isDirectory: true),
|
||||
body: "line one\nline two"
|
||||
)
|
||||
_ = try BoardWriter.createCard(
|
||||
inLane: origin.appendingPathComponent(todo.rawValue, isDirectory: true),
|
||||
title: "Ship the beta"
|
||||
)
|
||||
_ = try BoardWriter.createCard(
|
||||
inLane: origin.appendingPathComponent(done.rawValue, isDirectory: true),
|
||||
title: "Write the changelog"
|
||||
)
|
||||
|
||||
let snapshot = try BoardLoader.load(boardRoot: origin).model
|
||||
#expect(snapshot.lanes.map(\.title.value) == ["To Do", "Done"], "the fixture board itself")
|
||||
|
||||
for format in InterchangeFormat.allCases {
|
||||
let exported = BoardExporter.text(
|
||||
for: InterchangeBoard.from(snapshot, titled: "Origin"),
|
||||
format: format
|
||||
)
|
||||
let file = scratch.root.appendingPathComponent("Origin-\(format.rawValue).\(format.fileExtension)")
|
||||
try BoardExporter.write(exported, to: file, boardTitle: "Origin")
|
||||
|
||||
let source = try BoardImporter.read(contentsOf: file)
|
||||
#expect(source.format == format)
|
||||
|
||||
let destination = scratch.url("Back-\(format.rawValue).kanban")
|
||||
try BoardImporter.materialize(source.board, to: destination, title: "Back")
|
||||
let reloaded = try BoardLoader.load(boardRoot: destination).model
|
||||
|
||||
// CSV has no row for an empty lane, and this board has none — every lane here carries
|
||||
// cards, so all three formats must reproduce the whole board.
|
||||
#expect(reloaded.lanes.map(\.title.value) == ["To Do", "Done"], "\(format.rawValue) lanes")
|
||||
#expect(
|
||||
reloaded.lanes.map { $0.cards.map(\.title.value) }
|
||||
== [["Fix login", "Ship the beta"], ["Write the changelog"]],
|
||||
"\(format.rawValue) cards, in order"
|
||||
)
|
||||
#expect(
|
||||
reloaded.lanes.first?.cards.first?.body == "line one\nline two",
|
||||
"\(format.rawValue) body"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("The omissions count reads comments and attachments off the snapshot")
|
||||
func omissionsCounted() throws {
|
||||
let scratch = try Scratch()
|
||||
defer { scratch.tearDown() }
|
||||
|
||||
let root = scratch.url("Counted.kanban")
|
||||
try BoardWriter.createBoard(at: root, title: "Counted")
|
||||
let lane = try BoardWriter.createLane(inBoard: root, title: "L")
|
||||
let laneFolder = root.appendingPathComponent(lane.rawValue, isDirectory: true)
|
||||
let card = try BoardWriter.createCard(inLane: laneFolder, title: "A")
|
||||
let cardFolder = laneFolder.appendingPathComponent(card.rawValue, isDirectory: true)
|
||||
|
||||
#expect(InterchangeOmissions.of(try BoardLoader.load(boardRoot: root).model).isEmpty)
|
||||
|
||||
// Two attachments and one comment, written the way the schema spells them.
|
||||
let attachments = cardFolder.appendingPathComponent("attachments", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: attachments, withIntermediateDirectories: true)
|
||||
try Data("a".utf8).write(to: attachments.appendingPathComponent("one.txt"))
|
||||
try Data("b".utf8).write(to: attachments.appendingPathComponent("two.txt"))
|
||||
|
||||
let comment = cardFolder
|
||||
.appendingPathComponent("comments", isDirectory: true)
|
||||
.appendingPathComponent(UUID().uuidString.lowercased(), isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: comment, withIntermediateDirectories: true)
|
||||
try Data("---\nschema: 1\nkind: comment\n---\nhello\n".utf8)
|
||||
.write(to: comment.appendingPathComponent("index.md"))
|
||||
|
||||
let omissions = InterchangeOmissions.of(try BoardLoader.load(boardRoot: root).model)
|
||||
#expect(omissions == InterchangeOmissions(comments: 1, attachments: 2))
|
||||
#expect(BannerCenter.exportOmissionsMessage(omissions, format: .csv)
|
||||
== "Exported without 1 comment and 2 attachments — CSV carries neither")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,690 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Kanban
|
||||
|
||||
/// **Import/export v1, the pure half** (15-import-export.md) — the three serializers, the three
|
||||
/// parsers, the detector, and the phrasing. Everything here is a function of a value: no board on disk,
|
||||
/// no store, no window, which is exactly what the `InterchangeBoard` seam exists to buy.
|
||||
///
|
||||
/// The disk half — a parsed board materialized into a real `.kanban` folder — is
|
||||
/// `BoardImportWriteTests.swift`.
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// A three-lane board exercising every shape the writers have a rule for: a multi-line body, an empty
|
||||
/// lane, an untitled card, and a body carrying the characters each format has to escape.
|
||||
private func sampleBoard() -> InterchangeBoard {
|
||||
InterchangeBoard(
|
||||
title: "Roadmap",
|
||||
lanes: [
|
||||
InterchangeLane(title: "To Do", cards: [
|
||||
InterchangeCard(title: "Fix login", body: "The button does nothing\non the second click."),
|
||||
InterchangeCard(title: "Ship the beta")
|
||||
]),
|
||||
InterchangeLane(title: "Doing"),
|
||||
InterchangeLane(title: "Done", cards: [
|
||||
InterchangeCard(title: nil, body: "an untitled card"),
|
||||
InterchangeCard(title: "Comma, quote \" and all", body: "line one\n\nline three")
|
||||
])
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/// The comparison every round-trip assertion makes: titles, lanes, cards, order, bodies — everything
|
||||
/// the interchange shape carries **except the two export-only stamps**, which no importer reads
|
||||
/// (`InterchangeCard.created`).
|
||||
private func structure(of board: InterchangeBoard) -> [[String]] {
|
||||
board.lanes.map { lane in
|
||||
[lane.title ?? "\u{0}nil"] + lane.cards.map { "\($0.title ?? "\u{0}nil")\u{1}\($0.body)" }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Markdown export
|
||||
|
||||
@Suite("Markdown export")
|
||||
struct MarkdownBoardWriterTests {
|
||||
|
||||
@Test("The Obsidian flavour writes the plugin's frontmatter, ## lanes and - [ ] items")
|
||||
func obsidianShape() {
|
||||
let text = MarkdownBoardWriter.text(
|
||||
for: InterchangeBoard(title: "Roadmap", lanes: [
|
||||
InterchangeLane(title: "To Do", cards: [InterchangeCard(title: "A", body: "note")]),
|
||||
InterchangeLane(title: "Done", cards: [InterchangeCard(title: "B")])
|
||||
]),
|
||||
flavor: .obsidianKanban
|
||||
)
|
||||
#expect(text == """
|
||||
---
|
||||
|
||||
kanban-plugin: board
|
||||
|
||||
---
|
||||
|
||||
## To Do
|
||||
|
||||
- [ ] A
|
||||
note
|
||||
|
||||
## Done
|
||||
|
||||
- [ ] B
|
||||
|
||||
""")
|
||||
}
|
||||
|
||||
@Test("The plain outline writes an H1 title, no frontmatter, and plain bullets")
|
||||
func outlineShape() {
|
||||
let text = MarkdownBoardWriter.text(
|
||||
for: InterchangeBoard(title: "Roadmap", lanes: [
|
||||
InterchangeLane(title: "To Do", cards: [InterchangeCard(title: "A", body: "note")])
|
||||
]),
|
||||
flavor: .markdownOutline
|
||||
)
|
||||
#expect(text == """
|
||||
# Roadmap
|
||||
|
||||
## To Do
|
||||
|
||||
- A
|
||||
note
|
||||
|
||||
""")
|
||||
}
|
||||
|
||||
@Test("An empty lane costs one blank line, not two")
|
||||
func emptyLaneSpacing() {
|
||||
let text = MarkdownBoardWriter.text(
|
||||
for: InterchangeBoard(lanes: [
|
||||
InterchangeLane(title: "Empty"),
|
||||
InterchangeLane(title: "Next", cards: [InterchangeCard(title: "A")])
|
||||
]),
|
||||
flavor: .markdownOutline
|
||||
)
|
||||
#expect(text == """
|
||||
## Empty
|
||||
|
||||
## Next
|
||||
|
||||
- A
|
||||
|
||||
""")
|
||||
}
|
||||
|
||||
@Test("An untitled card writes a bare marker, never the word Untitled")
|
||||
func untitledCard() {
|
||||
let text = MarkdownBoardWriter.text(
|
||||
for: InterchangeBoard(lanes: [InterchangeLane(title: "L", cards: [InterchangeCard()])]),
|
||||
flavor: .obsidianKanban
|
||||
)
|
||||
#expect(text.contains("\n- [ ]\n"))
|
||||
#expect(!text.localizedCaseInsensitiveContains("untitled"))
|
||||
}
|
||||
|
||||
@Test("A blank line inside a body stays blank rather than gaining an indent")
|
||||
func blankBodyLineCarriesNoIndent() {
|
||||
let text = MarkdownBoardWriter.text(
|
||||
for: InterchangeBoard(lanes: [
|
||||
InterchangeLane(title: "L", cards: [InterchangeCard(title: "A", body: "one\n\nthree")])
|
||||
]),
|
||||
flavor: .markdownOutline
|
||||
)
|
||||
#expect(text.contains("- A\n one\n\n three\n"))
|
||||
}
|
||||
|
||||
@Test("Nothing is ever exported checked, whatever the lane is called")
|
||||
func everythingIsUnchecked() {
|
||||
let text = MarkdownBoardWriter.text(
|
||||
for: InterchangeBoard(lanes: [
|
||||
InterchangeLane(title: "Done", cards: [InterchangeCard(title: "Shipped")])
|
||||
]),
|
||||
flavor: .obsidianKanban
|
||||
)
|
||||
#expect(text.contains("- [ ] Shipped"))
|
||||
#expect(!text.contains("[x]"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Markdown import
|
||||
|
||||
@Suite("Markdown import")
|
||||
struct MarkdownBoardParserTests {
|
||||
|
||||
@Test("H2s are lanes, bullets are cards, indented lines are bodies")
|
||||
func basicOutline() {
|
||||
let board = MarkdownBoardParser.parse("""
|
||||
# Roadmap
|
||||
|
||||
## To Do
|
||||
|
||||
- Fix login
|
||||
The button does nothing.
|
||||
- Ship the beta
|
||||
|
||||
## Done
|
||||
|
||||
- Write the changelog
|
||||
""")
|
||||
#expect(board.title == "Roadmap")
|
||||
#expect(board.lanes.map(\.title) == ["To Do", "Done"])
|
||||
#expect(board.lanes[0].cards.map(\.title) == ["Fix login", "Ship the beta"])
|
||||
#expect(board.lanes[0].cards[0].body == "The button does nothing.")
|
||||
#expect(board.lanes[0].cards[1].body.isEmpty)
|
||||
#expect(board.lanes[1].cards.map(\.title) == ["Write the changelog"])
|
||||
}
|
||||
|
||||
@Test("Task markers are read and discarded, checked or not")
|
||||
func taskMarkersAreDropped() {
|
||||
let board = MarkdownBoardParser.parse("""
|
||||
## L
|
||||
|
||||
- [ ] Open
|
||||
- [x] Closed
|
||||
- [X] Also closed
|
||||
""")
|
||||
#expect(board.lanes[0].cards.map(\.title) == ["Open", "Closed", "Also closed"])
|
||||
}
|
||||
|
||||
@Test("H1s are lanes when the document has no H2s")
|
||||
func h1LanesWhenNoH2s() {
|
||||
let board = MarkdownBoardParser.parse("""
|
||||
# To Do
|
||||
|
||||
- A
|
||||
|
||||
# Done
|
||||
|
||||
- B
|
||||
""")
|
||||
#expect(board.title == nil)
|
||||
#expect(board.lanes.map(\.title) == ["To Do", "Done"])
|
||||
}
|
||||
|
||||
@Test("Deeper headings become cards")
|
||||
func deeperHeadingsAreCards() {
|
||||
let board = MarkdownBoardParser.parse("""
|
||||
## To Do
|
||||
|
||||
### Task A
|
||||
|
||||
some notes
|
||||
|
||||
### Task B
|
||||
""")
|
||||
#expect(board.lanes.count == 1)
|
||||
#expect(board.lanes[0].cards.map(\.title) == ["Task A", "Task B"])
|
||||
#expect(board.lanes[0].cards[0].body == "some notes")
|
||||
}
|
||||
|
||||
@Test("Content with no heading at all lands in one Imported lane")
|
||||
func headlessContentLandsInImported() {
|
||||
let board = MarkdownBoardParser.parse("""
|
||||
- milk
|
||||
- eggs
|
||||
""")
|
||||
#expect(board.lanes.map(\.title) == [MarkdownBoardParser.defaultLaneTitle])
|
||||
#expect(board.lanes[0].cards.map(\.title) == ["milk", "eggs"])
|
||||
}
|
||||
|
||||
@Test("Ordered lists are cards too")
|
||||
func orderedListItems() {
|
||||
let board = MarkdownBoardParser.parse("""
|
||||
## L
|
||||
|
||||
1. First
|
||||
2) Second
|
||||
""")
|
||||
#expect(board.lanes[0].cards.map(\.title) == ["First", "Second"])
|
||||
}
|
||||
|
||||
@Test("Column-0 prose continues the paragraph, or starts a card after a blank line")
|
||||
func lazyContinuationVersusNewCard() {
|
||||
let board = MarkdownBoardParser.parse("""
|
||||
## L
|
||||
|
||||
- Fix login
|
||||
still the same paragraph
|
||||
|
||||
a separate thought
|
||||
""")
|
||||
#expect(board.lanes[0].cards.count == 2)
|
||||
#expect(board.lanes[0].cards[0].title == "Fix login")
|
||||
#expect(board.lanes[0].cards[0].body == "still the same paragraph")
|
||||
#expect(board.lanes[0].cards[1].title == "a separate thought")
|
||||
}
|
||||
|
||||
@Test("The plugin's settings comment and archive separator never become cards")
|
||||
func pluginResidueIsSkipped() {
|
||||
let board = MarkdownBoardParser.parse("""
|
||||
---
|
||||
|
||||
kanban-plugin: board
|
||||
|
||||
---
|
||||
|
||||
## To Do
|
||||
|
||||
- [ ] A
|
||||
|
||||
***
|
||||
|
||||
## Archive
|
||||
|
||||
- [ ] Old
|
||||
|
||||
%% kanban:settings
|
||||
```
|
||||
{"kanban-plugin":"board"}
|
||||
```
|
||||
%%
|
||||
""")
|
||||
#expect(board.lanes.map(\.title) == ["To Do", "Archive"])
|
||||
#expect(board.lanes[0].cards.map(\.title) == ["A"])
|
||||
#expect(board.lanes[1].cards.map(\.title) == ["Old"])
|
||||
}
|
||||
|
||||
@Test("A column-0 fenced code block rides into one card rather than one card per line")
|
||||
func fencedCodeStaysWhole() {
|
||||
let board = MarkdownBoardParser.parse("""
|
||||
## L
|
||||
|
||||
- Snippet
|
||||
```swift
|
||||
let a = 1
|
||||
let b = 2
|
||||
```
|
||||
""")
|
||||
#expect(board.lanes[0].cards.count == 1)
|
||||
#expect(board.lanes[0].cards[0].body == "```swift\nlet a = 1\nlet b = 2\n```")
|
||||
}
|
||||
|
||||
@Test("Nested indentation inside a body keeps its relative shape")
|
||||
func nestedIndentationSurvives() {
|
||||
let board = MarkdownBoardParser.parse("""
|
||||
## L
|
||||
|
||||
- Parent
|
||||
intro
|
||||
deeper
|
||||
""")
|
||||
#expect(board.lanes[0].cards[0].body == "intro\n deeper")
|
||||
}
|
||||
|
||||
@Test("An empty document is an empty board, not an invented lane")
|
||||
func emptyDocument() {
|
||||
#expect(MarkdownBoardParser.parse("").lanes.isEmpty)
|
||||
#expect(MarkdownBoardParser.parse("\n\n \n").lanes.isEmpty)
|
||||
}
|
||||
|
||||
@Test("An untitled heading and an untitled bullet both parse as nil, never as a placeholder")
|
||||
func untitledShapesParseAsNil() {
|
||||
let board = MarkdownBoardParser.parse("""
|
||||
##
|
||||
|
||||
- [ ]
|
||||
""")
|
||||
#expect(board.lanes.count == 1)
|
||||
#expect(board.lanes[0].title == nil)
|
||||
#expect(board.lanes[0].cards.count == 1)
|
||||
#expect(board.lanes[0].cards[0].title == nil)
|
||||
}
|
||||
|
||||
@Test("CRLF input reads exactly as LF input does")
|
||||
func crlfIsNormalized() {
|
||||
let lf = MarkdownBoardParser.parse("## L\n\n- A\n body\n")
|
||||
let crlf = MarkdownBoardParser.parse("## L\r\n\r\n- A\r\n body\r\n")
|
||||
#expect(structure(of: lf) == structure(of: crlf))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CSV plumbing
|
||||
|
||||
@Suite("CSV document")
|
||||
struct CSVDocumentTests {
|
||||
|
||||
@Test("Only the four characters that force quoting get quoted")
|
||||
func quotingRule() {
|
||||
#expect(CSVDocument.field("plain") == "plain")
|
||||
#expect(CSVDocument.field("a,b") == "\"a,b\"")
|
||||
#expect(CSVDocument.field("say \"hi\"") == "\"say \"\"hi\"\"\"")
|
||||
#expect(CSVDocument.field("line\nbreak") == "\"line\nbreak\"")
|
||||
#expect(CSVDocument.field("") == "")
|
||||
}
|
||||
|
||||
@Test("Records end with CRLF, as RFC 4180 requires")
|
||||
func crlfTerminators() {
|
||||
#expect(CSVDocument.encode([["a", "b"], ["c", "d"]]) == "a,b\r\nc,d\r\n")
|
||||
#expect(CSVDocument.encode([]).isEmpty)
|
||||
}
|
||||
|
||||
@Test("Encode then decode is the identity on every awkward field")
|
||||
func roundTrip() {
|
||||
let records = [
|
||||
["lane", "title", "body"],
|
||||
["To Do", "Comma, here", "quote \" and\nnewline"],
|
||||
["", "", ""]
|
||||
]
|
||||
#expect(CSVDocument.decode(CSVDocument.encode(records)) == records)
|
||||
}
|
||||
|
||||
@Test("LF, CRLF and bare CR all terminate a record")
|
||||
func lineEndingTolerance() {
|
||||
#expect(CSVDocument.decode("a,b\nc,d") == [["a", "b"], ["c", "d"]])
|
||||
#expect(CSVDocument.decode("a,b\r\nc,d") == [["a", "b"], ["c", "d"]])
|
||||
#expect(CSVDocument.decode("a,b\rc,d") == [["a", "b"], ["c", "d"]])
|
||||
}
|
||||
|
||||
@Test("A trailing terminator makes no phantom record; a missing one loses nothing")
|
||||
func terminatorEdges() {
|
||||
#expect(CSVDocument.decode("a,b\r\n") == [["a", "b"]])
|
||||
#expect(CSVDocument.decode("a,b") == [["a", "b"]])
|
||||
#expect(CSVDocument.decode("").isEmpty)
|
||||
}
|
||||
|
||||
@Test("A BOM is dropped and a mid-field quote is literal text")
|
||||
func lenientReads() {
|
||||
#expect(CSVDocument.decode("\u{FEFF}a,b") == [["a", "b"]])
|
||||
#expect(CSVDocument.decode("say \"hi\",b") == [["say \"hi\"", "b"]])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CSV board
|
||||
|
||||
@Suite("CSV board")
|
||||
struct CSVBoardTests {
|
||||
|
||||
@Test("The header is the five columns, and the lane repeats per row")
|
||||
func exportShape() {
|
||||
let text = CSVBoardWriter.text(for: InterchangeBoard(lanes: [
|
||||
InterchangeLane(title: "To Do", cards: [
|
||||
InterchangeCard(title: "A"),
|
||||
InterchangeCard(title: "B", body: "note")
|
||||
])
|
||||
]))
|
||||
#expect(text == "lane,title,body,created,modified\r\nTo Do,A,,,\r\nTo Do,B,note,,\r\n")
|
||||
}
|
||||
|
||||
@Test("Stamps export as ISO 8601 UTC, absent ones as empty cells")
|
||||
func stampsExport() {
|
||||
let when = Date(timeIntervalSince1970: 1_754_000_000)
|
||||
let text = CSVBoardWriter.text(for: InterchangeBoard(lanes: [
|
||||
InterchangeLane(title: "L", cards: [InterchangeCard(title: "A", created: when, modified: nil)])
|
||||
]))
|
||||
#expect(text.contains(",\(when.formatted(.iso8601)),\r\n"))
|
||||
}
|
||||
|
||||
@Test("Header sniffing is case-insensitive and takes the common synonyms")
|
||||
func headerSynonyms() {
|
||||
#expect(CSVBoardParser.headerColumns(["Lane", "Title", "Body"])
|
||||
== CSVBoardParser.Columns(lane: 0, title: 1, body: 2))
|
||||
#expect(CSVBoardParser.headerColumns([" LIST ", "Name", "Notes"])
|
||||
== CSVBoardParser.Columns(lane: 0, title: 1, body: 2))
|
||||
#expect(CSVBoardParser.headerColumns(["Assignee", "Due"]) == nil)
|
||||
}
|
||||
|
||||
@Test("Unknown columns are ignored rather than refused")
|
||||
func unknownColumnsIgnored() {
|
||||
let board = CSVBoardParser.parse("""
|
||||
Assignee,Status,Name,Due\r
|
||||
rz,To Do,Fix login,2026-09-01\r
|
||||
""")
|
||||
#expect(board.lanes.map(\.title) == ["To Do"])
|
||||
#expect(board.lanes[0].cards.map(\.title) == ["Fix login"])
|
||||
}
|
||||
|
||||
@Test("A headerless file takes column 0 as the title and claims nothing else")
|
||||
func headerlessTakesFirstColumn() {
|
||||
let board = CSVBoardParser.parse("Fix login,rz\r\nShip the beta,rz\r\n")
|
||||
#expect(board.lanes.map(\.title) == [MarkdownBoardParser.defaultLaneTitle])
|
||||
#expect(board.lanes[0].cards.map(\.title) == ["Fix login", "Ship the beta"])
|
||||
#expect(board.lanes[0].cards.allSatisfy { $0.body.isEmpty })
|
||||
}
|
||||
|
||||
@Test("No lane column, or an empty lane cell, lands in the Imported lane")
|
||||
func missingLaneFallsBack() {
|
||||
let noColumn = CSVBoardParser.parse("title\r\nA\r\nB\r\n")
|
||||
#expect(noColumn.lanes.map(\.title) == [MarkdownBoardParser.defaultLaneTitle])
|
||||
|
||||
let emptyCell = CSVBoardParser.parse("lane,title\r\n,A\r\nTo Do,B\r\n")
|
||||
#expect(emptyCell.lanes.map(\.title) == [MarkdownBoardParser.defaultLaneTitle, "To Do"])
|
||||
}
|
||||
|
||||
@Test("Lanes appear in first-appearance order and rows join the lane they name")
|
||||
func laneOrderIsFirstAppearance() {
|
||||
let board = CSVBoardParser.parse("""
|
||||
lane,title\r
|
||||
Done,A\r
|
||||
To Do,B\r
|
||||
Done,C\r
|
||||
""")
|
||||
#expect(board.lanes.map(\.title) == ["Done", "To Do"])
|
||||
#expect(board.lanes[0].cards.map(\.title) == ["A", "C"])
|
||||
#expect(board.lanes[1].cards.map(\.title) == ["B"])
|
||||
}
|
||||
|
||||
@Test("A multi-line quoted body survives the trip through the table")
|
||||
func multilineBody() {
|
||||
let board = CSVBoardParser.parse("lane,title,body\r\nL,A,\"one\ntwo\"\r\n")
|
||||
#expect(board.lanes[0].cards[0].body == "one\ntwo")
|
||||
}
|
||||
|
||||
@Test("A CRLF inside a quoted body folds to LF, because a board's files are LF")
|
||||
func quotedCRLFFolds() {
|
||||
let board = CSVBoardParser.parse("lane,title,body\r\nL,A,\"one\r\ntwo\"\r\n")
|
||||
#expect(board.lanes.count == 1)
|
||||
#expect(board.lanes[0].cards[0].body == "one\ntwo")
|
||||
}
|
||||
|
||||
@Test("A short row imports its complete columns rather than trapping")
|
||||
func raggedRow() {
|
||||
let board = CSVBoardParser.parse("lane,title,body\r\nL,A\r\n")
|
||||
#expect(board.lanes[0].cards.map(\.title) == ["A"])
|
||||
#expect(board.lanes[0].cards[0].body.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Wholly empty rows are dropped, and an empty file is an empty board")
|
||||
func emptyInput() {
|
||||
#expect(CSVBoardParser.parse("").lanes.isEmpty)
|
||||
#expect(CSVBoardParser.parse("lane,title\r\n,\r\n").lanes.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Stamps are never read back — an imported board is born today")
|
||||
func stampsAreNotImported() {
|
||||
let board = CSVBoardParser.parse("lane,title,body,created,modified\r\nL,A,,2020-01-01T00:00:00Z,2020-01-01T00:00:00Z\r\n")
|
||||
#expect(board.lanes[0].cards[0].created == nil)
|
||||
#expect(board.lanes[0].cards[0].modified == nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Round trips
|
||||
|
||||
@Suite("Round trips")
|
||||
struct InterchangeRoundTripTests {
|
||||
|
||||
@Test("Obsidian Kanban: export then import preserves lanes, cards, order and bodies")
|
||||
func obsidianRoundTrip() {
|
||||
let board = sampleBoard()
|
||||
let text = BoardExporter.text(for: board, format: .obsidianKanban)
|
||||
let back = MarkdownBoardParser.parse(text)
|
||||
#expect(structure(of: back) == structure(of: board))
|
||||
}
|
||||
|
||||
@Test("Markdown outline: the same, plus the board title")
|
||||
func outlineRoundTrip() {
|
||||
let board = sampleBoard()
|
||||
let text = BoardExporter.text(for: board, format: .markdownOutline)
|
||||
let back = MarkdownBoardParser.parse(text)
|
||||
#expect(back.title == board.title)
|
||||
#expect(structure(of: back) == structure(of: board))
|
||||
}
|
||||
|
||||
@Test("CSV: the same, with untitled lanes landing in Imported")
|
||||
func csvRoundTrip() {
|
||||
// The one shape CSV cannot carry back is an untitled lane — it writes as an empty cell, which
|
||||
// reads as the fallback lane. The rest round-trips exactly, empty lane included… except that a
|
||||
// lane with no rows has nothing to write at all, which is the format's own grain.
|
||||
let board = InterchangeBoard(title: "Roadmap", lanes: [
|
||||
InterchangeLane(title: "To Do", cards: [
|
||||
InterchangeCard(title: "Fix login", body: "The button does nothing\non the second click."),
|
||||
InterchangeCard(title: "Ship the beta")
|
||||
]),
|
||||
InterchangeLane(title: "Done", cards: [
|
||||
InterchangeCard(title: "Comma, quote \" and all", body: "line one\n\nline three")
|
||||
])
|
||||
])
|
||||
let text = BoardExporter.text(for: board, format: .csv)
|
||||
let back = CSVBoardParser.parse(text)
|
||||
#expect(structure(of: back) == structure(of: board))
|
||||
}
|
||||
|
||||
@Test("An exported board detects as the format it was exported in")
|
||||
func exportsDetectAsThemselves() {
|
||||
let board = sampleBoard()
|
||||
for format in InterchangeFormat.allCases {
|
||||
let text = BoardExporter.text(for: board, format: format)
|
||||
let detected = InterchangeFormat.detect(text: text, fileName: "Roadmap.\(format.fileExtension)")
|
||||
#expect(detected == format, "\(format.rawValue) round-trips through detection")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("A second export of an imported document is byte-identical to the first")
|
||||
func exportIsStableUnderReimport() {
|
||||
let board = sampleBoard()
|
||||
for format in [InterchangeFormat.obsidianKanban, .markdownOutline] {
|
||||
let first = BoardExporter.text(for: board, format: format)
|
||||
let reparsed = MarkdownBoardParser.parse(first)
|
||||
// The Obsidian flavour drops the board title by design, so the second pass is compared
|
||||
// against a board carrying whatever the first pass could actually say.
|
||||
let second = BoardExporter.text(for: reparsed, format: format)
|
||||
#expect(first == second, "\(format.rawValue) is a fixed point")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Detection
|
||||
|
||||
@Suite("Format detection")
|
||||
struct InterchangeFormatDetectionTests {
|
||||
|
||||
@Test("The plugin's frontmatter marker is conclusive, whatever the file is called")
|
||||
func kanbanPluginMarkerWins() {
|
||||
let text = "---\n\nkanban-plugin: board\n\n---\n\n## L\n\n- [ ] A\n"
|
||||
#expect(InterchangeFormat.detect(text: text, fileName: "board.md") == .obsidianKanban)
|
||||
#expect(InterchangeFormat.detect(text: text, fileName: nil) == .obsidianKanban)
|
||||
#expect(InterchangeFormat.detect(text: text, fileName: "board.csv") == .obsidianKanban)
|
||||
}
|
||||
|
||||
@Test("Frontmatter without the marker is an ordinary outline")
|
||||
func otherFrontmatterIsOutline() {
|
||||
let text = "---\ntitle: Notes\n---\n\n## L\n\n- A\n"
|
||||
#expect(InterchangeFormat.detect(text: text, fileName: "notes.md") == .markdownOutline)
|
||||
}
|
||||
|
||||
@Test("A .csv extension outranks the shape sniff")
|
||||
func csvExtensionWins() {
|
||||
#expect(InterchangeFormat.detect(text: "title\nA\nB\n", fileName: "tasks.csv") == .csv)
|
||||
}
|
||||
|
||||
@Test("A Markdown extension keeps a comma-heavy document out of the table reader")
|
||||
func markdownExtensionWins() {
|
||||
let prose = "One, two, three, four\nFive, six, seven, eight\n"
|
||||
#expect(InterchangeFormat.detect(text: prose, fileName: "notes.md") == .markdownOutline)
|
||||
#expect(InterchangeFormat.detect(text: prose, fileName: "notes.txt") == .markdownOutline)
|
||||
}
|
||||
|
||||
@Test("An extension-less table is sniffed as CSV; an extension-less outline is not")
|
||||
func shapeSniff() {
|
||||
#expect(InterchangeFormat.detect(text: "lane,title\nTo Do,A\n", fileName: nil) == .csv)
|
||||
#expect(InterchangeFormat.detect(text: "## L\n\n- A\n", fileName: "board") == .markdownOutline)
|
||||
// One Markdown structural line vetoes the sniff outright, however comma-heavy the rest is.
|
||||
#expect(InterchangeFormat.detect(text: "a,b\n- item\nc,d\n", fileName: nil) == .markdownOutline)
|
||||
// A single column, a single record, and an empty file all fall through to the lenient parser.
|
||||
#expect(InterchangeFormat.detect(text: "just words\n", fileName: nil) == .markdownOutline)
|
||||
#expect(InterchangeFormat.detect(text: "a,b\n", fileName: nil) == .markdownOutline)
|
||||
#expect(InterchangeFormat.detect(text: "", fileName: nil) == .markdownOutline)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Parsing entry point
|
||||
|
||||
@Suite("Import parse")
|
||||
struct BoardImporterParseTests {
|
||||
|
||||
@Test("The board title is the document's when it has one, the file's name otherwise")
|
||||
func titleFallback() {
|
||||
let outline = BoardImporter.parse(text: "# Real Title\n\n## L\n\n- A\n", fileName: "whatever.md")
|
||||
#expect(outline.title == "Real Title")
|
||||
|
||||
let obsidian = BoardImporter.parse(
|
||||
text: "---\n\nkanban-plugin: board\n\n---\n\n## L\n\n- [ ] A\n",
|
||||
fileName: "Team Board.md"
|
||||
)
|
||||
#expect(obsidian.title == "Team Board")
|
||||
|
||||
let csv = BoardImporter.parse(text: "lane,title\r\nL,A\r\n", fileName: "export.csv")
|
||||
#expect(csv.title == "export")
|
||||
|
||||
let nameless = BoardImporter.parse(text: "- A\n", fileName: nil)
|
||||
#expect(nameless.title == "Imported Board")
|
||||
}
|
||||
|
||||
@Test("The save panel's suggestion is the title with the package extension")
|
||||
func suggestedName() {
|
||||
let source = BoardImporter.parse(text: "# Q3: ship/slip\n\n## L\n\n- A\n", fileName: "x.md")
|
||||
#expect(BoardImporter.suggestedFileName(for: source) == "Q3- ship-slip.kanban")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Omissions
|
||||
|
||||
@Suite("Export omissions")
|
||||
struct InterchangeOmissionsTests {
|
||||
|
||||
@Test("A lossless export says nothing at all")
|
||||
func silentWhenNothingIsLost() {
|
||||
#expect(InterchangeOmissions().isEmpty)
|
||||
#expect(BannerCenter.exportOmissionsMessage(InterchangeOmissions(), format: .csv) == nil)
|
||||
}
|
||||
|
||||
@Test("One kind names it and the format; both kinds fold into 'neither'")
|
||||
func phrasing() {
|
||||
#expect(BannerCenter.exportOmissionsMessage(
|
||||
InterchangeOmissions(comments: 12, attachments: 0), format: .csv
|
||||
) == "Exported without 12 comments — CSV can't carry them")
|
||||
|
||||
#expect(BannerCenter.exportOmissionsMessage(
|
||||
InterchangeOmissions(comments: 0, attachments: 1), format: .markdownOutline
|
||||
) == "Exported without 1 attachment — Markdown outline can't carry them")
|
||||
|
||||
#expect(BannerCenter.exportOmissionsMessage(
|
||||
InterchangeOmissions(comments: 1, attachments: 3), format: .obsidianKanban
|
||||
) == "Exported without 1 comment and 3 attachments — Obsidian Kanban Markdown carries neither")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Export file naming
|
||||
|
||||
@Suite("Export naming")
|
||||
struct BoardExporterNamingTests {
|
||||
|
||||
@Test("The suggested name is the board's, sanitized, with the format's extension")
|
||||
func suggestedFileName() {
|
||||
#expect(BoardExporter.suggestedFileName(boardTitle: "Roadmap", format: .csv) == "Roadmap.csv")
|
||||
#expect(BoardExporter.suggestedFileName(boardTitle: "Roadmap", format: .obsidianKanban) == "Roadmap.md")
|
||||
#expect(BoardExporter.suggestedFileName(boardTitle: "Q3: ship/slip", format: .markdownOutline) == "Q3- ship-slip.md")
|
||||
#expect(BoardExporter.suggestedFileName(boardTitle: " ", format: .csv) == "Board.csv")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Menu validation
|
||||
|
||||
@Suite("Export menu validation")
|
||||
struct ExportMenuValidationTests {
|
||||
|
||||
@Test("Export needs a board window and no open inline editor — Share…'s own rule")
|
||||
func enablement() {
|
||||
#expect(ExportBoardMenu.isEnabled(hasStore: true, hasRef: true, isEditingInline: false))
|
||||
#expect(!ExportBoardMenu.isEnabled(hasStore: false, hasRef: true, isEditingInline: false))
|
||||
#expect(!ExportBoardMenu.isEnabled(hasStore: true, hasRef: false, isEditingInline: false))
|
||||
#expect(!ExportBoardMenu.isEnabled(hasStore: true, hasRef: true, isEditingInline: true))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user