From a3c302b50bcdc1b78c921bf72ad3d7b74c00ef7e Mon Sep 17 00:00:00 2001 From: Beingpax Date: Wed, 13 Aug 2025 09:07:08 +0545 Subject: [PATCH] Update TranscriptCleanup with interval settings --- VoiceInk/Services/ImportExportService.swift | 17 ++- .../TranscriptionAutoCleanupService.swift | 100 ++++++++++++++---- .../Settings/AudioCleanupSettingsView.swift | 68 +++++++++--- VoiceInk/VoiceInk.swift | 17 +-- 4 files changed, 154 insertions(+), 48 deletions(-) diff --git a/VoiceInk/Services/ImportExportService.swift b/VoiceInk/Services/ImportExportService.swift index 036ec88..70735cd 100644 --- a/VoiceInk/Services/ImportExportService.swift +++ b/VoiceInk/Services/ImportExportService.swift @@ -13,7 +13,8 @@ struct GeneralSettings: Codable { let isMenuBarOnly: Bool? let useAppleScriptPaste: Bool? let recorderType: String? - let doNotMaintainTranscriptHistory: Bool? + let isTranscriptionCleanupEnabled: Bool? + let transcriptionRetentionMinutes: Int? let isAudioCleanupEnabled: Bool? let audioRetentionPeriod: Int? @@ -45,8 +46,9 @@ class ImportExportService { private let keyIsMenuBarOnly = "IsMenuBarOnly" private let keyUseAppleScriptPaste = "UseAppleScriptPaste" private let keyRecorderType = "RecorderType" - private let keyDoNotMaintainTranscriptHistory = "DoNotMaintainTranscriptHistory" private let keyIsAudioCleanupEnabled = "IsAudioCleanupEnabled" + private let keyIsTranscriptionCleanupEnabled = "IsTranscriptionCleanupEnabled" + private let keyTranscriptionRetentionMinutes = "TranscriptionRetentionMinutes" private let keyAudioRetentionPeriod = "AudioRetentionPeriod" private let keyIsSoundFeedbackEnabled = "isSoundFeedbackEnabled" @@ -90,7 +92,8 @@ class ImportExportService { isMenuBarOnly: menuBarManager.isMenuBarOnly, useAppleScriptPaste: UserDefaults.standard.bool(forKey: keyUseAppleScriptPaste), recorderType: whisperState.recorderType, - doNotMaintainTranscriptHistory: UserDefaults.standard.bool(forKey: keyDoNotMaintainTranscriptHistory), + isTranscriptionCleanupEnabled: UserDefaults.standard.bool(forKey: keyIsTranscriptionCleanupEnabled), + transcriptionRetentionMinutes: UserDefaults.standard.integer(forKey: keyTranscriptionRetentionMinutes), isAudioCleanupEnabled: UserDefaults.standard.bool(forKey: keyIsAudioCleanupEnabled), audioRetentionPeriod: UserDefaults.standard.integer(forKey: keyAudioRetentionPeriod), @@ -235,8 +238,12 @@ class ImportExportService { if let recType = general.recorderType { whisperState.recorderType = recType } - if let doNotMaintainHistory = general.doNotMaintainTranscriptHistory { - UserDefaults.standard.set(doNotMaintainHistory, forKey: self.keyDoNotMaintainTranscriptHistory) + + if let transcriptionCleanup = general.isTranscriptionCleanupEnabled { + UserDefaults.standard.set(transcriptionCleanup, forKey: self.keyIsTranscriptionCleanupEnabled) + } + if let transcriptionMinutes = general.transcriptionRetentionMinutes { + UserDefaults.standard.set(transcriptionMinutes, forKey: self.keyTranscriptionRetentionMinutes) } if let audioCleanup = general.isAudioCleanupEnabled { UserDefaults.standard.set(audioCleanup, forKey: self.keyIsAudioCleanupEnabled) diff --git a/VoiceInk/Services/TranscriptionAutoCleanupService.swift b/VoiceInk/Services/TranscriptionAutoCleanupService.swift index f2217c4..65b4cc7 100644 --- a/VoiceInk/Services/TranscriptionAutoCleanupService.swift +++ b/VoiceInk/Services/TranscriptionAutoCleanupService.swift @@ -2,68 +2,126 @@ import Foundation import SwiftData import OSLog -/// A service that automatically deletes transcriptions when "Do Not Maintain Transcript History" is enabled class TranscriptionAutoCleanupService { static let shared = TranscriptionAutoCleanupService() - + private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "TranscriptionAutoCleanupService") private var modelContext: ModelContext? - + + private let keyIsEnabled = "IsTranscriptionCleanupEnabled" + private let keyRetentionMinutes = "TranscriptionRetentionMinutes" + + private let defaultRetentionMinutes: Int = 24 * 60 + private init() {} - - /// Start monitoring for new transcriptions and auto-delete if needed + func startMonitoring(modelContext: ModelContext) { self.modelContext = modelContext - + NotificationCenter.default.addObserver( self, selector: #selector(handleTranscriptionCreated(_:)), name: .transcriptionCreated, object: nil ) - - logger.info("TranscriptionAutoCleanupService started monitoring") + + if UserDefaults.standard.bool(forKey: keyIsEnabled) { + + Task { [weak self] in + guard let self = self, let modelContext = self.modelContext else { return } + await self.sweepOldTranscriptions(modelContext: modelContext) + } + } else {} } - - /// Stop monitoring for transcriptions + func stopMonitoring() { NotificationCenter.default.removeObserver(self, name: .transcriptionCreated, object: nil) - logger.info("TranscriptionAutoCleanupService stopped monitoring") + } - + + func runManualCleanup(modelContext: ModelContext) async { + await sweepOldTranscriptions(modelContext: modelContext) + } + @objc private func handleTranscriptionCreated(_ notification: Notification) { - // Check if no-retention mode is enabled - guard UserDefaults.standard.bool(forKey: "DoNotMaintainTranscriptHistory") else { + let isEnabled = UserDefaults.standard.bool(forKey: keyIsEnabled) + guard isEnabled else { return } + + let minutes = UserDefaults.standard.integer(forKey: keyRetentionMinutes) + if minutes > 0 { + // Trigger a sweep based on the retention window whenever a new item is added + if let modelContext = self.modelContext { + Task { [weak self] in + guard let self = self else { return } + await self.sweepOldTranscriptions(modelContext: modelContext) + } + } return } - + guard let transcription = notification.object as? Transcription, let modelContext = self.modelContext else { logger.error("Invalid transcription or missing model context") return } + - logger.info("Auto-deleting transcription for zero data retention") - + // Delete the audio file if it exists if let urlString = transcription.audioFileURL, let url = URL(string: urlString) { do { try FileManager.default.removeItem(at: url) - logger.debug("Deleted audio file: \(url.lastPathComponent)") + } catch { logger.error("Failed to delete audio file: \(error.localizedDescription)") } } - + // Delete the transcription from the database modelContext.delete(transcription) - + do { try modelContext.save() - logger.debug("Successfully deleted transcription from database") + } catch { logger.error("Failed to save after transcription deletion: \(error.localizedDescription)") } } + + private func sweepOldTranscriptions(modelContext: ModelContext) async { + guard UserDefaults.standard.bool(forKey: keyIsEnabled) else { + return + } + + let retentionMinutes = UserDefaults.standard.integer(forKey: keyRetentionMinutes) + let effectiveMinutes = max(retentionMinutes, 0) + + let cutoffDate = Date().addingTimeInterval(TimeInterval(-effectiveMinutes * 60)) + + do { + try await MainActor.run { + let descriptor = FetchDescriptor( + predicate: #Predicate { transcription in + transcription.timestamp < cutoffDate + } + ) + let items = try modelContext.fetch(descriptor) + var deletedCount = 0 + for transcription in items { + // Remove audio file if present + if let urlString = transcription.audioFileURL, + let url = URL(string: urlString), + FileManager.default.fileExists(atPath: url.path) { + try? FileManager.default.removeItem(at: url) + } + modelContext.delete(transcription) + deletedCount += 1 + } + if deletedCount > 0 { try modelContext.save() } + } + } catch { + logger.error("Failed during transcription cleanup: \(error.localizedDescription)") + } + } } \ No newline at end of file diff --git a/VoiceInk/Views/Settings/AudioCleanupSettingsView.swift b/VoiceInk/Views/Settings/AudioCleanupSettingsView.swift index 035faa9..76ad822 100644 --- a/VoiceInk/Views/Settings/AudioCleanupSettingsView.swift +++ b/VoiceInk/Views/Settings/AudioCleanupSettingsView.swift @@ -1,12 +1,12 @@ import SwiftUI import SwiftData -/// A view component for configuring audio cleanup settings struct AudioCleanupSettingsView: View { @EnvironmentObject private var whisperState: WhisperState // Audio cleanup settings - @AppStorage("DoNotMaintainTranscriptHistory") private var doNotMaintainTranscriptHistory = false + @AppStorage("IsTranscriptionCleanupEnabled") private var isTranscriptionCleanupEnabled = false + @AppStorage("TranscriptionRetentionMinutes") private var transcriptionRetentionMinutes = 24 * 60 @AppStorage("IsAudioCleanupEnabled") private var isAudioCleanupEnabled = true @AppStorage("AudioRetentionPeriod") private var audioRetentionPeriod = 7 @State private var isPerformingCleanup = false @@ -14,6 +14,7 @@ struct AudioCleanupSettingsView: View { @State private var cleanupInfo: (fileCount: Int, totalSize: Int64, transcriptions: [Transcription]) = (0, 0, []) @State private var showResultAlert = false @State private var cleanupResult: (deletedCount: Int, errorCount: Int) = (0, 0) + @State private var showTranscriptCleanupResult = false var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -22,28 +23,60 @@ struct AudioCleanupSettingsView: View { .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) - Toggle("Do not maintain transcript history", isOn: $doNotMaintainTranscriptHistory) + Toggle("Automatically delete transcript history", isOn: $isTranscriptionCleanupEnabled) .toggleStyle(.switch) .padding(.vertical, 4) - if doNotMaintainTranscriptHistory { - Text("When enabled, no transcription history will be saved. This provides zero data retention for maximum privacy.") - .font(.system(size: 13)) - .foregroundColor(.orange) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 2) + if isTranscriptionCleanupEnabled { + VStack(alignment: .leading, spacing: 8) { + Picker("Delete transcripts older than", selection: $transcriptionRetentionMinutes) { + Text("Immediately").tag(0) + Text("1 hour").tag(60) + Text("1 day").tag(24 * 60) + Text("3 days").tag(3 * 24 * 60) + Text("7 days").tag(7 * 24 * 60) + } + .pickerStyle(.menu) + + Text("Older transcripts will be deleted automatically based on your selection.") + .font(.system(size: 13)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 2) + + Button(action: { + Task { + await TranscriptionAutoCleanupService.shared.runManualCleanup(modelContext: whisperState.modelContext) + await MainActor.run { + showTranscriptCleanupResult = true + } + } + }) { + HStack { + Image(systemName: "trash.circle") + Text("Run Transcript Cleanup Now") + } + } + .buttonStyle(.bordered) + .controlSize(.large) + .alert("Transcript Cleanup", isPresented: $showTranscriptCleanupResult) { + Button("OK", role: .cancel) { } + } message: { + Text("Cleanup triggered. Old transcripts are cleaned up according to your retention setting.") + } + } + .padding(.vertical, 4) } - - if !doNotMaintainTranscriptHistory { + + if !isTranscriptionCleanupEnabled { Divider() .padding(.vertical, 8) - Toggle("Enable automatic audio cleanup", isOn: $isAudioCleanupEnabled) .toggleStyle(.switch) .padding(.vertical, 4) } - - if isAudioCleanupEnabled && !doNotMaintainTranscriptHistory { + + if isAudioCleanupEnabled && !isTranscriptionCleanupEnabled { VStack(alignment: .leading, spacing: 8) { Picker("Keep audio files for", selection: $audioRetentionPeriod) { Text("1 day").tag(1) @@ -143,5 +176,12 @@ struct AudioCleanupSettingsView: View { } } } + .onChange(of: isTranscriptionCleanupEnabled) { _, newValue in + if newValue { + AudioCleanupManager.shared.stopAutomaticCleanup() + } else if isAudioCleanupEnabled { + AudioCleanupManager.shared.startAutomaticCleanup(modelContext: whisperState.modelContext) + } + } } } \ No newline at end of file diff --git a/VoiceInk/VoiceInk.swift b/VoiceInk/VoiceInk.swift index 54306bf..0f2f219 100644 --- a/VoiceInk/VoiceInk.swift +++ b/VoiceInk/VoiceInk.swift @@ -78,7 +78,6 @@ struct VoiceInkApp: App { ) _menuBarManager = StateObject(wrappedValue: menuBarManager) - // Configure ActiveWindowService with enhancementService let activeWindowService = ActiveWindowService.shared activeWindowService.configure(with: enhancementService) activeWindowService.configureWhisperState(whisperState) @@ -99,11 +98,13 @@ struct VoiceInkApp: App { .onAppear { updaterViewModel.silentlyCheckForUpdates() - // Start the automatic audio cleanup process - audioCleanupManager.startAutomaticCleanup(modelContext: container.mainContext) - - // Start the transcription auto-cleanup service for zero data retention + // Start the transcription auto-cleanup service (handles immediate and scheduled transcript deletion) transcriptionAutoCleanupService.startMonitoring(modelContext: container.mainContext) + + // Start the automatic audio cleanup process only if transcript cleanup is not enabled + if !UserDefaults.standard.bool(forKey: "IsTranscriptionCleanupEnabled") { + audioCleanupManager.startAutomaticCleanup(modelContext: container.mainContext) + } } .background(WindowAccessor { window in WindowManager.shared.configureWindow(window) @@ -111,11 +112,11 @@ struct VoiceInkApp: App { .onDisappear { whisperState.unloadModel() - // Stop the automatic audio cleanup process - audioCleanupManager.stopAutomaticCleanup() - // Stop the transcription auto-cleanup service transcriptionAutoCleanupService.stopMonitoring() + + // Stop the automatic audio cleanup process + audioCleanupManager.stopAutomaticCleanup() } } else { OnboardingView(hasCompletedOnboarding: $hasCompletedOnboarding)