diff --git a/BisonNotes AI/BisonNotes AI/EnhancedFileManager.swift b/BisonNotes AI/BisonNotes AI/EnhancedFileManager.swift index c1c142f..50ecdc9 100644 --- a/BisonNotes AI/BisonNotes AI/EnhancedFileManager.swift +++ b/BisonNotes AI/BisonNotes AI/EnhancedFileManager.swift @@ -363,18 +363,19 @@ class EnhancedFileManager: ObservableObject { } } - // Delete associated location file if it exists - let locationURL = normalizedURL.deletingPathExtension().appendingPathExtension("location") - if FileManager.default.fileExists(atPath: locationURL.path) { - do { - try FileManager.default.removeItem(at: locationURL) - AppLog.shared.fileManagement("Deleted location file: \(locationURL.lastPathComponent)") - } catch { - if error.isThumbnailGenerationError { - AppLog.shared.fileManagement("Thumbnail generation warning during location file deletion: \(error.localizedDescription)", level: .debug) - // Continue with deletion even if thumbnail generation fails - } else { - throw error + // Delete associated sidecar files if they exist + for ext in ["location", "recordingmeta"] { + let sidecarURL = normalizedURL.deletingPathExtension().appendingPathExtension(ext) + if FileManager.default.fileExists(atPath: sidecarURL.path) { + do { + try FileManager.default.removeItem(at: sidecarURL) + AppLog.shared.fileManagement("Deleted \(ext) file: \(sidecarURL.lastPathComponent)") + } catch { + if error.isThumbnailGenerationError { + AppLog.shared.fileManagement("Thumbnail generation warning during \(ext) file deletion: \(error.localizedDescription)", level: .debug) + } else { + throw error + } } } } @@ -669,6 +670,11 @@ class EnhancedFileManager: ObservableObject { if !dryRun { try FileManager.default.removeItem(at: file) + // Also remove sidecar files for the orphaned audio + for ext in ["location", "recordingmeta"] { + let sidecarURL = file.deletingPathExtension().appendingPathExtension(ext) + try? FileManager.default.removeItem(at: sidecarURL) + } AppLog.shared.fileManagement("Deleted orphaned file: \(file.lastPathComponent) (\(fileSize) bytes)") deletedCount += 1 } else { diff --git a/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift b/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift index 3355321..6449582 100644 --- a/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift +++ b/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift @@ -122,6 +122,11 @@ class RecordingArchiveService: ObservableObject { } catch { AppLog.shared.recording("Archived: failed to remove local audio: \(error.localizedDescription)", level: .error) } + // Clean up sidecar files alongside the audio + for ext in ["location", "recordingmeta"] { + let sidecarURL = url.deletingPathExtension().appendingPathExtension(ext) + try? FileManager.default.removeItem(at: sidecarURL) + } } } } diff --git a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Interruptions.swift b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Interruptions.swift index 88b8b18..59f6634 100644 --- a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Interruptions.swift +++ b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Interruptions.swift @@ -424,18 +424,7 @@ extension AudioRecorderViewModel { let originalFilename = currentURL.deletingPathExtension().lastPathComponent let duration = getRecordingDuration(url: currentURL) - // Get file creation date, or use current date as fallback - let recordingDate: Date - do { - let attributes = try FileManager.default.attributesOfItem(atPath: currentURL.path) - if let creationDate = attributes[.creationDate] as? Date { - recordingDate = creationDate - } else { - recordingDate = Date() - } - } catch { - recordingDate = Date() - } + let recordingDate = currentRecordingDate(for: currentURL) _ = workflowManager.createRecording( url: currentURL, @@ -446,6 +435,7 @@ extension AudioRecorderViewModel { quality: quality, locationData: recordingStartLocationData ) + recordingStartedAt = nil } } } @@ -583,7 +573,7 @@ extension AudioRecorderViewModel { let recordingId = workflowManager.createRecording( url: url, name: displayName, - date: Date(), + date: currentRecordingDate(for: url), fileSize: fileSize, duration: duration, quality: quality, @@ -598,6 +588,7 @@ extension AudioRecorderViewModel { // Reset processing flag recordingBeingProcessed = false resetRecordingLocation() + recordingStartedAt = nil // End background task after successful recovery and save endBackgroundTask() @@ -708,7 +699,7 @@ extension AudioRecorderViewModel { let recordingId = workflowManager.createRecording( url: url, name: displayName, - date: Date(), + date: currentRecordingDate(for: url), fileSize: fileSize, duration: duration, quality: quality, @@ -724,6 +715,7 @@ extension AudioRecorderViewModel { self.recordingURL = nil self.recordingBeingProcessed = false self.resetRecordingLocation() + self.recordingStartedAt = nil } // Send notification to user about recovery (with slight delay to improve visibility) diff --git a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Segments.swift b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Segments.swift index f87e774..03c7b05 100644 --- a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Segments.swift +++ b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Segments.swift @@ -127,7 +127,7 @@ extension AudioRecorderViewModel { let recordingId = workflowManager.createRecording( url: mainURL, name: displayName, - date: Date(), + date: currentRecordingDate(for: mainURL), fileSize: fileSize, duration: duration, quality: quality, @@ -137,6 +137,7 @@ extension AudioRecorderViewModel { AppLog.shared.recording("Merged recording created with workflow manager, ID: \(recordingId)") self.resetRecordingLocation() + self.recordingStartedAt = nil } else { AppLog.shared.recording("WorkflowManager not set - merged recording not saved to database", level: .error) } diff --git a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift index 5027c2e..ec345bc 100644 --- a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift +++ b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift @@ -9,6 +9,10 @@ import Foundation @preconcurrency import AVFoundation import UserNotifications +private struct RecordingTimestampMetadata: Codable { + let recordedAt: Date +} + // MARK: - AVAudioRecorderDelegate extension AudioRecorderViewModel: AVAudioRecorderDelegate { @@ -77,7 +81,7 @@ extension AudioRecorderViewModel: AVAudioRecorderDelegate { let recordingId = workflowManager.createRecording( url: recordingURL, name: displayName, - date: Date(), + date: currentRecordingDate(for: recordingURL), fileSize: fileSize, duration: duration, quality: quality, @@ -88,6 +92,7 @@ extension AudioRecorderViewModel: AVAudioRecorderDelegate { // Watch audio integration removed self.resetRecordingLocation() + self.recordingStartedAt = nil } else { AppLog.shared.recording("WorkflowManager not set - recording not saved to database", level: .error) } @@ -276,6 +281,37 @@ extension AudioRecorderViewModel { // Final fallback to the timer value we tracked during recording return recordingTime } + + func currentRecordingDate(for url: URL?) -> Date { + if let entry = recordingStartedAt, let url, entry.url == url { + return entry.date + } + + if let url, + let data = try? Data(contentsOf: recordingTimestampMetadataURL(for: url)), + let metadata = try? JSONDecoder().decode(RecordingTimestampMetadata.self, from: data) { + return metadata.recordedAt + } + + return Date() + } + + func persistRecordingCapturedAt(_ date: Date, for url: URL) { + let metadata = RecordingTimestampMetadata(recordedAt: date) + guard let data = try? JSONEncoder().encode(metadata) else { + return + } + + do { + try data.write(to: recordingTimestampMetadataURL(for: url), options: .atomic) + } catch { + AppLog.shared.recording("Failed to write recording timestamp metadata: \(error.localizedDescription)", level: .error) + } + } + + private func recordingTimestampMetadataURL(for recordingURL: URL) -> URL { + recordingURL.deletingPathExtension().appendingPathExtension("recordingmeta") + } } // MARK: - Naming Convention @@ -292,7 +328,7 @@ extension AudioRecorderViewModel { func generateAppRecordingDisplayName() -> String { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - let timestamp = formatter.string(from: Date()) + let timestamp = formatter.string(from: recordingStartedAt?.date ?? Date()) return "apprecording-\(timestamp)" } diff --git a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel.swift b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel.swift index df7e8f9..348f7ea 100644 --- a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel.swift +++ b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel.swift @@ -82,6 +82,7 @@ class AudioRecorderViewModel: NSObject, ObservableObject { // Track last checkpoint time for periodic data flushing var lastCheckpointTime: Date = Date.distantPast + var recordingStartedAt: (url: URL, date: Date)? let checkpointInterval: TimeInterval = 30.0 // Try to checkpoint every 30 seconds let forceCheckpointInterval: TimeInterval = 90.0 // Force checkpoint after 90 seconds even without silence @@ -454,7 +455,10 @@ class AudioRecorderViewModel: NSObject, ObservableObject { func setupRecording() { let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let audioFilename = documentsPath.appendingPathComponent(generateAppRecordingFilename()) + let recordingStartDate = Date() recordingURL = audioFilename + recordingStartedAt = (url: audioFilename, date: recordingStartDate) + persistRecordingCapturedAt(recordingStartDate, for: audioFilename) // Initialize segment tracking for this new recording mainRecordingURL = audioFilename @@ -600,7 +604,7 @@ class AudioRecorderViewModel: NSObject, ObservableObject { let recordingId = workflowManager.createRecording( url: url, name: displayName, - date: Date(), + date: currentRecordingDate(for: url), fileSize: fileSize, duration: duration, quality: quality, @@ -630,6 +634,7 @@ class AudioRecorderViewModel: NSObject, ObservableObject { } resetRecordingLocation() + recordingStartedAt = nil endBackgroundTask() } diff --git a/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift b/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift index d09335d..01ba47a 100644 --- a/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift +++ b/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift @@ -713,9 +713,11 @@ struct TranscriptsView: View { // Delete the associated dummy audio file if it exists if let recordingURL = appCoordinator.getAbsoluteURL(for: importedTranscript.recording) { try? FileManager.default.removeItem(at: recordingURL) - // Delete associated location file if present - let locationURL = recordingURL.deletingPathExtension().appendingPathExtension("location") - try? FileManager.default.removeItem(at: locationURL) + // Delete associated sidecar files if present + for ext in ["location", "recordingmeta"] { + let sidecarURL = recordingURL.deletingPathExtension().appendingPathExtension(ext) + try? FileManager.default.removeItem(at: sidecarURL) + } AppLog.shared.transcription("Deleted dummy audio file: \(recordingURL.lastPathComponent)", level: .debug) }