From ae5299eeb2f0375c00781dab36242ab2641112b3 Mon Sep 17 00:00:00 2001 From: Lionel Guichard Date: Mon, 22 Jun 2026 14:17:36 +0200 Subject: [PATCH] Added a Sync Folders action that mirrors --- CHANGELOG.md | 3 + Sources/ForelApp/AppModel.swift | 11 +- Sources/ForelApp/Views/RuleEditorView.swift | 37 +++- Sources/ForelCore/Engine/ActionExecutor.swift | 127 ++++++++++- Sources/ForelCore/Engine/RuleEngine.swift | 16 +- Sources/ForelCore/Models/Models.swift | 1 + Sources/ForelCore/Models/RuleSchema.swift | 10 +- Sources/ForelCore/Watcher/FileWatcher.swift | 19 +- .../Watcher/WatcherCoordinator.swift | 198 +++++++++++++++++- .../ForelCoreTests/ActionExecutorTests.swift | 101 +++++++++ Tests/ForelCoreTests/RuleEngineTests.swift | 51 +++++ Tests/ForelCoreTests/RuleSchemaTests.swift | 2 +- .../WatcherCoordinatorTests.swift | 85 ++++++++ 13 files changed, 634 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f053b79..2224942 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to Forel are documented here. Format loosely follows ## [Unreleased] +### Added +- Added a Sync Folders action that mirrors relative file changes between two folders, can run two-way, and can move counterpart files to the Trash when deletions are propagated. + ### Changed - Dry Run now shows each matched file's full path beneath its name. diff --git a/Sources/ForelApp/AppModel.swift b/Sources/ForelApp/AppModel.swift index df4efe4..d7fdd4f 100644 --- a/Sources/ForelApp/AppModel.swift +++ b/Sources/ForelApp/AppModel.swift @@ -304,6 +304,7 @@ final class AppModel: ObservableObject { } else { try db.insertRule(rule) } + if !paused { coordinator.refreshWatchedPaths() } reloadRules() } catch { showError(error) @@ -312,6 +313,7 @@ final class AppModel: ObservableObject { func deleteRule(_ rule: Rule) { try? db.deleteRule(rule.id) + if !paused { coordinator.refreshWatchedPaths() } reloadRules() } @@ -489,14 +491,17 @@ final class AppModel: ObservableObject { func togglePaused() { paused.toggle() try? db.setSetting("paused", paused ? "1" : "0") + if paused { + coordinator.removeAll() + return + } let allFolders = (try? db.listFolders()) ?? [] for folder in allFolders { - if paused { - coordinator.remove(folder.path) - } else if folder.enabled { + if folder.enabled { coordinator.add(folder.path) } } + coordinator.refreshWatchedPaths() } private func showNotice(title: String, message: String) { diff --git a/Sources/ForelApp/Views/RuleEditorView.swift b/Sources/ForelApp/Views/RuleEditorView.swift index de8614b..b39f03e 100644 --- a/Sources/ForelApp/Views/RuleEditorView.swift +++ b/Sources/ForelApp/Views/RuleEditorView.swift @@ -734,7 +734,7 @@ private struct ActionRow: View { @ViewBuilder private var actionParams: some View { switch action.kind { - case .moveToFolder, .copyToFolder: + case .moveToFolder, .copyToFolder, .syncFolders: FolderField(placeholder: "Destination folder", path: paramBinding(ActionParam.destination)) case .rename: RenamePatternEditor(pattern: paramBinding(ActionParam.pattern), cleanFileName: action.params[ActionParam.cleanFileName]?.boolValue == true) @@ -792,6 +792,9 @@ private struct ActionRow: View { var params: [String: JSONValue] = [:] if newKind == .importToLibrary { params[ActionParam.libraryType] = .string(LibraryType.music.rawValue) + } else if newKind == .syncFolders { + params[ActionParam.syncDirection] = .string(SyncDirection.twoWay.rawValue) + params[ActionParam.syncDeletePolicy] = .string(SyncDeletePolicy.moveToTrash.rawValue) } action = Action(id: action.id, ruleId: action.ruleId, kind: newKind, params: .object(params), position: action.position) } @@ -850,6 +853,8 @@ private struct ActionOptionsView: View { shortcutOptions case .moveToFolder, .copyToFolder, .importToLibrary: conflictResolutionOptions + case .syncFolders: + syncOptions case .rename: renameOptions default: @@ -894,6 +899,36 @@ private struct ActionOptionsView: View { } } + private var syncOptions: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text("Direction") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(ForelTheme.secondaryText) + + StringSelectMenu( + selection: paramBinding(ActionParam.syncDirection, defaultValue: SyncDirection.twoWay.rawValue), + options: SyncDirection.allCases.map(\.rawValue), + label: { value in SyncDirection(rawValue: value)?.label ?? value } + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + + VStack(alignment: .leading, spacing: 6) { + Text("Deletes") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(ForelTheme.secondaryText) + + StringSelectMenu( + selection: paramBinding(ActionParam.syncDeletePolicy, defaultValue: SyncDeletePolicy.moveToTrash.rawValue), + options: SyncDeletePolicy.allCases.map(\.rawValue), + label: { value in SyncDeletePolicy(rawValue: value)?.label ?? value } + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + private var renameOptions: some View { VStack(alignment: .leading, spacing: 6) { Toggle(isOn: cleanNameBinding) { diff --git a/Sources/ForelCore/Engine/ActionExecutor.swift b/Sources/ForelCore/Engine/ActionExecutor.swift index f4c04bf..005d521 100644 --- a/Sources/ForelCore/Engine/ActionExecutor.swift +++ b/Sources/ForelCore/Engine/ActionExecutor.swift @@ -137,6 +137,30 @@ public enum ShortcutInputMode: String, CaseIterable, Sendable { } } +public enum SyncDirection: String, CaseIterable, Sendable { + case twoWay = "two_way" + case mirrorToTarget = "mirror_to_target" + + public var label: String { + switch self { + case .twoWay: return "Two-way sync" + case .mirrorToTarget: return "Mirror to target" + } + } +} + +public enum SyncDeletePolicy: String, CaseIterable, Sendable { + case moveToTrash = "move_to_trash" + case keepOtherSide = "keep_other_side" + + public var label: String { + switch self { + case .moveToTrash: return "Move counterpart to Trash" + case .keepOtherSide: return "Keep counterpart" + } + } +} + public enum DryRunStatus: String, Codable, Equatable, Sendable { case wouldRun = "would_run" case wouldSkip = "would_skip" @@ -178,13 +202,15 @@ public struct ActionPlan: Equatable, Sendable { public enum ActionExecutor { /// Executes the action on the file at `path`, returning the new path and an /// `Undo` describing how to reverse it. - public static func execute(_ action: Action, path: String) throws -> Applied { + public static func execute(_ action: Action, path: String, root: String? = nil) throws -> Applied { switch action.kind { case .moveToFolder: let destDir = try stringParam(action, ActionParam.destination, "MoveToFolder") return try moveIntoDir(path: path, destDir: destDir, resolution: conflictResolution(action)) case .copyToFolder: return try copyToFolder(action, path: path) + case .syncFolders: + return try syncFolders(action, path: path, root: root) case .rename: return try renameFile(action, path: path) case .moveToTrash: @@ -237,6 +263,28 @@ public enum ActionExecutor { return Applied(newPath: path, undo: .none, copiedPath: dest) } + private static func syncFolders(_ action: Action, path: String, root: String?) throws -> Applied { + let sourceRoot = root ?? (path as NSString).deletingLastPathComponent + let syncTarget = try syncCounterpartPath(action, path: path, root: sourceRoot) + var isDirectory: ObjCBool = false + FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + + if isDirectory.boolValue { + var targetIsDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: syncTarget, isDirectory: &targetIsDirectory), !targetIsDirectory.boolValue { + _ = try syncResolvedTarget(action, naiveTarget: syncTarget) + } + try FileManager.default.createDirectory(atPath: syncTarget, withIntermediateDirectories: true) + return Applied(newPath: path, undo: .none, copiedPath: syncTarget) + } + + let target = try syncResolvedTarget(action, naiveTarget: syncTarget) + let parent = (target as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true) + try FileManager.default.copyItem(atPath: path, toPath: target) + return Applied(newPath: path, undo: .none, copiedPath: target) + } + /// Resolves the actual path a move/copy should write to, given the /// configured conflict resolution: `.rename` numbers the file instead; /// `.replace` sends whatever is already at `naiveDest` to the Trash @@ -781,7 +829,7 @@ public enum ActionExecutor { try plan(action, path: path).description } - public static func plan(_ action: Action, path: String) throws -> ActionPlan { + public static func plan(_ action: Action, path: String, root: String? = nil) throws -> ActionPlan { let fileName = (path as NSString).lastPathComponent switch action.kind { @@ -869,6 +917,42 @@ public enum ActionExecutor { copiedPath: target, isTerminal: false ) + case .syncFolders: + let sourceRoot = root ?? (path as NSString).deletingLastPathComponent + let naiveTarget = try syncCounterpartPath(action, path: path, root: sourceRoot) + let conflicts = FileManager.default.fileExists(atPath: naiveTarget) + var isDirectory: ObjCBool = false + FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + + if normalizedPath(path) == normalizedPath(naiveTarget) { + return ActionPlan( + kind: action.kind, + description: "Already synced", + sourcePath: path, + targetPath: naiveTarget, + status: .wouldSkip, + finalPath: path, + copiedPath: nil, + isTerminal: false + ) + } + + let description: String + if isDirectory.boolValue { + description = conflicts ? "Sync folder at \(naiveTarget)" : "Create synced folder at \(naiveTarget)" + } else { + description = conflicts ? "Sync to \(naiveTarget), updating existing file" : "Sync to \(naiveTarget)" + } + return ActionPlan( + kind: action.kind, + description: description, + sourcePath: path, + targetPath: naiveTarget, + status: .wouldRun, + finalPath: path, + copiedPath: naiveTarget, + isTerminal: false + ) case .rename: let pattern = action.params[ActionParam.pattern]?.stringValue ?? "" var newName = try applyRenamePattern(pattern, path: path) @@ -1046,11 +1130,48 @@ 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, .importToLibrary: + case .moveToFolder, .copyToFolder, .syncFolders, .moveToTrash, .delete, .runScript, .runShortcut, .importToLibrary: return true } } + public static func syncDirection(_ action: Action) -> SyncDirection { + guard let raw = action.params[ActionParam.syncDirection]?.stringValue else { return .twoWay } + return SyncDirection(rawValue: raw) ?? .twoWay + } + + public static func syncDeletePolicy(_ action: Action) -> SyncDeletePolicy { + guard let raw = action.params[ActionParam.syncDeletePolicy]?.stringValue else { return .moveToTrash } + return SyncDeletePolicy(rawValue: raw) ?? .moveToTrash + } + + public static func syncCounterpartPath(_ action: Action, path: String, root: String) throws -> String { + let targetRoot = try stringParam(action, ActionParam.destination, "SyncFolders") + if syncDirection(action) == .twoWay, let relative = relativePath(root: targetRoot, path: path) { + return (root as NSString).appendingPathComponent(relative) + } + if let relative = relativePath(root: root, path: path) { + return (targetRoot as NSString).appendingPathComponent(relative) + } + throw ActionError("Sync folders can only sync files inside the watched folder or target folder") + } + + private static func syncResolvedTarget(_ action: Action, naiveTarget: String) throws -> String { + let parent = (naiveTarget as NSString).deletingLastPathComponent + let fileName = (naiveTarget as NSString).lastPathComponent + return try resolveDestination(naiveDest: naiveTarget, dir: parent, fileName: fileName, resolution: .replace) + } + + private static func relativePath(root: String, path: String) -> String? { + let rootComponents = (normalizedPath(root) as NSString).pathComponents + let pathComponents = (normalizedPath(path) as NSString).pathComponents + guard pathComponents.count >= rootComponents.count else { return nil } + guard Array(pathComponents.prefix(rootComponents.count)) == rootComponents else { return nil } + let suffix = pathComponents.dropFirst(rootComponents.count) + guard !suffix.isEmpty else { return "" } + return NSString.path(withComponents: Array(suffix)) + } + private static func paramTags(_ action: Action) -> [String] { if let tags = action.params[ActionParam.tags]?.arrayValue { return tags.compactMap(\.stringValue) diff --git a/Sources/ForelCore/Engine/RuleEngine.swift b/Sources/ForelCore/Engine/RuleEngine.swift index a1e55d4..a94fbaf 100644 --- a/Sources/ForelCore/Engine/RuleEngine.swift +++ b/Sources/ForelCore/Engine/RuleEngine.swift @@ -132,7 +132,7 @@ public enum RuleEngine { guard !blockedRuleIds.contains(rule.id) else { continue } guard rule.enabled, ruleMatches(rule, path: currentPath, depth: currentDepth) else { continue } - let result = runActions(rule, path: currentPath, batchId: batchId) + let result = runActions(rule, path: currentPath, batchId: batchId, root: root) history.append(contentsOf: result.history) matched.append(rule.name) @@ -176,7 +176,7 @@ public enum RuleEngine { return (matched, history) } - public static func previewFile(path: String, depth: Int, rules: [Rule]) -> FilePreview? { + public static func previewFile(path: String, depth: Int, rules: [Rule], root: String? = nil) -> FilePreview? { struct PendingFile { let path: String let depth: Int @@ -200,7 +200,7 @@ public enum RuleEngine { let conditions = conditionPreviews(rule, path: currentPath) guard conditionResultsMatch(conditions.map(\.matched), rule.conditionMatch) else { continue } - let result = previewActions(rule, path: currentPath) + let result = previewActions(rule, path: currentPath, root: root) matchedRules.append( RulePreview( ruleId: rule.id, @@ -367,7 +367,7 @@ 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, root: String?) -> (history: [HistoryEntry], copiedPaths: [String], finalPath: String, isTerminal: Bool) { let sorted = rule.actions.sorted { $0.position < $1.position } var history: [HistoryEntry] = [] @@ -378,7 +378,7 @@ public enum RuleEngine { for action in sorted { let original = current do { - let actionPlan = try ActionExecutor.plan(action, path: current) + let actionPlan = try ActionExecutor.plan(action, path: current, root: root) switch actionPlan.status { case .wouldSkip: @@ -431,7 +431,7 @@ public enum RuleEngine { ) ) case .wouldRun: - let applied = try ActionExecutor.execute(action, path: current) + let applied = try ActionExecutor.execute(action, path: current, root: root) let resultPath: String if let copiedPath = applied.copiedPath { resultPath = copiedPath @@ -481,7 +481,7 @@ public enum RuleEngine { return (history, copiedPaths, current, stoppedOnTerminal) } - private static func previewActions(_ rule: Rule, path: String) -> (actions: [ActionPreview], copiedPaths: [String], finalPath: String, isTerminal: Bool) { + private static func previewActions(_ rule: Rule, path: String, root: String?) -> (actions: [ActionPreview], copiedPaths: [String], finalPath: String, isTerminal: Bool) { let sorted = rule.actions.sorted { $0.position < $1.position } var actions: [ActionPreview] = [] @@ -491,7 +491,7 @@ public enum RuleEngine { for action in sorted { do { - let plan = try ActionExecutor.plan(action, path: current) + let plan = try ActionExecutor.plan(action, path: current, root: root) actions.append( ActionPreview( kind: plan.kind, diff --git a/Sources/ForelCore/Models/Models.swift b/Sources/ForelCore/Models/Models.swift index 037cdf0..abcd97f 100644 --- a/Sources/ForelCore/Models/Models.swift +++ b/Sources/ForelCore/Models/Models.swift @@ -121,6 +121,7 @@ public struct Condition: Codable, Equatable, Sendable { public enum ActionKind: String, Codable, Equatable, Sendable { case moveToFolder = "move_to_folder" case copyToFolder = "copy_to_folder" + case syncFolders = "sync_folders" case rename case moveToTrash = "move_to_trash" case delete diff --git a/Sources/ForelCore/Models/RuleSchema.swift b/Sources/ForelCore/Models/RuleSchema.swift index 3ddf78d..13fe4d5 100644 --- a/Sources/ForelCore/Models/RuleSchema.swift +++ b/Sources/ForelCore/Models/RuleSchema.swift @@ -194,6 +194,8 @@ public enum FileKindCatalog { public enum ActionParam { public static let destination = "destination" public static let onConflict = "on_conflict" + public static let syncDirection = "sync_direction" + public static let syncDeletePolicy = "sync_delete_policy" public static let pattern = "pattern" public static let tags = "tags" public static let color = "color" @@ -234,6 +236,7 @@ public extension ActionKind { switch self { case .moveToFolder: return "Move to folder" case .copyToFolder: return "Copy to folder" + case .syncFolders: return "Sync folders" case .rename: return "Rename" case .moveToTrash: return "Move to Trash" case .delete: return "Delete" @@ -251,6 +254,7 @@ public extension ActionKind { switch self { case .moveToFolder: return "arrow.right.doc.on.clipboard" case .copyToFolder: return "doc.on.doc" + case .syncFolders: return "arrow.triangle.2.circlepath" case .rename: return "pencil" case .moveToTrash, .delete: return "trash" case .addTag, .removeTag: return "tag" @@ -267,7 +271,7 @@ public extension ActionKind { /// have none, instead of showing an empty "No options" popover. var hasOptions: Bool { switch self { - case .moveToFolder, .copyToFolder, .runShortcut, .rename, .importToLibrary: + case .moveToFolder, .copyToFolder, .syncFolders, .runShortcut, .rename, .importToLibrary: return true case .addTag, .removeTag, .setColorLabel, .runScript, .moveToTrash, .delete: return false @@ -278,7 +282,7 @@ public extension ActionKind { /// that take none (trash/delete). var params: [ActionParamSpec] { switch self { - case .moveToFolder, .copyToFolder: + case .moveToFolder, .copyToFolder, .syncFolders: return [ActionParamSpec(key: ActionParam.destination, kind: .folderPath)] case .rename: return [ActionParamSpec(key: ActionParam.pattern, kind: .renamePattern)] @@ -340,7 +344,7 @@ public enum RuleSchema { public static let conditionKinds: [ConditionKind] = conditionKindGroups.flatMap(\.kinds) public static let actionKindGroups: [ActionKindGroup] = [ - ActionKindGroup(title: nil, kinds: [.moveToFolder, .copyToFolder, .rename]), + ActionKindGroup(title: nil, kinds: [.moveToFolder, .copyToFolder, .syncFolders, .rename]), ActionKindGroup(title: "Tags", kinds: [.addTag, .removeTag, .setColorLabel]), ActionKindGroup(title: "Automation", kinds: [.runScript, .runShortcut]), ActionKindGroup(title: "Disposal", kinds: [.moveToTrash, .delete]), diff --git a/Sources/ForelCore/Watcher/FileWatcher.swift b/Sources/ForelCore/Watcher/FileWatcher.swift index 8487deb..8846b35 100644 --- a/Sources/ForelCore/Watcher/FileWatcher.swift +++ b/Sources/ForelCore/Watcher/FileWatcher.swift @@ -24,7 +24,12 @@ import Foundation /// with the updated set — same externally-visible behaviour as the old /// `WatcherCmd::Add`/`Remove` channel. public final class FileWatcher: @unchecked Sendable { - public typealias EventHandler = @Sendable (_ path: String) -> Void + public enum EventKind: Sendable { + case changed + case deleted + } + + public typealias EventHandler = @Sendable (_ path: String, _ kind: EventKind) -> Void private var onEvent: EventHandler private let lock = NSLock() @@ -55,6 +60,13 @@ public final class FileWatcher: @unchecked Sendable { if inserted { rebuildStream(paths: paths) } } + public func replaceAll(_ paths: Set) { + lock.lock() + watchedPaths = paths + lock.unlock() + rebuildStream(paths: paths) + } + public func remove(_ path: String) { lock.lock() watchedPaths.remove(path) @@ -113,11 +125,12 @@ public final class FileWatcher: @unchecked Sendable { for (index, path) in paths.enumerated() { let flag = flags[index] + let isDeleted = flag & UInt32(kFSEventStreamEventFlagItemRemoved) != 0 let isCreateOrRename = flag & UInt32(kFSEventStreamEventFlagItemCreated) != 0 || flag & UInt32(kFSEventStreamEventFlagItemRenamed) != 0 - guard isCreateOrRename else { continue } + guard isCreateOrRename || isDeleted else { continue } if SystemFileFilter.isExcluded((path as NSString).lastPathComponent) { continue } - handler(path) + handler(path, isDeleted ? .deleted : .changed) } } } diff --git a/Sources/ForelCore/Watcher/WatcherCoordinator.swift b/Sources/ForelCore/Watcher/WatcherCoordinator.swift index 0336b59..4abfb53 100644 --- a/Sources/ForelCore/Watcher/WatcherCoordinator.swift +++ b/Sources/ForelCore/Watcher/WatcherCoordinator.swift @@ -28,25 +28,59 @@ public final class WatcherCoordinator: @unchecked Sendable { public init(db: Database) { self.db = db var watcherRef: FileWatcher! - watcherRef = FileWatcher(onEvent: { _ in }) + watcherRef = FileWatcher(onEvent: { _, _ in }) self.watcher = watcherRef - self.watcher.replaceHandler { [weak self] path in - self?.handle(path: path) + self.watcher.replaceHandler { [weak self] path, kind in + self?.handle(path: path, kind: kind) } } - public func add(_ path: String) { watcher.add(path) } + public func add(_ path: String) { + watcher.add(path) + for target in syncTargets(forWatchedFolderPath: path) { + watcher.add(target) + } + } public func remove(_ path: String) { watcher.remove(path) } + public func removeAll() { watcher.replaceAll([]) } + public func refreshWatchedPaths() { + let paths = db.withLock { db -> Set in + let folders = ((try? db.listFolders()) ?? []).filter(\.enabled) + var paths = Set(folders.map(\.path)) + for folder in folders { + for target in syncTargets(for: folder, db: db) { + paths.insert(target) + } + } + return paths + } + watcher.replaceAll(paths) + } func handle(path: String) { + handle(path: path, kind: .changed) + } + + func handle(path: String, kind: FileWatcher.EventKind) { + if kind == .deleted { + handleDeleted(path: path) + return + } + + if !FileManager.default.fileExists(atPath: path) { + handleDeleted(path: path) + return + } + // A duplicate/coalesced FSEvent for a path a prior call already // moved away — common with FSEvents — would otherwise be replanned // (name/extension conditions don't require the file to exist) and // then fail at execution with a noisy "doesn't exist" entry. // Nothing meaningful can be evaluated against a path that's gone. - guard FileManager.default.fileExists(atPath: path) else { return } guard hasPathChangedSinceLastEvaluation(path) else { return } + if handleSyncTargetChange(path: path) { return } + guard let (folder, rules) = db.withLock({ db -> (WatchedFolder, [Rule])? in guard let folder = try? db.folderForPath(path) else { return nil } let rules = (try? db.listRules(folderId: folder.id)) ?? [] @@ -68,6 +102,160 @@ public final class WatcherCoordinator: @unchecked Sendable { recordEvaluatedState(path) } + private func handleSyncTargetChange(path: String) -> Bool { + guard let match = syncRuleMatch(forPath: path, includeWatchedRoot: false) else { return false } + guard ActionExecutor.syncDirection(match.action) == .twoWay else { return true } + guard hasPathChangedSinceLastEvaluation(path) else { return true } + + let batchId = UUID().uuidString + do { + let plan = try ActionExecutor.plan(match.action, path: path, root: match.folder.path) + guard plan.status == .wouldRun else { + insertSyncHistory(match: match, batchId: batchId, originalPath: path, resultPath: path, status: .skipped, message: plan.description, undo: .none) + recordEvaluatedState(path) + return true + } + let applied = try ActionExecutor.execute(match.action, path: path, root: match.folder.path) + insertSyncHistory(match: match, batchId: batchId, originalPath: path, resultPath: applied.copiedPath ?? applied.newPath, undo: applied.undo) + if let copiedPath = applied.copiedPath { recordEvaluatedState(copiedPath) } + recordEvaluatedState(path) + } catch { + insertSyncHistory(match: match, batchId: batchId, originalPath: path, resultPath: path, status: .failed, message: String(describing: error), undo: .none) + } + return true + } + + private func handleDeleted(path: String) { + guard let match = syncRuleMatch(forPath: path, includeWatchedRoot: true) else { return } + guard ActionExecutor.syncDeletePolicy(match.action) == .moveToTrash else { return } + + do { + let counterpart = try ActionExecutor.syncCounterpartPath(match.action, path: path, root: match.folder.path) + guard FileManager.default.fileExists(atPath: counterpart) else { return } + let trash = try moveToTrash(counterpart) + insertSyncHistory( + match: match, + batchId: UUID().uuidString, + originalPath: path, + resultPath: trash, + undo: .move(from: counterpart, to: trash) + ) + } catch { + insertSyncHistory( + match: match, + batchId: UUID().uuidString, + originalPath: path, + resultPath: path, + status: .failed, + message: String(describing: error), + undo: .none + ) + } + } + + private struct SyncRuleMatch { + let folder: WatchedFolder + let rule: Rule + let action: Action + } + + private func syncRuleMatch(forPath path: String, includeWatchedRoot: Bool) -> SyncRuleMatch? { + db.withLock { db in + let folders = ((try? db.listFolders()) ?? []).filter(\.enabled) + for folder in folders { + let rules = (try? db.listRules(folderId: folder.id)) ?? [] + for rule in rules where rule.enabled { + for action in rule.actions where action.kind == .syncFolders { + guard let target = action.params[ActionParam.destination]?.stringValue else { continue } + if includeWatchedRoot, isPathPrefix(folder.path, of: path) { + return SyncRuleMatch(folder: folder, rule: rule, action: action) + } + if isPathPrefix(target, of: path) { + return SyncRuleMatch(folder: folder, rule: rule, action: action) + } + } + } + } + return nil + } + } + + private func syncTargets(forWatchedFolderPath path: String) -> [String] { + db.withLock { db in + guard let folder = try? db.listFolders().first(where: { $0.enabled && $0.path == path }) else { return [] } + return syncTargets(for: folder, db: db) + } + } + + private func syncTargets(for folder: WatchedFolder, db: Database) -> [String] { + let rules = (try? db.listRules(folderId: folder.id)) ?? [] + return rules + .filter(\.enabled) + .flatMap(\.actions) + .filter { $0.kind == .syncFolders && ActionExecutor.syncDirection($0) == .twoWay } + .compactMap { $0.params[ActionParam.destination]?.stringValue } + } + + private func insertSyncHistory( + match: SyncRuleMatch, + batchId: String, + originalPath: String, + resultPath: String, + status: HistoryStatus = .applied, + message: String? = nil, + undo: Undo + ) { + let identity = FileFingerprint.identity(resultPath) + let entry = HistoryEntry( + batchId: batchId, + ruleId: match.rule.id, + ruleName: match.rule.name, + actionKind: match.action.kind, + originalPath: originalPath, + resultPath: resultPath, + undo: undo.toJSON(), + reversible: undo.isReversible, + status: status, + message: message, + resultVolumeId: identity?.volumeId, + resultFileId: identity?.fileId + ) + db.withLock { db in + try? db.insertHistoryEntries([entry]) + } + } + + private func moveToTrash(_ path: String) throws -> String { + let trash = (NSHomeDirectory() as NSString).appendingPathComponent(".Trash") + try FileManager.default.createDirectory(atPath: trash, withIntermediateDirectories: true) + let fileName = (path as NSString).lastPathComponent + let target = uniqueTrashPath(dir: trash, fileName: fileName) + try FileManager.default.moveItem(atPath: path, toPath: target) + return target + } + + private func uniqueTrashPath(dir: String, fileName: String) -> String { + let candidate = (dir as NSString).appendingPathComponent(fileName) + if !FileManager.default.fileExists(atPath: candidate) { return candidate } + let nsName = fileName as NSString + let stem = nsName.deletingPathExtension + let ext = nsName.pathExtension + var i = 1 + while true { + let newName = ext.isEmpty ? "\(stem) (\(i))" : "\(stem) (\(i)).\(ext)" + let candidate = (dir as NSString).appendingPathComponent(newName) + if !FileManager.default.fileExists(atPath: candidate) { return candidate } + i += 1 + } + } + + private func isPathPrefix(_ prefix: String, of path: String) -> Bool { + let prefixComponents = ((prefix as NSString).standardizingPath as NSString).pathComponents + let pathComponents = ((path as NSString).standardizingPath as NSString).pathComponents + guard pathComponents.count >= prefixComponents.count else { return false } + return Array(pathComponents.prefix(prefixComponents.count)) == prefixComponents + } + /// Whether `path` looks different from the last time the watcher fully /// evaluated it (same identity and fingerprint means nothing meaningful /// changed). Without this, an action that doesn't move the file out of diff --git a/Tests/ForelCoreTests/ActionExecutorTests.swift b/Tests/ForelCoreTests/ActionExecutorTests.swift index b95407c..b3054eb 100644 --- a/Tests/ForelCoreTests/ActionExecutorTests.swift +++ b/Tests/ForelCoreTests/ActionExecutorTests.swift @@ -19,6 +19,107 @@ import Foundation @testable import ForelCore @Suite struct ActionExecutorTests { + @Test func syncFoldersCopiesRelativePathToTarget() throws { + let dir = TempDir() + let sourceRoot = dir.dir("Source") + let sourceSubdir = (sourceRoot as NSString).appendingPathComponent("Docs") + try FileManager.default.createDirectory(atPath: sourceSubdir, withIntermediateDirectories: true) + let file = (sourceSubdir as NSString).appendingPathComponent("a.txt") + try "hello".write(toFile: file, atomically: true, encoding: .utf8) + let targetRoot = dir.dir("Target") + + let action = makeAction(.syncFolders, .object([ + ActionParam.destination: .string(targetRoot), + ActionParam.syncDirection: .string(SyncDirection.twoWay.rawValue), + ])) + + let plan = try ActionExecutor.plan(action, path: file, root: sourceRoot) + let expected = ((targetRoot as NSString).appendingPathComponent("Docs") as NSString).appendingPathComponent("a.txt") + #expect(plan.targetPath == expected) + + let applied = try ActionExecutor.execute(action, path: file, root: sourceRoot) + #expect(applied.copiedPath == expected) + #expect(try String(contentsOfFile: expected, encoding: .utf8) == "hello") + } + + @Test func syncFoldersTwoWayCopiesTargetChangeBackToWatchedRoot() throws { + let dir = TempDir() + let sourceRoot = dir.dir("Source") + let targetRoot = dir.dir("Target") + let file = (targetRoot as NSString).appendingPathComponent("a.txt") + try "target".write(toFile: file, atomically: true, encoding: .utf8) + + let action = makeAction(.syncFolders, .object([ + ActionParam.destination: .string(targetRoot), + ActionParam.syncDirection: .string(SyncDirection.twoWay.rawValue), + ])) + + let applied = try ActionExecutor.execute(action, path: file, root: sourceRoot) + let expected = (sourceRoot as NSString).appendingPathComponent("a.txt") + #expect(applied.copiedPath == expected) + #expect(try String(contentsOfFile: expected, encoding: .utf8) == "target") + } + + @Test func syncFoldersPrefersTargetRootWhenTargetIsNestedInWatchedRoot() throws { + let dir = TempDir() + let sourceRoot = dir.dir("Source") + let targetRoot = (sourceRoot as NSString).appendingPathComponent("DMG") + try FileManager.default.createDirectory(atPath: targetRoot, withIntermediateDirectories: true) + let file = (targetRoot as NSString).appendingPathComponent("a.txt") + try "target".write(toFile: file, atomically: true, encoding: .utf8) + + let action = makeAction(.syncFolders, .object([ + ActionParam.destination: .string(targetRoot), + ActionParam.syncDirection: .string(SyncDirection.twoWay.rawValue), + ])) + + let counterpart = try ActionExecutor.syncCounterpartPath(action, path: file, root: sourceRoot) + #expect(counterpart == (sourceRoot as NSString).appendingPathComponent("a.txt")) + #expect(counterpart != (targetRoot as NSString).appendingPathComponent("DMG/a.txt")) + } + + @Test func syncFoldersCreatesCounterpartDirectoryWithoutRecursiveCopy() throws { + let dir = TempDir() + let sourceRoot = dir.dir("Source") + let targetRoot = dir.dir("Target") + let sourceFolder = (sourceRoot as NSString).appendingPathComponent("RENALTO") + try FileManager.default.createDirectory(atPath: sourceFolder, withIntermediateDirectories: true) + try "nested".write(toFile: (sourceFolder as NSString).appendingPathComponent("a.txt"), atomically: true, encoding: .utf8) + + let action = makeAction(.syncFolders, .object([ + ActionParam.destination: .string(targetRoot), + ActionParam.syncDirection: .string(SyncDirection.twoWay.rawValue), + ])) + + let applied = try ActionExecutor.execute(action, path: sourceFolder, root: sourceRoot) + let counterpart = (targetRoot as NSString).appendingPathComponent("RENALTO") + #expect(applied.copiedPath == counterpart) + var isDirectory: ObjCBool = false + #expect(FileManager.default.fileExists(atPath: counterpart, isDirectory: &isDirectory)) + #expect(isDirectory.boolValue) + #expect(!FileManager.default.fileExists(atPath: (counterpart as NSString).appendingPathComponent("a.txt"))) + } + + @Test func syncFoldersUpdatesCounterpartWhenItAlreadyExists() throws { + let dir = TempDir() + let sourceRoot = dir.dir("Source") + let targetRoot = dir.dir("Target") + let source = (sourceRoot as NSString).appendingPathComponent("a.txt") + let existing = (targetRoot as NSString).appendingPathComponent("a.txt") + try "new".write(toFile: source, atomically: true, encoding: .utf8) + try "old".write(toFile: existing, atomically: true, encoding: .utf8) + + let action = makeAction(.syncFolders, .object([ + ActionParam.destination: .string(targetRoot), + ])) + + let applied = try ActionExecutor.execute(action, path: source, root: sourceRoot) + #expect(applied.copiedPath == existing) + #expect(try String(contentsOfFile: existing, encoding: .utf8) == "new") + let renamed = (targetRoot as NSString).appendingPathComponent("a (1).txt") + #expect(!FileManager.default.fileExists(atPath: renamed)) + } + @Test func addAndRemoveTagUpdatesFinderTagXattrWithoutDuplicates() throws { let dir = TempDir() let file = dir.file("document.txt", contents: "hello") diff --git a/Tests/ForelCoreTests/RuleEngineTests.swift b/Tests/ForelCoreTests/RuleEngineTests.swift index be92322..255cf18 100644 --- a/Tests/ForelCoreTests/RuleEngineTests.swift +++ b/Tests/ForelCoreTests/RuleEngineTests.swift @@ -110,6 +110,57 @@ import Foundation #expect(preview?.rules[0].actions.map(\.status) == [.wouldRun, .wouldRun]) } + @Test func previewSyncFoldersUpdatesExistingCounterpartWithoutExecuting() throws { + let dir = TempDir() + let sourceRoot = dir.dir("Source") + let targetRoot = dir.dir("Target") + let source = (sourceRoot as NSString).appendingPathComponent("note.txt") + let target = (targetRoot as NSString).appendingPathComponent("note.txt") + try "new".write(toFile: source, atomically: true, encoding: .utf8) + try "old".write(toFile: target, atomically: true, encoding: .utf8) + let rule = makeRule( + name: "sync", + actions: [makeAction(.syncFolders, .object([ + ActionParam.destination: .string(targetRoot), + ActionParam.syncDirection: .string(SyncDirection.twoWay.rawValue), + ]))] + ) + + let preview = RuleEngine.previewFile(path: source, depth: 0, rules: [rule], root: sourceRoot) + + #expect(preview?.rules[0].actions[0].kind == .syncFolders) + #expect(preview?.rules[0].actions[0].targetPath == target) + #expect(preview?.rules[0].actions[0].status == .wouldRun) + #expect(try String(contentsOfFile: target, encoding: .utf8) == "old") + #expect(!FileManager.default.fileExists(atPath: (targetRoot as NSString).appendingPathComponent("note (1).txt"))) + } + + @Test func runSyncFoldersUpdatesExistingCounterpartWithoutDuplicate() throws { + let dir = TempDir() + let sourceRoot = dir.dir("Source") + let targetRoot = dir.dir("Target") + let source = (sourceRoot as NSString).appendingPathComponent("note.txt") + let target = (targetRoot as NSString).appendingPathComponent("note.txt") + try "new".write(toFile: source, atomically: true, encoding: .utf8) + try "old".write(toFile: target, atomically: true, encoding: .utf8) + let rule = makeRule( + name: "sync", + actions: [makeAction(.syncFolders, .object([ + ActionParam.destination: .string(targetRoot), + ActionParam.syncDirection: .string(SyncDirection.twoWay.rawValue), + ]))] + ) + + let result = RuleEngine.run(path: source, depth: 0, rules: [rule], batchId: "batch", root: sourceRoot) + + #expect(result.history.count == 1) + #expect(result.history[0].actionKind == .syncFolders) + #expect(result.history[0].status == .applied) + #expect(result.history[0].resultPath == target) + #expect(try String(contentsOfFile: target, encoding: .utf8) == "new") + #expect(!FileManager.default.fileExists(atPath: (targetRoot as NSString).appendingPathComponent("note (1).txt"))) + } + @Test func previewFileShowsConditionResults() throws { let dir = TempDir() let file = dir.file("invoice.pdf", contents: "paid") diff --git a/Tests/ForelCoreTests/RuleSchemaTests.swift b/Tests/ForelCoreTests/RuleSchemaTests.swift index c48e17a..4d999ed 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, .rename, .importToLibrary] + let expectedWithOptions: Set = [.moveToFolder, .copyToFolder, .syncFolders, .runShortcut, .rename, .importToLibrary] for kind in RuleSchema.actionKinds { #expect(kind.hasOptions == expectedWithOptions.contains(kind), "\(kind) hasOptions mismatch") } diff --git a/Tests/ForelCoreTests/WatcherCoordinatorTests.swift b/Tests/ForelCoreTests/WatcherCoordinatorTests.swift index 2e0129e..0b699a5 100644 --- a/Tests/ForelCoreTests/WatcherCoordinatorTests.swift +++ b/Tests/ForelCoreTests/WatcherCoordinatorTests.swift @@ -181,4 +181,89 @@ import Foundation let numberedDuplicate = (pdfDir as NSString).appendingPathComponent("existing (1).pdf") #expect(!FileManager.default.fileExists(atPath: numberedDuplicate)) } + + @Test func syncFolderTargetChangeCopiesBackToWatchedRoot() throws { + let db = try makeDB() + let dir = TempDir() + let source = dir.dir("Source") + let target = dir.dir("Target") + let folder = WatchedFolder(path: source) + try db.insertFolder(folder) + var rule = makeRule(folderId: folder.id, name: "sync", recursionDepth: nil) + rule.actions = [makeAction(.syncFolders, .object([ + ActionParam.destination: .string(target), + ActionParam.syncDirection: .string(SyncDirection.twoWay.rawValue), + ]), position: 0, ruleId: rule.id)] + try db.insertRule(rule) + + let file = (target as NSString).appendingPathComponent("a.txt") + try "target".write(toFile: file, atomically: true, encoding: .utf8) + + let coordinator = WatcherCoordinator(db: db) + coordinator.handle(path: file, kind: .changed) + + let copied = (source as NSString).appendingPathComponent("a.txt") + #expect(try String(contentsOfFile: copied, encoding: .utf8) == "target") + let history = try db.listHistory() + #expect(history.count == 1) + #expect(history[0].actionKind == .syncFolders) + #expect(history[0].status == .applied) + } + + @Test func syncFolderDeletionMovesCounterpartToTrash() throws { + let db = try makeDB() + let dir = TempDir() + let source = dir.dir("Source") + let target = dir.dir("Target") + let folder = WatchedFolder(path: source) + try db.insertFolder(folder) + var rule = makeRule(folderId: folder.id, name: "sync", recursionDepth: nil) + rule.actions = [makeAction(.syncFolders, .object([ + ActionParam.destination: .string(target), + ActionParam.syncDirection: .string(SyncDirection.twoWay.rawValue), + ActionParam.syncDeletePolicy: .string(SyncDeletePolicy.moveToTrash.rawValue), + ]), position: 0, ruleId: rule.id)] + try db.insertRule(rule) + + let deleted = (source as NSString).appendingPathComponent("a.txt") + let counterpart = (target as NSString).appendingPathComponent("a.txt") + try "other".write(toFile: counterpart, atomically: true, encoding: .utf8) + + let coordinator = WatcherCoordinator(db: db) + coordinator.handle(path: deleted, kind: .deleted) + + #expect(!FileManager.default.fileExists(atPath: counterpart)) + let history = try db.listHistory() + #expect(history.count == 1) + #expect(history[0].actionKind == .syncFolders) + #expect(history[0].reversible) + } + + @Test func syncFolderMissingChangedEventIsTreatedAsDeletion() throws { + let db = try makeDB() + let dir = TempDir() + let source = dir.dir("Source") + let target = dir.dir("Target") + let folder = WatchedFolder(path: source) + try db.insertFolder(folder) + var rule = makeRule(folderId: folder.id, name: "sync", recursionDepth: nil) + rule.actions = [makeAction(.syncFolders, .object([ + ActionParam.destination: .string(target), + ActionParam.syncDirection: .string(SyncDirection.twoWay.rawValue), + ActionParam.syncDeletePolicy: .string(SyncDeletePolicy.moveToTrash.rawValue), + ]), position: 0, ruleId: rule.id)] + try db.insertRule(rule) + + let deleted = (source as NSString).appendingPathComponent("a.txt") + let counterpart = (target as NSString).appendingPathComponent("a.txt") + try "other".write(toFile: counterpart, atomically: true, encoding: .utf8) + + let coordinator = WatcherCoordinator(db: db) + coordinator.handle(path: deleted) + + #expect(!FileManager.default.fileExists(atPath: counterpart)) + let history = try db.listHistory() + #expect(history.count == 1) + #expect(history[0].actionKind == .syncFolders) + } }