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.
This commit is contained in:
2026-10-20 01:30:42 +08:00
parent 2d031e58a4
commit a01d5def89
8 changed files with 295 additions and 41 deletions
@@ -58,6 +58,67 @@ enum ArchiveConverter {
}
}
/// Packs a whole selection into one ZIP, the way Finder's *Compress* does.
/// Inputs that live in the same folder keep their relative names; a
/// selection spanning folders is staged through symlinks so the archive
/// still has flat, readable entries instead of absolute paths.
static func createArchive(
from inputs: [URL],
at destination: URL,
cancellation: Cancellation? = nil,
progress: ((Double) -> Void)? = nil
) throws {
guard !inputs.isEmpty else {
throw ConversionError.unsupported("There is nothing to archive.")
}
try cancellation?.check()
progress?(0.1)
let folders = Set(inputs.map { $0.deletingLastPathComponent().path })
if folders.count == 1, let folder = inputs.first?.deletingLastPathComponent() {
try Shell.run(
Toolchain.zip,
["-r", "-X", destination.path] + inputs.map { $0.lastPathComponent },
currentDirectory: folder,
cancellation: cancellation
)
progress?(1)
return
}
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("konverter-zip-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: staging) }
var names: [String] = []
for input in inputs {
var name = input.lastPathComponent
var index = 2
while names.contains(name) {
let ext = input.pathExtension
let base = input.deletingPathExtension().lastPathComponent
name = ext.isEmpty ? "\(base) \(index)" : "\(base) \(index).\(ext)"
index += 1
}
names.append(name)
try FileManager.default.createSymbolicLink(
at: staging.appendingPathComponent(name),
withDestinationURL: input
)
}
// `zip` follows symlinks unless asked for `-y`, so the archive holds
// the real files rather than links to them.
try Shell.run(
Toolchain.zip,
["-r", "-X", destination.path] + names,
currentDirectory: staging,
cancellation: cancellation
)
progress?(1)
}
/// Expands an archive into `directory`, returning the folder that callers
/// should reveal.
@discardableResult
@@ -117,10 +178,10 @@ enum ArchiveConverter {
case "tar", "tgz":
return try Shell.run(Toolchain.tar, ["-tf", source.path]).stdout
case "gz":
let size = (try? FileManager.default.attributesOfItem(atPath: source.path)[.size] as? Int) ?? 0
let size = (try? FileManager.default.attributesOfItem(atPath: source.path))?[.size] as? Int ?? 0
return """
GZIP stream
Compressed size: \(ByteCountFormatter.string(fromByteCount: Int64(size ?? 0), countStyle: .file))
Compressed size: \(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))
Inner name: \(source.deletingPathExtension().lastPathComponent)
"""
default: