mirror of
https://github.com/kfattic/cupsadmin.git
synced 2026-11-13 19:06:43 -05:00
Xerox and Konica Minolta driver profiles; optional second quick action value
Xerox: XROutputColor with XRColorCorrection fallback for color/black & white; user code sets XRAccountingSystem=XSA + AcctUserID, and an optional account ID (AcctAccountID) via a profile-declared second input, in the sheet and as 'cupsadmin quick usercode <queue> <user> [account]'. Konica Minolta: KMDuplex, PageSize=8.5x11, color from the generic ColorModel; user code unavailable with the reason. Offline tests sweep every Xerox and bizhub PPD found in CUPSKIT_PPD_DIRS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,8 @@ Private maintainer notes, if present, are in `CLAUDE.local.md` (gitignored).
|
|||||||
PPD's `*Manufacturer`/`*NickName`, vendor profiles first and `generic` (standard PPD keywords, IPP
|
PPD's `*Manufacturer`/`*NickName`, vendor profiles first and `generic` (standard PPD keywords, IPP
|
||||||
Everywhere `*-default` attributes) last. A pair is used only if that queue supports the keyword and
|
Everywhere `*-default` attributes) last. A pair is used only if that queue supports the keyword and
|
||||||
value; otherwise the action is unavailable with the reason. A profile's `unavailable` map gives
|
value; otherwise the action is unavailable with the reason. A profile's `unavailable` map gives
|
||||||
the reason an action can't be done with that driver (e.g. Canon department IDs). Use Letter Paper is
|
the reason an action can't be done with that driver (e.g. Canon department IDs); its `inputs` map relabels
|
||||||
|
the typed value and can add an optional second value (`{value2}`, e.g. the Xerox account ID). Use Letter Paper is
|
||||||
titled ", Fit to Nearest Size" only when the profile also sets a fit option. `cupsadmin ppdreport
|
titled ", Fit to Nearest Size" only when the profile also sets a fit option. `cupsadmin ppdreport
|
||||||
<queue | file.ppd[.gz]>` shows a driver's keywords and which actions its profile enables.
|
<queue | file.ppd[.gz]>` shows a driver's keywords and which actions its profile enables.
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ The things admins actually do, without knowing the driver's option keywords:
|
|||||||
cupsadmin quick color <queue> # always print in color
|
cupsadmin quick color <queue> # always print in color
|
||||||
cupsadmin quick bw <queue> # always print black & white
|
cupsadmin quick bw <queue> # always print black & white
|
||||||
cupsadmin quick usercode <queue> 12345 # set a Ricoh user code (enables it too)
|
cupsadmin quick usercode <queue> 12345 # set a Ricoh user code (enables it too)
|
||||||
|
cupsadmin quick usercode <queue> jdoe ART101 # Xerox Standard Accounting: user ID, optional account ID
|
||||||
cupsadmin quick duplex <queue> # default to two-sided
|
cupsadmin quick duplex <queue> # default to two-sided
|
||||||
cupsadmin quick simplex <queue>
|
cupsadmin quick simplex <queue>
|
||||||
cupsadmin quick letter <queue> # Letter paper; also fit to nearest size where the driver can (Ricoh)
|
cupsadmin quick letter <queue> # Letter paper; also fit to nearest size where the driver can (Ricoh)
|
||||||
@@ -67,13 +68,15 @@ Which keywords an action writes comes from a driver profile (`Sources/CupsKit/Re
|
|||||||
| Any other PPD, IPP Everywhere / AirPrint | ✓ color, black & white, duplex, Letter where the queue supports them (generic) |
|
| Any other PPD, IPP Everywhere / AirPrint | ✓ color, black & white, duplex, Letter where the queue supports them (generic) |
|
||||||
| Canon UFR II / LIPSLX / CARPS2 | ✓ color, black & white (color models), duplex, Letter (no fit-to-page option); department IDs are set in the Canon driver, not the PPD |
|
| Canon UFR II / LIPSLX / CARPS2 | ✓ color, black & white (color models), duplex, Letter (no fit-to-page option); department IDs are set in the Canon driver, not the PPD |
|
||||||
| HP (`HPColorAsGray` or `HPColorMode` drivers) | ✓ color, black & white, duplex, Letter (no fit-to-page option); no accounting code option |
|
| HP (`HPColorAsGray` or `HPColorMode` drivers) | ✓ color, black & white, duplex, Letter (no fit-to-page option); no accounting code option |
|
||||||
| Xerox, Konica Minolta and others | generic only — add a profile (below) |
|
| Xerox (AltaLink, VersaLink and others) | ✓ color, black & white, duplex, Letter (no fit-to-page option), user ID + optional account ID (Xerox Standard Accounting) |
|
||||||
|
| Konica Minolta bizhub C-series i / xi | ✓ color, black & white, duplex, Letter (no fit-to-page option); Account Track codes are set in the driver, not the PPD |
|
||||||
|
| Others | generic only — add a profile (below) |
|
||||||
|
|
||||||
### Adding your printer's driver
|
### Adding your printer's driver
|
||||||
|
|
||||||
1. `cupsadmin ppdreport <queue>` (or `ppdreport <file.ppd.gz>` for a PPD from a driver package you haven't installed — `pkgutil --expand-full` it first) lists every option your driver has; find the color, duplex, paper and accounting/user-code keywords and their choice values.
|
1. `cupsadmin ppdreport <queue>` (or `ppdreport <file.ppd.gz>` for a PPD from a driver package you haven't installed — `pkgutil --expand-full` it first) lists every option your driver has; find the color, duplex, paper and accounting/user-code keywords and their choice values.
|
||||||
2. Copy the `ricoh` entry in `Sources/CupsKit/Resources/driver-profiles.json`, give it an `id`, and set `match` to regular expressions for your PPD's `Manufacturer` and `NickName` (both are printed at the top of the report).
|
2. Copy the `ricoh` entry in `Sources/CupsKit/Resources/driver-profiles.json`, give it an `id`, and set `match` to regular expressions for your PPD's `Manufacturer` and `NickName` (both are printed at the top of the report).
|
||||||
3. Replace the `keyword=value` pairs. `{value}` is the code the user types; typed options take `Custom.{value}`. Leave out actions your driver can't do, or explain why under `unavailable`. Keep your profile above `generic`.
|
3. Replace the `keyword=value` pairs. `{value}` is the code the user types; typed options take `Custom.{value}`. Leave out actions your driver can't do, or explain why under `unavailable`. `inputs` relabels the typed value and can add an optional second one (see the `xerox` entry). Keep your profile above `generic`.
|
||||||
4. `swift build && .build/debug/cupsadmin ppdreport <queue>` shows which quick actions are now available; try one with `cupsadmin quick`, then open a pull request.
|
4. `swift build && .build/debug/cupsadmin ppdreport <queue>` shows which quick actions are now available; try one with `cupsadmin quick`, then open a pull request.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ final class QuickActionModel: Identifiable {
|
|||||||
let action: QuickAction
|
let action: QuickAction
|
||||||
let queue: String
|
let queue: String
|
||||||
var code = ""
|
var code = ""
|
||||||
|
/// Optional second value (Xerox account ID), when the driver profile has one.
|
||||||
|
var secondValue = ""
|
||||||
var showCurrentCode = false
|
var showCurrentCode = false
|
||||||
private(set) var phase: Phase = .loading
|
private(set) var phase: Phase = .loading
|
||||||
private(set) var state: OptionState?
|
private(set) var state: OptionState?
|
||||||
@@ -50,9 +52,15 @@ final class QuickActionModel: Identifiable {
|
|||||||
|
|
||||||
var validationError: String? { action.validationError(code, context: context) }
|
var validationError: String? { action.validationError(code, context: context) }
|
||||||
|
|
||||||
|
var inputLabel: String { action.inputLabel(context) }
|
||||||
|
var secondInputLabel: String? { action.secondInputLabel(context) }
|
||||||
|
var currentSecondValue: String? { action.currentSecondValue(context) }
|
||||||
|
var secondValidationError: String? { action.secondValidationError(secondValue, context: context) }
|
||||||
|
|
||||||
/// What will be written, or why the action can't run here.
|
/// What will be written, or why the action can't run here.
|
||||||
func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> {
|
func plannedChanges(clearing: Bool = false) -> Result<[OptionChange], QuickActionUnavailable> {
|
||||||
action.resolve(context, value: clearing ? nil : code, clearing: clearing).map(\.changes)
|
action.resolve(context, value: clearing ? nil : code, secondValue: clearing ? nil : secondValue,
|
||||||
|
clearing: clearing).map(\.changes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "Ricoh", "Generic (…)" — which driver profile supplied the keywords.
|
/// "Ricoh", "Generic (…)" — which driver profile supplied the keywords.
|
||||||
@@ -83,7 +91,9 @@ final class QuickActionModel: Identifiable {
|
|||||||
phase = .running
|
phase = .running
|
||||||
let started = Date()
|
let started = Date()
|
||||||
do {
|
do {
|
||||||
let outcome = try await action.run(queue: queue, value: action.input == .userCode && !clearing ? code : nil,
|
let typed = action.input == .userCode && !clearing
|
||||||
|
let outcome = try await action.run(queue: queue, value: typed ? code : nil,
|
||||||
|
secondValue: typed && !secondValue.isEmpty ? secondValue : nil,
|
||||||
clearing: clearing, client: store.client)
|
clearing: clearing, client: store.client)
|
||||||
readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) })
|
readBack = Dictionary(uniqueKeysWithValues: outcome.readBack.map { ($0.key, $0) })
|
||||||
state = try? await OptionState.load(client: store.client, queue: queue)
|
state = try? await OptionState.load(client: store.client, queue: queue)
|
||||||
@@ -161,7 +171,7 @@ struct QuickActionSheet: View {
|
|||||||
private var canApply: Bool {
|
private var canApply: Bool {
|
||||||
guard model.phase == .ready else { return false }
|
guard model.phase == .ready else { return false }
|
||||||
if model.action.input == .userCode {
|
if model.action.input == .userCode {
|
||||||
return !model.code.isEmpty && model.validationError == nil
|
return !model.code.isEmpty && model.validationError == nil && model.secondValidationError == nil
|
||||||
}
|
}
|
||||||
if case .failure = model.plannedChanges() { return false }
|
if case .failure = model.plannedChanges() { return false }
|
||||||
return true
|
return true
|
||||||
@@ -170,7 +180,7 @@ struct QuickActionSheet: View {
|
|||||||
@ViewBuilder private var content: some View {
|
@ViewBuilder private var content: some View {
|
||||||
if model.action.input == .userCode {
|
if model.action.input == .userCode {
|
||||||
Form {
|
Form {
|
||||||
LabeledContent("Current code") {
|
LabeledContent("Current \(model.inputLabel.lowercased())") {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
if let current = model.currentCode {
|
if let current = model.currentCode {
|
||||||
Text(model.showCurrentCode ? current : String(repeating: "•", count: current.count))
|
Text(model.showCurrentCode ? current : String(repeating: "•", count: current.count))
|
||||||
@@ -184,22 +194,39 @@ struct QuickActionSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
LabeledContent {
|
LabeledContent {
|
||||||
TextField("User code", text: $model.code, prompt: Text("Digits"))
|
TextField(model.inputLabel, text: $model.code,
|
||||||
|
prompt: model.action.digitsOnly(model.context) ? Text("Digits") : nil)
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.frame(width: 160)
|
.frame(width: 160)
|
||||||
.disabled(model.phase != .ready)
|
.disabled(model.phase != .ready)
|
||||||
} label: {
|
} label: {
|
||||||
Text("New code")
|
Text("New \(model.inputLabel.lowercased())")
|
||||||
if let error = model.validationError {
|
if let error = model.validationError {
|
||||||
Text(error).foregroundStyle(.red)
|
Text(error).foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let secondLabel = model.secondInputLabel {
|
||||||
|
LabeledContent {
|
||||||
|
TextField(secondLabel, text: $model.secondValue, prompt: model.currentSecondValue.map { Text($0) })
|
||||||
|
.labelsHidden()
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 160)
|
||||||
|
.disabled(model.phase != .ready)
|
||||||
|
} label: {
|
||||||
|
Text(secondLabel)
|
||||||
|
if let error = model.secondValidationError {
|
||||||
|
Text(error).foregroundStyle(.red)
|
||||||
|
} else if model.currentSecondValue != nil {
|
||||||
|
Text("Current value shown in the field").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
.scrollDisabled(true)
|
.scrollDisabled(true)
|
||||||
.frame(height: 130)
|
.frame(height: model.secondInputLabel == nil ? 130 : 190)
|
||||||
}
|
}
|
||||||
|
|
||||||
if model.action.isDefaultPrinter {
|
if model.action.isDefaultPrinter {
|
||||||
|
|||||||
@@ -54,8 +54,25 @@ public struct DriverProfile: Decodable, Identifiable {
|
|||||||
public let actions: [String: [[String]]]
|
public let actions: [String: [[String]]]
|
||||||
/// Action id -> why this driver can't do it (e.g. accounting codes set outside the PPD).
|
/// Action id -> why this driver can't do it (e.g. accounting codes set outside the PPD).
|
||||||
public let unavailable: [String: String]
|
public let unavailable: [String: String]
|
||||||
|
/// Action id -> how its typed value is labeled and checked, plus an optional second value.
|
||||||
|
public let inputs: [String: Input]
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey { case id, name, match, actions, unavailable }
|
public struct Input: Decodable {
|
||||||
|
/// "User ID"; nil keeps the default "User code".
|
||||||
|
public let label: String?
|
||||||
|
/// Default true (Ricoh user codes). Xerox user IDs are strings.
|
||||||
|
public let digitsOnly: Bool?
|
||||||
|
public let second: SecondInput?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An optional extra value, e.g. Xerox Standard Accounting's account ID. Its pairs use `{value2}`
|
||||||
|
/// and are added only when the user fills it in.
|
||||||
|
public struct SecondInput: Decodable {
|
||||||
|
public let label: String
|
||||||
|
public let pairs: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey { case id, name, match, actions, unavailable, inputs }
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
@@ -68,6 +85,7 @@ public struct DriverProfile: Decodable, Identifiable {
|
|||||||
for (action, pairs) in raw { actions[action] = pairs.alternatives }
|
for (action, pairs) in raw { actions[action] = pairs.alternatives }
|
||||||
self.actions = actions
|
self.actions = actions
|
||||||
unavailable = try c.decodeIfPresent([String: String].self, forKey: .unavailable) ?? [:]
|
unavailable = try c.decodeIfPresent([String: String].self, forKey: .unavailable) ?? [:]
|
||||||
|
inputs = try c.decodeIfPresent([String: Input].self, forKey: .inputs) ?? [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct FlexiblePairs: Decodable {
|
private struct FlexiblePairs: Decodable {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public struct QuickAction: Identifiable, Hashable {
|
|||||||
QuickAction(id: "bw", title: "Always Print Black & White", systemImage: "circle.lefthalf.filled", input: .none,
|
QuickAction(id: "bw", title: "Always Print Black & White", systemImage: "circle.lefthalf.filled", input: .none,
|
||||||
summary: "Black & white by default"),
|
summary: "Black & white by default"),
|
||||||
QuickAction(id: "usercode", title: "Set User Code…", systemImage: "person.badge.key", input: .userCode,
|
QuickAction(id: "usercode", title: "Set User Code…", systemImage: "person.badge.key", input: .userCode,
|
||||||
summary: "User/department code sent with every job (--clear to remove)"),
|
summary: "User/department code sent with every job; Xerox also takes an optional account ID (--clear to remove)"),
|
||||||
QuickAction(id: "duplex", title: "Default to Duplex", systemImage: "doc.on.doc", input: .none,
|
QuickAction(id: "duplex", title: "Default to Duplex", systemImage: "doc.on.doc", input: .none,
|
||||||
summary: "Two-sided, long edge"),
|
summary: "Two-sided, long edge"),
|
||||||
QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none,
|
QuickAction(id: "simplex", title: "Default to Single-Sided", systemImage: "doc", input: .none,
|
||||||
@@ -51,7 +51,7 @@ public struct QuickAction: Identifiable, Hashable {
|
|||||||
static let paperSizeKeys: Set<String> = ["PageSize", "media-default"]
|
static let paperSizeKeys: Set<String> = ["PageSize", "media-default"]
|
||||||
|
|
||||||
/// The profile and option changes this action would write on a queue, or why it can't.
|
/// The profile and option changes this action would write on a queue, or why it can't.
|
||||||
public func resolve(_ context: DriverContext, value: String? = nil, clearing: Bool = false,
|
public func resolve(_ context: DriverContext, value: String? = nil, secondValue: String? = nil, clearing: Bool = false,
|
||||||
profiles: [DriverProfile] = DriverProfiles.builtIn) -> Result<ResolvedQuickAction, QuickActionUnavailable> {
|
profiles: [DriverProfile] = DriverProfiles.builtIn) -> Result<ResolvedQuickAction, QuickActionUnavailable> {
|
||||||
let key = clearing ? id + "-clear" : id
|
let key = clearing ? id + "-clear" : id
|
||||||
var firstMissing: String?
|
var firstMissing: String?
|
||||||
@@ -67,7 +67,19 @@ public struct QuickAction: Identifiable, Hashable {
|
|||||||
if firstMissing == nil { firstMissing = "\(missing.0)=\(parsed.first { $0.key == missing.0 }!.value)" }
|
if firstMissing == nil { firstMissing = "\(missing.0)=\(parsed.first { $0.key == missing.0 }!.value)" }
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
let changes = filled.map { OptionChange(key: $0.0, value: $0.1, isSecret: false) }
|
var changes = filled.map { OptionChange(key: $0.0, value: $0.1, isSecret: false) }
|
||||||
|
if !clearing, let secondValue, !secondValue.isEmpty {
|
||||||
|
guard let second = profile.inputs[id]?.second else {
|
||||||
|
return .failure(QuickActionUnavailable(reason: "\(profile.name) takes one value for \(id), not two"))
|
||||||
|
}
|
||||||
|
for pair in second.pairs.compactMap(Self.parse) {
|
||||||
|
let value = pair.value.replacingOccurrences(of: "{value2}", with: secondValue)
|
||||||
|
guard context.supports(key: pair.key, value: value) else {
|
||||||
|
return .failure(QuickActionUnavailable(reason: "Not available for \(context.driverName) (no \(pair.key)=\(pair.value))"))
|
||||||
|
}
|
||||||
|
changes.append(OptionChange(key: pair.key, value: value, isSecret: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
return .success(ResolvedQuickAction(profile: profile, changes: changes))
|
return .success(ResolvedQuickAction(profile: profile, changes: changes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,13 +98,40 @@ public struct QuickAction: Identifiable, Hashable {
|
|||||||
|
|
||||||
public func isAvailable(in context: DriverContext) -> Bool { unavailableReason(in: context) == nil }
|
public func isAvailable(in context: DriverContext) -> Bool { unavailableReason(in: context) == nil }
|
||||||
|
|
||||||
/// For Set User Code: digits only, within the PPD's limit for the code option. nil when valid.
|
/// The profile's input settings for this action on this queue (label, digits, second value).
|
||||||
|
public func inputSpec(_ context: DriverContext) -> DriverProfile.Input? {
|
||||||
|
guard input == .userCode, case .success(let resolved) = resolve(context, value: "0") else { return nil }
|
||||||
|
return resolved.profile.inputs[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "User code" (Ricoh) or the profile's label ("User ID" for Xerox).
|
||||||
|
public func inputLabel(_ context: DriverContext) -> String { inputSpec(context)?.label ?? "User code" }
|
||||||
|
|
||||||
|
/// The optional second value's label ("Account ID (optional)"), or nil when the profile has none.
|
||||||
|
public func secondInputLabel(_ context: DriverContext) -> String? { inputSpec(context)?.second?.label }
|
||||||
|
|
||||||
|
public func digitsOnly(_ context: DriverContext) -> Bool { inputSpec(context)?.digitsOnly ?? true }
|
||||||
|
|
||||||
|
/// For Set User Code: digits only unless the profile allows text, within the PPD's limit. nil when valid.
|
||||||
public func validationError(_ value: String, context: DriverContext) -> String? {
|
public func validationError(_ value: String, context: DriverContext) -> String? {
|
||||||
guard input == .userCode, !value.isEmpty else { return nil }
|
guard input == .userCode, !value.isEmpty else { return nil }
|
||||||
guard value.allSatisfy({ ("0"..."9").contains($0) }) else { return "Digits only" }
|
return Self.valueError(value, digitsOnly: digitsOnly(context), keyword: codeKeyword(context), context: context)
|
||||||
if let keyword = codeKeyword(context),
|
}
|
||||||
let max = context.ppd?.option(keyword)?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max {
|
|
||||||
return "At most \(max) digits"
|
/// For the optional second value (text, within the PPD's limit). nil when valid.
|
||||||
|
public func secondValidationError(_ value: String, context: DriverContext) -> String? {
|
||||||
|
guard input == .userCode, !value.isEmpty else { return nil }
|
||||||
|
return Self.valueError(value, digitsOnly: false, keyword: secondKeyword(context), context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func valueError(_ value: String, digitsOnly: Bool, keyword: String?, context: DriverContext) -> String? {
|
||||||
|
if digitsOnly {
|
||||||
|
guard value.allSatisfy({ ("0"..."9").contains($0) }) else { return "Digits only" }
|
||||||
|
} else if value.contains(where: { $0 == "\"" || $0 == "'" || $0 == "\\" || $0.isNewline }) {
|
||||||
|
return "No quotes, backslashes or line breaks"
|
||||||
|
}
|
||||||
|
if let keyword, let max = context.ppd?.option(keyword)?.customParameters.first.flatMap({ Int($0.maximum) }), value.count > max {
|
||||||
|
return "At most \(max) \(digitsOnly ? "digits" : "characters")"
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -104,6 +143,17 @@ public struct QuickAction: Identifiable, Hashable {
|
|||||||
return String(value.dropFirst("Custom.".count))
|
return String(value.dropFirst("Custom.".count))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The second value currently set on the queue (e.g. the Xerox account ID), without Custom.
|
||||||
|
public func currentSecondValue(_ context: DriverContext) -> String? {
|
||||||
|
guard let keyword = secondKeyword(context),
|
||||||
|
let value = context.ppd?.option(keyword)?.defaultChoice, value.hasPrefix("Custom.") else { return nil }
|
||||||
|
return String(value.dropFirst("Custom.".count))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func secondKeyword(_ context: DriverContext) -> String? {
|
||||||
|
inputSpec(context)?.second?.pairs.compactMap(Self.parse).first { $0.value.contains("{value2}") }?.key
|
||||||
|
}
|
||||||
|
|
||||||
/// The option that receives `{value}` for this queue's profile (e.g. RIUserCode).
|
/// The option that receives `{value}` for this queue's profile (e.g. RIUserCode).
|
||||||
private func codeKeyword(_ context: DriverContext) -> String? {
|
private func codeKeyword(_ context: DriverContext) -> String? {
|
||||||
guard input == .userCode, case .success(let resolved) = resolve(context, value: "0") else { return nil }
|
guard input == .userCode, case .success(let resolved) = resolve(context, value: "0") else { return nil }
|
||||||
@@ -139,14 +189,15 @@ public struct QuickActionOutcome {
|
|||||||
|
|
||||||
extension QuickAction {
|
extension QuickAction {
|
||||||
/// Runs the action on a queue with read-back. Throws QuickActionUnavailable when no profile fits.
|
/// Runs the action on a queue with read-back. Throws QuickActionUnavailable when no profile fits.
|
||||||
public func run(queue: String, value: String? = nil, clearing: Bool = false, client: CupsClient) async throws -> QuickActionOutcome {
|
public func run(queue: String, value: String? = nil, secondValue: String? = nil, clearing: Bool = false,
|
||||||
|
client: CupsClient) async throws -> QuickActionOutcome {
|
||||||
if isDefaultPrinter {
|
if isDefaultPrinter {
|
||||||
let outcome = try await QueueActions.setDefault(queue: queue, client: client)
|
let outcome = try await QueueActions.setDefault(queue: queue, client: client)
|
||||||
return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, profile: nil,
|
return QuickActionOutcome(command: outcome.command, succeeded: outcome.succeeded, profile: nil,
|
||||||
readBack: [], changes: [], message: outcome.message)
|
readBack: [], changes: [], message: outcome.message)
|
||||||
}
|
}
|
||||||
let context = try await DriverContext.load(client: client, queue: queue)
|
let context = try await DriverContext.load(client: client, queue: queue)
|
||||||
let resolved = try resolve(context, value: value, clearing: clearing).get()
|
let resolved = try resolve(context, value: value, secondValue: secondValue, clearing: clearing).get()
|
||||||
let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: resolved.changes)
|
let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: resolved.changes)
|
||||||
let failed = readBack.filter { !$0.applied }
|
let failed = readBack.filter { !$0.applied }
|
||||||
return QuickActionOutcome(command: OptionApplier.displayCommand(queue: queue, changes: resolved.changes),
|
return QuickActionOutcome(command: OptionApplier.displayCommand(queue: queue, changes: resolved.changes),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"comment": "Quick Action mappings per driver. First matching profile that defines an action AND whose keyword=value pairs this queue supports wins; 'generic' (empty match) must stay last. An action is either one list of pairs (all applied together) or a list of alternative lists. {value} is replaced with the user's input. 'unavailable' maps an action id to the reason it can't be done with this driver (no later profile is tried). Find keywords with: cupsadmin ppdreport <queue | file.ppd>",
|
"comment": "Quick Action mappings per driver. First matching profile that defines an action AND whose keyword=value pairs this queue supports wins; 'generic' (empty match) must stay last. An action is either one list of pairs (all applied together) or a list of alternative lists. {value} is replaced with the user's input. 'inputs' can relabel an action's typed value (digitsOnly defaults to true) and add an optional second value ({value2}) with its own pairs. 'unavailable' maps an action id to the reason it can't be done with this driver (no later profile is tried). Find keywords with: cupsadmin ppdreport <queue | file.ppd>",
|
||||||
"profiles": [
|
"profiles": [
|
||||||
{
|
{
|
||||||
"id": "ricoh-m-c251fw",
|
"id": "ricoh-m-c251fw",
|
||||||
@@ -54,6 +54,37 @@
|
|||||||
"usercode": "HP drivers have no accounting code option"
|
"usercode": "HP drivers have no accounting code option"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "xerox",
|
||||||
|
"name": "Xerox",
|
||||||
|
"match": { "manufacturer": "^Xerox$" },
|
||||||
|
"actions": {
|
||||||
|
"color": [["XROutputColor=PrintAsColor"], ["XRColorCorrection=Auto"]],
|
||||||
|
"bw": [["XROutputColor=PrintAsGrayscale"], ["XRColorCorrection=Gray"]],
|
||||||
|
"usercode": ["XRAccountingSystem=XSA", "AcctUserID=Custom.{value}"],
|
||||||
|
"usercode-clear": ["XRAccountingSystem=None", "AcctUserID=None", "AcctAccountID=None"]
|
||||||
|
},
|
||||||
|
"inputs": {
|
||||||
|
"usercode": {
|
||||||
|
"label": "User ID",
|
||||||
|
"digitsOnly": false,
|
||||||
|
"second": { "label": "Account ID (optional)", "pairs": ["AcctAccountID=Custom.{value2}"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "konica-minolta",
|
||||||
|
"name": "Konica Minolta",
|
||||||
|
"match": { "manufacturer": "^KONICA MINOLTA$" },
|
||||||
|
"actions": {
|
||||||
|
"duplex": ["KMDuplex=Double"],
|
||||||
|
"simplex": ["KMDuplex=Single"],
|
||||||
|
"letter": ["PageSize=8.5x11"]
|
||||||
|
},
|
||||||
|
"unavailable": {
|
||||||
|
"usercode": "Konica Minolta Account Track codes are set by the driver, not the PPD"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "generic",
|
"id": "generic",
|
||||||
"name": "Generic (standard PPD keywords, IPP Everywhere)",
|
"name": "Generic (standard PPD keywords, IPP Everywhere)",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ enum QuickCommand {
|
|||||||
static func run(client: CupsClient, args: [String], clear: Bool) async throws -> String {
|
static func run(client: CupsClient, args: [String], clear: Bool) async throws -> String {
|
||||||
guard let name = args.first else {
|
guard let name = args.first else {
|
||||||
printTable(headers: ["ACTION", "VALUE", "WHAT IT SETS"], rows: QuickAction.all.map {
|
printTable(headers: ["ACTION", "VALUE", "WHAT IT SETS"], rows: QuickAction.all.map {
|
||||||
[$0.id, $0.input == .userCode ? "<code> | --clear" : "", $0.summary]
|
[$0.id, $0.input == .userCode ? "<code> [account] | --clear" : "", $0.summary]
|
||||||
})
|
})
|
||||||
print("\nDriver profiles (first match wins, generic last): "
|
print("\nDriver profiles (first match wins, generic last): "
|
||||||
+ DriverProfiles.builtIn.map(\.id).joined(separator: ", "))
|
+ DriverProfiles.builtIn.map(\.id).joined(separator: ", "))
|
||||||
@@ -18,6 +18,7 @@ enum QuickCommand {
|
|||||||
guard args.count >= 2 else { throw CupsAdminError.usage("quick \(name) needs a queue name") }
|
guard args.count >= 2 else { throw CupsAdminError.usage("quick \(name) needs a queue name") }
|
||||||
let queue = args[1]
|
let queue = args[1]
|
||||||
let value: String?
|
let value: String?
|
||||||
|
var secondValue: String?
|
||||||
switch action.input {
|
switch action.input {
|
||||||
case .none:
|
case .none:
|
||||||
guard args.count == 2, !clear else { throw CupsAdminError.usage("quick \(name) takes only a queue name") }
|
guard args.count == 2, !clear else { throw CupsAdminError.usage("quick \(name) takes only a queue name") }
|
||||||
@@ -27,8 +28,11 @@ enum QuickCommand {
|
|||||||
guard args.count == 2 else { throw CupsAdminError.usage("use a code or --clear, not both") }
|
guard args.count == 2 else { throw CupsAdminError.usage("use a code or --clear, not both") }
|
||||||
value = nil
|
value = nil
|
||||||
} else {
|
} else {
|
||||||
guard args.count == 3 else { throw CupsAdminError.usage("quick \(name) needs a code (digits) or --clear") }
|
guard args.count == 3 || args.count == 4 else {
|
||||||
|
throw CupsAdminError.usage("quick \(name) needs a code (and, for Xerox, an optional account ID) or --clear")
|
||||||
|
}
|
||||||
value = args[2]
|
value = args[2]
|
||||||
|
secondValue = args.count == 4 ? args[3] : nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }
|
guard try await client.queueExists(queue) else { throw CupsAdminError.failed("no such queue \(queue)") }
|
||||||
@@ -48,10 +52,13 @@ enum QuickCommand {
|
|||||||
let before = try await OptionState.load(client: client, queue: queue)
|
let before = try await OptionState.load(client: client, queue: queue)
|
||||||
let context = DriverContext(ppd: before.ppd, attributes: before.snapshot.attributes)
|
let context = DriverContext(ppd: before.ppd, attributes: before.snapshot.attributes)
|
||||||
if let value, let error = action.validationError(value, context: context) {
|
if let value, let error = action.validationError(value, context: context) {
|
||||||
throw CupsAdminError.usage("user code \(value): \(error)")
|
throw CupsAdminError.usage("\(action.inputLabel(context).lowercased()) \(value): \(error)")
|
||||||
|
}
|
||||||
|
if let secondValue, let error = action.secondValidationError(secondValue, context: context) {
|
||||||
|
throw CupsAdminError.usage("second value \(secondValue): \(error)")
|
||||||
}
|
}
|
||||||
let resolved: ResolvedQuickAction
|
let resolved: ResolvedQuickAction
|
||||||
switch action.resolve(context, value: value, clearing: clear) {
|
switch action.resolve(context, value: value, secondValue: secondValue, clearing: clear) {
|
||||||
case .success(let r): resolved = r
|
case .success(let r): resolved = r
|
||||||
case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) on \(queue): \(unavailable.reason)")
|
case .failure(let unavailable): throw CupsAdminError.failed("\(action.id) on \(queue): \(unavailable.reason)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ let usage = """
|
|||||||
set <queue> [-o key=value ...] [-v uri] [-D description] [-L location] [-m model | -P ppd]
|
set <queue> [-o key=value ...] [-v uri] [-D description] [-L location] [-m model | -P ppd]
|
||||||
change a queue via lpadmin, then show before/after
|
change a queue via lpadmin, then show before/after
|
||||||
quick list quick actions (task-named queue defaults)
|
quick list quick actions (task-named queue defaults)
|
||||||
quick <action> <queue> [code | --clear]
|
quick <action> <queue> [code [account] | --clear]
|
||||||
apply one via lpadmin, then verify
|
apply one via lpadmin, then verify
|
||||||
ppdreport <queue | path.ppd[.gz]>
|
ppdreport <queue | path.ppd[.gz]>
|
||||||
every PPD group and option: keyword, label, type, default, choices
|
every PPD group and option: keyword, label, type, default, choices
|
||||||
|
|||||||
@@ -18,13 +18,17 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? {
|
|||||||
@Suite struct DriverProfileFile {
|
@Suite struct DriverProfileFile {
|
||||||
@Test func builtInProfilesAreWellFormed() {
|
@Test func builtInProfilesAreWellFormed() {
|
||||||
let profiles = DriverProfiles.builtIn
|
let profiles = DriverProfiles.builtIn
|
||||||
#expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "canon", "hp", "generic"])
|
#expect(profiles.map(\.id) == ["ricoh-m-c251fw", "ricoh", "canon", "hp", "xerox", "konica-minolta", "generic"])
|
||||||
#expect(profiles.last?.isGeneric == true)
|
#expect(profiles.last?.isGeneric == true)
|
||||||
#expect(profiles.dropLast().allSatisfy { !$0.isGeneric })
|
#expect(profiles.dropLast().allSatisfy { !$0.isGeneric })
|
||||||
let known = Set(QuickAction.all.map(\.id)).union(["usercode-clear"])
|
let known = Set(QuickAction.all.map(\.id)).union(["usercode-clear"])
|
||||||
for profile in profiles {
|
for profile in profiles {
|
||||||
#expect(Set(profile.actions.keys).isSubset(of: known), "\(profile.id): \(profile.actions.keys.sorted())")
|
#expect(Set(profile.actions.keys).isSubset(of: known), "\(profile.id): \(profile.actions.keys.sorted())")
|
||||||
#expect(Set(profile.unavailable.keys).isSubset(of: known), "\(profile.id): \(profile.unavailable.keys.sorted())")
|
#expect(Set(profile.unavailable.keys).isSubset(of: known), "\(profile.id): \(profile.unavailable.keys.sorted())")
|
||||||
|
#expect(Set(profile.inputs.keys).isSubset(of: Set(QuickAction.all.filter { $0.input != .none }.map(\.id))))
|
||||||
|
for input in profile.inputs.values {
|
||||||
|
#expect(input.second.map { !$0.pairs.isEmpty && $0.pairs.allSatisfy { QuickAction.parse($0) != nil } } ?? true)
|
||||||
|
}
|
||||||
for alternatives in profile.actions.values {
|
for alternatives in profile.actions.values {
|
||||||
#expect(alternatives.allSatisfy { !$0.isEmpty && $0.allSatisfy { QuickAction.parse($0) != nil } })
|
#expect(alternatives.allSatisfy { !$0.isEmpty && $0.allSatisfy { QuickAction.parse($0) != nil } })
|
||||||
}
|
}
|
||||||
@@ -51,6 +55,10 @@ private func profileID(_ id: String, _ context: DriverContext) -> String? {
|
|||||||
#expect(code.map(\.value) == ["True", "Custom.4321"])
|
#expect(code.map(\.value) == ["True", "Custom.4321"])
|
||||||
let clear = try QuickAction.named("usercode")!.resolve(c, clearing: true).get().changes
|
let clear = try QuickAction.named("usercode")!.resolve(c, clearing: true).get().changes
|
||||||
#expect(clear.map(\.value) == ["False", "None"])
|
#expect(clear.map(\.value) == ["False", "None"])
|
||||||
|
// Ricoh takes one value: digits, no second input.
|
||||||
|
#expect(QuickAction.named("usercode")!.inputLabel(c) == "User code")
|
||||||
|
#expect(QuickAction.named("usercode")!.secondInputLabel(c) == nil)
|
||||||
|
#expect(throws: QuickActionUnavailable.self) { try QuickAction.named("usercode")!.resolve(c, value: "4321", secondValue: "9").get() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test(.enabled(if: hasVendorPPD("RICOH MP 5055")))
|
@Test(.enabled(if: hasVendorPPD("RICOH MP 5055")))
|
||||||
|
|||||||
@@ -146,3 +146,98 @@ private func hasChoice(_ c: DriverContext, _ keyword: String, _ choice: String)
|
|||||||
#expect(hpColor > 0)
|
#expect(hpColor > 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suite struct XeroxProfile {
|
||||||
|
static let altaLink = "Xerox AltaLink C8170.gz"
|
||||||
|
static let monoVersaLink = "Xerox VersaLink B620 Printer.gz"
|
||||||
|
|
||||||
|
@Test(.enabled(if: ExtraPPDs.path(altaLink) != nil))
|
||||||
|
func altaLinkColorAndStandardAccounting() throws {
|
||||||
|
let c = try #require(ExtraPPDs.context(Self.altaLink))
|
||||||
|
#expect(DriverProfiles.matching(c).map(\.id) == ["xerox", "generic"])
|
||||||
|
#expect(pairs("color", c) == ["XROutputColor=PrintAsColor"])
|
||||||
|
#expect(pairs("bw", c) == ["XROutputColor=PrintAsGrayscale"])
|
||||||
|
#expect(resolved("duplex", c)?.profile.id == "generic" && pairs("duplex", c) == ["Duplex=DuplexNoTumble"])
|
||||||
|
#expect(resolved("letter", c)?.profile.id == "generic" && pairs("letter", c) == ["PageSize=Letter"])
|
||||||
|
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
|
||||||
|
|
||||||
|
let usercode = QuickAction.named("usercode")!
|
||||||
|
#expect(usercode.inputLabel(c) == "User ID")
|
||||||
|
#expect(usercode.secondInputLabel(c) == "Account ID (optional)")
|
||||||
|
#expect(!usercode.digitsOnly(c))
|
||||||
|
#expect(try usercode.resolve(c, value: "jdoe").get().changes.map { "\($0.key)=\($0.value)" }
|
||||||
|
== ["XRAccountingSystem=XSA", "AcctUserID=Custom.jdoe"])
|
||||||
|
#expect(try usercode.resolve(c, value: "jdoe", secondValue: "").get().changes.count == 2)
|
||||||
|
#expect(try usercode.resolve(c, value: "jdoe", secondValue: "ART101").get().changes.map { "\($0.key)=\($0.value)" }
|
||||||
|
== ["XRAccountingSystem=XSA", "AcctUserID=Custom.jdoe", "AcctAccountID=Custom.ART101"])
|
||||||
|
#expect(try usercode.resolve(c, clearing: true).get().changes.map { "\($0.key)=\($0.value)" }
|
||||||
|
== ["XRAccountingSystem=None", "AcctUserID=None", "AcctAccountID=None"])
|
||||||
|
|
||||||
|
#expect(usercode.validationError("jdoe", context: c) == nil)
|
||||||
|
#expect(usercode.validationError(String(repeating: "a", count: 33), context: c) == "At most 32 characters")
|
||||||
|
#expect(usercode.validationError("j\"doe", context: c) == "No quotes, backslashes or line breaks")
|
||||||
|
#expect(usercode.secondValidationError("ART101", context: c) == nil)
|
||||||
|
#expect(usercode.secondValidationError(String(repeating: "9", count: 33), context: c) == "At most 32 characters")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(.enabled(if: ExtraPPDs.path(monoVersaLink) != nil))
|
||||||
|
func versaLinkWithoutXROutputColorFallsBackToColorCorrection() throws {
|
||||||
|
let c = try #require(ExtraPPDs.context(Self.monoVersaLink))
|
||||||
|
#expect(pairs("bw", c) == ["XRColorCorrection=Gray"])
|
||||||
|
#expect(resolved("bw", c)?.profile.id == "xerox")
|
||||||
|
#expect(!QuickAction.named("color")!.isAvailable(in: c)) // mono: Gray is the only choice
|
||||||
|
#expect(QuickAction.named("usercode")!.isAvailable(in: c))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every Xerox PPD in the package: each action is offered exactly when the PPD has what the profile needs.
|
||||||
|
@Test(.enabled(if: ExtraPPDs.files(prefix: "Xerox ").count > 100))
|
||||||
|
func everyXeroxPPD() throws {
|
||||||
|
var accounting = 0
|
||||||
|
for file in ExtraPPDs.files(prefix: "Xerox ") {
|
||||||
|
let c = try #require(ExtraPPDs.context(path: file), "\(file)")
|
||||||
|
let name = (file as NSString).lastPathComponent
|
||||||
|
#expect(DriverProfiles.matching(c).first?.id == "xerox", "\(name)")
|
||||||
|
#expect(QuickAction.named("color")!.isAvailable(in: c)
|
||||||
|
== (hasChoice(c, "XROutputColor", "PrintAsColor") || hasChoice(c, "XRColorCorrection", "Auto")), "\(name)")
|
||||||
|
#expect(QuickAction.named("bw")!.isAvailable(in: c)
|
||||||
|
== (hasChoice(c, "XROutputColor", "PrintAsGrayscale") || hasChoice(c, "XRColorCorrection", "Gray")), "\(name)")
|
||||||
|
let xsa = hasChoice(c, "XRAccountingSystem", "XSA") && c.ppd?.option("AcctUserID")?.customParameters.isEmpty == false
|
||||||
|
#expect(QuickAction.named("usercode")!.isAvailable(in: c) == xsa, "\(name)")
|
||||||
|
if xsa { accounting += 1 }
|
||||||
|
}
|
||||||
|
#expect(accounting > 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite struct KonicaMinoltaProfile {
|
||||||
|
static let bizhub = "KONICAMINOLTAC651i.gz"
|
||||||
|
static let reason = "Konica Minolta Account Track codes are set by the driver, not the PPD"
|
||||||
|
|
||||||
|
@Test(.enabled(if: ExtraPPDs.path(bizhub) != nil))
|
||||||
|
func bizhubC651i() throws {
|
||||||
|
let c = try #require(ExtraPPDs.context(Self.bizhub))
|
||||||
|
#expect(DriverProfiles.matching(c).map(\.id) == ["konica-minolta", "generic"])
|
||||||
|
#expect(pairs("color", c) == ["ColorModel=CMYK"] && resolved("color", c)?.profile.id == "generic")
|
||||||
|
#expect(pairs("bw", c) == ["ColorModel=Gray"])
|
||||||
|
#expect(pairs("duplex", c) == ["KMDuplex=Double"] && resolved("duplex", c)?.profile.id == "konica-minolta")
|
||||||
|
#expect(pairs("simplex", c) == ["KMDuplex=Single"])
|
||||||
|
#expect(pairs("letter", c) == ["PageSize=8.5x11"])
|
||||||
|
#expect(QuickAction.named("letter")!.title(in: c) == "Use Letter Paper")
|
||||||
|
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every bizhub PPD found (i and xi, plus the 1-sided "S" variants).
|
||||||
|
@Test(.enabled(if: !ExtraPPDs.files(prefix: "KONICAMINOLTA").isEmpty))
|
||||||
|
func everyKonicaMinoltaPPD() throws {
|
||||||
|
for file in ExtraPPDs.files(prefix: "KONICAMINOLTA") {
|
||||||
|
let c = try #require(ExtraPPDs.context(path: file), "\(file)")
|
||||||
|
let name = (file as NSString).lastPathComponent
|
||||||
|
#expect(DriverProfiles.matching(c).first?.id == "konica-minolta", "\(name)")
|
||||||
|
#expect(QuickAction.named("duplex")!.isAvailable(in: c) == hasChoice(c, "KMDuplex", "Double"), "\(name)")
|
||||||
|
#expect(QuickAction.named("simplex")!.isAvailable(in: c) == hasChoice(c, "KMDuplex", "Single"), "\(name)")
|
||||||
|
#expect(QuickAction.named("letter")!.isAvailable(in: c) == hasChoice(c, "PageSize", "8.5x11"), "\(name)")
|
||||||
|
#expect(QuickAction.named("bw")!.isAvailable(in: c) == hasChoice(c, "ColorModel", "Gray"), "\(name)")
|
||||||
|
#expect(QuickAction.named("usercode")!.unavailableReason(in: c) == Self.reason, "\(name)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user