import Foundation import Observation import os /// The named print profiles and the Last Used capture, persisted — **app-side, never in a board** /// (02-architecture.md § Per-board app state: "App-wide state has the same home"). /// /// ### Why `UserDefaults` and not the board /// /// A print profile describes *how this user likes to read on paper*. It is not a property of any /// board, it is not shared with a collaborator, and it has no business in frontmatter — the same /// argument `BoardZoomStore` makes for the zoom level, one rung stronger: a lane's `width` genuinely /// is board data because everyone opening the board sees it, while "Archive, comments on, Palatino /// 10pt" is one person's habit. Writing it into a `.kanban` package would also make it a thing agents /// and syncs have to round-trip, for a value no agent will ever read. /// /// It is not in `BoardRegistry` either, for `BoardZoomStore`'s reason: profiles are not per board, and /// a board opened on two machines wants the same *preference* applied rather than a per-board memory /// of one. /// /// ### Why `@Observable` rather than `@AppStorage` /// /// The accessory's profile popup and the print operation's live preview both read this, and the /// preview's refresh is driven by KVO on the accessory controller /// (`PrintOptionsAccessoryController`), which needs a change it can observe — a property wrapper /// living inside a SwiftUI view body cannot give a menu row or a print panel that. `BoardZoomStore`'s /// shape exactly, and for the same two consumers' reasons. /// /// ### Rules live in `PrintProfileCatalog` /// /// Everything about names, collisions, ordering and the reserved pseudo-profile is that value type's. /// This object holds one, publishes it, and persists it — nothing else. `defaults` is injectable so a /// test drives a suite of its own rather than the developer's own preferences (`BoardZoomStore`'s /// note, verbatim in intent). @MainActor @Observable public final class PrintProfileStore { /// The named profiles. Mutated only through the three methods below, each of which persists. public private(set) var catalog: PrintProfileCatalog /// **The options the last print ran with** — the reserved "Last Used" pseudo-profile's content /// (`PrintProfile.lastUsedName`). /// /// A first launch has none, and that absence is meaningful rather than a missing value to paper /// over: `options(named:)` answers the factory defaults for it, and the popup still shows the row, /// because "Last Used" naming today's defaults on the very first print is the truth — nothing else /// has been used yet. public private(set) var lastUsed: PrintOptions? @ObservationIgnored private let defaults: UserDefaults private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "printing") /// - Parameter defaults: the domain to persist in. Injected for `BoardZoomStore`'s reason — a test /// must be able to hold its own without touching the user's. public init(defaults: UserDefaults = .standard) { self.defaults = defaults catalog = Self.decode(PrintProfileCatalog.self, from: defaults, key: AppPreferences.printProfilesKey) ?? PrintProfileCatalog() lastUsed = Self.decode(PrintOptions.self, from: defaults, key: AppPreferences.printLastUsedKey) } // MARK: - Reading /// The options a menu selection resolves to: the reserved row answers `lastUsed` (or the factory /// defaults on a first launch — see `lastUsed`), a named row answers the catalog, and a name that /// matches neither answers `nil`. public func options(named name: String) -> PrintOptions? { if PrintProfile.isReserved(name) { return lastUsed ?? PrintOptions() } return catalog.options(named: name) } /// What the profile popup lists, top to bottom: the reserved row first, then the named ones in the /// order they were saved (`PrintProfileCatalog`'s own ordering note). /// /// The reserved row leads because it is what ⌘P opens on, and a menu whose first row is not the /// selected one reads as a menu that lost the user's place. public var menuNames: [String] { [PrintProfile.lastUsedName] + catalog.names } // MARK: - Writing /// **Captures the options a print just ran with.** Called when the operation ends and only if it ran /// (`PrintCompletion`, which carries the reasoning): the sheeted print panel returns control /// immediately, so there is no "on the way in" moment at which the user's edits exist yet, and Cancel is /// indistinguishable from a printer failure at the end — so the gate is success, and a cancelled print /// leaves the remembered settings exactly where they were. /// /// An unchanged capture writes nothing and publishes nothing, `BoardZoomStore.setLevel`'s guard and /// for its load-bearing reason: `@Observable` notifies on every set, and a no-op notification here /// would invalidate the accessory's own view mid-print. public func captureLastUsed(_ options: PrintOptions) { guard options != lastUsed else { return } lastUsed = options persist(options, key: AppPreferences.printLastUsedKey) } /// Saves `options` as a named profile, overwriting one of that name — the catalog's rule, persisted. /// `false` is a refused name (blank, or the reserved one), which writes nothing. @discardableResult public func save(_ options: PrintOptions, as name: String) -> Bool { var updated = catalog guard updated.save(options, as: name) else { return false } catalog = updated persistCatalog() return true } @discardableResult public func rename(_ name: String, to newName: String) -> Bool { var updated = catalog guard updated.rename(name, to: newName) else { return false } catalog = updated persistCatalog() return true } public func delete(_ name: String) { var updated = catalog updated.delete(name) guard updated != catalog else { return } catalog = updated persistCatalog() } // MARK: - Persistence /// JSON in a single `Data` value rather than a plist tree of dictionaries. /// /// The stored shape is then `Codable`'s, which is the shape the tests round-trip and the shape a /// future field extends — as opposed to a hand-written `[[String: Any]]` mapping that would have to /// be kept in step with the struct by hand. `UserDefaults` stores `Data` natively, so this costs /// nothing but a serialization the app performs at most once per print. private func persistCatalog() { persist(catalog, key: AppPreferences.printProfilesKey) } private func persist(_ value: Value, key: String) { do { defaults.set(try JSONEncoder().encode(value), forKey: key) } catch { // Nothing user-facing: a preferences write that fails costs a remembered profile, not any // of the user's content, and there is no board banner that would be honest about it (02 § // Write-failure surfacing is about *board* writes). The log is the whole response. Self.logger.error("print profiles could not be persisted: \(error.localizedDescription, privacy: .public)") } } /// Total, by the whole file's doctrine: a key holding the wrong type, truncated JSON, or a shape /// from a build that has moved on all answer `nil`, which reads as "nothing stored yet" — the same /// degrade `StyleRecents` gives a garbage preference rather than taking the surface down with it. private static func decode(_ type: Value.Type, from defaults: UserDefaults, key: String) -> Value? { guard let data = defaults.data(forKey: key) else { return nil } guard let value = try? JSONDecoder().decode(type, from: data) else { logger.warning("stored value for '\(key, privacy: .public)' could not be read — ignored") return nil } return value } }