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() } }