From 49c4977e9d253d2ca65844defadea130000bb610 Mon Sep 17 00:00:00 2001 From: zahirulAIIUB Date: Thu, 2 Jul 2026 08:45:07 +0600 Subject: [PATCH 1/2] feat(history): add automatic transcription history clearing (#463) Adds an 'Automatically Clear History' retention setting with Never, End of Day, After 7 Days, After 30 Days, and After 90 Days options. Expired entries and their saved audio are pruned at store load, before each new entry, at midnight via NSCalendarDayChanged, and immediately (behind a confirmation alert) when the user tightens the interval. Cutoffs are anchored to start-of-day so a retained day never partially expires. --- Fluid.xcodeproj/project.pbxproj | 4 + Sources/Fluid/Persistence/SettingsStore.swift | 78 +++++++++++++++++++ .../TranscriptionHistoryStore.swift | 44 +++++++++++ Sources/Fluid/UI/SettingsView.swift | 53 +++++++++++++ .../HistoryAutoClearIntervalTests.swift | 63 +++++++++++++++ 5 files changed, 242 insertions(+) create mode 100644 Tests/FluidDictationIntegrationTests/HistoryAutoClearIntervalTests.swift diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 1155efef..19f3846a 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */; }; 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; }; 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */; }; + B7F3D9A64E215C08A9E64D71 /* HistoryAutoClearIntervalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C4E7B209D34F86B1D52C40 /* HistoryAutoClearIntervalTests.swift */; }; 7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; }; 7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; }; 7CE006BD2E80EBE600DDCCD6 /* AppUpdater in Frameworks */ = {isa = PBXBuildFile; productRef = 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */; }; @@ -36,6 +37,7 @@ 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = ""; }; 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = ""; }; 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = ""; }; + A1C4E7B209D34F86B1D52C40 /* HistoryAutoClearIntervalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryAutoClearIntervalTests.swift; sourceTree = ""; }; 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = ""; }; 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = ""; }; 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; @@ -105,6 +107,7 @@ 7CDB0A262F3C4D5600FB7CAD /* Helpers */, 7CDB0A272F3C4D5600FB7CAD /* Resources */, 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */, + A1C4E7B209D34F86B1D52C40 /* HistoryAutoClearIntervalTests.swift */, 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */, 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */, ); @@ -260,6 +263,7 @@ files = ( 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */, 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */, + B7F3D9A64E215C08A9E64D71 /* HistoryAutoClearIntervalTests.swift in Sources */, 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */, 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */, ); diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index a1ed50b8..227ec9f9 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -2721,6 +2721,83 @@ final class SettingsStore: ObservableObject { DictationAudioHistoryStore.bytes(forGigabytes: self.audioHistoryBudgetGB) } + enum HistoryAutoClearInterval: String, CaseIterable, Identifiable, Codable { + case never + case endOfDay + case afterWeek + case afterMonth + case afterQuarter + + var id: String { + self.rawValue + } + + var displayName: String { + switch self { + case .never: + return "Never" + case .endOfDay: + return "End of Day" + case .afterWeek: + return "After 7 Days" + case .afterMonth: + return "After 30 Days" + case .afterQuarter: + return "After 90 Days" + } + } + + var description: String { + switch self { + case .never: + return "Keep transcription history until you delete it manually." + case .endOfDay: + return "Clears previous days' entries at midnight, keeping only today's history." + case .afterWeek: + return "Deletes history entries older than 7 days." + case .afterMonth: + return "Deletes history entries older than 30 days." + case .afterQuarter: + return "Deletes history entries older than 90 days." + } + } + + /// Entries with a timestamp before this date are expired. Nil means keep forever. + /// Anchored to start-of-day so a full retained day never partially expires. + func cutoffDate(relativeTo now: Date = Date(), calendar: Calendar = .current) -> Date? { + let retainedDays: Int + switch self { + case .never: + return nil + case .endOfDay: + retainedDays = 0 + case .afterWeek: + retainedDays = 7 + case .afterMonth: + retainedDays = 30 + case .afterQuarter: + retainedDays = 90 + } + return calendar.date(byAdding: .day, value: -retainedDays, to: calendar.startOfDay(for: now)) + } + } + + /// How long transcription history is retained before it is automatically cleared + var historyAutoClearInterval: HistoryAutoClearInterval { + get { + guard let raw = self.defaults.string(forKey: Keys.historyAutoClearInterval), + let interval = HistoryAutoClearInterval(rawValue: raw) + else { + return .never + } + return interval + } + set { + objectWillChange.send() + self.defaults.set(newValue.rawValue, forKey: Keys.historyAutoClearInterval) + } + } + /// Whether to show a native notification when AI post-processing fails and raw text is used var notifyAIProcessingFailures: Bool { get { @@ -4444,6 +4521,7 @@ private extension SettingsStore { static let saveTranscriptionHistory = "SaveTranscriptionHistory" static let saveAudioWithTranscriptionHistory = "SaveAudioWithTranscriptionHistory" static let audioHistoryBudgetGB = "AudioHistoryBudgetGB" + static let historyAutoClearInterval = "HistoryAutoClearInterval" static let notifyAIProcessingFailures = "NotifyAIProcessingFailures" // Filler Words diff --git a/Sources/Fluid/Persistence/TranscriptionHistoryStore.swift b/Sources/Fluid/Persistence/TranscriptionHistoryStore.swift index 0656a392..b1124a2a 100644 --- a/Sources/Fluid/Persistence/TranscriptionHistoryStore.swift +++ b/Sources/Fluid/Persistence/TranscriptionHistoryStore.swift @@ -157,8 +157,23 @@ final class TranscriptionHistoryStore: ObservableObject { @Published private(set) var entries: [TranscriptionHistoryEntry] = [] @Published var selectedEntryID: UUID? + private var dayChangeObserver: NSObjectProtocol? + private init() { self.loadEntries() + self.pruneExpiredEntries() + + // Re-check retention at midnight so long-running sessions honor + // the "End of Day" interval without waiting for the next dictation. + self.dayChangeObserver = NotificationCenter.default.addObserver( + forName: .NSCalendarDayChanged, + object: nil, + queue: .main + ) { _ in + Task { @MainActor in + TranscriptionHistoryStore.shared.pruneExpiredEntries() + } + } } // MARK: - Public Methods @@ -185,6 +200,8 @@ final class TranscriptionHistoryStore: ObservableObject { // Skip empty transcriptions guard !processedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + self.pruneExpiredEntries() + let entry = TranscriptionHistoryEntry( id: id, timestamp: timestamp, @@ -240,6 +257,33 @@ final class TranscriptionHistoryStore: ObservableObject { self.saveEntries() } + /// Delete entries older than the auto-clear retention window, including + /// their saved audio. No-op when the interval is set to Never. + @discardableResult + func pruneExpiredEntries(reference now: Date = Date()) -> Int { + guard let cutoff = SettingsStore.shared.historyAutoClearInterval.cutoffDate(relativeTo: now) else { + return 0 + } + + let expired = self.entries.filter { $0.timestamp < cutoff } + guard !expired.isEmpty else { return 0 } + + for entry in expired { + if let audio = entry.audio { + DictationAudioHistoryStore.shared.deleteAudio(fileName: audio.fileName) + } + } + self.entries.removeAll { $0.timestamp < cutoff } + + if let selected = selectedEntryID, !self.entries.contains(where: { $0.id == selected }) { + self.selectedEntryID = self.entries.first?.id + } + + self.saveEntries() + DebugLogger.shared.info("Auto-cleared \(expired.count) expired transcription history entries", source: "TranscriptionHistoryStore") + return expired.count + } + /// Clear all history func clearAllHistory() { DictationAudioHistoryStore.shared.deleteAllAudioFiles() diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index aab5624d..f8cb5e83 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -899,6 +899,9 @@ struct SettingsView: View { Divider().opacity(0.2) } + self.historyAutoClearRow + Divider().opacity(0.2) + self.optionToggleRow( title: "Notify AI Enhancement Failures", description: "Show a macOS notification when AI Enhancement fails and raw transcription is typed.", @@ -2323,6 +2326,56 @@ struct SettingsView: View { } } +private extension SettingsView { + var historyAutoClearRow: some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 2) { + Text("Automatically Clear History") + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + Text(SettingsStore.shared.historyAutoClearInterval.description) + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.settingsSecondaryText) + } + + Spacer() + + Picker("", selection: Binding( + get: { SettingsStore.shared.historyAutoClearInterval }, + set: { self.applyHistoryAutoClearInterval($0) } + )) { + ForEach(SettingsStore.HistoryAutoClearInterval.allCases) { interval in + Text(interval.displayName).tag(interval) + } + } + .pickerStyle(.menu) + .frame(width: 170, alignment: .trailing) + } + .disabled(!SettingsStore.shared.saveTranscriptionHistory) + } + + func applyHistoryAutoClearInterval(_ interval: SettingsStore.HistoryAutoClearInterval) { + if let cutoff = interval.cutoffDate() { + let expiredCount = TranscriptionHistoryStore.shared.entries.filter { $0.timestamp < cutoff }.count + if expiredCount > 0 { + let confirm = NSAlert() + confirm.messageText = "Clear older history now?" + confirm.informativeText = """ + This deletes \(expiredCount) history \(expiredCount == 1 ? "entry" : "entries") older than the selected period, including any saved audio. Stats and streaks based on those entries are affected. + """ + confirm.alertStyle = .warning + confirm.addButton(withTitle: "Apply and Clear") + confirm.addButton(withTitle: "Cancel") + guard confirm.runModal() == .alertFirstButtonReturn else { return } + } + } + + SettingsStore.shared.historyAutoClearInterval = interval + TranscriptionHistoryStore.shared.pruneExpiredEntries() + self.refreshAudioHistoryUsage() + } +} + private final class SettingsPersistentScroller: NSScroller { override static var isCompatibleWithOverlayScrollers: Bool { false diff --git a/Tests/FluidDictationIntegrationTests/HistoryAutoClearIntervalTests.swift b/Tests/FluidDictationIntegrationTests/HistoryAutoClearIntervalTests.swift new file mode 100644 index 00000000..735a54e2 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/HistoryAutoClearIntervalTests.swift @@ -0,0 +1,63 @@ +@testable import FluidVoice_Debug +import Foundation +import XCTest + +final class HistoryAutoClearIntervalTests: XCTestCase { + private var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone.gmt + return calendar + } + + private func date(_ year: Int, _ month: Int, _ day: Int, hour: Int = 0, minute: Int = 0) throws -> Date { + let components = DateComponents(year: year, month: month, day: day, hour: hour, minute: minute) + return try XCTUnwrap(self.calendar.date(from: components)) + } + + func testNeverHasNoCutoff() { + XCTAssertNil(SettingsStore.HistoryAutoClearInterval.never.cutoffDate(relativeTo: Date(), calendar: self.calendar)) + } + + func testEndOfDayCutoffIsStartOfToday() throws { + let now = try self.date(2026, 7, 2, hour: 15, minute: 30) + + let cutoff = SettingsStore.HistoryAutoClearInterval.endOfDay.cutoffDate(relativeTo: now, calendar: self.calendar) + + XCTAssertEqual(cutoff, try self.date(2026, 7, 2)) + } + + func testEndOfDayKeepsTodayAndExpiresYesterday() throws { + let now = try self.date(2026, 7, 2, hour: 9) + let cutoff = try XCTUnwrap( + SettingsStore.HistoryAutoClearInterval.endOfDay.cutoffDate(relativeTo: now, calendar: self.calendar) + ) + + let earlierToday = try self.date(2026, 7, 2, hour: 0, minute: 1) + let lateYesterday = try self.date(2026, 7, 1, hour: 23, minute: 59) + + XCTAssertFalse(earlierToday < cutoff, "Today's entries must survive an end-of-day prune") + XCTAssertTrue(lateYesterday < cutoff, "Yesterday's entries must expire after midnight") + } + + func testRollingIntervalsAnchorToStartOfDay() throws { + let now = try self.date(2026, 7, 2, hour: 18, minute: 45) + + XCTAssertEqual( + SettingsStore.HistoryAutoClearInterval.afterWeek.cutoffDate(relativeTo: now, calendar: self.calendar), + try self.date(2026, 6, 25) + ) + XCTAssertEqual( + SettingsStore.HistoryAutoClearInterval.afterMonth.cutoffDate(relativeTo: now, calendar: self.calendar), + try self.date(2026, 6, 2) + ) + XCTAssertEqual( + SettingsStore.HistoryAutoClearInterval.afterQuarter.cutoffDate(relativeTo: now, calendar: self.calendar), + try self.date(2026, 4, 3) + ) + } + + func testUnknownPersistedValueFallsBackSafely() { + XCTAssertNil(SettingsStore.HistoryAutoClearInterval(rawValue: "sometimes")) + XCTAssertEqual(SettingsStore.HistoryAutoClearInterval(rawValue: "endOfDay"), .endOfDay) + } +} From d661e843b021634e02bbea92a3f7e6665896e984 Mon Sep 17 00:00:00 2001 From: zahirulAIIUB Date: Thu, 2 Jul 2026 09:08:36 +0600 Subject: [PATCH 2/2] fix(history): include auto-clear interval in settings backup Adds historyAutoClearInterval to SettingsBackupPayload as an optional field so restoring a backup preserves the retention policy instead of silently falling back to Never. Older backups without the key decode as nil and leave the current setting untouched. --- Sources/Fluid/Persistence/BackupService.swift | 1 + Sources/Fluid/Persistence/SettingsStore.swift | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index 9ca1ced3..66f81e1b 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -68,6 +68,7 @@ struct SettingsBackupPayload: Codable, Equatable { let saveTranscriptionHistory: Bool let saveAudioWithTranscriptionHistory: Bool? let audioHistoryBudgetGB: Double? + let historyAutoClearInterval: SettingsStore.HistoryAutoClearInterval? let notifyAIProcessingFailures: Bool? let weekendsDontBreakStreak: Bool let fillerWords: [String] diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 227ec9f9..f711a99d 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -2869,6 +2869,7 @@ final class SettingsStore: ObservableObject { saveTranscriptionHistory: self.saveTranscriptionHistory, saveAudioWithTranscriptionHistory: self.saveAudioWithTranscriptionHistory, audioHistoryBudgetGB: self.audioHistoryBudgetGB, + historyAutoClearInterval: self.historyAutoClearInterval, notifyAIProcessingFailures: self.notifyAIProcessingFailures, weekendsDontBreakStreak: self.weekendsDontBreakStreak, fillerWords: self.fillerWords, @@ -2971,6 +2972,9 @@ final class SettingsStore: ObservableObject { if let audioHistoryBudgetGB = payload.audioHistoryBudgetGB { self.audioHistoryBudgetGB = audioHistoryBudgetGB } + if let historyAutoClearInterval = payload.historyAutoClearInterval { + self.historyAutoClearInterval = historyAutoClearInterval + } if let notifyAIProcessingFailures = payload.notifyAIProcessingFailures { self.notifyAIProcessingFailures = notifyAIProcessingFailures }