Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable changes to Forel are documented here. Format loosely follows
## [Unreleased]

### Added
- Copy one or more rules to another watched folder without recreating their conditions and actions.
- Date conditions now support hours, with an "is not in the last" option for excluding recently added, created, or modified files.

### Fixed
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ File matching and processing happen **locally on your Mac**. Forel does not uplo
- **Content matching & OCR** — Match text inside documents, images, and scanned PDFs using local extraction and on-device OCR. See [Content matching](#content-matching) for supported formats and limits.
- **Folder watching** — Monitor folders in real time with native macOS FSEvents: up to 2 in Free, unlimited in Pro.
- **Dry Run & Run Now** — Preview matching files and planned actions without changing files, then run rules manually on existing files when ready.
- **Copy rules** — Copy a rule to another watched folder, or select several with ⌘-click and choose **Copy rules to folder…**. Copies are independent and keep their action destinations.
- **History & Undo** — Review action results and undo supported actions when it is safe to do so.
- **Menu bar app** — Forel lives in your menu bar. Toggle individual rules on/off without opening the main window.
- **Actions** — Move, copy, rename, tag, trash, delete, or run a custom script.
Expand Down Expand Up @@ -250,7 +251,7 @@ History / Undo (SQLite)
- [ ] Toggle extension hidden / visible
- [ ] Move folder based on criteria
- [ ] Scheduler — **Pro · Coming soon**
- [ ] Copy rules accros watching folders
- [x] Copy rules across watched folders
- [ ] Compress actions
- [ ] Sync actions — **Pro · Coming soon**
- [ ] Dynamic destination folders with date tokens (e.g. `~/Screenshots/{year}/{month}`)
Expand Down
8 changes: 8 additions & 0 deletions Sources/ForelApp/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,14 @@ final class AppModel: ObservableObject {
} catch { showError(error); return false }
}

func copyRules(_ ruleIDs: Set<String>, fromFolderID: String, toFolderID: String) throws {
_ = try db.withLock { db in
try db.copyRules(ruleIDs, fromFolderID: fromFolderID, toFolderID: toFolderID, isPro: isPro)
}
selectedFolderId = toFolderID
reloadRules()
}

func deleteRule(_ rule: Rule) {
db.withLock { db in try? db.deleteRule(rule.id) }
clearRuleExpansionState(rule.id)
Expand Down
89 changes: 89 additions & 0 deletions Sources/ForelApp/Views/CopyRulesSheet.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import SwiftUI
import ForelCore

struct CopyRulesSheet: View {
let ruleCount: Int
let sourceFolderID: String
let folders: [WatchedFolder]
let isPro: Bool
let onCopy: (String) throws -> Void
let onCancel: () -> Void
@State private var destinationFolderID = ""
@State private var errorMessage: String?

private var destinations: [WatchedFolder] {
folders.filter { $0.id != sourceFolderID }
}

private func isAllowed(_ folder: WatchedFolder) -> Bool {
FreePlan.allows(folderID: folder.id, folders: folders, isPro: isPro)
}

private var canCopy: Bool {
destinations.contains { $0.id == destinationFolderID && isAllowed($0) }
}

var body: some View {
VStack(alignment: .leading, spacing: 18) {
ViewHeader(
title: ruleCount == 1 ? "Copy Rule" : "Copy \(ruleCount) Rules",
subtitle: "Choose another watched folder",
systemImage: "doc.on.doc"
)

if destinations.isEmpty {
Text("Add another watched folder to copy rules to it.")
.foregroundStyle(ForelTheme.secondaryText)
} else {
Picker("Destination", selection: $destinationFolderID) {
Text("Choose a folder").tag("")
ForEach(destinations, id: \.id) { folder in
Text(isAllowed(folder) ? folder.path : "\(folder.path) — Pro")
.tag(folder.id)
.disabled(!isAllowed(folder))
}
}
.pickerStyle(.menu)

if !destinations.contains(where: isAllowed) {
Text("Reorder your watched folders or activate Pro to use these destinations.")
.font(.callout)
.foregroundStyle(ForelTheme.secondaryText)
}
}

Text("Copies are independent and keep their conditions, actions, and enabled state. Action destinations stay unchanged.")
.font(.callout)
.foregroundStyle(ForelTheme.secondaryText)
.fixedSize(horizontal: false, vertical: true)

if let errorMessage {
Text(errorMessage)
.font(.callout)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
}

HStack {
Spacer()
Button("Cancel", action: onCancel)
.buttonStyle(SecondaryButtonStyle())
.keyboardShortcut(.cancelAction)
Button("Copy") {
do { try onCopy(destinationFolderID) }
catch { errorMessage = error.localizedDescription }
}
.buttonStyle(PrimaryButtonStyle())
.keyboardShortcut(.defaultAction)
.disabled(!canCopy)
}
}
.padding(22)
.frame(width: 520)
.background(ForelTheme.background)
.onAppear {
destinationFolderID = destinations.first(where: isAllowed)?.id ?? ""
}
}
}
98 changes: 95 additions & 3 deletions Sources/ForelApp/Views/RuleListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import ForelCore
struct RuleListView: View {
@EnvironmentObject var model: AppModel
@State private var editingRule: Rule?
@State private var selectedRuleIDs: Set<String> = []
@State private var copyRequest: RuleCopyRequest?

private var selectedFolder: WatchedFolder? {
model.folders.first { $0.id == model.selectedFolderId }
Expand All @@ -39,7 +41,21 @@ struct RuleListView: View {
.sheet(item: $model.previewResult) { result in
PreviewSheet(result: result) { model.previewResult = nil }
}
.sheet(item: $copyRequest) { request in
CopyRulesSheet(
ruleCount: request.ruleIDs.count,
sourceFolderID: request.sourceFolderID,
folders: model.folders, isPro: model.isPro,
onCopy: { destination in
try model.copyRules(request.ruleIDs, fromFolderID: request.sourceFolderID, toFolderID: destination)
copyRequest = nil
},
onCancel: { copyRequest = nil }
)
}
}
.onChange(of: model.selectedFolderId) { _, _ in selectedRuleIDs.removeAll() }
.onChange(of: model.rules.map(\.id)) { _, ids in selectedRuleIDs.formIntersection(ids) }
}

private var content: some View {
Expand All @@ -64,6 +80,18 @@ struct RuleListView: View {
if !model.rules.isEmpty {
actionBar
}
if !selectedRuleIDs.isEmpty {
HStack {
Text("\(selectedRuleIDs.count) rule\(selectedRuleIDs.count == 1 ? "" : "s") selected")
.font(.caption)
.foregroundStyle(ForelTheme.secondaryText)
Button("Copy rules to folder…") { requestCopy(selectedRuleIDs) }
.buttonStyle(SecondaryButtonStyle())
Button("Deselect all") { selectedRuleIDs.removeAll() }
.buttonStyle(SecondaryButtonStyle())
Spacer()
}
}

if let runNowMessage = model.runNowMessageForSelectedFolder {
runNowBanner(runNowMessage)
Expand All @@ -77,7 +105,7 @@ struct RuleListView: View {
Spacer(minLength: 0)
}
} else {
List {
List(selection: $selectedRuleIDs) {
ForEach(Array(model.rules.enumerated()), id: \.element.id) { index, rule in
VStack(alignment: .leading, spacing: 4) {
if !model.selectedFolderIsAllowed || (!model.isPro && FreePlan.requiredFeature(for: rule) != nil) {
Expand All @@ -96,10 +124,14 @@ struct RuleListView: View {
rule: rule,
order: index + 1,
isExpanded: model.isRuleExpanded(rule),
isSelected: selectedRuleIDs.contains(rule.id),
onToggleExpanded: { model.toggleRuleExpanded(rule) },
onEdit: { editingRule = rule }
onEdit: { editingRule = rule },
onCopy: { requestCopy([rule.id]) }
)
}
.tag(rule.id)
.background(RuleListSelectionAppearance())
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0))
Expand All @@ -109,6 +141,10 @@ struct RuleListView: View {
.listStyle(.plain)
.scrollContentBackground(.hidden)
.scrollIndicators(.never)
.contextMenu(forSelectionType: String.self) { ids in
Button("Copy rules to folder…") { requestCopy(ids) }
.disabled(ids.isEmpty)
}
}
}
.animation(.easeInOut(duration: 0.25), value: model.runNowMessageForSelectedFolder)
Expand All @@ -121,6 +157,11 @@ struct RuleListView: View {
max(680, availableHeight - 64)
}

private func requestCopy(_ ruleIDs: Set<String>) {
guard let sourceFolderID = model.selectedFolderId, !ruleIDs.isEmpty else { return }
copyRequest = RuleCopyRequest(sourceFolderID: sourceFolderID, ruleIDs: ruleIDs)
}

private func moveRules(from source: IndexSet, to destination: Int) {
var ids = model.rules.map(\.id)
ids.move(fromOffsets: source, toOffset: destination)
Expand Down Expand Up @@ -313,8 +354,10 @@ private struct RuleCard: View {
let rule: Rule
let order: Int
let isExpanded: Bool
let isSelected: Bool
let onToggleExpanded: () -> Void
let onEdit: () -> Void
let onCopy: () -> Void

var body: some View {
VStack(alignment: .leading, spacing: 0) {
Expand Down Expand Up @@ -378,6 +421,11 @@ private struct RuleCard: View {

Spacer(minLength: 8)

Button(action: onCopy) {
Image(systemName: "doc.on.doc")
}
.buttonStyle(IconButtonStyle())

Button(action: onEdit) {
Image(systemName: "pencil")
}
Expand Down Expand Up @@ -405,7 +453,10 @@ private struct RuleCard: View {
.background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(ForelTheme.surface))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(isExpanded ? ForelTheme.accent.opacity(0.55) : ForelTheme.surfaceBorder)
.strokeBorder(
isSelected ? ForelTheme.accent : (isExpanded ? ForelTheme.accent.opacity(0.55) : ForelTheme.surfaceBorder),
lineWidth: isSelected ? 1.5 : 1
)
)
.opacity(rule.enabled && model.selectedFolderIsAllowed ? 1 : 0.6)
}
Expand All @@ -424,6 +475,47 @@ private struct RuleCard: View {
}
}

/// Keep native List selection and keyboard handling without drawing a second,
/// rectangular selection background behind the rounded rule cards.
private struct RuleListSelectionAppearance: NSViewRepresentable {
func makeNSView(context: Context) -> SelectionAppearanceView { SelectionAppearanceView() }

func updateNSView(_ view: SelectionAppearanceView, context: Context) {
view.removeNativeHighlight()
}

final class SelectionAppearanceView: NSView {
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
removeNativeHighlight()
}

override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
removeNativeHighlight()
}

override func hitTest(_ point: NSPoint) -> NSView? { nil }

func removeNativeHighlight() {
var ancestor = superview
while let view = ancestor {
if let table = view as? NSTableView {
table.selectionHighlightStyle = .none
return
}
ancestor = view.superview
}
}
}
}

private struct RuleCopyRequest: Identifiable {
let id = UUID()
let sourceFolderID: String
let ruleIDs: Set<String>
}

private struct RuleDetails: View {
let rule: Rule

Expand Down
14 changes: 14 additions & 0 deletions Sources/ForelCore/Models/RuleCopyError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Foundation

public enum RuleCopyError: Error, LocalizedError {
case noSelection, sameFolder, missingFolder, missingRules

public var errorDescription: String? {
switch self {
case .noSelection: "Select at least one rule to copy."
case .sameFolder: "Choose a different watched folder for the copies."
case .missingFolder: "A folder has been removed. Choose another watched folder."
case .missingRules: "Some selected rules no longer exist. Select the rules again."
}
}
}
Loading
Loading