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