Skip to content
Open
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
4 changes: 4 additions & 0 deletions Fluid.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.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 */; };
Expand All @@ -37,6 +38,7 @@
7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = "<group>"; };
7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = "<group>"; };
343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = "<group>"; };
A1C4E7B209D34F86B1D52C40 /* HistoryAutoClearIntervalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryAutoClearIntervalTests.swift; sourceTree = "<group>"; };
980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = "<group>"; };
7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = "<group>"; };
7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = "<group>"; };
Expand Down Expand Up @@ -107,6 +109,7 @@
7CDB0A262F3C4D5600FB7CAD /* Helpers */,
7CDB0A272F3C4D5600FB7CAD /* Resources */,
7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */,
A1C4E7B209D34F86B1D52C40 /* HistoryAutoClearIntervalTests.swift */,
7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */,
343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */,
980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */,
Expand Down Expand Up @@ -263,6 +266,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 */,
272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */,
Expand Down
1 change: 1 addition & 0 deletions Sources/Fluid/Persistence/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,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]
Expand Down
82 changes: 82 additions & 0 deletions Sources/Fluid/Persistence/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
devzahirul marked this conversation as resolved.
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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2972,6 +3050,9 @@ final class SettingsStore: ObservableObject {
if let audioHistoryBudgetGB = payload.audioHistoryBudgetGB {
self.audioHistoryBudgetGB = audioHistoryBudgetGB
}
if let historyAutoClearInterval = payload.historyAutoClearInterval {
self.historyAutoClearInterval = historyAutoClearInterval

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Prune restored history after applying retention settings

When a backup carries a non-never interval, this restores the retention policy before BackupService.restore loads document.transcriptionHistory (BackupService.swift:169), and TranscriptionHistoryStore.restore(from:) only sorts and saves the payload without calling pruneExpiredEntries(). Importing a backup can therefore immediately expose entries older than the selected window, including saved audio, until the next day-change/new dictation/app relaunch; apply the cutoff after restoring the history payload.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 2f43c35TranscriptionHistoryStore.restore(from:) now calls pruneExpiredEntries() after saving the restored payload, so a backup carrying a non-never interval prunes expired entries (and their saved audio) immediately on import instead of waiting for the next dictation, day change, or relaunch. Settings are restored before history in BackupService.restore, so the cutoff already reflects the imported policy at that point. Lint and the full test suite pass.

}
if let notifyAIProcessingFailures = payload.notifyAIProcessingFailures {
self.notifyAIProcessingFailures = notifyAIProcessingFailures
}
Expand Down Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions Sources/Fluid/Persistence/TranscriptionHistoryStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,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
Expand Down Expand Up @@ -196,6 +211,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,
Expand Down Expand Up @@ -251,6 +268,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()
Expand Down Expand Up @@ -299,6 +343,11 @@ final class TranscriptionHistoryStore: ObservableObject {
self.entries = payload.sorted { $0.timestamp > $1.timestamp }
self.selectedEntryID = self.entries.first?.id
self.saveEntries()

// The restored settings can carry a tighter retention window than the
// restored history; prune immediately so expired entries (and their
// saved audio) don't linger until the next dictation or day change.
self.pruneExpiredEntries()
}

func attachAudio(_ audio: DictationAudioMetadata, to entryID: UUID) {
Expand Down
53 changes: 53 additions & 0 deletions Sources/Fluid/UI/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -2301,6 +2304,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
Expand Down
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)
}
}
Loading