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
230 lines
11 KiB
Swift
230 lines
11 KiB
Swift
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 }
|
|
}
|