diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaLibraryStore.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaLibraryStore.swift new file mode 100644 index 0000000..d84165c --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaLibraryStore.swift @@ -0,0 +1,117 @@ +import Foundation + +/// Persistence for `MediaRecipe` entries (issue #146). +/// +/// `media_library.json` is a sibling of `settings.json`, never a field +/// inside it. Writes are atomic via `JSONFileStore` → `AtomicFileWriter` +/// (`.tmp` + rename, #213). A corrupt file throws on load/upsert and is +/// never overwritten — the view model turns the throw into an empty +/// list plus a persistent warning banner. +public actor MediaLibraryStore { + + /// Default cap. + public static let defaultCapacity = 200 + + /// Path to the JSON store. + public let url: URL + + /// In-memory cache, kept in sync with disk. + private var recipes: [MediaRecipe] = [] + + /// Explicit load flag — an empty file is still "loaded". + private var loaded = false + + private let capacity: Int + private let fileStore: JSONFileStore<[MediaRecipe]> + + public init( + url: URL = AppPaths.appDataDir.appendingPathComponent("media_library.json"), + capacity: Int = defaultCapacity + ) { + self.url = url + self.capacity = capacity + self.fileStore = JSONFileStore( + fileURL: url, + corrupt: .throwCorrupt, + defaultValue: { [] }, + dateEncoding: .iso8601, + dateDecoding: .iso8601 + ) + } + + /// Loads recipes from disk. Returns the existing cache if already + /// loaded. + /// + /// Throws when the file exists but cannot be parsed; the existing + /// file is never overwritten in that case and `loaded` stays false + /// so the next call re-reads. + public func load() throws -> [MediaRecipe] { + guard !loaded else { return recipes } + guard FileManager.default.fileExists(atPath: url.path) else { + loaded = true + return [] + } + recipes = try fileStore.load() + loaded = true + return recipes + } + + /// Returns all cached recipes. + public func all() -> [MediaRecipe] { + recipes + } + + /// Inserts or replaces a recipe matched by `id`, then writes + /// atomically. Replacement preserves `created` and bumps `updated`; + /// inserts beyond `capacity` throw `.capacityReached` — no silent + /// eviction. + /// + /// Loads the existing library first and propagates any load error + /// so an unparseable file is never overwritten. + @discardableResult + public func upsert(_ recipe: MediaRecipe) throws -> [MediaRecipe] { + let validated = try recipe.validated() + try load() + + var updated = recipes + if let index = updated.firstIndex(where: { $0.id == validated.id }) { + var existing = validated + existing.created = updated[index].created + existing.updated = Date() + updated[index] = existing + } else { + guard updated.count < capacity else { + throw MediaLibraryError.capacityReached(capacity) + } + updated.append(validated) + } + + try fileStore.save(updated) + recipes = updated + return updated + } + + /// Removes a recipe by id and writes atomically. Returns false when + /// no recipe with that id exists. + @discardableResult + public func delete(id: String) throws -> Bool { + try load() + let before = recipes.count + let updated = recipes.filter { $0.id != id } + guard updated.count != before else { return false } + try fileStore.save(updated) + recipes = updated + return true + } + + public enum MediaLibraryError: LocalizedError, Equatable { + case capacityReached(Int) + + public var errorDescription: String? { + switch self { + case .capacityReached(let cap): + return "Media library is full (\(cap)). Delete a recipe first." + } + } + } +} diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaRecipe.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaRecipe.swift new file mode 100644 index 0000000..bd29a40 --- /dev/null +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Library/MediaRecipe.swift @@ -0,0 +1,146 @@ +import Foundation + +/// A media recipe: a named binding of CUPS queue + paper + ink set + +/// optional `.cal` to a `ProfilingPreset` (issue #146, docs/22 §Media +/// library). +/// +/// snake_case keys match the v1 JSON schema so `media_library.json` +/// stays import/export compatible. Identity + binding fields are +/// required; every other field is optional-defaulted. Unknown keys are +/// ignored on decode; missing required fields fail the whole array +/// decode (corrupt-file policy, never silently dropped). +public struct MediaRecipe: Codable, Equatable, Sendable, Identifiable { + + /// `"recipe-"`, never user-typed. + public var id: String + public var name: String + public var notes: String + /// CUPS queue id (`Printer.name` — `Printer` has no `id` member). + public var printerID: String + /// Human label from `Printer.displayName`. + public var printerDisplayName: String + /// Library metadata only — never written to targen `-P`/`-I` flags. + public var paperName: String + /// Last captured CUPS `media_type`, read-only. + public var driverMediaType: String? + /// Free text: `"PK"`, `"MK"`, `"Photo Black"`, … + public var inkSet: String + /// `"rgb"` | `"cmyk"` — must match the bound preset. + public var colourSpace: String + /// `ProfilingPreset.id` (built-in or custom). + public var presetID: String + /// Absolute `.cal` path stored verbatim; `nil` = none. + public var calibrationURL: String? + public var applyCalibration: Bool + public var created: Date + public var updated: Date + + public init( + id: String, + name: String, + notes: String = "", + printerID: String, + printerDisplayName: String = "", + paperName: String = "", + driverMediaType: String? = nil, + inkSet: String = "", + colourSpace: String, + presetID: String, + calibrationURL: String? = nil, + applyCalibration: Bool = false, + created: Date = Date(), + updated: Date = Date() + ) { + self.id = id + self.name = name + self.notes = notes + self.printerID = printerID + self.printerDisplayName = printerDisplayName + self.paperName = paperName + self.driverMediaType = driverMediaType + self.inkSet = inkSet + self.colourSpace = colourSpace + self.presetID = presetID + self.calibrationURL = calibrationURL + self.applyCalibration = applyCalibration + self.created = created + self.updated = updated + } + + enum CodingKeys: String, CodingKey { + case id, name, notes + case printerID = "printer_id" + case printerDisplayName = "printer_display_name" + case paperName = "paper_name" + case driverMediaType = "driver_media_type" + case inkSet = "ink_set" + case colourSpace = "colour_space" + case presetID = "preset_id" + case calibrationURL = "calibration_url" + case applyCalibration = "apply_calibration" + case created, updated + } + + /// Strict decode: required identity + binding fields must be + /// present; optionals default. Unknown keys are ignored. + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(String.self, forKey: .id) + name = try c.decode(String.self, forKey: .name) + notes = try c.decodeIfPresent(String.self, forKey: .notes) ?? "" + printerID = try c.decode(String.self, forKey: .printerID) + printerDisplayName = try c.decodeIfPresent(String.self, forKey: .printerDisplayName) ?? "" + paperName = try c.decodeIfPresent(String.self, forKey: .paperName) ?? "" + driverMediaType = try c.decodeIfPresent(String.self, forKey: .driverMediaType) + inkSet = try c.decodeIfPresent(String.self, forKey: .inkSet) ?? "" + colourSpace = try c.decode(String.self, forKey: .colourSpace) + presetID = try c.decode(String.self, forKey: .presetID) + calibrationURL = try c.decodeIfPresent(String.self, forKey: .calibrationURL) + applyCalibration = try c.decodeIfPresent(Bool.self, forKey: .applyCalibration) ?? false + created = try c.decodeIfPresent(Date.self, forKey: .created) ?? Date() + updated = try c.decodeIfPresent(Date.self, forKey: .updated) ?? Date() + } + + public enum ValidationError: LocalizedError, Equatable { + case emptyName + case emptyPrinterID + case invalidColourSpace(String) + case emptyPresetID + case invalidCalibrationURL(String) + + public var errorDescription: String? { + switch self { + case .emptyName: return "Media recipe is missing a name." + case .emptyPrinterID: return "Media recipe is missing a printer." + case .invalidColourSpace(let v): + return "colour_space must be \"rgb\" or \"cmyk\", got \"\(v)\"." + case .emptyPresetID: return "Media recipe is missing a preset." + case .invalidCalibrationURL(let v): + return "calibration_url must be an absolute path without \"..\" or NUL, got \"\(v)\"." + } + } + } + + /// Validates the binding fields. `colourSpace` is normalized to + /// lowercase before comparison. `CAL_` cal names are **not** + /// rejected — that is an apply-time policy, not schema. + @discardableResult + public func validated() throws -> MediaRecipe { + var r = self + r.id = id.trimmingCharacters(in: .whitespacesAndNewlines) + r.name = name.trimmingCharacters(in: .whitespacesAndNewlines) + r.colourSpace = colourSpace.lowercased() + guard !r.name.isEmpty else { throw ValidationError.emptyName } + guard !r.printerID.isEmpty else { throw ValidationError.emptyPrinterID } + guard r.colourSpace == "rgb" || r.colourSpace == "cmyk" else { + throw ValidationError.invalidColourSpace(colourSpace) + } + guard !r.presetID.isEmpty else { throw ValidationError.emptyPresetID } + if let cal = r.calibrationURL, !cal.isEmpty { + guard cal.hasPrefix("/"), !cal.contains(".."), !cal.contains("\0") else { + throw ValidationError.invalidCalibrationURL(cal) + } + } + return r + } +} diff --git a/Sources/ICCery/AppEnvironment.swift b/Sources/ICCery/AppEnvironment.swift index f32dfef..0576f8d 100644 --- a/Sources/ICCery/AppEnvironment.swift +++ b/Sources/ICCery/AppEnvironment.swift @@ -13,6 +13,7 @@ struct AppEnvironment: Sendable { let runner: ArgyllRunner let cupsService: CupsService let historyStore: VerificationHistoryStore + let mediaStore: MediaLibraryStore static func live( environment: [String: String] = ProcessInfo.processInfo.environment @@ -40,7 +41,8 @@ struct AppEnvironment: Sendable { cupsService: CupsService( processManager: .shared, binaryDir: cupsDir), - historyStore: VerificationHistoryStore() + historyStore: VerificationHistoryStore(), + mediaStore: MediaLibraryStore() ) } } diff --git a/Sources/ICCery/MediaLibraryDialogs.swift b/Sources/ICCery/MediaLibraryDialogs.swift new file mode 100644 index 0000000..b4313f5 --- /dev/null +++ b/Sources/ICCery/MediaLibraryDialogs.swift @@ -0,0 +1,255 @@ +import SwiftUI +import ICCeryCore + +/// `#saveMediaRecipeDialog` — capture the current printer + paper + +/// ink + `.cal` bound to the selected preset (issue #146). Clones +/// `SavePresetDialog` chrome; names render via `Text` only (#114). +struct SaveMediaRecipeDialog: View { + @ObservedObject var workflow: TargetWorkflowViewModel + /// Observed directly: nested ObservableObjects are not tracked + /// through the parent's `objectWillChange`. + @ObservedObject private var media: MediaLibraryViewModel + @ObservedObject private var printSession: PrintSessionViewModel + + init(workflow: TargetWorkflowViewModel) { + self.workflow = workflow + self._media = ObservedObject(wrappedValue: workflow.media) + self._printSession = ObservedObject(wrappedValue: workflow.print) + } + + private var printerCaption: String { + let queue = printSession.selectedPrinter + guard !queue.isEmpty else { return "None" } + let display = printSession.printers + .first { $0.name == queue }?.displayName ?? queue + return "\(display) (\(queue))" + } + + private func captureRow( + _ label: String, value: String, identifier: String + ) -> some View { + HStack { + Text(label).foregroundStyle(.secondary) + Spacer() + Text(value) + .foregroundStyle(Theme.text) + .lineLimit(1) + .truncationMode(.middle) + .accessibilityIdentifier(identifier) + } + } + + private var saveDisabled: Bool { + media.saveMediaName.trimmingCharacters(in: .whitespaces).isEmpty + || media.saveMediaPaper.trimmingCharacters(in: .whitespaces).isEmpty + || media.saveMediaInk.trimmingCharacters(in: .whitespaces).isEmpty + || media.captureColourSpaceMismatch + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Save Media Recipe").font(.title3).foregroundStyle(Theme.text) + TextField("Name", text: $media.saveMediaName) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("saveMediaName") + TextField("Notes (optional)", text: $media.saveMediaNotes) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("saveMediaNotes") + TextField("Paper", text: $media.saveMediaPaper) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("saveMediaPaper") + TextField("Ink set", text: $media.saveMediaInk) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("saveMediaInk") + + captureRow("Printer", value: printerCaption, + identifier: "saveMediaPrinter") + captureRow("Preset", + value: workflow.selectedPreset?.name ?? "No preset", + identifier: "saveMediaPreset") + captureRow("Colour space", + value: workflow.colourSpace.rawValue.uppercased(), + identifier: "saveMediaColourSpace") + captureRow("Calibration", + value: workflow.profile.calibrationFile.isEmpty + ? "None" : workflow.profile.calibrationFile, + identifier: "saveMediaCal") + Toggle("Apply calibration to profile", + isOn: $media.saveMediaApplyCal) + .disabled(!media.calApplyable) + .accessibilityIdentifier("saveMediaApplyCal") + + if media.captureColourSpaceMismatch { + Text("Colour space does not match the selected preset.") + .font(.caption).foregroundStyle(.orange) + } + if let error = media.saveMediaError { + Text(error).font(.caption).foregroundStyle(.orange) + } + + HStack { + Spacer() + Button("Cancel") { workflow.showingSaveMedia = false } + .accessibilityIdentifier("btnCloseSaveMediaDialog") + Button("Save") { + Task { + if await media.captureFromSession() { + workflow.showingSaveMedia = false + } + } + } + .disabled(saveDisabled) + .accessibilityIdentifier("btnConfirmSaveMedia") + } + } + .padding(20) + .frame(width: 380) + .background(Theme.background) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("saveMediaRecipeDialog") + } +} + +/// `#manageMediaDialog` — list, apply, delete, capture (issue #146). +/// `List`, not `Table` — macOS 12 target. Clones `ManagePresetsDialog`. +struct ManageMediaDialog: View { + @ObservedObject var workflow: TargetWorkflowViewModel + /// Observed directly: nested ObservableObjects are not tracked + /// through the parent's `objectWillChange`. + @ObservedObject private var media: MediaLibraryViewModel + @State private var selection: String? + @State private var pendingDelete: MediaRecipe? + + init(workflow: TargetWorkflowViewModel) { + self.workflow = workflow + self._media = ObservedObject(wrappedValue: workflow.media) + } + + private func presetCaption(for recipe: MediaRecipe) -> String { + workflow.presets.first { $0.id == recipe.presetID }?.name + ?? "Missing preset" + } + + private func calCaption(for recipe: MediaRecipe) -> String { + if media.staleReasons[recipe.id]?.contains(.calibration) == true { + return "Stale" + } + if let days = media.calAgeDays[recipe.id] { + return "Cal \(days)d" + } + return "No cal" + } + + private func applyAndDismiss(_ recipe: MediaRecipe) { + Task { + if await media.apply(recipe) { + workflow.showingManageMedia = false + } + } + } + + private func presetMissing(_ recipe: MediaRecipe) -> Bool { + !workflow.presets.contains { $0.id == recipe.presetID } + } + + private func calStale(_ recipe: MediaRecipe) -> Bool { + media.staleReasons[recipe.id]?.contains(.calibration) == true + } + + @ViewBuilder + private func row(_ recipe: MediaRecipe) -> some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(recipe.name).foregroundStyle(Theme.text) + Text("\(recipe.printerDisplayName) · \(recipe.paperName) · \(recipe.inkSet)") + .font(.caption).foregroundStyle(.secondary) + HStack(spacing: 8) { + Text(presetCaption(for: recipe)) + .font(.caption) + .foregroundStyle(presetMissing(recipe) ? .orange : .secondary) + Text(calCaption(for: recipe)) + .font(.caption) + .foregroundStyle(calStale(recipe) ? .orange : .secondary) + } + } + Spacer() + Button("Apply") { applyAndDismiss(recipe) } + .accessibilityIdentifier("btnMediaLibraryApply-\(recipe.id)") + Button("Delete", role: .destructive) { + pendingDelete = recipe + } + .accessibilityIdentifier("btnMediaLibraryDelete-\(recipe.id)") + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("mediaRow-\(recipe.id)") + .tag(recipe.id) + .contentShape(Rectangle()) + .simultaneousGesture( + TapGesture(count: 2).onEnded { applyAndDismiss(recipe) } + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Manage Media Recipes").font(.title3).foregroundStyle(Theme.text) + + List(selection: $selection) { + if media.recipes.isEmpty { + Text("No media recipes yet. Capture the current printer, paper and preset.") + .font(.callout).foregroundStyle(.secondary) + .accessibilityIdentifier("mediaLibraryEmpty") + } + ForEach(media.recipes) { recipe in + row(recipe) + } + } + .accessibilityIdentifier("mediaLibraryList") + .frame(minHeight: 260) + + HStack { + Button("Apply selected") { + if let id = selection, + let recipe = media.recipes.first(where: { $0.id == id }) { + applyAndDismiss(recipe) + } + } + .disabled(selection == nil) + .keyboardShortcut(.defaultAction) + .accessibilityIdentifier("btnMediaLibraryApply") + Button("Capture current…") { + media.captureAfterManageDismiss = true + workflow.showingManageMedia = false + } + .accessibilityIdentifier("btnMediaLibraryCaptureFromManage") + Spacer() + Button("Close") { workflow.showingManageMedia = false } + .accessibilityIdentifier("btnCloseManageMediaDialog") + } + } + .padding(20) + .frame(width: 640) + .background(Theme.background) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("manageMediaDialog") + .onAppear { + media.reload() + media.refreshStaleness() + } + .alert( + "Delete media recipe?", + isPresented: Binding( + get: { pendingDelete != nil }, + set: { if !$0 { pendingDelete = nil } } + ), + presenting: pendingDelete + ) { recipe in + Button("Cancel", role: .cancel) { pendingDelete = nil } + Button("Delete", role: .destructive) { + media.delete(recipe) + pendingDelete = nil + } + } message: { recipe in + Text("Delete \"\(recipe.name)\"? This does not delete the .cal or the preset.") + } + } +} diff --git a/Sources/ICCery/MediaLibraryViewModel.swift b/Sources/ICCery/MediaLibraryViewModel.swift new file mode 100644 index 0000000..1b11532 --- /dev/null +++ b/Sources/ICCery/MediaLibraryViewModel.swift @@ -0,0 +1,432 @@ +import Combine +import Foundation +import ICCeryCore + +/// Media recipe library: capture / apply / staleness for issue #146. +/// +/// A recipe binds a CUPS queue + paper + ink + `.cal` to a +/// `ProfilingPreset`. Applying a recipe goes through the existing +/// `applyPreset` (#82) path — there is no second Stage 1 form. Paper +/// and ink are library metadata only; they are never written to the +/// targen label fields. +@MainActor +final class MediaLibraryViewModel: ObservableObject { + + /// Why a recipe row is flagged stale. + struct StaleReason: OptionSet { + let rawValue: Int + static let calibration = StaleReason(rawValue: 1 << 0) + static let printer = StaleReason(rawValue: 1 << 1) + } + + let workflow: TargetWorkflowViewModel + let environment: AppEnvironment + private let store: MediaLibraryStore + private var cancellables = Set() + + @Published var recipes: [MediaRecipe] = [] + /// Sidebar picker selection; `"none"` = no media recipe. Written + /// only on a successful apply so a failed apply snaps back. + @Published var selectedRecipeID = "none" + /// `recipe.id` → stale reasons for the sidebar badge / manage sheet. + @Published var staleReasons: [String: StaleReason] = [:] + /// `recipe.id` → whole days since the bound `.cal` was created. + @Published var calAgeDays: [String: Int] = [:] + + // Save-sheet state (mirrors savePresetName/savePresetDesc). + @Published var saveMediaName = "" + @Published var saveMediaNotes = "" + @Published var saveMediaPaper = "" + @Published var saveMediaInk = "" + @Published var saveMediaApplyCal = false + /// Inline caption inside the capture sheet (no a11y id — roster complete). + @Published var saveMediaError: String? + + /// Pure flow flag — the manage sheet's "Capture current…" asks the + /// sheet's `onDismiss` to open the capture sheet, avoiding a + /// present-while-dismissing race. + var captureAfterManageDismiss = false + + init(workflow: TargetWorkflowViewModel, environment: AppEnvironment) { + self.workflow = workflow + self.environment = environment + self.store = environment.mediaStore + + reload() + refreshStaleness() + // The library needs queues enumerated at launch so Capture can + // enable and the not-installed badge is computable; Stage 2 only + // enumerates when a printtarg manifest exists. + if workflow.print.printers.isEmpty { + workflow.print.refreshPrinters() + } + + NotificationCenter.default + .publisher(for: SettingsStore.settingsDidChange) + .sink { [weak self] _ in self?.refreshStaleness() } + .store(in: &cancellables) + workflow.print.$printers + .sink { [weak self] _ in self?.refreshStaleness() } + .store(in: &cancellables) + workflow.print.$selectedPrinter + .sink { [weak self] _ in self?.refreshStaleness() } + .store(in: &cancellables) + } + + deinit { cancellables.removeAll() } + + // MARK: - Load / corrupt + + func reload() { + Task { await reloadAsync() } + } + + func reloadAsync() async { + do { + recipes = try await store.load() + } catch { + // Corrupt-file policy: keep the file, keep the cache, + // persistent warning; the picker falls back to "none". + workflow.wizard.showNotice( + "Media library is unreadable — the existing file was kept.", + kind: .warning, + autoHideAfter: nil + ) + if recipes.isEmpty { selectedRecipeID = "none" } + } + } + + // MARK: - Selection / apply + + /// Sidebar `mediaSelect` binding. `"none"` clears the selection + /// without resetting any Stage 1/2/4 field — it is not "reset to + /// factory". + func selectRecipe(_ id: String) { + if id == "none" { + selectedRecipeID = "none" + return + } + guard let recipe = recipes.first(where: { $0.id == id }) else { return } + Task { _ = await apply(recipe) } + } + + /// The single apply path — sidebar picker, manage-row Apply, and + /// the manage footer all funnel here. + /// + /// Returns `false` when any bound resource is unresolved (missing + /// preset, colour-space mismatch, queue absent, missing/unparseable + /// `.cal`) so the manage sheet stays open and the picker reverts. + /// A `CAL_`-blocked calibration counts as applied (`true` — success + /// with warning; the refusal is permanent so re-clicking can't help). + @discardableResult + func apply(_ recipe: MediaRecipe) async -> Bool { + guard let r = try? recipe.validated() else { + workflow.wizard.showNotice( + "Media recipe is invalid — not applied.", kind: .error) + return false + } + guard let preset = environment.presetStore.all() + .first(where: { $0.id == r.presetID }) + else { + workflow.wizard.showNotice( + "Preset \(r.presetID) no longer exists — recipe not applied.", + kind: .error) + return false + } + guard preset.colourSpace.lowercased() == r.colourSpace.lowercased() else { + workflow.wizard.showNotice( + "Recipe colour space does not match its preset — not applied.", + kind: .error) + return false + } + + // Existing #82 mapping: presetSelect jumps, Stage 1/2/4 fields. + workflow.applyPreset(preset) + // Literal per issue: displayName, not the queue id. + workflow.wizard.printerName = r.printerDisplayName + + var succeeded = true + + // Queue: enumerate fresh via the session's serialized path — + // listPrinters uses fixed process ids, so an overlapping + // enumeration would throw duplicateID. An empty result is a + // valid list. + if let queues = await workflow.print.enumeratePrinters() { + if queues.contains(where: { $0.name == r.printerID }) { + workflow.print.selectedPrinter = r.printerID + await workflow.print.reloadSelectedCapabilities() + } else { + workflow.wizard.showNotice( + "Printer \(r.printerDisplayName) is not installed.", + kind: .warning) + succeeded = false + } + } else { + workflow.wizard.showNotice( + "Could not enumerate printers — queue left unchanged.", + kind: .warning) + succeeded = false + } + + // Calibration — the recipe is authoritative and runs after + // applyPreset so the preset's own cal fields don't win. + let calPath = r.calibrationURL?.trimmingCharacters(in: .whitespaces) ?? "" + let calStem = URL(fileURLWithPath: calPath) + .deletingPathExtension().lastPathComponent + let blocked = r.applyCalibration && !calPath.isEmpty + && (CalibrationIdentity.isCalibration(calStem) + || CalibrationIdentity.isCalibration(workflow.wizard.basename)) + + if blocked { + // Literal CAL_ refusal on both names (decision 1): keep the + // path for display but never let `printtarg -K` see it. + workflow.profile.applyCalibration = false + workflow.profile.calibrationFile = calPath + selectedRecipeID = r.id + refreshStaleness() + workflow.wizard.showNotice( + "Applied \(r.name) — CAL_ calibrations cannot enable printtarg -K.", + kind: .warning, + autoHideAfter: nil) + return true + } + + if r.applyCalibration && !calPath.isEmpty { + guard FileManager.default.fileExists(atPath: calPath) else { + workflow.profile.applyCalibration = false + workflow.profile.calibrationFile = calPath + workflow.wizard.showNotice( + "Calibration file is missing: \(calPath)", kind: .error) + refreshStaleness() + return false + } + do { + let staleDays = environment.settingsStore.load().calibrationStaleDays + let calStore = CalibrationStore(staleDays: staleDays) + try await calStore.load(url: URL(fileURLWithPath: calPath)) + workflow.profile.calibrationFile = calPath + workflow.profile.applyCalibration = true + // Age check only — the .cal DESCRIPTOR is free text, not + // a queue id, so a name compare false-positives. + if await calStore.isStale() { + workflow.wizard.showNotice( + "Applied \(r.name) — calibration is stale.", + kind: .warning) + } + } catch { + workflow.profile.applyCalibration = false + workflow.wizard.showNotice( + "Could not load calibration: \(error.localizedDescription)", + kind: .error) + refreshStaleness() + return false + } + } else { + workflow.profile.applyCalibration = false + workflow.profile.calibrationFile = calPath + } + + if succeeded { + selectedRecipeID = r.id + workflow.wizard.showNotice("Applied \(r.name)") + } + refreshStaleness() + return succeeded + } + + // MARK: - Capture + + /// Whether the live `profile.calibrationFile` may be applied: + /// non-empty, not a `CAL_` stem, and present on disk. + var calApplyable: Bool { + let path = workflow.profile.calibrationFile + guard !path.isEmpty else { return false } + let stem = URL(fileURLWithPath: path) + .deletingPathExtension().lastPathComponent + guard !CalibrationIdentity.isCalibration(stem) else { return false } + return FileManager.default.fileExists(atPath: path) + } + + /// A bound preset whose colour space disagrees with the live form — + /// the sheet shows the mismatch caption and disables Save. + var captureColourSpaceMismatch: Bool { + guard let preset = workflow.selectedPreset else { return false } + return preset.colourSpace.lowercased() != workflow.colourSpace.rawValue + } + + /// Opens the capture sheet, prefilled from the selected recipe else + /// the most recently captured one ("last recipe … or empty"). + func beginCapture() { + let source = recipes.first(where: { $0.id == selectedRecipeID }) + ?? recipes.last + saveMediaPaper = source?.paperName ?? "" + saveMediaInk = source?.inkSet ?? "" + saveMediaName = "" + saveMediaNotes = "" + saveMediaError = nil + saveMediaApplyCal = workflow.profile.applyCalibration && calApplyable + if workflow.print.printers.isEmpty { + workflow.print.refreshPrinters() + } + workflow.showingSaveMedia = true + } + + /// Save button — the sheet closes only on `true`. + func captureFromSession() async -> Bool { + let name = saveMediaName.trimmingCharacters(in: .whitespacesAndNewlines) + let paper = saveMediaPaper.trimmingCharacters(in: .whitespacesAndNewlines) + let ink = saveMediaInk.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, !paper.isEmpty, !ink.isEmpty else { + saveMediaError = "Name, paper and ink are required." + return false + } + + let queue = workflow.print.selectedPrinter + guard !queue.isEmpty else { + saveMediaError = "Select a printer in Stage 2 first." + return false + } + + // Preset binding: the selected preset when its colour space + // matches the live form; otherwise auto-snapshot the live form + // as a custom preset (decision 2). + let presetID: String + if let bound = workflow.selectedPreset { + guard bound.colourSpace.lowercased() == workflow.colourSpace.rawValue else { + saveMediaError = "Colour space does not match the selected preset." + return false + } + presetID = bound.id + } else { + let snapshot = ProfilingPreset( + id: "custom-\(UUID().uuidString.lowercased())", + name: name, + description: "Auto-saved for media recipe", + targen: workflow.buildTargenConfig(), + printtarg: workflow.buildPrinttargConfig(), + colprof: workflow.profile.buildColprofConfig(), + calibrationFile: nil, + applyCalibration: nil + ) + do { + try environment.presetStore.saveCustom(snapshot) + workflow.reloadPresets() + workflow.selectedPresetID = snapshot.id + } catch { + saveMediaError = "Could not save preset: \(error.localizedDescription)" + return false + } + presetID = snapshot.id + } + + // The cal path is stored verbatim; applyCalibration is forced + // off for CAL_/missing paths via calApplyable. + let calPath = workflow.profile.calibrationFile + let printer = workflow.print.printers.first { $0.name == queue } + let now = Date() + let recipe = MediaRecipe( + id: "recipe-\(UUID().uuidString.lowercased())", + name: name, + notes: saveMediaNotes.trimmingCharacters(in: .whitespacesAndNewlines), + printerID: queue, + printerDisplayName: printer?.displayName ?? queue, + paperName: paper, + driverMediaType: workflow.print.selectedMediaType, + inkSet: ink, + colourSpace: workflow.colourSpace.rawValue, + presetID: presetID, + calibrationURL: calPath.isEmpty ? nil : calPath, + applyCalibration: saveMediaApplyCal && calApplyable, + created: now, + updated: now + ) + + do { + let validated = try recipe.validated() + try await store.upsert(validated) + await reloadAsync() + selectedRecipeID = validated.id + workflow.wizard.showNotice("Media recipe saved: \(validated.name)") + return true + } catch let error as MediaLibraryStore.MediaLibraryError { + saveMediaError = error.errorDescription + return false + } catch { + saveMediaError = "Could not save: \(error.localizedDescription)" + return false + } + } + + // MARK: - Delete + + func delete(_ recipe: MediaRecipe) { + Task { + do { + try await store.delete(id: recipe.id) + await reloadAsync() + if selectedRecipeID == recipe.id { + selectedRecipeID = "none" + } + } catch { + workflow.wizard.showNotice( + "Could not delete: \(error.localizedDescription)", + kind: .error) + } + } + } + + // MARK: - Staleness + + func refreshStaleness() { + Task { await refreshStalenessAsync() } + } + + /// Recomputes `staleReasons` + `calAgeDays` for every recipe. + /// + /// `.printer` fires only when `printerID` is absent from a + /// **non-empty** enumerated queue list — an un-enumerated list is + /// indeterminate, not stale (decision 5). `.calibration` is a pure + /// age check (`isStale()` with no `comparedTo:`) — the `.cal` + /// DESCRIPTOR is free text, not a queue id. + func refreshStalenessAsync() async { + let staleDays = environment.settingsStore.load().calibrationStaleDays + let queues = workflow.print.printers + let now = Date() + let calStore = CalibrationStore(staleDays: staleDays) + + var reasons: [String: StaleReason] = [:] + var ages: [String: Int] = [:] + for r in recipes { + var flags: StaleReason = [] + if !queues.isEmpty, !queues.contains(where: { $0.name == r.printerID }) { + flags.insert(.printer) + } + if let raw = r.calibrationURL?.trimmingCharacters(in: .whitespaces), + !raw.isEmpty, + FileManager.default.fileExists(atPath: raw), + (try? await calStore.load(url: URL(fileURLWithPath: raw))) != nil, + let created = await calStore.data?.created { + ages[r.id] = Calendar.current + .dateComponents([.day], from: created, to: now).day ?? 0 + if await calStore.isStale() { + flags.insert(.calibration) + } + } + if !flags.isEmpty { reasons[r.id] = flags } + } + staleReasons = reasons + calAgeDays = ages + } + + // MARK: - Manage sheet flow + + /// Called from the manage sheet's `onDismiss`. A deferred capture + /// request opens the save sheet only now, after the manage sheet has + /// fully dismissed. + func manageDismissed() { + if captureAfterManageDismiss { + captureAfterManageDismiss = false + beginCapture() + } + } +} diff --git a/Sources/ICCery/Print/PrintSessionViewModel.swift b/Sources/ICCery/Print/PrintSessionViewModel.swift index e8e4ed3..3eabd2f 100644 --- a/Sources/ICCery/Print/PrintSessionViewModel.swift +++ b/Sources/ICCery/Print/PrintSessionViewModel.swift @@ -24,24 +24,41 @@ final class PrintSessionViewModel: ObservableObject { self.environment = environment } + private var printerEnumTask: Task<[Printer]?, Never>? + func refreshPrinters() { - let cups = environment.cupsService - Task { @MainActor in + Task { @MainActor in _ = await enumeratePrinters() } + } + + /// Serialized queue enumeration — `listPrinters` uses fixed process + /// ids, so overlapping calls would throw `duplicateID`. Concurrent + /// callers coalesce onto the in-flight task (#146). + @discardableResult + func enumeratePrinters() async -> [Printer]? { + if let pending = printerEnumTask { return await pending.value } + let task = Task { @MainActor [weak self] () -> [Printer]? in + guard let self else { return nil } do { - let list = try await cups.listPrinters() - printers = list - if !list.contains(where: { $0.name == selectedPrinter }) { - selectedPrinter = list.first { $0.isDefault }?.name + let list = try await self.environment.cupsService.listPrinters() + self.printers = list + if !list.contains(where: { $0.name == self.selectedPrinter }) { + self.selectedPrinter = list.first { $0.isDefault }?.name ?? list.first?.name ?? "" } - await reloadSelectedCapabilities() + await self.reloadSelectedCapabilities() + return list } catch { - printNotice = Notice( + self.printNotice = Notice( kind: .error, text: "Could not list printers: \(error.localizedDescription)" ) + return nil } } + printerEnumTask = task + let result = await task.value + printerEnumTask = nil + return result } func reloadSelectedCapabilities() async { diff --git a/Sources/ICCery/RootView.swift b/Sources/ICCery/RootView.swift index aa67a55..031fb9b 100644 --- a/Sources/ICCery/RootView.swift +++ b/Sources/ICCery/RootView.swift @@ -56,6 +56,17 @@ struct RootView: View { .sheet(isPresented: $workflow.showingManagePresets) { ManagePresetsDialog(workflow: workflow) } + // Media library sheets live on RootView, never inside the + // 270 pt sidebar column (#146). + .sheet(isPresented: $workflow.showingSaveMedia) { + SaveMediaRecipeDialog(workflow: workflow) + } + .sheet( + isPresented: $workflow.showingManageMedia, + onDismiss: { workflow.media.manageDismissed() } + ) { + ManageMediaDialog(workflow: workflow) + } .sheet(isPresented: $showingAbout) { AboutView { showingAbout = false } } diff --git a/Sources/ICCery/SidebarView.swift b/Sources/ICCery/SidebarView.swift index 0c4d3c1..47fb1eb 100644 --- a/Sources/ICCery/SidebarView.swift +++ b/Sources/ICCery/SidebarView.swift @@ -9,6 +9,8 @@ struct SidebarView: View { /// through the parent's `objectWillChange`. @ObservedObject private var model: WizardViewModel @ObservedObject private var profile: ProfileWorkflowViewModel + @ObservedObject private var media: MediaLibraryViewModel + @ObservedObject private var printSession: PrintSessionViewModel var onOpenSettings: () -> Void var onOpenAbout: () -> Void @Binding var showingAllHelp: Bool @@ -22,6 +24,8 @@ struct SidebarView: View { self.workflow = workflow self._model = ObservedObject(wrappedValue: workflow.wizard) self._profile = ObservedObject(wrappedValue: workflow.profile) + self._media = ObservedObject(wrappedValue: workflow.media) + self._printSession = ObservedObject(wrappedValue: workflow.print) self.onOpenSettings = onOpenSettings self.onOpenAbout = onOpenAbout self._showingAllHelp = showingAllHelp @@ -90,6 +94,56 @@ struct SidebarView: View { .padding(.horizontal, 12) .padding(.bottom, 8) + // Media library (`#mediaSelect`) — issue #146. Selection + // applies the recipe immediately, like presets; names render + // via Text only (#114). Never reuses `presetSelect` (#137). + Picker("Media", selection: Binding( + get: { media.selectedRecipeID }, + set: { media.selectRecipe($0) } + )) { + Text("No media recipe").tag("none") + ForEach(media.recipes) { recipe in + Text(recipe.name).tag(recipe.id) + } + } + .pickerStyle(.menu) + .accessibilityIdentifier("mediaSelect") + .padding(.horizontal, 12) + .padding(.vertical, 8) + .helpOverlay( + "Saved printer + paper + ink + .cal bound to a preset.", + showing: $showingAllHelp) + + if let reasons = media.staleReasons[media.selectedRecipeID], + !reasons.isEmpty { + Text(reasons.contains(.printer) + ? "Printer not installed" : "Calibration stale") + .font(.caption) + .foregroundStyle(.orange) + .padding(.horizontal, 12) + .accessibilityIdentifier("mediaRecipeStale") + .helpOverlay( + "Re-run Stage 0 or pick a different recipe.", + showing: $showingAllHelp) + } + + HStack(spacing: 8) { + Button("Capture") { media.beginCapture() } + .disabled(printSession.selectedPrinter.isEmpty) + .accessibilityIdentifier("btnMediaLibraryCapture") + .helpOverlay( + "Select a printer in Stage 2 first", + showing: $showingAllHelp) + Button("Manage") { workflow.showingManageMedia = true } + .accessibilityIdentifier("btnMediaLibraryManage") + .helpOverlay( + "Apply or delete saved media recipes.", + showing: $showingAllHelp) + Spacer() + } + .padding(.horizontal, 12) + .padding(.bottom, 8) + // Calibrate Printer (`#btnCalibratePrinter`). Button(action: { model.enterCalibration() }) { Label("Calibrate Printer", systemImage: "slider.horizontal.3") diff --git a/Sources/ICCery/TargetWorkflowViewModel.swift b/Sources/ICCery/TargetWorkflowViewModel.swift index f89d37e..aa2da45 100644 --- a/Sources/ICCery/TargetWorkflowViewModel.swift +++ b/Sources/ICCery/TargetWorkflowViewModel.swift @@ -97,6 +97,11 @@ final class TargetWorkflowViewModel: ObservableObject { @Published var savePresetName = "" @Published var savePresetDesc = "" + // MARK: - Media library (issue #146) + + @Published var showingSaveMedia = false + @Published var showingManageMedia = false + /// Stage 3 measurement workflow, owned at the app level so it persists /// across stage switches and can observe settings changes. @Published var measurement: MeasurementWorkflowViewModel @@ -107,6 +112,8 @@ final class TargetWorkflowViewModel: ObservableObject { @Published var calibration: CalibrationViewModel! /// Stage 2 unmanaged print session. @Published var print: PrintSessionViewModel! + /// Media recipe library, created last — it needs a complete `self`. + @Published var media: MediaLibraryViewModel! init(environment: AppEnvironment = .live()) { self.environment = environment @@ -126,6 +133,10 @@ final class TargetWorkflowViewModel: ObservableObject { profile: self.profile, environment: environment ) + self.media = MediaLibraryViewModel( + workflow: self, + environment: environment + ) reloadPresets() } diff --git a/Tests/ICCeryCoreTests/MediaLibraryStoreTests.swift b/Tests/ICCeryCoreTests/MediaLibraryStoreTests.swift new file mode 100644 index 0000000..c72d108 --- /dev/null +++ b/Tests/ICCeryCoreTests/MediaLibraryStoreTests.swift @@ -0,0 +1,96 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +/// Issue #146 — `MediaLibraryStore` persistence contract. +final class MediaLibraryStoreTests: XCTestCase { + + private var url: URL! + + override func setUp() { + url = FileManager.default.temporaryDirectory + .appendingPathComponent("media-lib-\(UUID().uuidString).json") + } + + override func tearDown() { + try? FileManager.default.removeItem(at: url) + url = nil + } + + private func recipe(id: String, name: String = "R") -> MediaRecipe { + MediaRecipe( + id: id, name: name, printerID: "q", + colourSpace: "rgb", presetID: "preset-std-rgb") + } + + func testRoundTrip() async throws { + let store = MediaLibraryStore(url: url) + let r = recipe(id: "recipe-1", name: "Epson Rag") + try await store.upsert(r) + let loaded = try await store.load() + XCTAssertEqual(loaded, [r]) + } + + func testCorruptFileThrowsAndPreservesBytes() async throws { + try "not json".write(to: url, atomically: true, encoding: .utf8) + let before = try Data(contentsOf: url) + + let store = MediaLibraryStore(url: url) + await assertAsyncThrows(expectedType: DecodingError.self) { + try await store.load() + } + // upsert must also propagate — a corrupt file is never wiped. + await assertAsyncThrows(expectedType: DecodingError.self) { + try await store.upsert(recipe(id: "x")) + } + XCTAssertEqual(try Data(contentsOf: url), before) + } + + func testDelete() async throws { + let store = MediaLibraryStore(url: url) + try await store.upsert(recipe(id: "a")) + try await store.upsert(recipe(id: "b")) + + let removed = try await store.delete(id: "a") + XCTAssertTrue(removed) + let remaining = try await store.load().map(\.id) + XCTAssertEqual(remaining, ["b"]) + + let again = try await store.delete(id: "a") + XCTAssertFalse(again) + } + + func testCapacityReached() async throws { + let store = MediaLibraryStore(url: url, capacity: 2) + try await store.upsert(recipe(id: "1")) + try await store.upsert(recipe(id: "2")) + await assertAsyncThrows( + expectedType: MediaLibraryStore.MediaLibraryError.self + ) { + try await store.upsert(recipe(id: "3")) + } errorHandler: { + XCTAssertEqual($0, .capacityReached(2)) + } + let stored = try await store.load() + XCTAssertEqual(stored.count, 2) + } + + func testUpsertPreservesCreatedBumpsUpdated() async throws { + let store = MediaLibraryStore(url: url) + var r = recipe(id: "recipe-1") + r.created = Date(timeIntervalSince1970: 1_000_000) + r.updated = Date(timeIntervalSince1970: 1_000_000) + try await store.upsert(r) + + var edit = r + edit.name = "Renamed" + edit.updated = Date(timeIntervalSince1970: 2_000_000) + try await store.upsert(edit) + + let loaded = try await store.load() + XCTAssertEqual(loaded.count, 1) + XCTAssertEqual(loaded[0].name, "Renamed") + XCTAssertEqual(loaded[0].created, r.created) + XCTAssertGreaterThan(loaded[0].updated, r.updated) + } +} diff --git a/Tests/ICCeryCoreTests/MediaLibraryViewModelTests.swift b/Tests/ICCeryCoreTests/MediaLibraryViewModelTests.swift new file mode 100644 index 0000000..d62e6ce --- /dev/null +++ b/Tests/ICCeryCoreTests/MediaLibraryViewModelTests.swift @@ -0,0 +1,406 @@ +import Foundation +import XCTest +@testable import ICCeryCore +@testable import ICCery + +/// Issue #146 — `MediaLibraryViewModel` apply / capture / staleness +/// under an isolated `TestAppEnvironment` with a mock CUPS `bin` dir. +@MainActor +final class MediaLibraryViewModelTests: XCTestCase { + + // CTI3 fixture mirrored from CalibrationStoreTests. + private static let sampleCal = """ + CTI3 + DESCRIPTOR "Test printer" + COLOR_REP "RGB" + DEVICE_CLASS "OUTPUT" + MAX_TAC "300" + NUMBER_OF_FIELDS 5 + NUMBER_OF_SETS 3 + BEGIN_DATA_FORMAT + SAMPLE_ID INPUT_VALUE R G B + END_DATA_FORMAT + BEGIN_DATA + 1 0 0 0 0 + 2 128 64 64 64 + 3 255 255 255 255 + END_DATA + """ + + /// Same fixture plus an old CREATED keyword so the age check fires. + private static let staleCal = """ + CTI3 + DESCRIPTOR "Test printer" + CREATED "2020-01-01T00:00:00Z" + COLOR_REP "RGB" + DEVICE_CLASS "OUTPUT" + NUMBER_OF_FIELDS 5 + NUMBER_OF_SETS 3 + BEGIN_DATA_FORMAT + SAMPLE_ID INPUT_VALUE R G B + END_DATA_FORMAT + BEGIN_DATA + 1 0 0 0 0 + 2 128 64 64 64 + 3 255 255 255 255 + END_DATA + """ + + private var env: TestAppEnvironment! + private var workflow: TargetWorkflowViewModel! + private var media: MediaLibraryViewModel! + + override func setUp() async throws { + env = try TestAppEnvironment.make() + try installMockCups() + workflow = TargetWorkflowViewModel(environment: env.environment) + media = workflow.media + await media.reloadAsync() + } + + override func tearDown() async throws { + env?.cleanup() + env = nil + workflow = nil + media = nil + } + + /// Mock `lpstat`/`lpoptions` inside the env's `cups-bin` (the + /// `CupsService.binaryDir` `TestAppEnvironment` points at). Queues: + /// `Mock_Queue` (default) and `Other_Queue`. + private func installMockCups() throws { + let bin = env.root.appendingPathComponent("cups-bin") + try FileManager.default.createDirectory( + at: bin, withIntermediateDirectories: true) + + let lpstat = """ + #!/bin/sh + case "$1" in + -e) printf 'Mock_Queue\\nOther_Queue\\n' ;; + -p) printf 'printer Mock_Queue is idle.\\nprinter Other_Queue is idle.\\n' ;; + -d) printf 'system default destination: Mock_Queue\\n' ;; + esac + exit 0 + """ + let lpoptions = """ + #!/bin/sh + list=0 + queue="" + for arg in "$@"; do + case "$arg" in + -l) list=1 ;; + -p) ;; + *) queue="$arg" ;; + esac + done + if [ "$list" = "1" ]; then + printf 'PageSize/Media Size: *A4 Letter\\n' + printf 'MediaType/Media Type: *Stationery Glossy\\n' + exit 0 + fi + printf "printer-info='Mock %s' printer-type=42\\n" "$queue" + exit 0 + """ + for (name, body) in ["lpstat": lpstat, "lpoptions": lpoptions] { + let url = bin.appendingPathComponent(name) + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path) + } + } + + private func writeCal( + _ contents: String = MediaLibraryViewModelTests.sampleCal, + named name: String = "recipe.cal" + ) throws -> String { + let url = env.root.appendingPathComponent(name) + try contents.write(to: url, atomically: true, encoding: .utf8) + return url.path + } + + private func makeRecipe( + id: String = "recipe-t1", + printerID: String = "Mock_Queue", + colourSpace: String = "rgb", + presetID: String = "preset-std-rgb", + calibrationURL: String? = nil, + applyCalibration: Bool = false + ) -> MediaRecipe { + MediaRecipe( + id: id, + name: "Test Recipe", + printerID: printerID, + printerDisplayName: "Mock Queue Display", + paperName: "Rag", + inkSet: "PK", + colourSpace: colourSpace, + presetID: presetID, + calibrationURL: calibrationURL, + applyCalibration: applyCalibration) + } + + private func seed(_ recipe: MediaRecipe) async throws { + try await env.environment.mediaStore.upsert(recipe) + await media.reloadAsync() + } + + // MARK: - Apply + + func testApplyHappyPath() async throws { + let calPath = try writeCal() + let r = makeRecipe( + calibrationURL: calPath, applyCalibration: true) + try await seed(r) + + let applied = await media.apply(r) + + XCTAssertTrue(applied) + XCTAssertEqual(workflow.print.selectedPrinter, "Mock_Queue") + XCTAssertEqual(workflow.wizard.printerName, "Mock Queue Display") + XCTAssertTrue(workflow.profile.applyCalibration) + XCTAssertEqual(workflow.profile.calibrationFile, calPath) + XCTAssertEqual(media.selectedRecipeID, r.id) + XCTAssertEqual(workflow.selectedPresetID, "preset-std-rgb") + XCTAssertEqual( + workflow.buildPrinttargConfig().calibrationFile, calPath) + } + + func testApplyMissingPresetRefuses() async throws { + let r = makeRecipe(presetID: "preset-nonexistent") + try await seed(r) + + let applied = await media.apply(r) + + XCTAssertFalse(applied) + XCTAssertEqual(media.selectedRecipeID, "none") + XCTAssertEqual(workflow.selectedPresetID, "none") + XCTAssertTrue( + workflow.wizard.notice?.text.contains("no longer exists") == true) + } + + func testApplyColourSpaceMismatchRefuses() async throws { + let r = makeRecipe(colourSpace: "cmyk", presetID: "preset-std-rgb") + try await seed(r) + + let applied = await media.apply(r) + + XCTAssertFalse(applied) + XCTAssertEqual(media.selectedRecipeID, "none") + XCTAssertTrue( + workflow.wizard.notice?.text.contains("colour space") == true) + } + + func testApplyMissingCalFileFails() async throws { + let missing = env.root.appendingPathComponent("gone.cal").path + let r = makeRecipe( + calibrationURL: missing, applyCalibration: true) + try await seed(r) + + let applied = await media.apply(r) + + XCTAssertFalse(applied) + XCTAssertFalse(workflow.profile.applyCalibration) + XCTAssertEqual(workflow.profile.calibrationFile, missing) + XCTAssertEqual(workflow.wizard.notice?.kind, .error) + XCTAssertEqual(media.selectedRecipeID, "none") + } + + func testApplyCalPrefixedCalCannotArmK() async throws { + let calPath = try writeCal(named: "CAL_target.cal") + let r = makeRecipe( + calibrationURL: calPath, applyCalibration: true) + try await seed(r) + + let applied = await media.apply(r) + + // Success with warning — the refusal is permanent, re-clicking + // cannot unstick it (decision 6). + XCTAssertTrue(applied) + XCTAssertFalse(workflow.profile.applyCalibration) + XCTAssertEqual(workflow.profile.calibrationFile, calPath) + XCTAssertNil(workflow.buildPrinttargConfig().calibrationFile) + XCTAssertEqual(media.selectedRecipeID, r.id) + XCTAssertEqual(workflow.wizard.notice?.kind, .warning) + } + + func testApplyLiveCalBasenameBlocks() async throws { + let calPath = try writeCal() + let r = makeRecipe( + calibrationURL: calPath, applyCalibration: true) + try await seed(r) + workflow.wizard.basename = "CAL_live" + + let applied = await media.apply(r) + + XCTAssertTrue(applied) + XCTAssertFalse(workflow.profile.applyCalibration) + XCTAssertNil(workflow.buildPrinttargConfig().calibrationFile) + } + + func testApplyMissingQueueLeavesQueueUntouched() async throws { + workflow.print.selectedPrinter = "Other_Queue" + let r = makeRecipe(printerID: "No_Such_Queue") + try await seed(r) + + let applied = await media.apply(r) + + XCTAssertFalse(applied) + XCTAssertEqual(workflow.print.selectedPrinter, "Other_Queue") + XCTAssertTrue( + workflow.wizard.notice?.text.contains("is not installed") == true) + XCTAssertEqual(media.selectedRecipeID, "none") + } + + // MARK: - Capture + + func testCaptureCopiesPrinterAndPreset() async throws { + workflow.print.printers = [ + Printer(name: "Mock_Queue", displayName: "Mock Queue Display") + ] + workflow.print.selectedPrinter = "Mock_Queue" + workflow.selectedPresetID = "preset-std-rgb" + media.saveMediaName = "My Recipe" + media.saveMediaPaper = "Rag" + media.saveMediaInk = "PK" + + let saved = await media.captureFromSession() + + XCTAssertTrue(saved) + let stored = await env.environment.mediaStore.all() + XCTAssertEqual(stored.count, 1) + XCTAssertEqual(stored[0].printerID, "Mock_Queue") + XCTAssertEqual(stored[0].printerDisplayName, "Mock Queue Display") + XCTAssertEqual(stored[0].presetID, "preset-std-rgb") + XCTAssertEqual(stored[0].colourSpace, "rgb") + XCTAssertEqual(media.selectedRecipeID, stored[0].id) + } + + func testCaptureNoPresetAutoSnapshots() async throws { + workflow.print.printers = [ + Printer(name: "Mock_Queue", displayName: "Mock Queue Display") + ] + workflow.print.selectedPrinter = "Mock_Queue" + workflow.selectedPresetID = "none" + media.saveMediaName = "Snap" + media.saveMediaPaper = "Rag" + media.saveMediaInk = "MK" + + let saved = await media.captureFromSession() + + XCTAssertTrue(saved) + let stored = await env.environment.mediaStore.all() + XCTAssertEqual(stored.count, 1) + XCTAssertTrue(stored[0].presetID.hasPrefix("custom-")) + XCTAssertTrue( + env.environment.presetStore.customs() + .contains { $0.id == stored[0].presetID }) + XCTAssertEqual(workflow.selectedPresetID, stored[0].presetID) + } + + func testCaptureRequiresPrinter() async { + workflow.print.selectedPrinter = "" + media.saveMediaName = "n" + media.saveMediaPaper = "p" + media.saveMediaInk = "i" + + let saved = await media.captureFromSession() + + XCTAssertFalse(saved) + XCTAssertNotNil(media.saveMediaError) + } + + func testCaptureForcesOffCalToggleForCalFile() async throws { + let calPath = try writeCal(named: "CAL_target.cal") + workflow.profile.calibrationFile = calPath + workflow.profile.applyCalibration = true + workflow.print.printers = [Printer(name: "Mock_Queue")] + workflow.print.selectedPrinter = "Mock_Queue" + workflow.selectedPresetID = "preset-std-rgb" + media.saveMediaName = "n" + media.saveMediaPaper = "p" + media.saveMediaInk = "i" + media.saveMediaApplyCal = true // forced off by calApplyable + + XCTAssertFalse(media.calApplyable) + let saved = await media.captureFromSession() + + XCTAssertTrue(saved) + let stored = await env.environment.mediaStore.all() + XCTAssertEqual(stored[0].calibrationURL, calPath) // verbatim + XCTAssertFalse(stored[0].applyCalibration) + } + + // MARK: - Staleness + + func testStaleCalFlagged() async throws { + let calPath = try writeCal( + MediaLibraryViewModelTests.staleCal, named: "old.cal") + let r = makeRecipe(calibrationURL: calPath) + try await seed(r) + workflow.print.printers = [Printer(name: "Mock_Queue")] + + await media.refreshStalenessAsync() + + XCTAssertTrue( + media.staleReasons[r.id]?.contains(.calibration) == true) + XCTAssertNotNil(media.calAgeDays[r.id]) + } + + func testAbsentQueueFlaggedOnlyWhenListNonEmpty() async throws { + let r = makeRecipe(printerID: "No_Such_Queue") + try await seed(r) + + // Un-enumerated list is indeterminate → no flag. + workflow.print.printers = [] + await media.refreshStalenessAsync() + XCTAssertNil(media.staleReasons[r.id]) + + // Absent from a non-empty list → .printer. + workflow.print.printers = [Printer(name: "Other_Queue")] + await media.refreshStalenessAsync() + XCTAssertTrue( + media.staleReasons[r.id]?.contains(.printer) == true) + + // Present but unselected → no flag. + workflow.print.printers = [ + Printer(name: "Other_Queue"), Printer(name: "No_Such_Queue"), + ] + workflow.print.selectedPrinter = "Other_Queue" + await media.refreshStalenessAsync() + XCTAssertNil(media.staleReasons[r.id]) + } + + // MARK: - Delete + + func testDeleteResetsSelection() async throws { + let r = makeRecipe() + try await seed(r) + media.selectedRecipeID = r.id + + media.delete(r) + try await Task.sleep(nanoseconds: 200_000_000) + + XCTAssertEqual(media.selectedRecipeID, "none") + let stored = await env.environment.mediaStore.all() + XCTAssertTrue(stored.isEmpty) + } + + // MARK: - Corrupt library + + func testCorruptLibraryKeepsFileAndWarns() async throws { + // Fresh environment so the store's `loaded` flag is still false. + let env2 = try TestAppEnvironment.make() + defer { env2.cleanup() } + try "garbage".write( + to: env2.mediaLibraryURL, atomically: true, encoding: .utf8) + let workflow2 = TargetWorkflowViewModel( + environment: env2.environment) + await workflow2.media.reloadAsync() + + XCTAssertTrue(workflow2.media.recipes.isEmpty) + XCTAssertEqual(workflow2.media.selectedRecipeID, "none") + XCTAssertEqual(workflow2.wizard.notice?.kind, .warning) + XCTAssertEqual(try Data(contentsOf: env2.mediaLibraryURL), + "garbage".data(using: .utf8)) + } +} diff --git a/Tests/ICCeryCoreTests/MediaRecipeTests.swift b/Tests/ICCeryCoreTests/MediaRecipeTests.swift new file mode 100644 index 0000000..1c02039 --- /dev/null +++ b/Tests/ICCeryCoreTests/MediaRecipeTests.swift @@ -0,0 +1,126 @@ +import Foundation +import XCTest +@testable import ICCeryCore + +/// Issue #146 — `MediaRecipe` Codable + validation contract. +final class MediaRecipeTests: XCTestCase { + + private func makeRecipe() -> MediaRecipe { + MediaRecipe( + id: "recipe-abc", + name: "Epson Rag", + notes: "notes", + printerID: "epson_p900", + printerDisplayName: "Epson SureColor P900", + paperName: "Rag Photographique", + driverMediaType: "PhotographicGlossy", + inkSet: "PK", + colourSpace: "rgb", + presetID: "preset-std-rgb", + calibrationURL: "/tmp/prof.cal", + applyCalibration: true, + created: Date(timeIntervalSince1970: 1_700_000_000), + updated: Date(timeIntervalSince1970: 1_700_000_100) + ) + } + + func testRoundTripSnakeCase() throws { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(makeRecipe()) + let object = try JSONSerialization.jsonObject(with: data) as! [String: Any] + + for key in [ + "printer_id", "printer_display_name", "paper_name", + "driver_media_type", "ink_set", "colour_space", "preset_id", + "calibration_url", "apply_calibration", "created", "updated", + "id", "name", "notes", + ] { + XCTAssertNotNil(object[key], "missing key \(key)") + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(MediaRecipe.self, from: data) + XCTAssertEqual(decoded, makeRecipe()) + } + + func testMissingRequiredKeyThrows() { + for key in ["id", "name", "printer_id", "colour_space", "preset_id"] { + var dict: [String: Any] = [ + "id": "r1", "name": "n", "printer_id": "q", + "colour_space": "rgb", "preset_id": "p", + ] + dict.removeValue(forKey: key) + let data = try! JSONSerialization.data(withJSONObject: dict) + XCTAssertThrowsError( + try JSONDecoder().decode(MediaRecipe.self, from: data), + "expected throw without \(key)") + } + } + + func testUnknownKeysIgnored() throws { + let dict: [String: Any] = [ + "id": "r1", "name": "n", "printer_id": "q", + "colour_space": "rgb", "preset_id": "p", + "future_field": "ignored", + ] + let data = try JSONSerialization.data(withJSONObject: dict) + let recipe = try JSONDecoder().decode(MediaRecipe.self, from: data) + XCTAssertEqual(recipe.id, "r1") + XCTAssertEqual(recipe.notes, "") + XCTAssertFalse(recipe.applyCalibration) + } + + func testValidatedGoldens() { + var r = makeRecipe() + + r.name = " " + XCTAssertThrowsError(try r.validated()) { + XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyName) + } + + r = makeRecipe() + r.printerID = "" + XCTAssertThrowsError(try r.validated()) { + XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyPrinterID) + } + + r = makeRecipe() + r.presetID = "" + XCTAssertThrowsError(try r.validated()) { + XCTAssertEqual($0 as? MediaRecipe.ValidationError, .emptyPresetID) + } + + r = makeRecipe() + r.colourSpace = "lab" + XCTAssertThrowsError(try r.validated()) { + XCTAssertEqual( + $0 as? MediaRecipe.ValidationError, .invalidColourSpace("lab")) + } + + for bad in ["../evil.cal", "rel/path.cal", "/tmp/a\0b.cal"] { + r = makeRecipe() + r.calibrationURL = bad + XCTAssertThrowsError(try r.validated(), "expected throw for \(bad)") { + XCTAssertEqual( + $0 as? MediaRecipe.ValidationError, + .invalidCalibrationURL(bad)) + } + } + } + + func testColourSpaceNormalisedToLowercase() throws { + var r = makeRecipe() + r.colourSpace = "RGB" + let validated = try r.validated() + XCTAssertEqual(validated.colourSpace, "rgb") + } + + func testCalPrefixedCalNameIsSchemaValid() throws { + var r = makeRecipe() + // CAL_ refusal is an apply-time policy, not a schema error. + r.calibrationURL = "/tmp/CAL_target.cal" + XCTAssertNoThrow(try r.validated()) + } +} diff --git a/Tests/ICCeryCoreTests/TestAppEnvironment.swift b/Tests/ICCeryCoreTests/TestAppEnvironment.swift index 1a7f9d0..4594be8 100644 --- a/Tests/ICCeryCoreTests/TestAppEnvironment.swift +++ b/Tests/ICCeryCoreTests/TestAppEnvironment.swift @@ -19,6 +19,9 @@ struct TestAppEnvironment { var historyURL: URL { root.appendingPathComponent("verification_history.json") } + var mediaLibraryURL: URL { + root.appendingPathComponent("media_library.json") + } /// Creates an isolated environment under `NSTemporaryDirectory()`. /// Call `cleanup()` when finished. @@ -49,6 +52,9 @@ struct TestAppEnvironment { ), historyStore: VerificationHistoryStore( url: root.appendingPathComponent("verification_history.json") + ), + mediaStore: MediaLibraryStore( + url: root.appendingPathComponent("media_library.json") ) ) return TestAppEnvironment(root: root, environment: environment) diff --git a/Tests/ICCeryUITests/Milestone10MediaLibraryUITests.swift b/Tests/ICCeryUITests/Milestone10MediaLibraryUITests.swift new file mode 100644 index 0000000..bb4b518 --- /dev/null +++ b/Tests/ICCeryUITests/Milestone10MediaLibraryUITests.swift @@ -0,0 +1,162 @@ +import XCTest + +/// Milestone 10 UI tests — issue #146 media recipe library. Mock CUPS +/// binaries (`ICCERY_CUPS_BIN_DIR` → `Fixtures/bin`) emit +/// `Mock_Epson_7450` / `Mock_Canon_Pro`; recipes are seeded by writing +/// `/AppData/media_library.json` before launch — +/// `AppPaths` redirects app data under `ICCERY_TEST_ROOT`. All queries +/// are by identifier only ("Media" also appears in help overlays). +@MainActor +final class Milestone10MediaLibraryUITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + + override func setUp() async throws { + continueAfterFailure = false + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui10-\(UUID().uuidString)") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + + let appData = testRoot.appendingPathComponent("AppData", isDirectory: true) + try FileManager.default.createDirectory( + at: appData, withIntermediateDirectories: true) + // A recipe bound to a queue that is never enumerated. + let fixture = """ + [ + { + "id": "fixture-missing-queue", + "name": "Missing Queue Recipe", + "notes": "", + "printer_id": "No_Such_Queue", + "printer_display_name": "Missing Queue", + "paper_name": "Rag", + "ink_set": "PK", + "colour_space": "rgb", + "preset_id": "preset-std-rgb", + "calibration_url": null, + "apply_calibration": false, + "created": "2026-09-12T00:00:00Z", + "updated": "2026-09-12T00:00:00Z" + } + ] + """ + try fixture.write( + to: appData.appendingPathComponent("media_library.json"), + atomically: true, encoding: .utf8) + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_ROOT": testRoot.path, + "ICCERY_ARGYLL_BINARY_DIR": binDir.path, + "ICCERY_CUPS_BIN_DIR": binDir.path, + ] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + testRoot = nil + } + + private func launchApp() { + app.launch() + if !app.wait(for: .runningForeground, timeout: 10) { + app.activate() + } + } + + private func element(_ id: String) -> XCUIElement { + let inApp = app.descendants(matching: .any)[id].firstMatch + if inApp.exists { return inApp } + return app.sheets.firstMatch.descendants(matching: .any)[id].firstMatch + } + + private func waitFor(_ id: String, timeout: TimeInterval = 10) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let el = element(id) + if el.exists { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + let el = element(id) + XCTAssertTrue(el.exists, "Expected element \(id)") + return el + } + + private func waitForEnabled(_ id: String, timeout: TimeInterval = 15) -> XCUIElement { + let el = waitFor(id, timeout: timeout) + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if el.isEnabled { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + return el + } + + func testMediaPickerDoesNotReusePresetSelect() throws { + launchApp() + + let preset = app.popUpButtons["presetSelect"] + XCTAssertTrue(preset.waitForExistence(timeout: 10)) + let media = app.popUpButtons["mediaSelect"] + XCTAssertTrue(media.waitForExistence(timeout: 10)) + } + + func testCaptureRequiresNamePaperInk() throws { + launchApp() + + // Capture enables once the mock CUPS enumeration selects a queue. + let capture = waitForEnabled("btnMediaLibraryCapture") + XCTAssertTrue(capture.isEnabled) + capture.click() + + _ = waitFor("saveMediaRecipeDialog") + let save = element("btnConfirmSaveMedia") + XCTAssertTrue(save.exists) + XCTAssertFalse(save.isEnabled) + + for (id, text) in [ + ("saveMediaName", "UI Recipe"), + ("saveMediaPaper", "Rag"), + ("saveMediaInk", "PK"), + ] { + let field = element(id) + field.click() + field.typeText(text) + } + + XCTAssertTrue(save.isEnabled) + } + + func testManageApplyMissingPrinterShowsBanner() throws { + launchApp() + + let manage = app.buttons["btnMediaLibraryManage"] + XCTAssertTrue(manage.waitForExistence(timeout: 10)) + manage.click() + _ = waitFor("manageMediaDialog") + + let apply = element("btnMediaLibraryApply-fixture-missing-queue") + XCTAssertTrue(apply.waitForExistence(timeout: 10)) + apply.click() + + let notice = waitFor("noticeText") + let text = (notice.value as? String) ?? notice.label + XCTAssertTrue( + text.contains("is not installed"), + "expected not-installed notice, got: \(text)") + + // A failed apply keeps the manage sheet open and the sidebar + // picker reverts. + XCTAssertTrue(element("manageMediaDialog").exists) + XCTAssertTrue(element("mediaSelect").exists) + } +} diff --git a/docs/21-ui-reference.md b/docs/21-ui-reference.md index 648cd33..d2006b7 100644 --- a/docs/21-ui-reference.md +++ b/docs/21-ui-reference.md @@ -1,14 +1,14 @@ # 21 — UI reference -Vanilla HTML + CSS. Rewrite may use any toolkit; **ids and behaviours** below are the functional contract. **242 element ids** in `src/index.html` — complete roster at the end. +Vanilla HTML + CSS. Rewrite may use any toolkit; **ids and behaviours** below are the functional contract. **267 element ids** in `src/index.html` — complete roster at the end. ## Shell -- Sidebar 270 px: logo `./assets/ICCery-logo.svg`, settings/about icon buttons, preset select, Calibrate Printer, cal status chip, stepper 1–5. +- Sidebar 270 px: logo `./assets/ICCery-logo.svg`, settings/about icon buttons, preset select, media recipe select + Capture/Manage, Calibrate Printer, cal status chip, stepper 1–5. - Main: notification banner, one visible `.stage`. - Window 1280×800, min 1100×700, hidden until paint, dark `#1A1A22`. -Sidebar chrome ids: `openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`. +Sidebar chrome ids: `openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `btnCalibratePrinter`, `calStatusChip`. Banner: `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`. Auto-hides via `wizardState.noticeTimer`. @@ -70,6 +70,8 @@ Keyboard: **R** resets gamut camera when Stage 5 is visible. Bind to a focusable | About | `aboutDialog` | `aboutVersion`, `aboutBuildDate` from `get_app_info`, `closeAboutBtn` | | Save preset | `savePresetDialog` | `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog` | | Manage presets | `managePresetsDialog` | `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog` | +| Save media recipe | `saveMediaRecipeDialog` | `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog` | +| Manage media recipes | `manageMediaDialog` | `mediaLibraryList`, `mediaLibraryEmpty`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`, `btnMediaLibraryApply`, `btnMediaLibraryCaptureFromManage`, `btnCloseManageMediaDialog` | | Cal collision | `calCollisionDialog` | Overwrite / Rename / Cancel | | Profile install collision | `profileInstallCollisionDialog` | `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn` | @@ -77,6 +79,6 @@ Keyboard: **R** resets gamut camera when Stage 5 is visible. Bind to a focusable Tauri v2 has **no** `window.__TAURI__.dialog`. Use invoke wrappers (`select_*`). Bugs #103, #210, #211 were exactly this. -## Complete `id=` roster (242) +## Complete `id=` roster (267) -`openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`, `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`, `stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalBackToWizard`, `calLogContainer`, `calLog`, `stage-1`, `btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`, `stage-2`, `cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`, `stage-3`, `stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`, `stage-4`, `colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`, `stage-5`, `btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`, `settingsDialog`, `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`, `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`, `profileInstallCollisionDialog`, `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`, `aboutDialog`, `aboutVersion`, `aboutBuildDate`, `closeAboutBtn`, `savePresetDialog`, `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog`, `managePresetsDialog`, `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog`. +`openSettingsBtn`, `openAboutBtn`, `btnSavePresetModal`, `btnOpenPresetsDialog`, `presetSelect`, `btnCalibratePrinter`, `calStatusChip`, `wizardNotification`, `wizardNotificationIcon`, `wizardNotificationText`, `wizardNotificationClose`, `stage-cal`, `calApplyToggleDash`, `calRgbHint`, `calSteps`, `calInkExplore`, `calNeutralEmphasis`, `btnCalGenerate`, `btnCalLayout`, `btnCalMeasure`, `calCurrentFile`, `btnCalLoad`, `btnCalLibrary`, `btnCalClear`, `calSavedSelect`, `btnCalCompute`, `calCurveSvg`, `calCurveLegend`, `calTacValue`, `calTacOverride`, `calInkLimitControls`, `calRecommendedPower`, `btnCalBackToWizard`, `calLogContainer`, `calLog`, `stage-1`, `btnToggleAllHelp`, `calStage1Recommend`, `btnCalRecalibrate`, `stage1FormContainer`, `patchCountPreset`, `patchCountCustom`, `whitePatches`, `blackPatches`, `btn-import-dataset`, `btnOpenExisting`, `targetBasename`, `btnBrowse`, `selectedPathDisplay`, `targenAdvancedDetails`, `targenPrecondProfile`, `btnBrowsePrecondProfile`, `targenNeutralSteps`, `targenNeutralConcentration`, `targenNeutralConcVal`, `targenGreySteps`, `targenSingleChannelSteps`, `targenAdaptation`, `targenAdaptationVal`, `targenDarkEmphasis`, `targenDarkEmphasisVal`, `targenDevicePower`, `targenInkLimitGroup`, `targenInkLimit`, `targenAlgorithm`, `targenHighQuality`, `btnGenerate`, `targenLogContainer`, `targenLog`, `stage-2`, `cmWarningBanner`, `instrumentSelect`, `pageSizeSelect`, `customPageSizeRow`, `customPageW`, `customPageH`, `tiffDpi`, `printtargLayoutOrder`, `printtargCustomSeedGroup`, `printtargCustomSeed`, `btnToggleLabelEdit`, `targetMetadataPrinter`, `targetMetadataInkSet`, `targetMetadataDriverPaper`, `targetMetadataActualPaper`, `targetLabelPreview`, `btnCreateLayout`, `printtargLogContainer`, `printtargLog`, `tiffGallery`, `galleryInfo`, `galleryGrid`, `rawPrintPanel`, `printNotification`, `printNotificationIcon`, `printNotificationText`, `printerSelect`, `btnRefreshPrinters`, `btnPrinterProperties`, `printerStatusBadge`, `cupsOptionsGroup`, `chkPpdFallback`, `printerTraySelect`, `mediaTypeGroup`, `printerMediaTypeSelect`, `btnOrientPortrait`, `btnOrientLandscape`, `btnPrintAll`, `btnAdvanceToStage3`, `stage-3`, `stage3LoadedTargetBanner`, `stage3TargetBasename`, `stage3TargetMeta`, `stage3TargetBadge`, `chartreadInstrumentSelect`, `btnDetectInstruments`, `xyTableHint`, `xyTablePanel`, `xyTableActiveStepBadge`, `xyStepPlace`, `xyStepAlign`, `xyStepScan`, `xyStepRemove`, `chartreadState`, `chartreadPrompt`, `btnStartRead`, `btnCalibrate`, `btnDoneRead`, `btnAccept`, `btnRetry`, `btnUndo`, `btnSkip`, `btnCancel`, `readProgressContainer`, `readProgress`, `readProgressText`, `readStats`, `swatchGrid`, `chartreadAveragingPanel`, `passCounterBadge`, `passesList`, `btnMeasureAnotherSheet`, `btnFinishAndAverage`, `chartreadLogContainer`, `chartreadLog`, `stage-4`, `colprofQuality`, `colprofDescription`, `colprofCopyright`, `colprofAlgorithm`, `colprofFwa`, `colprofCustomSpRow`, `colprofCustomSpPath`, `btnBrowseCustomSp`, `colprofIlluminant`, `colprofObserver`, `colprofInputViewCond`, `colprofOutputViewCond`, `btnCreateProfile`, `colprofSpinnerContainer`, `colprofStageLabel`, `colprofSuccessCard`, `colprofSuccessInfo`, `btnGoToVerify`, `colprofLogContainer`, `colprofLog`, `stage-5`, `btnVerify`, `btnInstallProfile`, `profcheckReportCard`, `profcheckBadge`, `profcheckAvgDe`, `profcheckMaxDe`, `profcheckRmsDe`, `driftHistorySection`, `driftAlertCard`, `driftAlertIcon`, `driftAlertText`, `btnDriftRecalibrate`, `driftFilterRow`, `driftPrinterFilter`, `driftChartWrap`, `driftTrendChart`, `driftEmptyState`, `verificationHistoryTable`, `verificationHistoryTbody`, `btnExportHistoryCsv`, `btnClearHistory`, `gamutViewerWrap`, `gamutViewerContainer`, `gamutControlsPanel`, `chkProfileGamut`, `rngProfileOpacity`, `chkSrgbReference`, `rngSrgbOpacity`, `chkLabAxes`, `rngAxisOpacity`, `btnGamutResetCamera`, `profcheckLogContainer`, `profcheckLog`, `settingsDialog`, `argyll_binary_dir`, `default_instrument`, `enable_i1pro2_leds`, `deltaEGoodMax`, `deltaEWarningMax`, `deltaEThresholdError`, `calibrationStaleDays`, `defaultInstallLocation`, `askBeforeOverwriteProfile`, `openColorPanelAfterInstall`, `logLevelSelect`, `btnOpenLogFolder`, `btnCopyLogPath`, `btnCopyLogExcerpt`, `logPathDisplay`, `saveSettingsBtn`, `closeSettingsBtn`, `calCollisionDialog`, `calCollisionMessage`, `calOverwriteBtn`, `calRenameBtn`, `calCancelCollisionBtn`, `profileInstallCollisionDialog`, `profileInstallCollisionMessage`, `profileOverwriteBtn`, `profileRenameBtn`, `profileCancelCollisionBtn`, `aboutDialog`, `aboutVersion`, `aboutBuildDate`, `closeAboutBtn`, `savePresetDialog`, `savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`, `btnCloseSavePresetDialog`, `managePresetsDialog`, `managePresetsList`, `btnExportActivePreset`, `btnImportPreset`, `btnCloseManagePresetsDialog`, `mediaSelect`, `mediaRecipeStale`, `btnMediaLibraryCapture`, `btnMediaLibraryManage`, `saveMediaRecipeDialog`, `saveMediaName`, `saveMediaNotes`, `saveMediaPaper`, `saveMediaInk`, `saveMediaPrinter`, `saveMediaPreset`, `saveMediaColourSpace`, `saveMediaCal`, `saveMediaApplyCal`, `btnConfirmSaveMedia`, `btnCloseSaveMediaDialog`, `manageMediaDialog`, `mediaLibraryList`, `mediaLibraryEmpty`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`, `btnMediaLibraryApply`, `btnMediaLibraryCaptureFromManage`, `btnCloseManageMediaDialog`. diff --git a/docs/22-settings-presets.md b/docs/22-settings-presets.md index a908bb4..69ff08c 100644 --- a/docs/22-settings-presets.md +++ b/docs/22-settings-presets.md @@ -67,3 +67,25 @@ Built-ins cannot be deleted. Custom presets overlay by `id`. Import/export is JS All four: `instrument: "i1"`, `colprof_algorithm: "l"`, `random_seed: 1`, `no_randomize: false`, `colprof_fwa: "D50"`. UI: `#presetSelect`, `#btnSavePresetModal` → `#savePresetDialog` (`savePresetName`, `savePresetDesc`, `btnConfirmSavePreset`), `#btnOpenPresetsDialog` → `#managePresetsDialog` (`managePresetsList`, `btnExportActivePreset`, `btnImportPreset`). + +## Media library (`media_library.json`) + +Persisted at `{app_data}/media_library.json` — a sibling of `settings.json`, never a field inside it (issue #146). A `MediaRecipe` binds a CUPS queue + paper + ink set + optional `.cal` to a `ProfilingPreset`. Cap: 200 entries; the 201st is refused with an error, never silently evicted. Corrupt JSON → keep the file, load `[]`, persistent warning banner. + +| Field | Type | Notes | +|-------|------|-------| +| `id` | string | `recipe-`, never user-typed | +| `name`, `notes` | string | Rendered through `Text` only (#114) | +| `printer_id` | string | CUPS queue id (`lpstat -e` name) | +| `printer_display_name` | string | Human label; applied to `wizard.printerName` | +| `paper_name`, `ink_set` | string | Library metadata only — never written to targen flags | +| `driver_media_type` | string? | Last captured CUPS `media_type` (read-only) | +| `colour_space` | `"rgb"` \| `"cmyk"` | Must match the bound preset | +| `preset_id` | string | `ProfilingPreset.id` (built-in or custom) | +| `calibration_url` | string? | Absolute `.cal` path, stored verbatim | +| `apply_calibration` | bool | Forced off for `CAL_` stems or missing files | +| `created`, `updated` | iso8601 | | + +Apply path: recipe → `applyPreset` (#82 mapping, no second Stage 1 form) → queue re-enumerated (`lpstat -e`) → `printer_id` absent from a non-empty list warns "not installed" and leaves the queue untouched; an empty list is indeterminate and never flags. `CAL_` bound cal **or** a live `CAL_` wizard basename forces `applyCalibration` off — `printtarg -K` can never see a `CAL_` file (literal refusal; escape hatch is rename + re-capture). Staleness: `.printer` = absent from enumerated queues; `.calibration` = bound cal `CREATED + calibration_stale_days < now`. + +Capture with no preset selected auto-snapshots the live form as a `custom-` preset and binds to it. UI: `#mediaSelect` (immediate apply, `#presetSelect`-style), `#btnMediaLibraryCapture` → `#saveMediaRecipeDialog`, `#btnMediaLibraryManage` → `#manageMediaDialog` (`mediaLibraryList`, `mediaRow-{id}`, `btnMediaLibraryApply-{id}`, `btnMediaLibraryDelete-{id}`), stale badge `#mediaRecipeStale`.