From 47419125250b50af938733e51368aa20c7bc020d Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Sat, 25 Jul 2026 11:16:07 -0700 Subject: [PATCH 1/2] Expand rule automation and migration support --- CHANGELOG.md | 13 + README.md | 11 +- Sources/ForelApp/AppDelegate.swift | 28 +- Sources/ForelApp/AppModel.swift | 65 ++++ .../MenuBar/StatusBarController.swift | 15 + .../Views/ActionInsertionDropDelegate.swift | 38 +++ Sources/ForelApp/Views/ContentView.swift | 4 + .../Views/HazelImportAssistantView.swift | 82 +++++ Sources/ForelApp/Views/RuleEditorView.swift | 254 +++++++++++++++- Sources/ForelApp/Views/RuleListView.swift | 94 +++++- Sources/ForelApp/Views/SettingsView.swift | 10 + Sources/ForelCore/Engine/ActionExecutor.swift | 278 ++++++++++++++++- .../ForelCore/Engine/ConditionEvaluator.swift | 95 ++++++ Sources/ForelCore/Engine/FinderTags.swift | 23 ++ Sources/ForelCore/Engine/RuleEngine.swift | 28 +- Sources/ForelCore/Models/Models.swift | 28 ++ Sources/ForelCore/Models/RuleSchema.swift | 135 ++++++++- Sources/ForelCore/Models/RuleTransfer.swift | 280 ++++++++++++++++++ Sources/ForelCore/Models/RuleValidator.swift | 41 ++- Sources/ForelCore/Persistence/Database.swift | 6 +- .../ForelCoreTests/ActionExecutorTests.swift | 29 ++ .../ConditionEvaluatorTests.swift | 64 ++++ Tests/ForelCoreTests/DatabaseTests.swift | 10 + Tests/ForelCoreTests/RuleEngineTests.swift | 100 +++++++ Tests/ForelCoreTests/RuleSchemaTests.swift | 11 +- Tests/ForelCoreTests/RuleTransferTests.swift | 83 ++++++ Tests/ForelCoreTests/RuleValidatorTests.swift | 18 ++ .../WatcherCoordinatorTests.swift | 33 +++ 28 files changed, 1827 insertions(+), 49 deletions(-) create mode 100644 Sources/ForelApp/Views/ActionInsertionDropDelegate.swift create mode 100644 Sources/ForelApp/Views/HazelImportAssistantView.swift create mode 100644 Sources/ForelCore/Models/RuleTransfer.swift create mode 100644 Tests/ForelCoreTests/RuleTransferTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bc5ba2..8b933fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to Forel are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +- Added a Settings option to hide Forel's menu bar icon. +- Opening Forel from Finder or Spotlight now always brings its main window forward. +- Added visible drag-and-drop action order controls so actions run in the sequence you set. +- Added a one-time Hazel migration assistant that detects Hazel, opens it, and guides rule export and import. +- Added rule import and export, including compatible import of Hazel rule exports with clear review warnings for unsupported parts. + +### Added +- Rules can now match only when none of their conditions match, and can pause between actions for a chosen number of seconds. +- Added sorting, one-way folder sync, FTP/SFTP/WebDAV upload, Finder metadata and file-state actions, archiving, script and Automator runners, Finder reveal/open/alias actions, rule-flow controls, and per-rule notifications. +- Added conditions for Finder comments, paths, folder item counts, last-opened dates, image dimensions, photo capture dates, PDF page counts, and advanced Spotlight metadata. + ## [1.0.8] - 2026-07-24 ### Fixed diff --git a/README.md b/README.md index 6a158a5..9a3bae1 100644 --- a/README.md +++ b/README.md @@ -197,12 +197,11 @@ History / Undo (SQLite) - [x] Shortcuts actions - [x] Open App actions - [x] Validate actions / conditions before save -- [ ] Export / Import rules -- [ ] Toggle extension hidden / visible -- [ ] Compress actions -- [ ] Compress actions -- [ ] Sync actions -- [ ] Upload actions +- [x] Import / export rules (including Hazel imports with compatibility warnings) +- [x] Toggle extension hidden / visible +- [x] Archive / compress actions +- [x] Sync actions +- [x] Upload actions - [x] Native notifications on rule actions - [ ] AI features diff --git a/Sources/ForelApp/AppDelegate.swift b/Sources/ForelApp/AppDelegate.swift index dbe05e9..2b4fcb1 100644 --- a/Sources/ForelApp/AppDelegate.swift +++ b/Sources/ForelApp/AppDelegate.swift @@ -16,13 +16,14 @@ import AppKit -/// Closing the window hides it instead of quitting; Forel keeps running in -/// the menu bar. Quit is only available from the status item menu. +/// Closing the main window hides it instead of quitting; Forel keeps running +/// in the background. @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { var statusBarController: StatusBarController? private var model: AppModel? private var updater: UpdaterManager? + private weak var mainWindow: NSWindow? /// `@NSApplicationDelegateAdaptor` requires a zero-argument initializer; /// the app's model/updater are handed in afterward once SwiftUI has @@ -83,11 +84,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { } private func configureMainWindow() { - guard let window = NSApp.windows.first(where: { !($0 is NSPanel) }) else { return } + guard let window = mainWindow ?? NSApp.windows.first(where: { !($0 is NSPanel) }) else { return } configureMainWindow(window) } private func configureMainWindow(_ window: NSWindow) { + mainWindow = window window.delegate = self window.title = "Forel" window.titleVisibility = .hidden @@ -113,9 +115,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { return true } - func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - guard !flag else { return true } - openMainWindow() + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows _: Bool) -> Bool { + // Finder and Spotlight send a reopen event when Forel is already + // running. Always surface the actual main window, even if Settings is + // the only currently-visible window. + if openMainWindow() { + return false + } return true } @@ -124,13 +130,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { statusBarController = StatusBarController( model: model, updater: updater, - window: NSApp.windows.first + window: mainWindow ?? NSApp.windows.first ) } - private func openMainWindow() { - let targetWindow = NSApp.windows.first { !($0 is NSPanel) } + @discardableResult + private func openMainWindow() -> Bool { + guard let targetWindow = mainWindow ?? NSApp.windows.first(where: { !($0 is NSPanel) }) else { + return false + } WindowActivation.activateSoon(targetWindow, showsDockIcon: model?.showDockIcon != false) + return true } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { diff --git a/Sources/ForelApp/AppModel.swift b/Sources/ForelApp/AppModel.swift index 0d49058..4f2a3d6 100644 --- a/Sources/ForelApp/AppModel.swift +++ b/Sources/ForelApp/AppModel.swift @@ -48,6 +48,7 @@ final class AppModel: ObservableObject { @Published var detailRoute: DetailRoute = .rules @Published var accentPreset: AccentPreset = .default @Published var showDockIcon: Bool = true + @Published var showMenuBarIcon: Bool = true @Published var watcherNotificationsEnabled: Bool = true @Published var historyMaxDays: Int = 30 /// Bumped whenever the accent colour changes, so views can force a full @@ -59,6 +60,7 @@ final class AppModel: ObservableObject { private var runNowMessageId: UUID? @Published private(set) var isPreviewing = false @Published var previewResult: PreviewResult? + @Published var showHazelImportAssistant = false @Published private var ruleExpansionPreferences = RuleExpansionPreferences() let db: Database @@ -69,6 +71,7 @@ final class AppModel: ObservableObject { private var historyCleanupTimer: AnyCancellable? private var pendingWatcherNotification = PendingWatcherNotification() private var watcherNotificationTask: Task? + private let hazelAppURL: URL? init() throws { let appSupportRoot = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] @@ -79,6 +82,7 @@ final class AppModel: ObservableObject { let db = try Database(path: dbPath) self.db = db + self.hazelAppURL = Self.locateHazel() self.coordinator = WatcherCoordinator(db: db) self.coordinator.onActivity = { [weak self] summary in Task { @MainActor in @@ -101,6 +105,9 @@ final class AppModel: ObservableObject { let storedShowDockIcon = db.withLock { db in try? db.getSetting("show_dock_icon") } self.showDockIcon = storedShowDockIcon.map { $0 == "1" } ?? true + let storedShowMenuBarIcon = db.withLock { db in try? db.getSetting("show_menu_bar_icon") } + self.showMenuBarIcon = storedShowMenuBarIcon.map { $0 == "1" } ?? true + let storedWatcherNotificationsEnabled = db.withLock { db in try? db.getSetting("watcher_notifications_enabled") } self.watcherNotificationsEnabled = storedWatcherNotificationsEnabled.map { $0 == "1" } ?? true @@ -110,10 +117,19 @@ final class AppModel: ObservableObject { self.ruleExpansionPreferences = db.withLock { db in RuleExpansionPreferences.load(from: db) } reloadFolders() + let hazelPromptSeen = db.withLock { db in (try? db.getSetting("hazel_import_prompt_seen")) == "1" } + showHazelImportAssistant = !hazelPromptSeen && folders.isEmpty && hazelAppURL != nil startWatchingEnabledFolders() startHistoryCleanupTimer() } + private static func locateHazel() -> URL? { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.noodlesoft.Hazel") + ?? ["/Applications/Hazel.app", "/Applications/Setapp/Hazel.app"] + .map(URL.init(fileURLWithPath:)) + .first(where: { FileManager.default.fileExists(atPath: $0.path) }) + } + /// Forel's bundle identifier moved from `com.forel.app` (`.app` isn't a /// valid reverse-DNS component on macOS) to `com.lab421.forel`. Existing /// users have their database and settings under the old identifier's @@ -167,6 +183,11 @@ final class AppModel: ObservableObject { applyDockIconPreference(keepingWindowsVisible: true) } + func setShowMenuBarIcon(_ enabled: Bool) { + showMenuBarIcon = enabled + db.withLock { db in try? db.setSetting("show_menu_bar_icon", enabled ? "1" : "0") } + } + func setWatcherNotificationsEnabled(_ enabled: Bool) { watcherNotificationsEnabled = enabled db.withLock { db in try? db.setSetting("watcher_notifications_enabled", enabled ? "1" : "0") } @@ -476,6 +497,50 @@ final class AppModel: ObservableObject { reloadRules() } + func exportRules(to url: URL) { + do { + let data = try RuleTransfer.exportForel(rules) + try data.write(to: url, options: .atomic) + showRunNowMessage("Exported \(rules.count) rule\(rules.count == 1 ? "" : "s")") + } catch { + showError(error) + } + } + + @discardableResult + func importRules(from url: URL) -> Bool { + guard let folderId = selectedFolderId else { return false } + do { + let result = try RuleTransfer.importRules(from: Data(contentsOf: url), folderId: folderId) + try db.withLock { db in + for rule in result.rules { try db.insertRule(rule) } + } + reloadRules() + if result.issues.isEmpty { + showRunNowMessage("Imported \(result.rules.count) rule\(result.rules.count == 1 ? "" : "s")") + } else { + alertTitle = "Imported with review needed" + errorMessage = "Imported \(result.rules.count) rule\(result.rules.count == 1 ? "" : "s"). Rules with unsupported Hazel parts were disabled.\n\n" + result.issues.map { "\($0.ruleName): \($0.message)" }.joined(separator: "\n") + } + return true + } catch { + showError(error) + return false + } + } + + func openHazel() { + guard let hazelAppURL else { return } + NSWorkspace.shared.openApplication(at: hazelAppURL, configuration: .init()) { _, error in + if let error { Task { @MainActor in self.showError(error) } } + } + } + + func finishHazelImportAssistant() { + db.withLock { db in try? db.setSetting("hazel_import_prompt_seen", "1") } + showHazelImportAssistant = false + } + /// Runs all enabled rules in the selected folder against every file /// currently in it (a manual "Run Now"), bounded by each rule's scope. func runNow() { diff --git a/Sources/ForelApp/MenuBar/StatusBarController.swift b/Sources/ForelApp/MenuBar/StatusBarController.swift index 24490e9..41f2680 100644 --- a/Sources/ForelApp/MenuBar/StatusBarController.swift +++ b/Sources/ForelApp/MenuBar/StatusBarController.swift @@ -33,6 +33,7 @@ final class StatusBarController: NSObject { private var globalDismissMonitor: Any? private var pausedSubscription: AnyCancellable? private var updateSubscription: AnyCancellable? + private var visibilitySubscription: AnyCancellable? init(model: AppModel, updater: UpdaterManager, window: NSWindow?) { self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) @@ -41,6 +42,8 @@ final class StatusBarController: NSObject { self.window = window super.init() + statusItem.isVisible = model.showMenuBarIcon + if let button = statusItem.button { button.image = Self.glyph(paused: model.paused, updateAvailable: updater.updateAvailable) button.image?.isTemplate = false @@ -55,12 +58,24 @@ final class StatusBarController: NSObject { guard let self else { return } self.refreshGlyph(paused: self.model.paused) } + visibilitySubscription = model.$showMenuBarIcon + .removeDuplicates() + .sink { [weak self] isVisible in + self?.setVisible(isVisible) + } } private func refreshGlyph(paused: Bool) { statusItem.button?.image = Self.glyph(paused: paused, updateAvailable: updater.updateAvailable) } + private func setVisible(_ isVisible: Bool) { + if !isVisible { + closePopover() + } + statusItem.isVisible = isVisible + } + @objc private func togglePopover() { if let popover, popover.isShown { popover.performClose(nil) diff --git a/Sources/ForelApp/Views/ActionInsertionDropDelegate.swift b/Sources/ForelApp/Views/ActionInsertionDropDelegate.swift new file mode 100644 index 0000000..31c0c1c --- /dev/null +++ b/Sources/ForelApp/Views/ActionInsertionDropDelegate.swift @@ -0,0 +1,38 @@ +// Forel - A native macOS file-automation app +// Copyright (C) 2026 Lab421 + +import SwiftUI +import UniformTypeIdentifiers + +/// Handles a local action reorder without consuming drops from other apps. +struct ActionInsertionDropDelegate: DropDelegate { + let insertionIndex: Int + @Binding var draggedActionId: String? + @Binding var activeInsertionIndex: Int? + let move: (String, Int) -> Void + + func dropEntered(info: DropInfo) { + guard draggedActionId != nil else { return } + activeInsertionIndex = insertionIndex + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + guard draggedActionId != nil else { return nil } + activeInsertionIndex = insertionIndex + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + defer { + draggedActionId = nil + activeInsertionIndex = nil + } + guard let actionId = draggedActionId else { return false } + move(actionId, insertionIndex) + return true + } + + func dropExited(info: DropInfo) { + if activeInsertionIndex == insertionIndex { activeInsertionIndex = nil } + } +} diff --git a/Sources/ForelApp/Views/ContentView.swift b/Sources/ForelApp/Views/ContentView.swift index a49a110..bfc9990 100644 --- a/Sources/ForelApp/Views/ContentView.swift +++ b/Sources/ForelApp/Views/ContentView.swift @@ -39,6 +39,10 @@ struct ContentView: View { } message: { Text(model.errorMessage ?? "") } + .sheet(isPresented: $model.showHazelImportAssistant) { + HazelImportAssistantView() + .environmentObject(model) + } .tint(ForelTheme.accent) // ForelTheme.accent is a plain static var, not observable; bumping the // identity here forces every descendant to rebuild and re-read it. diff --git a/Sources/ForelApp/Views/HazelImportAssistantView.swift b/Sources/ForelApp/Views/HazelImportAssistantView.swift new file mode 100644 index 0000000..a56ea11 --- /dev/null +++ b/Sources/ForelApp/Views/HazelImportAssistantView.swift @@ -0,0 +1,82 @@ +// Forel - A native macOS file-automation app +// Copyright (C) 2026 Lab421 + +import SwiftUI +import AppKit +import UniformTypeIdentifiers + +/// One-time, opt-in handoff from Hazel's per-folder export menu. +struct HazelImportAssistantView: View { + @EnvironmentObject var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + HStack(spacing: 12) { + Image(systemName: "arrow.triangle.2.circlepath.circle.fill") + .font(.system(size: 30)) + .foregroundStyle(ForelTheme.accent) + VStack(alignment: .leading, spacing: 3) { + Text("Import your Hazel rules?") + .font(.system(size: 20, weight: .bold)) + Text("Hazel was found on this Mac.") + .foregroundStyle(ForelTheme.secondaryText) + } + } + + Text("Forel can import Hazel’s rule export and will flag anything it cannot reproduce exactly for your review.") + .font(.system(size: 13)) + .foregroundStyle(ForelTheme.primaryText) + + VStack(alignment: .leading, spacing: 12) { + step(1, "Choose the folder to receive the imported rules.") { + Button(model.selectedFolderId == nil ? "Choose Folder…" : "Choose Different Folder…") { + if let path = FolderPicker.choose() { model.addFolder(path: path) } + } + .buttonStyle(SecondaryButtonStyle()) + } + step(2, "Open Hazel, select that folder, then choose ••• → Export Rules…") { + Button("Open Hazel", action: model.openHazel) + .buttonStyle(SecondaryButtonStyle()) + } + step(3, "Return here and select the .hazelrules file Hazel saved.") { + Button("Choose Hazel Export…", action: chooseHazelExport) + .buttonStyle(PrimaryButtonStyle()) + .disabled(model.selectedFolderId == nil) + } + } + + HStack { + Spacer() + Button("Not Now", action: model.finishHazelImportAssistant) + .buttonStyle(SecondaryButtonStyle()) + } + } + .padding(24) + .frame(width: 520) + .interactiveDismissDisabled() + } + + private func step(_ number: Int, _ text: String, @ViewBuilder content: () -> Content) -> some View { + HStack(alignment: .top, spacing: 10) { + Text("\(number)") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 22, height: 22) + .background(Circle().fill(ForelTheme.accent)) + VStack(alignment: .leading, spacing: 7) { + Text(text).font(.system(size: 13, weight: .medium)) + content() + } + } + } + + private func chooseHazelExport() { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [UTType(filenameExtension: "hazelrules") ?? .data] + panel.prompt = "Import" + guard panel.runModal() == .OK, let url = panel.url else { return } + if model.importRules(from: url) { model.finishHazelImportAssistant() } + } +} diff --git a/Sources/ForelApp/Views/RuleEditorView.swift b/Sources/ForelApp/Views/RuleEditorView.swift index d978218..b6add85 100644 --- a/Sources/ForelApp/Views/RuleEditorView.swift +++ b/Sources/ForelApp/Views/RuleEditorView.swift @@ -16,6 +16,7 @@ import AppKit import SwiftUI +import UniformTypeIdentifiers import ForelCore #if canImport(Photos) import Photos @@ -25,6 +26,8 @@ struct RuleEditorView: View { @State private var rule: Rule @State private var showValidationErrors = false @State private var errorDismissTask: Task? + @State private var draggedActionId: String? + @State private var actionInsertionIndex: Int? @EnvironmentObject private var model: AppModel private let preferredHeight: CGFloat let onSave: (Rule) -> Void @@ -59,6 +62,7 @@ struct RuleEditorView: View { Picker("", selection: $rule.conditionMatch) { Text("Match all conditions").tag(ConditionMatch.all) Text("Match any condition").tag(ConditionMatch.any) + Text("Match no conditions").tag(ConditionMatch.none) } .labelsHidden() .pickerStyle(.segmented) @@ -91,7 +95,12 @@ struct RuleEditorView: View { } HStack { - SectionLabel(title: "Actions") + VStack(alignment: .leading, spacing: 2) { + SectionLabel(title: "Actions") + Text("Run from top to bottom") + .font(.system(size: 11)) + .foregroundStyle(ForelTheme.secondaryText) + } Spacer() Button { rule.actions.append(Action(ruleId: rule.id, kind: .moveToFolder, params: .object(["destination": .string("")]), position: Int64(rule.actions.count))) @@ -105,13 +114,41 @@ struct RuleEditorView: View { if rule.actions.isEmpty { placeholder("No actions yet — add at least one to make this rule do something.") } - ForEach($rule.actions, id: \.id) { $action in - ActionRow(action: $action) { + ForEach(Array(rule.actions.enumerated()), id: \.element.id) { index, action in + actionDropTarget(index) + ActionRow( + action: Binding( + get: { rule.actions[index] }, + set: { rule.actions[index] = $0 } + ), + order: index + 1, + canMoveUp: index > 0, + canMoveDown: index < rule.actions.count - 1, + onMoveUp: { moveAction(at: index, by: -1) }, + onMoveDown: { moveAction(at: index, by: 1) }, + dragProvider: { + draggedActionId = action.id + return NSItemProvider(object: action.id as NSString) + } + ) { rule.actions.removeAll { $0.id == action.id } + normalizeActionPositions() } + .opacity(draggedActionId == action.id ? 0.55 : 1) + .onDrop( + of: [.plainText], + delegate: ActionInsertionDropDelegate( + insertionIndex: index, + draggedActionId: $draggedActionId, + activeInsertionIndex: $actionInsertionIndex, + move: moveAction(id:toInsertionIndex:) + ) + ) } + if !rule.actions.isEmpty { actionDropTarget(rule.actions.count) } } .padding(18) + .animation(.easeInOut(duration: 0.12), value: actionInsertionIndex) } } } @@ -120,7 +157,9 @@ struct RuleEditorView: View { Divider().overlay(ForelTheme.divider) HStack { - Toggle("Enabled", isOn: $rule.enabled) + Toggle(isOn: $rule.enabled) { + Text(rule.enabled ? "Enabled" : "Disabled") + } .toggleStyle(.switch) .tint(ForelTheme.accent) .font(.system(size: 12)) @@ -169,6 +208,51 @@ struct RuleEditorView: View { !validationMessages.isEmpty } + private func moveAction(at index: Int, by offset: Int) { + let destination = index + offset + guard rule.actions.indices.contains(index), rule.actions.indices.contains(destination) else { return } + rule.actions.swapAt(index, destination) + normalizeActionPositions() + } + + private func moveAction(id: String, toInsertionIndex insertionIndex: Int) { + guard let sourceIndex = rule.actions.firstIndex(where: { $0.id == id }) else { return } + let action = rule.actions.remove(at: sourceIndex) + let targetIndex = sourceIndex < insertionIndex ? insertionIndex - 1 : insertionIndex + rule.actions.insert(action, at: max(0, min(targetIndex, rule.actions.count))) + normalizeActionPositions() + } + + private func normalizeActionPositions() { + for index in rule.actions.indices { + rule.actions[index].position = Int64(index) + } + } + + private func actionDropTarget(_ index: Int) -> some View { + ZStack { + Rectangle() + .fill(Color.clear) + .frame(height: 10) + if actionInsertionIndex == index, draggedActionId != nil { + Capsule() + .fill(ForelTheme.accent) + .frame(height: 2) + .shadow(color: ForelTheme.accent.opacity(0.35), radius: 2, y: 1) + } + } + .contentShape(Rectangle()) + .onDrop( + of: [.plainText], + delegate: ActionInsertionDropDelegate( + insertionIndex: index, + draggedActionId: $draggedActionId, + activeInsertionIndex: $actionInsertionIndex, + move: moveAction(id:toInsertionIndex:) + ) + ) + } + private func placeholder(_ text: String) -> some View { Text(text) .font(.system(size: 11)) @@ -362,6 +446,8 @@ private struct ConditionRow: View { AppPickerField(value: $condition.value) case .size: SizeValueEditor(value: $condition.value) + case .number: + NumberValueEditor(value: $condition.value) case .relativeDate: RelativeDateValueEditor(value: $condition.value) case .absoluteDate: @@ -370,6 +456,8 @@ private struct ConditionRow: View { RegexValueEditor(value: $condition.value) case .text: GlassField(placeholder: "Value", text: $condition.value) + case .spotlightMetadata: + SpotlightMetadataValueEditor(value: $condition.value) } } @@ -416,6 +504,8 @@ private struct ConditionRow: View { switch kind.baseValueKind { case .fileKind: return "image" case .size: return "0 MB" + case .number: return "0" + case .spotlightMetadata: return SpotlightMetadataCondition.make(key: "kMDItemAuthors", value: "") case .absoluteDate: return operator_.usesRelativeDateValue ? "7 days" : DateValueFormatter.string(from: Date()) default: return "" @@ -685,6 +775,48 @@ private struct SizeValueEditor: View { } } +private struct NumberValueEditor: View { + @Binding var value: String + + var body: some View { + GlassField(placeholder: "0", text: Binding( + get: { value }, + set: { value = $0.filter { $0.isNumber || $0 == "." } } + )) + .frame(width: 100, alignment: .leading) + } +} + +private struct SpotlightMetadataValueEditor: View { + @Binding var value: String + + var body: some View { + HStack(spacing: 8) { + GlassField(placeholder: "kMDItemAuthors", text: keyBinding) + .frame(width: 180) + GlassField(placeholder: "Value", text: matchValueBinding) + } + } + + private var parsed: (key: String, value: String) { + SpotlightMetadataCondition.parse(value) ?? ("kMDItemAuthors", "") + } + + private var keyBinding: Binding { + Binding( + get: { parsed.key }, + set: { value = SpotlightMetadataCondition.make(key: $0, value: parsed.value) } + ) + } + + private var matchValueBinding: Binding { + Binding( + get: { parsed.value }, + set: { value = SpotlightMetadataCondition.make(key: parsed.key, value: $0) } + ) + } +} + /// Text field showing the matched app's real icon (same idea as `FolderField`), /// with a "Choose…" button that opens a Finder-style picker scoped to /// `/Applications`. Stays a plain text field underneath so a missing or @@ -795,11 +927,50 @@ private struct KindValuePicker: View { private struct ActionRow: View { @Binding var action: Action + let order: Int + let canMoveUp: Bool + let canMoveDown: Bool + let onMoveUp: () -> Void + let onMoveDown: () -> Void + let dragProvider: () -> NSItemProvider let onDelete: () -> Void @State private var showingOptions = false var body: some View { HStack(alignment: .center, spacing: 12) { + VStack(spacing: 2) { + Text("\(order)") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(ForelTheme.accent) + .frame(width: 18, height: 18) + .background(Circle().fill(ForelTheme.accent.opacity(0.14))) + HStack(spacing: 0) { + Button(action: onMoveUp) { + Image(systemName: "chevron.up") + } + .buttonStyle(.plain) + .disabled(!canMoveUp) + .help("Move action earlier") + Button(action: onMoveDown) { + Image(systemName: "chevron.down") + } + .buttonStyle(.plain) + .disabled(!canMoveDown) + .help("Move action later") + } + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(ForelTheme.secondaryText) + } + .frame(width: 24) + + Image(systemName: "line.3.horizontal") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(ForelTheme.secondaryText.opacity(0.75)) + .frame(width: 16) + .contentShape(Rectangle()) + .onDrag(dragProvider) + .help("Drag to reorder") + ActionKindMenu(selection: kindBinding) .frame(minWidth: 160, alignment: .leading) @@ -841,18 +1012,44 @@ private struct ActionRow: View { switch action.kind { case .moveToFolder, .copyToFolder: FolderField(placeholder: "Destination folder", path: paramBinding(ActionParam.destination)) + case .syncToFolder: + FolderField(placeholder: "Destination folder", path: paramBinding(ActionParam.destination)) case .rename: RenamePatternEditor(pattern: paramBinding(ActionParam.pattern), cleanFileName: action.params[ActionParam.cleanFileName]?.boolValue == true) + case .sortIntoSubfolder: + GlassField(placeholder: "Subfolder path", text: paramBinding(ActionParam.subfolder)) + case .upload: + GlassField(placeholder: "FTP, SFTP, or WebDAV URL", text: paramBinding(ActionParam.uploadURL)) case .addTag, .removeTag: TagTokensEditor(tags: tagsBinding, placeholder: action.kind == .addTag ? "Add tag" : "Tag") case .setColorLabel: ColorLabelPicker(selection: paramBinding(ActionParam.color), allowNone: true) + case .addComment: + GlassField(placeholder: "Finder comment", text: paramBinding(ActionParam.comment)) + case .toggleExtension: + actionDescription("Shows or hides the filename extension") + case .toggleLock: + actionDescription("Locks or unlocks the item") + case .archive: + actionDescription("Creates a ZIP archive beside the item") case .runScript: GlassField(placeholder: "Bash script (file path in $FOREL_FILE)", text: paramBinding(ActionParam.script)) case .runShortcut: ShortcutPicker(selection: paramBinding(ActionParam.shortcutName)) + case .runAppleScript: + GlassField(placeholder: "AppleScript (forelFile is the matched file)", text: paramBinding(ActionParam.script)) + case .runJavaScript: + GlassField(placeholder: "JavaScript (forelFile is the matched file)", text: paramBinding(ActionParam.script)) + case .runAutomatorWorkflow: + GlassField(placeholder: "Automator workflow path", text: paramBinding(ActionParam.workflowPath)) case .openApplication: ApplicationPathPickerField(path: paramBinding(ActionParam.applicationPath)) + case .open: + actionDescription("Opens the matched item with its default app") + case .showInFinder: + actionDescription("Reveals the matched item in Finder") + case .makeAlias: + FolderField(placeholder: "Alias destination folder", path: paramBinding(ActionParam.aliasDestination)) case .importToLibrary: let libTypeBinding = paramBinding(ActionParam.libraryType, defaultValue: LibraryType.music.rawValue) let libType = LibraryType(rawValue: libTypeBinding.wrappedValue) @@ -889,6 +1086,25 @@ private struct ActionRow: View { .font(.system(size: 11)) .foregroundStyle(ForelTheme.secondaryText) .frame(minHeight: 32, alignment: .center) + case .pause: + HStack(spacing: 8) { + GlassField(placeholder: "1", text: pauseSecondsBinding) + .frame(width: 72) + Text("seconds") + .font(.system(size: 12)) + .foregroundStyle(ForelTheme.secondaryText) + } + case .runRulesOnFolderContents: + actionDescription("Runs the full rule list on items inside this folder") + case .continueMatchingRules: + actionDescription("Forel already continues matching later rules") + case .displayNotification: + VStack(alignment: .leading, spacing: 6) { + GlassField(placeholder: "Notification title (optional)", text: paramBinding(ActionParam.notificationTitle)) + GlassField(placeholder: "Notification message (optional)", text: paramBinding(ActionParam.notificationBody)) + } + case .ignore: + actionDescription("Stops this item from matching later rules") case .moveToTrash, .delete: Text("No parameters") .font(.system(size: 11)) @@ -897,6 +1113,13 @@ private struct ActionRow: View { } } + private func actionDescription(_ text: String) -> some View { + Text(text) + .font(.system(size: 11)) + .foregroundStyle(ForelTheme.secondaryText) + .frame(minHeight: 32, alignment: .center) + } + private var kindBinding: Binding { Binding( get: { action.kind }, @@ -906,6 +1129,8 @@ private struct ActionRow: View { params[ActionParam.libraryType] = .string(LibraryType.music.rawValue) } else if newKind == .openApplication { params[ActionParam.passFileToApplication] = .bool(true) + } else if newKind == .pause { + params[ActionParam.pauseSeconds] = .number(1) } action = Action(id: action.id, ruleId: action.ruleId, kind: newKind, params: .object(params), position: action.position) } @@ -948,6 +1173,25 @@ private struct ActionRow: View { } ) } + + private var pauseSecondsBinding: Binding { + Binding( + get: { + guard case .number(let seconds) = action.params[ActionParam.pauseSeconds] else { return "" } + return seconds.formatted() + }, + set: { newValue in + var dict: [String: JSONValue] = [:] + if case .object(let existing) = action.params { dict = existing } + if let seconds = Double(newValue), seconds.isFinite, seconds >= 0 { + dict[ActionParam.pauseSeconds] = .number(seconds) + } else { + dict.removeValue(forKey: ActionParam.pauseSeconds) + } + action.params = .object(dict) + } + ) + } } private struct ActionOptionsView: View { @@ -964,7 +1208,7 @@ private struct ActionOptionsView: View { shortcutOptions case .openApplication: openApplicationOptions - case .moveToFolder, .copyToFolder, .importToLibrary, .uncompress: + case .moveToFolder, .copyToFolder, .sortIntoSubfolder, .syncToFolder, .importToLibrary, .uncompress: conflictResolutionOptions case .rename: renameOptions diff --git a/Sources/ForelApp/Views/RuleListView.swift b/Sources/ForelApp/Views/RuleListView.swift index 63d8c31..469b14e 100644 --- a/Sources/ForelApp/Views/RuleListView.swift +++ b/Sources/ForelApp/Views/RuleListView.swift @@ -16,6 +16,7 @@ import SwiftUI import AppKit +import UniformTypeIdentifiers import ForelCore struct RuleListView: View { @@ -46,7 +47,7 @@ struct RuleListView: View { private var content: some View { VStack(alignment: .leading, spacing: 14) { header - if !model.rules.isEmpty { + if model.selectedFolderId != nil { actionBar } @@ -166,10 +167,43 @@ struct RuleListView: View { .buttonStyle(SecondaryButtonStyle()) .disabled(model.selectedFolderId == nil || model.isPreviewing) + Menu { + Button("Import Rules…", action: importRules) + Button("Export Rules…", action: exportRules) + .disabled(model.rules.isEmpty) + } label: { + Label("Import / Export", systemImage: "arrow.left.arrow.right") + } + .menuStyle(.borderlessButton) + .fixedSize() + .disabled(model.selectedFolderId == nil) + Spacer() } } + private func importRules() { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [ + UTType(filenameExtension: "forelrules") ?? .json, + UTType(filenameExtension: "hazelrules") ?? .data, + ] + panel.prompt = "Import" + guard panel.runModal() == .OK, let url = panel.url else { return } + model.importRules(from: url) + } + + private func exportRules() { + let panel = NSSavePanel() + panel.allowedContentTypes = [UTType(filenameExtension: "forelrules") ?? .json] + panel.nameFieldStringValue = "Forel Rules.forelrules" + panel.prompt = "Export" + guard panel.runModal() == .OK, let url = panel.url else { return } + model.exportRules(to: url) + } + private var emptyState: some View { VStack(spacing: 8) { if model.selectedFolderId == nil { @@ -247,11 +281,14 @@ private struct RuleCard: View { .buttonStyle(.plain) .pointingHandCursor() - Toggle("", isOn: enabledBinding) - .labelsHidden() - .toggleStyle(.switch) - .tint(ForelTheme.accent) - .controlSize(.small) + Toggle(isOn: enabledBinding) { + Text(rule.enabled ? "Enabled" : "Disabled") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(rule.enabled ? ForelTheme.accent : ForelTheme.secondaryText) + } + .toggleStyle(.switch) + .tint(ForelTheme.accent) + .controlSize(.small) Button(action: onToggleExpanded) { VStack(alignment: .leading, spacing: 3) { @@ -438,6 +475,12 @@ private struct RuleDetails: View { return ("to folder", action.params[ActionParam.destination]?.stringValue) case .rename: return ("to \(action.params[ActionParam.pattern]?.stringValue ?? "")", action.params[ActionParam.cleanFileName]?.boolValue == true ? "clean file name" : nil) + case .sortIntoSubfolder: + return ("into \(action.params[ActionParam.subfolder]?.stringValue ?? "subfolder")", nil) + case .syncToFolder: + return ("sync to folder", action.params[ActionParam.destination]?.stringValue) + case .upload: + return ("upload", action.params[ActionParam.uploadURL]?.stringValue) case .moveToTrash: return ("move to Trash", nil) case .delete: @@ -449,6 +492,14 @@ private struct RuleDetails: View { case .setColorLabel: let color = action.params[ActionParam.color]?.stringValue ?? "" return (color.isEmpty ? "clear color label" : "set to \(color)", nil) + case .addComment: + return ("add Finder comment", action.params[ActionParam.comment]?.stringValue) + case .toggleExtension: + return ("toggle extension visibility", nil) + case .toggleLock: + return ("toggle lock", nil) + case .archive: + return ("create ZIP archive", nil) case .runScript: let script = action.params[ActionParam.script]?.stringValue ?? "" let firstLine = script.split(separator: "\n").first.map(String.init) ?? "" @@ -456,17 +507,48 @@ private struct RuleDetails: View { case .runShortcut: let name = action.params[ActionParam.shortcutName]?.stringValue ?? "" return (name.isEmpty ? "run shortcut" : name, ActionExecutor.shortcutInputMode(action).label) + case .runAppleScript, .runJavaScript: + let script = action.params[ActionParam.script]?.stringValue ?? "" + let firstLine = script.split(separator: "\n").first.map(String.init) ?? "" + return (firstLine.isEmpty ? action.kind.label : firstLine, nil) + case .runAutomatorWorkflow: + return ("run Automator workflow", action.params[ActionParam.workflowPath]?.stringValue) case .openApplication: let path = action.params[ActionParam.applicationPath]?.stringValue ?? "" let appName = path.isEmpty ? "open application" : ((path as NSString).lastPathComponent as NSString).deletingPathExtension let detail = ActionExecutor.passesFileToApplication(action) ? "with matched file" : nil return (appName, detail) + case .open: + return ("open", nil) + case .showInFinder: + return ("show in Finder", nil) + case .makeAlias: + return ("make alias", action.params[ActionParam.aliasDestination]?.stringValue) case .importToLibrary: let library = LibraryType(rawValue: action.params[ActionParam.libraryType]?.stringValue ?? "")?.label ?? "Library" let playlist = action.params[ActionParam.targetPlaylist]?.stringValue ?? "" return ("import to \(library)", playlist.isEmpty ? nil : playlist) case .uncompress: return ("uncompress ZIP", MoveConflictResolution(rawValue: action.params[ActionParam.onConflict]?.stringValue ?? "")?.label) + case .pause: + let seconds: String + let isSingular: Bool + if case .number(let value) = action.params[ActionParam.pauseSeconds] { + seconds = value.formatted() + isSingular = value == 1 + } else { + seconds = "invalid duration" + isSingular = false + } + return ("pause for \(seconds) second\(isSingular ? "" : "s")", nil) + case .runRulesOnFolderContents: + return ("run rules on folder contents", nil) + case .continueMatchingRules: + return ("continue matching rules", nil) + case .displayNotification: + return ("display notification", action.params[ActionParam.notificationTitle]?.stringValue) + case .ignore: + return ("ignore", nil) } } diff --git a/Sources/ForelApp/Views/SettingsView.swift b/Sources/ForelApp/Views/SettingsView.swift index 4732503..c063c07 100644 --- a/Sources/ForelApp/Views/SettingsView.swift +++ b/Sources/ForelApp/Views/SettingsView.swift @@ -103,6 +103,12 @@ struct SettingsView: View { isOn: dockIconBinding ) Divider().overlay(ForelTheme.divider).padding(.leading, 14) + ToggleRow( + title: "Show menu bar icon", + subtitle: "Open Forel from Finder or Spotlight when it is hidden", + isOn: menuBarIconBinding + ) + Divider().overlay(ForelTheme.divider).padding(.leading, 14) ToggleRow( title: "Watcher notifications", subtitle: "Notify when automatic rules process files", @@ -188,6 +194,10 @@ struct SettingsView: View { Binding(get: { model.showDockIcon }, set: { model.setShowDockIcon($0) }) } + private var menuBarIconBinding: Binding { + Binding(get: { model.showMenuBarIcon }, set: { model.setShowMenuBarIcon($0) }) + } + private var watcherNotificationsBinding: Binding { Binding(get: { model.watcherNotificationsEnabled }, set: { model.setWatcherNotificationsEnabled($0) }) } diff --git a/Sources/ForelCore/Engine/ActionExecutor.swift b/Sources/ForelCore/Engine/ActionExecutor.swift index ca8da14..b2cef6d 100644 --- a/Sources/ForelCore/Engine/ActionExecutor.swift +++ b/Sources/ForelCore/Engine/ActionExecutor.swift @@ -17,6 +17,9 @@ import Foundation import UniformTypeIdentifiers import ZIPFoundation +#if canImport(UserNotifications) +import UserNotifications +#endif #if canImport(AppKit) import AppKit #endif @@ -191,6 +194,14 @@ public enum ActionExecutor { return try copyToFolder(action, path: path) case .rename: return try renameFile(action, path: path) + case .sortIntoSubfolder: + let subfolder = try stringParam(action, ActionParam.subfolder, "SortIntoSubfolder") + let destination = ((path as NSString).deletingLastPathComponent as NSString).appendingPathComponent(subfolder) + return try moveIntoDir(path: path, destDir: destination, resolution: conflictResolution(action)) + case .syncToFolder: + return try syncToFolder(action, path: path) + case .upload: + return try upload(action, path: path) case .moveToTrash: return try moveIntoDir(path: path, destDir: try trashDir()) case .delete: @@ -202,19 +213,58 @@ public enum ActionExecutor { return try applyTags(action, path: path, add: false) case .setColorLabel: return try setColor(action, path: path) + case .addComment: + return try addComment(action, path: path) + case .toggleExtension: + return try toggleExtension(path: path) + case .toggleLock: + return try toggleLock(path: path) + case .archive: + return try archive(path: path) case .runScript: return try runScript(action, path: path) case .runShortcut: return try runShortcut(action, path: path) + case .runAppleScript: + return try runAppleScriptAction(action, path: path) + case .runJavaScript: + return try runJavaScript(action, path: path) + case .runAutomatorWorkflow: + return try runAutomatorWorkflow(action, path: path) case .openApplication: return try openApplication(action, path: path) + case .open: + return try open(path: path) + case .showInFinder: + return try showInFinder(path: path) + case .makeAlias: + return try makeAlias(action, path: path) case .importToLibrary: return try importToLibrary(action, path: path) case .uncompress: return try uncompress(action, path: path) + case .pause: + let seconds = try pauseDuration(action) + if seconds > 0 { + Thread.sleep(forTimeInterval: seconds) + } + return Applied(newPath: path, undo: .none) + case .runRulesOnFolderContents, .continueMatchingRules, .ignore: + return Applied(newPath: path, undo: .none) + case .displayNotification: + displayNotification(action, path: path) + return Applied(newPath: path, undo: .none) } } + private static func pauseDuration(_ action: Action) throws -> TimeInterval { + guard case .number(let seconds) = action.params[ActionParam.pauseSeconds], + seconds.isFinite, seconds >= 0 else { + throw ActionError("Pause requires a non-negative number of seconds") + } + return seconds + } + private static func stringParam(_ action: Action, _ key: String, _ kind: String) throws -> String { guard let value = action.params[key]?.stringValue else { throw ActionError("\(kind) requires '\(key)' param") @@ -245,6 +295,45 @@ public enum ActionExecutor { return Applied(newPath: path, undo: .none, copiedPath: dest) } + private static func syncToFolder(_ action: Action, path: String) throws -> Applied { + let destDir = try stringParam(action, ActionParam.destination, "SyncToFolder") + try FileManager.default.createDirectory(atPath: destDir, withIntermediateDirectories: true) + let fileName = (path as NSString).lastPathComponent + var destination = (destDir as NSString).appendingPathComponent(fileName) + if FileManager.default.fileExists(atPath: destination) { + if FileManager.default.contentsEqual(atPath: path, andPath: destination) { + return Applied(newPath: path, undo: .none) + } + switch conflictResolution(action) { + case .skip: + return Applied(newPath: path, undo: .none) + case .rename: + destination = uniqueDest(dir: destDir, fileName: fileName) + case .replace: + try FileManager.default.removeItem(atPath: destination) + } + } + try FileManager.default.copyItem(atPath: path, toPath: destination) + return Applied(newPath: path, undo: .none, copiedPath: destination) + } + + private static func upload(_ action: Action, path: String) throws -> Applied { + let url = try stringParam(action, ActionParam.uploadURL, "Upload") + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/curl") + process.arguments = ["--fail", "--silent", "--show-error", "--upload-file", path, url] + let errors = Pipe() + process.standardOutput = FileHandle.nullDevice + process.standardError = errors + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let message = String(data: errors.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Upload failed" + throw ActionError(message) + } + return Applied(newPath: path, undo: .none) + } + private struct ZipExtractionPlan { let target: String let topLevelItems: [String] @@ -357,6 +446,38 @@ public enum ActionExecutor { return Applied(newPath: path, undo: .color(path: path, previous: previous)) } + private static func addComment(_ action: Action, path: String) throws -> Applied { + let comment = try stringParam(action, ActionParam.comment, "AddComment") + try FinderTags.writeComment(path, comment) + return Applied(newPath: path, undo: .none) + } + + private static func toggleExtension(path: String) throws -> Applied { + var url = URL(fileURLWithPath: path) + let values = try url.resourceValues(forKeys: [.hasHiddenExtensionKey]) + var updated = URLResourceValues() + updated.hasHiddenExtension = !(values.hasHiddenExtension ?? false) + try url.setResourceValues(updated) + return Applied(newPath: path, undo: .none) + } + + private static func toggleLock(path: String) throws -> Applied { + var url = URL(fileURLWithPath: path) + let values = try url.resourceValues(forKeys: [.isUserImmutableKey]) + var updated = URLResourceValues() + updated.isUserImmutable = !(values.isUserImmutable ?? false) + try url.setResourceValues(updated) + return Applied(newPath: path, undo: .none) + } + + private static func archive(path: String) throws -> Applied { + let parent = (path as NSString).deletingLastPathComponent + let name = (path as NSString).lastPathComponent + let target = uniqueDest(dir: parent, fileName: "\(name).zip") + try FileManager.default.zipItem(at: URL(fileURLWithPath: path), to: URL(fileURLWithPath: target)) + return Applied(newPath: path, undo: .none, copiedPath: target) + } + private static let scriptDefaultTimeout: TimeInterval = 60 private static func runScript(_ action: Action, path: String) throws -> Applied { @@ -396,6 +517,38 @@ public enum ActionExecutor { return Applied(newPath: path, undo: .none) } + private static func runAppleScriptAction(_ action: Action, path: String) throws -> Applied { + let script = try stringParam(action, ActionParam.script, "RunAppleScript") + let file = appleScriptEscapePath(path) + try runAppleScript("set forelFile to POSIX file \"\(file)\"\n\(script)") + return Applied(newPath: path, undo: .none) + } + + private static func runJavaScript(_ action: Action, path: String) throws -> Applied { + let script = try stringParam(action, ActionParam.script, "RunJavaScript") + let data = try JSONEncoder().encode(path) + let literal = String(data: data, encoding: .utf8) ?? "\"\"" + try runOSA(script: "const forelFile = Path(\(literal));\n\(script)", language: "JavaScript") + return Applied(newPath: path, undo: .none) + } + + private static func runAutomatorWorkflow(_ action: Action, path: String) throws -> Applied { + let workflow = try stringParam(action, ActionParam.workflowPath, "RunAutomatorWorkflow") + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/automator") + process.arguments = ["-i", path, workflow] + let errors = Pipe() + process.standardOutput = FileHandle.nullDevice + process.standardError = errors + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + let message = String(data: errors.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Automator workflow failed" + throw ActionError(message) + } + return Applied(newPath: path, undo: .none) + } + private static func openApplication(_ action: Action, path: String) throws -> Applied { #if canImport(AppKit) let appPath = try stringParam(action, ActionParam.applicationPath, "OpenApplication") @@ -420,6 +573,51 @@ public enum ActionExecutor { #endif } + private static func open(path: String) throws -> Applied { + #if canImport(AppKit) + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + return Applied(newPath: path, undo: .none) + #else + throw ActionError("Open is not available on this platform") + #endif + } + + private static func showInFinder(path: String) throws -> Applied { + #if canImport(AppKit) + NSWorkspace.shared.selectFile(path, inFileViewerRootedAtPath: "") + return Applied(newPath: path, undo: .none) + #else + throw ActionError("Show in Finder is not available on this platform") + #endif + } + + private static func makeAlias(_ action: Action, path: String) throws -> Applied { + let destination = try stringParam(action, ActionParam.aliasDestination, "MakeAlias") + let source = appleScriptEscapePath(path) + let target = appleScriptEscapePath(destination) + try runAppleScript(""" + tell application "Finder" + set sourceItem to POSIX file "\(source)" as alias + set destinationFolder to POSIX file "\(target)" as alias + make new alias file to sourceItem at destinationFolder + end tell + """) + return Applied(newPath: path, undo: .none) + } + + private static func displayNotification(_ action: Action, path: String) { + #if canImport(UserNotifications) + let title = action.params[ActionParam.notificationTitle]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + let body = action.params[ActionParam.notificationBody]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + let content = UNMutableNotificationContent() + content.title = title?.isEmpty == false ? title! : "Forel" + content.body = body?.isEmpty == false ? body! : (path as NSString).lastPathComponent + content.sound = .default + let request = UNNotificationRequest(identifier: "forel-action-\(UUID().uuidString)", content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) + #endif + } + // MARK: - Import to Library private static func importToLibrary(_ action: Action, path: String) throws -> Applied { @@ -534,9 +732,17 @@ public enum ActionExecutor { @discardableResult static func runAppleScript(_ script: String) throws -> String { + try runOSA(script: script) + } + + @discardableResult + private static func runOSA(script: String, language: String? = nil) throws -> String { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") - process.arguments = ["-e", script] + var arguments: [String] = [] + if let language { arguments += ["-l", language] } + arguments += ["-e", script] + process.arguments = arguments let outputPipe = Pipe() let errorPipe = Pipe() process.standardOutput = outputPipe @@ -1159,6 +1365,74 @@ public enum ActionExecutor { copiedPath: nil, isTerminal: false ) + case .sortIntoSubfolder: + let subfolder = action.params[ActionParam.subfolder]?.stringValue ?? "" + let destination = ((path as NSString).deletingLastPathComponent as NSString).appendingPathComponent(subfolder) + let target = (destination as NSString).appendingPathComponent(fileName) + return ActionPlan(kind: action.kind, description: "Sort into \(subfolder)", sourcePath: path, targetPath: target, status: subfolder.isEmpty ? .wouldSkip : .wouldRun, finalPath: target, copiedPath: nil, isTerminal: true) + case .syncToFolder: + let destination = action.params[ActionParam.destination]?.stringValue ?? "" + let naiveTarget = (destination as NSString).appendingPathComponent(fileName) + let exists = !destination.isEmpty && FileManager.default.fileExists(atPath: naiveTarget) + let unchanged = exists && FileManager.default.contentsEqual(atPath: path, andPath: naiveTarget) + let resolution = conflictResolution(action) + if exists && !unchanged && resolution == .skip { + return ActionPlan(kind: action.kind, description: "Skip — a file already exists at \(naiveTarget)", sourcePath: path, targetPath: naiveTarget, status: .wouldSkip, finalPath: path, copiedPath: nil, isTerminal: false) + } + let target = exists && !unchanged && resolution == .rename + ? uniqueDest(dir: destination, fileName: fileName) + : naiveTarget + let description = unchanged ? "Already synced" : exists && resolution == .replace ? "Sync to \(target) (replacing existing file)" : "Sync to \(target)" + return ActionPlan(kind: action.kind, description: description, sourcePath: path, targetPath: target, status: destination.isEmpty || unchanged ? .wouldSkip : .wouldRun, finalPath: path, copiedPath: unchanged ? nil : target, isTerminal: false) + case .upload: + let url = action.params[ActionParam.uploadURL]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return ActionPlan(kind: action.kind, description: url.isEmpty ? "Upload" : "Upload to \(url)", sourcePath: path, targetPath: nil, status: url.isEmpty ? .wouldSkip : .wouldRun, finalPath: path, copiedPath: nil, isTerminal: false) + case .addComment: + let comment = action.params[ActionParam.comment]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return ActionPlan(kind: action.kind, description: comment.isEmpty ? "Add comment" : "Add Finder comment", sourcePath: path, targetPath: nil, status: comment.isEmpty ? .wouldSkip : .wouldRun, finalPath: path, copiedPath: nil, isTerminal: false) + case .toggleExtension: + return ActionPlan(kind: action.kind, description: "Toggle extension visibility", sourcePath: path, targetPath: nil, status: .wouldRun, finalPath: path, copiedPath: nil, isTerminal: false) + case .toggleLock: + return ActionPlan(kind: action.kind, description: "Toggle file lock", sourcePath: path, targetPath: nil, status: .wouldRun, finalPath: path, copiedPath: nil, isTerminal: false) + case .archive: + let target = ((path as NSString).deletingLastPathComponent as NSString).appendingPathComponent("\(fileName).zip") + return ActionPlan(kind: action.kind, description: "Archive to \(target)", sourcePath: path, targetPath: target, status: .wouldRun, finalPath: path, copiedPath: target, isTerminal: false) + case .runAppleScript, .runJavaScript: + let script = action.params[ActionParam.script]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return ActionPlan(kind: action.kind, description: script.isEmpty ? action.kind.label : action.kind.label, sourcePath: path, targetPath: nil, status: script.isEmpty ? .wouldSkip : .wouldRun, finalPath: path, copiedPath: nil, isTerminal: false) + case .runAutomatorWorkflow: + let workflow = action.params[ActionParam.workflowPath]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return ActionPlan(kind: action.kind, description: workflow.isEmpty ? "Run Automator workflow" : "Run \((workflow as NSString).lastPathComponent)", sourcePath: path, targetPath: nil, status: workflow.isEmpty ? .wouldSkip : .wouldRun, finalPath: path, copiedPath: nil, isTerminal: false) + case .open: + return ActionPlan(kind: action.kind, description: "Open", sourcePath: path, targetPath: nil, status: .wouldRun, finalPath: path, copiedPath: nil, isTerminal: false) + case .showInFinder: + return ActionPlan(kind: action.kind, description: "Show in Finder", sourcePath: path, targetPath: nil, status: .wouldRun, finalPath: path, copiedPath: nil, isTerminal: false) + case .makeAlias: + let destination = action.params[ActionParam.aliasDestination]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return ActionPlan(kind: action.kind, description: destination.isEmpty ? "Make alias" : "Make alias in \(destination)", sourcePath: path, targetPath: destination.isEmpty ? nil : destination, status: destination.isEmpty ? .wouldSkip : .wouldRun, finalPath: path, copiedPath: nil, isTerminal: false) + case .runRulesOnFolderContents: + var isDirectory = ObjCBool(false) + let isFolder = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) && isDirectory.boolValue + return ActionPlan(kind: action.kind, description: isFolder ? "Run rules on folder contents" : "Requires a folder", sourcePath: path, targetPath: nil, status: isFolder ? .wouldRun : .wouldSkip, finalPath: path, copiedPath: nil, isTerminal: false) + case .continueMatchingRules: + return ActionPlan(kind: action.kind, description: "Continue matching rules", sourcePath: path, targetPath: nil, status: .wouldRun, finalPath: path, copiedPath: nil, isTerminal: false) + case .displayNotification: + let title = action.params[ActionParam.notificationTitle]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return ActionPlan(kind: action.kind, description: title.isEmpty ? "Display notification" : "Display notification: \(title)", sourcePath: path, targetPath: nil, status: .wouldRun, finalPath: path, copiedPath: nil, isTerminal: false) + case .ignore: + return ActionPlan(kind: action.kind, description: "Ignore", sourcePath: path, targetPath: nil, status: .wouldRun, finalPath: path, copiedPath: nil, isTerminal: true) + case .pause: + let seconds = try pauseDuration(action) + return ActionPlan( + kind: action.kind, + description: "Pause for \(seconds.formatted()) second\(seconds == 1 ? "" : "s")", + sourcePath: path, + targetPath: nil, + status: .wouldRun, + finalPath: path, + copiedPath: nil, + isTerminal: false + ) } } @@ -1177,7 +1451,7 @@ public enum ActionExecutor { let pattern = action.params[ActionParam.pattern]?.stringValue ?? "" guard let newName = try? applyRenamePattern(pattern, path: path) else { return true } return (path as NSString).lastPathComponent != newName - case .moveToFolder, .copyToFolder, .moveToTrash, .delete, .runScript, .runShortcut, .openApplication, .importToLibrary, .uncompress: + case .moveToFolder, .copyToFolder, .sortIntoSubfolder, .syncToFolder, .upload, .moveToTrash, .delete, .addComment, .toggleExtension, .toggleLock, .archive, .runScript, .runShortcut, .runAppleScript, .runJavaScript, .runAutomatorWorkflow, .openApplication, .open, .showInFinder, .makeAlias, .importToLibrary, .uncompress, .pause, .runRulesOnFolderContents, .continueMatchingRules, .displayNotification, .ignore: return true } } diff --git a/Sources/ForelCore/Engine/ConditionEvaluator.swift b/Sources/ForelCore/Engine/ConditionEvaluator.swift index af71ed4..a51728c 100644 --- a/Sources/ForelCore/Engine/ConditionEvaluator.swift +++ b/Sources/ForelCore/Engine/ConditionEvaluator.swift @@ -16,6 +16,8 @@ import Foundation import Darwin +import ImageIO +import PDFKit public enum ConditionEvaluator { /// Returns true if the file at `path` satisfies the condition. @@ -95,6 +97,42 @@ public enum ConditionEvaluator { guard let added = dateAdded(path: path) else { return false } return matchDate(condition.operator, added, condition.value) + case .finderComment: + guard let comment = FinderTags.readComment(path) else { return false } + return matchString(condition.operator, comment, condition.value) + + case .filePath: + return matchString(condition.operator, path, condition.value) + + case .itemCount: + guard let count = itemCount(path: path) else { return false } + return matchNumber(condition.operator, count, condition.value) + + case .lastOpened: + guard let accessed = try? url.resourceValues(forKeys: [.contentAccessDateKey]).contentAccessDate else { return false } + return matchDate(condition.operator, accessed, condition.value) + + case .imageWidth: + guard let dimensions = imageDimensions(path: path) else { return false } + return matchNumber(condition.operator, dimensions.width, condition.value) + + case .imageHeight: + guard let dimensions = imageDimensions(path: path) else { return false } + return matchNumber(condition.operator, dimensions.height, condition.value) + + case .photoDateTaken: + guard let taken = photoDateTaken(path: path) else { return false } + return matchDate(condition.operator, taken, condition.value) + + case .pdfPageCount: + guard let pageCount = PDFDocument(url: url)?.pageCount else { return false } + return matchNumber(condition.operator, Double(pageCount), condition.value) + + case .spotlightMetadata: + guard let (key, value) = SpotlightMetadataCondition.parse(condition.value), + let metadata = spotlightMetadata(path: path, key: key) else { return false } + return matchString(condition.operator, metadata, value) + case .downloadedFromWebsite: return matchAnyOf(condition.operator, DownloadMetadata.websiteURLs(path), condition.value) @@ -164,6 +202,63 @@ public enum ConditionEvaluator { } } + private static func matchNumber(_ operator_: Operator, _ actual: Double, _ expected: String) -> Bool { + guard let target = Double(expected.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false } + switch operator_ { + case .is: return actual == target + case .isNot: return actual != target + case .greaterThan: return actual > target + case .lessThan: return actual < target + default: return false + } + } + + private static func itemCount(path: String) -> Double? { + var isDirectory = ObjCBool(false) + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory), isDirectory.boolValue, + let children = try? FileManager.default.contentsOfDirectory(atPath: path) else { return nil } + return Double(children.filter { !SystemFileFilter.isExcluded($0) }.count) + } + + private static func imageDimensions(path: String) -> (width: Double, height: Double)? { + guard let source = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? NSNumber, + let height = properties[kCGImagePropertyPixelHeight] as? NSNumber else { return nil } + return (width.doubleValue, height.doubleValue) + } + + private static func photoDateTaken(path: String) -> Date? { + guard let source = CGImageSourceCreateWithURL(URL(fileURLWithPath: path) as CFURL, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let exif = properties[kCGImagePropertyExifDictionary] as? [CFString: Any], + let raw = exif[kCGImagePropertyExifDateTimeOriginal] as? String else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy:MM:dd HH:mm:ss" + return formatter.date(from: raw) + } + + private static func spotlightMetadata(path: String, key: String) -> String? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/mdls") + process.arguments = ["-name", key, "-raw", path] + let output = Pipe() + process.standardOutput = output + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + return nil + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let result = String(data: output.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let result, result != "(null)" else { return nil } + return result + } + private static func appNamesMatch(actual: String, expected: String) -> Bool { actual == expected || actual == quarantineAgentAlias(forAppName: expected) } diff --git a/Sources/ForelCore/Engine/FinderTags.swift b/Sources/ForelCore/Engine/FinderTags.swift index d3cbf52..ad51e60 100644 --- a/Sources/ForelCore/Engine/FinderTags.swift +++ b/Sources/ForelCore/Engine/FinderTags.swift @@ -23,6 +23,7 @@ import Darwin /// with "\nN" (the colour's Finder index). enum FinderTags { static let xattrName = "com.apple.metadata:_kMDItemUserTags" + static let commentXattrName = "com.apple.metadata:kMDItemFinderComment" /// Reads the Finder tags on `path`, or an empty list if there are none. static func read(_ path: String) -> [String] { @@ -99,6 +100,28 @@ enum FinderTags { try write(path, tags) } + static func writeComment(_ path: String, _ comment: String) throws { + let data = try PropertyListSerialization.data(fromPropertyList: comment, format: .binary, options: 0) + let result = data.withUnsafeBytes { bytes in + setxattr(path, commentXattrName, bytes.baseAddress, data.count, 0, 0) + } + guard result == 0 else { + throw SQLiteError("failed to write Finder comment on \(path): errno \(errno)") + } + } + + static func readComment(_ path: String) -> String? { + let size = getxattr(path, commentXattrName, nil, 0, 0, 0) + guard size > 0 else { return nil } + var buffer = [UInt8](repeating: 0, count: size) + let read = getxattr(path, commentXattrName, &buffer, size, 0, 0) + guard read > 0, + let value = try? PropertyListSerialization.propertyList(from: Data(buffer[0.. String { guard let first = s.first else { return s } return String(first).uppercased() + s.dropFirst().lowercased() diff --git a/Sources/ForelCore/Engine/RuleEngine.swift b/Sources/ForelCore/Engine/RuleEngine.swift index 9d17cac..8e58345 100644 --- a/Sources/ForelCore/Engine/RuleEngine.swift +++ b/Sources/ForelCore/Engine/RuleEngine.swift @@ -158,6 +158,23 @@ public enum RuleEngine { ) } + if let contentsFolder = result.folderContents { + for entry in walkEntries(root: contentsFolder, maxDepth: 0) { + pending.append( + PendingFile( + path: entry.path, + // This action explicitly opts its children + // into the full rule list. Treat them as + // direct entries so rules using Forel's + // default “Current folder” scope participate. + depth: 0, + startRuleIndex: 0, + blockedRuleIds: blockedRuleIds + ) + ) + } + } + // A terminal action (move/trash/delete) takes the file out of // this location — even if it didn't actually run (e.g. a // skipped/blocked conflict), later actions in this rule and @@ -279,6 +296,7 @@ public enum RuleEngine { switch match { case .all: return results.allSatisfy { $0 } case .any: return results.contains(true) + case .none: return results.allSatisfy { !$0 } } } @@ -300,6 +318,8 @@ public enum RuleEngine { return ordered.allSatisfy { ConditionEvaluator.evaluate($0, path: path) } case .any: return ordered.contains { ConditionEvaluator.evaluate($0, path: path) } + case .none: + return ordered.allSatisfy { !ConditionEvaluator.evaluate($0, path: path) } } } @@ -375,11 +395,12 @@ public enum RuleEngine { /// the exact same way `previewActions` would (via `ActionExecutor.plan`) /// before acting on it — the single place preview and execution can /// never disagree. - private static func runActions(_ rule: Rule, path: String, batchId: String) -> (history: [HistoryEntry], copiedPaths: [String], finalPath: String, isTerminal: Bool) { + private static func runActions(_ rule: Rule, path: String, batchId: String) -> (history: [HistoryEntry], copiedPaths: [String], folderContents: String?, finalPath: String, isTerminal: Bool) { let sorted = rule.actions.sorted { $0.position < $1.position } var history: [HistoryEntry] = [] var copiedPaths: [String] = [] + var folderContents: String? var current = path var stoppedOnTerminal = false @@ -462,6 +483,9 @@ public enum RuleEngine { resultFileId: resultIdentity?.fileId ) ) + if action.kind == .runRulesOnFolderContents { + folderContents = current + } current = applied.newPath } @@ -486,7 +510,7 @@ public enum RuleEngine { ) } } - return (history, copiedPaths, current, stoppedOnTerminal) + return (history, copiedPaths, folderContents, current, stoppedOnTerminal) } private static func previewActions(_ rule: Rule, path: String) -> (actions: [ActionPreview], copiedPaths: [String], finalPath: String, isTerminal: Bool) { diff --git a/Sources/ForelCore/Models/Models.swift b/Sources/ForelCore/Models/Models.swift index c73059c..396aa52 100644 --- a/Sources/ForelCore/Models/Models.swift +++ b/Sources/ForelCore/Models/Models.swift @@ -60,6 +60,7 @@ public struct WatchedPathState: Codable, Equatable, Sendable { public enum ConditionMatch: String, Codable, Equatable, Sendable { case all case any + case none } public enum ConditionKind: String, Codable, Equatable, Sendable { @@ -73,6 +74,15 @@ public enum ConditionKind: String, Codable, Equatable, Sendable { case createdAt = "created_at" case dateModified = "date_modified" case dateAdded = "date_added" + case finderComment = "finder_comment" + case filePath = "file_path" + case itemCount = "item_count" + case lastOpened = "last_opened" + case imageWidth = "image_width" + case imageHeight = "image_height" + case photoDateTaken = "photo_date_taken" + case pdfPageCount = "pdf_page_count" + case spotlightMetadata = "spotlight_metadata" case downloadedFromWebsite = "downloaded_from_website" case downloadedWithApp = "downloaded_with_app" case rawWhereFromMetadata = "raw_where_from_metadata" @@ -122,16 +132,34 @@ public enum ActionKind: String, Codable, Equatable, Sendable { case moveToFolder = "move_to_folder" case copyToFolder = "copy_to_folder" case rename + case sortIntoSubfolder = "sort_into_subfolder" + case syncToFolder = "sync_to_folder" + case upload case moveToTrash = "move_to_trash" case delete case addTag = "add_tag" case removeTag = "remove_tag" case setColorLabel = "set_color_label" + case addComment = "add_comment" + case toggleExtension = "toggle_extension" + case toggleLock = "toggle_lock" + case archive case runScript = "run_script" case runShortcut = "run_shortcut" + case runAppleScript = "run_applescript" + case runJavaScript = "run_javascript" + case runAutomatorWorkflow = "run_automator_workflow" case openApplication = "open_application" + case open + case showInFinder = "show_in_finder" + case makeAlias = "make_alias" case importToLibrary = "import_to_library" case uncompress + case pause + case runRulesOnFolderContents = "run_rules_on_folder_contents" + case continueMatchingRules = "continue_matching_rules" + case displayNotification = "display_notification" + case ignore public init(dbValue: String) { self = ActionKind(rawValue: dbValue) ?? .moveToFolder diff --git a/Sources/ForelCore/Models/RuleSchema.swift b/Sources/ForelCore/Models/RuleSchema.swift index d74f7a8..a84f5f3 100644 --- a/Sources/ForelCore/Models/RuleSchema.swift +++ b/Sources/ForelCore/Models/RuleSchema.swift @@ -70,6 +70,8 @@ public enum ConditionValueKind: Sendable, Equatable { case relativeDate case fileKind case colorLabel + case number + case spotlightMetadata /// Free text combined with a suggestion list (e.g. installed apps) — /// still a plain string value underneath, just with autocomplete. case appPicker @@ -90,6 +92,15 @@ public extension ConditionKind { case .createdAt: return "Date created" case .dateModified: return "Date modified" case .dateAdded: return "Date added" + case .finderComment: return "Finder comment" + case .filePath: return "File path" + case .itemCount: return "Number of items" + case .lastOpened: return "Last opened" + case .imageWidth: return "Image width" + case .imageHeight: return "Image height" + case .photoDateTaken: return "Photo date taken" + case .pdfPageCount: return "PDF page count" + case .spotlightMetadata: return "Spotlight metadata" case .downloadedFromWebsite: return "Downloaded from website" case .downloadedWithApp: return "Downloaded with app" case .rawWhereFromMetadata: return "Raw where-from metadata" @@ -101,13 +112,13 @@ public extension ConditionKind { /// `RuleSchemaTests`. var validOperators: [Operator] { switch self { - case .createdAt, .dateModified, .dateAdded: + case .createdAt, .dateModified, .dateAdded, .lastOpened, .photoDateTaken: return [.before, .after, .olderThan, .withinLast] - case .sizeBytes: + case .sizeBytes, .itemCount, .imageWidth, .imageHeight, .pdfPageCount: return [.is, .isNot, .greaterThan, .lessThan] case .kind, .colorLabel: return [.is, .isNot] - case .name, .extension_, .tags, .contents, + case .name, .extension_, .tags, .contents, .finderComment, .filePath, .spotlightMetadata, .downloadedFromWebsite, .rawWhereFromMetadata: return [.is, .isNot, .contains, .doesNotContain, .startsWith, .endsWith, .matchesRegex] case .downloadedWithApp: @@ -125,9 +136,11 @@ public extension ConditionKind { switch self { case .kind: return .fileKind case .sizeBytes: return .size + case .itemCount, .imageWidth, .imageHeight, .pdfPageCount: return .number case .colorLabel: return .colorLabel - case .createdAt, .dateModified, .dateAdded: return .absoluteDate - case .name, .extension_, .tags, .contents, + case .createdAt, .dateModified, .dateAdded, .lastOpened, .photoDateTaken: return .absoluteDate + case .spotlightMetadata: return .spotlightMetadata + case .name, .extension_, .tags, .contents, .finderComment, .filePath, .downloadedFromWebsite, .rawWhereFromMetadata: return .text case .downloadedWithApp: return .appPicker } @@ -146,6 +159,14 @@ public extension ConditionKind { case .createdAt: return "calendar.badge.plus" case .dateModified: return "calendar.badge.clock" case .dateAdded: return "calendar.day.timeline.left" + case .finderComment: return "text.bubble" + case .filePath: return "point.topleft.down.curvedto.point.bottomright.up" + case .itemCount: return "folder.badge.plus" + case .lastOpened: return "clock.arrow.circlepath" + case .imageWidth, .imageHeight: return "aspectratio" + case .photoDateTaken: return "camera" + case .pdfPageCount: return "doc.richtext" + case .spotlightMetadata: return "magnifyingglass" case .downloadedFromWebsite: return "globe" case .downloadedWithApp: return "macwindow" case .rawWhereFromMetadata: return "curlybraces" @@ -161,6 +182,8 @@ public extension ConditionKind { return "Uses macOS download metadata. Availability depends on the app that created the file." case .contents: return "Matches text from plain files, PDFs, Word documents, and images via OCR when available." + case .spotlightMetadata: + return "Enter a Spotlight key (for example kMDItemAuthors) and the value to match. Availability depends on macOS indexing." default: return nil } @@ -205,6 +228,14 @@ public enum ActionParam { public static let cleanFileName = "clean_file_name" public static let libraryType = "library_type" public static let targetPlaylist = "target_playlist" + public static let pauseSeconds = "pause_seconds" + public static let subfolder = "subfolder" + public static let uploadURL = "upload_url" + public static let comment = "comment" + public static let aliasDestination = "alias_destination" + public static let workflowPath = "workflow_path" + public static let notificationTitle = "notification_title" + public static let notificationBody = "notification_body" } /// The abstract shape of an action parameter; the UI maps it to a concrete editor. @@ -218,6 +249,9 @@ public enum ActionParamKind: Sendable, Equatable { case applicationPath case libraryType case playlist + case duration + case text + case filePath } public struct ActionParamSpec: Sendable, Equatable { @@ -238,16 +272,34 @@ public extension ActionKind { case .moveToFolder: return "Move to folder" case .copyToFolder: return "Copy to folder" case .rename: return "Rename" + case .sortIntoSubfolder: return "Sort into subfolder" + case .syncToFolder: return "Sync to folder" + case .upload: return "Upload" case .moveToTrash: return "Move to Trash" case .delete: return "Delete" case .addTag: return "Add tag" case .removeTag: return "Remove tag" case .setColorLabel: return "Set color label" - case .runScript: return "Run script" + case .addComment: return "Add comment" + case .toggleExtension: return "Toggle extension" + case .toggleLock: return "Toggle lock" + case .archive: return "Archive" + case .runScript: return "Run shell script" case .runShortcut: return "Run shortcut" + case .runAppleScript: return "Run AppleScript" + case .runJavaScript: return "Run JavaScript" + case .runAutomatorWorkflow: return "Run Automator workflow" case .openApplication: return "Open application" + case .open: return "Open" + case .showInFinder: return "Show in Finder" + case .makeAlias: return "Make alias" case .importToLibrary: return "Import to library" case .uncompress: return "Uncompress" + case .pause: return "Pause" + case .runRulesOnFolderContents: return "Run rules on folder contents" + case .continueMatchingRules: return "Continue matching rules" + case .displayNotification: return "Display notification" + case .ignore: return "Ignore" } } @@ -257,14 +309,32 @@ public extension ActionKind { case .moveToFolder: return "arrow.right.doc.on.clipboard" case .copyToFolder: return "doc.on.doc" case .rename: return "pencil" + case .sortIntoSubfolder: return "folder.badge.gearshape" + case .syncToFolder: return "arrow.triangle.2.circlepath" + case .upload: return "arrow.up.circle" case .moveToTrash, .delete: return "trash" case .addTag, .removeTag: return "tag" case .setColorLabel: return "paintpalette" + case .addComment: return "text.bubble" + case .toggleExtension: return "textformat.abc" + case .toggleLock: return "lock" + case .archive: return "archivebox" case .runScript: return "terminal" case .runShortcut: return "square.stack.3d.up" + case .runAppleScript: return "applescript" + case .runJavaScript: return "curlybraces" + case .runAutomatorWorkflow: return "gearshape.2" case .openApplication: return "app" + case .open: return "arrow.up.forward.app" + case .showInFinder: return "folder" + case .makeAlias: return "arrowshape.turn.up.right" case .importToLibrary: return "tray.full" case .uncompress: return "doc.zipper" + case .pause: return "pause.circle" + case .runRulesOnFolderContents: return "folder.badge.play" + case .continueMatchingRules: return "arrow.right.circle" + case .displayNotification: return "bell" + case .ignore: return "eye.slash" } } @@ -274,9 +344,9 @@ public extension ActionKind { /// have none, instead of showing an empty "No options" popover. var hasOptions: Bool { switch self { - case .moveToFolder, .copyToFolder, .runShortcut, .openApplication, .rename, .importToLibrary, .uncompress: + case .moveToFolder, .copyToFolder, .sortIntoSubfolder, .syncToFolder, .runShortcut, .openApplication, .rename, .importToLibrary, .uncompress: return true - case .addTag, .removeTag, .setColorLabel, .runScript, .moveToTrash, .delete: + case .upload, .addTag, .removeTag, .setColorLabel, .addComment, .toggleExtension, .toggleLock, .archive, .runScript, .runAppleScript, .runJavaScript, .runAutomatorWorkflow, .open, .showInFinder, .makeAlias, .moveToTrash, .delete, .pause, .runRulesOnFolderContents, .continueMatchingRules, .displayNotification, .ignore: return false } } @@ -287,22 +357,39 @@ public extension ActionKind { switch self { case .moveToFolder, .copyToFolder: return [ActionParamSpec(key: ActionParam.destination, kind: .folderPath)] + case .syncToFolder: + return [ActionParamSpec(key: ActionParam.destination, kind: .folderPath)] + case .sortIntoSubfolder: + return [ActionParamSpec(key: ActionParam.subfolder, kind: .text)] + case .upload: + return [ActionParamSpec(key: ActionParam.uploadURL, kind: .text)] case .rename: return [ActionParamSpec(key: ActionParam.pattern, kind: .renamePattern)] case .addTag, .removeTag: return [ActionParamSpec(key: ActionParam.tags, kind: .tags)] case .setColorLabel: return [ActionParamSpec(key: ActionParam.color, kind: .colorLabel)] - case .runScript: + case .addComment: + return [ActionParamSpec(key: ActionParam.comment, kind: .text)] + case .runScript, .runAppleScript, .runJavaScript: return [ActionParamSpec(key: ActionParam.script, kind: .script)] + case .runAutomatorWorkflow: + return [ActionParamSpec(key: ActionParam.workflowPath, kind: .filePath)] case .runShortcut: return [ActionParamSpec(key: ActionParam.shortcutName, kind: .shortcut)] case .openApplication: return [ActionParamSpec(key: ActionParam.applicationPath, kind: .applicationPath)] + case .makeAlias: + return [ActionParamSpec(key: ActionParam.aliasDestination, kind: .folderPath)] case .importToLibrary: return [ActionParamSpec(key: ActionParam.libraryType, kind: .libraryType), ActionParamSpec(key: ActionParam.targetPlaylist, kind: .playlist)] - case .moveToTrash, .delete, .uncompress: + case .pause: + return [ActionParamSpec(key: ActionParam.pauseSeconds, kind: .duration)] + case .displayNotification: + return [ActionParamSpec(key: ActionParam.notificationTitle, kind: .text), + ActionParamSpec(key: ActionParam.notificationBody, kind: .text)] + case .moveToTrash, .delete, .toggleExtension, .toggleLock, .archive, .open, .showInFinder, .uncompress, .runRulesOnFolderContents, .continueMatchingRules, .ignore: return [] } } @@ -339,19 +426,22 @@ public enum RuleSchema { public static let conditionKindGroups: [ConditionKindGroup] = [ ConditionKindGroup(title: nil, kinds: [ .name, .extension_, .kind, .sizeBytes, .tags, .colorLabel, .contents, - .createdAt, .dateModified, .dateAdded, + .createdAt, .dateModified, .dateAdded, .finderComment, .filePath, .itemCount, + .lastOpened, .imageWidth, .imageHeight, .photoDateTaken, .pdfPageCount, ]), ConditionKindGroup(title: "Metadata", kinds: [ - .downloadedFromWebsite, .downloadedWithApp, + .downloadedFromWebsite, .downloadedWithApp, .spotlightMetadata, ]), ] public static let conditionKinds: [ConditionKind] = conditionKindGroups.flatMap(\.kinds) public static let actionKindGroups: [ActionKindGroup] = [ - ActionKindGroup(title: nil, kinds: [.moveToFolder, .copyToFolder, .rename, .uncompress]), + ActionKindGroup(title: nil, kinds: [.moveToFolder, .copyToFolder, .rename, .sortIntoSubfolder, .syncToFolder, .upload, .archive, .uncompress]), ActionKindGroup(title: "Tags", kinds: [.addTag, .removeTag, .setColorLabel]), - ActionKindGroup(title: "Automation", kinds: [.runScript, .runShortcut, .openApplication]), + ActionKindGroup(title: "Finder", kinds: [.addComment, .toggleExtension, .toggleLock, .open, .showInFinder, .makeAlias]), + ActionKindGroup(title: "Automation", kinds: [.runShortcut, .runAppleScript, .runJavaScript, .runAutomatorWorkflow, .runScript, .openApplication, .pause]), + ActionKindGroup(title: "Rule flow", kinds: [.runRulesOnFolderContents, .continueMatchingRules, .displayNotification, .ignore]), ActionKindGroup(title: "Disposal", kinds: [.moveToTrash, .delete]), ActionKindGroup(title: "Library", kinds: [.importToLibrary]), ] @@ -366,3 +456,20 @@ public enum RuleSchema { return kind.baseValueKind } } + +/// Encodes the key and comparison value for the advanced Spotlight metadata +/// condition in the existing single string column used by `Condition`. +public enum SpotlightMetadataCondition { + private static let separator = "\u{1F}" + + public static func parse(_ storedValue: String) -> (key: String, value: String)? { + let parts = storedValue.components(separatedBy: separator) + guard parts.count == 2, + !parts[0].trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } + return (parts[0], parts[1]) + } + + public static func make(key: String, value: String) -> String { + "\(key)\(separator)\(value)" + } +} diff --git a/Sources/ForelCore/Models/RuleTransfer.swift b/Sources/ForelCore/Models/RuleTransfer.swift new file mode 100644 index 0000000..339670d --- /dev/null +++ b/Sources/ForelCore/Models/RuleTransfer.swift @@ -0,0 +1,280 @@ +// Forel - A native macOS file-automation app +// Copyright (C) 2026 Lab421 + +import Foundation + +/// A non-fatal incompatibility discovered while reading another app's rules. +/// Rules with one or more issues are imported disabled so they can be reviewed +/// before Forel ever runs them. +public struct RuleTransferIssue: Equatable, Sendable { + public let ruleName: String + public let message: String + + public init(ruleName: String, message: String) { + self.ruleName = ruleName + self.message = message + } +} + +public struct RuleImportResult: Sendable { + public let rules: [Rule] + public let issues: [RuleTransferIssue] + + public init(rules: [Rule], issues: [RuleTransferIssue]) { + self.rules = rules + self.issues = issues + } +} + +public enum RuleTransferError: LocalizedError { + case unsupportedFile + case invalidNativeFile + + public var errorDescription: String? { + switch self { + case .unsupportedFile: return "This file is not a Forel or Hazel rule export." + case .invalidNativeFile: return "This Forel rule export is invalid or from a newer version of Forel." + } + } +} + +/// File interchange for rules. Forel exports a documented JSON format +/// (`.forelrules`) and imports both that format and Hazel's `.hazelrules` +/// keyed archives. Hazel's format is proprietary, therefore unsupported +/// predicates and actions are surfaced as issues instead of guessed. +public enum RuleTransfer { + private struct NativeExport: Codable { + let format: String + let version: Int + let rules: [Rule] + } + + public static func exportForel(_ rules: [Rule]) throws -> Data { + let export = NativeExport(format: "forel-rules", version: 1, rules: rules) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(export) + } + + public static func importRules(from data: Data, folderId: String) throws -> RuleImportResult { + if let native = try? JSONDecoder().decode(NativeExport.self, from: data) { + guard native.format == "forel-rules", native.version == 1 else { throw RuleTransferError.invalidNativeFile } + return RuleImportResult(rules: native.rules.map { remap($0, folderId: folderId) }, issues: []) + } + return try importHazel(data: data, folderId: folderId) + } + + private static func remap(_ source: Rule, folderId: String, disabled: Bool = false) -> Rule { + let id = UUID().uuidString + var rule = Rule( + id: id, + folderId: folderId, + name: source.name, + enabled: source.enabled && !disabled, + conditionMatch: source.conditionMatch, + recursionDepth: source.recursionDepth, + priority: source.priority + ) + rule.conditions = source.conditions.enumerated().map { + Condition(ruleId: id, kind: $0.element.kind, operator: $0.element.operator, value: $0.element.value) + } + rule.actions = source.actions.enumerated().map { + Action(ruleId: id, kind: $0.element.kind, params: $0.element.params, position: Int64($0.offset)) + } + return rule + } + + private static func importHazel(data: Data, folderId: String) throws -> RuleImportResult { + let archive = try HazelArchive(data: data) + guard let root = archive.root, + let archivedRules = archive.array(root["rules"]) else { throw RuleTransferError.unsupportedFile } + + var rules: [Rule] = [] + var issues: [RuleTransferIssue] = [] + for archivedRule in archivedRules { + guard let object = archivedRule as? [String: Any], + archive.className(object) == "HazelRule", + let name = archive.string(object["description"]), !name.isEmpty else { continue } + var localIssues: [RuleTransferIssue] = [] + var source = Rule(folderId: folderId, name: name, enabled: true) + source.conditionMatch = (archive.number(object["predicateType"]) == 0) ? .any : .all + + let criteria = archive.array(object["criteria"]) ?? [] + for criterion in criteria { + if let condition = hazelCondition(criterion, archive: archive, ruleId: source.id) { + source.conditions.append(condition) + } else { + localIssues.append(.init(ruleName: name, message: "Unsupported Hazel condition was skipped.")) + } + } + + for (position, archivedAction) in (archive.array(object["actions"]) ?? []).enumerated() { + if let action = hazelAction(archivedAction, archive: archive, ruleId: source.id, position: position) { + source.actions.append(action) + } else { + let type = archive.className(archivedAction as? [String: Any]) ?? "unknown action" + localIssues.append(.init(ruleName: name, message: "Hazel action \(type) was skipped because Forel cannot represent it.")) + } + } + if criteria.isEmpty { localIssues.append(.init(ruleName: name, message: "The Hazel rule has no conditions and matches every file.")) } + if source.actions.isEmpty { localIssues.append(.init(ruleName: name, message: "The Hazel rule has no supported actions.")) } + source.enabled = localIssues.isEmpty + source = remap(source, folderId: folderId, disabled: !localIssues.isEmpty) + rules.append(source) + issues.append(contentsOf: localIssues) + } + guard !rules.isEmpty else { throw RuleTransferError.unsupportedFile } + return RuleImportResult(rules: rules, issues: issues) + } + + private static func hazelCondition(_ raw: Any, archive: HazelArchive, ruleId: String) -> Condition? { + guard let predicate = raw as? [String: Any], + let key = archive.findString(predicate["NSLeftExpression"], key: "NSKeyPath"), + let value = archive.constantString(predicate["NSRightExpression"]), + let operatorType = archive.number((predicate["NSPredicateOperator"] as? [String: Any])?["NSOperatorType"]) else { return nil } + let kind: ConditionKind + switch key { + case "displayBasename", "displayName": kind = .name + case "displayExtensions", "extension": kind = .extension_ + case "dateCreated": kind = .createdAt + case "dateModified": kind = .dateModified + case "fileSize", "logicalSize": kind = .sizeBytes + case "comment": kind = .finderComment + case "path", "filePath": kind = .filePath + case "tags", "tagNames": kind = .tags + default: return nil + } + let negated = ((predicate["NSPredicateOperator"] as? [String: Any])?["NSNegate"] as? Bool) == true + let op: Operator? + switch operatorType { + case 0: op = .lessThan + case 2: op = .greaterThan + case 4: op = negated ? .isNot : .is + case 5: op = .isNot + case 6: op = negated ? nil : .matchesRegex + case 8: op = negated ? nil : .startsWith + case 9: op = negated ? nil : .endsWith + case 10, 11: op = negated ? .doesNotContain : .contains + default: op = nil + } + guard let op else { return nil } + return Condition(ruleId: ruleId, kind: kind, operator: op, value: value) + } + + private static func hazelAction(_ raw: Any, archive: HazelArchive, ruleId: String, position: Int) -> Action? { + guard let object = raw as? [String: Any], let type = archive.className(object) else { return nil } + let params = object["parameter"] + switch type { + case "HazelMoveAction": + if archive.className(params as? [String: Any]) == "HazelTrashFolder" { + return Action(ruleId: ruleId, kind: .moveToTrash, params: .object([:]), position: Int64(position)) + } + guard let destination = archive.findPath(params) else { return nil } + return Action(ruleId: ruleId, kind: .moveToFolder, params: .object([ActionParam.destination: .string(destination)]), position: Int64(position)) + case "HazelCopyAction": + guard let destination = archive.findPath(params) else { return nil } + return Action(ruleId: ruleId, kind: .copyToFolder, params: .object([ActionParam.destination: .string(destination)]), position: Int64(position)) + case "HazelTrashAction": + return Action(ruleId: ruleId, kind: .moveToTrash, params: .object([:]), position: Int64(position)) + case "HazelPauseAction": + guard let seconds = archive.number((params as? [String: Any])?["amount"]) else { return nil } + return Action(ruleId: ruleId, kind: .pause, params: .object([ActionParam.pauseSeconds: .number(seconds)]), position: Int64(position)) + case "HazelShellScriptAction": + guard let script = archive.string((params as? [String: Any])?["script"]) else { return nil } + let shell = archive.string((params as? [String: Any])?["shell"]) ?? "/bin/zsh" + return Action(ruleId: ruleId, kind: .runScript, params: .object(["script": .string(script), "shell": .string(shell)]), position: Int64(position)) + default: return nil + } + } +} + +/// Small, deliberately non-executing reader for an NSKeyedArchiver plist. +/// It resolves only plist values and class labels; it never instantiates Hazel +/// classes from an untrusted import file. +private struct HazelArchive { + let objects: [Any] + let root: [String: Any]? + + init(data: Data) throws { + guard let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any], + plist["$archiver"] as? String == "NSKeyedArchiver", + let objects = plist["$objects"] as? [Any], + let top = plist["$top"] as? [String: Any] else { throw RuleTransferError.unsupportedFile } + self.objects = objects + self.root = HazelArchive.resolve(top["root"], objects: objects) as? [String: Any] + } + + func array(_ value: Any?) -> [Any]? { + guard let dict = value as? [String: Any] else { return value as? [Any] } + return dict["NS.objects"] as? [Any] + } + + func className(_ value: [String: Any]?) -> String? { value?["__class"] as? String } + func string(_ value: Any?) -> String? { + if let string = value as? String, string != "$null" { return string } + if let data = value as? Data { return String(data: data, encoding: .utf8) } + return nil + } + func number(_ value: Any?) -> Double? { + if let number = value as? NSNumber { return number.doubleValue } + if let string = value as? String { return Double(string) } + if let decimal = value as? [String: Any], + let data = decimal["NS.mantissa"] as? Data { + let mantissa = data.prefix(8).enumerated().reduce(UInt64(0)) { partial, byte in + partial | (UInt64(byte.element) << UInt64(byte.offset * 8)) + } + let exponent = (decimal["NS.exponent"] as? NSNumber)?.intValue ?? 0 + let sign = (decimal["NS.negative"] as? Bool) == true ? -1.0 : 1.0 + return sign * Double(mantissa) * pow(10, Double(exponent)) + } + return nil + } + func constantString(_ value: Any?) -> String? { + guard let dict = value as? [String: Any] else { return string(value) } + return string(dict["NSConstantValue"]) ?? findString(value, key: "NSConstantValue") + } + func findString(_ value: Any?, key: String) -> String? { + guard let value else { return nil } + if let dict = value as? [String: Any] { + if let found = string(dict[key]) { return found } + for child in dict.values where findString(child, key: key) != nil { return findString(child, key: key) } + } else if let array = value as? [Any] { + for child in array where findString(child, key: key) != nil { return findString(child, key: key) } + } + return nil + } + func findPath(_ value: Any?) -> String? { + findString(value, key: "path") ?? findString(value, key: "displayName") ?? findString(value, key: "bookmark") + } + + private static func resolve(_ value: Any?, objects: [Any]) -> Any? { + guard let value else { return nil } + if let uid = uidValue(value), objects.indices.contains(uid) { + if uid == 0 { return nil } + return resolve(objects[uid], objects: objects) + } + if let dict = value as? [String: Any] { + var resolved = dict.compactMapValues { resolve($0, objects: objects) } + if let classObject = resolve(dict["$class"], objects: objects) as? [String: Any], + let name = classObject["$classname"] as? String { resolved["__class"] = name } + return resolved + } + if let array = value as? [Any] { return array.compactMap { resolve($0, objects: objects) } } + return value + } + + /// Binary plists surface keyed-archive references as Foundation's private + /// UID object, while XML fixtures use the public `CF$UID` dictionary. + /// Read its numeric value only; no archived class is ever instantiated. + private static func uidValue(_ value: Any) -> Int? { + if let dict = value as? [String: Any], let number = dict["CF$UID"] as? NSNumber { + return number.intValue + } + guard let object = value as? NSObject, + object.description.hasPrefix(" [Issue] { actions.compactMap { action in switch action.kind { - case .moveToFolder, .copyToFolder: + case .moveToFolder, .copyToFolder, .syncToFolder: if action.params[ActionParam.destination]?.stringValue?.trimmingCharacters(in: .whitespaces).isEmpty != false { return Issue(message: "Destination path cannot be empty") } + case .sortIntoSubfolder: + let subfolder = action.params[ActionParam.subfolder]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if subfolder.isEmpty || (subfolder as NSString).isAbsolutePath || subfolder.split(separator: "/").contains("..") { + return Issue(message: "Subfolder must be a relative path") + } + case .upload: + let url = action.params[ActionParam.uploadURL]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if URL(string: url)?.scheme == nil { + return Issue(message: "Upload URL must include a protocol") + } case .rename: if action.params[ActionParam.pattern]?.stringValue?.trimmingCharacters(in: .whitespaces).isEmpty != false { return Issue(message: "Rename pattern cannot be empty") @@ -56,6 +74,27 @@ public enum RuleValidator { if action.params[ActionParam.applicationPath]?.stringValue?.trimmingCharacters(in: .whitespaces).isEmpty != false { return Issue(message: "Application cannot be empty") } + case .pause: + guard case .number(let seconds) = action.params[ActionParam.pauseSeconds], + seconds.isFinite, seconds >= 0 else { + return Issue(message: "Pause duration must be a non-negative number of seconds") + } + case .addComment: + if action.params[ActionParam.comment]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false { + return Issue(message: "Comment cannot be empty") + } + case .runAppleScript, .runJavaScript, .runScript: + if action.params[ActionParam.script]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false { + return Issue(message: "Script cannot be empty") + } + case .runAutomatorWorkflow: + if action.params[ActionParam.workflowPath]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false { + return Issue(message: "Automator workflow cannot be empty") + } + case .makeAlias: + if action.params[ActionParam.aliasDestination]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false { + return Issue(message: "Alias destination cannot be empty") + } default: break } diff --git a/Sources/ForelCore/Persistence/Database.swift b/Sources/ForelCore/Persistence/Database.swift index a130ea0..b63b05d 100644 --- a/Sources/ForelCore/Persistence/Database.swift +++ b/Sources/ForelCore/Persistence/Database.swift @@ -481,7 +481,7 @@ public final class Database: @unchecked Sendable { folderId: stmt.columnText(1), name: stmt.columnText(2), enabled: stmt.columnBool(3), - conditionMatch: stmt.columnText(4) == "any" ? .any : .all, + conditionMatch: ConditionMatch(rawValue: stmt.columnText(4)) ?? .all, recursionDepth: depth >= 0 ? depth : nil, priority: stmt.columnInt64(6), createdAt: stmt.columnText(7) @@ -522,7 +522,7 @@ public final class Database: @unchecked Sendable { stmt.bind(2, rule.folderId) stmt.bind(3, rule.name) stmt.bind(4, bool: rule.enabled) - stmt.bind(5, rule.conditionMatch == .any ? "any" : "all") + stmt.bind(5, rule.conditionMatch.rawValue) stmt.bind(6, rule.recursionDepth ?? -1) stmt.bind(7, priority) stmt.bind(8, rule.createdAt) @@ -540,7 +540,7 @@ public final class Database: @unchecked Sendable { ) stmt.bind(1, rule.name) stmt.bind(2, bool: rule.enabled) - stmt.bind(3, rule.conditionMatch == .any ? "any" : "all") + stmt.bind(3, rule.conditionMatch.rawValue) stmt.bind(4, rule.recursionDepth ?? -1) stmt.bind(5, rule.priority) stmt.bind(6, rule.id) diff --git a/Tests/ForelCoreTests/ActionExecutorTests.swift b/Tests/ForelCoreTests/ActionExecutorTests.swift index b57d736..9344fcf 100644 --- a/Tests/ForelCoreTests/ActionExecutorTests.swift +++ b/Tests/ForelCoreTests/ActionExecutorTests.swift @@ -19,6 +19,35 @@ import Foundation @testable import ForelCore @Suite struct ActionExecutorTests { + @Test func expandedActionCatalogProducesPlans() throws { + let dir = TempDir() + let file = dir.file("report.txt", contents: "report") + let destination = dir.dir("Destination") + let actions: [Action] = [ + makeAction(.sortIntoSubfolder, .object([ActionParam.subfolder: .string("Sorted")])), + makeAction(.syncToFolder, .object([ActionParam.destination: .string(destination)])), + makeAction(.upload, .object([ActionParam.uploadURL: .string("sftp://example.com/report.txt")])), + makeAction(.addComment, .object([ActionParam.comment: .string("Reviewed")])), + makeAction(.toggleExtension, .object([:])), + makeAction(.toggleLock, .object([:])), + makeAction(.archive, .object([:])), + makeAction(.runAppleScript, .object([ActionParam.script: .string("return \"ok\"")])), + makeAction(.runJavaScript, .object([ActionParam.script: .string("\"ok\";")])), + makeAction(.runAutomatorWorkflow, .object([ActionParam.workflowPath: .string("/tmp/example.workflow")])), + makeAction(.open, .object([:])), + makeAction(.showInFinder, .object([:])), + makeAction(.makeAlias, .object([ActionParam.aliasDestination: .string(destination)])), + makeAction(.runRulesOnFolderContents, .object([:])), + makeAction(.continueMatchingRules, .object([:])), + makeAction(.displayNotification, .object([ActionParam.notificationTitle: .string("Done")])), + makeAction(.ignore, .object([:])), + ] + + for action in actions { + let plan = try ActionExecutor.plan(action, path: action.kind == .runRulesOnFolderContents ? dir.path : file) + #expect(plan.kind == action.kind) + } + } @Test func addAndRemoveTagUpdatesFinderTagXattrWithoutDuplicates() throws { let dir = TempDir() let file = dir.file("document.txt", contents: "hello") diff --git a/Tests/ForelCoreTests/ConditionEvaluatorTests.swift b/Tests/ForelCoreTests/ConditionEvaluatorTests.swift index cdbce40..40ee4f3 100644 --- a/Tests/ForelCoreTests/ConditionEvaluatorTests.swift +++ b/Tests/ForelCoreTests/ConditionEvaluatorTests.swift @@ -17,8 +17,29 @@ import Testing import Foundation import Darwin +import AppKit +import PDFKit @testable import ForelCore +private func writeTestImage(width: Int, height: Int, to path: String) { + guard let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: width, + pixelsHigh: height, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ), + let data = rep.bitmapData, + let png = rep.representation(using: .png, properties: [:]) else { return } + data.initialize(repeating: 255, count: rep.bytesPerRow * height) + try? png.write(to: URL(fileURLWithPath: path)) +} + @Suite struct ConditionEvaluatorTests { @Test func sizeConditionComparesParsedThresholds() throws { let dir = TempDir() @@ -33,6 +54,49 @@ import Darwin #expect(!ConditionEvaluator.evaluate(makeCondition(.sizeBytes, .greaterThan, "1 KB"), path: file)) } + @Test func finderCommentAndFilePathConditionsMatchText() throws { + let dir = TempDir() + let file = dir.file("invoice.txt") + try FinderTags.writeComment(file, "Paid by Acme") + + #expect(ConditionEvaluator.evaluate(makeCondition(.finderComment, .contains, "Acme"), path: file)) + #expect(ConditionEvaluator.evaluate(makeCondition(.finderComment, .doesNotContain, "Overdue"), path: file)) + #expect(ConditionEvaluator.evaluate(makeCondition(.filePath, .contains, dir.path), path: file)) + #expect(ConditionEvaluator.evaluate(makeCondition(.filePath, .endsWith, "invoice.txt"), path: file)) + } + + @Test func itemCountAndMediaDimensionConditionsCompareNumbers() throws { + let dir = TempDir() + let folder = dir.dir("Batch") + _ = (folder as NSString).appendingPathComponent("one.txt") + FileManager.default.createFile(atPath: (folder as NSString).appendingPathComponent("one.txt"), contents: Data()) + FileManager.default.createFile(atPath: (folder as NSString).appendingPathComponent("two.txt"), contents: Data()) + let imagePath = (dir.path as NSString).appendingPathComponent("image.png") + writeTestImage(width: 320, height: 180, to: imagePath) + + #expect(ConditionEvaluator.evaluate(makeCondition(.itemCount, .is, "2"), path: folder)) + #expect(ConditionEvaluator.evaluate(makeCondition(.itemCount, .greaterThan, "1"), path: folder)) + #expect(ConditionEvaluator.evaluate(makeCondition(.imageWidth, .is, "320"), path: imagePath)) + #expect(ConditionEvaluator.evaluate(makeCondition(.imageHeight, .lessThan, "200"), path: imagePath)) + } + + @Test func pdfPageCountComparesNumbers() throws { + let dir = TempDir() + let path = (dir.path as NSString).appendingPathComponent("document.pdf") + let document = PDFDocument() + let image = NSImage(size: NSSize(width: 20, height: 20)) + image.lockFocus() + NSColor.white.setFill() + NSRect(origin: .zero, size: image.size).fill() + image.unlockFocus() + document.insert(PDFPage(image: image)!, at: 0) + document.insert(PDFPage(image: image)!, at: 1) + #expect(document.write(to: URL(fileURLWithPath: path))) + + #expect(ConditionEvaluator.evaluate(makeCondition(.pdfPageCount, .is, "2"), path: path)) + #expect(ConditionEvaluator.evaluate(makeCondition(.pdfPageCount, .greaterThan, "1"), path: path)) + } + @Test func stringOperatorsWorkAcrossNameExtensionAndContents() throws { let dir = TempDir() let file = dir.file("invoice-2026.PDF", contents: "paid in full") diff --git a/Tests/ForelCoreTests/DatabaseTests.swift b/Tests/ForelCoreTests/DatabaseTests.swift index 09f2c36..62acb73 100644 --- a/Tests/ForelCoreTests/DatabaseTests.swift +++ b/Tests/ForelCoreTests/DatabaseTests.swift @@ -24,6 +24,16 @@ import SQLite3 try Database(path: ":memory:") } + @Test func menuBarIconPreferenceRoundTrips() throws { + let db = try makeDB() + + try db.setSetting("show_menu_bar_icon", "0") + #expect(try db.getSetting("show_menu_bar_icon") == "0") + + try db.setSetting("show_menu_bar_icon", "1") + #expect(try db.getSetting("show_menu_bar_icon") == "1") + } + @Test func ruleRoundTripPreservesTagAndColorVariants() throws { let db = try makeDB() let folder = WatchedFolder(path: "/tmp/forel-test-\(UUID().uuidString)") diff --git a/Tests/ForelCoreTests/RuleEngineTests.swift b/Tests/ForelCoreTests/RuleEngineTests.swift index f075078..6c73705 100644 --- a/Tests/ForelCoreTests/RuleEngineTests.swift +++ b/Tests/ForelCoreTests/RuleEngineTests.swift @@ -104,6 +104,106 @@ import Foundation #expect(matched == ["any contents-gated"]) } + @Test func noneConditionMatchRequiresEveryConditionToFailInPreviewAndRun() throws { + let dir = TempDir() + let file = dir.file("invoice.txt", contents: "paid") + let matchesNone = makeRule( + name: "matches none", + conditionMatch: .none, + conditions: [ + makeCondition(.name, .contains, "receipt"), + makeCondition(.contents, .contains, "refunded"), + ] + ) + let matchesOne = makeRule( + name: "matches one", + conditionMatch: .none, + conditions: [ + makeCondition(.name, .contains, "invoice"), + makeCondition(.contents, .contains, "refunded"), + ] + ) + + let preview = RuleEngine.previewFile(path: file, depth: 0, rules: [matchesNone, matchesOne]) + let run = RuleEngine.run(path: file, depth: 0, rules: [matchesNone, matchesOne], batchId: "batch") + + #expect(preview?.rules.map(\.ruleName) == ["matches none"]) + #expect(run.matched == ["matches none"]) + } + + @Test func finderCommentConditionMatchesInPreviewAndManualRun() throws { + let dir = TempDir() + let file = dir.file("invoice.txt") + try FinderTags.writeComment(file, "Ready to file") + let rule = makeRule( + name: "file ready invoices", + conditions: [makeCondition(.finderComment, .contains, "Ready")] + ) + + let preview = RuleEngine.previewFile(path: file, depth: 0, rules: [rule]) + let run = RuleEngine.run(path: file, depth: 0, rules: [rule], batchId: "batch") + + #expect(preview?.rules.map(\.ruleName) == ["file ready invoices"]) + #expect(run.matched == ["file ready invoices"]) + } + + @Test func pauseIsShownInPreviewAndDelaysManualExecution() throws { + let dir = TempDir() + let file = dir.file("invoice.txt") + let pause = makeAction(.pause, .object([ActionParam.pauseSeconds: .number(0.02)])) + let rule = makeRule(name: "pause", actions: [pause]) + + let previewStarted = Date() + let preview = RuleEngine.previewFile(path: file, depth: 0, rules: [rule]) + let previewElapsed = Date().timeIntervalSince(previewStarted) + + let runStarted = Date() + let run = RuleEngine.run(path: file, depth: 0, rules: [rule], batchId: "batch") + let runElapsed = Date().timeIntervalSince(runStarted) + + #expect(preview?.rules[0].actions.map(\.description) == ["Pause for 0.02 seconds"]) + #expect(previewElapsed < 0.01) + #expect(run.matched == ["pause"]) + #expect(run.history.map(\.actionKind) == [.pause]) + #expect(runElapsed >= 0.015) + } + + @Test func runRulesOnFolderContentsAppliesTheFullRuleListToChildren() throws { + let dir = TempDir() + let folder = dir.dir("Incoming") + let child = (folder as NSString).appendingPathComponent("document.txt") + FileManager.default.createFile(atPath: child, contents: Data()) + let descend = makeRule( + name: "descend", + conditions: [makeCondition(.kind, .is, "folder")], + actions: [makeAction(.runRulesOnFolderContents, .object([:]))] + ) + let tag = makeRule( + name: "tag text", + conditions: [makeCondition(.extension_, .is, "txt")], + actions: [makeAction(.addTag, .object([ActionParam.tags: .stringArray(["Processed"])]))] + ) + + let result = RuleEngine.run(path: folder, depth: 0, rules: [descend, tag], batchId: "batch", root: dir.path) + + #expect(result.matched == ["descend", "tag text"]) + #expect(result.history.map(\.actionKind) == [.runRulesOnFolderContents, .addTag]) + #expect(FinderTags.read(child).contains("Processed")) + } + + @Test func ignoreStopsThisItemFromReachingLaterRules() throws { + let dir = TempDir() + let file = dir.file("document.txt") + let ignore = makeRule(name: "ignore", actions: [makeAction(.ignore, .object([:]))]) + let tag = makeRule(name: "tag", actions: [makeAction(.addTag, .object([ActionParam.tags: .stringArray(["Wrong"])]))]) + + let result = RuleEngine.run(path: file, depth: 0, rules: [ignore, tag], batchId: "batch") + + #expect(result.matched == ["ignore"]) + #expect(result.history.map(\.actionKind) == [.ignore]) + #expect(!FinderTags.read(file).contains("Wrong")) + } + @Test func allConditionsCombineRegexWithCompleteFilenameExclusionsInPreviewAndRun() throws { let dir = TempDir() let destination = dir.dir("Processed") diff --git a/Tests/ForelCoreTests/RuleSchemaTests.swift b/Tests/ForelCoreTests/RuleSchemaTests.swift index 2dccea1..044bf76 100644 --- a/Tests/ForelCoreTests/RuleSchemaTests.swift +++ b/Tests/ForelCoreTests/RuleSchemaTests.swift @@ -46,7 +46,7 @@ import Foundation } @Test func hasOptionsMatchesActionsThatExposeAnOptionsPopover() { - let expectedWithOptions: Set = [.moveToFolder, .copyToFolder, .runShortcut, .openApplication, .rename, .importToLibrary, .uncompress] + let expectedWithOptions: Set = [.moveToFolder, .copyToFolder, .sortIntoSubfolder, .syncToFolder, .runShortcut, .openApplication, .rename, .importToLibrary, .uncompress] for kind in RuleSchema.actionKinds { #expect(kind.hasOptions == expectedWithOptions.contains(kind), "\(kind) hasOptions mismatch") } @@ -66,6 +66,15 @@ import Foundation #expect(RuleSchema.valueKind(for: .downloadedWithApp, operator: .is) == .appPicker) // The remaining user-facing metadata kind stays plain text. #expect(RuleSchema.valueKind(for: .downloadedFromWebsite, operator: .is) == .text) + #expect(RuleSchema.valueKind(for: .imageWidth, operator: .greaterThan) == .number) + #expect(RuleSchema.valueKind(for: .spotlightMetadata, operator: .contains) == .spotlightMetadata) + } + + @Test func spotlightMetadataConditionStoresKeyAndValueTogether() { + let value = SpotlightMetadataCondition.make(key: "kMDItemAuthors", value: "Ada") + #expect(SpotlightMetadataCondition.parse(value)?.key == "kMDItemAuthors") + #expect(SpotlightMetadataCondition.parse(value)?.value == "Ada") + #expect(SpotlightMetadataCondition.parse("kMDItemAuthors") == nil) } // MARK: - Engine handles every declared operator diff --git a/Tests/ForelCoreTests/RuleTransferTests.swift b/Tests/ForelCoreTests/RuleTransferTests.swift new file mode 100644 index 0000000..d41693b --- /dev/null +++ b/Tests/ForelCoreTests/RuleTransferTests.swift @@ -0,0 +1,83 @@ +// Forel - A native macOS file-automation app +// Copyright (C) 2026 Lab421 + +import Foundation +import Testing +@testable import ForelCore + +@Suite struct RuleTransferTests { + @Test func nativeExportRoundTripsRulesWithFreshIdentifiers() throws { + let sourceId = UUID().uuidString + let source = Rule( + id: sourceId, + folderId: "source-folder", + name: "Invoices", + enabled: true, + conditionMatch: .none, + recursionDepth: nil, + conditions: [Condition(ruleId: sourceId, kind: .filePath, operator: .contains, value: "/Archive")], + actions: [Action(ruleId: sourceId, kind: .addTag, params: .object([ActionParam.tags: .stringArray(["Filed"])]), position: 0)] + ) + + let data = try RuleTransfer.exportForel([source]) + let result = try RuleTransfer.importRules(from: data, folderId: "destination-folder") + + #expect(result.issues.isEmpty) + #expect(result.rules.count == 1) + #expect(result.rules[0].id != source.id) + #expect(result.rules[0].folderId == "destination-folder") + #expect(result.rules[0].conditionMatch == .none) + #expect(result.rules[0].conditions[0].ruleId == result.rules[0].id) + #expect(result.rules[0].actions[0].ruleId == result.rules[0].id) + } + + @Test func hazelImportMapsSupportedPartsAndDisablesIncompleteRules() throws { + let data = try PropertyListSerialization.data(fromPropertyList: hazelFixture(), format: .binary, options: 0) + let result = try RuleTransfer.importRules(from: data, folderId: "folder") + + #expect(result.rules.count == 2) + #expect(result.rules[0].name == "Archive G-code") + #expect(result.rules[0].enabled) + #expect(result.rules[0].conditions.map(\.kind) == [.extension_]) + #expect(result.rules[0].actions.map(\.kind) == [.moveToTrash]) + #expect(!result.rules[1].enabled) + #expect(result.issues.contains { $0.ruleName == "Needs review" }) + } + + private func hazelFixture() -> [String: Any] { + let uid: (Int) -> [String: Any] = { ["CF$UID": $0] } + return [ + "$archiver": "NSKeyedArchiver", + "$top": ["root": uid(1)], + "$objects": [ + "$null", + ["$class": uid(2), "rules": uid(3)], + ["$classname": "HazelRuleSet"], + ["$class": uid(4), "NS.objects": [uid(5), uid(11)]], + ["$classname": "NSArray"], + [ + "$class": uid(6), "description": "Archive G-code", "predicateType": 1, + "criteria": uid(7), "actions": uid(9), + ], + ["$classname": "HazelRule"], + ["$class": uid(4), "NS.objects": [uid(8)]], + [ + "NSLeftExpression": ["NSKeyPath": "displayExtensions"], + "NSRightExpression": ["NSConstantValue": ".gcode"], + "NSPredicateOperator": ["NSOperatorType": 4], + ], + ["$class": uid(4), "NS.objects": [uid(10)]], + ["$class": uid(16)], + [ + "$class": uid(12), "description": "Needs review", "predicateType": 1, + "criteria": uid(7), "actions": uid(13), + ], + ["$classname": "HazelRule"], + ["$class": uid(4), "NS.objects": [uid(14)]], + ["$class": uid(15)], + ["$classname": "HazelUnsupportedAction"], + ["$classname": "HazelTrashAction"], + ], + ] + } +} diff --git a/Tests/ForelCoreTests/RuleValidatorTests.swift b/Tests/ForelCoreTests/RuleValidatorTests.swift index 64ced6d..187853c 100644 --- a/Tests/ForelCoreTests/RuleValidatorTests.swift +++ b/Tests/ForelCoreTests/RuleValidatorTests.swift @@ -111,6 +111,24 @@ import Foundation #expect(RuleValidator.validate(actions).isEmpty) } + @Test func pauseRequiresANonNegativeDuration() { + let valid = [makeAction(.pause, .object([ActionParam.pauseSeconds: .number(0.5)]))] + let missing = [makeAction(.pause, .object([:]))] + let negative = [makeAction(.pause, .object([ActionParam.pauseSeconds: .number(-1)]))] + + #expect(RuleValidator.validate(valid).isEmpty) + #expect(RuleValidator.validate(missing) == [.init(message: "Pause duration must be a non-negative number of seconds")]) + #expect(RuleValidator.validate(negative) == [.init(message: "Pause duration must be a non-negative number of seconds")]) + } + + @Test func spotlightMetadataConditionRequiresAKeyAndValue() { + let valid = [makeCondition(.spotlightMetadata, .contains, SpotlightMetadataCondition.make(key: "kMDItemAuthors", value: "Ada"))] + let missingValue = [makeCondition(.spotlightMetadata, .contains, SpotlightMetadataCondition.make(key: "kMDItemAuthors", value: ""))] + + #expect(RuleValidator.validate(valid).isEmpty) + #expect(RuleValidator.validate(missingValue) == [.init(message: "Spotlight metadata needs both a key and a value")]) + } + @Test func openApplicationWithMissingApplicationReportsIssue() { let actions = [makeAction(.openApplication, .object([:]))] #expect(RuleValidator.validate(actions) == [.init(message: "Application cannot be empty")]) diff --git a/Tests/ForelCoreTests/WatcherCoordinatorTests.swift b/Tests/ForelCoreTests/WatcherCoordinatorTests.swift index c16efc9..426bc2e 100644 --- a/Tests/ForelCoreTests/WatcherCoordinatorTests.swift +++ b/Tests/ForelCoreTests/WatcherCoordinatorTests.swift @@ -61,6 +61,24 @@ import Foundation #expect(try db.listHistory().count == 1) } + @Test func watcherMatchesFinderCommentConditions() throws { + let db = try makeDB() + let dir = TempDir() + let file = dir.file("invoice.txt") + try FinderTags.writeComment(file, "Ready to file") + let folder = WatchedFolder(path: dir.path) + try db.insertFolder(folder) + var rule = makeRule(folderId: folder.id, name: "tag ready files") + rule.conditions = [makeCondition(.finderComment, .contains, "Ready", ruleId: rule.id)] + rule.actions = [makeAction(.addTag, .object([ActionParam.tags: .stringArray(["Ready"])]), ruleId: rule.id)] + try db.insertRule(rule) + + WatcherCoordinator(db: db).handle(path: file) + + #expect(FinderTags.read(file).contains("Ready")) + #expect(try db.listHistory().map(\.actionKind) == [.addTag]) + } + @Test func watcherActivityReportsAppliedActionsOnly() throws { let db = try makeDB() let dir = TempDir() @@ -110,6 +128,21 @@ import Foundation #expect(try db.listHistory()[0].status == .skipped) } + @Test func watcherExecutesPauseActions() throws { + let db = try makeDB() + let dir = TempDir() + let file = dir.file("a.txt") + let folder = WatchedFolder(path: dir.path) + try db.insertFolder(folder) + var rule = makeRule(folderId: folder.id, name: "pause") + rule.actions = [makeAction(.pause, .object([ActionParam.pauseSeconds: .number(0)]), ruleId: rule.id)] + try db.insertRule(rule) + + WatcherCoordinator(db: db).handle(path: file) + + #expect(try db.listHistory().map(\.actionKind) == [.pause]) + } + @Test func handleHonorsCompleteFilenameExclusionsCombinedWithRegex() throws { let db = try makeDB() let dir = TempDir() From 12c47745f9f42065b1291bb8905ee39eeb10b448 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Sat, 25 Jul 2026 11:17:57 -0700 Subject: [PATCH 2/2] Link to the repository from Settings --- CHANGELOG.md | 1 + Sources/ForelApp/Views/SettingsView.swift | 17 ++++++++++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b933fa..a990903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Forel are documented here. Format loosely follows ## [Unreleased] +- Settings now links to Forel's GitHub repository from the About screen. - Added a Settings option to hide Forel's menu bar icon. - Opening Forel from Finder or Spotlight now always brings its main window forward. - Added visible drag-and-drop action order controls so actions run in the sequence you set. diff --git a/Sources/ForelApp/Views/SettingsView.swift b/Sources/ForelApp/Views/SettingsView.swift index c063c07..f868a73 100644 --- a/Sources/ForelApp/Views/SettingsView.swift +++ b/Sources/ForelApp/Views/SettingsView.swift @@ -162,12 +162,19 @@ struct SettingsView: View { Group { SectionLabel(title: "About") GlassCard { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Forel").font(.system(size: 13, weight: .semibold)).foregroundStyle(ForelTheme.primaryText) - Text("Open-source file automation for macOS").font(.system(size: 11)).foregroundStyle(ForelTheme.secondaryText) + VStack(alignment: .leading, spacing: 10) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Forel").font(.system(size: 13, weight: .semibold)).foregroundStyle(ForelTheme.primaryText) + Text("Open-source file automation for macOS").font(.system(size: 11)).foregroundStyle(ForelTheme.secondaryText) + } + Spacer() + } + Link(destination: URL(string: "https://github.com/lab421/forel")!) { + Label("View on GitHub", systemImage: "arrow.up.right.square") } - Spacer() + .buttonStyle(.bordered) + .tint(ForelTheme.accent) } .padding(.vertical, 10) .padding(.horizontal, 14)