diff --git a/Meetingnotes.xcodeproj/project.pbxproj b/Meetingnotes.xcodeproj/project.pbxproj index b539b36..c32433a 100644 --- a/Meetingnotes.xcodeproj/project.pbxproj +++ b/Meetingnotes.xcodeproj/project.pbxproj @@ -276,7 +276,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 25; + CURRENT_PROJECT_VERSION = 26; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_TEAM = G9LVHZAJNX; ENABLE_HARDENED_RUNTIME = YES; @@ -290,7 +290,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.1.13; + MARKETING_VERSION = 1.1.14; ONLY_ACTIVE_ARCH = NO; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; @@ -312,7 +312,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 25; + CURRENT_PROJECT_VERSION = 26; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_TEAM = G9LVHZAJNX; ENABLE_HARDENED_RUNTIME = YES; @@ -326,7 +326,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.1.13; + MARKETING_VERSION = 1.1.14; ONLY_ACTIVE_ARCH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; diff --git a/meetingnotes/Managers/AudioManager.swift b/meetingnotes/Managers/AudioManager.swift index f9f2eb5..31687fc 100644 --- a/meetingnotes/Managers/AudioManager.swift +++ b/meetingnotes/Managers/AudioManager.swift @@ -35,6 +35,7 @@ final class AudioManager: NSObject, ObservableObject { private var audioEngine = AVAudioEngine() private var sessionID = UUID() + private var meetingID = UUID() private var processTap: ProcessTap? private let permission = AudioRecordingPermission() private let tapQueue = DispatchQueue(label: "io.meetingnotes.audiotap", qos: .userInitiated) @@ -59,11 +60,12 @@ final class AudioManager: NSObject, ObservableObject { NotificationCenter.default.removeObserver(self) } - func startRecording() { + func startRecording(for meetingID: UUID) { errorMessage = nil lastRecoveryAudioFolderName = nil cancelCapture(removeFiles: true) sessionID = UUID() + self.meetingID = meetingID recordingStartedAt = Date() do { try prepareAudioFiles() @@ -76,7 +78,7 @@ final class AudioManager: NSObject, ObservableObject { } func stopRecordingAndTranscribe() async -> [TranscriptChunk] { - let completedSessionID = sessionID + let completedMeetingID = meetingID let captureStartedAt = recordingStartedAt let files = stopCaptureAndCloseFiles() isProcessing = true @@ -97,16 +99,15 @@ final class AudioManager: NSObject, ObservableObject { ) transcriptChunks = updated let completedFiles = files.compactMap { $0 } - if failures.isEmpty { - lastRecoveryAudioFolderName = nil - removeAudioFiles(completedFiles) - } else { - let recoveryFolder = preserveAudioFiles(completedFiles, sessionID: completedSessionID) - lastRecoveryAudioFolderName = recoveryFolder?.lastPathComponent - let recoveryMessage = recoveryFolder == nil + let audioFolder = preserveAudioFiles(completedFiles, meetingID: completedMeetingID) + lastRecoveryAudioFolderName = audioFolder?.lastPathComponent + if !failures.isEmpty { + let recoveryMessage = audioFolder == nil ? " The audio remains in the app's temporary folder." - : " Audio was saved in Documents/Meetingnotes-Recovery/\(completedSessionID.uuidString)." + : " Audio was kept for three days. Use Show Audio Folder in the Meetingnotes menu to find it." errorMessage = "Transcription failed for " + failures.joined(separator: "; ") + recoveryMessage + } else if audioFolder == nil, !completedFiles.isEmpty { + errorMessage = "The transcript completed, but Meetingnotes could not move the audio into its three-day storage folder." } return updated } @@ -502,29 +503,8 @@ final class AudioManager: NSObject, ObservableObject { for url in urls { try? FileManager.default.removeItem(at: url) } } - private func preserveAudioFiles(_ urls: [URL], sessionID: UUID) -> URL? { - guard !urls.isEmpty, - let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { - return nil - } - let folder = documents - .appendingPathComponent("Meetingnotes-Recovery", isDirectory: true) - .appendingPathComponent(sessionID.uuidString, isDirectory: true) - do { - try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) - } catch { - return nil - } - var preservedCount = 0 - for url in urls { - do { - try FileManager.default.moveItem(at: url, to: folder.appendingPathComponent(url.lastPathComponent)) - preservedCount += 1 - } catch { - continue - } - } - return preservedCount > 0 ? folder : nil + private func preserveAudioFiles(_ urls: [URL], meetingID: UUID) -> URL? { + LocalStorageManager.shared.preserveAudioFiles(urls, for: meetingID) } private func resetAudioLevels() { diff --git a/meetingnotes/Managers/LocalStorageManager.swift b/meetingnotes/Managers/LocalStorageManager.swift index a8cdb86..a813bca 100644 --- a/meetingnotes/Managers/LocalStorageManager.swift +++ b/meetingnotes/Managers/LocalStorageManager.swift @@ -2,6 +2,7 @@ // Handles local storage of meetings and app data import Foundation +import AppKit /// Manages local file storage for meetings and app data class LocalStorageManager { @@ -16,6 +17,7 @@ class LocalStorageManager { private let meetingsDirectory: URL private let templatesDirectory: URL private let recoveryDirectory: URL + private let audioRetentionInterval: TimeInterval = 3 * 24 * 60 * 60 private init() { // Get the app's documents directory @@ -28,7 +30,7 @@ class LocalStorageManager { // Create templates subdirectory templatesDirectory = documentsDirectory.appendingPathComponent("Templates") - recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes-Recovery") + recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes Audio") // Ensure directories exist try? FileManager.default.createDirectory(at: meetingsDirectory, @@ -37,6 +39,8 @@ class LocalStorageManager { withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: recoveryDirectory, withIntermediateDirectories: true) + migrateLegacyRecoveryAudio() + purgeExpiredAudioFolders() } // MARK: - Meeting Management @@ -151,6 +155,66 @@ class LocalStorageManager { // MARK: - Recovery Audio + func preserveAudioFiles(_ urls: [URL], for meetingID: UUID) -> URL? { + guard !urls.isEmpty else { return nil } + purgeExpiredAudioFolders() + + let folder = recoveryDirectory.appendingPathComponent(meetingID.uuidString, isDirectory: true) + do { + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + } catch { + return nil + } + + var preservedCount = 0 + for url in urls { + let destination = folder.appendingPathComponent(url.lastPathComponent) + do { + if FileManager.default.fileExists(atPath: destination.path) { + try FileManager.default.removeItem(at: destination) + } + try FileManager.default.moveItem(at: url, to: destination) + preservedCount += 1 + } catch { + continue + } + } + return preservedCount > 0 ? folder : nil + } + + func purgeExpiredAudioFolders(now: Date = Date()) { + guard let folders = try? FileManager.default.contentsOfDirectory( + at: recoveryDirectory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return } + + let expirationDate = now.addingTimeInterval(-audioRetentionInterval) + for folder in folders { + guard (try? folder.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } + let audioFiles = recoveryAudioFiles(in: folder) + let newestDate = audioFiles.compactMap { file -> Date? in + let values = try? file.url.resourceValues(forKeys: [.contentModificationDateKey, .creationDateKey]) + return values?.contentModificationDate ?? values?.creationDate + }.max() + + guard let newestDate else { + try? FileManager.default.removeItem(at: folder) + continue + } + if newestDate < expirationDate { + try? FileManager.default.removeItem(at: folder) + } + } + } + + @MainActor + func showAudioFolderInFinder(_ folder: URL? = nil) { + let target = folder ?? recoveryDirectory + try? FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) + NSWorkspace.shared.open(target) + } + func recoveryAudioFolder(named name: String) -> URL? { let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedName.isEmpty, @@ -245,6 +309,22 @@ class LocalStorageManager { try? FileManager.default.removeItem(at: folder) } + private func migrateLegacyRecoveryAudio() { + let legacyDirectory = documentsDirectory.appendingPathComponent("Meetingnotes-Recovery", isDirectory: true) + guard let folders = try? FileManager.default.contentsOfDirectory( + at: legacyDirectory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return } + + for folder in folders { + let destination = recoveryDirectory.appendingPathComponent(folder.lastPathComponent, isDirectory: true) + guard !FileManager.default.fileExists(atPath: destination.path) else { continue } + try? FileManager.default.moveItem(at: folder, to: destination) + } + try? FileManager.default.removeItem(at: legacyDirectory) + } + /// Imports meeting JSON files from a folder selected by the user. func importMeetings(from directory: URL) throws -> MeetingImportResult { let didStartAccess = directory.startAccessingSecurityScopedResource() diff --git a/meetingnotes/Managers/RecordingSessionManager.swift b/meetingnotes/Managers/RecordingSessionManager.swift index a958c10..0323497 100644 --- a/meetingnotes/Managers/RecordingSessionManager.swift +++ b/meetingnotes/Managers/RecordingSessionManager.swift @@ -81,7 +81,7 @@ class RecordingSessionManager: ObservableObject { activeMeetingId = meetingId recordingStartedAt = Date() - audioManager.startRecording() + audioManager.startRecording(for: meetingId) } func stopRecording() async -> [TranscriptChunk] { diff --git a/meetingnotes/MeetingnotesApp.swift b/meetingnotes/MeetingnotesApp.swift index 2f4ee35..89c5062 100644 --- a/meetingnotes/MeetingnotesApp.swift +++ b/meetingnotes/MeetingnotesApp.swift @@ -46,6 +46,10 @@ struct MeetingnotesApp: App { .commands { CommandGroup(after: .appInfo) { CheckForUpdatesView(updater: updaterController.updater) + Divider() + Button("Show Audio Folder") { + LocalStorageManager.shared.showAudioFolderInFinder() + } } } @@ -79,6 +83,12 @@ private struct MeetingnotesMenu: View { Label("Open Meetingnotes", systemImage: "macwindow") } + Button { + LocalStorageManager.shared.showAudioFolderInFinder() + } label: { + Label("Show Audio Folder", systemImage: "folder") + } + Divider() Button { diff --git a/meetingnotes/ViewModels/MeetingViewModel.swift b/meetingnotes/ViewModels/MeetingViewModel.swift index a3ea4af..77ca7b2 100644 --- a/meetingnotes/ViewModels/MeetingViewModel.swift +++ b/meetingnotes/ViewModels/MeetingViewModel.swift @@ -244,10 +244,6 @@ class MeetingViewModel: ObservableObject { throw CocoaError(.fileWriteUnknown) } - LocalStorageManager.shared.deleteRecoveryAudioFolder(recoveryAudioFolderURL) - meeting.recoveryAudioFolderName = nil - self.recoveryAudioFolderURL = nil - saveMeeting() await generateNotes() } catch { errorMessage = error.localizedDescription @@ -262,6 +258,11 @@ class MeetingViewModel: ObservableObject { meeting.recoveryAudioFolderName = recoveryAudioFolderURL.lastPathComponent } } + + func showAudioInFinder() { + guard let recoveryAudioFolderURL else { return } + LocalStorageManager.shared.showAudioFolderInFinder(recoveryAudioFolderURL) + } func loadTemplates() { templates = LocalStorageManager.shared.loadTemplates() diff --git a/meetingnotes/Views/MeetingListView.swift b/meetingnotes/Views/MeetingListView.swift index 6b5a18c..1444893 100644 --- a/meetingnotes/Views/MeetingListView.swift +++ b/meetingnotes/Views/MeetingListView.swift @@ -267,6 +267,12 @@ struct MeetingDetailContentView: View { } .disabled(!viewModel.canRetryTranscription) + Button { + viewModel.showAudioInFinder() + } label: { + Label("Show Audio in Finder", systemImage: "folder") + } + Divider() }