import Foundation /// A tool: an in-place transform that is not a plain format change. enum ToolID: String, CaseIterable, Identifiable, Codable { // Images case compressImage, resizeImage, cropImage, rotateImage, flipImage case grayscaleImage, stripMetadata, watermarkImage, readQR // Video case compressVideo, trimVideo, muteVideo, speedVideo, snapshotVideo case videoToGIF, resizeVideo // Audio case trimAudio, normalizeAudio, channelsAudio, speedAudio, bleepAudio, compressAudio // PDF case mergePDF, splitPDF, rotatePDF, compressPDF, encryptPDF, decryptPDF case imagesToPDF, collageImages, extractPDFText // Documents case documentToPDF, wordCount // Spreadsheets case sheetsToCSV // Subtitles case shiftSubtitle // Archives case extractArchive, listArchive, createArchive // Media metadata case mediaInfo var id: String { rawValue } var title: String { switch self { case .compressImage: return "Compress" case .resizeImage: return "Resize" case .cropImage: return "Crop" case .rotateImage: return "Rotate" case .flipImage: return "Flip" case .grayscaleImage: return "Grayscale" case .stripMetadata: return "Strip Metadata" case .watermarkImage: return "Watermark" case .readQR: return "Read QR" case .compressVideo: return "Compress" case .trimVideo: return "Trim" case .muteVideo: return "Mute" case .speedVideo: return "Speed" case .snapshotVideo: return "Snapshot" case .videoToGIF: return "To GIF" case .resizeVideo: return "Resize" case .trimAudio: return "Trim" case .normalizeAudio: return "Normalize" case .channelsAudio: return "Channels" case .speedAudio: return "Speed" case .bleepAudio: return "Bleep" case .compressAudio: return "Compress" case .mergePDF: return "Merge" case .splitPDF: return "Split" case .rotatePDF: return "Rotate" case .compressPDF: return "Compress" case .encryptPDF: return "Encrypt" case .decryptPDF: return "Decrypt" case .imagesToPDF: return "Images to PDF" case .collageImages: return "Collage" case .extractPDFText: return "Extract Text" case .documentToPDF: return "To PDF" case .wordCount: return "Statistics" case .sheetsToCSV: return "To CSV" case .shiftSubtitle: return "Shift Time" case .extractArchive: return "Extract" case .listArchive: return "List Contents" case .createArchive: return "Zip" case .mediaInfo: return "Info" } } var symbol: String { switch self { case .compressImage, .compressVideo, .compressAudio, .compressPDF: return "arrow.down.right.and.arrow.up.left" case .resizeImage, .resizeVideo: return "arrow.up.left.and.arrow.down.right" case .cropImage: return "crop" case .rotateImage, .rotatePDF: return "rotate.right" case .flipImage: return "arrow.left.and.right.righttriangle.left.righttriangle.right" case .grayscaleImage: return "circle.lefthalf.filled" case .stripMetadata, .mediaInfo: return "info.circle" case .watermarkImage: return "signature" case .readQR: return "qrcode.viewfinder" case .trimVideo, .trimAudio: return "scissors" case .muteVideo, .bleepAudio: return "speaker.slash" case .speedVideo, .speedAudio: return "gauge.with.dots.needle.67percent" case .snapshotVideo: return "camera" case .videoToGIF: return "rectangle.stack" case .normalizeAudio: return "waveform.path.ecg" case .channelsAudio: return "speaker.wave.2" case .mergePDF: return "square.stack.3d.down.right" case .splitPDF: return "scissors" case .encryptPDF: return "lock" case .decryptPDF: return "lock.open" case .imagesToPDF: return "doc.badge.plus" case .collageImages: return "rectangle.grid.2x2" case .extractPDFText: return "text.viewfinder" case .documentToPDF: return "doc.richtext" case .wordCount: return "number" case .sheetsToCSV: return "tablecells.badge.ellipsis" case .shiftSubtitle: return "clock.arrow.circlepath" case .extractArchive: return "archivebox" case .listArchive: return "list.bullet.rectangle" case .createArchive: return "doc.zipper" } } var category: FileCategory { switch self { case .compressImage, .resizeImage, .cropImage, .rotateImage, .flipImage, .grayscaleImage, .stripMetadata, .watermarkImage, .readQR: return .image case .compressVideo, .trimVideo, .muteVideo, .speedVideo, .snapshotVideo, .videoToGIF, .resizeVideo: return .video case .trimAudio, .normalizeAudio, .channelsAudio, .speedAudio, .bleepAudio, .compressAudio: return .audio case .mergePDF, .splitPDF, .rotatePDF, .compressPDF, .encryptPDF, .decryptPDF, .imagesToPDF, .collageImages, .extractPDFText: return .pdf case .documentToPDF, .wordCount: return .document case .sheetsToCSV: return .spreadsheet case .shiftSubtitle: return .subtitle case .extractArchive, .listArchive, .createArchive: return .archive case .mediaInfo: return .video } } /// Tools that only make sense for a multi-file selection. var requiresMultipleFiles: Bool { switch self { case .mergePDF, .imagesToPDF, .collageImages, .createArchive: return true default: return false } } var minimumFileCount: Int { requiresMultipleFiles ? 2 : 1 } /// Tools that produce a derived file instead of a transformed copy. var producesArtifacts: Bool { switch self { case .readQR, .extractPDFText, .listArchive, .mediaInfo, .wordCount: return true default: return false } } /// Some tools can only work on narrower inputs (PDF tools on PDFs already, /// video-only tools on video). static func tools(for category: FileCategory) -> [ToolID] { allCases.filter { $0.category == category } } /// Tools that accept a drag of mixed kinds. When a selection cannot become /// one format, there is still something useful to do with it: Filee calls /// this a mixed profile, and it is why its donut is never empty. static func combineTools(for urls: [URL]) -> [ToolID] { guard urls.count >= 2 else { return [] } var tools: [ToolID] = [] if urls.allSatisfy(canBecomePDF) { tools.append(.mergePDF) } tools.append(.createArchive) return tools } /// Kinds that `Converter` knows how to turn into a PDF, which is what /// ``mergePDF`` needs from anything that is not a PDF already. static func canBecomePDF(_ url: URL) -> Bool { switch FileKind.category(of: url) { case .image, .pdf, .document, .slides, .spreadsheet, .ebook: return true default: return false } } } /// One editable field in a tool's options form. struct ToolField: Identifiable, Hashable { enum Kind: Hashable { case text case secret case integer(min: Int, max: Int) case decimal(min: Double, max: Double) case toggle case choice([String]) case timecode } let id: String let label: String let kind: Kind let defaultValue: String let unit: String? let help: String? init(_ id: String, _ label: String, _ kind: Kind, default value: String, unit: String? = nil, help: String? = nil) { self.id = id self.label = label self.kind = kind self.defaultValue = value self.unit = unit self.help = help } } extension ToolID { var fields: [ToolField] { switch self { case .compressImage: return [ToolField("quality", "Quality", .integer(min: 1, max: 100), default: "70", unit: "%")] case .resizeImage: return [ ToolField("width", "Max width", .integer(min: 16, max: 20000), default: "1920", unit: "px"), ToolField("height", "Max height", .integer(min: 16, max: 20000), default: "1920", unit: "px"), ToolField("percent", "…or scale", .integer(min: 1, max: 400), default: "100", unit: "%") ] case .cropImage: return [ ToolField("inset", "Inset from each edge", .integer(min: 0, max: 4000), default: "0", unit: "px"), ToolField("aspect", "Aspect", .choice(["Keep", "1:1", "4:3", "3:2", "16:9", "9:16"]), default: "Keep") ] case .rotateImage, .rotatePDF: return [ToolField("angle", "Rotation", .choice(["90° right", "180°", "90° left"]), default: "90° right")] case .flipImage: return [ToolField("axis", "Axis", .choice(["Horizontal", "Vertical"]), default: "Horizontal")] case .watermarkImage: return [ ToolField("text", "Text", .text, default: "©"), ToolField("position", "Position", .choice(["Bottom Right", "Bottom Left", "Top Right", "Top Left", "Center"]), default: "Bottom Right"), ToolField("opacity", "Opacity", .integer(min: 5, max: 100), default: "45", unit: "%"), ToolField("size", "Size", .integer(min: 8, max: 40), default: "16", unit: "% of width") ] case .compressVideo: return [ToolField("preset", "Quality", .choice(["Low", "Medium", "High"]), default: "Medium")] case .trimVideo, .trimAudio: return [ ToolField("start", "Start", .timecode, default: "00:00:00"), ToolField("end", "End", .timecode, default: "", help: "Leave empty to keep the tail") ] case .speedVideo, .speedAudio: return [ToolField("rate", "Speed", .decimal(min: 0.25, max: 4), default: "2", unit: "×")] case .snapshotVideo: return [ToolField("at", "Timestamp", .timecode, default: "00:00:01")] case .videoToGIF: return [ ToolField("fps", "Frame rate", .integer(min: 5, max: 30), default: "12", unit: "fps"), ToolField("width", "Width", .integer(min: 120, max: 1600), default: "480", unit: "px") ] case .resizeVideo: return [ToolField("width", "Width", .integer(min: 160, max: 7680), default: "1280", unit: "px")] case .bleepAudio: return [ ToolField("start", "Mute from", .timecode, default: "00:00:00"), ToolField("end", "Mute until", .timecode, default: "00:00:01") ] case .compressAudio: return [ToolField("bitrate", "Bitrate", .choice(["64", "96", "128", "192", "256", "320"]), default: "128", unit: "kbps")] case .channelsAudio: return [ToolField("mode", "Channels", .choice(["Mono", "Stereo"]), default: "Mono")] case .splitPDF: return [ToolField("pages", "Pages per file", .integer(min: 1, max: 500), default: "1")] case .compressPDF: return [ToolField("quality", "Image quality", .integer(min: 10, max: 100), default: "55", unit: "%")] case .encryptPDF: return [ ToolField("password", "Password", .secret, default: ""), ToolField("owner", "Owner password", .secret, default: "", help: "Optional, defaults to the password") ] case .decryptPDF: return [ToolField("password", "Password", .secret, default: "")] case .collageImages: return [ ToolField("columns", "Columns", .integer(min: 1, max: 8), default: "3"), ToolField("page", "Page", .choice(["A4", "Letter", "Square"]), default: "A4"), ToolField("margin", "Margin", .integer(min: 0, max: 120), default: "24", unit: "pt"), ToolField("captions", "File names", .toggle, default: "false") ] case .shiftSubtitle: return [ToolField("offset", "Shift by", .decimal(min: -600, max: 600), default: "1", unit: "s")] case .createArchive: return [ToolField("name", "Archive name", .text, default: "Archive", help: "Saved beside the first file")] case .listArchive, .extractArchive, .wordCount, .mediaInfo, .readQR, .stripMetadata, .grayscaleImage, .normalizeAudio, .extractPDFText, .documentToPDF, .sheetsToCSV, .muteVideo, .mergePDF, .imagesToPDF: return [] } } } /// Values collected from a tool's options form. Round-trips through /// `UserDefaults` per tool so repeat use is one drop. struct ToolParams { private var storage: [String: String] = [:] subscript(key: String) -> String? { get { storage[key] } set { storage[key] = newValue } } func string(_ key: String, default fallback: String = "") -> String { let value = storage[key]?.trimmingCharacters(in: .whitespaces) ?? "" return value.isEmpty ? fallback : value } func int(_ key: String, default fallback: Int) -> Int { Int(string(key)) ?? fallback } func double(_ key: String, default fallback: Double) -> Double { Double(string(key)) ?? fallback } func bool(_ key: String, default fallback: Bool) -> Bool { guard let value = storage[key] else { return fallback } return value == "true" || value == "1" } static func defaults(for tool: ToolID) -> ToolParams { var params = ToolParams() for field in tool.fields { params[field.id] = field.defaultValue } return params } mutating func fillMissing(from tool: ToolID) { for field in tool.fields where storage[field.id] == nil { storage[field.id] = field.defaultValue } } var isEmpty: Bool { storage.isEmpty } var dictionary: [String: String] { storage } } /// Timecodes come in as `mm:ss`, `hh:mm:ss` or plain seconds. enum Timecode { static func seconds(from string: String) -> Double? { let trimmed = string.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty { return nil } let parts = trimmed.split(separator: ":").map(String.init) switch parts.count { case 1: return Double(parts[0]) case 2: guard let minutes = Double(parts[0]), let seconds = Double(parts[1]) else { return nil } return minutes * 60 + seconds case 3: guard let h = Double(parts[0]), let m = Double(parts[1]), let s = Double(parts[2]) else { return nil } return h * 3600 + m * 60 + s default: return nil } } }