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

492 lines
25 KiB
Swift

import AppKit
import Foundation
import PDFKit
/// What a tool produced. `message` is for the read-only tools (Info, Read QR,
/// List Contents, Statistics) which show a result instead of writing a file.
struct ToolOutcome {
var outputs: [URL] = []
var message: String?
var messageTitle: String?
var revealInFinder: Bool = true
}
/// Runs a `ToolID` over one or more files. Every tool is written so that a
/// failure on one file does not silently swallow the rest: the first error is
/// reported after the successful outputs are collected.
enum ToolRunner {
static func run(
tool: ToolID,
inputs: [URL],
params: ToolParams,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws -> ToolOutcome {
guard inputs.count >= tool.minimumFileCount else {
throw ConversionError.failed("\(tool.title) needs at least \(tool.minimumFileCount) files.")
}
try cancellation?.check()
switch tool {
case .compressImage:
let quality = Double(params.int("quality", default: 70)) / 100
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let format = FileKind.format(of: source) ?? .jpg
let destination = OutputNaming.sibling(for: source, suffix: "-compressed")
try ImageTools.compress(source, at: destination, format: format, quality: quality)
return destination
}
case .resizeImage:
let width = params.int("width", default: 1920)
let height = params.int("height", default: 1920)
let percent = params.int("percent", default: 100)
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-resized")
try ImageTools.resize(source, at: destination, maxWidth: width, maxHeight: height, percent: percent)
return destination
}
case .cropImage:
let inset = params.int("inset", default: 0)
let aspect = params.string("aspect", default: "Keep")
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-cropped")
try ImageTools.crop(source, at: destination, inset: inset, aspect: aspect)
return destination
}
case .rotateImage:
let degrees = params.string("angle", default: "90° right").hasPrefix("90° r") ? 90 : (params.string("angle", default: "").hasPrefix("180") ? 180 : -90)
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-rotated")
try ImageTools.rotate(source, at: destination, degrees: degrees)
return destination
}
case .flipImage:
let horizontal = params.string("axis", default: "Horizontal") == "Horizontal"
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-flipped")
try ImageTools.flip(source, at: destination, horizontal: horizontal)
return destination
}
case .grayscaleImage:
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-gray", extension: "png")
try ImageTools.grayscale(source, at: destination)
return destination
}
case .stripMetadata:
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let format = FileKind.format(of: source) ?? .jpg
let destination = OutputNaming.sibling(for: source, suffix: "-clean")
try ImageTools.stripMetadata(source, at: destination, format: format)
return destination
}
case .watermarkImage:
let text = params.string("text", default: "©")
let position = params.string("position", default: "Bottom Right")
let opacity = Double(params.int("opacity", default: 45))
let size = Double(params.int("size", default: 16))
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-watermark")
try ImageTools.watermark(source, at: destination, text: text, position: position, opacity: opacity, sizePercent: size)
return destination
}
case .readQR:
var results: [String] = []
for source in inputs {
try cancellation?.check()
let payload = ImageTools.readQR(source)
results.append("\(source.lastPathComponent):\n\(payload ?? "No barcode found")")
}
return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Barcode")
case .compressVideo:
let quality = params.string("preset", default: "Medium")
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let ext = container(for: source, fallback: "mp4")
let destination = OutputNaming.sibling(for: source, suffix: "-compressed", extension: ext)
try MediaTools.compressVideo(source, at: destination, quality: quality)
return destination
}
case .trimVideo, .trimAudio:
let start = Timecode.seconds(from: params.string("start", default: "0")) ?? 0
let end = Timecode.seconds(from: params.string("end", default: ""))
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let ext = container(for: source, fallback: tool == .trimAudio ? "m4a" : "mp4")
let destination = OutputNaming.sibling(for: source, suffix: "-trimmed", extension: ext)
try trim(source, at: destination, start: start, end: end, cancellation: cancellation)
return destination
}
case .muteVideo:
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let ext = container(for: source, fallback: "mp4")
let destination = OutputNaming.sibling(for: source, suffix: "-muted", extension: ext)
try AVConverter.mute(source: source, at: destination, cancellation: cancellation)
return destination
}
case .speedVideo, .speedAudio:
let rate = params.double("rate", default: 2)
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let ext = container(for: source, fallback: tool == .speedAudio ? "m4a" : "mp4")
let destination = OutputNaming.sibling(for: source, suffix: "-\(rateString(rate))x", extension: ext)
try AVConverter.changeSpeed(source: source, at: destination, rate: rate, cancellation: cancellation)
return destination
}
case .snapshotVideo:
let at = Timecode.seconds(from: params.string("at", default: "1")) ?? 1
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-frame", extension: "png")
try AVConverter.snapshot(source: source, at: destination, time: at, cancellation: cancellation)
return destination
}
case .videoToGIF:
let fps = params.int("fps", default: 12)
let width = params.int("width", default: 480)
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "", extension: "gif")
try AVConverter.makeGIF(source: source, at: destination, fps: fps, maxWidth: width, cancellation: cancellation)
return destination
}
case .resizeVideo:
let width = params.int("width", default: 1280)
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let ext = container(for: source, fallback: "mp4")
let destination = OutputNaming.sibling(for: source, suffix: "-resized", extension: ext)
try AVConverter.resize(source: source, at: destination, width: width, cancellation: cancellation)
return destination
}
case .normalizeAudio:
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = MediaTools.audioSibling(for: source, suffix: "-normalized")
try MediaTools.normalizeAudio(source, at: destination)
return destination
}
case .channelsAudio:
let mono = params.string("mode", default: "Mono") == "Mono"
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = MediaTools.audioSibling(for: source, suffix: mono ? "-mono" : "-stereo")
try MediaTools.channels(source, at: destination, mono: mono)
return destination
}
case .bleepAudio:
let start = Timecode.seconds(from: params.string("start", default: "0")) ?? 0
let end = Timecode.seconds(from: params.string("end", default: "1")) ?? 1
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = MediaTools.audioSibling(for: source, suffix: "-bleeped")
try MediaTools.bleep(source, at: destination, start: start, end: max(end, start + 0.05))
return destination
}
case .compressAudio:
let bitrate = params.string("bitrate", default: "128")
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = MediaTools.audioSibling(for: source, suffix: "-compressed")
try MediaTools.compressAudio(source, at: destination, bitrate: bitrate)
return destination
}
case .mergePDF:
let first = inputs[0]
let destination = OutputNaming.availableURL(
for: first.deletingLastPathComponent()
.appendingPathComponent(first.deletingPathExtension().lastPathComponent + " merged.pdf")
)
// A mixed selection is merged by turning each non-PDF into a PDF
// first, so "3 photos + 2 PDFs" is one drop instead of two jobs.
var staged: [URL] = []
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-merge-\(UUID().uuidString)", isDirectory: true)
defer { if !staged.isEmpty { try? FileManager.default.removeItem(at: staging) } }
var sources: [URL] = []
for (index, input) in inputs.enumerated() {
try cancellation?.check()
if FileKind.category(of: input) == .pdf {
sources.append(input)
continue
}
if staged.isEmpty {
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
}
let name = String(format: "%03d", index) + "-"
+ input.deletingPathExtension().lastPathComponent + ".pdf"
let temp = staging.appendingPathComponent(name)
try Converter.convert(
source: input,
to: .pdf,
at: temp,
cancellation: cancellation,
progress: { fraction in progress?(fraction * 0.5) }
)
staged.append(temp)
sources.append(temp)
}
try PDFConverter.merge(sources, at: destination, cancellation: cancellation)
progress?(1)
return ToolOutcome(outputs: [destination])
case .splitPDF:
let perFile = max(1, params.int("pages", default: 1))
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let directory = OutputNaming.artifactDirectory(for: source, suffix: " split")
let written = try PDFConverter.split(source, pagesPerFile: perFile, into: directory, cancellation: cancellation)
guard let first = written.first else { throw ConversionError.failed("Nothing to split.") }
return first
}
case .rotatePDF:
let angle = params.string("angle", default: "90° right")
let degrees = angle.hasPrefix("180") ? 180 : (angle.hasPrefix("90° l") ? -90 : 90)
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-rotated", extension: "pdf")
try PDFConverter.rotate(source, degrees: degrees, at: destination)
return destination
}
case .compressPDF:
let quality = Double(params.int("quality", default: 55)) / 100
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-compressed", extension: "pdf")
try PDFConverter.compress(source, at: destination, quality: quality, cancellation: cancellation)
return destination
}
case .encryptPDF:
let password = params.string("password", default: "")
guard !password.isEmpty else { throw ConversionError.failed("Enter a password first.") }
let owner = params.string("owner", default: "")
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-encrypted", extension: "pdf")
try PDFConverter.encrypt(source, at: destination, password: password, ownerPassword: owner)
return destination
}
case .decryptPDF:
let password = params.string("password", default: "")
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "-decrypted", extension: "pdf")
guard let document = PDFDocument(url: source),
document.unlock(withPassword: password) || !document.isLocked else {
throw ConversionError.failed("Wrong password or the PDF is not encrypted.")
}
guard document.write(to: destination) else {
throw ConversionError.failed("Could not write the decrypted PDF.")
}
return destination
}
case .imagesToPDF:
let images: [(CGImage, String?)] = try inputs.map { source in
guard let image = Raster.firstImage(at: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
return (image, source.lastPathComponent)
}
let first = inputs[0]
let destination = OutputNaming.availableURL(
for: first.deletingLastPathComponent()
.appendingPathComponent(first.deletingPathExtension().lastPathComponent + " images.pdf")
)
try PDFConverter.imagesToPDF(images, at: destination, cancellation: cancellation)
progress?(1)
return ToolOutcome(outputs: [destination])
case .collageImages:
let columns = max(1, params.int("columns", default: 3))
let page = params.string("page", default: "A4")
let margin = Double(params.int("margin", default: 24))
let captions = params.bool("captions", default: false)
let entries: [(CGImage, String)] = try inputs.map { source in
guard let image = Raster.firstImage(at: source) else {
throw ConversionError.unreadable(source.lastPathComponent)
}
return (image, source.lastPathComponent)
}
let first = inputs[0]
let destination = OutputNaming.availableURL(
for: first.deletingLastPathComponent()
.appendingPathComponent(first.deletingPathExtension().lastPathComponent + " collage.pdf")
)
try PDFConverter.collage(
entries,
at: destination,
columns: columns,
page: page,
margin: margin,
captions: captions,
cancellation: cancellation
)
progress?(1)
return ToolOutcome(outputs: [destination])
case .extractPDFText:
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let text = PDFConverter.extractText(source)
guard !text.trimmed.isEmpty else {
throw ConversionError.failed("No text layer in \(source.lastPathComponent).")
}
let destination = OutputNaming.sibling(for: source, suffix: "", extension: "txt")
try TextDocument.write(text, to: destination)
return destination
}
case .documentToPDF:
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let destination = OutputNaming.sibling(for: source, suffix: "", extension: "pdf")
try DocumentConverter.convert(source: source, to: .pdf, at: destination, cancellation: cancellation)
return destination
}
case .wordCount:
var results: [String] = []
for source in inputs {
try cancellation?.check()
let text = TextDocument.plainText(from: source) ?? ""
let words = text.split { $0 == " " || $0.isNewline }.count
let lines = text.components(separatedBy: "\n").count
results.append("""
\(source.lastPathComponent)
\(words) words · \(text.count) characters · \(lines) lines
""")
}
return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Statistics")
case .sheetsToCSV:
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let rows = try SpreadsheetConverter.read(source, cancellation: cancellation)
let destination = OutputNaming.sibling(for: source, suffix: "", extension: "csv")
try SpreadsheetConverter.write(rows, to: destination, delimiter: ",")
return destination
}
case .shiftSubtitle:
let offset = params.double("offset", default: 0)
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let format = FileKind.format(of: source) ?? .srt
let cues = SubtitleConverter.shift(try SubtitleConverter.parse(source), by: offset)
let destination = OutputNaming.sibling(for: source, suffix: "-shifted")
try SubtitleConverter.write(cues, to: destination, format: format == .vtt ? .vtt : .srt)
return destination
}
case .extractArchive:
return try mapFiles(inputs, cancellation: cancellation, progress: progress) { source in
let directory = OutputNaming.artifactDirectory(for: source, suffix: " extracted")
let result = try ArchiveConverter.extract(source: source, into: directory, cancellation: cancellation, progress: nil)
return result
}
case .listArchive:
var results: [String] = []
for source in inputs {
try cancellation?.check()
results.append("\(source.lastPathComponent)\n\((try? ArchiveConverter.list(source)) ?? "Unreadable")")
}
return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Archive")
case .mediaInfo:
var results: [String] = []
for source in inputs {
try cancellation?.check()
results.append(MediaInfo.describe(source))
}
return ToolOutcome(message: results.joined(separator: "\n\n"), messageTitle: "Info")
case .createArchive:
let folder = inputs[0].deletingLastPathComponent()
let name = params.string("name", default: "Archive")
let base = name.hasSuffix(".zip") ? String(name.dropLast(4)) : name
let destination = OutputNaming.availableURL(
for: folder.appendingPathComponent(base).appendingPathExtension("zip")
)
try ArchiveConverter.createArchive(
from: inputs,
at: destination,
cancellation: cancellation,
progress: progress
)
return ToolOutcome(outputs: [destination])
}
}
// MARK: - Helpers
private static func mapFiles(
_ inputs: [URL],
cancellation: Cancellation?,
progress: ((Double) -> Void)?,
_ transform: (URL) throws -> URL
) throws -> ToolOutcome {
var outputs: [URL] = []
var firstError: Error?
for (index, source) in inputs.enumerated() {
try cancellation?.check()
do {
let output = try transform(source)
outputs.append(output)
} catch is CancellationError {
throw CancellationError()
} catch {
if firstError == nil { firstError = error }
}
progress?(Double(index + 1) / Double(inputs.count))
}
if outputs.isEmpty, let firstError { throw firstError }
return ToolOutcome(outputs: outputs)
}
/// Keeps the source container when it is one we can write, otherwise uses
/// the fallback (`mp4` for video, `m4a` for audio).
private static func container(for source: URL, fallback: String) -> String {
let ext = FileKind.canonicalExtension(source.pathExtension)
let videoContainers = ["mp4", "mov", "m4v"]
let audioContainers = ["m4a", "mp3", "wav", "aiff", "flac", "ogg", "opus", "wma"]
if videoContainers.contains(ext) || audioContainers.contains(ext) { return ext }
return fallback
}
private static func rateString(_ rate: Double) -> String {
rate == rate.rounded() ? String(Int(rate)) : String(format: "%.2f", rate)
}
private static func trim(_ source: URL, at destination: URL, start: Double, end: Double?, cancellation: Cancellation?) throws {
let ext = FileKind.canonicalExtension(source.pathExtension)
if ["mp4", "mov", "m4v", "m4a"].contains(ext) {
try AVConverter.trim(source: source, at: destination, start: start, end: end, cancellation: cancellation)
return
}
if Toolchain.hasFFmpeg, Settings.shared.useExternalTools {
var arguments = ["-ss", String(format: "%.3f", start)]
if let end, end > start { arguments += ["-t", String(format: "%.3f", end - start)] }
arguments += ["-c:a", "copy"]
try FFmpeg.filter(source: source, at: destination, arguments: arguments)
return
}
if ["wav", "aiff"].contains(ext) {
let staged = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-trim-\(UUID().uuidString).m4a")
defer { try? FileManager.default.removeItem(at: staged) }
try AVConverter.trim(source: source, at: staged, start: start, end: end, cancellation: cancellation)
try CoreAudio.convert(source: staged, to: FileKind.format(of: destination) ?? .wav, at: destination, cancellation: cancellation)
return
}
throw ConversionError.missingTool("Trimming .\(ext) needs ffmpeg — brew install ffmpeg")
}
}