Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
11 changes: 8 additions & 3 deletions Sources/ForelApp/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ final class AppModel: ObservableObject {
} else {
try db.insertRule(rule)
}
if !paused { coordinator.refreshWatchedPaths() }
reloadRules()
} catch {
showError(error)
Expand All @@ -312,6 +313,7 @@ final class AppModel: ObservableObject {

func deleteRule(_ rule: Rule) {
try? db.deleteRule(rule.id)
if !paused { coordinator.refreshWatchedPaths() }
reloadRules()
}

Expand Down Expand Up @@ -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) {
Expand Down
37 changes: 36 additions & 1 deletion Sources/ForelApp/Views/RuleEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -850,6 +853,8 @@ private struct ActionOptionsView: View {
shortcutOptions
case .moveToFolder, .copyToFolder, .importToLibrary:
conflictResolutionOptions
case .syncFolders:
syncOptions
case .rename:
renameOptions
default:
Expand Down Expand Up @@ -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) {
Expand Down
127 changes: 124 additions & 3 deletions Sources/ForelCore/Engine/ActionExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 8 additions & 8 deletions Sources/ForelCore/Engine/RuleEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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] = []
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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] = []
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions Sources/ForelCore/Models/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading