mirror of
https://github.com/kfattic/cupsadmin.git
synced 2026-11-13 19:06:43 -05:00
cupsadmin: CLI, CupsKit, app in progress
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
173
Sources/CupsKit/PPD.swift
Normal file
173
Sources/CupsKit/PPD.swift
Normal file
@@ -0,0 +1,173 @@
|
||||
import Foundation
|
||||
|
||||
/// PPD parsing for what `lpoptions -l` and the CUPS web UI's "Set Default Options" page showed:
|
||||
/// UI options with their groups, UI types, choices, defaults and custom-value parameters.
|
||||
public struct PPD {
|
||||
public enum UIType: String {
|
||||
case pickOne = "PickOne"
|
||||
case pickMany = "PickMany"
|
||||
case boolean = "Boolean"
|
||||
case unknown
|
||||
}
|
||||
|
||||
public struct Choice: Hashable {
|
||||
public let keyword: String
|
||||
/// Translation text after the slash, or the keyword when there is none.
|
||||
public let text: String
|
||||
|
||||
public init(keyword: String, text: String) {
|
||||
self.keyword = keyword
|
||||
self.text = text
|
||||
}
|
||||
}
|
||||
|
||||
/// One `*ParamCustomX Name/Text: order type min max` line.
|
||||
public struct CustomParameter: Hashable {
|
||||
public let name: String
|
||||
public let text: String
|
||||
public let order: Int
|
||||
/// PPD type as written: string, passcode, password, int, real, points, curve, invcurve.
|
||||
public let type: String
|
||||
public let minimum: String
|
||||
public let maximum: String
|
||||
}
|
||||
|
||||
public struct Option {
|
||||
public let keyword: String
|
||||
public let text: String
|
||||
public var choices: [String] = []
|
||||
public var defaultChoice: String?
|
||||
/// "Custom.INTEGER", "Custom.WIDTHxHEIGHT", ... when the PPD allows a custom value.
|
||||
public var customChoice: String?
|
||||
|
||||
/// `*OpenGroup` label (with " / subgroup" when nested); "General" for ungrouped options.
|
||||
public var group: String = PPD.generalGroup
|
||||
public var uiType: UIType = .unknown
|
||||
public var choiceList: [Choice] = []
|
||||
/// Parameters from `*ParamCustomX`, in order; empty unless the PPD has `*CustomX True`.
|
||||
public var customParameters: [CustomParameter] = []
|
||||
public var isJCL = false
|
||||
}
|
||||
|
||||
/// Where libcups and the CUPS web UI put options that aren't inside an `*OpenGroup`.
|
||||
public static let generalGroup = "General"
|
||||
|
||||
public private(set) var options: [Option] = []
|
||||
/// Group names in first-appearance order ("General" where the first ungrouped option appears).
|
||||
public private(set) var groups: [String] = []
|
||||
/// Top-level `*Key: value` attributes (NickName, Manufacturer, ...), first occurrence wins.
|
||||
public private(set) var attributes: [String: String] = [:]
|
||||
|
||||
public init(text: String) {
|
||||
var defaults: [String: String] = [:]
|
||||
var customKeywords: Set<String> = []
|
||||
var customParams: [String: [CustomParameter]] = [:]
|
||||
var groupStack: [String] = []
|
||||
var current: Option?
|
||||
var inQuotedValue = false
|
||||
|
||||
for rawLine in text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) {
|
||||
let line = String(rawLine)
|
||||
// Multi-line quoted values (invocation code, etc.) — skip until the closing quote.
|
||||
if inQuotedValue {
|
||||
if line.contains("\"") { inQuotedValue = false }
|
||||
continue
|
||||
}
|
||||
guard line.hasPrefix("*"), !line.hasPrefix("*%"), let colon = line.firstIndex(of: ":") else { continue }
|
||||
|
||||
let key = line[line.index(after: line.startIndex) ..< colon] // "PageSize Letter/US Letter"
|
||||
let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
|
||||
if value.hasPrefix("\""), value.filter({ $0 == "\"" }).count == 1 { inQuotedValue = true }
|
||||
|
||||
let parts = key.split(separator: " ", maxSplits: 1)
|
||||
let mainKey = String(parts[0])
|
||||
|
||||
switch mainKey {
|
||||
case "OpenGroup", "OpenSubGroup":
|
||||
// *OpenGroup: InstallableOptions/Installable Options
|
||||
let (name, label) = Self.splitText(value)
|
||||
groupStack.append(label ?? name)
|
||||
case "CloseGroup", "CloseSubGroup":
|
||||
_ = groupStack.popLast()
|
||||
case "OpenUI", "JCLOpenUI":
|
||||
// *OpenUI *PageSize/Media Size: PickOne
|
||||
guard parts.count == 2 else { continue }
|
||||
let spec = parts[1].trimmingCharacters(in: .whitespaces).drop { $0 == "*" }
|
||||
let nameAndText = spec.split(separator: "/", maxSplits: 1)
|
||||
let keyword = String(nameAndText[0])
|
||||
let text = nameAndText.count > 1 ? String(nameAndText[1]) : Self.standardText[keyword] ?? keyword
|
||||
current = Option(keyword: keyword, text: text)
|
||||
current!.group = groupStack.isEmpty ? Self.generalGroup : groupStack.joined(separator: " / ")
|
||||
current!.uiType = UIType(rawValue: value) ?? .unknown
|
||||
current!.isJCL = mainKey == "JCLOpenUI"
|
||||
case "CloseUI", "JCLCloseUI":
|
||||
if let option = current { options.append(option) }
|
||||
current = nil
|
||||
default:
|
||||
if mainKey.hasPrefix("ParamCustom"), parts.count == 2 {
|
||||
// *ParamCustomRIAngel Angel/Text: 1 int 0 359
|
||||
let fields = value.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
|
||||
let (name, label) = Self.splitText(String(parts[1]))
|
||||
if fields.count >= 2, let order = Int(fields[0]) {
|
||||
customParams[String(mainKey.dropFirst("ParamCustom".count)), default: []].append(
|
||||
CustomParameter(name: name, text: label ?? name, order: order, type: fields[1],
|
||||
minimum: fields.count > 2 ? fields[2] : "",
|
||||
maximum: fields.count > 3 ? fields[3] : ""))
|
||||
}
|
||||
} else if mainKey.hasPrefix("Custom"), parts.count == 2, Self.splitText(String(parts[1])).name == "True" {
|
||||
customKeywords.insert(String(mainKey.dropFirst("Custom".count)))
|
||||
} else if mainKey.hasPrefix("Default"), parts.count == 1 {
|
||||
defaults[String(mainKey.dropFirst("Default".count))] = value
|
||||
} else if parts.count == 2, let option = current, mainKey == option.keyword {
|
||||
let (choice, label) = Self.splitText(String(parts[1]))
|
||||
current!.choices.append(choice)
|
||||
current!.choiceList.append(Choice(keyword: choice, text: label ?? choice))
|
||||
} else if parts.count == 1, attributes[mainKey] == nil {
|
||||
attributes[mainKey] = value.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
|
||||
}
|
||||
}
|
||||
}
|
||||
if let option = current { options.append(option) }
|
||||
|
||||
// lpoptions hides PageRegion (it mirrors PageSize).
|
||||
options.removeAll { $0.keyword == "PageRegion" }
|
||||
for i in options.indices {
|
||||
let keyword = options[i].keyword
|
||||
options[i].defaultChoice = defaults[keyword]
|
||||
if !groups.contains(options[i].group) { groups.append(options[i].group) }
|
||||
guard customKeywords.contains(keyword) else { continue }
|
||||
let params = (customParams[keyword] ?? []).sorted { $0.order < $1.order }
|
||||
options[i].customParameters = params
|
||||
if keyword == "PageSize" {
|
||||
options[i].customChoice = "Custom.WIDTHxHEIGHT"
|
||||
} else if params.count == 1 {
|
||||
options[i].customChoice = "Custom.\(Self.lpoptionsTypeName(params[0].type))"
|
||||
} else if params.count > 1 {
|
||||
options[i].customChoice = "{" + params.map { "\($0.name)=\(Self.lpoptionsTypeName($0.type))" }
|
||||
.joined(separator: " ") + "}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Labels libcups gives options whose *OpenUI line has no translation text.
|
||||
private static let standardText = ["PageSize": "Media Size", "MediaType": "Media Type",
|
||||
"InputSlot": "Media Source", "ColorModel": "Output Mode",
|
||||
"Resolution": "Resolution"]
|
||||
|
||||
/// How `lpoptions -l` spells a custom parameter type.
|
||||
private static func lpoptionsTypeName(_ type: String) -> String {
|
||||
type == "int" ? "INTEGER" : type.uppercased()
|
||||
}
|
||||
|
||||
/// "Letter/US Letter" -> ("Letter", "US Letter"); "Letter" -> ("Letter", nil).
|
||||
private static func splitText(_ s: String) -> (name: String, text: String?) {
|
||||
let pieces = s.split(separator: "/", maxSplits: 1)
|
||||
let name = pieces.first.map { $0.trimmingCharacters(in: .whitespaces) } ?? ""
|
||||
let text = pieces.count > 1 ? pieces[1].trimmingCharacters(in: .whitespaces) : nil
|
||||
return (name, text)
|
||||
}
|
||||
|
||||
public func option(_ keyword: String) -> Option? {
|
||||
options.first { $0.keyword == keyword }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user