From a01d5def8984cfe45211ce89fcd5c30da8899f5b Mon Sep 17 00:00:00 2001 From: TZGyn Date: Tue, 20 Oct 2026 01:30:42 +0800 Subject: [PATCH] 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. --- AGENTS.md | 17 ++++- README.md | 17 ++++- .../Konverter/Convert/ArchiveConverter.swift | 65 +++++++++++++++- Sources/Konverter/Convert/ToolRunner.swift | 47 +++++++++++- Sources/Konverter/Core/Tools.swift | 33 ++++++++- Sources/Konverter/Core/WheelOption.swift | 60 ++++++++++----- Sources/Konverter/SelfTest.swift | 74 +++++++++++++++++++ Sources/Konverter/UI/QuickMenu.swift | 23 +++--- 8 files changed, 295 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f6aa90d..624965a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 --to ./.build/debug/Konverter --convert --tool --param k=v ./build-app.sh # → 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 diff --git a/README.md b/README.md index 814dc89..92a8a00 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/Konverter/Convert/ArchiveConverter.swift b/Sources/Konverter/Convert/ArchiveConverter.swift index aba6285..f6b22fb 100644 --- a/Sources/Konverter/Convert/ArchiveConverter.swift +++ b/Sources/Konverter/Convert/ArchiveConverter.swift @@ -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: diff --git a/Sources/Konverter/Convert/ToolRunner.swift b/Sources/Konverter/Convert/ToolRunner.swift index 2a2cc9a..2b001ed 100644 --- a/Sources/Konverter/Convert/ToolRunner.swift +++ b/Sources/Konverter/Convert/ToolRunner.swift @@ -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]) } } diff --git a/Sources/Konverter/Core/Tools.swift b/Sources/Konverter/Core/Tools.swift index ef35d60..a0d6037 100644 --- a/Sources/Konverter/Core/Tools.swift +++ b/Sources/Konverter/Core/Tools.swift @@ -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: diff --git a/Sources/Konverter/Core/WheelOption.swift b/Sources/Konverter/Core/WheelOption.swift index 6df3c98..6f41f4e 100644 --- a/Sources/Konverter/Core/WheelOption.swift +++ b/Sources/Konverter/Core/WheelOption.swift @@ -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 { diff --git a/Sources/Konverter/SelfTest.swift b/Sources/Konverter/SelfTest.swift index d3020cb..6ecc863 100644 --- a/Sources/Konverter/SelfTest.swift +++ b/Sources/Konverter/SelfTest.swift @@ -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) diff --git a/Sources/Konverter/UI/QuickMenu.swift b/Sources/Konverter/UI/QuickMenu.swift index a88734f..1eac6d6 100644 --- a/Sources/Konverter/UI/QuickMenu.swift +++ b/Sources/Konverter/UI/QuickMenu.swift @@ -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 - } }