diff --git a/CHANGELOG.md b/CHANGELOG.md index faeef3e..72593bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Sources/ForelApp/AppModel.swift b/Sources/ForelApp/AppModel.swift index 962bbfb..76d18f8 100644 --- a/Sources/ForelApp/AppModel.swift +++ b/Sources/ForelApp/AppModel.swift @@ -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 @@ -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 @@ -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? init() throws { let appSupportRoot = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] @@ -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 @@ -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) @@ -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) @@ -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 }) { @@ -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)." + ) + } +} diff --git a/Sources/ForelApp/Views/SettingsView.swift b/Sources/ForelApp/Views/SettingsView.swift index 527fd84..4732503 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: "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) @@ -182,6 +188,10 @@ struct SettingsView: View { Binding(get: { model.showDockIcon }, set: { model.setShowDockIcon($0) }) } + private var watcherNotificationsBinding: Binding { + Binding(get: { model.watcherNotificationsEnabled }, set: { model.setWatcherNotificationsEnabled($0) }) + } + private var historyMaxDaysBinding: Binding { Binding(get: { model.historyMaxDays }, set: { model.setHistoryMaxDays($0) }) } diff --git a/Sources/ForelCore/Watcher/WatcherCoordinator.swift b/Sources/ForelCore/Watcher/WatcherCoordinator.swift index 522f301..a00490e 100644 --- a/Sources/ForelCore/Watcher/WatcherCoordinator.swift +++ b/Sources/ForelCore/Watcher/WatcherCoordinator.swift @@ -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 @@ -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 @@ -84,6 +91,7 @@ public final class WatcherCoordinator: @unchecked Sendable { try? db.insertHistoryEntries(history) } recordEvaluatedResultStates(history) + notifyActivity(from: history) } recordEvaluatedState(path) } @@ -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() + ) + } } diff --git a/Tests/ForelCoreTests/WatcherCoordinatorTests.swift b/Tests/ForelCoreTests/WatcherCoordinatorTests.swift index 74c90b6..c16efc9 100644 --- a/Tests/ForelCoreTests/WatcherCoordinatorTests.swift +++ b/Tests/ForelCoreTests/WatcherCoordinatorTests.swift @@ -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:") } @@ -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()