From bfaf303e6d0dbab5cae246dae6485f5d4508acc6 Mon Sep 17 00:00:00 2001 From: Alin Panaitiu Date: Mon, 29 Jul 2024 17:03:50 +0300 Subject: [PATCH] Output path templates --- .../xcdebugger/Expressions.xcexplist | 34 +- .../xcschemes/xcschememanagement.plist | 4 +- Clop/Automation.swift | 5 +- Clop/ClopApp.swift | 272 ++++++++----- Clop/ClopShortcuts.swift | 19 +- Clop/ClopUtils.swift | 129 ------ Clop/Images.swift | 26 +- Clop/OptimisationUtils.swift | 84 +++- Clop/Settings.swift | 26 ++ Clop/SettingsView.swift | 370 ++++++++++++++---- Clop/Uploads.swift | 4 +- ClopCLI/main.swift | 97 +++-- FinderOptimiser/ActionRequestHandler.swift | 4 +- ReleaseNotes/2.6.0.md | 43 +- Shared.swift | 246 +++++++++++- 15 files changed, 966 insertions(+), 397 deletions(-) diff --git a/Clop.xcodeproj/project.xcworkspace/xcuserdata/alin.xcuserdatad/xcdebugger/Expressions.xcexplist b/Clop.xcodeproj/project.xcworkspace/xcuserdata/alin.xcuserdatad/xcdebugger/Expressions.xcexplist index 8bdbc81..484c56a 100644 --- a/Clop.xcodeproj/project.xcworkspace/xcuserdata/alin.xcuserdatad/xcdebugger/Expressions.xcexplist +++ b/Clop.xcodeproj/project.xcworkspace/xcuserdata/alin.xcuserdatad/xcdebugger/Expressions.xcexplist @@ -3,10 +3,13 @@ version = "1.0"> + contextName = "generateFilePath(template:for:autoIncrementingNumber:mkdir:):Shared.swift"> + value = "newpath.string"> + + @@ -18,5 +21,32 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/Clop.xcodeproj/xcuserdata/alin.xcuserdatad/xcschemes/xcschememanagement.plist b/Clop.xcodeproj/xcuserdata/alin.xcuserdatad/xcschemes/xcschememanagement.plist index 850a243..6a1f5aa 100644 --- a/Clop.xcodeproj/xcuserdata/alin.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Clop.xcodeproj/xcuserdata/alin.xcuserdatad/xcschemes/xcschememanagement.plist @@ -22,7 +22,7 @@ ClopCLI.xcscheme_^#shared#^_ orderHint - 4 + 5 Example (Playground) 1.xcscheme @@ -95,7 +95,7 @@ FinderOptimiser-setapp.xcscheme_^#shared#^_ orderHint - 5 + 4 FinderOptimiser.xcscheme_^#shared#^_ diff --git a/Clop/Automation.swift b/Clop/Automation.swift index 94e2da5..f5f7ebf 100644 --- a/Clop/Automation.swift +++ b/Clop/Automation.swift @@ -176,7 +176,6 @@ var shortcutCacheResetTask: DispatchWorkItem? { oldValue?.cancel() } } -import EonilFSEvents func startShortcutWatcher() { guard fm.fileExists(atPath: "\(HOME)/Library/Shortcuts") else { @@ -184,10 +183,10 @@ func startShortcutWatcher() { } do { - try EonilFSEvents.startWatching(paths: ["\(HOME)/Library/Shortcuts"], for: ObjectIdentifier(AppDelegate.instance)) { event in + try LowtechFSEvents.startWatching(paths: ["\(HOME)/Library/Shortcuts"], for: ObjectIdentifier(AppDelegate.instance), latency: 0.9) { event in guard !SWIFTUI_PREVIEW else { return } - shortcutCacheResetTask = mainAsyncAfter(ms: 1000) { + shortcutCacheResetTask = mainAsyncAfter(ms: 100) { SHM.invalidateCache() } } diff --git a/Clop/ClopApp.swift b/Clop/ClopApp.swift index aee3a4b..65ce321 100644 --- a/Clop/ClopApp.swift +++ b/Clop/ClopApp.swift @@ -486,13 +486,13 @@ class AppDelegate: AppDelegateParent { KM.primaryKeys = Defaults[.enabledKeys] + Defaults[.quickResizeKeys] KM.onPrimaryHotkey = { key in self.handleHotkey(key) - checkInternalRequirements(PRODUCTS, nil) + let _ = checkInternalRequirements(PRODUCTS, nil) } KM.secondaryKeyModifiers = [.lcmd] KM.onSecondaryHotkey = { key in self.handleCommandHotkey(key) - checkInternalRequirements(PRODUCTS, nil) + let _ = checkInternalRequirements(PRODUCTS, nil) } } super.applicationDidFinishLaunching(_: notification) @@ -590,7 +590,7 @@ class AppDelegate: AppDelegateParent { .store(in: &observers) initMachPortListener() - checkInternalRequirements(PRODUCTS, nil) + let _ = checkInternalRequirements(PRODUCTS, nil) setupServiceProvider() startShortcutWatcher() Dropshare.fetchAppURL() @@ -701,18 +701,10 @@ class AppDelegate: AppDelegateParent { fileType: .video, shouldHandle: shouldHandleVideo(event:), cancel: cancelVideoOptimisation(path:) - ) { event in + ) { path in Task.init { - await FileOptimisationWatcher.waitForModificationDateToSettle(event.path) - - if pauseForNextClipboardEvent { - log.debug("Skipping video \(event.path) because Clop was paused") - pauseForNextClipboardEvent = false - return - } - - let video = Video(path: FilePath(event.path)) - try? await optimiseVideo(video, debounceMS: debounceMS, source: Defaults[.videoDirs].filter { event.path.starts(with: $0) }.max(by: \.count)) + let video = Video(path: path) + let _ = try? await optimiseVideo(video, debounceMS: debounceMS, source: Defaults[.videoDirs].filter { path.string.starts(with: $0) }.max(by: \.count)) } } imageWatcher = FileOptimisationWatcher( @@ -722,18 +714,10 @@ class AppDelegate: AppDelegateParent { fileType: .image, shouldHandle: shouldHandleImage(event:), cancel: cancelImageOptimisation(path:) - ) { event in + ) { path in Task.init { - await FileOptimisationWatcher.waitForModificationDateToSettle(event.path) - - if pauseForNextClipboardEvent { - log.debug("Skipping image \(event.path) because Clop was paused") - pauseForNextClipboardEvent = false - return - } - - guard let img = Image(path: FilePath(event.path), retinaDownscaled: false) else { return } - try? await optimiseImage(img, debounceMS: debounceMS, source: Defaults[.imageDirs].filter { event.path.starts(with: $0) }.max(by: \.count)) + guard let img = Image(path: path, retinaDownscaled: false) else { return } + let _ = try? await optimiseImage(img, debounceMS: debounceMS, source: Defaults[.imageDirs].filter { path.string.starts(with: $0) }.max(by: \.count)) } } pdfWatcher = FileOptimisationWatcher( @@ -743,18 +727,9 @@ class AppDelegate: AppDelegateParent { fileType: .pdf, shouldHandle: shouldHandlePDF(event:), cancel: cancelPDFOptimisation(path:) - ) { event in + ) { path in Task.init { - await FileOptimisationWatcher.waitForModificationDateToSettle(event.path) - - if pauseForNextClipboardEvent { - log.debug("Skipping PDF \(event.path) because Clop was paused") - pauseForNextClipboardEvent = false - return - } - - guard let path = event.path.existingFilePath else { return } - try? await optimisePDF(PDF(path), debounceMS: debounceMS, source: Defaults[.pdfDirs].filter { event.path.starts(with: $0) }.max(by: \.count)) + let _ = try? await optimisePDF(PDF(path), debounceMS: debounceMS, source: Defaults[.pdfDirs].filter { path.string.starts(with: $0) }.max(by: \.count)) } } @@ -762,7 +737,7 @@ class AppDelegate: AppDelegateParent { initClipboardOptimiser() } - checkInternalRequirements(PRODUCTS, nil) + let _ = checkInternalRequirements(PRODUCTS, nil) } @MainActor func initClipboardOptimiser() { @@ -841,11 +816,70 @@ extension NSPasteboardItem { } } -enum ClopFileType: String, CaseIterable { +enum ClopFileType: String, CaseIterable, CustomStringConvertible { case image case video case pdf + var defaultNameTemplatePath: FilePath { + switch self { + case .image: + "~/Desktop/shot.png".filePath! + case .video: + "~/Desktop/rec.mp4".filePath! + case .pdf: + "~/Desktop/doc.pdf".filePath! + } + } + + var optimisedBehaviourKey: Defaults.Key { + switch self { + case .image: + .optimisedImageBehaviour + case .video: + .optimisedVideoBehaviour + case .pdf: + .optimisedPDFBehaviour + } + } + + var sameFolderNameTemplateKey: Defaults.Key { + switch self { + case .image: + .sameFolderNameTemplateImage + case .video: + .sameFolderNameTemplateVideo + case .pdf: + .sameFolderNameTemplatePDF + } + } + + var specificFolderNameTemplateKey: Defaults.Key { + switch self { + case .image: + .specificFolderNameTemplateImage + case .video: + .specificFolderNameTemplateVideo + case .pdf: + .specificFolderNameTemplatePDF + } + } + + var optimisedFileBehaviour: OptimisedFileBehaviour { + Defaults[optimisedBehaviourKey] + } + + var description: String { + switch self { + case .image: + "image" + case .video: + "video" + case .pdf: + "PDF" + } + } + var otherCases: [ClopFileType] { ClopFileType.allCases.filter { $0 != self } } @@ -863,7 +897,7 @@ enum ClopFileType: String, CaseIterable { import Ignore -extension EonilFSEventsEvent: Hashable { +extension EonilFSEventsEvent: Hashable { // @retroactive Hashable { public static func == (lhs: EonilFSEventsEvent, rhs: EonilFSEventsEvent) -> Bool { lhs.path == rhs.path } @@ -882,7 +916,7 @@ class FileOptimisationWatcher { fileType: ClopFileType, shouldHandle: @escaping (EonilFSEventsEvent) -> Bool, cancel: @escaping (FilePath) -> Void, - handler: @escaping (EonilFSEventsEvent) -> Void + handler: @escaping (FilePath) -> Void ) { self.pathsKey = pathsKey self.enabledKey = enabledKey @@ -945,14 +979,16 @@ class FileOptimisationWatcher { var maxFilesToHandleKey: Defaults.Key lazy var maxFilesToHandle: Int = Defaults[maxFilesToHandleKey] - var handler: (EonilFSEventsEvent) -> Void + var handler: (FilePath) -> Void var cancel: (FilePath) -> Void var shouldHandle: (EonilFSEventsEvent) -> Bool var observers = Set() var justAddedFiles = Set() var cancelledFiles = Set() + var alreadyOptimisedFiles = Set() var addedFileRemovers = [FilePath: DispatchWorkItem]() + var alreadyOptimisedFileRemovers = [String: DispatchWorkItem]() let startedWatchingAt = Date() @@ -1022,7 +1058,7 @@ class FileOptimisationWatcher { func stopWatching() { if watching { - EonilFSEvents.stopWatching(for: ObjectIdentifier(self)) + LowtechFSEvents.stopWatching(for: ObjectIdentifier(self)) watching = false } } @@ -1032,59 +1068,29 @@ class FileOptimisationWatcher { guard !paths.isEmpty, enabled, !Defaults[.pauseAutomaticOptimisations] else { return } do { - try EonilFSEvents.startWatching(paths: paths, for: ObjectIdentifier(self)) { event in - guard !SWIFTUI_PREVIEW, self.enabled else { return } - - mainAsync { [weak self] in - guard let self, enabled, isAddedFile(event: event), let path = event.path.existingFilePath else { - return - } - - addedFilesCleaner = nil - log.debug("Added \(path.string) to justAddedFiles") - justAddedFiles.insert(event) - cancelledFiles.remove(path) - if !withinSafeMeasureTime { - addedFileRemovers[path]?.cancel() - addedFileRemovers[path] = mainAsyncAfter(ms: 1000) { [weak self] in - log.debug("Removed \(path.string) from justAddedFiles") - self?.justAddedFiles.remove(event) - self?.addedFileRemovers.removeValue(forKey: path) - } + try LowtechFSEvents.startWatching(paths: paths, for: ObjectIdentifier(self), latency: 0.3) { [weak self] event in + guard !SWIFTUI_PREVIEW, let self, enabled, isAddedFile(event: event), + !self.alreadyOptimisedFiles.contains(event.path), + !OM.optimisers.contains(where: { $0.url?.path == event.path }), + let path = event.path.existingFilePath, shouldHandle(event) + else { return } + + let typeName = fileType.description + addedFilesCleaner = nil + log.debug("Added \(path.string) to justAddedFiles in the \(typeName) watcher") + justAddedFiles.insert(event) + cancelledFiles.remove(path) + + if !withinSafeMeasureTime { + addedFileRemovers[path]?.cancel() + addedFileRemovers[path] = mainAsyncAfter(ms: 1000) { [weak self] in + log.debug("Removed \(path.string) from justAddedFiles in the \(typeName) watcher") + self?.justAddedFiles.remove(event) + self?.addedFileRemovers.removeValue(forKey: path) } } - mainActor { [weak self] in - guard let self, enabled else { return } - guard shouldHandle(event) else { return } - - if let root = paths.first(where: { event.path.hasPrefix($0) }), let ignorePath = "\(root)/\(clopIgnoreFileName)".existingFilePath, event.path.isIgnored(in: ignorePath.string) { - log.debug("Ignoring \(event.path) because it's in \(ignorePath.string)") - return - } - - guard !hasSpuriousEvent(event) else { return } - - guard justAddedFiles.count <= maxFilesToHandle else { - let notice = "More than \(maxFilesToHandle) \(fileType.rawValue)s appeared in the\n`\(justAddedFiles.first!.path.filePath?.dir.shellString ?? "folder")`, ignoring…" - log.debug(notice) - showNotice(notice) - for path in justAddedFiles.compactMap(\.path.existingFilePath).set.subtracting(cancelledFiles) { - log.debug("Cancelling optimisation on \(path)") - cancel(path) - cancelledFiles.insert(path) - } - addedFilesCleaner = mainAsyncAfter(ms: 1000) { [weak self] in - log.debug("Cleaning up justAddedFiles and cancelledFiles") - self?.cancelledFiles.removeAll() - self?.justAddedFiles.removeAll() - } - - return - } - - process(event: event) - } + Task.init { [weak self] in await self?.checkEventAndProcess(event) } } } catch { log.error("Failed to start watching \(fileType.rawValue) folders: \(error)") @@ -1093,6 +1099,57 @@ class FileOptimisationWatcher { watching = true } + @MainActor + func checkEventAndProcess(_ event: EonilFSEventsEvent) async { + let shouldContinue = await MainActor.run { [weak self] in + guard let self, enabled else { return false } + // guard !alreadyOptimisedFiles.contains(event.path) else { return false } + // guard shouldHandle(event) else { return false } + + if let root = paths.first(where: { event.path.hasPrefix($0) }), let ignorePath = "\(root)/\(clopIgnoreFileName)".existingFilePath, event.path.isIgnored(in: ignorePath.string) { + log.debug("Ignoring \(event.path) because it's in \(ignorePath.string)") + return false + } + + guard !hasSpuriousEvent(event) else { return false } + + guard justAddedFiles.count <= maxFilesToHandle else { + let notice = "More than \(maxFilesToHandle) \(fileType.rawValue)s appeared in the\n`\(justAddedFiles.first!.path.filePath?.dir.shellString ?? "folder")`, ignoring…" + log.debug(notice) + showNotice(notice) + for path in justAddedFiles.compactMap(\.path.existingFilePath).set.subtracting(cancelledFiles) { + log.debug("Cancelling optimisation on \(path)") + cancel(path) + cancelledFiles.insert(path) + } + addedFilesCleaner = mainAsyncAfter(ms: 1000) { [weak self] in + log.debug("Cleaning up justAddedFiles and cancelledFiles") + self?.cancelledFiles.removeAll() + self?.justAddedFiles.removeAll() + } + + return false + } + + return true + } + + guard shouldContinue else { return } + await Self.waitForModificationDateToSettle(event.path) + + if pauseForNextClipboardEvent { + log.debug("Skipping \(fileType.description) \(event.path) because Clop was paused") + pauseForNextClipboardEvent = false + return + } + + do { + try await process(event: event) + } catch { + log.error("Failed to process \(fileType.rawValue) \(event.path) file event: \(error)") + } + } + func hasSpuriousEvent(_ event: EonilFSEventsEvent) -> Bool { guard withinSafeMeasureTime, !justAddedFiles.isEmpty else { return false @@ -1141,7 +1198,7 @@ class FileOptimisationWatcher { guard let path = ev.path.existingFilePath else { return false } return !self.cancelledFiles.contains(path) }) { - process(event: event) + Task.init { [weak self] in try await self?.process(event: event) } } justAddedFiles.removeAll() delayOptimiser?.remove(after: 0) @@ -1151,16 +1208,27 @@ class FileOptimisationWatcher { return true } - func process(event: EonilFSEventsEvent) { - Task.init { [weak self] in - try await Task.sleep(nanoseconds: 300_000_000) - guard let self, let path = event.path.existingFilePath, !self.cancelledFiles.contains(path) else { return } + @MainActor + func process(event: EonilFSEventsEvent) async throws { + try await Task.sleep(nanoseconds: 300_000_000) + guard var path = event.path.existingFilePath, !self.cancelledFiles.contains(path) else { return } + + let oldPath = path + if let newPath = try getTemplatedPath(type: fileType, path: path), newPath != path { + alreadyOptimisedFiles.insert(newPath.string) + alreadyOptimisedFiles.insert(path.string) + path = try path.copy(to: newPath, force: true) + } - var count = optimisedCount - try? await proGuard(count: &count, limit: 5, url: event.path.fileURL) { - self.handler(event) - } - optimisedCount = count + var count = optimisedCount + try? await proGuard(count: &count, limit: 5, url: path.url) { + self.handler(path) + } + optimisedCount = count + alreadyOptimisedFileRemovers[oldPath.string]?.cancel() + alreadyOptimisedFileRemovers[oldPath.string] = mainAsyncAfter(ms: 3000) { [weak self] in + self?.alreadyOptimisedFiles.remove(oldPath.string) + self?.alreadyOptimisedFileRemovers.removeValue(forKey: oldPath.string) } } diff --git a/Clop/ClopShortcuts.swift b/Clop/ClopShortcuts.swift index 020f92e..971d6d3 100644 --- a/Clop/ClopShortcuts.swift +++ b/Clop/ClopShortcuts.swift @@ -95,6 +95,7 @@ struct ChangePlaybackSpeedOptimiseFileIntent: AppIntent { %S Seconds %p AM/PM + %P Source file path (without name) %f Source file name (without extension) %e Source file extension @@ -220,6 +221,7 @@ struct ConvertImageIntent: AppIntent { %S Seconds %p AM/PM + %P Source file path (without name) %f Source file name (without extension) %e Source file extension @@ -247,14 +249,14 @@ struct ConvertImageIntent: AppIntent { convertedImage = await (try? optimiseImage(convertedImage, copyToClipboard: false, debounceMS: 0, hideFloatingResult: hideFloatingResult, aggressiveOptimisation: aggressiveOptimisation, source: "shortcuts")) ?? convertedImage } - var outFilePath: FilePath = if let outPath = output?.filePath, outPath.string.contains("/"), outPath.string.starts(with: "/") { - outPath.isDir ? outPath.appending(stem) : outPath.dir / generateFileName(template: outPath.name.string, for: path, autoIncrementingNumber: &Defaults[.lastAutoIncrementingNumber]) - } else if let output { - path.dir / generateFileName(template: output, for: path, autoIncrementingNumber: &Defaults[.lastAutoIncrementingNumber]) - } else { - path.removingLastComponent().appending(stem) - } + var outFilePath: FilePath = + if let output, let outPath = output.filePath { + try generateFilePath(template: outPath, for: path, autoIncrementingNumber: &Defaults[.lastAutoIncrementingNumber], mkdir: true) + } else { + path.removingLastComponent().appending(stem) + } outFilePath = FilePath("\(outFilePath.string).\(ext)") + try convertedImage.path.move(to: outFilePath, force: true) return .result(value: IntentFile(data: convertedImage.data, filename: outFilePath.name.string, type: type)) @@ -346,6 +348,7 @@ struct CropOptimiseFileIntent: AppIntent { %S Seconds %p AM/PM + %P Source file path (without name) %f Source file name (without extension) %e Source file extension @@ -550,6 +553,7 @@ struct OptimiseFileIntent: AppIntent { %S Seconds %p AM/PM + %P Source file path (without name) %f Source file name (without extension) %e Source file extension @@ -660,6 +664,7 @@ struct OptimiseURLIntent: AppIntent { %S Seconds %p AM/PM + %P Source file path (without name) %f Source file name (without extension) %e Source file extension diff --git a/Clop/ClopUtils.swift b/Clop/ClopUtils.swift index 56994f3..5005043 100644 --- a/Clop/ClopUtils.swift +++ b/Clop/ClopUtils.swift @@ -96,135 +96,6 @@ enum ClopProcError: Error, CustomStringConvertible { } } -enum ClopError: Error, CustomStringConvertible, Codable { - case fileNotFound(FilePath) - case fileNotImage(FilePath) - case noClipboardImage(FilePath) - case noProcess(String) - case alreadyOptimised(FilePath) - case alreadyResized(FilePath) - case unknownImageType(FilePath) - case skippedType(String) - case imageSizeLarger(FilePath) - case videoSizeLarger(FilePath) - case pdfSizeLarger(FilePath) - case videoError(String) - case pdfError(String) - case downloadError(String) - case optimisationPaused(FilePath) - case optimisationFailed(String) - case conversionFailed(FilePath) - case proError(String) - case downscaleFailed(FilePath) - case dropshareNotRunning(FilePath) - case encryptedPDF(FilePath) - case invalidPDF(FilePath) - case unknownType - - var localizedDescription: String { description } - var description: String { - switch self { - case let .fileNotFound(p): - return "File not found: \(p)" - case let .fileNotImage(p): - return "File is not an image: \(p)" - case let .noClipboardImage(p): - if p.string.isEmpty { return "No image in clipboard" } - return "No image in clipboard: \(p.string.count > 100 ? p.string.prefix(50) + "..." + p.string.suffix(50) : p.string)" - case let .noProcess(string): - return "Can't start process: \(string)" - case let .alreadyOptimised(p): - return "Image is already optimised: \(p)" - case let .alreadyResized(p): - return "Image is already at the correct size or smaller: \(p)" - case let .imageSizeLarger(p): - return "Optimised image size is larger: \(p)" - case let .videoSizeLarger(p): - return "Optimised video size is larger: \(p)" - case let .pdfSizeLarger(p): - return "Optimised PDF size is larger: \(p)" - case let .unknownImageType(p): - return "Unknown image type: \(p)" - case let .videoError(string): - return "Error processing video: \(string)" - case let .pdfError(string): - return "Error processing PDF: \(string)" - case let .downloadError(string): - return "Download failed: \(string)" - case let .skippedType(string): - return "Type is skipped: \(string)" - case let .optimisationPaused(p): - return "Optimisation paused: \(p)" - case let .conversionFailed(p): - return "Conversion failed: \(p)" - case let .proError(string): - return "Pro error: \(string)" - case let .downscaleFailed(p): - return "Downscale failed: \(p)" - case let .optimisationFailed(p): - return "Optimisation failed: \(p)" - case let .dropshareNotRunning(p): - return "Dropshare is not running, upload failed: \(p)" - case let .invalidPDF(p): - return "Can't parse PDF: \(p)" - case let .encryptedPDF(p): - return "PDF is encrypted: \(p)" - case .unknownType: - return "Unknown type" - } - } - var humanDescription: String { - switch self { - case .fileNotFound: - "File not found" - case .fileNotImage: - "Not an image" - case .noClipboardImage: - "No image in clipboard" - case .noProcess: - "Can't start process" - case .alreadyOptimised: - "Already optimised" - case .alreadyResized: - "Image is already at the correct size or smaller" - case .imageSizeLarger: - "Already optimised" - case .videoSizeLarger: - "Already optimised" - case .pdfSizeLarger: - "Already optimised" - case .unknownImageType: - "Unknown image type" - case .videoError: - "Video error" - case .pdfError: - "PDF error" - case .downloadError: - "Download failed" - case .skippedType: - "Type is skipped" - case .optimisationPaused: - "Optimisation paused" - case .conversionFailed: - "Conversion failed" - case .proError: - "Pro error" - case .downscaleFailed: - "Downscale failed" - case .optimisationFailed: - "Optimisation failed" - case .dropshareNotRunning: - "Dropshare not running" - case .encryptedPDF: - "PDF is encrypted" - case .invalidPDF: - "Can't parse PDF" - case .unknownType: - "Unknown type" - } - } -} - extension Progress.FileOperationKind { static let analyzing = Self(rawValue: "Analyzing") static let optimising = Self(rawValue: "Optimising") diff --git a/Clop/Images.swift b/Clop/Images.swift index 69dbedc..29f4957 100644 --- a/Clop/Images.swift +++ b/Clop/Images.swift @@ -47,6 +47,18 @@ extension NSPasteboard.PasteboardType { } extension UTType { + var fileType: ClopFileType? { + if conforms(to: UTType.image) { + .image + } else if conforms(to: UTType.movie) || conforms(to: UTType.video) { + .video + } else if conforms(to: UTType.pdf) { + .pdf + } else { + nil + } + } + var imgType: NSBitmapImageRep.FileType { switch self { case .png: @@ -189,9 +201,10 @@ class Image: CustomStringConvertible { return nil } - var type = type ?? nsImage.type + let type = type ?? nsImage.type + let rpath: FilePath if let path { - self.path = path + rpath = path } else { guard let ext = type?.preferredFilenameExtension else { return nil } @@ -199,11 +212,12 @@ class Image: CustomStringConvertible { // let tempPath = fm.temporaryDirectory.appendingPathComponent("\(Int.random(in: 100 ... 100_000)).\(ext)").path guard fm.createFile(atPath: tempPath.string, contents: data) else { return nil } - self.path = tempPath + rpath = tempPath } - type = type ?? UTType(filenameExtension: self.path.extension ?? "") ?? UTType(mimeType: self.path.fetchFileType() ?? "") ?? .png + let rtype = type ?? UTType(filenameExtension: rpath.extension ?? "") ?? UTType(mimeType: rpath.fetchFileType() ?? "") ?? .png + self.path = rpath self.data = data - self.type = type! + self.type = rtype image = nsImage self.retinaDownscaled = retinaDownscaled @@ -1092,7 +1106,7 @@ extension FilePath { optimisedImage = try img.optimise(optimiser: optimiser, allowLarger: allowLarger, aggressiveOptimisation: aggressiveOptimisation, adaptiveSize: adaptiveOptimisation ?? Defaults[.adaptiveImageSize]) } if optimisedImage!.type == img.type { - try optimisedImage!.path.copy(to: img.path, force: true) + optimisedImage = try optimisedImage?.copyWithPath(optimisedImage!.path.copy(to: img.path, force: true)) } else { mainActor { optimiser.url = optimisedImage!.path.url diff --git a/Clop/OptimisationUtils.swift b/Clop/OptimisationUtils.swift index 581a2fa..475d2c7 100644 --- a/Clop/OptimisationUtils.swift +++ b/Clop/OptimisationUtils.swift @@ -463,6 +463,18 @@ final class QuickLooker: QLPreviewPanelDataSource { var isComparing = false + var fileType: ClopFileType? { + switch type { + case .image: + .image + case .video: + .video + case .pdf: + .pdf + default: + url?.utType()?.fileType + } + } var comparisonOriginalURL: URL? { if let startingURL, startingURL != url, fm.fileExists(atPath: startingURL.path) { return startingURL } if let originalURL, originalURL != url, fm.fileExists(atPath: originalURL.path) { return originalURL } @@ -1195,7 +1207,8 @@ final class QuickLooker: QLPreviewPanelDataSource { aggressiveOptimisation: aggressive, optimisationCount: &manualOptimisationCount, copyToClipboard: id == IDs.clipboardImage, - source: source + source: source, + optimisedFileBehaviour: .inPlace ) } } @@ -1908,7 +1921,13 @@ var manualOptimisationCount = 0 } else { downloadPath } - try fileURL.filePath!.move(to: outFilePath, force: true) + guard let path = fileURL.existingFilePath else { + throw ClopError.downloadError("file not found at \(fileURL.path)") + } + guard outFilePath.dir.mkdir(withIntermediateDirectories: true) else { + throw ClopError.downloadError("could not create directory \(outFilePath.dir.string)") + } + try path.move(to: outFilePath, force: true) guard optimiser.running, !optimiser.inRemoval else { return nil @@ -1958,6 +1977,19 @@ var manualOptimisationCount = 0 var THUMBNAIL_URLS: ThreadSafeDictionary = .init() +func getTemplatedPath(type: ClopFileType, path: FilePath, optimisedFileBehaviour: OptimisedFileBehaviour? = nil) throws -> FilePath? { + switch optimisedFileBehaviour ?? type.optimisedFileBehaviour { + case .temporary: + path.tempFile(addUniqueID: true) + case .inPlace: + path + case .sameFolder: + path.dir / generateFileName(template: Defaults[type.sameFolderNameTemplateKey], for: path, autoIncrementingNumber: &Defaults[.lastAutoIncrementingNumber]) + case .specificFolder: + try generateFilePath(template: Defaults[type.specificFolderNameTemplateKey], for: path, autoIncrementingNumber: &Defaults[.lastAutoIncrementingNumber], mkdir: true) + } +} + @discardableResult @MainActor func optimiseItem( _ item: ClipboardType, @@ -1972,7 +2004,8 @@ var THUMBNAIL_URLS: ThreadSafeDictionary = .init() copyToClipboard: Bool, source: String? = nil, output: String? = nil, - removeAudio: Bool? = nil + removeAudio: Bool? = nil, + optimisedFileBehaviour: OptimisedFileBehaviour? = nil ) async throws -> ClipboardType? { func nope(notice: String, thumbnail: NSImage? = nil, url: URL? = nil, type: ItemType? = nil) { let optimiser = OM.optimiser(id: id, type: type ?? .unknown, operation: "", hidden: hideFloatingResult) @@ -1985,18 +2018,20 @@ var THUMBNAIL_URLS: ThreadSafeDictionary = .init() optimiser.finish(notice: notice) } + var outFilePath: FilePath? let output = output? .replacingOccurrences(of: "%s", with: factorStr(scalingFactor)) .replacingOccurrences(of: "%z", with: cropSizeStr(cropSize)) .replacingOccurrences(of: "%x", with: factorStr(changePlaybackSpeedFactor)) - let outFilePath: FilePath? = - if let path = output?.filePath, path.string.contains("/"), path.string.starts(with: "/") { - path.isDir ? path.appending(item.path.name) : path.dir / generateFileName(template: path.name.string, for: item.path, autoIncrementingNumber: &Defaults[.lastAutoIncrementingNumber]) - } else if let output { - item.path.dir / generateFileName(template: output, for: item.path, autoIncrementingNumber: &Defaults[.lastAutoIncrementingNumber]) - } else { - nil + if let output { + do { + outFilePath = try generateFilePath(template: output, for: item.path, autoIncrementingNumber: &Defaults[.lastAutoIncrementingNumber], mkdir: true) + } catch { + nope(notice: error.localizedDescription) + return nil } + } + let item: ClipboardType = if let outFilePath, item.path.exists, case let .image(img) = item { try .image(img.copyWithPath(item.path.copy(to: outFilePath, force: true))) @@ -2007,7 +2042,11 @@ var THUMBNAIL_URLS: ThreadSafeDictionary = .init() } switch item { - case let .image(img): + case var .image(img): + if outFilePath == nil, let newPath = try getTemplatedPath(type: .image, path: img.path, optimisedFileBehaviour: optimisedFileBehaviour), newPath != img.path { + img = try img.copyWithPath(img.path.copy(to: newPath, force: true)) + } + let result: Image? = try await proGuard(count: &optimisationCount, limit: 5, url: img.path.url) { if let cropSize { guard cropSize < img.size else { throw ClopError.alreadyResized(img.path) } @@ -2048,8 +2087,8 @@ var THUMBNAIL_URLS: ThreadSafeDictionary = .init() } guard let result else { return nil } return .image(result) - case let .file(path): - if path.isImage, let img = Image(path: path, retinaDownscaled: false) { + case var .file(path): + if path.isImage, var img = Image(path: path, retinaDownscaled: false) { guard aggressiveOptimisation == true || scalingFactor != nil || cropSize != nil || !path.hasOptimisationStatusXattr() else { let optimiser = OM.optimiser(id: id, type: .image(img.type), operation: "", hidden: hideFloatingResult, source: source) optimiser.url = path.url @@ -2058,6 +2097,12 @@ var THUMBNAIL_URLS: ThreadSafeDictionary = .init() optimiser.finish(oldBytes: fileSize, newBytes: fileSize, oldSize: img.size) throw ClopError.alreadyOptimised(path) } + + if outFilePath == nil, let newPath = try getTemplatedPath(type: .image, path: img.path, optimisedFileBehaviour: optimisedFileBehaviour), newPath != img.path { + path = try path.copy(to: newPath, force: true) + img = img.copyWithPath(path) + } + let result: Image? = try await proGuard(count: &optimisationCount, limit: 5, url: path.url) { if let cropSize { guard cropSize < img.size else { throw ClopError.alreadyResized(img.path) } @@ -2113,6 +2158,11 @@ var THUMBNAIL_URLS: ThreadSafeDictionary = .init() } throw ClopError.alreadyOptimised(path) } + + if outFilePath == nil, let newPath = try getTemplatedPath(type: .video, path: path, optimisedFileBehaviour: optimisedFileBehaviour), newPath != path { + path = try path.copy(to: newPath, force: true) + } + let result: Video? = try await proGuard(count: &optimisationCount, limit: 5, url: path.url) { let video = await (try? Video.byFetchingMetadata(path: path, thumb: !hideFloatingResult)) ?? Video(path: path, thumb: !hideFloatingResult) @@ -2175,6 +2225,11 @@ var THUMBNAIL_URLS: ThreadSafeDictionary = .init() throw ClopError.alreadyOptimised(path) } + + if outFilePath == nil, let newPath = try getTemplatedPath(type: .pdf, path: path, optimisedFileBehaviour: optimisedFileBehaviour), newPath != path { + path = try path.copy(to: newPath, force: true) + } + let result = try await proGuard(count: &optimisationCount, limit: 5, url: path.url) { let pdf = PDF(path, thumb: !hideFloatingResult) guard let doc = pdf.document else { throw ClopError.invalidPDF(path) } @@ -2255,7 +2310,8 @@ func processOptimisationRequest(_ req: OptimisationRequest) async throws -> [Opt copyToClipboard: req.copyToClipboard, source: req.source, output: req.output, - removeAudio: req.removeAudio + removeAudio: req.removeAudio, + optimisedFileBehaviour: .inPlace ) if let origURL = req.originalUrls[url] { diff --git a/Clop/Settings.swift b/Clop/Settings.swift index 7bfd300..bdd3126 100644 --- a/Clop/Settings.swift +++ b/Clop/Settings.swift @@ -74,6 +74,16 @@ extension Defaults.Keys { static let convertedImageBehaviour = Key("convertedImageBehaviour", default: .sameFolder) static let convertedVideoBehaviour = Key("convertedVideoBehaviour", default: .sameFolder) + static let optimisedImageBehaviour = Key("optimisedImageBehaviour", default: .inPlace) + static let optimisedVideoBehaviour = Key("optimisedVideoBehaviour", default: .inPlace) + static let optimisedPDFBehaviour = Key("optimisedPDFBehaviour", default: .inPlace) + static let sameFolderNameTemplateImage = Key("sameFolderNameTemplateImage", default: DEFAULT_SAME_FOLDER_NAME_TEMPLATE) + static let sameFolderNameTemplateVideo = Key("sameFolderNameTemplateVideo", default: DEFAULT_SAME_FOLDER_NAME_TEMPLATE) + static let sameFolderNameTemplatePDF = Key("sameFolderNameTemplatePDF", default: DEFAULT_SAME_FOLDER_NAME_TEMPLATE) + static let specificFolderNameTemplateImage = Key("specificFolderNameTemplateImage", default: DEFAULT_SPECIFIC_FOLDER_NAME_TEMPLATE) + static let specificFolderNameTemplateVideo = Key("specificFolderNameTemplateVideo", default: DEFAULT_SPECIFIC_FOLDER_NAME_TEMPLATE) + static let specificFolderNameTemplatePDF = Key("specificFolderNameTemplatePDF", default: DEFAULT_SPECIFIC_FOLDER_NAME_TEMPLATE) + static let capVideoFPS = Key("capVideoFPS", default: true) static let targetVideoFPS = Key("targetVideoFPS", default: 60) static let minVideoFPS = Key("minVideoFPS", default: 30) @@ -176,6 +186,13 @@ public enum ConvertedFileBehaviour: String, Defaults.Serializable { case sameFolder } +public enum OptimisedFileBehaviour: String, Defaults.Serializable { + case temporary + case inPlace + case sameFolder + case specificFolder +} + let SETTINGS_TO_SYNC: [Defaults._AnyKey] = [ Defaults.Keys.showMenubarIcon, .adaptiveImageSize, @@ -213,6 +230,15 @@ let SETTINGS_TO_SYNC: [Defaults._AnyKey] = [ .maxVideoSizeMB, .minVideoFPS, .removeAudioFromVideos, + .optimisedImageBehaviour, + .optimisedVideoBehaviour, + .optimisedPDFBehaviour, + .sameFolderNameTemplateImage, + .sameFolderNameTemplateVideo, + .sameFolderNameTemplatePDF, + .specificFolderNameTemplateImage, + .specificFolderNameTemplateVideo, + .specificFolderNameTemplatePDF, .optimiseImagePathClipboard, .optimiseTIFF, .optimiseVideoClipboard, diff --git a/Clop/SettingsView.swift b/Clop/SettingsView.swift index 9a510f2..8591ca2 100644 --- a/Clop/SettingsView.swift +++ b/Clop/SettingsView.swift @@ -12,7 +12,7 @@ import Lowtech import SwiftUI import System -extension String: Identifiable { +extension String: Identifiable { // @retroactive Identifiable { public var id: String { self } } @@ -224,6 +224,9 @@ struct PDFSettingsView: View { @Default(.maxPDFFileCount) var maxPDFFileCount @Default(.useAggressiveOptimisationPDF) var useAggressiveOptimisationPDF @Default(.enableAutomaticPDFOptimisations) var enableAutomaticPDFOptimisations + @Default(.optimisedPDFBehaviour) var optimisedPDFBehaviour + @Default(.sameFolderNameTemplatePDF) var sameFolderNameTemplatePDF + @Default(.specificFolderNameTemplatePDF) var specificFolderNameTemplatePDF var body: some View { Form { @@ -231,6 +234,11 @@ struct PDFSettingsView: View { DirListView(fileType: .pdf, dirs: $pdfDirs, enabled: $enableAutomaticPDFOptimisations) } Section(header: SectionHeader(title: "Optimisation rules")) { + OptimisedFileBehaviourView( + type: .pdf, optimisedBehaviour: $optimisedPDFBehaviour, + sameFolderNameTemplate: $sameFolderNameTemplatePDF, + specificFolderNameTemplate: $specificFolderNameTemplatePDF + ) HStack { Text("Skip PDFs larger than").regular(13).padding(.trailing, 10) TextField("", value: $maxPDFSizeMB, formatter: BoundFormatter(min: 1, max: 10000)) @@ -245,7 +253,7 @@ struct PDFSettingsView: View { .multilineTextAlignment(.center) .frame(width: 50) .background(RoundedRectangle(cornerRadius: 6, style: .continuous).stroke(Color.gray, lineWidth: 1)) - Text(maxPDFFileCount == 1 ? "PDF is dropped" : "PDFs are dropped").regular(13) + Text(maxPDFFileCount == 1 ? "PDF is dropped, copied or moved" : "PDFs are dropped, copied or moved").regular(13) } Toggle(isOn: $useAggressiveOptimisationPDF) { @@ -267,6 +275,9 @@ struct VideoSettingsView: View { @Default(.targetVideoFPS) var targetVideoFPS @Default(.minVideoFPS) var minVideoFPS @Default(.convertedVideoBehaviour) var convertedVideoBehaviour + @Default(.optimisedVideoBehaviour) var optimisedVideoBehaviour + @Default(.sameFolderNameTemplateVideo) var sameFolderNameTemplateVideo + @Default(.specificFolderNameTemplateVideo) var specificFolderNameTemplateVideo @Default(.maxVideoFileCount) var maxVideoFileCount @Default(.removeAudioFromVideos) var removeAudioFromVideos @@ -282,6 +293,11 @@ struct VideoSettingsView: View { DirListView(fileType: .video, dirs: $videoDirs, enabled: $enableAutomaticVideoOptimisations) } Section(header: SectionHeader(title: "Optimisation rules")) { + OptimisedFileBehaviourView( + type: .video, optimisedBehaviour: $optimisedVideoBehaviour, + sameFolderNameTemplate: $sameFolderNameTemplateVideo, + specificFolderNameTemplate: $specificFolderNameTemplateVideo + ) HStack { Text("Skip videos larger than").regular(13).padding(.trailing, 10) TextField("", value: $maxVideoSizeMB, formatter: BoundFormatter(min: 1, max: 10000)) @@ -296,10 +312,12 @@ struct VideoSettingsView: View { .multilineTextAlignment(.center) .frame(width: 50) .background(RoundedRectangle(cornerRadius: 6, style: .continuous).stroke(Color.gray, lineWidth: 1)) - Text(maxVideoFileCount == 1 ? "video is dropped" : "videos are dropped").regular(13) + Text(maxVideoFileCount == 1 ? "video is dropped, copied or moved" : "videos are dropped, copied or moved").regular(13) } HStack { Text("Ignore videos with extension").regular(13).padding(.trailing, 10) + Spacer() + ForEach(VIDEO_FORMATS, id: \.identifier) { format in Button(format.preferredFilenameExtension!) { videoFormatsToSkip.toggle(format) @@ -333,6 +351,8 @@ struct VideoSettingsView: View { Toggle(isOn: $capVideoFPS.animation(.spring())) { HStack { Text("Cap frames per second to").regular(13).padding(.trailing, 10) + Spacer() + Button("30fps") { withAnimation(.spring()) { targetVideoFPS = 30 } }.buttonStyle(ToggleButton(isOn: .oneway { targetVideoFPS == 30 })) @@ -350,6 +370,8 @@ struct VideoSettingsView: View { if targetVideoFPS < 0, capVideoFPS { HStack { Text("but no less than").regular(13).padding(.trailing, 10) + Spacer() + Button("10fps") { minVideoFPS = 10 }.buttonStyle(ToggleButton(isOn: .oneway { minVideoFPS == 10 })) @@ -370,34 +392,42 @@ struct VideoSettingsView: View { Section(header: SectionHeader(title: "Compatibility", subtitle: "Converts less known formats to more compatible ones before optimisation")) { HStack { (Text("Convert to ").regular(13) + Text("mp4").mono(13)).padding(.trailing, 10) + Spacer() + ForEach(FORMATS_CONVERTIBLE_TO_MP4, id: \.identifier) { format in Button(format.preferredFilenameExtension!) { formatsToConvertToMP4.toggle(format) }.buttonStyle(ToggleButton(isOn: .oneway { formatsToConvertToMP4.contains(format) })) } } - HStack { - ( - Text("Converted video location").regular(13) + - Text("\nThis only applies to the mp4 files converted from the above formats").round(10) - ).padding(.trailing, 10) - - Button("Temporary folder") { - convertedVideoBehaviour = .temporary - }.buttonStyle(ToggleButton(isOn: .oneway { convertedVideoBehaviour == .temporary })) - .font(.round(11)) - Button("In-place (replace original)") { - convertedVideoBehaviour = .inPlace - }.buttonStyle(ToggleButton(isOn: .oneway { convertedVideoBehaviour == .inPlace })) - .font(.round(11)) - Button("Same folder (as original)") { - convertedVideoBehaviour = .sameFolder - }.buttonStyle(ToggleButton(isOn: .oneway { convertedVideoBehaviour == .sameFolder })) - .font(.round(11)) - } + convertedVideoLocation } }.padding(4) } + + var convertedVideoLocation: some View { + HStack { + ( + Text("Converted video location").regular(13) + + Text("\nThis only applies to the MP4 files resulting from the conversion of the above formats").round(10) + ).padding(.trailing, 10) + + Spacer() + + Button("Temporary folder") { + convertedVideoBehaviour = .temporary + }.buttonStyle(ToggleButton(isOn: .oneway { convertedVideoBehaviour == .temporary })) + .font(.round(11)) + Button("In-place (replace original)") { + convertedVideoBehaviour = .inPlace + }.buttonStyle(ToggleButton(isOn: .oneway { convertedVideoBehaviour == .inPlace })) + .font(.round(11)) + Button("Same folder (as original)") { + convertedVideoBehaviour = .sameFolder + }.buttonStyle(ToggleButton(isOn: .oneway { convertedVideoBehaviour == .sameFolder })) + .font(.round(11)) + } + } } struct SectionHeader: View { @@ -411,6 +441,168 @@ struct SectionHeader: View { } let DEFAULT_NAME_TEMPLATE = "clop_%y-%m-%d_%i" +let DEFAULT_SAME_FOLDER_NAME_TEMPLATE = "%f-optimised.%e" +let DEFAULT_SPECIFIC_FOLDER_NAME_TEMPLATE = "%P/optimised/%f.%e" + +struct OptimisedFileBehaviourView: View { + let type: ClopFileType + @Binding var optimisedBehaviour: OptimisedFileBehaviour + @Binding var sameFolderNameTemplate: String + @Binding var specificFolderNameTemplate: String + + var body: some View { + VStack { + HStack { + ( + Text("Optimised \(type.description) location").regular(13) + + Text("\nWhere to place the optimised files").round(10) + ).padding(.trailing, 10) + + Spacer() + + Button("Temporary folder") { + optimisedBehaviour = .temporary + }.buttonStyle(ToggleButton(isOn: .oneway { optimisedBehaviour == .temporary })) + Button("In-place (replace original)") { + optimisedBehaviour = .inPlace + }.buttonStyle(ToggleButton(isOn: .oneway { optimisedBehaviour == .inPlace })) + Button("Same folder (as original)") { + optimisedBehaviour = .sameFolder + }.buttonStyle(ToggleButton(isOn: .oneway { optimisedBehaviour == .sameFolder })) + Button("Specific folder") { + optimisedBehaviour = .specificFolder + }.buttonStyle(ToggleButton(isOn: .oneway { optimisedBehaviour == .specificFolder })) + } + if optimisedBehaviour == .sameFolder { + SameFolderNameTemplate(type: type, template: $sameFolderNameTemplate) + .roundbg(radius: 10, verticalPadding: 8, horizontalPadding: 8, color: .fg.warm.opacity(0.05)) + } + if optimisedBehaviour == .specificFolder { + SpecificFolderNameTemplate(type: type, template: $specificFolderNameTemplate) + .roundbg(radius: 10, verticalPadding: 8, horizontalPadding: 8, color: .fg.warm.opacity(0.05)) + } + } + } +} + +struct SameFolderNameTemplate: View { + let type: ClopFileType + @Binding var template: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Name template").medium(12) + + Text("\nRename the optimised file using this template").round(11, weight: .regular).foregroundColor(.secondary) + + HStack { + TextField("", text: $template, prompt: Text(DEFAULT_SAME_FOLDER_NAME_TEMPLATE)) + .frame(width: 300, height: 18, alignment: .leading) + .padding(6) + .background(RoundedRectangle(cornerRadius: 6, style: .continuous).stroke(Color.gray, lineWidth: 1)) + Spacer(minLength: 20) + Text("Example on \(type.defaultNameTemplatePath.name.string): ") + .round(12) + .lineLimit(1) + .allowsTightening(false) + .foregroundColor(.secondary.opacity(0.6)) + Text(generateFileName(template: template ?! DEFAULT_SAME_FOLDER_NAME_TEMPLATE, for: type.defaultNameTemplatePath, autoIncrementingNumber: &Defaults[.lastAutoIncrementingNumber])) + .round(12) + .lineLimit(1) + .allowsTightening(true) + .truncationMode(.middle) + .foregroundColor(.secondary) + } + HStack { + Text(""" + **Date** | **Time** + --------------------|----------------- + Year **%y** | Hour **%H** + Month (numeric) **%m** | Minutes **%M** + Month (name) **%n** | Seconds **%S** + Day **%d** | AM/PM **%p** + Weekday **%w** | + """) + + Spacer() + + Text(""" + Source file name (without extension) **%f** + Source file extension **%e** + + Random characters **%r** + Auto-incrementing number **%i** + """) + } + .font(.mono(12, weight: .light)) + .foregroundColor(.secondary) + .padding(6) + } + } +} +struct SpecificFolderNameTemplate: View { + let type: ClopFileType + @Binding var template: String + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Path template").medium(12) + + Text("\nCreate the optimised file into a path generated by this template").round(11, weight: .regular).foregroundColor(.secondary) + + HStack { + TextField("", text: $template, prompt: Text(DEFAULT_SPECIFIC_FOLDER_NAME_TEMPLATE)) + .frame(width: 400, height: 18, alignment: .leading) + .padding(6) + .background(RoundedRectangle(cornerRadius: 6, style: .continuous).stroke(Color.gray, lineWidth: 1)) + Spacer(minLength: 20) + VStack(alignment: .trailing, spacing: 0) { + Text("Example on \(type.defaultNameTemplatePath.shellString): ") + .mono(10) + .lineLimit(1) + .allowsTightening(false) + .foregroundColor(.secondary.opacity(0.6)) + Text( + try! generateFilePath( + template: template ?! DEFAULT_SPECIFIC_FOLDER_NAME_TEMPLATE, + for: type.defaultNameTemplatePath, + autoIncrementingNumber: &Defaults[.lastAutoIncrementingNumber], + mkdir: false + )?.shellString ?? "Invalid path" + ) + .round(12) + .lineLimit(1) + .allowsTightening(true) + .truncationMode(.middle) + .foregroundColor(.secondary) + } + } + HStack { + Text(""" + **Date** | **Time** + --------------------|----------------- + Year **%y** | Hour **%H** + Month (numeric) **%m** | Minutes **%M** + Month (name) **%n** | Seconds **%S** + Day **%d** | AM/PM **%p** + Weekday **%w** | + """) + + Spacer() + + Text(""" + Source file path (without name) **%P** + Source file name (without extension) **%f** + Source file extension **%e** + + Random characters **%r** + Auto-incrementing number **%i** + """) + } + .font(.mono(12, weight: .light)) + .foregroundColor(.secondary) + .padding(6) + } + } +} struct ImagesSettingsView: View { @Default(.imageDirs) var imageDirs @@ -421,6 +613,9 @@ struct ImagesSettingsView: View { @Default(.adaptiveImageSize) var adaptiveImageSize @Default(.downscaleRetinaImages) var downscaleRetinaImages @Default(.convertedImageBehaviour) var convertedImageBehaviour + @Default(.optimisedImageBehaviour) var optimisedImageBehaviour + @Default(.sameFolderNameTemplateImage) var sameFolderNameTemplateImage + @Default(.specificFolderNameTemplateImage) var specificFolderNameTemplateImage @Default(.maxImageFileCount) var maxImageFileCount @Default(.copyImageFilePath) var copyImageFilePath @Default(.customNameTemplateForClipboardImages) var customNameTemplateForClipboardImages @@ -431,6 +626,47 @@ struct ImagesSettingsView: View { @Default(.useAggressiveOptimisationGIF) var useAggressiveOptimisationGIF @Default(.enableAutomaticImageOptimisations) var enableAutomaticImageOptimisations + var customNameTemplate: some View { + VStack(alignment: .leading, spacing: 6) { + Text("Custom name template").regular(13) + + Text("\nRename the file using this template before copying the path to the clipboard").round(11, weight: .regular).foregroundColor(.secondary) + + HStack { + TextField("", text: $customNameTemplateForClipboardImages, prompt: Text(DEFAULT_NAME_TEMPLATE)) + .frame(width: 400, height: 18, alignment: .leading) + .padding(6) + .background(RoundedRectangle(cornerRadius: 6, style: .continuous).stroke(Color.gray.opacity(useCustomNameTemplateForClipboardImages ? 1 : 0.35), lineWidth: 1)) + .disabled(!useCustomNameTemplateForClipboardImages) + if useCustomNameTemplateForClipboardImages { + Spacer(minLength: 20) + Text(generateFileName(template: customNameTemplateForClipboardImages ?! DEFAULT_NAME_TEMPLATE, autoIncrementingNumber: &Defaults[.lastAutoIncrementingNumber])) + .round(12) + .lineLimit(1) + .allowsTightening(true) + .truncationMode(.middle) + .foregroundColor(.secondary) + } + } + if useCustomNameTemplateForClipboardImages { + Text(""" + **Date** | **Time** + --------------------|----------------- + Year **%y** | Hour **%H** + Month (numeric) **%m** | Minutes **%M** + Month (name) **%n** | Seconds **%S** + Day **%d** | AM/PM **%p** + Weekday **%w** | + + Random characters **%r** + Auto-incrementing number **%i** + """) + .mono(12, weight: .light) + .foregroundColor(.secondary) + .padding(.top, 6) + } + } + + } var body: some View { Form { Section(header: SectionHeader(title: "Watch paths", subtitle: "Optimise images as they appear in these folders")) { @@ -442,49 +678,17 @@ struct ImagesSettingsView: View { + Text("\nWhen copying optimised image data, also copy the path of the image file").round(11, weight: .regular).foregroundColor(.secondary) } Toggle(isOn: $useCustomNameTemplateForClipboardImages.animation(.default)) { - VStack(alignment: .leading, spacing: 6) { - Text("Custom name template").regular(13) - + Text("\nRename the file using this template before copying the path to the clipboard").round(11, weight: .regular).foregroundColor(.secondary) - - HStack { - TextField("", text: $customNameTemplateForClipboardImages, prompt: Text(DEFAULT_NAME_TEMPLATE)) - .frame(width: 400, height: 18, alignment: .leading) - .padding(6) - .background(RoundedRectangle(cornerRadius: 6, style: .continuous).stroke(Color.gray.opacity(useCustomNameTemplateForClipboardImages ? 1 : 0.35), lineWidth: 1)) - .disabled(!useCustomNameTemplateForClipboardImages) - if useCustomNameTemplateForClipboardImages { - Spacer(minLength: 20) - Text(generateFileName(template: customNameTemplateForClipboardImages ?! DEFAULT_NAME_TEMPLATE, autoIncrementingNumber: &Defaults[.lastAutoIncrementingNumber])) - .round(12) - .lineLimit(1) - .allowsTightening(true) - .truncationMode(.middle) - .foregroundColor(.secondary) - } - } - if useCustomNameTemplateForClipboardImages { - Text(""" - **Date** | **Time** - --------------------|----------------- - Year **%y** | Hour **%H** - Month (numeric) **%m** | Minutes **%M** - Month (name) **%n** | Seconds **%S** - Day **%d** | AM/PM **%p** - Weekday **%w** | - - Random characters **%r** - Auto-incrementing number **%i** - """) - .mono(12, weight: .light) - .foregroundColor(.secondary) - .padding(.top, 6) - } - } - + customNameTemplate }.disabled(!copyImageFilePath) } Section(header: SectionHeader(title: "Optimisation rules")) { + OptimisedFileBehaviourView( + type: .image, optimisedBehaviour: $optimisedImageBehaviour, + sameFolderNameTemplate: $sameFolderNameTemplateImage, + specificFolderNameTemplate: $specificFolderNameTemplateImage + ) + HStack { Text("Skip images larger than").regular(13).padding(.trailing, 10) TextField("", value: $maxImageSizeMB, formatter: BoundFormatter(min: 1, max: 500)) @@ -499,11 +703,13 @@ struct ImagesSettingsView: View { .multilineTextAlignment(.center) .frame(width: 50) .background(RoundedRectangle(cornerRadius: 6, style: .continuous).stroke(Color.gray, lineWidth: 1)) - Text(maxImageFileCount == 1 ? "image is dropped" : "images are dropped").regular(13) + Text(maxImageFileCount == 1 ? "image is dropped, copied or moved" : "images are dropped, copied or moved").regular(13) } HStack { Text("Ignore images with extension").regular(13).padding(.trailing, 10) + Spacer() + ForEach(IMAGE_FORMATS, id: \.identifier) { format in Button(format.preferredFilenameExtension!) { imageFormatsToSkip.toggle(format) @@ -512,6 +718,8 @@ struct ImagesSettingsView: View { } HStack { Text("Use more aggressive optimisation for").regular(13).padding(.trailing, 10) + Spacer() + Button("jpeg") { useAggressiveOptimisationJPEG.toggle() }.buttonStyle(ToggleButton(isOn: $useAggressiveOptimisationJPEG)) @@ -535,6 +743,8 @@ struct ImagesSettingsView: View { Section(header: SectionHeader(title: "Compatibility", subtitle: "Converts less known formats to more compatible ones before optimisation")) { HStack { (Text("Convert to ").regular(13) + Text("jpeg").mono(13)).padding(.trailing, 10) + Spacer() + ForEach(FORMATS_CONVERTIBLE_TO_JPEG, id: \.identifier) { format in Button(format.preferredFilenameExtension!) { formatsToConvertToJPEG.toggle(format) @@ -546,6 +756,8 @@ struct ImagesSettingsView: View { } HStack { (Text("Convert to ").regular(13) + Text("png").mono(13)).padding(.trailing, 10) + Spacer() + ForEach(FORMATS_CONVERTIBLE_TO_PNG, id: \.identifier) { format in Button(format.preferredFilenameExtension!) { formatsToConvertToPNG.toggle(format) @@ -555,23 +767,31 @@ struct ImagesSettingsView: View { }.buttonStyle(ToggleButton(isOn: .oneway { formatsToConvertToPNG.contains(format) })) } } - HStack { - Text("Converted image location").regular(13).padding(.trailing, 10) - Button("Temporary folder") { - convertedImageBehaviour = .temporary - }.buttonStyle(ToggleButton(isOn: .oneway { convertedImageBehaviour == .temporary })) - Button("In-place (replace original)") { - convertedImageBehaviour = .inPlace - }.buttonStyle(ToggleButton(isOn: .oneway { convertedImageBehaviour == .inPlace })) - Button("Same folder (as original)") { - convertedImageBehaviour = .sameFolder - }.buttonStyle(ToggleButton(isOn: .oneway { convertedImageBehaviour == .sameFolder })) - } - + convertedImageLocation } }.padding(4) } + var convertedImageLocation: some View { + HStack { + ( + Text("Converted image location").regular(13) + + Text("\nThis only applies to JPGs and PNGs resulting from the conversion of the above formats").round(10) + ).padding(.trailing, 10) + + Spacer() + + Button("Temporary folder") { + convertedImageBehaviour = .temporary + }.buttonStyle(ToggleButton(isOn: .oneway { convertedImageBehaviour == .temporary })) + Button("In-place (replace original)") { + convertedImageBehaviour = .inPlace + }.buttonStyle(ToggleButton(isOn: .oneway { convertedImageBehaviour == .inPlace })) + Button("Same folder (as original)") { + convertedImageBehaviour = .sameFolder + }.buttonStyle(ToggleButton(isOn: .oneway { convertedImageBehaviour == .sameFolder })) + } + } } class BoundFormatter: Formatter { diff --git a/Clop/Uploads.swift b/Clop/Uploads.swift index 36974b5..75cda17 100644 --- a/Clop/Uploads.swift +++ b/Clop/Uploads.swift @@ -3,8 +3,8 @@ import Foundation import Lowtech import System -extension NSRunningApplication: @unchecked Sendable {} -extension NSWorkspace.OpenConfiguration: @unchecked Sendable {} +extension NSRunningApplication: @unchecked Sendable {} // @retroactive @unchecked Sendable {} +extension NSWorkspace.OpenConfiguration: @unchecked Sendable {} // @retroactive @unchecked Sendable {} @MainActor class Dropshare { diff --git a/ClopCLI/main.swift b/ClopCLI/main.swift index d84b4cd..7671f91 100644 --- a/ClopCLI/main.swift +++ b/ClopCLI/main.swift @@ -12,6 +12,20 @@ import PDFKit import System import UniformTypeIdentifiers +let HOME_DIR_REGEX = (try? Regex("^/*?\(NSHomeDirectory())(/)?", as: (Substring, Substring?).self))?.ignoresCase() + +extension String { + var shellString: String { + guard let homeDirRegex = HOME_DIR_REGEX else { + return replacingFirstOccurrence(of: NSHomeDirectory(), with: "~") + } + return replacing(homeDirRegex, with: { "~" + ($0.1 ?? "") }) + } +} +extension URL { + var shellString: String { isFileURL ? path.shellString : absoluteString } +} + extension UserDefaults { #if SETAPP static let app: UserDefaults? = .init(suiteName: "com.lowtechguys.Clop-setapp") @@ -42,7 +56,7 @@ func ensureAppIsRunning() { NSWorkspace.shared.open(CLOP_APP) } -extension UTType: ExpressibleByArgument { +extension UTType: ExpressibleByArgument { // @retroactive ExpressibleByArgument { public init?(argument: String) { if argument == "video" || argument == "movie" { self = .movie @@ -78,7 +92,7 @@ extension String.StringInterpolation { } extension PageLayout: ExpressibleByArgument {} -extension FilePath: ExpressibleByArgument { +extension FilePath: ExpressibleByArgument { // @retroactive ExpressibleByArgument { public init?(argument: String) { guard FileManager.default.fileExists(atPath: argument) else { return nil @@ -98,7 +112,7 @@ extension FilePath: ExpressibleByArgument { } } -extension NSSize: ExpressibleByArgument { +extension NSSize: ExpressibleByArgument { // @retroactive ExpressibleByArgument { public init?(argument: String) { if let size = Int(argument) { self.init(width: size, height: size) @@ -292,6 +306,27 @@ func sendRequest(urls: [URL], showProgress: Bool, async: Bool, gui: Bool, json: } } +let ABSOLUTE_PATH_REGEX = /^(\/|%P)/ + +func normalizeRelativePath(_ path: FilePath) -> FilePath { + guard path.isRelative else { + return path + } + return FilePath("\(FileManager.default.currentDirectoryPath)/\(path.string.trimmingPrefix("./"))").lexicallyNormalized() +} + +func normalizeRelativeOutput(_ output: String?) -> String? { + guard let output = output?.ns.expandingTildeInPath else { + return nil + } + + if output.contains(ABSOLUTE_PATH_REGEX) { + return output + } + + return "\(FileManager.default.currentDirectoryPath)/\(output.trimmingPrefix("./"))" +} + func checkOutputIsDir(_ output: String?, itemCount: Int) throws { guard let output, output.contains("/"), !output.contains("%"), itemCount > 1 else { return @@ -300,7 +335,7 @@ func checkOutputIsDir(_ output: String?, itemCount: Int) throws { var isDir: ObjCBool = false let exists = FileManager.default.fileExists(atPath: output, isDirectory: &isDir) if exists, !isDir.boolValue { - throw ValidationError("Output path must be a folder when cropping multiple files") + throw ValidationError("Output path must be a folder when processing multiple files") } try FileManager.default.createDirectory(atPath: output, withIntermediateDirectories: true, attributes: nil) } @@ -353,13 +388,6 @@ func getPDFsFromFolder(_ folder: FilePath, recursive: Bool) -> [FilePath] { return pdfs } -func normPath(_ pathString: String?) -> String? { - guard let pathString = pathString?.ns.expandingTildeInPath else { return nil } - guard pathString.contains("/") else { return pathString } - - return pathString.starts(with: "/") ? pathString : FileManager.default.currentDirectoryPath + "/" + pathString -} - enum ImageFormat: String, CaseIterable, Equatable, Decodable, ExpressibleByArgument { case avif, heic, webp @@ -401,10 +429,11 @@ struct Clop: ParsableCommand { %H Hour %M Minutes - %S Seconds - %p AM/PM + %S Seconds + %p AM/PM - %f Source file name (without extension) + %P Source file path (without name) + %f Source file name (without extension) %e Source file extension %q Quality @@ -433,6 +462,8 @@ struct Clop: ParsableCommand { throw ValidationError("Invalid image format") } self.type = type + + files = files.filter { !$0.isDir && $0.exists } } func convertToAVIF(path: FilePath, outFilePath: FilePath) throws { @@ -479,20 +510,19 @@ struct Clop: ParsableCommand { return } - var outFilePath: FilePath = if let outPath = output?.filePath, outPath.string.contains("/"), outPath.string.starts(with: "/") { - outPath.isDir - ? outPath.appending(stem) - : outPath.dir / generateFileName( - template: outPath.name.string.replacingOccurrences(of: "%q", with: "\(quality)"), - for: path, - autoIncrementingNumber: &UserDefaults.standard.lastAutoIncrementingNumber + let output = normalizeRelativeOutput(output)?.replacingOccurrences(of: "%q", with: "\(quality)") + var outFilePath: FilePath = + if let output, let outPath = output.filePath { + try generateFilePath( + template: outPath, + for: normalizeRelativePath(path), + autoIncrementingNumber: &UserDefaults.standard.lastAutoIncrementingNumber, + mkdir: true ) - } else if let output { - path.dir / generateFileName(template: output.replacingOccurrences(of: "%q", with: "\(quality)"), for: path, autoIncrementingNumber: &UserDefaults.standard.lastAutoIncrementingNumber) - } else { - path.removingLastComponent().appending(stem) - } - outFilePath = FilePath("\(outFilePath.string).\(ext)") + } else { + path.removingLastComponent().appending(stem) + } + outFilePath = outFilePath.isDir ? outFilePath.appending("\(stem).\(ext)") : FilePath("\(outFilePath.string).\(ext)") print("\(CIRCLE) Converting \(path.shellString.underline()) to \(format.rawValue.bold())".dim()) let tempFile = URL.temporaryDirectory.appendingPathComponent(outFilePath.name.string).filePath! @@ -524,6 +554,8 @@ struct Clop: ParsableCommand { DispatchQueue.concurrentPerform(iterations: files.count) { i in do { try convert(path: files[i]) + } catch let error as ClopError { + printerr("\(ERROR_X) \(files[i].shellString.underline()) \(ARROW) \(error.localizedDescription)") } catch { printerr("\(ERROR_X) \(files[i].shellString.underline()) \(ARROW) \(error.localizedDescription)") } @@ -842,6 +874,7 @@ struct Clop: ParsableCommand { Day %d | AM/PM %p Weekday %w | + Source file path (without name) %P Source file name (without extension) %f Source file extension %e @@ -889,8 +922,6 @@ struct Clop: ParsableCommand { } else { urls = try validateItems(items, recursive: recursive, skipErrors: skipErrors, types: types) } - - try checkOutputIsDir(output, itemCount: urls.count) } mutating func run() throws { @@ -906,7 +937,7 @@ struct Clop: ParsableCommand { aggressiveOptimisation: aggressive, adaptiveOptimisation: adaptiveOptimisation, source: "cli", - output: normPath(output), + output: normalizeRelativeOutput(output), removeAudio: removeAudio ) } @@ -966,6 +997,7 @@ struct Clop: ParsableCommand { Day %d | AM/PM %p Weekday %w | + Source file path (without name) %P Source file name (without extension) %f Source file extension %e @@ -1017,7 +1049,7 @@ struct Clop: ParsableCommand { aggressiveOptimisation: aggressive, adaptiveOptimisation: adaptiveOptimisation, source: "cli", - output: normPath(output), + output: normalizeRelativeOutput(output), removeAudio: removeAudio ) } @@ -1086,6 +1118,7 @@ struct Clop: ParsableCommand { Day %d | AM/PM %p Weekday %w | + Source file path (without name) %P Source file name (without extension) %f Source file extension %e @@ -1139,7 +1172,7 @@ struct Clop: ParsableCommand { aggressiveOptimisation: aggressive, adaptiveOptimisation: adaptiveOptimisation, source: "cli", - output: normPath(output), + output: normalizeRelativeOutput(output), removeAudio: removeAudio ) } diff --git a/FinderOptimiser/ActionRequestHandler.swift b/FinderOptimiser/ActionRequestHandler.swift index 7b52b32..ff3fcdc 100644 --- a/FinderOptimiser/ActionRequestHandler.swift +++ b/FinderOptimiser/ActionRequestHandler.swift @@ -11,8 +11,8 @@ import Foundation import System import UniformTypeIdentifiers -extension NSExtensionContext: @unchecked Sendable {} -extension NSItemProvider: @unchecked Sendable {} +extension NSExtensionContext: @unchecked Sendable {} // @retroactive @unchecked Sendable {} +extension NSItemProvider: @unchecked Sendable {} // @retroactive @unchecked Sendable {} let CLOP_APP: URL = Bundle.main.bundleURL.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() diff --git a/ReleaseNotes/2.6.0.md b/ReleaseNotes/2.6.0.md index 2a19f5e..8133d0e 100644 --- a/ReleaseNotes/2.6.0.md +++ b/ReleaseNotes/2.6.0.md @@ -1,7 +1,45 @@ +## Side-by-side comparison + +You can now compare images, videos and PDFs side by side to see the difference between the original and the optimised version. + +- right click on the thumbnail and click **Compare** +- or hover the thumbnail and press `Cmd-D` + + + +## Optimised file location + +There is now a way to set where the optimised files will be placed: + +- **Temporary folder**: they will be placed in the system temp folder that gets cleaned up periodically by the system +- **In-place (replace original)**: the default, moves the original file into Clop's backup folder and replaces it with the optimised file +- **Same folder (as original)**: places the optimised file alongside the original, renaming it based on the configured template +- **Specific folder**: places the optimised file in a specific path anywhere on disk, configured with a template + + + +## Flexible template paths for the `--output` CLI + +When using commands like `clop optimise --output `, the output path now uses the new templating engine. + +To understand this better, here are some examples when resizing the PNG files on Desktop using something like: + +```css +# the command is being run from ~/Documents/ +~/Documents ❯ clop crop --size 1600x900 --output