Compare commits

..
1 Commits
Author SHA1 Message Date
james f1d57da227 feat: retain meeting audio for three days 2026-07-22 16:24:08 +02:00
7 changed files with 120 additions and 43 deletions
+4 -4
View File
@@ -276,7 +276,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 25; CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX; DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -290,7 +290,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.13; MARKETING_VERSION = 1.1.14;
ONLY_ACTIVE_ARCH = NO; ONLY_ACTIVE_ARCH = NO;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
@@ -312,7 +312,7 @@
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 25; CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"meetingnotes/Preview Content\"";
DEVELOPMENT_TEAM = G9LVHZAJNX; DEVELOPMENT_TEAM = G9LVHZAJNX;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@@ -326,7 +326,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.1.13; MARKETING_VERSION = 1.1.14;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI";
PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes;
+13 -33
View File
@@ -35,6 +35,7 @@ final class AudioManager: NSObject, ObservableObject {
private var audioEngine = AVAudioEngine() private var audioEngine = AVAudioEngine()
private var sessionID = UUID() private var sessionID = UUID()
private var meetingID = UUID()
private var processTap: ProcessTap? private var processTap: ProcessTap?
private let permission = AudioRecordingPermission() private let permission = AudioRecordingPermission()
private let tapQueue = DispatchQueue(label: "io.meetingnotes.audiotap", qos: .userInitiated) private let tapQueue = DispatchQueue(label: "io.meetingnotes.audiotap", qos: .userInitiated)
@@ -59,11 +60,12 @@ final class AudioManager: NSObject, ObservableObject {
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
} }
func startRecording() { func startRecording(for meetingID: UUID) {
errorMessage = nil errorMessage = nil
lastRecoveryAudioFolderName = nil lastRecoveryAudioFolderName = nil
cancelCapture(removeFiles: true) cancelCapture(removeFiles: true)
sessionID = UUID() sessionID = UUID()
self.meetingID = meetingID
recordingStartedAt = Date() recordingStartedAt = Date()
do { do {
try prepareAudioFiles() try prepareAudioFiles()
@@ -76,7 +78,7 @@ final class AudioManager: NSObject, ObservableObject {
} }
func stopRecordingAndTranscribe() async -> [TranscriptChunk] { func stopRecordingAndTranscribe() async -> [TranscriptChunk] {
let completedSessionID = sessionID let completedMeetingID = meetingID
let captureStartedAt = recordingStartedAt let captureStartedAt = recordingStartedAt
let files = stopCaptureAndCloseFiles() let files = stopCaptureAndCloseFiles()
isProcessing = true isProcessing = true
@@ -97,16 +99,15 @@ final class AudioManager: NSObject, ObservableObject {
) )
transcriptChunks = updated transcriptChunks = updated
let completedFiles = files.compactMap { $0 } let completedFiles = files.compactMap { $0 }
if failures.isEmpty { let audioFolder = preserveAudioFiles(completedFiles, meetingID: completedMeetingID)
lastRecoveryAudioFolderName = nil lastRecoveryAudioFolderName = audioFolder?.lastPathComponent
removeAudioFiles(completedFiles) if !failures.isEmpty {
} else { let recoveryMessage = audioFolder == nil
let recoveryFolder = preserveAudioFiles(completedFiles, sessionID: completedSessionID)
lastRecoveryAudioFolderName = recoveryFolder?.lastPathComponent
let recoveryMessage = recoveryFolder == nil
? " The audio remains in the app's temporary folder." ? " 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 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 return updated
} }
@@ -502,29 +503,8 @@ final class AudioManager: NSObject, ObservableObject {
for url in urls { try? FileManager.default.removeItem(at: url) } for url in urls { try? FileManager.default.removeItem(at: url) }
} }
private func preserveAudioFiles(_ urls: [URL], sessionID: UUID) -> URL? { private func preserveAudioFiles(_ urls: [URL], meetingID: UUID) -> URL? {
guard !urls.isEmpty, LocalStorageManager.shared.preserveAudioFiles(urls, for: meetingID)
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 resetAudioLevels() { private func resetAudioLevels() {
@@ -2,6 +2,7 @@
// Handles local storage of meetings and app data // Handles local storage of meetings and app data
import Foundation import Foundation
import AppKit
/// Manages local file storage for meetings and app data /// Manages local file storage for meetings and app data
class LocalStorageManager { class LocalStorageManager {
@@ -16,6 +17,7 @@ class LocalStorageManager {
private let meetingsDirectory: URL private let meetingsDirectory: URL
private let templatesDirectory: URL private let templatesDirectory: URL
private let recoveryDirectory: URL private let recoveryDirectory: URL
private let audioRetentionInterval: TimeInterval = 3 * 24 * 60 * 60
private init() { private init() {
// Get the app's documents directory // Get the app's documents directory
@@ -28,7 +30,7 @@ class LocalStorageManager {
// Create templates subdirectory // Create templates subdirectory
templatesDirectory = documentsDirectory.appendingPathComponent("Templates") templatesDirectory = documentsDirectory.appendingPathComponent("Templates")
recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes-Recovery") recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes Audio")
// Ensure directories exist // Ensure directories exist
try? FileManager.default.createDirectory(at: meetingsDirectory, try? FileManager.default.createDirectory(at: meetingsDirectory,
@@ -37,6 +39,8 @@ class LocalStorageManager {
withIntermediateDirectories: true) withIntermediateDirectories: true)
try? FileManager.default.createDirectory(at: recoveryDirectory, try? FileManager.default.createDirectory(at: recoveryDirectory,
withIntermediateDirectories: true) withIntermediateDirectories: true)
migrateLegacyRecoveryAudio()
purgeExpiredAudioFolders()
} }
// MARK: - Meeting Management // MARK: - Meeting Management
@@ -151,6 +155,66 @@ class LocalStorageManager {
// MARK: - Recovery Audio // 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? { func recoveryAudioFolder(named name: String) -> URL? {
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty, guard !trimmedName.isEmpty,
@@ -245,6 +309,22 @@ class LocalStorageManager {
try? FileManager.default.removeItem(at: folder) 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. /// Imports meeting JSON files from a folder selected by the user.
func importMeetings(from directory: URL) throws -> MeetingImportResult { func importMeetings(from directory: URL) throws -> MeetingImportResult {
let didStartAccess = directory.startAccessingSecurityScopedResource() let didStartAccess = directory.startAccessingSecurityScopedResource()
@@ -81,7 +81,7 @@ class RecordingSessionManager: ObservableObject {
activeMeetingId = meetingId activeMeetingId = meetingId
recordingStartedAt = Date() recordingStartedAt = Date()
audioManager.startRecording() audioManager.startRecording(for: meetingId)
} }
func stopRecording() async -> [TranscriptChunk] { func stopRecording() async -> [TranscriptChunk] {
+10
View File
@@ -46,6 +46,10 @@ struct MeetingnotesApp: App {
.commands { .commands {
CommandGroup(after: .appInfo) { CommandGroup(after: .appInfo) {
CheckForUpdatesView(updater: updaterController.updater) 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") Label("Open Meetingnotes", systemImage: "macwindow")
} }
Button {
LocalStorageManager.shared.showAudioFolderInFinder()
} label: {
Label("Show Audio Folder", systemImage: "folder")
}
Divider() Divider()
Button { Button {
@@ -244,10 +244,6 @@ class MeetingViewModel: ObservableObject {
throw CocoaError(.fileWriteUnknown) throw CocoaError(.fileWriteUnknown)
} }
LocalStorageManager.shared.deleteRecoveryAudioFolder(recoveryAudioFolderURL)
meeting.recoveryAudioFolderName = nil
self.recoveryAudioFolderURL = nil
saveMeeting()
await generateNotes() await generateNotes()
} catch { } catch {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
@@ -263,6 +259,11 @@ class MeetingViewModel: ObservableObject {
} }
} }
func showAudioInFinder() {
guard let recoveryAudioFolderURL else { return }
LocalStorageManager.shared.showAudioFolderInFinder(recoveryAudioFolderURL)
}
func loadTemplates() { func loadTemplates() {
templates = LocalStorageManager.shared.loadTemplates() templates = LocalStorageManager.shared.loadTemplates()
+6
View File
@@ -267,6 +267,12 @@ struct MeetingDetailContentView: View {
} }
.disabled(!viewModel.canRetryTranscription) .disabled(!viewModel.canRetryTranscription)
Button {
viewModel.showAudioInFinder()
} label: {
Label("Show Audio in Finder", systemImage: "folder")
}
Divider() Divider()
} }