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
246 lines
11 KiB
Swift
246 lines
11 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
import os
|
|
|
|
// MARK: - Export
|
|
|
|
/// **File ▸ Export ▸ {Obsidian Kanban Markdown…, Markdown Outline…, CSV…}** — the frontmost board
|
|
/// written out as one foreign document (15-import-export.md ▸ Export is three rows).
|
|
///
|
|
/// ### Three rows rather than one row with a format popup
|
|
///
|
|
/// A format popup inside a save panel is a control the user has to find before they can answer the
|
|
/// question the panel is asking, and it makes the *command* ambiguous in the menu — "Export…" gives no
|
|
/// hint that CSV is on offer. Three named rows are three discoverable verbs, each of which opens a panel
|
|
/// already pointed at the right extension. It also keeps the titles stable, which matters more here than
|
|
/// tidiness: **a menu title is API** (`KanbanApp.menuCommands` — the App Shortcuts remap mechanism keys
|
|
/// on it), and a row whose meaning lived in a popup would have one title for three commands.
|
|
///
|
|
/// ### The sequence
|
|
///
|
|
/// 1. **The flush first** — `AppModel.flushPendingWork(for:)`, `DuplicateBoardCommand`'s own opening
|
|
/// step, for its reason: an export serializes the snapshot, and a card window holding unsaved
|
|
/// keystrokes would export the card as it was before they were typed.
|
|
/// 2. **The save panel**, after the flush, so the document the panel is about to write is the one the
|
|
/// user just finished editing. Cancelling it says nothing at all — "the user declined, nothing
|
|
/// failed" (Duplicate's own rule).
|
|
/// 3. **The write**, then the omissions notice — and only when there are any (`BannerCenter
|
|
/// .postExportOmissions`). A lossless export is silent: the file is where the user pointed, and a row
|
|
/// saying so would be noise.
|
|
///
|
|
/// ### Validation is Share…'s, and for Share…'s reasons
|
|
///
|
|
/// **An export is a read.** It never writes a byte into the board's own tree, so the read-only lock does
|
|
/// not close it — a board on a read-only volume is exactly the board somebody wants a copy of, which is
|
|
/// `PrintCommand`'s "a print is a read" posture one command over. The one carve-out kept is the
|
|
/// focused-inline-editor half: an open rename or new-card placeholder holds the one pending change no
|
|
/// flush can reach, and exporting mid-rename would write out a title the user is still typing.
|
|
struct ExportBoardMenu: View {
|
|
|
|
let appModel: AppModel
|
|
|
|
@FocusedValue(\.boardStore) private var store
|
|
@FocusedValue(\.boardWindowRef) private var ref
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "interchange")
|
|
|
|
var body: some View {
|
|
Menu("Export") {
|
|
Button("Obsidian Kanban Markdown…") { export(.obsidianKanban) }
|
|
Button("Markdown Outline…") { export(.markdownOutline) }
|
|
Button("CSV…") { export(.csv) }
|
|
}
|
|
.disabled(!canExport)
|
|
}
|
|
|
|
/// The submenu's whole decision, as a pure function of the three facts it turns on —
|
|
/// `ShareBoardCommand.isEnabled`'s shape and its reason: a rule reachable only through a menu is a
|
|
/// rule nobody tests.
|
|
static func isEnabled(hasStore: Bool, hasRef: Bool, isEditingInline: Bool) -> Bool {
|
|
hasStore && hasRef && !isEditingInline
|
|
}
|
|
|
|
private var canExport: Bool {
|
|
Self.isEnabled(
|
|
hasStore: store != nil,
|
|
hasRef: ref != nil,
|
|
isEditingInline: store?.isEditingInline == true
|
|
)
|
|
}
|
|
|
|
private func export(_ format: InterchangeFormat) {
|
|
guard canExport, let store, let ref else { return }
|
|
let name = AppModel.displayName(of: store)
|
|
|
|
Task { @MainActor in
|
|
await appModel.flushPendingWork(for: ref)
|
|
|
|
// Read *after* the flush: that is the whole point of running one.
|
|
let snapshot = store.snapshot
|
|
guard let url = Self.chooseDestination(boardTitle: name, format: format) else { return }
|
|
|
|
let text = BoardExporter.text(
|
|
for: InterchangeBoard.from(snapshot, titled: name),
|
|
format: format
|
|
)
|
|
do throws(BoardWriteError) {
|
|
try BoardExporter.write(text, to: url, boardTitle: name)
|
|
store.banners.postExportOmissions(InterchangeOmissions.of(snapshot), format: format)
|
|
} catch {
|
|
Self.logger.error("export failed: \(error.description, privacy: .public)")
|
|
store.banners.post(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The save panel. `allowsOtherFileTypes` stays on so a user who wants `.markdown` or `.txt` gets it
|
|
/// — the panel's answer is honored verbatim, exactly as Duplicate's is, because second-guessing a
|
|
/// name the user typed is how a save panel stops being one.
|
|
private static func chooseDestination(boardTitle: String, format: InterchangeFormat) -> URL? {
|
|
let panel = NSSavePanel()
|
|
panel.nameFieldStringValue = BoardExporter.suggestedFileName(boardTitle: boardTitle, format: format)
|
|
panel.canCreateDirectories = true
|
|
panel.isExtensionHidden = false
|
|
panel.allowsOtherFileTypes = true
|
|
panel.prompt = "Export"
|
|
panel.message = "Choose where to write the \(format.displayName) file."
|
|
if let type = UTType(filenameExtension: format.fileExtension) {
|
|
panel.allowedContentTypes = [type]
|
|
}
|
|
|
|
guard panel.runModal() == .OK, let url = panel.url else { return nil }
|
|
return url
|
|
}
|
|
}
|
|
|
|
// MARK: - Import
|
|
|
|
/// **File ▸ Import Board…** — a foreign document read into a brand-new board (15-import-export.md
|
|
/// ▸ Import is one row).
|
|
///
|
|
/// ### One row, and no format picker
|
|
///
|
|
/// The format is **detected**, never asked (`InterchangeFormat.detect`): the plugin's frontmatter marker
|
|
/// identifies an Obsidian Kanban file outright, an extension answers for the rest, and the fallback is
|
|
/// the lenient outline parser that cannot fail. Asking the user to classify their own file would be the
|
|
/// app making them do the one part of this it can do reliably — and getting it wrong is cheap here,
|
|
/// because an import that lands badly is one folder to delete.
|
|
///
|
|
/// ### Two panels, in the order the questions arise
|
|
///
|
|
/// Open panel (which file), then save panel (where the board goes), then the board opens through the
|
|
/// ordinary open path — so it registers, bookmarks and titles itself like any other. The **save panel's
|
|
/// name wins**: 01-storage-format.md § Board naming wants display name and folder name matching, and the
|
|
/// parsed title is what seeded the suggestion rather than what overrides the answer
|
|
/// (09-templates.md ▸ Instantiation, verbatim).
|
|
///
|
|
/// ### Available everywhere, like New Board…
|
|
///
|
|
/// An import needs no board in front — it *makes* one — so the row never disables. That is also why it
|
|
/// sits beside File ▸ Open… rather than beside Export: the two rows next to each other are the two ways
|
|
/// a board arrives from disk, while Export belongs with the board-scoped commands that act on the board
|
|
/// already in front.
|
|
///
|
|
/// ### Failure is an alert, deliberately
|
|
///
|
|
/// `TemplateChooserView`'s own reasoning, one flow over: there is no board window yet, so there is no
|
|
/// banner strip to carry a row — and the user is mid-conversation with two modal panels, which is where
|
|
/// the answer belongs. A **cancelled** import says nothing at all (the partial is already gone, the
|
|
/// duplicate rule verbatim).
|
|
///
|
|
/// There is no in-progress row for the same reason there is no banner: nothing on screen owns this
|
|
/// operation yet. The materialization still runs off the main actor so a large CSV cannot freeze the
|
|
/// app while it lands.
|
|
struct ImportBoardCommand: View {
|
|
|
|
let appModel: AppModel
|
|
|
|
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "interchange")
|
|
|
|
var body: some View {
|
|
Button("Import Board…") {
|
|
runImport()
|
|
}
|
|
}
|
|
|
|
private func runImport() {
|
|
guard let sourceURL = Self.chooseSource() else { return }
|
|
|
|
Task { @MainActor in
|
|
do {
|
|
let source = try BoardImporter.read(contentsOf: sourceURL)
|
|
Self.logger.info("importing as \(source.format.rawValue, privacy: .public)")
|
|
|
|
guard let destination = Self.chooseDestination(for: source) else { return }
|
|
let title = TemplateEngine.documentName(of: destination)
|
|
|
|
// Detached for `DuplicateBoardCommand`'s two-part reason, minus the spinner: a board's
|
|
// worth of folder creates is real I/O, and a detached task inherits no cancellation from
|
|
// whatever else the enclosing task is doing.
|
|
let board = source.board
|
|
let landed = try await Task.detached(priority: .userInitiated) {
|
|
try BoardImporter.materialize(board, to: destination, title: title)
|
|
}.value
|
|
|
|
appModel.openBoard(at: landed)
|
|
} catch let failure as BoardImporter.Failure {
|
|
switch failure {
|
|
case .cancelled:
|
|
Self.logger.notice("import cancelled — the partial board was removed")
|
|
case let .failed(error):
|
|
Self.logger.error("import failed: \(error.description, privacy: .public)")
|
|
Self.present(error)
|
|
}
|
|
} catch {
|
|
let write = BoardWriteError(
|
|
operation: .importBoard(fileName: sourceURL.lastPathComponent),
|
|
path: sourceURL.path,
|
|
reason: .io(message: error.localizedDescription)
|
|
)
|
|
Self.logger.error("import failed: \(write.description, privacy: .public)")
|
|
Self.present(write)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The open panel. **No `allowedContentTypes`**, on purpose: detection is by content first and
|
|
/// extension second, so an extension-less export somebody emailed is exactly the file this feature
|
|
/// should accept — and a filter that hid it would make the detector's leniency unreachable.
|
|
private static func chooseSource() -> URL? {
|
|
let panel = NSOpenPanel()
|
|
panel.canChooseFiles = true
|
|
panel.canChooseDirectories = false
|
|
panel.allowsMultipleSelection = false
|
|
panel.prompt = "Import"
|
|
panel.message = "Choose a Markdown or CSV file to import as a new board."
|
|
|
|
guard panel.runModal() == .OK, let url = panel.url else { return nil }
|
|
return url
|
|
}
|
|
|
|
private static func chooseDestination(for source: BoardImporter.Source) -> URL? {
|
|
let panel = NSSavePanel()
|
|
panel.nameFieldStringValue = BoardImporter.suggestedFileName(for: source)
|
|
panel.canCreateDirectories = true
|
|
panel.isExtensionHidden = false
|
|
panel.allowsOtherFileTypes = true
|
|
panel.prompt = "Create"
|
|
panel.message = "Choose where to keep the imported board."
|
|
|
|
guard panel.runModal() == .OK, let url = panel.url else { return nil }
|
|
return url
|
|
}
|
|
|
|
/// The alert — `TemplateChooserView.present(_:)`'s, verbatim in shape and voice: the banner's own
|
|
/// sentence for the failure, the path underneath it, one button.
|
|
private static func present(_ error: BoardWriteError) {
|
|
let alert = NSAlert()
|
|
alert.alertStyle = .warning
|
|
alert.messageText = BannerCenter.headline(for: error)
|
|
alert.informativeText = error.path
|
|
alert.addButton(withTitle: "OK")
|
|
alert.runModal()
|
|
}
|
|
}
|