-
-
Notifications
You must be signed in to change notification settings - Fork 462
feat(history): add automatic transcription history clearing (#463) #504
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
49c4977
d661e84
9e8405f
2f43c35
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2790,6 +2790,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 { | ||
|
|
@@ -2863,6 +2940,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, | ||
|
|
@@ -2972,6 +3050,9 @@ final class SettingsStore: ObservableObject { | |
| if let audioHistoryBudgetGB = payload.audioHistoryBudgetGB { | ||
| self.audioHistoryBudgetGB = audioHistoryBudgetGB | ||
| } | ||
| if let historyAutoClearInterval = payload.historyAutoClearInterval { | ||
| self.historyAutoClearInterval = historyAutoClearInterval | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a backup carries a non- Useful? React with 👍 / 👎.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 2f43c35 — |
||
| } | ||
| if let notifyAIProcessingFailures = payload.notifyAIProcessingFailures { | ||
| self.notifyAIProcessingFailures = notifyAIProcessingFailures | ||
| } | ||
|
|
@@ -4528,6 +4609,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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.