import Foundation /// **The board as a table** — one row per card, the lane repeated down the column /// (15-import-export.md ▸ The formats ▸ CSV). /// /// ```csv /// lane,title,body,created,modified /// To Do,Fix login,"The button does nothing /// on the second click.",2026-08-01T09:14:00Z,2026-08-07T11:02:00Z /// To Do,Ship the beta,,2026-08-02T10:00:00Z,2026-08-02T10:00:00Z /// Done,Write the changelog,,2026-07-30T08:30:00Z,2026-08-05T16:45:00Z /// ``` /// /// **Order is row order**, exactly as it is line order in the Markdown flavours: lanes appear in board /// order, and within a lane its cards appear in card order, so a reader that preserves rows preserves /// the board. Nothing writes a rank down and nothing reads one back. /// /// **The lane is a repeated string, not a key.** Two lanes that happen to share a title merge into one /// on re-import — the one place the CSV round trip is not faithful, and an inherent property of a flat /// table rather than a choice made here. Recorded as a limitation in 15. public enum CSVBoardWriter { /// The header row, in the order the columns are written. `created` and `modified` are export-only — /// no importer reads them (`InterchangeCard.created`). public static let header = ["lane", "title", "body", "created", "modified"] public static func text(for board: InterchangeBoard) -> String { var records: [[String]] = [header] for lane in board.lanes { for card in lane.cards { records.append([ lane.title ?? "", card.title ?? "", card.body, stamp(card.created), stamp(card.modified) ]) } } return CSVDocument.encode(records) } /// ISO 8601 in UTC — `FrontmatterValue.date`'s own rendering, so a stamp reads in the export exactly /// as it reads in the file it came from. An absent stamp is an empty cell rather than a zero date. private static func stamp(_ date: Date?) -> String { date.map { $0.formatted(.iso8601) } ?? "" } } /// **A table read as a board** — the CSV importer (15 ▸ Import is one row). /// /// ### Header sniffing /// /// The first record is a header when **any** of its cells names a column this importer knows. Matching /// is case-insensitive, trims whitespace, and accepts the small synonym set the spreadsheets people /// actually arrive with use — Asana and monday.com export `Name` and `Notes`, Trello's CSV calls the /// lane a `List`, and a hand-kept sheet says `Status` or `Column`. Recognizing them costs a few lines /// and is the difference between the feature working and the feature needing the user to rename their /// headers first. /// /// Columns this importer does not know are **ignored, not refused** — a sheet with a dozen columns /// imports its three useful ones rather than failing. /// /// ### Headerless files /// /// A first record with no recognized name at all is data, and then **column 0 is the title** and nothing /// else is interpreted. Guessing that column 1 is a body would as easily import a due date or an /// assignee into the card's prose; the minimum honest reading is the one that cannot be wrong about what /// a cell means. /// /// ### The lane column /// /// Lanes are created **in first-appearance order** and rows land in the lane their cell names. A row /// with an empty lane cell — and every row of a file with no lane column — goes to a single lane called /// "Imported" (`MarkdownBoardParser.defaultLaneTitle`, deliberately the same word the outline parser /// falls back to). public enum CSVBoardParser { public static func parse(_ text: String) -> InterchangeBoard { var records = CSVDocument.decode(text).filter { record in record.contains { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } guard !records.isEmpty else { return InterchangeBoard() } let columns: Columns if let header = headerColumns(records[0]) { columns = header records.removeFirst() } else { // Headerless: the first column is the title, and nothing else is claimed. columns = Columns(lane: nil, title: 0, body: nil) } var lanes: [InterchangeLane] = [] var indexByTitle: [String: Int] = [:] for record in records { let laneTitle = columns.lane.flatMap { cell(record, $0) }? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let key = laneTitle.isEmpty ? MarkdownBoardParser.defaultLaneTitle : laneTitle // Line endings inside a quoted cell arrive exactly as the source file spelled them, and a // board's files are LF (01-storage-format.md § Encoding and line endings) — so the fold // happens here, on the way in, rather than leaving a CRLF body for the Writer to store. let card = InterchangeCard( title: columns.title .flatMap { cell(record, $0) } .map(MarkdownBoardParser.normalized) .flatMap { $0.isEmpty ? nil : $0 }, body: columns.body .flatMap { cell(record, $0) } .map(MarkdownBoardParser.normalized) ?? "" ) if let existing = indexByTitle[key] { lanes[existing].cards.append(card) } else { indexByTitle[key] = lanes.count lanes.append(InterchangeLane(title: key, cards: [card])) } } return InterchangeBoard(title: nil, lanes: lanes) } /// Which column holds what. Every one is optional: a file may name a lane and no body, a body and no /// lane, or — the minimum this importer accepts — nothing but a title. struct Columns: Equatable { var lane: Int? var title: Int? var body: Int? } /// The header record read as column positions, or `nil` when it names nothing recognizable — which /// is how a headerless file is detected, since there is no other signal in a CSV that could say so. static func headerColumns(_ record: [String]) -> Columns? { var columns = Columns() for (index, cell) in record.enumerated() { let name = cell.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if columns.lane == nil, laneNames.contains(name) { columns.lane = index; continue } if columns.title == nil, titleNames.contains(name) { columns.title = index; continue } if columns.body == nil, bodyNames.contains(name) { columns.body = index; continue } } guard columns.lane != nil || columns.title != nil || columns.body != nil else { return nil } // A header that names a lane and a body but no title still imports: every row becomes an // untitled card carrying its body, which is content preserved rather than a refusal. return columns } private static let laneNames: Set = ["lane", "list", "column", "status", "group", "section", "stage"] private static let titleNames: Set = ["title", "name", "card", "task", "subject", "summary"] private static let bodyNames: Set = ["body", "description", "notes", "note", "content", "details"] /// A cell by position, `nil` for a short row — a ragged table imports its complete columns rather /// than trapping on the row that stopped early. private static func cell(_ record: [String], _ index: Int) -> String? { index < record.count ? record[index] : nil } }