|
|
|
@@ -61,7 +61,24 @@ extension FocusedValues {
|
|
|
|
|
/// **File ▸ Print… (⌘P)** — 11-command-nexus.md's Print row, and the retirement of that document's "No
|
|
|
|
|
/// Print story in v1 (⌘P unused)" line.
|
|
|
|
|
///
|
|
|
|
|
/// ### Two scopes, one row
|
|
|
|
|
/// ### The reported bug's actual cause, and what this file adds beside it
|
|
|
|
|
///
|
|
|
|
|
/// The owner's repro (2026-08-09) is the board window frontmost, File ▸ Print… **enabled**, chosen from the
|
|
|
|
|
/// menu — and the system alert appears anyway: "This application does not support printing. Please contact
|
|
|
|
|
/// the application's developer." That rules out this row ever being unclaimed or disabled in the repro; the
|
|
|
|
|
/// row runs, `PrintCoordinator` runs, and the alert comes out of the print operation itself. The actual cause
|
|
|
|
|
/// is `Kanban.entitlements`: the app is sandboxed (`com.apple.security.app-sandbox`) and had no
|
|
|
|
|
/// `com.apple.security.print` key, and a sandboxed app's `NSPrintOperation` is denied by the sandbox with
|
|
|
|
|
/// this exact wording — the fix is that one entitlement key, not anything in this file.
|
|
|
|
|
///
|
|
|
|
|
/// What *is* in this file, below, is real but secondary hardening: a **separate, narrower** failure mode
|
|
|
|
|
/// with the coincidence of producing the identical alert text through AppKit's own nil-target print action
|
|
|
|
|
/// rather than the sandbox (see "Always enabled" below) — a window that published no print scope at all used
|
|
|
|
|
/// to leave ⌘P's key equivalent live and unclaimed, which is worth closing regardless of the entitlement.
|
|
|
|
|
/// Finder's `printFiles` half (`PrintCoordinator.printFiles`) is hardening of a different kind: it was
|
|
|
|
|
/// simply unimplemented before, which is its own route to the same stock alert, entitlement or not.
|
|
|
|
|
///
|
|
|
|
|
/// ### Two scopes, one row — and a third that isn't a scope at all
|
|
|
|
|
///
|
|
|
|
|
/// The board window prints **the board**: its lanes left to right, each lane's cards top to bottom, as a
|
|
|
|
|
/// linear document rather than a picture of the strip (`PrintSource.board`). The card window prints **that
|
|
|
|
@@ -74,17 +91,41 @@ extension FocusedValues {
|
|
|
|
|
/// decides nothing, and it follows `RevealInFinderCommand`'s — the board in front wins, the card-window
|
|
|
|
|
/// branch stands when a card window is.
|
|
|
|
|
///
|
|
|
|
|
/// ### Validation is scope and nothing else
|
|
|
|
|
/// A third window shape publishes neither: welcome, the template chooser, Settings, the restore-bootstrap
|
|
|
|
|
/// window, or a card window whose board has not joined yet (`CardPrintSubject.card`'s own `nil` note). Those
|
|
|
|
|
/// are not a third *scope* — there is nothing behind them to print — but ⌘P has to answer for them somehow,
|
|
|
|
|
/// and the next section is about exactly that answer.
|
|
|
|
|
///
|
|
|
|
|
/// Neither the read-only lock nor the focused-editor rule closes it, unlike every mutating row in
|
|
|
|
|
/// `BoardCommands.swift`: a print is a **read**, and a locked board is exactly the board someone wants a
|
|
|
|
|
/// paper copy of (`RevealInFinderCommand`'s posture, and `BoardInfoCommand`'s). An inline title editor is no
|
|
|
|
|
/// obstacle either — the print takes the snapshot as it stands, which is what is on screen.
|
|
|
|
|
/// ### Always enabled — the alternative is worse than a refusal
|
|
|
|
|
///
|
|
|
|
|
/// **The row never disables**, which is a reversal from this feature's first cut: it used to grey out over
|
|
|
|
|
/// the scopeless window above, on the ordinary "nothing to act on" theory every other command follows. The
|
|
|
|
|
/// trouble is what "greyed out" means for a *keyboard shortcut* rather than a menu click — a disabled
|
|
|
|
|
/// `Button` still owns its `.keyboardShortcut`, so ⌘P over one of those windows was never actually inert.
|
|
|
|
|
/// SwiftUI drops the unclaimed chord back into the responder chain, which hands it to AppKit's own nil-target
|
|
|
|
|
/// `printDocument:` — the platform's stock print action, present whether or not this app ever registers a
|
|
|
|
|
/// document type for it — and *that* action's stock failure is, coincidentally, the same alert text the
|
|
|
|
|
/// entitlement gap produces: "This application does not support printing. Please contact the application's
|
|
|
|
|
/// developer." Two different mechanisms, one AppKit sentence — the entitlement is what the owner's repro
|
|
|
|
|
/// needed; this closes the other door to the same words, for a window with genuinely nothing to print rather
|
|
|
|
|
/// than a board or card the app simply couldn't get permission to send to a printer.
|
|
|
|
|
///
|
|
|
|
|
/// So the row claims ⌘P **unconditionally**, in every window, and answers the scopeless case itself rather
|
|
|
|
|
/// than declining to answer at all — the same instinct `BoardInfoCommand` and `RevealInFinderCommand` already
|
|
|
|
|
/// have for the read-only lock (a locked board is exactly the board someone wants a paper copy of), pushed
|
|
|
|
|
/// one layer further out. Validation used to be "scope and nothing else"; now there *is* nothing else to
|
|
|
|
|
/// validate — every window answers, and the only question is which of three answers it gets.
|
|
|
|
|
///
|
|
|
|
|
/// Neither the read-only lock nor the focused-editor rule ever closed this row, unlike every mutating row in
|
|
|
|
|
/// `BoardCommands.swift`: a print is a **read**. An inline title editor is no obstacle either — the print
|
|
|
|
|
/// takes the snapshot as it stands, which is what is on screen.
|
|
|
|
|
///
|
|
|
|
|
/// A board with **nothing to print** — no lanes, or every card empty of every included component — still
|
|
|
|
|
/// enables the row, deliberately: the honest place to discover that is the panel's own preview, and a ⌘P
|
|
|
|
|
/// that greys out on a board the user is looking at reads as a broken app rather than as an empty document.
|
|
|
|
|
/// The refusal, when it happens, is `PrintCoordinator`'s and it says so.
|
|
|
|
|
/// That refusal, and the scopeless one, are both `PrintCoordinator`'s and both say so out loud
|
|
|
|
|
/// (`PrintCoordinator.refuse` and `.refuseNoScope`) — an app-drawn sentence in this app's own voice, never
|
|
|
|
|
/// AppKit's stock line about a developer to contact.
|
|
|
|
|
struct PrintCommand: View {
|
|
|
|
|
|
|
|
|
|
let appModel: AppModel
|
|
|
|
@@ -97,56 +138,79 @@ struct PrintCommand: View {
|
|
|
|
|
print()
|
|
|
|
|
}
|
|
|
|
|
.keyboardShortcut("p", modifiers: .command)
|
|
|
|
|
.disabled(!isEnabled)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One answer for both the `disabled` state and the action — the codebase's usual shape, for its usual
|
|
|
|
|
/// reason: two derivations of a rule are two chances to disagree.
|
|
|
|
|
private var isEnabled: Bool {
|
|
|
|
|
Self.isEnabled(hasBoard: store != nil, hasPrintableCard: cardPrint?.card != nil)
|
|
|
|
|
/// The row's three answers, named — the shape `print()` switches over and the shape a test can hold
|
|
|
|
|
/// without a menu, a window, or AppKit (`SaveAsTemplateCommand.allowsSave`'s reason for `isEnabled`
|
|
|
|
|
/// existing as a pure function in the first place, one refactor later).
|
|
|
|
|
enum Scope: Equatable {
|
|
|
|
|
/// A board window is in front — print the board.
|
|
|
|
|
case board
|
|
|
|
|
/// No board window, but a card window with a card that has joined its board — print the card.
|
|
|
|
|
case card
|
|
|
|
|
/// Neither: welcome, the template chooser, Settings, the restore-bootstrap window, or a card
|
|
|
|
|
/// window whose board has not caught up yet. Not a failure — a fact about what is on screen — and
|
|
|
|
|
/// answered with a sentence rather than silence (`PrintCoordinator.refuseNoScope`).
|
|
|
|
|
case refuse
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The row's validation as a pure function of the two facts it turns on, extracted from `isEnabled` for
|
|
|
|
|
/// `SaveAsTemplateCommand.allowsSave`'s reason: a rule that can only be exercised through a menu is a
|
|
|
|
|
/// rule nobody tests.
|
|
|
|
|
/// The row's whole decision, as a pure function of the two facts it turns on — extracted from the view
|
|
|
|
|
/// for the same reason `isEnabled` used to be: a rule that can only be exercised through a menu is a
|
|
|
|
|
/// rule nobody tests. What changed is the shape of the answer, not the two facts it is a function of.
|
|
|
|
|
///
|
|
|
|
|
/// **Two disjuncts and nothing else.** No lock, no focused-editor rule, no is-there-anything-to-print —
|
|
|
|
|
/// see the type's doc comment for why each of those is deliberately absent. A card window whose board
|
|
|
|
|
/// has not loaded yet publishes a subject with no card, which is the second disjunct's whole point:
|
|
|
|
|
/// scope alone would enable the row over a window with nothing behind it.
|
|
|
|
|
static func isEnabled(hasBoard: Bool, hasPrintableCard: Bool) -> Bool {
|
|
|
|
|
hasBoard || hasPrintableCard
|
|
|
|
|
/// **The board in front wins**, exactly as `RevealInFinderCommand`'s three-way branch does — a card
|
|
|
|
|
/// window publishes no `boardStore`, so the two facts can never both be true at once, and the order
|
|
|
|
|
/// below decides nothing real.
|
|
|
|
|
static func resolveScope(hasBoard: Bool, hasPrintableCard: Bool) -> Scope {
|
|
|
|
|
if hasBoard { return .board }
|
|
|
|
|
if hasPrintableCard { return .card }
|
|
|
|
|
return .refuse
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func print() {
|
|
|
|
|
if let store {
|
|
|
|
|
switch Self.resolveScope(hasBoard: store != nil, hasPrintableCard: cardPrint?.card != nil) {
|
|
|
|
|
case .board:
|
|
|
|
|
guard let store else { return }
|
|
|
|
|
PrintCoordinator.printBoard(store: store, profiles: appModel.printProfiles)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if let cardPrint {
|
|
|
|
|
case .card:
|
|
|
|
|
guard let cardPrint else { return }
|
|
|
|
|
PrintCoordinator.printCard(cardPrint, profiles: appModel.printProfiles)
|
|
|
|
|
case .refuse:
|
|
|
|
|
PrintCoordinator.refuseNoScope()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - PrintCoordinator
|
|
|
|
|
|
|
|
|
|
/// **The AppKit half of ⌘P**: build the session, hand the panel our accessory, run the operation.
|
|
|
|
|
/// **The AppKit half of ⌘P**: build the session, hand the panel our accessory, run the operation. And, since
|
|
|
|
|
/// 2026-08-09, **the AppKit half of Finder's half too** — the `printFiles` Apple Event a File ▸ Print… on a
|
|
|
|
|
/// selected board, a drag onto a printer queue, or a print-and-open service delivers, forwarded here from
|
|
|
|
|
/// `AppDelegate.application(_:printFiles:withSettings:showPrintPanels:)` rather than left unimplemented.
|
|
|
|
|
/// Neither half of this was the reported bug's actual cause — `PrintCommand`'s own doc comment tells that
|
|
|
|
|
/// story, and it is `Kanban.entitlements`' `com.apple.security.print` key, not this file. Left unimplemented,
|
|
|
|
|
/// though, an unhandled `printFiles` *is* its own route to AppKit's stock refusal — "This application does
|
|
|
|
|
/// not support printing. Please contact the application's developer." — entitlement or not, so implementing
|
|
|
|
|
/// it is still worth doing on its own terms.
|
|
|
|
|
///
|
|
|
|
|
/// ### Why the operation is sheeted on the window rather than run modally
|
|
|
|
|
/// ### Why the windowed operation is sheeted rather than run modally
|
|
|
|
|
///
|
|
|
|
|
/// `runModal()` would block the main thread inside a nested run loop for as long as the panel is up, which
|
|
|
|
|
/// is the shape `NSSavePanel`'s uses in `DuplicateBoardCommand` — and is right there, because that panel
|
|
|
|
|
/// answers a question the copy is *waiting* on. A print is not: the board keeps reloading, the watcher keeps
|
|
|
|
|
/// running, an agent may be writing. So this uses the sheeted form, whose completion is where the Last Used
|
|
|
|
|
/// capture lands.
|
|
|
|
|
/// running, an agent may be writing. So `run(_:)` uses the sheeted form when a window exists, whose
|
|
|
|
|
/// completion is where the Last Used capture lands. `printFiles` never has that luxury — Finder's caller is
|
|
|
|
|
/// not a window at all — so its half always takes the synchronous fallback `run(_:)` itself falls back to
|
|
|
|
|
/// when there is no key window (`runOperation(for:showsPrintPanel:)`, the two entry points' shared floor).
|
|
|
|
|
///
|
|
|
|
|
/// ### The one refusal
|
|
|
|
|
/// ### The two refusals
|
|
|
|
|
///
|
|
|
|
|
/// A document with no pages is refused with an alert instead of printed. It is reachable two ways — a board
|
|
|
|
|
/// with no cards, and every component toggled off — and both deserve a sentence rather than a sheet of
|
|
|
|
|
/// running heads over blank paper. The alert is the whole response: nothing failed, so this is not
|
|
|
|
|
/// 02-architecture.md's write-failure banner surface, which is about writes.
|
|
|
|
|
/// A document with no pages is refused with an alert instead of printed (`refuse(_:)`). It is reachable two
|
|
|
|
|
/// ways — a board with no cards, and every component toggled off — and both deserve a sentence rather than a
|
|
|
|
|
/// sheet of running heads over blank paper. A ⌘P with **no scope at all** — the window in front is not a
|
|
|
|
|
/// board and not a card — gets the second sentence (`refuseNoScope()`), and `printFiles` reuses that same
|
|
|
|
|
/// wording for a path that does not resolve to a board. Neither alert is 02-architecture.md's write-failure
|
|
|
|
|
/// banner surface, which is about writes: nothing failed in either case, there was simply nothing to print.
|
|
|
|
|
@MainActor
|
|
|
|
|
enum PrintCoordinator {
|
|
|
|
|
|
|
|
|
@@ -207,29 +271,7 @@ enum PrintCoordinator {
|
|
|
|
|
refuse(session)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// `NSPrintInfo.shared`, deliberately: it is where the panel's paper, orientation and margins are
|
|
|
|
|
// remembered between prints, which is exactly the continuity a user expects from a print dialog —
|
|
|
|
|
// and an app with no `NSDocument` has no per-document print info for them to live in instead.
|
|
|
|
|
let printInfo = NSPrintInfo.shared
|
|
|
|
|
// Both modes are moot while the view answers `knowsPageRange` itself — AppKit takes the view's page
|
|
|
|
|
// rects and does not subdivide them further — and they are set anyway to say what the document is:
|
|
|
|
|
// one column exactly as wide as the page, which never spills sideways.
|
|
|
|
|
printInfo.horizontalPagination = .clip
|
|
|
|
|
printInfo.verticalPagination = .automatic
|
|
|
|
|
|
|
|
|
|
let view = PrintDocumentView(session: session, printInfo: printInfo)
|
|
|
|
|
let operation = NSPrintOperation(view: view, printInfo: printInfo)
|
|
|
|
|
operation.jobTitle = session.jobTitle
|
|
|
|
|
operation.showsPrintPanel = true
|
|
|
|
|
operation.showsProgressPanel = true
|
|
|
|
|
|
|
|
|
|
let panel = operation.printPanel
|
|
|
|
|
// The preview is what makes the accessory worth having (`PrintOptionsAccessoryController`), and the
|
|
|
|
|
// page-setup group is what lets the paper questions be answered in the same dialog rather than in a
|
|
|
|
|
// second one this app does not have (there is no File ▸ Page Setup… row — 11-command-nexus.md).
|
|
|
|
|
panel.options.formUnion([.showsPreview, .showsPaperSize, .showsOrientation, .showsScaling, .showsCopies, .showsPageRange])
|
|
|
|
|
panel.addAccessoryController(PrintOptionsAccessoryController(session: session))
|
|
|
|
|
let operation = makeOperation(for: session, showsPrintPanel: true)
|
|
|
|
|
|
|
|
|
|
// **The Last Used capture happens when the operation ends, and only if it ran** — see
|
|
|
|
|
// `PrintCompletion`, which is also the reason the sheeted form needs a delegate at all.
|
|
|
|
@@ -245,12 +287,61 @@ enum PrintCoordinator {
|
|
|
|
|
// No window to sheet on — which should not happen for a command scoped to a focused window, but
|
|
|
|
|
// a print is still a legitimate thing to do and a modal run is the honest fallback. `run()` is
|
|
|
|
|
// synchronous, so the capture is an ordinary line rather than a callback.
|
|
|
|
|
if operation.run() {
|
|
|
|
|
session.profiles.captureLastUsed(session.options)
|
|
|
|
|
}
|
|
|
|
|
runOperation(operation, session: session)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// **The one place an `NSPrintOperation` is actually built** — `run(_:)`'s sheeted path and
|
|
|
|
|
/// `printFiles`'s always-headless one both start here, so the two can never quietly drift apart on the
|
|
|
|
|
/// paper, the accessory, or the job title.
|
|
|
|
|
///
|
|
|
|
|
/// `NSPrintInfo.shared`, deliberately: it is where the panel's paper, orientation and margins are
|
|
|
|
|
/// remembered between prints, which is exactly the continuity a user expects from a print dialog — and
|
|
|
|
|
/// an app with no `NSDocument` has no per-document print info for them to live in instead (which is also
|
|
|
|
|
/// why `printFiles`'s own `withSettings` dictionary is read by nobody here — see its doc comment).
|
|
|
|
|
///
|
|
|
|
|
/// The accessory and the panel's option set are built only when a panel is actually going to show:
|
|
|
|
|
/// `showsPrintPanel: false` is Finder's silent-print request, and an accessory nobody will ever see is a
|
|
|
|
|
/// `PrintSession` retained for no reason.
|
|
|
|
|
private static func makeOperation(for session: PrintSession, showsPrintPanel: Bool) -> NSPrintOperation {
|
|
|
|
|
let printInfo = NSPrintInfo.shared
|
|
|
|
|
// Both modes are moot while the view answers `knowsPageRange` itself — AppKit takes the view's page
|
|
|
|
|
// rects and does not subdivide them further — and they are set anyway to say what the document is:
|
|
|
|
|
// one column exactly as wide as the page, which never spills sideways.
|
|
|
|
|
printInfo.horizontalPagination = .clip
|
|
|
|
|
printInfo.verticalPagination = .automatic
|
|
|
|
|
|
|
|
|
|
let view = PrintDocumentView(session: session, printInfo: printInfo)
|
|
|
|
|
let operation = NSPrintOperation(view: view, printInfo: printInfo)
|
|
|
|
|
operation.jobTitle = session.jobTitle
|
|
|
|
|
operation.showsPrintPanel = showsPrintPanel
|
|
|
|
|
operation.showsProgressPanel = showsPrintPanel
|
|
|
|
|
|
|
|
|
|
if showsPrintPanel {
|
|
|
|
|
let panel = operation.printPanel
|
|
|
|
|
// The preview is what makes the accessory worth having (`PrintOptionsAccessoryController`), and
|
|
|
|
|
// the page-setup group is what lets the paper questions be answered in the same dialog rather
|
|
|
|
|
// than in a second one this app does not have (there is no File ▸ Page Setup… row —
|
|
|
|
|
// 11-command-nexus.md).
|
|
|
|
|
panel.options.formUnion([.showsPreview, .showsPaperSize, .showsOrientation, .showsScaling, .showsCopies, .showsPageRange])
|
|
|
|
|
panel.addAccessoryController(PrintOptionsAccessoryController(session: session))
|
|
|
|
|
}
|
|
|
|
|
return operation
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The synchronous fallback both windowless callers use: run the operation now, capture Last Used only
|
|
|
|
|
/// on success (`PrintCompletion`'s own asymmetry, restated here because there is no delegate on this
|
|
|
|
|
/// path to hold it) — and hand back what happened, which `run(_:)`'s own fallback discards and
|
|
|
|
|
/// `printFiles` reports to Finder.
|
|
|
|
|
@discardableResult
|
|
|
|
|
private static func runOperation(_ operation: NSPrintOperation, session: PrintSession) -> Bool {
|
|
|
|
|
let success = operation.run()
|
|
|
|
|
if success {
|
|
|
|
|
session.profiles.captureLastUsed(session.options)
|
|
|
|
|
}
|
|
|
|
|
return success
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The window the sheet hangs on: the app's key window, which for a command validated against the focus
|
|
|
|
|
/// system *is* the window that published the subject. Asked of AppKit rather than threaded through the
|
|
|
|
|
/// focus system because a `NSWindow` is not a value a `FocusedValue` should carry, and because
|
|
|
|
@@ -269,6 +360,146 @@ enum PrintCoordinator {
|
|
|
|
|
alert.addButton(withTitle: "OK")
|
|
|
|
|
alert.runModal()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// **The row's third answer** (`PrintCommand.Scope.refuse`): no board window, no printable card. Same
|
|
|
|
|
/// voice as `refuse(_:)` — "Nothing to Print" is the one title this file's alerts ever wear — and the
|
|
|
|
|
/// same posture: nothing failed, there was simply nothing behind the window to print. This is the
|
|
|
|
|
/// sentence a stray ⌘P now gets instead of falling through to AppKit's "This application does not
|
|
|
|
|
/// support printing" (`PrintCommand`'s doc comment tells the whole story).
|
|
|
|
|
static func refuseNoScope() {
|
|
|
|
|
logger.notice("print refused: no board or card in focus")
|
|
|
|
|
let alert = NSAlert()
|
|
|
|
|
alert.messageText = "Nothing to Print"
|
|
|
|
|
alert.informativeText = "Open a board or a card to print it."
|
|
|
|
|
alert.addButton(withTitle: "OK")
|
|
|
|
|
alert.runModal()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Finder's half: the `printFiles` Apple Event
|
|
|
|
|
|
|
|
|
|
/// One `printFiles` path's answer — the Finder-print counterpart of `PrintCommand.Scope`, and a
|
|
|
|
|
/// separate type from it rather than a reused one because a Finder path can never resolve to `.card`:
|
|
|
|
|
/// Finder hands over folders, never the identity of a card inside one.
|
|
|
|
|
enum FinderPrintTarget {
|
|
|
|
|
case board(BoardModel)
|
|
|
|
|
case refuse
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// **Board-or-refuse, read straight off disk** — `BoardLoader` succeeding on the path is the whole
|
|
|
|
|
/// test, exactly as `BoardModel`'s own doc comment describes what opens: "`<root>` is the `.kanban`
|
|
|
|
|
/// package (or an extension-less folder — both open)". No extension check, no UTI re-derivation: a path
|
|
|
|
|
/// this app's own loader accepts is a board, whatever it is named, and a path it refuses (a plain file,
|
|
|
|
|
/// a folder with no root `index.md`, one whose `schema` is newer than this build understands) is not.
|
|
|
|
|
///
|
|
|
|
|
/// A free function of a path rather than folded into `printFiles` itself, for `PrintCommand.resolveScope`'s
|
|
|
|
|
/// own reason one boundary over: this is the part a test can call without handing AppKit a real print job.
|
|
|
|
|
static func resolveFinderPrint(atPath path: String) -> FinderPrintTarget {
|
|
|
|
|
guard let model = try? BoardLoader.load(boardRoot: URL(fileURLWithPath: path)).model else {
|
|
|
|
|
return .refuse
|
|
|
|
|
}
|
|
|
|
|
return .board(model)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// **`AppDelegate.application(_:printFiles:withSettings:showPrintPanels:)`'s whole implementation.**
|
|
|
|
|
/// Every path is resolved independently (`resolveFinderPrint`) and, for a path that resolves to a
|
|
|
|
|
/// board, printed **headless** — `BoardLoader`'s snapshot directly, no `BoardStore`, no window, the same
|
|
|
|
|
/// frozen-snapshot posture `printBoard(store:profiles:)` takes from a live one, because this is a
|
|
|
|
|
/// snapshot too: read once, printed once. A path that is not a board refuses politely
|
|
|
|
|
/// (`refuseNoScope()`'s wording) rather than surfacing the loader's own defect UI, which is a board-open
|
|
|
|
|
/// experience this is not.
|
|
|
|
|
///
|
|
|
|
|
/// `printSettings` is accepted, never read: this app has no `NSDocument` and therefore no per-document
|
|
|
|
|
/// print info for a caller's dictionary to override (`makeOperation`'s own note) — `NSPrintInfo.shared`
|
|
|
|
|
/// is the one paper configuration Finder's print and the in-app one both draw from, which is what keeps
|
|
|
|
|
/// "Print This Board" from Finder and ⌘P on the same board agreeing about margins.
|
|
|
|
|
///
|
|
|
|
|
/// The reply folds every path's outcome into AppKit's one required answer: any success at all is
|
|
|
|
|
/// `.printingSuccess` (Finder's queue is the honest place to discover a *partial* batch failure, not this
|
|
|
|
|
/// return value), a batch with no successes but at least one non-board path is `.printingFailure`, and a
|
|
|
|
|
/// batch of boards that all cancelled or failed at the operation level is `.printingCancelled` — the same
|
|
|
|
|
/// cancel-or-jam ambiguity `PrintCompletion` already lives with, one layer up.
|
|
|
|
|
static func printFiles(
|
|
|
|
|
_ paths: [String],
|
|
|
|
|
settings printSettings: [NSPrintInfo.AttributeKey: Any],
|
|
|
|
|
showPrintPanels: Bool,
|
|
|
|
|
profiles: PrintProfileStore
|
|
|
|
|
) -> NSApplication.PrintReply {
|
|
|
|
|
guard !paths.isEmpty else { return .printingFailure }
|
|
|
|
|
|
|
|
|
|
var printedAny = false
|
|
|
|
|
var refusedAny = false
|
|
|
|
|
|
|
|
|
|
for path in paths {
|
|
|
|
|
switch resolveFinderPrint(atPath: path) {
|
|
|
|
|
case let .board(model):
|
|
|
|
|
if printHeadlessBoard(model, atPath: path, showPrintPanels: showPrintPanels, profiles: profiles) {
|
|
|
|
|
printedAny = true
|
|
|
|
|
}
|
|
|
|
|
case .refuse:
|
|
|
|
|
refuseNoScope()
|
|
|
|
|
refusedAny = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if printedAny { return .printingSuccess }
|
|
|
|
|
return refusedAny ? .printingFailure : .printingCancelled
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One board, loaded and printed with no store and no window behind it — `printBoard(store:profiles:)`
|
|
|
|
|
/// read straight off a `BoardModel` instead of a live store, with the comments closure rebuilt against
|
|
|
|
|
/// `CommentThread.load` in place of `BoardStore.commentThread(inCard:)`, since there is no store here to
|
|
|
|
|
/// ask.
|
|
|
|
|
private static func printHeadlessBoard(
|
|
|
|
|
_ model: BoardModel,
|
|
|
|
|
atPath path: String,
|
|
|
|
|
showPrintPanels: Bool,
|
|
|
|
|
profiles: PrintProfileStore
|
|
|
|
|
) -> Bool {
|
|
|
|
|
let root = URL(fileURLWithPath: path)
|
|
|
|
|
let title = displayName(of: model, atPath: root)
|
|
|
|
|
|
|
|
|
|
let session = PrintSession(
|
|
|
|
|
provider: PrintSourceProvider(
|
|
|
|
|
withoutComments: PrintSource.board(model, titled: title),
|
|
|
|
|
withComments: {
|
|
|
|
|
PrintSource.board(model, titled: title) { card in
|
|
|
|
|
PrintComment.list(of: commentThread(of: card, inBoardAt: root, snapshot: model))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
profiles: profiles,
|
|
|
|
|
cardFolder: nil,
|
|
|
|
|
jobTitle: title,
|
|
|
|
|
boardTitle: title
|
|
|
|
|
)
|
|
|
|
|
guard !session.blocks().isEmpty else {
|
|
|
|
|
refuse(session)
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return runOperation(makeOperation(for: session, showsPrintPanel: showPrintPanels), session: session)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// `AppModel.displayName(of:)`'s reading, off a `BoardModel` rather than a live `BoardStore` — there is
|
|
|
|
|
/// no store on this path to ask, and the rule is the same either way: the board's own `title`, falling
|
|
|
|
|
/// back to the folder name (01-storage-format.md § Board naming).
|
|
|
|
|
private static func displayName(of model: BoardModel, atPath root: URL) -> String {
|
|
|
|
|
if let title = model.title.value, !title.isEmpty { return title }
|
|
|
|
|
return AppModel.folderDisplayName(of: root)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One card's thread, read fresh from disk — `BoardStore.commentThread(inCard:)`'s own reading, off a
|
|
|
|
|
/// bare snapshot: find which lane the card lives in (`BoardStore.boardItem`, a `nonisolated static`
|
|
|
|
|
/// function that needs no store instance), resolve its folder (`ItemPath.card(lane:id:).folder(under:)`),
|
|
|
|
|
/// and read the thread there. `.empty` for a card the snapshot no longer names — unreachable in practice
|
|
|
|
|
/// (the card came from this very snapshot's own walk), kept as the same vanished-target answer every
|
|
|
|
|
/// other card-scoped read gives rather than a force-unwrap.
|
|
|
|
|
private static func commentThread(of card: Card, inBoardAt root: URL, snapshot: BoardModel) -> CommentThread {
|
|
|
|
|
guard let item = BoardStore.boardItem(card.id, in: snapshot), let cardID = item.cardID else {
|
|
|
|
|
return .empty
|
|
|
|
|
}
|
|
|
|
|
let path = "\(item.laneID.rawValue)/\(cardID.rawValue)"
|
|
|
|
|
return CommentThread.load(inCard: ItemPath.card(lane: item.laneID, id: cardID).folder(under: root), path: path)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The sheeted operation's delegate — **and the only place the Last Used capture can honestly happen.**
|
|
|
|
|