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 @@ -37,6 +37,7 @@ All notable changes to Forel are documented here. Format loosely follows
## [1.0.3] - 2026-06-22

### Added
- Added grouped watcher notifications that summarize automatically applied rule actions without sending one alert per file, with a Settings toggle to turn them on or off.
- Added an Uncompress action for ZIP archives, with conflict handling and action chaining on the extracted item.
- Added the ability to change the path of an existing watched folder while keeping its rules.

Expand Down
103 changes: 103 additions & 0 deletions Sources/ForelApp/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ import Foundation
import ForelCore
import Combine
import AppKit
import UserNotifications

private let historyCleanupInterval: TimeInterval = 3600
private let watcherNotificationDelay: Duration = .seconds(5)

/// Central observable state for the SwiftUI app: owns the database, the
/// watcher coordinator, and the in-memory view of folders/rules. Mirrors the
Expand All @@ -46,6 +48,7 @@ final class AppModel: ObservableObject {
@Published var detailRoute: DetailRoute = .rules
@Published var accentPreset: AccentPreset = .default
@Published var showDockIcon: Bool = true
@Published var watcherNotificationsEnabled: Bool = true
@Published var historyMaxDays: Int = 30
/// Bumped whenever the accent colour changes, so views can force a full
/// re-render with `.id(model.accentVersion)` — `ForelTheme.accent` is a
Expand All @@ -64,6 +67,8 @@ final class AppModel: ObservableObject {
private let previewMatchLimit = 500
private let ruleRunStatsWindowDays = 30
private var historyCleanupTimer: AnyCancellable?
private var pendingWatcherNotification = PendingWatcherNotification()
private var watcherNotificationTask: Task<Void, Never>?

init() throws {
let appSupportRoot = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
Expand All @@ -75,6 +80,11 @@ final class AppModel: ObservableObject {
let db = try Database(path: dbPath)
self.db = db
self.coordinator = WatcherCoordinator(db: db)
self.coordinator.onActivity = { [weak self] summary in
Task { @MainActor in
self?.queueWatcherNotification(summary)
}
}
// Default to paused (watching off) on a fresh install — a brand-new
// user hasn't set up any rules yet, and starting to watch folders
// immediately means triggering permission prompts and risking rule-less
Expand All @@ -91,6 +101,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 storedWatcherNotificationsEnabled = db.withLock { db in try? db.getSetting("watcher_notifications_enabled") }
self.watcherNotificationsEnabled = storedWatcherNotificationsEnabled.map { $0 == "1" } ?? true

let storedMaxDays = db.withLock { db in (try? db.getSetting("history_max_days")).flatMap { Int($0) } }
self.historyMaxDays = min(max(storedMaxDays ?? 30, 1), 30)

Expand Down Expand Up @@ -154,6 +167,15 @@ final class AppModel: ObservableObject {
applyDockIconPreference(keepingWindowsVisible: true)
}

func setWatcherNotificationsEnabled(_ enabled: Bool) {
watcherNotificationsEnabled = enabled
db.withLock { db in try? db.setSetting("watcher_notifications_enabled", enabled ? "1" : "0") }
guard !enabled else { return }
watcherNotificationTask?.cancel()
watcherNotificationTask = nil
pendingWatcherNotification = PendingWatcherNotification()
}

func setAccentPreset(_ preset: AccentPreset) {
accentPreset = preset
ForelTheme.apply(preset)
Expand Down Expand Up @@ -185,6 +207,45 @@ final class AppModel: ObservableObject {
runHistoryCleanup()
}

private func queueWatcherNotification(_ summary: WatcherActivitySummary) {
guard watcherNotificationsEnabled else { return }
pendingWatcherNotification.add(summary)
guard watcherNotificationTask == nil else { return }
watcherNotificationTask = Task { [weak self] in
try? await Task.sleep(for: watcherNotificationDelay)
await self?.flushWatcherNotification()
}
}

private func flushWatcherNotification() async {
watcherNotificationTask = nil
guard watcherNotificationsEnabled else {
pendingWatcherNotification = PendingWatcherNotification()
return
}
guard let notification = pendingWatcherNotification.makeNotification() else { return }
pendingWatcherNotification = PendingWatcherNotification()

let center = UNUserNotificationCenter.current()
let settings = await center.notificationSettings()
if settings.authorizationStatus == .notDetermined {
_ = try? await center.requestAuthorization(options: [.alert, .sound])
}
guard (await center.notificationSettings()).authorizationStatus == .authorized else { return }

let content = UNMutableNotificationContent()
content.title = notification.title
content.body = notification.body
content.sound = .default

let request = UNNotificationRequest(
identifier: "forel-watcher-\(UUID().uuidString)",
content: content,
trigger: nil
)
try? await center.add(request)
}

func reloadFolders() {
folders = db.withLock { db in (try? db.listFolders()) ?? [] }
if let selectedFolderId, !folders.contains(where: { $0.id == selectedFolderId }) {
Expand Down Expand Up @@ -608,3 +669,45 @@ final class AppModel: ObservableObject {
errorMessage = message
}
}

private struct PendingWatcherNotification {
var actionCount = 0
var fileCount = 0
var ruleCounts: [String: Int] = [:]

mutating func add(_ summary: WatcherActivitySummary) {
actionCount += summary.actionCount
fileCount += summary.fileCount
for ruleName in summary.ruleNames {
ruleCounts[ruleName, default: 0] += 1
}
}

mutating func makeNotification() -> (title: String, body: String)? {
guard actionCount > 0 else { return nil }

let actionLabel = actionCount == 1 ? "action" : "actions"
let fileLabel = fileCount == 1 ? "file" : "files"
let title = "Forel applied \(actionCount) \(actionLabel)"

let ruleNames = ruleCounts
.sorted { lhs, rhs in
lhs.value == rhs.value ? lhs.key < rhs.key : lhs.value > rhs.value
}
.map(\.key)
let visibleRules = ruleNames.prefix(3)
let remainingRuleCount = max(0, ruleNames.count - visibleRules.count)
let rulesText: String
if visibleRules.isEmpty {
rulesText = "No rules"
} else {
rulesText = visibleRules.joined(separator: ", ")
+ (remainingRuleCount > 0 ? " and \(remainingRuleCount) more" : "")
}

return (
title,
"\(fileCount) \(fileLabel) processed. Rules: \(rulesText)."
)
}
}
10 changes: 10 additions & 0 deletions Sources/ForelApp/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ struct SettingsView: View {
isOn: dockIconBinding
)
Divider().overlay(ForelTheme.divider).padding(.leading, 14)
ToggleRow(
title: "Watcher notifications",
subtitle: "Notify when automatic rules process files",
isOn: watcherNotificationsBinding
)
Divider().overlay(ForelTheme.divider).padding(.leading, 14)
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("Keep history for").font(.system(size: 13, weight: .semibold)).foregroundStyle(ForelTheme.primaryText)
Expand Down Expand Up @@ -182,6 +188,10 @@ struct SettingsView: View {
Binding(get: { model.showDockIcon }, set: { model.setShowDockIcon($0) })
}

private var watcherNotificationsBinding: Binding<Bool> {
Binding(get: { model.watcherNotificationsEnabled }, set: { model.setWatcherNotificationsEnabled($0) })
}

private var historyMaxDaysBinding: Binding<Int> {
Binding(get: { model.historyMaxDays }, set: { model.setHistoryMaxDays($0) })
}
Expand Down
24 changes: 24 additions & 0 deletions Sources/ForelCore/Watcher/WatcherCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@

import Foundation

public struct WatcherActivitySummary: Equatable, Sendable {
public let actionCount: Int
public let fileCount: Int
public let ruleNames: [String]
}

/// Wires `FileWatcher` events to the database and rule engine: for every
/// created/renamed path, finds the owning watched folder, loads its rules,
/// evaluates them, and persists any resulting action history. Mirrors
Expand All @@ -26,6 +32,7 @@ public final class WatcherCoordinator: @unchecked Sendable {
private let activeProcessingLock = NSLock()
private var activeProcessingRoots: [String: Int] = [:]
public var onRuleMatched: (@Sendable (String, String) -> Void)?
public var onActivity: (@Sendable (WatcherActivitySummary) -> Void)?

public init(db: Database) {
self.db = db
Expand Down Expand Up @@ -84,6 +91,7 @@ public final class WatcherCoordinator: @unchecked Sendable {
try? db.insertHistoryEntries(history)
}
recordEvaluatedResultStates(history)
notifyActivity(from: history)
}
recordEvaluatedState(path)
}
Expand Down Expand Up @@ -204,7 +212,23 @@ public final class WatcherCoordinator: @unchecked Sendable {
try? db.insertHistoryEntries(history)
}
recordEvaluatedResultStates(history)
notifyActivity(from: history)
}
recordEvaluatedState(path)
}

private func notifyActivity(from history: [HistoryEntry]) {
guard let summary = Self.activitySummary(from: history) else { return }
onActivity?(summary)
}

static func activitySummary(from history: [HistoryEntry]) -> WatcherActivitySummary? {
let applied = history.filter { $0.status == .applied }
guard !applied.isEmpty else { return nil }
return WatcherActivitySummary(
actionCount: applied.count,
fileCount: Set(applied.map(\.originalPath)).count,
ruleNames: Array(Set(applied.map(\.ruleName))).sorted()
)
}
}
66 changes: 66 additions & 0 deletions Tests/ForelCoreTests/WatcherCoordinatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ import Foundation
@testable import ForelCore

@Suite struct WatcherCoordinatorTests {
private final class SummaryBox: @unchecked Sendable {
private let lock = NSLock()
private var values: [WatcherActivitySummary] = []

func append(_ summary: WatcherActivitySummary) {
lock.lock()
values.append(summary)
lock.unlock()
}

func snapshot() -> [WatcherActivitySummary] {
lock.lock()
defer { lock.unlock() }
return values
}
}

private func makeDB() throws -> Database {
try Database(path: ":memory:")
}
Expand All @@ -44,6 +61,55 @@ import Foundation
#expect(try db.listHistory().count == 1)
}

@Test func watcherActivityReportsAppliedActionsOnly() throws {
let db = try makeDB()
let dir = TempDir()
let file = dir.file("a.txt")
let destination = dir.dir("Archive")
let folder = WatchedFolder(path: dir.path)
try db.insertFolder(folder)
var rule = makeRule(folderId: folder.id, name: "archive")
rule.conditions = [makeCondition(.extension_, .is, "txt", ruleId: rule.id)]
rule.actions = [makeAction(.moveToFolder, .object(["destination": .string(destination)]), position: 0, ruleId: rule.id)]
try db.insertRule(rule)

let coordinator = WatcherCoordinator(db: db)
let summaries = SummaryBox()
coordinator.onActivity = { summary in
summaries.append(summary)
}

coordinator.handle(path: file)

#expect(summaries.snapshot() == [
WatcherActivitySummary(actionCount: 1, fileCount: 1, ruleNames: ["archive"])
])
}

@Test func watcherActivityDoesNotReportSkippedActions() 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: "already there")
rule.conditions = [makeCondition(.extension_, .is, "txt", ruleId: rule.id)]
rule.actions = [makeAction(.moveToFolder, .object(["destination": .string(dir.path)]), position: 0, ruleId: rule.id)]
try db.insertRule(rule)

let coordinator = WatcherCoordinator(db: db)
let summaries = SummaryBox()
coordinator.onActivity = { summary in
summaries.append(summary)
}

coordinator.handle(path: file)

#expect(summaries.snapshot().isEmpty)
#expect(try db.listHistory().count == 1)
#expect(try db.listHistory()[0].status == .skipped)
}

@Test func handleHonorsCompleteFilenameExclusionsCombinedWithRegex() throws {
let db = try makeDB()
let dir = TempDir()
Expand Down
Loading