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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user