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:
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user