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.
297 lines
12 KiB
Swift
297 lines
12 KiB
Swift
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 }
|
|
}
|
|
}
|