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
160 lines
7.5 KiB
Swift
160 lines
7.5 KiB
Swift
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
|
|
}
|
|
}
|