Added support for removing recordings older than x days.
This commit is contained in:
parent
37d1914e38
commit
19142522bd
241
VoiceInk/Views/Settings/AudioCleanupManager.swift
Normal file
241
VoiceInk/Views/Settings/AudioCleanupManager.swift
Normal file
@ -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<Transcription>(
|
||||
predicate: #Predicate<Transcription> { 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<Transcription>(
|
||||
predicate: #Predicate<Transcription> { 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)
|
||||
}
|
||||
}
|
||||
132
VoiceInk/Views/Settings/AudioCleanupSettingsView.swift
Normal file
132
VoiceInk/Views/Settings/AudioCleanupSettingsView.swift
Normal file
@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user