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
+16 -1
View File
@@ -9,7 +9,7 @@ drag machinery or the conversion catalog.
```bash
swift build # debug
swift build -c release
./.build/debug/Konverter --self-test # 41 checks, exits non-zero on failure
./.build/debug/Konverter --self-test # 46 checks, exits non-zero on failure
./.build/debug/Konverter --convert <files…> --to <ext>
./.build/debug/Konverter --convert <file> --tool <toolID> --param k=v
./build-app.sh <version> # → Konverter.app (signed)
@@ -119,6 +119,21 @@ maths, update those expectations rather than deleting them.
- Optional engines must always degrade, never crash: absence of ffmpeg or
LibreOffice should surface as an unavailable wheel item with a reason, and
`Toolchain.missingToolAdvice` supplies the `brew install …` line for the UI.
- **Mixed selections must never produce an empty wheel.** `WheelBuilder` falls
back to `ToolID.combineTools(for:)` (Zip, Merge) whenever a drag has no single
category, and appends Zip to every multi-file tools wheel. `mergePDF` converts
non-PDF inputs through `Converter.convert(_:to:.pdf)` into a temp folder, so
it accepts photos and documents, not just PDFs. If you add a tool that works
across kinds, add it to `combineTools` and cover it in the self-test.
## Reference material
Research on the apps this project is modelled on lives in an untracked
`.reference/` folder (`.reference/.gitignore` contains `*`, so nothing there is
ever committed). Currently: `tangerine.md` (the closed-source app we clone),
`filee.md` + a shallow clone of `KnifeLemon/Filee` (MIT, the open-source
sibling), and `README.md` indexing them. Read `filee.md` before designing a
feature that Filee already has — the "borrow next" list there is the roadmap.
## Build/bundle notes
+14 -3
View File
@@ -23,6 +23,9 @@ Everything runs locally — no uploads, no accounts, no analytics.
Dropping on the dead zone in the middle, pressing Escape, or dropping the
file anywhere else cancels and lets Finder do its normal thing.
Mixed selections still work: **Zip** packs the selection into one archive and
**Merge** turns it into one PDF (non-PDFs are converted first).
The wheel also works without dragging: the menu bar item and the Finder Quick
Action **Convert with Konverter…** open the same options as a menu.
@@ -55,7 +58,7 @@ finished, sound, use of external tools, and open at login. Stored in
| Subtitle | SRT, VTT, TXT |
| Archive / folder | ZIP, TAR, GZIP |
Plus 38 tools:
Plus 39 tools:
| Group | Tools |
| --- | --- |
@@ -66,9 +69,17 @@ Plus 38 tools:
| Document | To PDF, Statistics |
| Spreadsheet | To CSV |
| Subtitle | Shift Time |
| Archive | Extract, List Contents |
| Archive | Extract, List Contents, Zip |
| Any | Info |
### Mixed selections
Dragging files of different kinds still shows a wheel instead of nothing:
**Zip** packs the whole selection into one archive (flat names, like Finder's
*Compress*), and **Merge** turns everything into one PDF — photos, documents
and existing PDFs are converted to PDF first, then combined. A multi-file drag
of a single kind gets **Zip** in its tools wheel too.
### Engines and fallbacks
| Layer | Used for | Ships with macOS |
@@ -112,7 +123,7 @@ Requires Xcode (Swift 6 toolchain) and macOS 13+.
swift build # debug binary
swift build -c release
./.build/debug/Konverter --self-test # 41 checks over every engine
./.build/debug/Konverter --self-test # 46 checks over every engine
./.build/debug/Konverter --convert photo.heic --to png
./.build/debug/Konverter --convert clip.mov --tool trimVideo \
--param start=0:02 --param end=0:05
@@ -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:
+46 -1
View File
@@ -207,7 +207,37 @@ enum ToolRunner {
for: first.deletingLastPathComponent()
.appendingPathComponent(first.deletingPathExtension().lastPathComponent + " merged.pdf")
)
try PDFConverter.merge(inputs, at: destination, cancellation: cancellation)
// 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])
@@ -377,6 +407,21 @@ enum ToolRunner {
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])
}
}
+30 -3
View File
@@ -20,7 +20,7 @@ enum ToolID: String, CaseIterable, Identifiable, Codable {
// Subtitles
case shiftSubtitle
// Archives
case extractArchive, listArchive
case extractArchive, listArchive, createArchive
// Media metadata
case mediaInfo
@@ -65,6 +65,7 @@ enum ToolID: String, CaseIterable, Identifiable, Codable {
case .shiftSubtitle: return "Shift Time"
case .extractArchive: return "Extract"
case .listArchive: return "List Contents"
case .createArchive: return "Zip"
case .mediaInfo: return "Info"
}
}
@@ -100,6 +101,7 @@ enum ToolID: String, CaseIterable, Identifiable, Codable {
case .shiftSubtitle: return "clock.arrow.circlepath"
case .extractArchive: return "archivebox"
case .listArchive: return "list.bullet.rectangle"
case .createArchive: return "doc.zipper"
}
}
@@ -122,7 +124,7 @@ enum ToolID: String, CaseIterable, Identifiable, Codable {
return .spreadsheet
case .shiftSubtitle:
return .subtitle
case .extractArchive, .listArchive:
case .extractArchive, .listArchive, .createArchive:
return .archive
case .mediaInfo:
return .video
@@ -131,7 +133,10 @@ enum ToolID: String, CaseIterable, Identifiable, Codable {
/// Tools that only make sense for a multi-file selection.
var requiresMultipleFiles: Bool {
self == .mergePDF || self == .imagesToPDF || self == .collageImages
switch self {
case .mergePDF, .imagesToPDF, .collageImages, .createArchive: return true
default: return false
}
}
var minimumFileCount: Int { requiresMultipleFiles ? 2 : 1 }
@@ -151,6 +156,26 @@ enum ToolID: String, CaseIterable, Identifiable, Codable {
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.
@@ -256,6 +281,8 @@ extension ToolID {
]
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:
+40 -20
View File
@@ -59,7 +59,12 @@ enum WheelBuilder {
private static func conversionItems(urls: [URL]) -> [WheelItem] {
let categories = urls.map { FileKind.category(of: $0) }
let formats = ConversionCatalog.outputs(for: categories)
guard let category = categories.first, !formats.isEmpty else { return [] }
// A mixed drag has no single output format, but it can still become one
// file, so the convert wheel offers the combine tools instead of
// vanishing (this is Filee's mixed profile).
guard let category = categories.first, !formats.isEmpty else {
return combineItems(urls: urls)
}
return formats.compactMap { format in
guard !ConversionCatalog.isRedundant(format, urls: urls) else { return nil }
@@ -77,26 +82,41 @@ enum WheelBuilder {
private static func toolItems(urls: [URL]) -> [WheelItem] {
let categories = Set(urls.map { FileKind.category(of: $0) })
guard categories.count == 1, let category = categories.first else { return [] }
return ToolID.tools(for: category).compactMap { tool in
guard urls.count >= tool.minimumFileCount else { return nil }
var availability = Availability.ok
if tool.producesArtifacts, urls.count > 1 {
availability = .ok
}
if requiresFFmpeg(tool), !Toolchain.hasFFmpeg {
availability = .unavailable("Needs ffmpeg — brew install ffmpeg")
}
return WheelItem(
id: "tool-\(tool.rawValue)",
payload: .tool(tool),
title: tool.title,
subtitle: availability.isAvailable ? nil : availability.reason,
symbol: tool.symbol,
availability: availability
)
if categories.count == 1, let category = categories.first,
category != .folder, category != .unknown {
var tools = ToolID.tools(for: category)
// Any multi-file selection can be packed into one archive.
if urls.count >= 2 { tools.append(.createArchive) }
return tools.compactMap { item(for: $0, urls: urls) }
}
return combineItems(urls: urls)
}
/// The slices a selection of mixed kinds can still act on.
private static func combineItems(urls: [URL]) -> [WheelItem] {
let subtitles: [ToolID: String] = [
.mergePDF: "One PDF",
.createArchive: "One ZIP"
]
return ToolID.combineTools(for: urls).compactMap { tool in
item(for: tool, urls: urls, subtitle: subtitles[tool])
}
}
private static func item(for tool: ToolID, urls: [URL], subtitle: String? = nil) -> WheelItem? {
guard urls.count >= tool.minimumFileCount else { return nil }
var availability = Availability.ok
if requiresFFmpeg(tool), !Toolchain.hasFFmpeg {
availability = .unavailable("Needs ffmpeg — brew install ffmpeg")
}
return WheelItem(
id: "tool-\(tool.rawValue)",
payload: .tool(tool),
title: tool.title,
subtitle: availability.isAvailable ? subtitle : availability.reason,
symbol: tool.symbol,
availability: availability
)
}
private static func requiresFFmpeg(_ tool: ToolID) -> Bool {
+74
View File
@@ -1,6 +1,7 @@
import AppKit
import CoreGraphics
import Foundation
import PDFKit
/// `--self-test` exercises the real conversion paths end to end and exits
/// non-zero on failure. Kept in the shipped binary so a build can be verified
@@ -29,6 +30,7 @@ enum SelfTest {
testDocuments()
testAudio()
testEbook()
testCombining()
testGeometry()
print("")
@@ -224,6 +226,25 @@ enum SelfTest {
try ArchiveConverter.convert(source: folder, to: .tar, at: tar)
try requireFile(tar)
}
check("several files → one zip") {
let first = directory.appendingPathComponent("note.txt")
let second = directory.appendingPathComponent("spreadsheet.csv")
try? "one".write(to: first, atomically: true, encoding: .utf8)
try? "a,b\n1,2\n".write(to: second, atomically: true, encoding: .utf8)
let zip = directory.appendingPathComponent("selection.zip")
try ArchiveConverter.createArchive(from: [first, second], at: zip)
try requireFile(zip)
let target = directory.appendingPathComponent("selection-unpacked", isDirectory: true)
try ArchiveConverter.extract(source: zip, into: target)
for name in ["note.txt", "spreadsheet.csv"] {
let entry = target.appendingPathComponent(name)
guard FileManager.default.fileExists(atPath: entry.path) else {
throw TestFailure("\(name) missing from the archive")
}
}
}
}
private static func testDocuments() {
@@ -325,6 +346,59 @@ enum SelfTest {
}
}
/// Filee's "mixed profile": a drag of different kinds still has something
/// useful to do, instead of an empty wheel.
private static func testCombining() {
section("Mixed selections")
let photo = directory.appendingPathComponent("sample.png")
let pdf = directory.appendingPathComponent("sample.pdf")
let note = directory.appendingPathComponent("note.txt")
check("a mixed drag still gets a wheel") {
let items = WheelBuilder.items(kind: .convert, urls: [photo, pdf])
let tools = items.compactMap { item -> ToolID? in
if case .tool(let tool) = item.payload { return tool }
return nil
}
guard !items.isEmpty else { throw TestFailure("the convert wheel was empty") }
guard tools.contains(.mergePDF), tools.contains(.createArchive) else {
throw TestFailure("expected Merge and Zip, got \(tools.map(\.rawValue))")
}
}
check("a multi-file wheel can always be zipped") {
let items = WheelBuilder.items(kind: .tools, urls: [photo, directory.appendingPathComponent("note.txt")])
let tools = items.compactMap { item -> ToolID? in
if case .tool(let tool) = item.payload { return tool }
return nil
}
guard tools.contains(.createArchive) else {
throw TestFailure("Zip is missing from a two-file image wheel")
}
}
check("photos and pdfs merge into one pdf") {
let outcome = try ToolRunner.run(tool: .mergePDF, inputs: [photo, pdf], params: ToolParams())
guard let merged = outcome.outputs.first,
let document = PDFDocument(url: merged) else {
throw TestFailure("no merged pdf was produced")
}
guard document.pageCount >= 2 else {
throw TestFailure("merged pdf has \(document.pageCount) page(s)")
}
}
check("any files → one zip") {
let outcome = try ToolRunner.run(tool: .createArchive, inputs: [note, photo], params: ToolParams())
guard let zip = outcome.outputs.first else { throw TestFailure("no archive was produced") }
try requireFile(zip)
let entries = try ArchiveConverter.list(zip)
for name in ["note.txt", "sample.png"] where !entries.contains(name) {
throw TestFailure("\(name) is missing from the archive: \(entries)")
}
}
}
private static func testGeometry() {
section("Wheel geometry")
let frame = CGRect(x: 0, y: 0, width: 400, height: 400)
+12 -11
View File
@@ -38,24 +38,30 @@ enum QuickMenu {
let category = FileKind.category(of: urls[0])
if ConversionCatalog.outputs(for: urls.map { FileKind.category(of: $0) }).isEmpty {
// The convert wheel also carries the combine tools, so it is empty only
// when there is genuinely nothing to offer.
let conversions = WheelBuilder.items(kind: .convert, urls: urls)
if conversions.isEmpty {
let item = NSMenuItem(title: "No conversions for this selection", action: nil, keyEquivalent: "")
item.isEnabled = false
menu.addItem(item)
} else {
let convert = NSMenu()
for item in WheelBuilder.items(kind: .convert, urls: urls) {
for item in conversions {
guard case .format(let target) = item.payload else { continue }
let entry = NSMenuItem(title: item.title, action: #selector(Choice.fire), keyEquivalent: "")
entry.target = makeChoice {
JobStore.shared.submit(urls: urls, target: .format(format(from: item)))
JobStore.shared.submit(urls: urls, target: .format(target))
}
entry.isEnabled = item.isEnabled
if let reason = item.availability.reason { entry.toolTip = reason }
convert.addItem(entry)
}
let submenu = NSMenuItem(title: "Convert", action: nil, keyEquivalent: "")
submenu.submenu = convert
menu.addItem(submenu)
if !convert.items.isEmpty {
let submenu = NSMenuItem(title: "Convert", action: nil, keyEquivalent: "")
submenu.submenu = convert
menu.addItem(submenu)
}
}
let tools = WheelBuilder.items(kind: .tools, urls: urls)
@@ -102,9 +108,4 @@ enum QuickMenu {
retained.append(choice)
return choice
}
private static func format(from item: WheelItem) -> Format {
if case .format(let format) = item.payload { return format }
return .png
}
}