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,296 @@
|
||||
import Foundation
|
||||
|
||||
/// The broad family a file belongs to. Drives which wheel is shown and which
|
||||
/// converter runs.
|
||||
enum FileCategory: String, CaseIterable, Identifiable, Codable {
|
||||
case image, video, audio, pdf, document, slides, spreadsheet, ebook, subtitle, archive, folder, unknown
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .image: return "Images"
|
||||
case .video: return "Video"
|
||||
case .audio: return "Audio"
|
||||
case .pdf: return "PDF"
|
||||
case .document: return "Documents"
|
||||
case .slides: return "Slides"
|
||||
case .spreadsheet: return "Spreadsheets"
|
||||
case .ebook: return "EPUB"
|
||||
case .subtitle: return "Subtitles"
|
||||
case .archive: return "Archives"
|
||||
case .folder: return "Folders"
|
||||
case .unknown: return "Files"
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .image: return "photo"
|
||||
case .video: return "film"
|
||||
case .audio: return "waveform"
|
||||
case .pdf: return "doc.richtext"
|
||||
case .document: return "doc.text"
|
||||
case .slides: return "rectangle.on.rectangle.angled"
|
||||
case .spreadsheet: return "tablecells"
|
||||
case .ebook: return "book"
|
||||
case .subtitle: return "captions.bubble"
|
||||
case .archive: return "doc.zipper"
|
||||
case .folder: return "folder"
|
||||
case .unknown: return "doc"
|
||||
}
|
||||
}
|
||||
|
||||
/// Wheels only make sense for these.
|
||||
static var convertible: [FileCategory] {
|
||||
[.image, .video, .audio, .pdf, .document, .slides, .spreadsheet, .ebook, .subtitle, .archive]
|
||||
}
|
||||
}
|
||||
|
||||
/// A canonical output extension.
|
||||
enum Format: String, CaseIterable, Identifiable, Codable, Hashable {
|
||||
// Images
|
||||
case jpg, png, heic, webp, tiff, bmp, gif, avif, svg, ico
|
||||
// Documents
|
||||
case pdf
|
||||
case docx, doc, pages, rtf, txt, md, html, odt, epub
|
||||
// Slides
|
||||
case pptx, ppt, key
|
||||
// Spreadsheets
|
||||
case xlsx, xls, ods, csv, tsv, numbers
|
||||
// Audio
|
||||
case mp3, m4a, wav, flac, ogg, opus, aiff, wma
|
||||
// Video
|
||||
case mp4, mov, mkv, webm, avi, wmv, m4v
|
||||
// Subtitles
|
||||
case srt, vtt
|
||||
// Archives
|
||||
case zip, tar, gzip
|
||||
|
||||
var id: String { rawValue }
|
||||
var ext: String { rawValue }
|
||||
var displayName: String { rawValue.uppercased() }
|
||||
|
||||
/// Which wheel this format belongs to when offered as an output.
|
||||
var category: FileCategory {
|
||||
switch self {
|
||||
case .jpg, .png, .heic, .webp, .tiff, .bmp, .gif, .avif, .svg, .ico: return .image
|
||||
case .pdf: return .pdf
|
||||
case .docx, .doc, .pages, .rtf, .txt, .md, .html, .odt: return .document
|
||||
case .epub: return .ebook
|
||||
case .pptx, .ppt, .key: return .slides
|
||||
case .xlsx, .xls, .ods, .csv, .tsv, .numbers: return .spreadsheet
|
||||
case .mp3, .m4a, .wav, .flac, .ogg, .opus, .aiff, .wma: return .audio
|
||||
case .mp4, .mov, .mkv, .webm, .avi, .wmv, .m4v: return .video
|
||||
case .srt, .vtt: return .subtitle
|
||||
case .zip, .tar, .gzip: return .archive
|
||||
}
|
||||
}
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .jpg, .png, .heic, .webp, .tiff, .bmp, .gif, .avif, .ico: return "photo"
|
||||
case .svg: return "scribble.variable"
|
||||
case .pdf: return "doc.richtext"
|
||||
case .md: return "text.alignleft"
|
||||
case .txt: return "doc.plaintext"
|
||||
case .html: return "chevron.left.forwardslash.chevron.right"
|
||||
case .docx, .doc: return "doc.text"
|
||||
case .rtf: return "doc.richtext"
|
||||
case .odt: return "doc.text"
|
||||
case .pages: return "doc.text"
|
||||
case .epub: return "book"
|
||||
case .pptx, .ppt: return "rectangle.on.rectangle.angled"
|
||||
case .key: return "rectangle.on.rectangle.angled"
|
||||
case .xlsx, .xls: return "tablecells"
|
||||
case .ods: return "tablecells"
|
||||
case .csv, .tsv: return "tablecells.badge.ellipsis"
|
||||
case .numbers: return "tablecells"
|
||||
case .mp3, .m4a: return "waveform"
|
||||
case .wav, .flac, .aiff: return "waveform"
|
||||
case .ogg, .opus, .wma: return "waveform"
|
||||
case .mp4, .mov, .mkv, .webm, .avi, .wmv, .m4v: return "film"
|
||||
case .srt, .vtt: return "captions.bubble"
|
||||
case .zip, .tar, .gzip: return "doc.zipper"
|
||||
}
|
||||
}
|
||||
|
||||
/// Uniform type identifier, also used for ImageIO destinations.
|
||||
var uti: String? {
|
||||
switch self {
|
||||
case .jpg: return "public.jpeg"
|
||||
case .png: return "public.png"
|
||||
case .heic: return "public.heic"
|
||||
case .webp: return "org.webmproject.webp"
|
||||
case .tiff: return "public.tiff"
|
||||
case .bmp: return "com.microsoft.bmp"
|
||||
case .gif: return "com.compuserve.gif"
|
||||
case .avif: return "public.avif"
|
||||
case .svg: return "public.svg-image"
|
||||
case .ico: return "com.microsoft.ico"
|
||||
case .pdf: return "com.adobe.pdf"
|
||||
case .txt: return "public.plain-text"
|
||||
case .html: return "public.html"
|
||||
case .rtf: return "public.rtf"
|
||||
case .docx: return "org.openxmlformats.wordprocessingml.document"
|
||||
case .doc: return "com.microsoft.word.doc"
|
||||
case .odt: return "org.oasis-open.opendocument.text"
|
||||
case .epub: return "org.idpf.epub-container"
|
||||
case .pptx: return "org.openxmlformats.presentationml.presentation"
|
||||
case .xlsx: return "org.openxmlformats.spreadsheetml.sheet"
|
||||
case .csv: return "public.comma-separated-values-text"
|
||||
case .tsv: return "public.tab-separated-values-text"
|
||||
case .mp3: return "public.mp3"
|
||||
case .m4a: return "public.mpeg-4-audio"
|
||||
case .wav: return "com.microsoft.waveform-audio"
|
||||
case .aiff: return "public.aiff-audio"
|
||||
case .flac: return "org.xiph.flac"
|
||||
case .ogg: return "org.xiph.ogg"
|
||||
case .opus: return "org.xiph.opus"
|
||||
case .mp4: return "public.mpeg-4"
|
||||
case .mov: return "com.apple.quicktime-movie"
|
||||
case .mkv: return "org.matroska.mkv"
|
||||
case .srt: return "public.srt-subtitle"
|
||||
case .vtt: return "public.vtt-subtitle"
|
||||
case .zip: return "public.zip-archive"
|
||||
case .tar: return "public.tar-archive"
|
||||
case .gzip: return "org.gnu.gnu-zip-archive"
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats Apple's frameworks cannot *write*; ffmpeg is required.
|
||||
var requiresFFmpeg: Bool {
|
||||
switch self {
|
||||
case .mkv, .webm, .avi, .wmv, .mp3, .flac, .ogg, .opus, .wma: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats that need LibreOffice to write with real layout fidelity.
|
||||
var requiresLibreOffice: Bool {
|
||||
switch self {
|
||||
case .pages, .key, .numbers, .ppt, .pptx, .xls: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Lossy formats take a quality knob.
|
||||
var isLossy: Bool {
|
||||
switch self {
|
||||
case .jpg, .heic, .avif, .webp, .mp3, .m4a, .ogg, .opus, .wma, .mp4, .mkv, .webm, .avi, .wmv, .m4v:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static func from(extension ext: String) -> Format? {
|
||||
Format(rawValue: FileKind.canonicalExtension(ext))
|
||||
}
|
||||
}
|
||||
|
||||
/// Input side: figure out what a dropped file is.
|
||||
enum FileKind {
|
||||
private static let extensionMap: [String: FileCategory] = [
|
||||
// Images
|
||||
"jpg": .image, "jpeg": .image, "png": .image, "heic": .image, "heif": .image,
|
||||
"webp": .image, "tif": .image, "tiff": .image, "bmp": .image, "gif": .image,
|
||||
"avif": .image, "svg": .image, "ico": .image, "jp2": .image, "psd": .image,
|
||||
"exr": .image, "tga": .image, "raw": .image, "dng": .image, "cr2": .image,
|
||||
// Video
|
||||
"mp4": .video, "mov": .video, "m4v": .video, "mkv": .video, "webm": .video,
|
||||
"avi": .video, "wmv": .video, "flv": .video, "mpg": .video, "mpeg": .video,
|
||||
"3gp": .video, "ts": .video, "mts": .video,
|
||||
// Audio
|
||||
"mp3": .audio, "m4a": .audio, "m4b": .audio, "m4r": .audio, "wav": .audio,
|
||||
"flac": .audio, "ogg": .audio, "oga": .audio, "opus": .audio, "aiff": .audio,
|
||||
"aif": .audio, "aifc": .audio, "wma": .audio, "caf": .audio, "aac": .audio,
|
||||
"ape": .audio,
|
||||
// PDF
|
||||
"pdf": .pdf,
|
||||
// Documents
|
||||
"docx": .document, "doc": .document, "rtf": .document, "rtfd": .document,
|
||||
"odt": .document, "txt": .document, "text": .document, "md": .document,
|
||||
"markdown": .document, "html": .document, "htm": .document, "xml": .document,
|
||||
"webarchive": .document, "wordml": .document, "tex": .document, "log": .document,
|
||||
"pages": .document,
|
||||
// Slides
|
||||
"ppt": .slides, "pptx": .slides, "key": .slides, "odp": .slides,
|
||||
// Spreadsheets
|
||||
"xlsx": .spreadsheet, "xls": .spreadsheet, "ods": .spreadsheet, "csv": .spreadsheet,
|
||||
"tsv": .spreadsheet, "numbers": .spreadsheet,
|
||||
// Ebook
|
||||
"epub": .ebook, "mobi": .ebook, "azw3": .ebook,
|
||||
// Subtitles
|
||||
"srt": .subtitle, "vtt": .subtitle, "ass": .subtitle, "ssa": .subtitle, "sub": .subtitle,
|
||||
// Archives
|
||||
"zip": .archive, "tar": .archive, "gz": .archive, "tgz": .archive, "bz2": .archive,
|
||||
"xz": .archive, "rar": .archive, "7z": .archive, "zst": .archive
|
||||
]
|
||||
|
||||
private static let canonicalMap: [String: String] = [
|
||||
"jpeg": "jpg", "tif": "tiff", "markdown": "md", "htm": "html", "text": "txt",
|
||||
"tgz": "tar", "aif": "aiff", "aifc": "aiff", "oga": "ogg", "m4b": "m4a",
|
||||
"heif": "heic", "wordml": "odt"
|
||||
]
|
||||
|
||||
static func canonicalExtension(_ ext: String) -> String {
|
||||
let lower = ext.lowercased()
|
||||
return canonicalMap[lower] ?? lower
|
||||
}
|
||||
|
||||
static func category(of url: URL) -> FileCategory {
|
||||
if (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true {
|
||||
return .folder
|
||||
}
|
||||
return category(forExtension: url.pathExtension)
|
||||
}
|
||||
|
||||
static func category(forExtension ext: String) -> FileCategory {
|
||||
extensionMap[ext.lowercased()] ?? .unknown
|
||||
}
|
||||
|
||||
static func format(of url: URL) -> Format? {
|
||||
Format(rawValue: canonicalExtension(url.pathExtension))
|
||||
}
|
||||
|
||||
static func isSupportedInput(_ url: URL) -> Bool {
|
||||
category(of: url) != .unknown
|
||||
}
|
||||
}
|
||||
|
||||
/// What a wheel can offer for a given selection. Kept separate from the UI so
|
||||
/// the same table drives the drag wheel, the menu bar fallback and the
|
||||
/// Quick Action.
|
||||
enum ConversionCatalog {
|
||||
static let targets: [FileCategory: [Format]] = [
|
||||
.image: [.png, .jpg, .heic, .webp, .tiff, .bmp, .gif, .avif, .pdf],
|
||||
.video: [.mp4, .mov, .mkv, .webm, .avi, .wmv, .gif, .mp3, .m4a, .wav],
|
||||
.audio: [.mp3, .m4a, .wav, .flac, .ogg, .opus, .aiff, .wma],
|
||||
.pdf: [.docx, .pptx, .xlsx, .jpg, .png, .epub, .txt, .md, .html],
|
||||
.document: [.docx, .md, .txt, .rtf, .html, .epub, .pdf],
|
||||
.slides: [.pdf, .jpg, .png],
|
||||
.spreadsheet: [.xlsx, .ods, .csv, .tsv, .pdf],
|
||||
.ebook: [.txt, .html, .md, .pdf],
|
||||
.subtitle: [.srt, .vtt, .txt],
|
||||
.archive: [.zip, .tar, .gzip]
|
||||
]
|
||||
|
||||
/// Outputs to show for a selection: the category's list minus the format
|
||||
/// the file already is. A mixed selection falls back to the intersection.
|
||||
static func outputs(for categories: [FileCategory]) -> [Format] {
|
||||
let unique = Set(categories)
|
||||
guard !unique.isEmpty, unique.count == 1, let category = unique.first else {
|
||||
return []
|
||||
}
|
||||
return targets[category] ?? []
|
||||
}
|
||||
|
||||
/// True when every input already has this extension (so it is pointless to
|
||||
/// offer it).
|
||||
static func isRedundant(_ format: Format, urls: [URL]) -> Bool {
|
||||
guard !urls.isEmpty else { return false }
|
||||
return urls.allSatisfy { FileKind.canonicalExtension($0.pathExtension) == format.ext }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user