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,141 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// One wedge of the wheel.
|
||||
struct WheelItem: Identifiable {
|
||||
enum Payload {
|
||||
case format(Format)
|
||||
case tool(ToolID)
|
||||
case action(ActionID)
|
||||
}
|
||||
|
||||
let id: String
|
||||
let payload: Payload
|
||||
let title: String
|
||||
let subtitle: String?
|
||||
let symbol: String
|
||||
let availability: Availability
|
||||
|
||||
var isEnabled: Bool { availability.isAvailable }
|
||||
}
|
||||
|
||||
enum WheelKind {
|
||||
case convert
|
||||
case tools
|
||||
case actions
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .convert: return "Convert"
|
||||
case .tools: return "Tools"
|
||||
case .actions: return "Actions"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the wheel contents for whatever is being dragged.
|
||||
enum WheelBuilder {
|
||||
static func kind(for modifiers: NSEvent.ModifierFlags, settings: Settings = .shared) -> WheelKind? {
|
||||
if modifiers.contains(settings.actionsKey.flag) { return .actions }
|
||||
if modifiers.contains(settings.toolsKey.flag) {
|
||||
if settings.requireShiftForTools, !modifiers.contains(.shift) { return nil }
|
||||
return .tools
|
||||
}
|
||||
if modifiers.contains(settings.convertKey.flag) { return .convert }
|
||||
return nil
|
||||
}
|
||||
|
||||
static func items(kind: WheelKind, urls: [URL], settings: Settings = .shared) -> [WheelItem] {
|
||||
switch kind {
|
||||
case .convert:
|
||||
return conversionItems(urls: urls)
|
||||
case .tools:
|
||||
return toolItems(urls: urls)
|
||||
case .actions:
|
||||
return actionItems(urls: urls, settings: settings)
|
||||
}
|
||||
}
|
||||
|
||||
private static func conversionItems(urls: [URL]) -> [WheelItem] {
|
||||
let categories = urls.map { FileKind.category(of: $0) }
|
||||
let formats = ConversionCatalog.outputs(for: categories)
|
||||
guard let category = categories.first, !formats.isEmpty else { return [] }
|
||||
|
||||
return formats.compactMap { format in
|
||||
guard !ConversionCatalog.isRedundant(format, urls: urls) else { return nil }
|
||||
let availability = Converter.availability(target: format, source: category)
|
||||
return WheelItem(
|
||||
id: "format-\(format.ext)",
|
||||
payload: .format(format),
|
||||
title: format.displayName,
|
||||
subtitle: availability.isAvailable ? nil : availability.reason,
|
||||
symbol: format.symbol,
|
||||
availability: availability
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func toolItems(urls: [URL]) -> [WheelItem] {
|
||||
let categories = Set(urls.map { FileKind.category(of: $0) })
|
||||
guard categories.count == 1, let category = categories.first else { return [] }
|
||||
|
||||
return ToolID.tools(for: category).compactMap { tool in
|
||||
guard urls.count >= tool.minimumFileCount else { return nil }
|
||||
var availability = Availability.ok
|
||||
if tool.producesArtifacts, urls.count > 1 {
|
||||
availability = .ok
|
||||
}
|
||||
if requiresFFmpeg(tool), !Toolchain.hasFFmpeg {
|
||||
availability = .unavailable("Needs ffmpeg — brew install ffmpeg")
|
||||
}
|
||||
return WheelItem(
|
||||
id: "tool-\(tool.rawValue)",
|
||||
payload: .tool(tool),
|
||||
title: tool.title,
|
||||
subtitle: availability.isAvailable ? nil : availability.reason,
|
||||
symbol: tool.symbol,
|
||||
availability: availability
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func requiresFFmpeg(_ tool: ToolID) -> Bool {
|
||||
switch tool {
|
||||
case .normalizeAudio: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func actionItems(urls: [URL], settings: Settings) -> [WheelItem] {
|
||||
let allFolders = urls.allSatisfy { FileKind.category(of: $0) == .folder }
|
||||
return ActionID.allCases.compactMap { action in
|
||||
if allFolders, action != .airdrop, action != .copy, action != .move,
|
||||
action != .copyPath, action != .terminal, action != .revealInFinder {
|
||||
return nil
|
||||
}
|
||||
_ = settings
|
||||
return WheelItem(
|
||||
id: "action-\(action.rawValue)",
|
||||
payload: .action(action),
|
||||
title: action.title,
|
||||
subtitle: nil,
|
||||
symbol: action.symbol,
|
||||
availability: .ok
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A one-line description of the selection for the centre of the wheel.
|
||||
static func summary(for urls: [URL]) -> String {
|
||||
guard let first = urls.first else { return "" }
|
||||
if urls.count > 1 { return "\(urls.count) files" }
|
||||
return first.lastPathComponent
|
||||
}
|
||||
|
||||
static func icon(for urls: [URL]) -> NSImage {
|
||||
guard let first = urls.first else {
|
||||
return NSImage(systemSymbolName: "doc", accessibilityDescription: nil) ?? NSImage()
|
||||
}
|
||||
return NSWorkspace.shared.icon(forFile: first.path)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user