diff --git a/Sources/App/Main.swift b/Sources/App/Main.swift index e98c793..e47a91b 100644 --- a/Sources/App/Main.swift +++ b/Sources/App/Main.swift @@ -176,6 +176,8 @@ final class TuckApplication: NSObject, NSApplicationDelegate { case "customAction": runCustomAction(paths: request.paths, argument: request.argument) + case "runRecipe": + runSavedRecipe(paths: request.paths, argument: request.argument) case "copyTo", "moveTo": runTransfer(paths: request.paths, argument: request.argument, @@ -657,6 +659,26 @@ final class TuckApplication: NSObject, NSApplicationDelegate { title: "Copied to Clipboard") } + private func runSavedRecipe(paths: [String], argument: String?) { + guard let raw = argument, let id = UUID(uuidString: raw), + let recipe = RecipePreferences.all.first(where: { $0.id == id }) else { + report("The saved recipe is missing or invalid.", title: "Recipe Unavailable") + return + } + let prompt = NSAlert() + prompt.messageText = "Run \(recipe.title) on \(paths.count) item(s)?" + prompt.informativeText = recipe.steps.map { $0.title }.joined(separator: " → ") + + "\n\nOriginals are untouched. Final copies appear in an export folder beside the originals." + prompt.addButton(withTitle: "Run Recipe") + prompt.addButton(withTitle: "Cancel") + guard prompt.runModal() == .alertFirstButtonReturn else { return } + queueOperation(title: "Recipe: \(recipe.title)", operation: "runRecipe", + paths: paths, canUndo: true) { token, progress in + try RecipeRunner.execute(recipe, paths: paths, + shouldCancel: { token.isCancelled }, progress: progress) + } + } + private func runCustomAction(paths: [String], argument: String?) { guard let value = argument, let uuid = UUID(uuidString: value), let action = CustomActionPreferences.all.first(where: { $0.id == uuid }), diff --git a/Sources/App/Recipes/RecipeRunner.swift b/Sources/App/Recipes/RecipeRunner.swift new file mode 100644 index 0000000..01f5c12 --- /dev/null +++ b/Sources/App/Recipes/RecipeRunner.swift @@ -0,0 +1,81 @@ +import Foundation + +enum RecipeRunner { + static let allowedImageFormats: Set = ["jpg","jpeg","png","heic","tif","tiff","webp","bmp"] + + static func execute(_ recipe: SavedRecipe, paths: [String], + shouldCancel: @escaping @Sendable () -> Bool, + progress: @escaping @Sendable (Double,String) -> Void) throws -> JobOutcome { + guard recipe.valid, !paths.isEmpty, paths.count <= 256 else { + throw UndoError.unsafe("The saved recipe or input selection is invalid.") + } + var outputs: [URL] = [] + var failures: [JobFailure] = [] + for (index, path) in paths.enumerated() { + if shouldCancel() { break } + let source = URL(fileURLWithPath: path) + progress(Double(index) / Double(paths.count), source.lastPathComponent) + do { + guard allowedImageFormats.contains(source.pathExtension.lowercased()), + (try source.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])).isRegularFile == true, + (try source.resourceValues(forKeys: [.isSymbolicLinkKey])).isSymbolicLink != true else { + throw UndoError.unsafe("This recipe accepts regular image files only.") + } + outputs.append(try process(recipe, source: source, shouldCancel: shouldCancel)) + } catch { + if !shouldCancel() { + failures.append(JobFailure(inputPath: path, reason: error.localizedDescription)) + } + } + } + return JobOutcome(outputs: outputs, failures: failures) + } + + private static func process(_ recipe: SavedRecipe, source: URL, + shouldCancel: @escaping @Sendable () -> Bool) throws -> URL { + let fm = FileManager.default + guard Spill.root().path.hasPrefix("/Volumes/") else { + throw UndoError.unsafe("Connect a writable external scratch volume before running a recipe.") + } + let work = try WorkArea(beside: source) + defer { work.clean() } + // Recipes can create many encoded intermediates; require a mounted + // external scratch volume instead of falling back to internal storage. + guard work.root.path.hasPrefix("/Volumes/"), + fm.fileExists(atPath: work.root.path) else { + throw UndoError.unsafe("A mounted external scratch volume is required for recipes.") + } + let fileBytes = (try? fm.attributesOfItem(atPath: source.path)[.size] as? NSNumber)?.intValue ?? 1_000_000 + try work.requireSpace(bytes: min(fileBytes, 1_000_000_000)) + var current = work.makeTemporary("recipe-source-\(UUID().uuidString)", + extension: source.pathExtension) + try fm.copyItem(at: source, to: current) + for step in recipe.steps { + if shouldCancel() { throw CancellationError() } + let next: URL + switch step.kind { + case .resizeLongestEdge: + guard let edge = Int(step.value) else { + throw UndoError.unsafe("Invalid resize step.") + } + next = try ImageToolkit.resize(url: current, mode: .longestEdge(edge), + shouldCancel: shouldCancel) + case .convert: + next = try ImageToolkit.convert(url: current, targetFormat: step.value, + shouldCancel: shouldCancel) + case .stripMetadata: + next = try MetadataToolkit.strip(url: current) + } + guard fm.fileExists(atPath: next.path) else { + throw UndoError.unsafe("Recipe step did not create its expected output.") + } + current = work.track(next) + } + if shouldCancel() { throw CancellationError() } + let export = source.deletingLastPathComponent().appendingPathComponent("export", isDirectory: true) + try fm.createDirectory(at: export, withIntermediateDirectories: true) + let outputOrigin = export.appendingPathComponent(source.lastPathComponent) + return try OutputName.publish(current, beside: outputOrigin, suffix: "-export", + extraExtension: current.pathExtension) + } +} diff --git a/Sources/App/Recipes/RecipeSettingsView.swift b/Sources/App/Recipes/RecipeSettingsView.swift new file mode 100644 index 0000000..b69fdbe --- /dev/null +++ b/Sources/App/Recipes/RecipeSettingsView.swift @@ -0,0 +1,167 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +@MainActor +struct RecipeSettingsView: View { + @State private var recipes = RecipePreferences.all + @State private var selectedID: UUID? + @State private var newTitle = "Export for Web" + @State private var stepKind: RecipeStep.Kind = .resizeLongestEdge + @State private var stepValue = "1600" + @State private var errorText = "" + @State private var showError = false + + private var current: Int? { recipes.firstIndex { $0.id == selectedID } } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Recipes").font(.largeTitle.weight(.semibold)) + Text("Reusable, non-destructive image steps. Originals are unchanged; final copies go into an export folder.") + .font(.subheadline).foregroundStyle(.secondary) + GroupBox("Saved Recipes") { + VStack(alignment: .leading, spacing: 8) { + ForEach(recipes) { recipe in + HStack { + Button { + selectedID = recipe.id + } label: { + HStack { + Image(systemName: recipe.id == selectedID ? "checkmark.circle.fill" : "circle") + Text(recipe.title) + Spacer() + } + }.buttonStyle(.plain) + Button { + guard recipes.count < 12 else { return } + let copy = SavedRecipe(title: recipe.title + " copy", steps: recipe.steps) + recipes.append(copy) + selectedID = copy.id + save() + } label: { Image(systemName: "plus.square.on.square") } + .accessibilityLabel("Duplicate \(recipe.title)") + Button(role: .destructive) { + recipes.removeAll { $0.id == recipe.id } + if selectedID == recipe.id { selectedID = nil } + save() + } label: { Image(systemName: "minus.circle") } + .accessibilityLabel("Delete \(recipe.title)") + } + } + HStack { + TextField("New recipe name", text: $newTitle) + Button("New Recipe") { + guard recipes.count < 12 else { return } + let starter = SavedRecipe(title: newTitle, steps: [ + RecipeStep(kind: .resizeLongestEdge, value: "1600"), + RecipeStep(kind: .convert, value: "jpeg") + ]) + recipes.append(starter) + selectedID = starter.id + save() + }.disabled(newTitle.isEmpty || recipes.count >= 12) + } + HStack { + Button("Import JSON…") { importRecipe() } + Button("Export JSON…") { exportRecipe() }.disabled(current == nil) + Spacer() + } + }.padding(10) + } + if let index = current { + GroupBox("Steps — \(recipes[index].title)") { + VStack(alignment: .leading, spacing: 10) { + TextField("Recipe name", text: Binding( + get: { recipes[index].title }, + set: { recipes[index].title = $0; save() } + )) + List { + ForEach(recipes[index].steps) { step in + HStack { + Image(systemName: "line.3.horizontal") + .foregroundStyle(.tertiary) + Text(step.title) + Spacer() + Button { + guard recipes[index].steps.count > 1 else { return } + recipes[index].steps.removeAll { $0.id == step.id } + save() + } label: { Image(systemName: "minus.circle") } + .disabled(recipes[index].steps.count <= 1) + .accessibilityLabel("Remove \(step.title)") + } + } + .onMove { offsets, target in + recipes[index].steps.move(fromOffsets: offsets, toOffset: target) + save() + } + } + .frame(height: 150) + HStack { + Picker("Add step", selection: $stepKind) { + Text("Resize longest edge").tag(RecipeStep.Kind.resizeLongestEdge) + Text("Convert format").tag(RecipeStep.Kind.convert) + Text("Strip metadata").tag(RecipeStep.Kind.stripMetadata) + }.frame(maxWidth: 270) + if stepKind != .stripMetadata { + TextField(stepKind == .convert ? "jpeg / png / heic / tiff / webp" : "1600", + text: $stepValue) + .frame(maxWidth: 180) + } + Button("Add Step") { + guard let index = current else { return } + let step = RecipeStep(kind: stepKind, + value: stepKind == .stripMetadata ? "" : stepValue) + guard step.valid && recipes[index].steps.count < 8 else { + errorText = "Invalid step, or recipe already contains eight steps." + showError = true + return + } + recipes[index].steps.append(step) + save() + } + } + Text("Drag to reorder. Maximum eight steps per recipe, 12 recipes total. Format availability depends on installed native/optional codecs.") + .font(.caption).foregroundStyle(.secondary) + }.padding(10) + } + } + Text("Recipes contain typed built-in actions only. Imported JSON cannot launch scripts, Shortcuts or network requests.") + .font(.caption2).foregroundStyle(.secondary) + } + .onAppear { if selectedID == nil { selectedID = recipes.first?.id } } + .alert("Unable to update recipe", isPresented: $showError) { + Button("OK", role: .cancel) {} + } message: { Text(errorText) } + } + + private func save() { + do { try RecipePreferences.save(recipes) } + catch { errorText = error.localizedDescription; showError = true } + } + private func importRecipe() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.json] + panel.canChooseDirectories = false + guard panel.runModal() == .OK, let url = panel.url else { return } + do { + let data = try Data(contentsOf: url) + guard data.count <= 64_000 else { throw UndoError.unsafe("JSON is too large.") } + let parsed = try JSONDecoder().decode(SavedRecipe.self, from: data) + guard parsed.valid, recipes.count < 12 else { + throw UndoError.unsafe("Unsupported or invalid recipe, or the recipe limit was reached.") + } + let copy = SavedRecipe(title: parsed.title, steps: parsed.steps) + recipes.append(copy); selectedID = copy.id; save() + } catch { errorText = error.localizedDescription; showError = true } + } + private func exportRecipe() { + guard let index = current else { return } + let panel = NSSavePanel() + panel.allowedContentTypes = [.json] + panel.nameFieldStringValue = "tuck-recipe.json" + guard panel.runModal() == .OK, let url = panel.url else { return } + do { try JSONEncoder().encode(recipes[index]).write(to: url, options: .atomic) } + catch { errorText = error.localizedDescription; showError = true } + } +} diff --git a/Sources/App/SettingsView.swift b/Sources/App/SettingsView.swift index 1d8bbd6..50cdf0a 100644 --- a/Sources/App/SettingsView.swift +++ b/Sources/App/SettingsView.swift @@ -7,6 +7,7 @@ private enum SettingsPane: String, CaseIterable, Identifiable { case activity = "Activity" case destinations = "Destinations" case automation = "Automation" + case recipes = "Recipes" case applications = "Applications" case general = "General" var id: Self { self } @@ -17,6 +18,7 @@ private enum SettingsPane: String, CaseIterable, Identifiable { case .activity: "clock.arrow.circlepath" case .destinations: "folder.badge.gearshape" case .automation: "bolt.badge.clock" + case .recipes: "square.stack.3d.up" case .applications: "terminal" case .general: "gearshape" } @@ -51,6 +53,7 @@ struct SettingsView: View { case .activity: activityPane case .destinations: FavoriteSettingsView() case .automation: CustomActionsSettingsView() + case .recipes: RecipeSettingsView() case .applications: applicationsPane case .general: generalPane } @@ -186,6 +189,8 @@ struct SettingsView: View { Button(action.title) { lastPreview = "Browse or extract ZIP entries" } case .customActions: Button(action.title) { lastPreview = "Run a user-configured action (confirmation required)" } + case .recipes: + Button(action.title) { lastPreview = "Run a saved non-destructive image pipeline" } } } diff --git a/Sources/Extension/FinderSync.swift b/Sources/Extension/FinderSync.swift index 079f1d2..bfaa0d0 100644 --- a/Sources/Extension/FinderSync.swift +++ b/Sources/Extension/FinderSync.swift @@ -162,6 +162,8 @@ final class TuckFinderSync: FIFinderSync { item.action = #selector(inspectZIP(_:)) case .customActions: item.submenu = makeCustomActionMenu() + case .recipes: + item.submenu = makeRecipeMenu() } root.addItem(item) } @@ -196,6 +198,23 @@ final class TuckFinderSync: FIFinderSync { return root } + private func makeRecipeMenu() -> NSMenu { + let menu = NSMenu(title: "Recipes") + for recipe in RecipePreferences.all { + let item = menu.addItem(withTitle: recipe.title, + action: #selector(runRecipe(_:)), keyEquivalent: "") + item.representedObject = recipe.id.uuidString + } + return menu + } + + @objc func runRecipe(_ sender: NSMenuItem) { + guard let id = sender.representedObject as? String else { return } + let (selection, _) = resolveContext() + guard !selection.isEmpty else { return } + dispatch("runRecipe", paths: selection.map(\.path), argument: id) + } + private func makeCustomActionMenu() -> NSMenu { let menu = NSMenu(title: "Custom Actions") let selection = menuSelection ?? selected diff --git a/Sources/Shared/Actions.swift b/Sources/Shared/Actions.swift index 81c7e82..0d8551d 100644 --- a/Sources/Shared/Actions.swift +++ b/Sources/Shared/Actions.swift @@ -2,7 +2,7 @@ import Foundation enum TuckAction: String, CaseIterable, Identifiable { case newFile, openWith, compress, terminal - case pdfToolkit, imageToolkit, metadata, copyAs, rename, destinations, duplicates, zipInspector, customActions + case pdfToolkit, imageToolkit, metadata, copyAs, rename, destinations, duplicates, zipInspector, customActions, recipes var id: String { rawValue } var title: String { @@ -20,6 +20,7 @@ enum TuckAction: String, CaseIterable, Identifiable { case .duplicates: "Find Duplicates" case .zipInspector: "Inspect ZIP" case .customActions: "Custom Actions" + case .recipes: "Recipes" } } @@ -38,6 +39,7 @@ enum TuckAction: String, CaseIterable, Identifiable { case .duplicates: "square.on.square" case .zipInspector: "archivebox" case .customActions: "bolt" + case .recipes: "square.stack.3d.up" } } } @@ -81,6 +83,8 @@ enum TuckMenuModel { return context == .archive case .customActions: return context.hasSelection && CustomActionPreferences.all.contains { $0.enabled } + case .recipes: + return context == .image && !RecipePreferences.all.isEmpty } } } diff --git a/Sources/Shared/Preferences.swift b/Sources/Shared/Preferences.swift index a0bd38e..e5a12a4 100644 --- a/Sources/Shared/Preferences.swift +++ b/Sources/Shared/Preferences.swift @@ -24,7 +24,7 @@ enum TuckPreferences { static let defaults: [String] = [ "newFile", "openWith", "compress", "terminal", - "pdfToolkit", "imageToolkit", "metadata", "copyAs", "rename", "destinations", "duplicates", "zipInspector", "customActions" + "pdfToolkit", "imageToolkit", "metadata", "copyAs", "rename", "destinations", "duplicates", "zipInspector", "customActions", "recipes" ] static var order: [String] { diff --git a/Sources/Shared/Recipes.swift b/Sources/Shared/Recipes.swift new file mode 100644 index 0000000..ff42fe7 --- /dev/null +++ b/Sources/Shared/Recipes.swift @@ -0,0 +1,55 @@ +import Foundation + +struct RecipeStep: Codable, Sendable, Identifiable { + enum Kind: String, Codable, Sendable { case resizeLongestEdge, convert, stripMetadata } + var id: UUID = UUID() + var kind: Kind + var value: String = "" + var valid: Bool { + switch kind { + case .resizeLongestEdge: + guard let n = Int(value) else { return false } + return (16...4096).contains(n) + case .convert: + return ["jpeg", "png", "heic", "tiff", "webp"].contains(value) + case .stripMetadata: return value.isEmpty + } + } + var title: String { + switch kind { + case .resizeLongestEdge: "Resize longest edge to \(value)px" + case .convert: "Convert to \(value.uppercased())" + case .stripMetadata: "Remove supported metadata" + } + } +} + +struct SavedRecipe: Codable, Sendable, Identifiable { + var id: UUID = UUID() + var title: String + var steps: [RecipeStep] + var valid: Bool { + !title.trimmingCharacters(in: .whitespaces).isEmpty && + title.utf8.count <= 70 && + (1...8).contains(steps.count) && steps.allSatisfy(\.valid) + } +} + +enum RecipePreferences { + private static let key = "savedRecipesV1" + static var all: [SavedRecipe] { + guard let data = TuckPreferences.store.data(forKey: key), + let recipes = try? JSONDecoder().decode([SavedRecipe].self, from: data) else { + return [] + } + var seen = Set() + return Array(recipes.filter { $0.valid && seen.insert($0.id).inserted }.prefix(12)) + } + static func save(_ recipes: [SavedRecipe]) throws { + guard recipes.count <= 12, recipes.allSatisfy(\.valid), + Set(recipes.map(\.id)).count == recipes.count else { + throw CocoaError(.fileWriteInvalidFileName) + } + TuckPreferences.store.set(try JSONEncoder().encode(recipes), forKey: key) + } +}