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:
2026-08-09 08:38:06 -04:00
parent 242d013d8c
commit b0c134a896
15 changed files with 2851 additions and 1 deletions
+127
View File
@@ -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
}
}