Files
TZGyn a01d5def89 Give mixed selections something to do
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.
2026-10-20 01:30:42 +08:00

361 lines
15 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
}