From 19142522bd6ab548ca778cf2ee79de7d783a4335 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Wed, 12 Mar 2025 14:11:39 +0545 Subject: [PATCH] Added support for removing recordings older than x days. --- .../Views/Settings/AudioCleanupManager.swift | 241 ++++++++++++++++++ .../Settings/AudioCleanupSettingsView.swift | 132 ++++++++++ .../Views/{ => Settings}/SettingsView.swift | 10 + VoiceInk/VoiceInk.swift | 10 + 4 files changed, 393 insertions(+) create mode 100644 VoiceInk/Views/Settings/AudioCleanupManager.swift create mode 100644 VoiceInk/Views/Settings/AudioCleanupSettingsView.swift rename VoiceInk/Views/{ => Settings}/SettingsView.swift (97%) diff --git a/VoiceInk/Views/Settings/AudioCleanupManager.swift b/VoiceInk/Views/Settings/AudioCleanupManager.swift new file mode 100644 index 0000000..a500c3a --- /dev/null +++ b/VoiceInk/Views/Settings/AudioCleanupManager.swift @@ -0,0 +1,241 @@ +import Foundation +import SwiftData +import OSLog + +/// A utility class that manages automatic cleanup of audio files while preserving transcript data +class AudioCleanupManager { + static let shared = AudioCleanupManager() + + private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "AudioCleanupManager") + private var cleanupTimer: Timer? + + // Default cleanup settings + private let defaultRetentionDays = 7 + private let cleanupCheckInterval: TimeInterval = 86400 // Check once per day (in seconds) + + private init() { + logger.info("AudioCleanupManager initialized") + } + + /// Start the automatic cleanup process + func startAutomaticCleanup(modelContext: ModelContext) { + logger.info("Starting automatic audio cleanup") + + // Cancel any existing timer + cleanupTimer?.invalidate() + + // Perform initial cleanup + Task { + await performCleanup(modelContext: modelContext) + } + + // Schedule regular cleanup + cleanupTimer = Timer.scheduledTimer(withTimeInterval: cleanupCheckInterval, repeats: true) { [weak self] _ in + Task { [weak self] in + await self?.performCleanup(modelContext: modelContext) + } + } + + logger.info("Automatic cleanup scheduled") + } + + /// Stop the automatic cleanup process + func stopAutomaticCleanup() { + logger.info("Stopping automatic audio cleanup") + cleanupTimer?.invalidate() + cleanupTimer = nil + } + + /// Get information about the files that would be cleaned up + func getCleanupInfo(modelContext: ModelContext) async -> (fileCount: Int, totalSize: Int64, transcriptions: [Transcription]) { + logger.info("Analyzing potential audio cleanup") + + // Get retention period from UserDefaults + let retentionDays = UserDefaults.standard.integer(forKey: "AudioRetentionPeriod") + let effectiveRetentionDays = retentionDays > 0 ? retentionDays : defaultRetentionDays + + // Calculate the cutoff date + let calendar = Calendar.current + guard let cutoffDate = calendar.date(byAdding: .day, value: -effectiveRetentionDays, to: Date()) else { + logger.error("Failed to calculate cutoff date") + return (0, 0, []) + } + + do { + // Execute SwiftData operations on the main thread + return try await MainActor.run { + // Create a predicate to find transcriptions with audio files older than the cutoff date + let descriptor = FetchDescriptor( + predicate: #Predicate { transcription in + transcription.timestamp < cutoffDate && + transcription.audioFileURL != nil + } + ) + + let transcriptions = try modelContext.fetch(descriptor) + + // Calculate stats (can be done on any thread) + var fileCount = 0 + var totalSize: Int64 = 0 + var eligibleTranscriptions: [Transcription] = [] + + for transcription in transcriptions { + if let urlString = transcription.audioFileURL, + let url = URL(string: urlString), + FileManager.default.fileExists(atPath: url.path) { + do { + // Get file attributes to determine size + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + if let fileSize = attributes[.size] as? Int64 { + totalSize += fileSize + fileCount += 1 + eligibleTranscriptions.append(transcription) + } + } catch { + self.logger.error("Failed to get attributes for \(url.lastPathComponent): \(error.localizedDescription)") + } + } + } + + self.logger.info("Found \(fileCount) files eligible for cleanup, totaling \(self.formatFileSize(totalSize))") + return (fileCount, totalSize, eligibleTranscriptions) + } + } catch { + logger.error("Error analyzing files for cleanup: \(error.localizedDescription)") + return (0, 0, []) + } + } + + /// Perform the cleanup operation + private func performCleanup(modelContext: ModelContext) async { + logger.info("Performing audio cleanup") + + // Get retention period from UserDefaults + let retentionDays = UserDefaults.standard.integer(forKey: "AudioRetentionPeriod") + let effectiveRetentionDays = retentionDays > 0 ? retentionDays : defaultRetentionDays + + // Check if automatic cleanup is enabled + let isCleanupEnabled = UserDefaults.standard.bool(forKey: "IsAudioCleanupEnabled") + guard isCleanupEnabled else { + logger.info("Audio cleanup is disabled, skipping") + return + } + + logger.info("Audio retention period: \(effectiveRetentionDays) days") + + // Calculate the cutoff date + let calendar = Calendar.current + guard let cutoffDate = calendar.date(byAdding: .day, value: -effectiveRetentionDays, to: Date()) else { + logger.error("Failed to calculate cutoff date") + return + } + + logger.info("Cutoff date for audio cleanup: \(cutoffDate)") + + do { + // Execute SwiftData operations on the main thread + try await MainActor.run { + // Create a predicate to find transcriptions with audio files older than the cutoff date + let descriptor = FetchDescriptor( + predicate: #Predicate { transcription in + transcription.timestamp < cutoffDate && + transcription.audioFileURL != nil + } + ) + + let transcriptions = try modelContext.fetch(descriptor) + self.logger.info("Found \(transcriptions.count) transcriptions with audio files to clean up") + + var deletedCount = 0 + var errorCount = 0 + + for transcription in transcriptions { + if let urlString = transcription.audioFileURL, + let url = URL(string: urlString), + FileManager.default.fileExists(atPath: url.path) { + do { + // Delete the audio file + try FileManager.default.removeItem(at: url) + + // Update the transcription to remove the audio file reference + transcription.audioFileURL = nil + + deletedCount += 1 + self.logger.debug("Deleted audio file: \(url.lastPathComponent)") + } catch { + errorCount += 1 + self.logger.error("Failed to delete audio file \(url.lastPathComponent): \(error.localizedDescription)") + } + } + } + + if deletedCount > 0 || errorCount > 0 { + try modelContext.save() + self.logger.info("Cleanup complete. Deleted \(deletedCount) files. Failed: \(errorCount)") + } + } + } catch { + logger.error("Error during audio cleanup: \(error.localizedDescription)") + } + } + + /// Run cleanup manually - can be called from settings + func runManualCleanup(modelContext: ModelContext) async { + await performCleanup(modelContext: modelContext) + } + + /// Run cleanup on the specified transcriptions + func runCleanupForTranscriptions(modelContext: ModelContext, transcriptions: [Transcription]) async -> (deletedCount: Int, errorCount: Int) { + logger.info("Running cleanup for \(transcriptions.count) specific transcriptions") + + do { + // Execute SwiftData operations on the main thread + return try await MainActor.run { + var deletedCount = 0 + var errorCount = 0 + + for transcription in transcriptions { + if let urlString = transcription.audioFileURL, + let url = URL(string: urlString), + FileManager.default.fileExists(atPath: url.path) { + do { + // Delete the audio file + try FileManager.default.removeItem(at: url) + + // Update the transcription to remove the audio file reference + transcription.audioFileURL = nil + + deletedCount += 1 + self.logger.debug("Deleted audio file: \(url.lastPathComponent)") + } catch { + errorCount += 1 + self.logger.error("Failed to delete audio file \(url.lastPathComponent): \(error.localizedDescription)") + } + } + } + + if deletedCount > 0 || errorCount > 0 { + do { + try modelContext.save() + self.logger.info("Cleanup complete. Deleted \(deletedCount) files. Failed: \(errorCount)") + } catch { + self.logger.error("Error saving model context after cleanup: \(error.localizedDescription)") + } + } + + return (deletedCount, errorCount) + } + } catch { + logger.error("Error during targeted cleanup: \(error.localizedDescription)") + return (0, 0) + } + } + + /// Format file size in human-readable form + func formatFileSize(_ size: Int64) -> String { + let byteCountFormatter = ByteCountFormatter() + byteCountFormatter.allowedUnits = [.useKB, .useMB, .useGB] + byteCountFormatter.countStyle = .file + return byteCountFormatter.string(fromByteCount: size) + } +} diff --git a/VoiceInk/Views/Settings/AudioCleanupSettingsView.swift b/VoiceInk/Views/Settings/AudioCleanupSettingsView.swift new file mode 100644 index 0000000..9e73219 --- /dev/null +++ b/VoiceInk/Views/Settings/AudioCleanupSettingsView.swift @@ -0,0 +1,132 @@ +import SwiftUI +import SwiftData + +/// A view component for configuring audio cleanup settings +struct AudioCleanupSettingsView: View { + @EnvironmentObject private var whisperState: WhisperState + + // Audio cleanup settings + @AppStorage("IsAudioCleanupEnabled") private var isAudioCleanupEnabled = true + @AppStorage("AudioRetentionPeriod") private var audioRetentionPeriod = 7 + @State private var isPerformingCleanup = false + @State private var isShowingConfirmation = false + @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) + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("VoiceInk can automatically delete audio files from transcription history while preserving the text transcripts.") + .font(.system(size: 13)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Toggle("Enable automatic audio cleanup", isOn: $isAudioCleanupEnabled) + .toggleStyle(.switch) + .padding(.vertical, 4) + + if isAudioCleanupEnabled { + VStack(alignment: .leading, spacing: 8) { + Text("Retention Period") + .font(.system(size: 14, weight: .medium)) + + Picker("Keep audio files for", selection: $audioRetentionPeriod) { + Text("1 day").tag(1) + Text("3 days").tag(3) + Text("7 days").tag(7) + Text("14 days").tag(14) + Text("30 days").tag(30) + } + .pickerStyle(.menu) + + Text("Audio files older than the selected period will be automatically deleted, while keeping the text transcripts intact.") + .font(.system(size: 13)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 2) + } + .padding(.vertical, 4) + + Button(action: { + // Start by analyzing what would be cleaned up + Task { + // Update UI state + await MainActor.run { + isPerformingCleanup = true + } + + // Get cleanup info + let info = await AudioCleanupManager.shared.getCleanupInfo(modelContext: whisperState.modelContext) + + // Update UI with results + await MainActor.run { + cleanupInfo = info + isPerformingCleanup = false + isShowingConfirmation = true + } + } + }) { + HStack { + if isPerformingCleanup { + ProgressView() + .controlSize(.small) + .padding(.trailing, 4) + } else { + Image(systemName: "arrow.clockwise") + } + Text(isPerformingCleanup ? "Analyzing..." : "Run Cleanup Now") + } + } + .buttonStyle(.bordered) + .controlSize(.large) + .disabled(isPerformingCleanup) + .alert("Audio Cleanup", isPresented: $isShowingConfirmation) { + Button("Cancel", role: .cancel) { } + + if cleanupInfo.fileCount > 0 { + Button("Delete \(cleanupInfo.fileCount) Files", role: .destructive) { + Task { + // Update UI state + await MainActor.run { + isPerformingCleanup = true + } + + // Perform cleanup + let result = await AudioCleanupManager.shared.runCleanupForTranscriptions( + modelContext: whisperState.modelContext, + transcriptions: cleanupInfo.transcriptions + ) + + // Update UI with results + await MainActor.run { + cleanupResult = result + isPerformingCleanup = false + showResultAlert = true + } + } + } + } + } message: { + VStack(alignment: .leading, spacing: 8) { + if cleanupInfo.fileCount > 0 { + Text("This will delete \(cleanupInfo.fileCount) audio files older than \(audioRetentionPeriod) day\(audioRetentionPeriod > 1 ? "s" : "").") + Text("Total size to be freed: \(AudioCleanupManager.shared.formatFileSize(cleanupInfo.totalSize))") + Text("The text transcripts will be preserved.") + } else { + Text("No audio files found that are older than \(audioRetentionPeriod) day\(audioRetentionPeriod > 1 ? "s" : "").") + } + } + } + .alert("Cleanup Complete", isPresented: $showResultAlert) { + Button("OK", role: .cancel) { } + } message: { + if cleanupResult.errorCount > 0 { + Text("Successfully deleted \(cleanupResult.deletedCount) audio files. Failed to delete \(cleanupResult.errorCount) files.") + } else { + Text("Successfully deleted \(cleanupResult.deletedCount) audio files.") + } + } + } + } + } +} \ No newline at end of file diff --git a/VoiceInk/Views/SettingsView.swift b/VoiceInk/Views/Settings/SettingsView.swift similarity index 97% rename from VoiceInk/Views/SettingsView.swift rename to VoiceInk/Views/Settings/SettingsView.swift index 837765f..6a39416 100644 --- a/VoiceInk/Views/SettingsView.swift +++ b/VoiceInk/Views/Settings/SettingsView.swift @@ -3,6 +3,7 @@ import Cocoa import KeyboardShortcuts import LaunchAtLogin import AVFoundation +// Additional imports for Settings components struct SettingsView: View { @EnvironmentObject private var updaterViewModel: UpdaterViewModel @@ -165,6 +166,15 @@ struct SettingsView: View { } } + // Audio Cleanup Section + SettingsSection( + icon: "trash.circle", + title: "Audio Cleanup", + subtitle: "Manage recording storage" + ) { + AudioCleanupSettingsView() + } + // Reset Onboarding Section SettingsSection( icon: "arrow.counterclockwise", diff --git a/VoiceInk/VoiceInk.swift b/VoiceInk/VoiceInk.swift index 2960230..b3d6412 100644 --- a/VoiceInk/VoiceInk.swift +++ b/VoiceInk/VoiceInk.swift @@ -2,6 +2,7 @@ import SwiftUI import SwiftData import Sparkle import AppKit +import OSLog @main struct VoiceInkApp: App { @@ -17,6 +18,9 @@ struct VoiceInkApp: App { @StateObject private var activeWindowService = ActiveWindowService.shared @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false + // Audio cleanup manager for automatic deletion of old audio files + private let audioCleanupManager = AudioCleanupManager.shared + init() { do { let schema = Schema([ @@ -90,12 +94,18 @@ struct VoiceInkApp: App { .modelContainer(container) .onAppear { updaterViewModel.silentlyCheckForUpdates() + + // Start the automatic audio cleanup process + audioCleanupManager.startAutomaticCleanup(modelContext: container.mainContext) } .background(WindowAccessor { window in WindowManager.shared.configureWindow(window) }) .onDisappear { whisperState.unloadModel() + + // Stop the automatic audio cleanup process + audioCleanupManager.stopAutomaticCleanup() } } else { OnboardingView(hasCompletedOnboarding: $hasCompletedOnboarding)