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 */; };
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 */; };
Expand All @@ -36,6 +37,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>"; };
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>"; };
7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
Expand Down Expand Up @@ -105,6 +107,7 @@
7CDB0A262F3C4D5600FB7CAD /* Helpers */,
7CDB0A272F3C4D5600FB7CAD /* Resources */,
7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */,
A1C4E7B209D34F86B1D52C40 /* HistoryAutoClearIntervalTests.swift */,
7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */,
343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */,
);
Expand Down Expand Up @@ -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 */,
);
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 @@ -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]
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 @@ -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 {
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 @@ -2792,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,
Expand Down Expand Up @@ -2894,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
}
Expand Down Expand Up @@ -4444,6 +4525,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
44 changes: 44 additions & 0 deletions Sources/Fluid/Persistence/TranscriptionHistoryStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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()
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 @@ -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
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)
}
}