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
128 lines
5.4 KiB
Swift
128 lines
5.4 KiB
Swift
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
|
|
}
|
|
}
|