From 79b2f61dfe86c73891157de40bb920992b8fd7a1 Mon Sep 17 00:00:00 2001 From: SuperDooper86 Date: Wed, 26 Aug 2026 10:09:21 +0200 Subject: [PATCH] feat: merge split meeting continuations --- Meetingnotes.xcodeproj/project.pbxproj | 8 +- README.md | 1 + meetingnotes/Managers/AudioManager.swift | 56 ++++++++--- .../Managers/LocalStorageManager.swift | 92 +++++++++++++++++++ .../ViewModels/MeetingListViewModel.swift | 22 +++++ meetingnotes/Views/MeetingListView.swift | 57 +++++++++++- 6 files changed, 216 insertions(+), 20 deletions(-) diff --git a/Meetingnotes.xcodeproj/project.pbxproj b/Meetingnotes.xcodeproj/project.pbxproj index 28786a7..6c200f6 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 = 35; + CURRENT_PROJECT_VERSION = 36; 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.23; + MARKETING_VERSION = 1.1.24; 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 = 35; + CURRENT_PROJECT_VERSION = 36; 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.23; + MARKETING_VERSION = 1.1.24; ONLY_ACTIVE_ARCH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D ENABLE_TCC_SPI"; PRODUCT_BUNDLE_IDENTIFIER = net.jamesbone.meetingnotes; diff --git a/README.md b/README.md index 2aa942a..9911bf4 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Implemented: - Abilty to edit system prompt - Select any compatible Coder model for transcription and note generation - Automatic start and stop from MuteDeck through a compatible local API +- Merge an automatically split continuation back into its previous meeting - Auto updates - Text formatting - Different note templates diff --git a/meetingnotes/Managers/AudioManager.swift b/meetingnotes/Managers/AudioManager.swift index d876431..ca46e81 100644 --- a/meetingnotes/Managers/AudioManager.swift +++ b/meetingnotes/Managers/AudioManager.swift @@ -133,17 +133,29 @@ final class AudioManager: NSObject, ObservableObject { isProcessing = true defer { isProcessing = false } let model = UserDefaultsManager.shared.transcriptionModel - let micURL = recoveryFiles.first(where: { $0.source == .mic })?.url - let systemURL = recoveryFiles.first(where: { $0.source == .system })?.url - async let micResult = transcribe(micURL, model: model, diarization: false) - async let systemResult = transcribe(systemURL, model: model, diarization: true) - let (micTranscription, systemTranscription) = await (micResult, systemResult) - let results = [micTranscription, systemTranscription] - let (chunks, failures) = buildTranscriptChunks( - from: results, - captureStartedAt: captureStartedAt, - existingChunks: [] - ) + let sessions = Dictionary(grouping: recoveryFiles) { recoverySessionKey(for: $0.url) } + .values + .map { files in + (files: files, startedAt: recoveryCaptureStartedAt(for: files, fallback: captureStartedAt)) + } + .sorted { $0.startedAt < $1.startedAt } + + var chunks: [TranscriptChunk] = [] + var failures: [String] = [] + for session in sessions { + let micURL = session.files.first(where: { $0.source == .mic })?.url + let systemURL = session.files.first(where: { $0.source == .system })?.url + async let micResult = transcribe(micURL, model: model, diarization: false) + async let systemResult = transcribe(systemURL, model: model, diarization: true) + let (micTranscription, systemTranscription) = await (micResult, systemResult) + let result = buildTranscriptChunks( + from: [micTranscription, systemTranscription], + captureStartedAt: session.startedAt, + existingChunks: chunks + ) + chunks = result.0 + failures.append(contentsOf: result.1) + } if !failures.isEmpty { throw RecoveryTranscriptionError.requestFailed(failures.joined(separator: "; ")) @@ -154,6 +166,28 @@ final class AudioManager: NSObject, ObservableObject { return chunks } + private func recoverySessionKey(for url: URL) -> String { + let name = url.deletingPathExtension().lastPathComponent + if name.hasSuffix("-mic") { return String(name.dropLast(4)) } + if name.hasSuffix("-system") { return String(name.dropLast(7)) } + return name + } + + private func recoveryCaptureStartedAt( + for files: [(url: URL, source: AudioSource)], + fallback: Date + ) -> Date { + let estimatedStarts = files.compactMap { file -> Date? in + guard let duration = audioDuration(at: file.url), + let values = try? file.url.resourceValues(forKeys: [.contentModificationDateKey, .creationDateKey]), + let finishedAt = values.contentModificationDate ?? values.creationDate else { + return nil + } + return finishedAt.addingTimeInterval(-duration) + } + return estimatedStarts.min() ?? fallback + } + func cancelRecording() { cancelCapture(removeFiles: true) lastRecoveryAudioFolderName = nil diff --git a/meetingnotes/Managers/LocalStorageManager.swift b/meetingnotes/Managers/LocalStorageManager.swift index a954355..86bd587 100644 --- a/meetingnotes/Managers/LocalStorageManager.swift +++ b/meetingnotes/Managers/LocalStorageManager.swift @@ -152,6 +152,98 @@ class LocalStorageManager { } } + func mergeMeeting(_ continuation: Meeting, into previous: Meeting) -> Meeting? { + guard continuation.id != previous.id, continuation.date >= previous.date else { return nil } + + let previousFolder = recoveryDirectory.appendingPathComponent(previous.id.uuidString, isDirectory: true) + let continuationFolder = recoveryAudioFolder(named: continuation.id.uuidString) + var copiedAudioURLs: [URL] = [] + var createdPreviousFolder = false + + if let continuationFolder { + if !FileManager.default.fileExists(atPath: previousFolder.path) { + do { + try FileManager.default.createDirectory(at: previousFolder, withIntermediateDirectories: true) + createdPreviousFolder = true + } catch { + return nil + } + } + + for audioFile in recoveryAudioFiles(in: continuationFolder) { + let destination = previousFolder.appendingPathComponent(audioFile.url.lastPathComponent) + if FileManager.default.fileExists(atPath: destination.path) { + guard FileManager.default.contentsEqual( + atPath: audioFile.url.path, + andPath: destination.path + ) else { + rollbackMergedAudio(copiedAudioURLs, removeFolder: createdPreviousFolder ? previousFolder : nil) + return nil + } + continue + } + + do { + try FileManager.default.copyItem(at: audioFile.url, to: destination) + copiedAudioURLs.append(destination) + } catch { + rollbackMergedAudio(copiedAudioURLs, removeFolder: createdPreviousFolder ? previousFolder : nil) + return nil + } + } + } + + var merged = previous + var seenChunkIDs = Set() + merged.transcriptChunks = (previous.transcriptChunks + continuation.transcriptChunks) + .filter { seenChunkIDs.insert($0.id).inserted } + .sorted { + if $0.timestamp == $1.timestamp { + return $0.id.uuidString < $1.id.uuidString + } + return $0.timestamp < $1.timestamp + } + merged.userNotes = mergedText(previous.userNotes, continuation.userNotes) + merged.generatedNotes = mergedText(previous.generatedNotes, continuation.generatedNotes) + merged.templateId = previous.templateId ?? continuation.templateId + if !recoveryAudioFiles(in: previousFolder).isEmpty { + merged.recoveryAudioFolderName = previous.id.uuidString + } + + guard saveMeeting(merged) else { + rollbackMergedAudio(copiedAudioURLs, removeFolder: createdPreviousFolder ? previousFolder : nil) + return nil + } + + guard deleteMeeting(continuation) else { + _ = saveMeeting(previous) + rollbackMergedAudio(copiedAudioURLs, removeFolder: createdPreviousFolder ? previousFolder : nil) + return nil + } + + if let continuationFolder { + try? FileManager.default.removeItem(at: continuationFolder) + } + return merged + } + + private func mergedText(_ first: String, _ second: String) -> String { + let first = first.trimmingCharacters(in: .whitespacesAndNewlines) + let second = second.trimmingCharacters(in: .whitespacesAndNewlines) + guard !first.isEmpty else { return second } + guard !second.isEmpty, second != first else { return first } + return first + "\n\n---\n\n" + second + } + + private func rollbackMergedAudio(_ copiedURLs: [URL], removeFolder folder: URL?) { + for url in copiedURLs { + try? FileManager.default.removeItem(at: url) + } + if let folder, recoveryAudioFiles(in: folder).isEmpty { + try? FileManager.default.removeItem(at: folder) + } + } + // MARK: - Recovery Audio func preserveAudioFiles(_ urls: [URL], for meetingID: UUID) -> URL? { diff --git a/meetingnotes/ViewModels/MeetingListViewModel.swift b/meetingnotes/ViewModels/MeetingListViewModel.swift index 3821d82..69d616f 100644 --- a/meetingnotes/ViewModels/MeetingListViewModel.swift +++ b/meetingnotes/ViewModels/MeetingListViewModel.swift @@ -66,6 +66,28 @@ class MeetingListViewModel: ObservableObject { meetings.removeAll { $0.id == meeting.id } _ = LocalStorageManager.shared.deleteMeeting(meeting) } + + func previousMeeting(for meeting: Meeting) -> Meeting? { + meetings + .filter { $0.id != meeting.id && $0.date < meeting.date } + .max { $0.date < $1.date } + } + + func mergeIntoPrevious(_ meeting: Meeting) -> Meeting? { + guard let previous = previousMeeting(for: meeting), + let merged = LocalStorageManager.shared.mergeMeeting(meeting, into: previous) else { + errorMessage = "The meetings could not be merged. Their original records and audio were kept." + return nil + } + + meetings.removeAll { $0.id == meeting.id || $0.id == previous.id } + meetings.append(merged) + meetings.sort { $0.date > $1.date } + NotificationCenter.default.post(name: .meetingSaved, object: merged) + NotificationCenter.default.post(name: .meetingDeleted, object: meeting) + PostHogSDK.shared.capture("meetings_merged") + return merged + } func createNewMeeting() -> Meeting { let newMeeting = Meeting(templateId: LocalStorageManager.shared.preferredTemplateID()) diff --git a/meetingnotes/Views/MeetingListView.swift b/meetingnotes/Views/MeetingListView.swift index 61b654c..7f94cb5 100644 --- a/meetingnotes/Views/MeetingListView.swift +++ b/meetingnotes/Views/MeetingListView.swift @@ -83,10 +83,19 @@ struct MeetingListView: View { NavigationStack(path: $navigationPath) { Group { if let selectedMeeting = selectedMeeting { - MeetingDetailContentView(meeting: selectedMeeting, onDelete: { - // When a meeting is deleted from the detail view, clear the selection - self.selectedMeeting = nil - }) + MeetingDetailContentView( + meeting: selectedMeeting, + mergeCandidate: viewModel.previousMeeting(for: selectedMeeting), + onMerge: { meeting in + guard let merged = viewModel.mergeIntoPrevious(meeting) else { return false } + self.selectedMeeting = merged + return true + }, + onDelete: { + // When a meeting is deleted from the detail view, clear the selection + self.selectedMeeting = nil + } + ) .id(selectedMeeting.id) // Force recreation when selection changes } else { ContentUnavailableView( @@ -229,12 +238,22 @@ struct MeetingDetailContentView: View { @StateObject private var viewModel: MeetingViewModel @StateObject private var recordingSessionManager = RecordingSessionManager.shared @State private var showDeleteAlert = false + @State private var showMergeAlert = false @State private var isEditing = false @State private var showCopyConfirmation = false + let mergeCandidate: Meeting? + let onMerge: (Meeting) -> Bool let onDelete: () -> Void - init(meeting: Meeting, onDelete: @escaping () -> Void) { + init( + meeting: Meeting, + mergeCandidate: Meeting?, + onMerge: @escaping (Meeting) -> Bool, + onDelete: @escaping () -> Void + ) { self._viewModel = StateObject(wrappedValue: MeetingViewModel(meeting: meeting)) + self.mergeCandidate = mergeCandidate + self.onMerge = onMerge self.onDelete = onDelete } @@ -276,6 +295,17 @@ struct MeetingDetailContentView: View { Divider() } + if mergeCandidate != nil { + Button { + showMergeAlert = true + } label: { + Label("Merge into Previous Meeting", systemImage: "arrow.triangle.merge") + } + .disabled(recordingSessionManager.isRecording || recordingSessionManager.isProcessing) + + Divider() + } + Button("Delete Meeting", role: .destructive) { showDeleteAlert = true } @@ -461,6 +491,23 @@ struct MeetingDetailContentView: View { } message: { Text("Are you sure you want to delete this meeting? This action cannot be undone.") } + .alert("Merge into Previous Meeting?", isPresented: $showMergeAlert) { + Button("Merge", role: .destructive) { + // Prevent the disappearing detail view from auto-saving the + // continuation after the storage layer removes it. + viewModel.isDeleted = true + if !onMerge(viewModel.meeting) { + viewModel.isDeleted = false + viewModel.errorMessage = "The meetings could not be merged. Their original records and audio were kept." + } + } + Button("Cancel", role: .cancel) { } + } message: { + let previousTitle = mergeCandidate?.title.isEmpty == false + ? mergeCandidate?.title ?? "the previous meeting" + : "the previous meeting" + Text("This combines this meeting's transcript, notes, and saved audio into \"\(previousTitle)\", keeps the earlier meeting's title and time, then removes this continuation.") + } .onDisappear { // A failed recording may still be empty. Keep it until the user // explicitly deletes it so app updates cannot erase history.