Add Konverter: a local-only menu bar file converter
Drag files in Finder, hold a modifier, and drop on a wheel of outputs. Conversions run entirely on-device through ImageIO, PDFKit, AVFoundation, the bundled CLI tools, and optional ffmpeg/LibreOffice. Includes a --self-test suite (41 checks) and a --convert CLI mode so the conversion matrix can be verified without the UI.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// Native menu fallback for the paths where there is no drag to follow: the
|
||||
/// menu bar's "Konvert Files…" item and the Finder Quick Action.
|
||||
enum QuickMenu {
|
||||
private final class Choice: NSObject {
|
||||
let handler: () -> Void
|
||||
|
||||
init(_ handler: @escaping () -> Void) {
|
||||
self.handler = handler
|
||||
}
|
||||
|
||||
@objc func fire() {
|
||||
handler()
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu items hold their target weakly, so the choices are retained
|
||||
/// here for the duration of the (synchronous) popup.
|
||||
private static var retained: [Choice] = []
|
||||
|
||||
static func show(urls: [URL], at point: CGPoint) {
|
||||
guard !urls.isEmpty else { return }
|
||||
retained.removeAll()
|
||||
|
||||
let menu = NSMenu()
|
||||
menu.autoenablesItems = false
|
||||
|
||||
let header = NSMenuItem(
|
||||
title: WheelBuilder.summary(for: urls),
|
||||
action: nil,
|
||||
keyEquivalent: ""
|
||||
)
|
||||
header.isEnabled = false
|
||||
menu.addItem(header)
|
||||
menu.addItem(.separator())
|
||||
|
||||
let category = FileKind.category(of: urls[0])
|
||||
|
||||
if ConversionCatalog.outputs(for: urls.map { FileKind.category(of: $0) }).isEmpty {
|
||||
let item = NSMenuItem(title: "No conversions for this selection", action: nil, keyEquivalent: "")
|
||||
item.isEnabled = false
|
||||
menu.addItem(item)
|
||||
} else {
|
||||
let convert = NSMenu()
|
||||
for item in WheelBuilder.items(kind: .convert, urls: urls) {
|
||||
let entry = NSMenuItem(title: item.title, action: #selector(Choice.fire), keyEquivalent: "")
|
||||
entry.target = makeChoice {
|
||||
JobStore.shared.submit(urls: urls, target: .format(format(from: item)))
|
||||
}
|
||||
entry.isEnabled = item.isEnabled
|
||||
if let reason = item.availability.reason { entry.toolTip = reason }
|
||||
convert.addItem(entry)
|
||||
}
|
||||
let submenu = NSMenuItem(title: "Convert", action: nil, keyEquivalent: "")
|
||||
submenu.submenu = convert
|
||||
menu.addItem(submenu)
|
||||
}
|
||||
|
||||
let tools = WheelBuilder.items(kind: .tools, urls: urls)
|
||||
if category != .unknown, !tools.isEmpty {
|
||||
let submenu = NSMenu()
|
||||
for item in tools {
|
||||
guard case .tool(let tool) = item.payload else { continue }
|
||||
let entry = NSMenuItem(title: tool.title, action: #selector(Choice.fire), keyEquivalent: "")
|
||||
entry.target = makeChoice {
|
||||
if tool.fields.isEmpty {
|
||||
JobStore.shared.submit(urls: urls, target: .tool(tool))
|
||||
} else {
|
||||
OptionsWindowController.shared.present(tool: tool, urls: urls, origin: point)
|
||||
}
|
||||
}
|
||||
entry.isEnabled = item.isEnabled
|
||||
if let reason = item.availability.reason { entry.toolTip = reason }
|
||||
submenu.addItem(entry)
|
||||
}
|
||||
let wrapper = NSMenuItem(title: "Tools", action: nil, keyEquivalent: "")
|
||||
wrapper.submenu = submenu
|
||||
menu.addItem(wrapper)
|
||||
}
|
||||
|
||||
let actions = NSMenu()
|
||||
for item in WheelBuilder.items(kind: .actions, urls: urls) {
|
||||
guard case .action(let action) = item.payload else { continue }
|
||||
let entry = NSMenuItem(title: action.title, action: #selector(Choice.fire), keyEquivalent: "")
|
||||
entry.target = makeChoice {
|
||||
JobStore.shared.submit(urls: urls, target: .action(action))
|
||||
}
|
||||
actions.addItem(entry)
|
||||
}
|
||||
let actionsWrapper = NSMenuItem(title: "Actions", action: nil, keyEquivalent: "")
|
||||
actionsWrapper.submenu = actions
|
||||
menu.addItem(actionsWrapper)
|
||||
|
||||
menu.popUp(positioning: nil, at: point, in: nil)
|
||||
retained.removeAll()
|
||||
}
|
||||
|
||||
private static func makeChoice(_ handler: @escaping () -> Void) -> Choice {
|
||||
let choice = Choice(handler)
|
||||
retained.append(choice)
|
||||
return choice
|
||||
}
|
||||
|
||||
private static func format(from item: WheelItem) -> Format {
|
||||
if case .format(let format) = item.payload { return format }
|
||||
return .png
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user