import Foundation // MARK: - PrintProfile /// A named set of print options — "these configurations probably good to persist (as named print /// profiles) and reused", the card's closing line, as a type. /// /// ### Its name is its identity /// /// No UUID, deliberately. A print profile is a **user's own label** for a way of printing ("Standup /// handout", "Archive, comments on"), and a label is what the popup menu shows, what a rename /// changes, and what a save collides on. Minting an id beside it would create a second identity the /// UI never shows and the user could never reconcile — two profiles both called "Handout", one of /// them unreachable. So names are unique (case-insensitively — see `PrintProfileCatalog`), and /// renaming *is* re-identifying. /// /// This is the same reasoning `ItemID` states for a card folder and reaches the opposite conclusion /// for the opposite reason: a card's title is content that may repeat and must be free to, while a /// profile's name is a key the user types. public struct PrintProfile: Codable, Sendable, Equatable, Identifiable { public var name: String public var options: PrintOptions public var id: String { PrintProfileCatalog.key(name) } public init(name: String, options: PrintOptions) { self.name = name self.options = options } private enum CodingKeys: String, CodingKey { case name, options } /// Lenient for `PrintOptions`' reason, one level up: a stored profile missing its name or its /// options decodes to a nameless one rather than throwing, and a nameless profile is dropped by /// `PrintProfileCatalog.init(profiles:)`. Without this, one malformed entry in the plist would /// fail the whole array's decode and cost the user every profile they saved — the exact failure /// mode the option decoder exists to rule out. public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) name = ((try? container.decodeIfPresent(String.self, forKey: .name)) ?? nil) ?? "" options = ((try? container.decodeIfPresent(PrintOptions.self, forKey: .options)) ?? nil) ?? PrintOptions() } // MARK: The reserved pseudo-profile /// **"Last Used" is reserved** and is not a member of the catalog at all — it is the options the /// last print ran with, captured automatically, and it exists so that ⌘P opens on *what the user /// did last* rather than on a factory default they overrode a hundred prints ago. /// /// A pseudo-profile rather than a real one because the two behave nothing alike: this one cannot /// be renamed, cannot be deleted, and rewrites itself on every print — a named profile does none /// of those and would be worthless if it did (the point of "Handout" is that it stays what it was /// when you saved it). Keeping the reserved name out of the stored list is also what makes the /// rule enforceable rather than remembered: `PrintProfileCatalog` refuses the name, so no code /// path can create a shadow of it. public static let lastUsedName = "Last Used" /// Whether `name` is one a user may claim. The comparison is the catalog's own key rule, so /// "last used" and "LAST USED" are refused exactly as the canonical spelling is. public static func isReserved(_ name: String) -> Bool { PrintProfileCatalog.key(name) == PrintProfileCatalog.key(lastUsedName) } } // MARK: - PrintProfileCatalog /// **The named profiles, and every rule about them** — save, rename, delete, look up — as a pure /// value type with no `UserDefaults` and no `@Observable` anywhere near it. /// /// The split is the codebase's usual one (`BoardZoom` beside `BoardZoomStore`, `StyleRecents.updated` /// beside the store that persists it): the rules are the interesting part and a rule that can only be /// exercised through a preferences domain is a rule nobody tests. `PrintProfileStore` is this type's /// persistence and its observability, and it owns no rules of its own. /// /// ### Order is the user's, not the alphabet's /// /// Profiles keep the order they were saved in, newest last, and a rename does not move one. The popup /// menu is short by nature (a handful of ways one person prints), and a list that re-sorted itself /// when a profile was renamed would move the row the user was looking at. `StyleRecents`' most-recent- /// first list makes the opposite choice for the opposite reason — that list *is* a recency ranking. public struct PrintProfileCatalog: Codable, Sendable, Equatable { public private(set) var profiles: [PrintProfile] public init(profiles: [PrintProfile] = []) { // Sanitized on the way in for the same reason the option decoder is lenient: this value comes // out of a preferences plist a human may have edited. Reserved and blank names are dropped, // and a duplicate keeps its first occurrence — the reading a menu can actually render. var kept: [PrintProfile] = [] for profile in profiles { let name = Self.normalized(profile.name) guard !name.isEmpty, !PrintProfile.isReserved(name) else { continue } guard !kept.contains(where: { Self.key($0.name) == Self.key(name) }) else { continue } kept.append(PrintProfile(name: name, options: profile.options)) } self.profiles = kept } // MARK: Names /// A name as stored: outer whitespace trimmed, inner text untouched. Trimming is what makes /// `" Handout "` and `"Handout"` the same profile rather than two rows that look identical. public static func normalized(_ name: String) -> String { name.trimmingCharacters(in: .whitespacesAndNewlines) } /// The comparison key — the normalized name case-folded. Case-insensitive because a user typing /// "handout" a week later means the profile they called "Handout", and two rows differing only in /// case is a bug report, not a feature. /// /// `localizedLowercase` rather than `lowercased()`: these are human words in the user's own /// language, unlike `ItemID`'s ASCII-hex fold. public static func key(_ name: String) -> String { normalized(name).localizedLowercase } /// Whether `name` may be saved or renamed to — blank and reserved refused, an existing name /// allowed (a save over one's own profile is an overwrite, which is what the Save button means /// when the popup is already on that profile). public static func isAcceptable(_ name: String) -> Bool { !normalized(name).isEmpty && !PrintProfile.isReserved(name) } // MARK: Reading public func contains(_ name: String) -> Bool { profiles.contains { Self.key($0.name) == Self.key(name) } } /// The named profile's options, or `nil` — the popup's selection resolved. Never a fallback to /// anything: a selection that names no profile is a UI out of step with its model, and quietly /// substituting the defaults would hide that. public func options(named name: String) -> PrintOptions? { profiles.first { Self.key($0.name) == Self.key(name) }?.options } /// The names in list order — the popup's rows below the reserved one. public var names: [String] { profiles.map(\.name) } // MARK: Writing /// Saves `options` under `name`, overwriting a profile of that name in place. /// /// **Overwrite rather than a second row**, and *in place* rather than moved to the end: saving /// again over "Handout" is the gesture "this is what Handout means now", and re-ordering the menu /// as a side effect of it would be the list moving under the user's cursor. The spelling is /// updated to whatever was typed — `"handout"` saved over `"Handout"` renames the case — because /// the last spelling the user typed is the one they meant. /// /// Refuses a blank or reserved name (`isAcceptable`), answering `false`. A refusal writes nothing. @discardableResult public mutating func save(_ options: PrintOptions, as name: String) -> Bool { guard Self.isAcceptable(name) else { return false } let stored = PrintProfile(name: Self.normalized(name), options: options) if let index = profiles.firstIndex(where: { Self.key($0.name) == Self.key(name) }) { profiles[index] = stored } else { profiles.append(stored) } return true } /// Renames a profile, keeping its position and its options. /// /// Refuses when the old name names nothing, when the new one is blank or reserved, or when it is /// already another profile's — a rename that swallowed a sibling would destroy a profile the user /// never mentioned. Renaming to a different **case of its own name** is allowed, and is the one /// case where the target name already exists. @discardableResult public mutating func rename(_ name: String, to newName: String) -> Bool { guard Self.isAcceptable(newName), let index = profiles.firstIndex(where: { Self.key($0.name) == Self.key(name) }) else { return false } let collision = profiles.firstIndex { Self.key($0.name) == Self.key(newName) } guard collision == nil || collision == index else { return false } profiles[index].name = Self.normalized(newName) return true } /// Deletes a profile. A name that matches nothing is a no-op — the honest answer for a menu row /// that raced a deletion, and one no caller has to guard against. public mutating func delete(_ name: String) { profiles.removeAll { Self.key($0.name) == Self.key(name) } } // MARK: Codable /// A keyed container rather than a bare array, so the stored shape has somewhere to grow (an /// ordering key, a per-profile note) without every existing plist becoming undecodable. The /// decode routes through `init(profiles:)`, which is what applies the sanitizing rule to bytes /// that may have been hand-edited. private enum CodingKeys: String, CodingKey { case profiles } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let decoded = (try? container.decodeIfPresent([PrintProfile].self, forKey: .profiles)) ?? nil self.init(profiles: decoded ?? []) } }