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 } } }