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
+59
View File
@@ -659,6 +659,24 @@ public final class BannerCenter {
nonisolated static let mixedTrashDragMessage =
"Cards and lanes leave the trash separately \u{2014} restore one kind at a time"
/// **The lossy export's notice** (15-import-export.md Lossy exports say so): the document landed
/// where the user asked, and this is the row naming what none of the three v1 formats can carry.
///
/// **A loss row, on `postSkippedFolders`' exact reasoning** the clearest existing member of the
/// class, and the same shape of event: the operation succeeded and only the payload the destination
/// cannot hold stayed behind. It must be said out loud (an export the user believes is complete is
/// the harm), it must not evaporate unread, and it must not rank as an error, because nothing
/// failed. A `signpost` would be too quiet it ranks last and may collapse behind "+N more" and
/// a `oneShot` would be a lie, since it carries a `BoardWriteError` and the write succeeded.
///
/// **An export that left nothing behind says nothing at all.** A board with no comments and no
/// attachments exports losslessly, and a row confirming that would be noise on top of a file the
/// user is already looking at in Finder.
public func postExportOmissions(_ omissions: InterchangeOmissions, format: InterchangeFormat) {
guard let message = Self.exportOmissionsMessage(omissions, format: format) else { return }
postLoss(message)
}
/// Removes a dismissable row: a one-shot failure, a loss row, or a signpost.
/// **An id that names an in-progress operation is ignored** rather than ending it, because
/// "dismiss" and "cancel" are different promises and a row that offers one must never quietly do
@@ -923,6 +941,17 @@ public final class BannerCenter {
// so the sentence is about the staged copy that never reached the picker, not about
// this board's own files.
if let title { "Couldn't share '\(title)'" } else { "Couldn't share the board" }
case let .exportBoard(title):
// The command's own word (File Export ), on `.shareBoard`'s reasoning exactly: the
// board is untouched by an export that failed to write, so the sentence is about the
// document that never landed. It names no format the user chose one row out of three a
// moment ago and does not need telling which.
if let title { "Couldn't export '\(title)'" } else { "Couldn't export the board" }
case let .importBoard(fileName):
// **The file, not a board**: there is no board yet, and naming one would name something
// that does not exist. This sentence covers the read and the parse only once the tree
// starts being written, the ordinary create operations speak for themselves.
"Couldn't import '\(fileName)'"
case let .importAttachment(filename):
"Couldn't import '\(filename)'"
case .listAttachments:
@@ -1255,6 +1284,36 @@ public final class BannerCenter {
return "Repaired duplicate id — \(sole(only))"
}
/// The lossy export's line, in the relocation family's voice the act first, the cause after an
/// em dash, plurals folded into their counts.
///
/// - **Both**: "Exported without 12 comments and 3 attachments CSV carries neither".
/// - **One kind**: "Exported without 12 comments CSV can't carry them".
///
/// **The tail names the format**, which is the whole explanation the row owes: nothing is wrong with
/// the board and nothing failed the destination simply has no place to put these and a sentence
/// without it would read as something the app decided to leave out. The format is named rather than
/// described because the user picked it by name one dialog ago.
///
/// The counts carry their own plurality ("1 comment", "12 comments") while the tail stays invariant:
/// "them" reads correctly after either count, and one sentence shape is one thing to keep true.
///
/// `nil` when nothing was left behind a lossless export is not news.
public nonisolated static func exportOmissionsMessage(
_ omissions: InterchangeOmissions,
format: InterchangeFormat
) -> String? {
guard !omissions.isEmpty else { return nil }
let comments = omissions.comments == 1 ? "1 comment" : "\(omissions.comments) comments"
let attachments = omissions.attachments == 1 ? "1 attachment" : "\(omissions.attachments) attachments"
if omissions.comments > 0, omissions.attachments > 0 {
return "Exported without \(comments) and \(attachments)\(format.displayName) carries neither"
}
let subject = omissions.comments > 0 ? comments : attachments
return "Exported without \(subject)\(format.displayName) can't carry them"
}
/// A sole migrated item's name: its title in quotes, or the untitled rendering the relocation
/// line already uses ("an untitled card" / "an untitled lane" are one phrase here, because the
/// clause it sits in already says which level it is).