A Drag of different kinds showed an empty wheel, because the catalog only answers for a single category. Filee's answer is a mixed profile, so add Zip (one archive, flat names) and Merge (non-PDFs are converted to PDF first); a multi-file drag of one kind gets Zip too.
361 lines
15 KiB
Swift
361 lines
15 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|