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,144 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// The Control wheel: things you can do with a file that are not conversions.
|
||||
enum ActionID: String, CaseIterable, Identifiable, Codable {
|
||||
case airdrop, float, copy, move, copyPath, terminal, revealInFinder
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .airdrop: return "AirDrop"
|
||||
case .float: return "Float"
|
||||
case .copy: return "Copy"
|
||||
case .move: return "Move"
|
||||
case .copyPath: return "Copy Path"
|
||||
case .terminal: return "Terminal"
|
||||
case .revealInFinder: return "Reveal"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .airdrop: return "airdrop"
|
||||
case .float: return "macwindow.on.rectangle"
|
||||
case .copy: return "doc.on.doc"
|
||||
case .move: return "arrow.right.doc.on.clipboard"
|
||||
case .copyPath: return "text.cursor"
|
||||
case .terminal: return "terminal"
|
||||
case .revealInFinder: return "folder"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ActionError: LocalizedError {
|
||||
case needsSingleFile
|
||||
case noDestination
|
||||
case unavailable(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .needsSingleFile: return "This action works on one file at a time."
|
||||
case .noDestination: return "No destination folder was chosen."
|
||||
case .unavailable(let reason): return reason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// File actions never touch the file's contents, and never use a conversion
|
||||
/// slot — matching Tangerine's behaviour.
|
||||
enum FileActions {
|
||||
@MainActor
|
||||
static func perform(_ action: ActionID, urls: [URL], settings: Settings = .shared) async throws {
|
||||
guard !urls.isEmpty else { throw ActionError.noDestination }
|
||||
switch action {
|
||||
case .airdrop:
|
||||
try performAirDrop(urls: urls)
|
||||
case .float:
|
||||
FloatPanel.shared.show(urls: urls)
|
||||
case .copy:
|
||||
if let destination = try await chooseDestination() {
|
||||
try transfer(urls: urls, to: destination, moving: false)
|
||||
}
|
||||
case .move:
|
||||
if let destination = try await chooseDestination() {
|
||||
try transfer(urls: urls, to: destination, moving: true)
|
||||
}
|
||||
case .copyPath:
|
||||
copyPaths(urls: urls)
|
||||
case .terminal:
|
||||
try openTerminal(urls: urls)
|
||||
case .revealInFinder:
|
||||
NSWorkspace.shared.activateFileViewerSelecting(urls)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func performAirDrop(urls: [URL]) throws {
|
||||
guard let service = NSSharingService(named: .sendViaAirDrop) else {
|
||||
throw ActionError.unavailable("AirDrop is not available on this Mac.")
|
||||
}
|
||||
guard service.canPerform(withItems: urls) else {
|
||||
throw ActionError.unavailable("AirDrop cannot send this selection.")
|
||||
}
|
||||
service.perform(withItems: urls)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func chooseDestination() async throws -> URL? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canCreateDirectories = true
|
||||
panel.prompt = "Choose"
|
||||
panel.message = "Choose a destination folder"
|
||||
panel.directoryURL = Settings.shared.resolvedOutputFolder
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return panel.runModal() == .OK ? panel.url : nil
|
||||
}
|
||||
|
||||
private static func transfer(urls: [URL], to destination: URL, moving: Bool) throws {
|
||||
let manager = FileManager.default
|
||||
for url in urls {
|
||||
var target = destination.appendingPathComponent(url.lastPathComponent)
|
||||
target = OutputNaming.availableURL(for: target)
|
||||
do {
|
||||
if moving {
|
||||
try manager.moveItem(at: url, to: target)
|
||||
} else {
|
||||
try manager.copyItem(at: url, to: target)
|
||||
}
|
||||
} catch {
|
||||
throw ActionError.unavailable("Could not \(moving ? "move" : "copy") \(url.lastPathComponent): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func copyPaths(urls: [URL]) {
|
||||
let text = urls.map(\.path).joined(separator: "\n")
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(text, forType: .string)
|
||||
Notifier.flash(
|
||||
title: urls.count == 1 ? "Path copied" : "\(urls.count) paths copied",
|
||||
body: text.count > 120 ? String(text.prefix(120)) + "…" : text
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func openTerminal(urls: [URL]) throws {
|
||||
let directory = urls[0].deletingLastPathComponent()
|
||||
let terminal = URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app")
|
||||
let configuration = NSWorkspace.OpenConfiguration()
|
||||
NSWorkspace.shared.open([directory], withApplicationAt: terminal, configuration: configuration) { _, error in
|
||||
if let error {
|
||||
DispatchQueue.main.async {
|
||||
Notifier.error("Terminal", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user