import CupsKit import Foundation import Observation import OSLog /// Options tab state for one queue: queue defaults, pending edits, and per-row apply results. /// Apply always writes queue defaults with `lpadmin -p queue -o` (never lpoptions). @MainActor @Observable final class OptionsModel { enum RowStatus: Equatable { case applying case applied(note: String?) case failed(reason: String) } struct CustomPair: Identifiable, Equatable { let id = UUID() let key: String let value: String var status: RowStatus? } struct Failure: Identifiable { let id = UUID() let title: String let message: String } let queue: String private(set) var state: OptionState? private(set) var sections: [OptionSection] = [] private(set) var loadError: String? private(set) var isApplying = false /// Row key -> new raw value. private(set) var pending: [String: String] = [:] /// Field text as typed, so invalid input stays visible with its error. private(set) var fieldText: [String: String] = [:] private(set) var rowStatus: [String: RowStatus] = [:] private(set) var customPairs: [CustomPair] = [] var customEntry = "" private(set) var customEntryError: String? var failure: Failure? private let report: (String) -> Void private let client = CupsClient() private let log = Logger(subsystem: "edu.wku.cupsadmin.app", category: "options") init(queue: String, report: @escaping (String) -> Void) { self.queue = queue self.report = report } // MARK: loading func load() async { let started = Date() do { state = try await OptionState.load(client: client, queue: queue) loadError = nil rebuild() let count = sections.reduce(0) { $0 + $1.rows.count } report("Loaded \(count) options for \(queue) in \(elapsedText(since: started))") log.info("loaded \(count) options for \(self.queue, privacy: .public) in \(Date().timeIntervalSince(started), format: .fixed(precision: 3)) s") } catch { loadError = String(describing: error) report("ERROR: \(error)") log.error("load failed for \(self.queue, privacy: .public): \(String(describing: error), privacy: .public)") } } private func rebuild() { sections = state?.sections() ?? [] } // MARK: values var hasChanges: Bool { !pending.isEmpty || customPairs.contains { $0.status == nil } } var changeCount: Int { pending.count + customPairs.filter { $0.status == nil }.count } var hasErrors: Bool { sections.flatMap(\.rows).contains { validationError($0) != nil } } /// The queue default before pending edits. func currentValue(_ row: OptionRow) -> String? { state?.queueValue(row.key) } /// The value shown in the control: pending edit, else current. func value(_ row: OptionRow) -> String? { pending[row.key] ?? currentValue(row) } func set(_ row: OptionRow, to newValue: String) { rowStatus[row.key] = nil pending[row.key] = newValue == currentValue(row) ? nil : newValue } func fieldText(_ row: OptionRow, field: CustomField) -> String { fieldText[row.key] ?? field.text(fromValue: value(row)) } func setFieldText(_ text: String, row: OptionRow, field: CustomField) { fieldText[row.key] = text set(row, to: field.value(fromText: text)) if pending[row.key] == nil { fieldText[row.key] = nil } } func validationError(_ row: OptionRow) -> String? { guard case .field(let field) = row.control, let text = fieldText[row.key] else { return nil } return field.validationError(text) } func isPending(_ row: OptionRow) -> Bool { pending[row.key] != nil } /// Your own ~/.cups/lpoptions value wins over the queue default for your jobs; say so. func overrideNote(_ row: OptionRow) -> String? { guard let state, let mine = state.userOverride(row.key) else { return nil } return "Your ~/.cups/lpoptions overrides this for your jobs: \(state.display(mine, row: row))" } // MARK: custom option row func addCustomEntry() { let entry = customEntry.trimmingCharacters(in: .whitespaces) guard let eq = entry.firstIndex(of: "="), eq != entry.startIndex else { customEntryError = "Use keyword=value" return } let key = String(entry[.. String { let message = result.message.isEmpty ? "\(result.commandLine) exited \(result.status)" : result.message let lower = message.lowercased() if lower.contains("forbidden") || lower.contains("not authorized") || lower.contains("unauthorized") || lower.contains("authentication") { return message + "\n\nChanging queue defaults needs an admin account or membership in _lpadmin:\n" + "sudo dseditgroup -o edit -a \(NSUserName()) -t user _lpadmin" } return message } }