Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1d57da227 |
@@ -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;
|
||||||
|
|||||||
@@ -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] {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -262,6 +258,11 @@ class MeetingViewModel: ObservableObject {
|
|||||||
meeting.recoveryAudioFolderName = recoveryAudioFolderURL.lastPathComponent
|
meeting.recoveryAudioFolderName = recoveryAudioFolderURL.lastPathComponent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func showAudioInFinder() {
|
||||||
|
guard let recoveryAudioFolderURL else { return }
|
||||||
|
LocalStorageManager.shared.showAudioFolderInFinder(recoveryAudioFolderURL)
|
||||||
|
}
|
||||||
|
|
||||||
func loadTemplates() {
|
func loadTemplates() {
|
||||||
templates = LocalStorageManager.shared.loadTemplates()
|
templates = LocalStorageManager.shared.loadTemplates()
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user