From 00c19cb582fea2d55cb913a4b57c7a5c4775f3bf Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:27:46 -0400 Subject: [PATCH 01/21] Add summary attachments and note export support --- .../Models/EnhancedSummaryData.swift | 43 ++- .../Models/SummaryAttachmentStore.swift | 117 +++++++ .../Services/PDFExportService.swift | 62 ++++ .../Services/RTFExportService.swift | 19 +- .../BisonNotes AI/SummaryDetailView.swift | 308 +++++++++++++++++- 5 files changed, 543 insertions(+), 6 deletions(-) create mode 100644 BisonNotes AI/BisonNotes AI/Models/SummaryAttachmentStore.swift diff --git a/BisonNotes AI/BisonNotes AI/Models/EnhancedSummaryData.swift b/BisonNotes AI/BisonNotes AI/Models/EnhancedSummaryData.swift index 0aca188..6f8d22d 100644 --- a/BisonNotes AI/BisonNotes AI/Models/EnhancedSummaryData.swift +++ b/BisonNotes AI/BisonNotes AI/Models/EnhancedSummaryData.swift @@ -281,6 +281,33 @@ struct TitleItem: Codable, Identifiable, Equatable, Hashable, Sendable { } } +// MARK: - Summary Attachments + +struct SummaryAttachment: Codable, Identifiable, Equatable, Hashable, Sendable { + let id: UUID + let fileName: String + let storedFileName: String + let contentType: String? + let fileSize: Int64 + let createdAt: Date + + init( + id: UUID = UUID(), + fileName: String, + storedFileName: String, + contentType: String? = nil, + fileSize: Int64, + createdAt: Date = Date() + ) { + self.id = id + self.fileName = fileName + self.storedFileName = storedFileName + self.contentType = contentType + self.fileSize = fileSize + self.createdAt = createdAt + } +} + // MARK: - Enhanced Summary Data public struct EnhancedSummaryData: Codable, Identifiable, Sendable { @@ -296,6 +323,8 @@ public struct EnhancedSummaryData: Codable, Identifiable, Sendable { let tasks: [TaskItem] let reminders: [ReminderItem] let titles: [TitleItem] + let attachments: [SummaryAttachment] + let userNotes: String? // Metadata let contentType: ContentType @@ -324,7 +353,7 @@ public struct EnhancedSummaryData: Codable, Identifiable, Sendable { } // Legacy initializer for backward compatibility - init(recordingURL: URL, recordingName: String, recordingDate: Date, summary: String, tasks: [TaskItem] = [], reminders: [ReminderItem] = [], titles: [TitleItem] = [], contentType: ContentType = .general, aiEngine: String = "Unknown", aiModel: String, originalLength: Int, processingTime: TimeInterval = 0) { + init(recordingURL: URL, recordingName: String, recordingDate: Date, summary: String, tasks: [TaskItem] = [], reminders: [ReminderItem] = [], titles: [TitleItem] = [], attachments: [SummaryAttachment] = [], userNotes: String? = nil, contentType: ContentType = .general, aiEngine: String = "Unknown", aiModel: String, originalLength: Int, processingTime: TimeInterval = 0) { self.id = UUID() self.recordingId = nil self.transcriptId = nil @@ -335,6 +364,8 @@ public struct EnhancedSummaryData: Codable, Identifiable, Sendable { self.tasks = tasks.sorted { $0.priority.sortOrder < $1.priority.sortOrder } self.reminders = reminders.sorted { $0.urgency.sortOrder < $1.urgency.sortOrder } self.titles = titles.sorted { $0.confidence > $1.confidence } + self.attachments = attachments + self.userNotes = userNotes self.contentType = contentType self.aiEngine = aiEngine self.aiModel = aiModel @@ -353,7 +384,7 @@ public struct EnhancedSummaryData: Codable, Identifiable, Sendable { } // New initializer for unified architecture - init(recordingId: UUID, transcriptId: UUID? = nil, recordingURL: URL, recordingName: String, recordingDate: Date, summary: String, tasks: [TaskItem] = [], reminders: [ReminderItem] = [], titles: [TitleItem] = [], contentType: ContentType = .general, aiEngine: String = "Unknown", aiModel: String, originalLength: Int, processingTime: TimeInterval = 0) { + init(recordingId: UUID, transcriptId: UUID? = nil, recordingURL: URL, recordingName: String, recordingDate: Date, summary: String, tasks: [TaskItem] = [], reminders: [ReminderItem] = [], titles: [TitleItem] = [], attachments: [SummaryAttachment] = [], userNotes: String? = nil, contentType: ContentType = .general, aiEngine: String = "Unknown", aiModel: String, originalLength: Int, processingTime: TimeInterval = 0) { self.id = UUID() self.recordingId = recordingId self.transcriptId = transcriptId @@ -364,6 +395,8 @@ public struct EnhancedSummaryData: Codable, Identifiable, Sendable { self.tasks = tasks.sorted { $0.priority.sortOrder < $1.priority.sortOrder } self.reminders = reminders.sorted { $0.urgency.sortOrder < $1.urgency.sortOrder } self.titles = titles.sorted { $0.confidence > $1.confidence } + self.attachments = attachments + self.userNotes = userNotes self.contentType = contentType self.aiEngine = aiEngine self.aiModel = aiModel @@ -382,7 +415,7 @@ public struct EnhancedSummaryData: Codable, Identifiable, Sendable { } // Initializer for Core Data conversion that preserves the original ID - init(id: UUID, recordingId: UUID, transcriptId: UUID? = nil, recordingURL: URL, recordingName: String, recordingDate: Date, summary: String, tasks: [TaskItem] = [], reminders: [ReminderItem] = [], titles: [TitleItem] = [], contentType: ContentType = .general, aiEngine: String = "Unknown", aiModel: String, originalLength: Int, processingTime: TimeInterval = 0, generatedAt: Date? = nil, version: Int = 1, wordCount: Int? = nil, compressionRatio: Double? = nil, confidence: Double? = nil) { + init(id: UUID, recordingId: UUID, transcriptId: UUID? = nil, recordingURL: URL, recordingName: String, recordingDate: Date, summary: String, tasks: [TaskItem] = [], reminders: [ReminderItem] = [], titles: [TitleItem] = [], attachments: [SummaryAttachment] = [], userNotes: String? = nil, contentType: ContentType = .general, aiEngine: String = "Unknown", aiModel: String, originalLength: Int, processingTime: TimeInterval = 0, generatedAt: Date? = nil, version: Int = 1, wordCount: Int? = nil, compressionRatio: Double? = nil, confidence: Double? = nil) { self.id = id self.recordingId = recordingId self.transcriptId = transcriptId @@ -393,6 +426,8 @@ public struct EnhancedSummaryData: Codable, Identifiable, Sendable { self.tasks = tasks.sorted { $0.priority.sortOrder < $1.priority.sortOrder } self.reminders = reminders.sorted { $0.urgency.sortOrder < $1.urgency.sortOrder } self.titles = titles.sorted { $0.confidence > $1.confidence } + self.attachments = attachments + self.userNotes = userNotes self.contentType = contentType self.aiEngine = aiEngine self.aiModel = aiModel @@ -436,4 +471,4 @@ struct SummaryStatistics { var formattedAverageCompressionRatio: String { return String(format: "%.1f%%", averageCompressionRatio * 100) } -} \ No newline at end of file +} diff --git a/BisonNotes AI/BisonNotes AI/Models/SummaryAttachmentStore.swift b/BisonNotes AI/BisonNotes AI/Models/SummaryAttachmentStore.swift new file mode 100644 index 0000000..53bf664 --- /dev/null +++ b/BisonNotes AI/BisonNotes AI/Models/SummaryAttachmentStore.swift @@ -0,0 +1,117 @@ +import Foundation + +struct SummarySupplementalData: Codable, Sendable { + var userNotes: String? + var attachments: [SummaryAttachment] + + static let empty = SummarySupplementalData(userNotes: nil, attachments: []) +} + +final class SummaryAttachmentStore { + static let shared = SummaryAttachmentStore() + + private let fileManager = FileManager.default + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + private init() { + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } + + func load(for summaryId: UUID) -> SummarySupplementalData { + let metadataURL = metadataFileURL(for: summaryId) + guard let data = try? Data(contentsOf: metadataURL), + let decoded = try? decoder.decode(SummarySupplementalData.self, from: data) else { + return .empty + } + return decoded + } + + @discardableResult + func addAttachment(from sourceURL: URL, summaryId: UUID) throws -> SummaryAttachment { + let fileName = sourceURL.lastPathComponent + let id = UUID() + let destinationFolder = attachmentsDirectory(for: summaryId) + try fileManager.createDirectory(at: destinationFolder, withIntermediateDirectories: true) + + let sanitizedName = sanitizeFileName(fileName) + let storedFileName = "\(id.uuidString)_\(sanitizedName)" + let destinationURL = destinationFolder.appendingPathComponent(storedFileName) + + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + + try fileManager.copyItem(at: sourceURL, to: destinationURL) + + let attributes = try? fileManager.attributesOfItem(atPath: destinationURL.path) + let fileSize = (attributes?[.size] as? NSNumber)?.int64Value ?? 0 + + let attachment = SummaryAttachment( + id: id, + fileName: fileName, + storedFileName: storedFileName, + contentType: nil, + fileSize: fileSize, + createdAt: Date() + ) + + var supplemental = load(for: summaryId) + supplemental.attachments.insert(attachment, at: 0) + try save(supplemental, summaryId: summaryId) + + return attachment + } + + func removeAttachment(_ attachment: SummaryAttachment, summaryId: UUID) throws { + let fileURL = fileURL(for: attachment, summaryId: summaryId) + if fileManager.fileExists(atPath: fileURL.path) { + try fileManager.removeItem(at: fileURL) + } + + var supplemental = load(for: summaryId) + supplemental.attachments.removeAll { $0.id == attachment.id } + try save(supplemental, summaryId: summaryId) + } + + func saveUserNotes(_ notes: String?, summaryId: UUID) throws { + var supplemental = load(for: summaryId) + let trimmed = notes?.trimmingCharacters(in: .whitespacesAndNewlines) + supplemental.userNotes = (trimmed?.isEmpty == true) ? nil : notes + try save(supplemental, summaryId: summaryId) + } + + func fileURL(for attachment: SummaryAttachment, summaryId: UUID) -> URL { + attachmentsDirectory(for: summaryId).appendingPathComponent(attachment.storedFileName) + } + + private func save(_ supplemental: SummarySupplementalData, summaryId: UUID) throws { + let directory = storageDirectory(for: summaryId) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + let metadataURL = metadataFileURL(for: summaryId) + let data = try encoder.encode(supplemental) + try data.write(to: metadataURL, options: .atomic) + } + + private func rootDirectory() -> URL { + let documents = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first ?? fileManager.temporaryDirectory + return documents.appendingPathComponent("SummaryAttachments", isDirectory: true) + } + + private func storageDirectory(for summaryId: UUID) -> URL { + rootDirectory().appendingPathComponent(summaryId.uuidString, isDirectory: true) + } + + private func attachmentsDirectory(for summaryId: UUID) -> URL { + storageDirectory(for: summaryId).appendingPathComponent("files", isDirectory: true) + } + + private func metadataFileURL(for summaryId: UUID) -> URL { + storageDirectory(for: summaryId).appendingPathComponent("metadata.json") + } + + private func sanitizeFileName(_ fileName: String) -> String { + let invalidCharacters = CharacterSet(charactersIn: "\\/:*?\"<>|") + return fileName.components(separatedBy: invalidCharacters).joined(separator: "_") + } +} diff --git a/BisonNotes AI/BisonNotes AI/Services/PDFExportService.swift b/BisonNotes AI/BisonNotes AI/Services/PDFExportService.swift index 0451d82..d8a249b 100644 --- a/BisonNotes AI/BisonNotes AI/Services/PDFExportService.swift +++ b/BisonNotes AI/BisonNotes AI/Services/PDFExportService.swift @@ -133,6 +133,21 @@ class PDFExportService { exportDate: exportDate ) + if let notes = summaryData.userNotes?.trimmingCharacters(in: .whitespacesAndNewlines), + !notes.isEmpty { + currentY = drawUserNotesSection( + notes, + at: currentY, + contentWidth: contentWidth, + margins: margins, + context: context, + pageSize: pageSize, + contentBottom: contentBottom, + pageNumber: &pageNumber, + exportDate: exportDate + ) + } + // Tasks if !summaryData.tasks.isEmpty { currentY = drawTasksSection( @@ -575,6 +590,53 @@ class PDFExportService { return currentY + 10 } + private func drawUserNotesSection( + _ notes: String, + at y: CGFloat, + contentWidth: CGFloat, + margins: UIEdgeInsets, + context: UIGraphicsPDFRendererContext, + pageSize: CGSize, + contentBottom: CGFloat, + pageNumber: inout Int, + exportDate: Date + ) -> CGFloat { + var currentY = y + + currentY = checkAndStartNewPageWithBranding( + currentY: currentY, + requiredHeight: 80, + pageSize: pageSize, + margins: margins, + context: context, + contentBottom: contentBottom, + pageNumber: &pageNumber, + exportDate: exportDate + ) + + currentY = drawSectionTitle("User Notes", at: currentY, contentWidth: contentWidth, margins: margins, context: context) + + let attributed = SummaryExportFormatter.attributedSummary( + for: notes, + baseFontSize: 12, + textColor: .black + ) + + currentY = drawAttributedSummary( + attributed, + at: currentY, + contentWidth: contentWidth, + margins: margins, + context: context, + pageSize: pageSize, + contentBottom: contentBottom, + pageNumber: &pageNumber, + exportDate: exportDate + ) + + return currentY + 12 + } + private func drawRemindersSection( _ reminders: [ReminderItem], at y: CGFloat, diff --git a/BisonNotes AI/BisonNotes AI/Services/RTFExportService.swift b/BisonNotes AI/BisonNotes AI/Services/RTFExportService.swift index 08f5e81..80068f2 100644 --- a/BisonNotes AI/BisonNotes AI/Services/RTFExportService.swift +++ b/BisonNotes AI/BisonNotes AI/Services/RTFExportService.swift @@ -55,6 +55,7 @@ final class RTFExportService { } appendSummarySection(for: summaryData, to: document) + appendNotesSection(for: summaryData, to: document) // Always include sections even if empty to match PDF export quality if !summaryData.tasks.isEmpty { @@ -316,6 +317,23 @@ final class RTFExportService { document.append(withSpacing) } + private func appendNotesSection(for summaryData: EnhancedSummaryData, to document: NSMutableAttributedString) { + guard let notes = summaryData.userNotes?.trimmingCharacters(in: .whitespacesAndNewlines), + !notes.isEmpty else { + return + } + + appendSectionTitle("User Notes", to: document) + let attributed = SummaryExportFormatter.attributedSummary( + for: notes, + baseFontSize: 12, + textColor: .black + ) + let withSpacing = NSMutableAttributedString(attributedString: attributed) + withSpacing.append(NSAttributedString(string: "\n\n")) + document.append(withSpacing) + } + private func appendTasksSection(tasks: [TaskItem], to document: NSMutableAttributedString) { appendSectionTitle("Tasks (\(tasks.count))", to: document) @@ -541,4 +559,3 @@ final class RTFExportService { document.append(list) } } - diff --git a/BisonNotes AI/BisonNotes AI/SummaryDetailView.swift b/BisonNotes AI/BisonNotes AI/SummaryDetailView.swift index 8d9b25b..604743e 100644 --- a/BisonNotes AI/BisonNotes AI/SummaryDetailView.swift +++ b/BisonNotes AI/BisonNotes AI/SummaryDetailView.swift @@ -4,6 +4,8 @@ import Contacts @preconcurrency import CoreLocation import UIKit import LinkPresentation +import UniformTypeIdentifiers +import PDFKit private actor SummaryGeocodeCache { enum Entry: Sendable { @@ -59,6 +61,15 @@ struct SummaryDetailView: View { @State private var exportError: String? @State private var geocodingTask: Task? @State private var showingExportFormatPicker = false + @State private var showingAttachmentPicker = false + @State private var attachmentError: String? + @State private var attachments: [SummaryAttachment] = [] + @State private var noteDraft: String = "" + @State private var showingTextAttachment = false + @State private var showingPDFAttachment = false + @State private var selectedAttachmentName: String = "" + @State private var selectedAttachmentText: String = "" + @State private var selectedAttachmentPDFURL: URL? private enum ExportFormat { case pdf @@ -161,6 +172,9 @@ struct SummaryDetailView: View { // Titles Section (Expandable) titlesSection + + // Attachments + Note Section + attachmentsSection // Date/Time Editor Section dateTimeEditorSection @@ -195,6 +209,7 @@ struct SummaryDetailView: View { } scheduleLocationGeocoding() + loadSupplementalSummaryData() } .onDisappear { geocodingTask?.cancel() @@ -293,6 +308,55 @@ struct SummaryDetailView: View { } message: { Text("Export includes summary, tasks, reminders, and processing details.") } + .fileImporter( + isPresented: $showingAttachmentPicker, + allowedContentTypes: [.item], + allowsMultipleSelection: true + ) { result in + handleAttachmentImport(result) + } + .sheet(isPresented: $showingTextAttachment) { + NavigationView { + ScrollView { + Text(selectedAttachmentText) + .font(.body.monospaced()) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .navigationTitle(selectedAttachmentName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + showingTextAttachment = false + } + } + } + } + } + .sheet(isPresented: $showingPDFAttachment) { + if let selectedAttachmentPDFURL { + NavigationView { + SummaryAttachmentPDFView(url: selectedAttachmentPDFURL) + .navigationTitle(selectedAttachmentName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + showingPDFAttachment = false + } + } + } + } + } + } + .alert("Attachment Error", isPresented: .constant(attachmentError != nil)) { + Button("OK") { + attachmentError = nil + } + } message: { + Text(attachmentError ?? "") + } } // MARK: - Geocoding Helpers @@ -820,7 +884,81 @@ struct SummaryDetailView: View { } } } - + + // MARK: - Attachments Section + + private var attachmentsSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "paperclip") + .foregroundColor(.accentColor) + Text("Attachments & Notes") + .font(.headline) + Spacer() + Button { + showingAttachmentPicker = true + } label: { + Label("Attach File", systemImage: "plus.circle") + .font(.caption) + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Note") + .font(.subheadline) + .fontWeight(.semibold) + + TextEditor(text: $noteDraft) + .frame(minHeight: 110) + .padding(6) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .onChange(of: noteDraft) { _, _ in + saveUserNotes() + } + } + + if attachments.isEmpty { + emptyStateView(message: "No attachments yet", icon: "paperclip") + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(attachments, id: \.id) { attachment in + HStack(spacing: 10) { + Image(systemName: iconName(for: attachment)) + .foregroundColor(.secondary) + + VStack(alignment: .leading, spacing: 2) { + Text(attachment.fileName) + .font(.subheadline) + .lineLimit(2) + + Text(ByteCountFormatter.string(fromByteCount: attachment.fileSize, countStyle: .file)) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + + Button("Open") { + openAttachment(attachment) + } + .font(.caption) + + Button(role: .destructive) { + removeAttachment(attachment) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.plain) + } + .padding(10) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + } + } + // MARK: - Date/Time Editor Section private var dateTimeEditorSection: some View { @@ -1234,6 +1372,8 @@ struct SummaryDetailView: View { tasks: summaryData.tasks, reminders: summaryData.reminders, titles: summaryData.titles, + attachments: attachments, + userNotes: noteDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : noteDraft, contentType: summaryData.contentType, aiEngine: summaryData.aiEngine, aiModel: summaryData.aiModel, @@ -1298,6 +1438,8 @@ struct SummaryDetailView: View { tasks: summaryData.tasks, reminders: summaryData.reminders, titles: summaryData.titles, + attachments: attachments, + userNotes: noteDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : noteDraft, contentType: summaryData.contentType, aiEngine: summaryData.aiEngine, aiModel: summaryData.aiModel, @@ -1401,6 +1543,149 @@ struct SummaryDetailView: View { try appCoordinator.coreDataManager.saveContext() } + // MARK: - Attachments / Notes + + private func loadSupplementalSummaryData() { + let supplemental = SummaryAttachmentStore.shared.load(for: summaryData.id) + attachments = supplemental.attachments + noteDraft = supplemental.userNotes ?? "" + summaryData = rebuildSummaryData(userNotes: supplemental.userNotes, attachments: supplemental.attachments) + } + + private func saveUserNotes() { + do { + try SummaryAttachmentStore.shared.saveUserNotes(noteDraft, summaryId: summaryData.id) + summaryData = rebuildSummaryData(userNotes: noteDraft, attachments: attachments) + } catch { + attachmentError = "Unable to save notes: \(error.localizedDescription)" + } + } + + private func handleAttachmentImport(_ result: Result<[URL], Error>) { + do { + let urls = try result.get() + guard !urls.isEmpty else { return } + + for url in urls { + let accessed = url.startAccessingSecurityScopedResource() + defer { + if accessed { + url.stopAccessingSecurityScopedResource() + } + } + + let attachment = try SummaryAttachmentStore.shared.addAttachment(from: url, summaryId: summaryData.id) + attachments.insert(attachment, at: 0) + } + + summaryData = rebuildSummaryData(userNotes: noteDraft, attachments: attachments) + } catch { + attachmentError = "Unable to attach file: \(error.localizedDescription)" + } + } + + private func removeAttachment(_ attachment: SummaryAttachment) { + do { + try SummaryAttachmentStore.shared.removeAttachment(attachment, summaryId: summaryData.id) + attachments.removeAll { $0.id == attachment.id } + summaryData = rebuildSummaryData(userNotes: noteDraft, attachments: attachments) + } catch { + attachmentError = "Unable to remove attachment: \(error.localizedDescription)" + } + } + + private func openAttachment(_ attachment: SummaryAttachment) { + let url = SummaryAttachmentStore.shared.fileURL(for: attachment, summaryId: summaryData.id) + guard FileManager.default.fileExists(atPath: url.path) else { + attachmentError = "Attachment file no longer exists." + return + } + + let ext = url.pathExtension.lowercased() + if ext == "pdf" { + selectedAttachmentName = attachment.fileName + selectedAttachmentPDFURL = url + showingPDFAttachment = true + return + } + + let textBasedExtensions: Set = ["txt", "md", "markdown", "csv", "json", "log", "xml", "yaml", "yml"] + if textBasedExtensions.contains(ext), + let data = try? Data(contentsOf: url), + let text = String(data: data, encoding: .utf8) { + selectedAttachmentName = attachment.fileName + selectedAttachmentText = text + showingTextAttachment = true + return + } + + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + + private func iconName(for attachment: SummaryAttachment) -> String { + let ext = (attachment.fileName as NSString).pathExtension.lowercased() + switch ext { + case "pdf": + return "doc.richtext" + case "txt", "md", "markdown", "csv", "json": + return "doc.text" + case "doc", "docx": + return "doc" + case "jpg", "jpeg", "png", "heic", "gif": + return "photo" + default: + return "doc" + } + } + + private func rebuildSummaryData(userNotes: String?, attachments: [SummaryAttachment]) -> EnhancedSummaryData { + let normalizedNotes = userNotes?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true ? nil : userNotes + + if let recordingId = summaryData.recordingId { + return EnhancedSummaryData( + id: summaryData.id, + recordingId: recordingId, + transcriptId: summaryData.transcriptId, + recordingURL: summaryData.recordingURL, + recordingName: summaryData.recordingName, + recordingDate: summaryData.recordingDate, + summary: summaryData.summary, + tasks: summaryData.tasks, + reminders: summaryData.reminders, + titles: summaryData.titles, + attachments: attachments, + userNotes: normalizedNotes, + contentType: summaryData.contentType, + aiEngine: summaryData.aiEngine, + aiModel: summaryData.aiModel, + originalLength: summaryData.originalLength, + processingTime: summaryData.processingTime, + generatedAt: summaryData.generatedAt, + version: summaryData.version, + wordCount: summaryData.wordCount, + compressionRatio: summaryData.compressionRatio, + confidence: summaryData.confidence + ) + } + + return EnhancedSummaryData( + recordingURL: summaryData.recordingURL, + recordingName: summaryData.recordingName, + recordingDate: summaryData.recordingDate, + summary: summaryData.summary, + tasks: summaryData.tasks, + reminders: summaryData.reminders, + titles: summaryData.titles, + attachments: attachments, + userNotes: normalizedNotes, + contentType: summaryData.contentType, + aiEngine: summaryData.aiEngine, + aiModel: summaryData.aiModel, + originalLength: summaryData.originalLength, + processingTime: summaryData.processingTime + ) + } + // MARK: - Export Functions private func export(format: ExportFormat) { @@ -3087,6 +3372,27 @@ private struct StaticLocationMapView: View { } } +// MARK: - Attachment Preview + +private struct SummaryAttachmentPDFView: UIViewRepresentable { + let url: URL + + func makeUIView(context: Context) -> PDFView { + let view = PDFView() + view.autoScales = true + view.displayMode = .singlePageContinuous + view.displayDirection = .vertical + view.document = PDFDocument(url: url) + return view + } + + func updateUIView(_ uiView: PDFView, context: Context) { + if uiView.document?.documentURL != url { + uiView.document = PDFDocument(url: url) + } + } +} + // MARK: - Share Sheet struct ShareSheet: UIViewControllerRepresentable { From 7672b4c5e44af11595934413d2eb4a1da3e81469 Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:25:43 -0400 Subject: [PATCH 02/21] Add recording title editing from audio and transcript views --- .../BisonNotes AI/Views/AudioPlayerView.swift | 89 ++++++++++++++++++- .../BisonNotes AI/Views/TranscriptViews.swift | 87 ++++++++++++++++++ 2 files changed, 172 insertions(+), 4 deletions(-) diff --git a/BisonNotes AI/BisonNotes AI/Views/AudioPlayerView.swift b/BisonNotes AI/BisonNotes AI/Views/AudioPlayerView.swift index 64308c8..d902cab 100644 --- a/BisonNotes AI/BisonNotes AI/Views/AudioPlayerView.swift +++ b/BisonNotes AI/BisonNotes AI/Views/AudioPlayerView.swift @@ -11,9 +11,14 @@ import AVFoundation struct AudioPlayerView: View { let recording: AudioRecordingFile @EnvironmentObject var recorderVM: AudioRecorderViewModel + @EnvironmentObject var appCoordinator: AppDataCoordinator @Environment(\.dismiss) private var dismiss @State private var duration: TimeInterval = 0 @State private var showingShareSheet = false + @State private var editableTitle: String = "" + @State private var currentSavedTitle: String = "" + @State private var isUpdatingTitle = false + @State private var titleUpdateError: String? var body: some View { VStack(spacing: 20) { @@ -32,9 +37,33 @@ struct AudioPlayerView: View { .fontWeight(.bold) .foregroundColor(.primary) - Text("Recording: \(recording.name)") - .font(.title2) - .multilineTextAlignment(.center) + VStack(alignment: .leading, spacing: 8) { + Text("Recording Title") + .font(.caption) + .foregroundColor(.secondary) + + HStack(spacing: 8) { + TextField("Enter title", text: $editableTitle) + .textFieldStyle(.roundedBorder) + .disabled(isUpdatingTitle) + .onSubmit { + updateRecordingTitle() + } + + Button(action: updateRecordingTitle) { + if isUpdatingTitle { + ProgressView() + .scaleEffect(0.8) + } else { + Text("Save") + .fontWeight(.semibold) + } + } + .buttonStyle(.borderedProminent) + .disabled(isUpdatingTitle || editableTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || editableTitle.trimmingCharacters(in: .whitespacesAndNewlines) == currentSavedTitle) + } + } + .frame(maxWidth: .infinity) Text("Date: \(recording.dateString)") .font(.subheadline) @@ -117,8 +146,20 @@ struct AudioPlayerView: View { } .onAppear { AppLog.shared.recording("AudioPlayerView appeared", level: .debug) + editableTitle = recording.name + currentSavedTitle = recording.name setupAudio() } + .alert("Unable to Update Title", isPresented: Binding( + get: { titleUpdateError != nil }, + set: { if !$0 { titleUpdateError = nil } } + )) { + Button("OK", role: .cancel) { + titleUpdateError = nil + } + } message: { + Text(titleUpdateError ?? "Unknown error") + } .onDisappear { AppLog.shared.recording("AudioPlayerView disappeared", level: .debug) if recorderVM.isPlaying { @@ -168,4 +209,44 @@ struct AudioPlayerView: View { let seconds = Int(time) % 60 return String(format: "%d:%02d", minutes, seconds) } -} \ No newline at end of file + + private func updateRecordingTitle() { + let trimmedName = editableTitle.trimmingCharacters(in: .whitespacesAndNewlines) + guard !isUpdatingTitle, + !trimmedName.isEmpty, + trimmedName != recording.name else { + return + } + + guard let recordingEntry = appCoordinator.getRecording(url: recording.url), + let recordingId = recordingEntry.id else { + titleUpdateError = "Could not find this recording in storage." + return + } + + isUpdatingTitle = true + + Task { + do { + try appCoordinator.coreDataManager.updateRecordingName(for: recordingId, newName: trimmedName) + + await MainActor.run { + isUpdatingTitle = false + currentSavedTitle = trimmedName + editableTitle = trimmedName + NotificationCenter.default.post( + name: NSNotification.Name("RecordingRenamed"), + object: nil, + userInfo: ["recordingId": recordingId, "newName": trimmedName] + ) + AppLog.shared.recording("Updated recording title from AudioPlayerView to: \(trimmedName)") + } + } catch { + await MainActor.run { + isUpdatingTitle = false + titleUpdateError = error.localizedDescription + } + } + } + } +} diff --git a/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift b/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift index a55cdaa..1945dc2 100644 --- a/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift +++ b/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift @@ -1116,6 +1116,10 @@ struct EditableTranscriptView: View { @State private var editedSegments: [TranscriptSegment] @State private var speakerMappings: [String: String] @State private var isRerunningTranscription = false + @State private var editableRecordingName: String + @State private var savedRecordingName: String + @State private var isUpdatingRecordingName = false + @State private var recordingRenameError: String? @State private var showingRerunAlert = false @State private var showingSaveSuccessAlert = false @State private var showingSaveErrorAlert = false @@ -1139,6 +1143,9 @@ struct EditableTranscriptView: View { self.transcriptManager = transcriptManager self._editedSegments = State(initialValue: transcript.segments) self._speakerMappings = State(initialValue: transcript.speakerMappings) + let initialName = recording.recordingName ?? transcript.recordingName + self._editableRecordingName = State(initialValue: initialName) + self._savedRecordingName = State(initialValue: initialName) } var body: some View { @@ -1147,6 +1154,9 @@ struct EditableTranscriptView: View { // Transcript Content ScrollView { + VStack(alignment: .leading, spacing: 16) { + recordingTitleEditor + if editedSegments.isEmpty { VStack(spacing: 16) { Image(systemName: "doc.text") @@ -1190,6 +1200,7 @@ struct EditableTranscriptView: View { .padding(.vertical, 12) .id("transcript-\(editedSegments.count)-\(editedSegments.first?.text.prefix(10).hashValue ?? 0)") } + } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -1268,6 +1279,16 @@ struct EditableTranscriptView: View { } message: { Text(saveErrorMessage) } + .alert("Rename Failed", isPresented: Binding( + get: { recordingRenameError != nil }, + set: { if !$0 { recordingRenameError = nil } } + )) { + Button("OK", role: .cancel) { + recordingRenameError = nil + } + } message: { + Text(recordingRenameError ?? "Unknown error") + } .sheet(isPresented: $showingSpeakerEditor) { SpeakerEditingView( speakerIds: uniqueSpeakers, @@ -1307,6 +1328,36 @@ struct EditableTranscriptView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } + private var recordingTitleEditor: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Recording Title") + .font(.caption) + .foregroundColor(.secondary) + + HStack(spacing: 8) { + TextField("Enter title", text: $editableRecordingName) + .textFieldStyle(.roundedBorder) + .disabled(isUpdatingRecordingName) + .onSubmit { + renameRecordingFromTranscript() + } + + Button(action: renameRecordingFromTranscript) { + if isUpdatingRecordingName { + ProgressView() + .scaleEffect(0.8) + } else { + Text("Save") + .fontWeight(.semibold) + } + } + .buttonStyle(.borderedProminent) + .disabled(isUpdatingRecordingName || editableRecordingName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || editableRecordingName.trimmingCharacters(in: .whitespacesAndNewlines) == savedRecordingName) + } + } + .padding(.horizontal, 16) + } + private func saveTranscript() -> Bool { guard let recordingId = recording.id else { AppLog.shared.transcription("Cannot save transcript: missing recording ID", level: .error) @@ -1333,6 +1384,42 @@ struct EditableTranscriptView: View { return false } } + + private func renameRecordingFromTranscript() { + let trimmedName = editableRecordingName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !isUpdatingRecordingName, + !trimmedName.isEmpty, + trimmedName != savedRecordingName, + let recordingId = recording.id else { + return + } + + isUpdatingRecordingName = true + + Task { + do { + try appCoordinator.coreDataManager.updateRecordingName(for: recordingId, newName: trimmedName) + + await MainActor.run { + isUpdatingRecordingName = false + savedRecordingName = trimmedName + editableRecordingName = trimmedName + NotificationCenter.default.post( + name: NSNotification.Name("RecordingRenamed"), + object: nil, + userInfo: ["recordingId": recordingId, "newName": trimmedName] + ) + AppLog.shared.transcription("Updated recording title from transcript editor to: \(trimmedName)") + } + } catch { + await MainActor.run { + isUpdatingRecordingName = false + recordingRenameError = error.localizedDescription + } + AppLog.shared.transcription("Failed to update recording title from transcript editor: \(error)", level: .error) + } + } + } private func rerunTranscription() { AppLog.shared.transcription("Starting transcription rerun", level: .debug) From 480ec1c8949e0bfd503c8fac06c74114b536fd7a Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 23:09:49 +0000 Subject: [PATCH 03/21] Fix recording title editor bugs and refactor shared component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: Fix silent no-op when renaming back to original title in AudioPlayerView — guard now compares against currentSavedTitle instead of the immutable recording.name - P3: Extract RecordingTitleEditorView shared component to eliminate duplicate title-editor UI between AudioPlayerView and EditableTranscriptView - P4: Fix indentation regression in TranscriptViews.swift — re-indent the if/else block inside VStack to match recordingTitleEditor - P2: Add comments clarifying that both save paths update the display name only (consistent with SummaryDetailView pattern) Co-authored-by: Tim Champ --- .../BisonNotes AI/Views/AudioPlayerView.swift | 75 +++++++----- .../BisonNotes AI/Views/TranscriptViews.swift | 110 ++++++++---------- 2 files changed, 94 insertions(+), 91 deletions(-) diff --git a/BisonNotes AI/BisonNotes AI/Views/AudioPlayerView.swift b/BisonNotes AI/BisonNotes AI/Views/AudioPlayerView.swift index d902cab..6db80ec 100644 --- a/BisonNotes AI/BisonNotes AI/Views/AudioPlayerView.swift +++ b/BisonNotes AI/BisonNotes AI/Views/AudioPlayerView.swift @@ -37,32 +37,12 @@ struct AudioPlayerView: View { .fontWeight(.bold) .foregroundColor(.primary) - VStack(alignment: .leading, spacing: 8) { - Text("Recording Title") - .font(.caption) - .foregroundColor(.secondary) - - HStack(spacing: 8) { - TextField("Enter title", text: $editableTitle) - .textFieldStyle(.roundedBorder) - .disabled(isUpdatingTitle) - .onSubmit { - updateRecordingTitle() - } - - Button(action: updateRecordingTitle) { - if isUpdatingTitle { - ProgressView() - .scaleEffect(0.8) - } else { - Text("Save") - .fontWeight(.semibold) - } - } - .buttonStyle(.borderedProminent) - .disabled(isUpdatingTitle || editableTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || editableTitle.trimmingCharacters(in: .whitespacesAndNewlines) == currentSavedTitle) - } - } + RecordingTitleEditorView( + title: $editableTitle, + savedTitle: currentSavedTitle, + isSaving: isUpdatingTitle, + onSave: updateRecordingTitle + ) .frame(maxWidth: .infinity) Text("Date: \(recording.dateString)") @@ -214,7 +194,7 @@ struct AudioPlayerView: View { let trimmedName = editableTitle.trimmingCharacters(in: .whitespacesAndNewlines) guard !isUpdatingTitle, !trimmedName.isEmpty, - trimmedName != recording.name else { + trimmedName != currentSavedTitle else { return } @@ -228,6 +208,8 @@ struct AudioPlayerView: View { Task { do { + // Updates the display name only (recordingName field in Core Data). + // Physical audio file renaming is not performed here, consistent with SummaryDetailView. try appCoordinator.coreDataManager.updateRecordingName(for: recordingId, newName: trimmedName) await MainActor.run { @@ -250,3 +232,42 @@ struct AudioPlayerView: View { } } } + +/// Reusable title-editing row shared by AudioPlayerView and EditableTranscriptView. +struct RecordingTitleEditorView: View { + @Binding var title: String + let savedTitle: String + let isSaving: Bool + let onSave: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Recording Title") + .font(.caption) + .foregroundColor(.secondary) + + HStack(spacing: 8) { + TextField("Enter title", text: $title) + .textFieldStyle(.roundedBorder) + .disabled(isSaving) + .onSubmit { onSave() } + + Button(action: onSave) { + if isSaving { + ProgressView() + .scaleEffect(0.8) + } else { + Text("Save") + .fontWeight(.semibold) + } + } + .buttonStyle(.borderedProminent) + .disabled( + isSaving || + title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + title.trimmingCharacters(in: .whitespacesAndNewlines) == savedTitle + ) + } + } + } +} diff --git a/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift b/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift index 1945dc2..4753ce2 100644 --- a/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift +++ b/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift @@ -1157,49 +1157,49 @@ struct EditableTranscriptView: View { VStack(alignment: .leading, spacing: 16) { recordingTitleEditor - if editedSegments.isEmpty { - VStack(spacing: 16) { - Image(systemName: "doc.text") - .font(.system(size: 48)) - .foregroundColor(.gray) - Text("No transcript content available") - .font(.title2) - .foregroundColor(.secondary) - Text("Transcript segments: \(editedSegments.count)") - .font(.caption) - .foregroundColor(.gray) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - } else { - LazyVStack(alignment: .leading, spacing: 16) { - if !uniqueSpeakers.isEmpty { - Button(action: { showingSpeakerEditor = true }) { - HStack { - Image(systemName: "person.2.fill") - Text("Edit Speakers (\(uniqueSpeakers.count))") - Spacer() - Image(systemName: "chevron.right") - .font(.caption) + if editedSegments.isEmpty { + VStack(spacing: 16) { + Image(systemName: "doc.text") + .font(.system(size: 48)) + .foregroundColor(.gray) + Text("No transcript content available") + .font(.title2) + .foregroundColor(.secondary) + Text("Transcript segments: \(editedSegments.count)") + .font(.caption) + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } else { + LazyVStack(alignment: .leading, spacing: 16) { + if !uniqueSpeakers.isEmpty { + Button(action: { showingSpeakerEditor = true }) { + HStack { + Image(systemName: "person.2.fill") + Text("Edit Speakers (\(uniqueSpeakers.count))") + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + } + .font(.subheadline) + .fontWeight(.medium) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Color.purple.opacity(0.1)) + .foregroundColor(.purple) + .cornerRadius(10) } - .font(.subheadline) - .fontWeight(.medium) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(Color.purple.opacity(0.1)) - .foregroundColor(.purple) - .cornerRadius(10) } - } - ForEach(Array(editedSegments.enumerated()), id: \.offset) { index, segment in - TranscriptSegmentView(segment: $editedSegments[index], speakerMappings: speakerMappings) + ForEach(Array(editedSegments.enumerated()), id: \.offset) { index, segment in + TranscriptSegmentView(segment: $editedSegments[index], speakerMappings: speakerMappings) + } } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .id("transcript-\(editedSegments.count)-\(editedSegments.first?.text.prefix(10).hashValue ?? 0)") } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .id("transcript-\(editedSegments.count)-\(editedSegments.first?.text.prefix(10).hashValue ?? 0)") - } } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -1329,32 +1329,12 @@ struct EditableTranscriptView: View { } private var recordingTitleEditor: some View { - VStack(alignment: .leading, spacing: 8) { - Text("Recording Title") - .font(.caption) - .foregroundColor(.secondary) - - HStack(spacing: 8) { - TextField("Enter title", text: $editableRecordingName) - .textFieldStyle(.roundedBorder) - .disabled(isUpdatingRecordingName) - .onSubmit { - renameRecordingFromTranscript() - } - - Button(action: renameRecordingFromTranscript) { - if isUpdatingRecordingName { - ProgressView() - .scaleEffect(0.8) - } else { - Text("Save") - .fontWeight(.semibold) - } - } - .buttonStyle(.borderedProminent) - .disabled(isUpdatingRecordingName || editableRecordingName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || editableRecordingName.trimmingCharacters(in: .whitespacesAndNewlines) == savedRecordingName) - } - } + RecordingTitleEditorView( + title: $editableRecordingName, + savedTitle: savedRecordingName, + isSaving: isUpdatingRecordingName, + onSave: renameRecordingFromTranscript + ) .padding(.horizontal, 16) } @@ -1398,6 +1378,8 @@ struct EditableTranscriptView: View { Task { do { + // Updates the display name only (recordingName field in Core Data). + // Physical audio file renaming is not performed here, consistent with SummaryDetailView. try appCoordinator.coreDataManager.updateRecordingName(for: recordingId, newName: trimmedName) await MainActor.run { From d3bfcdffbb6706099441fd2b9399edd266da1892 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 23:26:23 +0000 Subject: [PATCH 04/21] Fix review issues: backward compat decode, file leak, main-thread read, QuickLook fallback, debounce, trim bug - EnhancedSummaryData: add custom init(from:) with decodeIfPresent for `attachments` and `userNotes` so legacy serialized summaries (missing those keys) still load without keyNotFound errors (P1 Codex review) - SummaryAttachmentStore: fix saveUserNotes storing untrimmed `notes` instead of `trimmed`; add deleteAll(for:) to remove per-summary folder; populate contentType via UTType(filenameExtension:) - SummaryDetailView: call deleteAll before deleteSummary to prevent orphaned attachment files on disk; wrap text file read in Task.detached so large files do not block the main thread; replace UIApplication.shared.open (broken for sandboxed files) with .quickLookPreview SwiftUI modifier; debounce saveUserNotes with a cancellable Task (500 ms) and guard against spurious saves during loadSupplementalSummaryData using isLoadingSupplemental flag Co-authored-by: Tim Champ --- .../Models/EnhancedSummaryData.swift | 39 ++++++++++++++++++- .../Models/SummaryAttachmentStore.swift | 14 ++++++- .../BisonNotes AI/SummaryDetailView.swift | 35 +++++++++++++---- 3 files changed, 78 insertions(+), 10 deletions(-) diff --git a/BisonNotes AI/BisonNotes AI/Models/EnhancedSummaryData.swift b/BisonNotes AI/BisonNotes AI/Models/EnhancedSummaryData.swift index 6f8d22d..b67d8fd 100644 --- a/BisonNotes AI/BisonNotes AI/Models/EnhancedSummaryData.swift +++ b/BisonNotes AI/BisonNotes AI/Models/EnhancedSummaryData.swift @@ -448,10 +448,47 @@ public struct EnhancedSummaryData: Codable, Identifiable, Sendable { var formattedCompressionRatio: String { return String(format: "%.1f%%", compressionRatio * 100) } - + var formattedProcessingTime: String { return String(format: "%.1fs", processingTime) } + + // Custom decoder so that legacy serialized summaries (which lack the + // `attachments` / `userNotes` keys) still decode successfully instead of + // throwing a keyNotFound error. + private enum CodingKeys: String, CodingKey { + case id, recordingId, transcriptId, recordingURL, recordingName, recordingDate + case summary, tasks, reminders, titles, attachments, userNotes + case contentType, aiEngine, aiModel, generatedAt, version + case wordCount, originalLength, compressionRatio, confidence, processingTime + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decode(UUID.self, forKey: .id) + recordingId = try c.decodeIfPresent(UUID.self, forKey: .recordingId) + transcriptId = try c.decodeIfPresent(UUID.self, forKey: .transcriptId) + recordingURL = try c.decode(URL.self, forKey: .recordingURL) + recordingName = try c.decode(String.self, forKey: .recordingName) + recordingDate = try c.decode(Date.self, forKey: .recordingDate) + summary = try c.decode(String.self, forKey: .summary) + tasks = try c.decode([TaskItem].self, forKey: .tasks) + reminders = try c.decode([ReminderItem].self, forKey: .reminders) + titles = try c.decode([TitleItem].self, forKey: .titles) + // New fields — fall back gracefully so legacy stored summaries still load. + attachments = try c.decodeIfPresent([SummaryAttachment].self, forKey: .attachments) ?? [] + userNotes = try c.decodeIfPresent(String.self, forKey: .userNotes) + contentType = try c.decode(ContentType.self, forKey: .contentType) + aiEngine = try c.decode(String.self, forKey: .aiEngine) + aiModel = try c.decode(String.self, forKey: .aiModel) + generatedAt = try c.decode(Date.self, forKey: .generatedAt) + version = try c.decode(Int.self, forKey: .version) + wordCount = try c.decode(Int.self, forKey: .wordCount) + originalLength = try c.decode(Int.self, forKey: .originalLength) + compressionRatio = try c.decode(Double.self, forKey: .compressionRatio) + confidence = try c.decode(Double.self, forKey: .confidence) + processingTime = try c.decode(TimeInterval.self, forKey: .processingTime) + } } // MARK: - Summary Statistics diff --git a/BisonNotes AI/BisonNotes AI/Models/SummaryAttachmentStore.swift b/BisonNotes AI/BisonNotes AI/Models/SummaryAttachmentStore.swift index 53bf664..aa25179 100644 --- a/BisonNotes AI/BisonNotes AI/Models/SummaryAttachmentStore.swift +++ b/BisonNotes AI/BisonNotes AI/Models/SummaryAttachmentStore.swift @@ -1,4 +1,5 @@ import Foundation +import UniformTypeIdentifiers struct SummarySupplementalData: Codable, Sendable { var userNotes: String? @@ -47,11 +48,13 @@ final class SummaryAttachmentStore { let attributes = try? fileManager.attributesOfItem(atPath: destinationURL.path) let fileSize = (attributes?[.size] as? NSNumber)?.int64Value ?? 0 + let contentType = UTType(filenameExtension: sourceURL.pathExtension)?.identifier + let attachment = SummaryAttachment( id: id, fileName: fileName, storedFileName: storedFileName, - contentType: nil, + contentType: contentType, fileSize: fileSize, createdAt: Date() ) @@ -77,10 +80,17 @@ final class SummaryAttachmentStore { func saveUserNotes(_ notes: String?, summaryId: UUID) throws { var supplemental = load(for: summaryId) let trimmed = notes?.trimmingCharacters(in: .whitespacesAndNewlines) - supplemental.userNotes = (trimmed?.isEmpty == true) ? nil : notes + supplemental.userNotes = (trimmed?.isEmpty == true) ? nil : trimmed try save(supplemental, summaryId: summaryId) } + func deleteAll(for summaryId: UUID) throws { + let dir = storageDirectory(for: summaryId) + if fileManager.fileExists(atPath: dir.path) { + try fileManager.removeItem(at: dir) + } + } + func fileURL(for attachment: SummaryAttachment, summaryId: UUID) -> URL { attachmentsDirectory(for: summaryId).appendingPathComponent(attachment.storedFileName) } diff --git a/BisonNotes AI/BisonNotes AI/SummaryDetailView.swift b/BisonNotes AI/BisonNotes AI/SummaryDetailView.swift index 604743e..f14c7d6 100644 --- a/BisonNotes AI/BisonNotes AI/SummaryDetailView.swift +++ b/BisonNotes AI/BisonNotes AI/SummaryDetailView.swift @@ -6,6 +6,7 @@ import UIKit import LinkPresentation import UniformTypeIdentifiers import PDFKit +import QuickLook private actor SummaryGeocodeCache { enum Entry: Sendable { @@ -65,11 +66,14 @@ struct SummaryDetailView: View { @State private var attachmentError: String? @State private var attachments: [SummaryAttachment] = [] @State private var noteDraft: String = "" + @State private var isLoadingSupplemental = false + @State private var noteSaveTask: Task? @State private var showingTextAttachment = false @State private var showingPDFAttachment = false @State private var selectedAttachmentName: String = "" @State private var selectedAttachmentText: String = "" @State private var selectedAttachmentPDFURL: URL? + @State private var selectedAttachmentGenericURL: URL? private enum ExportFormat { case pdf @@ -350,6 +354,7 @@ struct SummaryDetailView: View { } } } + .quickLookPreview($selectedAttachmentGenericURL) .alert("Attachment Error", isPresented: .constant(attachmentError != nil)) { Button("OK") { attachmentError = nil @@ -914,7 +919,13 @@ struct SummaryDetailView: View { .background(Color(.systemGray6)) .clipShape(RoundedRectangle(cornerRadius: 8)) .onChange(of: noteDraft) { _, _ in - saveUserNotes() + guard !isLoadingSupplemental else { return } + noteSaveTask?.cancel() + noteSaveTask = Task { + try? await Task.sleep(for: .milliseconds(500)) + guard !Task.isCancelled else { return } + saveUserNotes() + } } } @@ -1207,6 +1218,9 @@ struct SummaryDetailView: View { Task { do { + // Clean up attachment files before removing the Core Data entry. + try? SummaryAttachmentStore.shared.deleteAll(for: summaryData.id) + // Delete the summary locally and from iCloud try await appCoordinator.deleteSummary(id: summaryData.id) AppLog.shared.summarization("Summary deleted from Core Data") @@ -1546,6 +1560,8 @@ struct SummaryDetailView: View { // MARK: - Attachments / Notes private func loadSupplementalSummaryData() { + isLoadingSupplemental = true + defer { isLoadingSupplemental = false } let supplemental = SummaryAttachmentStore.shared.load(for: summaryData.id) attachments = supplemental.attachments noteDraft = supplemental.userNotes ?? "" @@ -1610,16 +1626,21 @@ struct SummaryDetailView: View { } let textBasedExtensions: Set = ["txt", "md", "markdown", "csv", "json", "log", "xml", "yaml", "yml"] - if textBasedExtensions.contains(ext), - let data = try? Data(contentsOf: url), - let text = String(data: data, encoding: .utf8) { + if textBasedExtensions.contains(ext) { selectedAttachmentName = attachment.fileName - selectedAttachmentText = text - showingTextAttachment = true + Task.detached { + guard let data = try? Data(contentsOf: url), + let text = String(data: data, encoding: .utf8) else { return } + await MainActor.run { + selectedAttachmentText = text + showingTextAttachment = true + } + } return } - UIApplication.shared.open(url, options: [:], completionHandler: nil) + // Fallback: use QuickLook which can open any file type within the app sandbox. + selectedAttachmentGenericURL = url } private func iconName(for attachment: SummaryAttachment) -> String { From 3b31d0560400f0ffa6b4e760e4d1d5f504b03000 Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:47:58 -0400 Subject: [PATCH 05/21] Redact private data from logs and improve note/attachment lifecycle Privacy: Strip user content (AI responses, recording names, GPS coordinates, file names, URLs) from all log statements across 15 files. Replace Watch app print() calls with os.Logger using privacy annotations. Retain diagnostic metadata (byte counts, HTTP status codes, UUIDs, boolean flags) so logs remain useful for troubleshooting. Features: Refactor summary note editor into a sheet, migrate supplemental data (notes/attachments) on summary regeneration, clean up attachment files on summary and recording deletion, update delete confirmation text to mention notes and attachments. Co-Authored-By: Claude Sonnet 4.6 --- .../WatchLocationManager.swift | 24 ++--- .../WatchRecordingStorage.swift | 40 ++++---- .../WatchRecordingViewModel.swift | 7 +- .../BisonNotes AI/BisonNotesAIApp.swift | 4 +- BisonNotes AI/BisonNotes AI/ContentView.swift | 12 ++- .../BisonNotes AI/EnhancedFileManager.swift | 12 +-- .../BisonNotes AI/FileImportManager.swift | 4 +- .../BisonNotes AI/FutureAIEngines.swift | 2 +- .../BisonNotes AI/GoogleAIStudioService.swift | 3 +- .../MistralAISummarizationService.swift | 5 +- .../Models/AppDataCoordinator.swift | 3 + .../Models/CoreDataManager.swift | 9 +- .../Models/RecordingWorkflowManager.swift | 15 +++ .../Models/SummaryAttachmentStore.swift | 13 +++ .../BisonNotes AI/OllamaService.swift | 2 +- .../OnDeviceLLMDownloadManager.swift | 4 +- .../OpenAITranscribeService.swift | 9 +- .../BisonNotes AI/SummaryDetailView.swift | 99 +++++++++++++++---- .../BisonNotes AI/SummaryManager.swift | 16 ++- .../SummaryRegenerationManager.swift | 24 ++--- .../TranscriptImportManager.swift | 2 +- .../Views/EnhancedDeleteDialog.swift | 2 +- .../ShareViewController.swift | 4 +- BisonNotes AI/Shared/WatchAudioChunk.swift | 6 +- 24 files changed, 211 insertions(+), 110 deletions(-) diff --git a/BisonNotes AI/BisonNotes AI Watch App/WatchLocationManager.swift b/BisonNotes AI/BisonNotes AI Watch App/WatchLocationManager.swift index 851cc43..17e2fd2 100644 --- a/BisonNotes AI/BisonNotes AI Watch App/WatchLocationManager.swift +++ b/BisonNotes AI/BisonNotes AI Watch App/WatchLocationManager.swift @@ -8,6 +8,7 @@ import Foundation @preconcurrency import CoreLocation import Combine +import os.log /// Location manager for Apple Watch to collect location data during recordings @MainActor @@ -23,6 +24,7 @@ class WatchLocationManager: NSObject, ObservableObject { private let locationManager = CLLocationManager() private var locationCompletion: ((CLLocation?) -> Void)? private var isRequestingLocation = false + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.bisonnotes.watchapp", category: "Location") override init() { super.init() @@ -44,14 +46,14 @@ class WatchLocationManager: NSObject, ObservableObject { /// Request location permission from user func requestLocationPermission() { - print("📍⌚ Requesting location permission on watch...") + logger.debug("Requesting location permission on watch") locationManager.requestWhenInUseAuthorization() } /// Get current location for recording func getCurrentLocation(completion: @escaping (CLLocation?) -> Void) { guard isLocationAvailable else { - print("📍⌚ Location not available") + logger.debug("Location not available") completion(nil) return } @@ -59,13 +61,13 @@ class WatchLocationManager: NSObject, ObservableObject { // If we have a recent location (less than 30 seconds old), use it if let currentLocation = currentLocation, currentLocation.timestamp.timeIntervalSinceNow > -30 { - print("📍⌚ Using cached location") + logger.debug("Using cached location") completion(currentLocation) return } // Request fresh location - print("📍⌚ Requesting fresh location...") + logger.debug("Requesting fresh location") locationCompletion = completion isRequestingLocation = true locationManager.requestLocation() @@ -75,13 +77,13 @@ class WatchLocationManager: NSObject, ObservableObject { func startLocationUpdates() { guard isLocationAvailable else { return } - print("📍⌚ Starting location monitoring...") + logger.debug("Starting location monitoring") locationManager.startUpdatingLocation() } /// Stop monitoring location changes func stopLocationUpdates() { - print("📍⌚ Stopping location monitoring...") + logger.debug("Stopping location monitoring") locationManager.stopUpdatingLocation() isRequestingLocation = false locationCompletion = nil @@ -95,7 +97,7 @@ class WatchLocationManager: NSObject, ObservableObject { let servicesEnabled = CLLocationManager.locationServicesEnabled() await MainActor.run { self.isLocationAvailable = (self.authorizationStatus == .authorizedWhenInUse || self.authorizationStatus == .authorizedAlways) && servicesEnabled - print("📍⌚ Location availability updated: \(self.isLocationAvailable)") + self.logger.debug("Location availability updated: \(self.isLocationAvailable, privacy: .public)") } } } @@ -109,7 +111,7 @@ class WatchLocationManager: NSObject, ObservableObject { isRequestingLocation = false } - print("📍⌚ Location updated: \(location.coordinate.latitude), \(location.coordinate.longitude), accuracy: \(location.horizontalAccuracy)m") + logger.debug("Location updated, accuracy: \(location.horizontalAccuracy, privacy: .public)m") } private func handleLocationError(_ error: Error) { @@ -121,7 +123,7 @@ class WatchLocationManager: NSObject, ObservableObject { isRequestingLocation = false } - print("📍⌚ Location error: \(error.localizedDescription)") + logger.error("Location error: \(error.localizedDescription, privacy: .public)") } } @@ -134,7 +136,7 @@ extension WatchLocationManager: CLLocationManagerDelegate { // Filter out invalid or inaccurate locations guard location.horizontalAccuracy < 100 && location.horizontalAccuracy > 0 else { - print("📍⌚ Location accuracy too low: \(location.horizontalAccuracy)m") + Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.bisonnotes.watchapp", category: "Location").debug("Location accuracy too low: \(location.horizontalAccuracy, privacy: .public)m") return } @@ -150,7 +152,7 @@ extension WatchLocationManager: CLLocationManagerDelegate { } nonisolated func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { - print("📍⌚ Location authorization changed to: \(status.rawValue)") + Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.bisonnotes.watchapp", category: "Location").debug("Location authorization changed to: \(status.rawValue, privacy: .public)") Task { @MainActor in authorizationStatus = status diff --git a/BisonNotes AI/BisonNotes AI Watch App/WatchRecordingStorage.swift b/BisonNotes AI/BisonNotes AI Watch App/WatchRecordingStorage.swift index 1e3234a..2cd4b69 100644 --- a/BisonNotes AI/BisonNotes AI Watch App/WatchRecordingStorage.swift +++ b/BisonNotes AI/BisonNotes AI Watch App/WatchRecordingStorage.swift @@ -8,6 +8,7 @@ import Foundation import CryptoKit import Combine +import os.log #if canImport(WatchKit) import WatchKit @@ -22,6 +23,9 @@ class WatchRecordingStorage: ObservableObject { @Published var storageUsed: Int64 = 0 @Published var availableStorage: Int64 = 0 + // MARK: - Logging + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.bisonnotes.watchapp", category: "RecordingStorage") + // MARK: - Private Properties private let fileManager = FileManager.default private let recordingsDirectoryName = "WatchRecordings" @@ -64,9 +68,9 @@ class WatchRecordingStorage: ObservableObject { try fileManager.createDirectory(at: recordingsSubdirectoryURL, withIntermediateDirectories: true, attributes: nil) - print("⌚ Storage directories created successfully") + logger.debug("Storage directories created successfully") } catch { - print("❌ Failed to create storage directories: \(error)") + logger.error("Failed to create storage directories: \(error.localizedDescription, privacy: .public)") } } @@ -109,11 +113,11 @@ class WatchRecordingStorage: ObservableObject { // Clean up old recordings if needed performStorageCleanup() - print("✅ Saved recording: \(filename) (\(fileSize) bytes)") + logger.debug("Saved recording (\(fileSize, privacy: .public) bytes)") return metadata } catch { - print("❌ Failed to save recording: \(error)") + logger.error("Failed to save recording: \(error.localizedDescription, privacy: .public)") return nil } } @@ -137,10 +141,10 @@ class WatchRecordingStorage: ObservableObject { saveRecordingsMetadata() updateStorageInfo() - print("🗑 Deleted recording: \(recording.filename)") + logger.debug("Deleted recording") } catch { - print("❌ Failed to delete recording: \(error)") + logger.error("Failed to delete recording: \(error.localizedDescription, privacy: .public)") } } @@ -157,7 +161,7 @@ class WatchRecordingStorage: ObservableObject { localRecordings[index] = metadata saveRecordingsMetadata() - print("📊 Updated sync status for \(metadata.filename): \(status.rawValue)") + logger.debug("Updated sync status: \(status.rawValue, privacy: .public)") } } @@ -190,10 +194,10 @@ class WatchRecordingStorage: ObservableObject { // Verify files still exist and clean up orphaned metadata cleanupOrphanedMetadata() - print("📱 Loaded \(localRecordings.count) recordings from metadata") - + logger.debug("Loaded \(self.localRecordings.count, privacy: .public) recordings from metadata") + } catch { - print("❌ Failed to load recordings metadata: \(error)") + logger.error("Failed to load recordings metadata: \(error.localizedDescription, privacy: .public)") localRecordings = [] } } @@ -202,9 +206,9 @@ class WatchRecordingStorage: ObservableObject { do { let data = try JSONEncoder().encode(localRecordings) try data.write(to: metadataFileURL) - print("💾 Saved recordings metadata") + logger.debug("Saved recordings metadata") } catch { - print("❌ Failed to save recordings metadata: \(error)") + logger.error("Failed to save recordings metadata: \(error.localizedDescription, privacy: .public)") } } @@ -215,14 +219,14 @@ class WatchRecordingStorage: ObservableObject { let fileURL = recordingsSubdirectoryURL.appendingPathComponent(metadata.filename) let exists = fileManager.fileExists(atPath: fileURL.path) if !exists { - print("🧹 Removing orphaned metadata for: \(metadata.filename)") + logger.debug("Removing orphaned recording metadata") } return exists } if localRecordings.count != originalCount { saveRecordingsMetadata() - print("🧹 Cleaned up \(originalCount - localRecordings.count) orphaned metadata entries") + logger.debug("Cleaned up \(originalCount - self.localRecordings.count, privacy: .public) orphaned metadata entries") } } @@ -242,7 +246,7 @@ class WatchRecordingStorage: ObservableObject { availableStorage = min(freeSpace, maxStorageUsage - storageUsed) } catch { - print("❌ Failed to get storage info: \(error)") + logger.error("Failed to get storage info: \(error.localizedDescription, privacy: .public)") availableStorage = max(0, maxStorageUsage - storageUsed) } } @@ -266,7 +270,7 @@ class WatchRecordingStorage: ObservableObject { } private func performAutomaticCleanup() { - print("🧹 Performing automatic storage cleanup...") + logger.debug("Performing automatic storage cleanup") // First, remove synced recordings (oldest first) let syncedRecordings = getSyncedRecordings() @@ -299,7 +303,7 @@ class WatchRecordingStorage: ObservableObject { } } - print("🧹 Cleanup complete. Storage: \(storageUsed) bytes, Recordings: \(localRecordings.count)") + logger.debug("Cleanup complete. Storage: \(self.storageUsed, privacy: .public) bytes, Recordings: \(self.localRecordings.count, privacy: .public)") } // MARK: - Utilities @@ -313,7 +317,7 @@ class WatchRecordingStorage: ObservableObject { let digest = Insecure.MD5.hash(data: data) return digest.map { String(format: "%02hhx", $0) }.joined() } catch { - print("❌ Failed to calculate checksum: \(error)") + logger.error("Failed to calculate checksum: \(error.localizedDescription, privacy: .public)") return nil } } diff --git a/BisonNotes AI/BisonNotes AI Watch App/WatchRecordingViewModel.swift b/BisonNotes AI/BisonNotes AI Watch App/WatchRecordingViewModel.swift index 1afb467..536e4f3 100644 --- a/BisonNotes AI/BisonNotes AI Watch App/WatchRecordingViewModel.swift +++ b/BisonNotes AI/BisonNotes AI Watch App/WatchRecordingViewModel.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI import Combine +import os.log #if canImport(WatchKit) import WatchKit @@ -537,7 +538,7 @@ class WatchRecordingViewModel: ObservableObject { accuracy: location.horizontalAccuracy ) self?.recordingStartLocation = watchLocationData - print("📍⌚ Captured recording location: \(location.coordinate.latitude), \(location.coordinate.longitude)") + Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.bisonnotes.watchapp", category: "Recording").debug("Captured recording location, accuracy: \(location.horizontalAccuracy, privacy: .public)m") } else { print("📍⌚ Failed to get recording location") } @@ -655,7 +656,7 @@ class WatchRecordingViewModel: ObservableObject { return } - print("⌚ Starting sync for recording: \(recording.filename)") + Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.bisonnotes.watchapp", category: "Recording").debug("Starting sync for recording") startRecordingSync(recording) } @@ -683,7 +684,7 @@ class WatchRecordingViewModel: ObservableObject { return } - print("⌚ Recording completed and saved locally: \(metadata.filename)") + Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.bisonnotes.watchapp", category: "Recording").debug("Recording completed and saved locally") // Update state recordingState = .processing diff --git a/BisonNotes AI/BisonNotes AI/BisonNotesAIApp.swift b/BisonNotes AI/BisonNotes AI/BisonNotesAIApp.swift index 9fbdb5f..58db379 100644 --- a/BisonNotes AI/BisonNotes AI/BisonNotesAIApp.swift +++ b/BisonNotes AI/BisonNotes AI/BisonNotesAIApp.swift @@ -623,10 +623,10 @@ struct BisonNotesAIApp: App { let textExtensions: Set = ["txt", "text", "md", "markdown", "pdf", "doc", "docx"] if audioExtensions.contains(ext) { - NSLog("📎 Importing audio file: \(url.lastPathComponent)") + NSLog("📎 Importing audio file (.\(ext))") await fileImportManager.importAudioFiles(from: [url]) } else if textExtensions.contains(ext) { - NSLog("📎 Importing text file: \(url.lastPathComponent)") + NSLog("📎 Importing text file (.\(ext))") await transcriptImportManager.importTranscriptFiles(from: [url]) } else { NSLog("📎 Unsupported file type: \(ext)") diff --git a/BisonNotes AI/BisonNotes AI/ContentView.swift b/BisonNotes AI/BisonNotes AI/ContentView.swift index a757d70..95833c0 100644 --- a/BisonNotes AI/BisonNotes AI/ContentView.swift +++ b/BisonNotes AI/BisonNotes AI/ContentView.swift @@ -172,11 +172,13 @@ struct ContentView: View { } .alert("Unexpected Shutdown", isPresented: $showingCrashReport) { Button("Send Report") { - do { - let url = try LogExporter.exportLogs() - LogEmailPresenter.shared.presentLogEmail(logFileURL: url) {} - } catch { - AppLog.shared.error("Failed to generate crash report: \(error.localizedDescription)", category: .general) + Task { + do { + let url = try await LogExporter.exportLogs() + LogEmailPresenter.shared.presentLogEmail(logFileURL: url) {} + } catch { + AppLog.shared.error("Failed to generate crash report: \(error.localizedDescription)", category: .general) + } } } Button("Dismiss", role: .cancel) { } diff --git a/BisonNotes AI/BisonNotes AI/EnhancedFileManager.swift b/BisonNotes AI/BisonNotes AI/EnhancedFileManager.swift index 974c5ff..d9e715c 100644 --- a/BisonNotes AI/BisonNotes AI/EnhancedFileManager.swift +++ b/BisonNotes AI/BisonNotes AI/EnhancedFileManager.swift @@ -380,7 +380,7 @@ class EnhancedFileManager: ObservableObject { // Delete transcript if present if let transcript = await appCoordinator.coreDataManager.getTranscript(for: recordingId) { await appCoordinator.coreDataManager.deleteTranscript(id: transcript.id) - AppLog.shared.fileManagement("Deleted transcript for: \(relationships.recordingName)") + AppLog.shared.fileManagement("Deleted transcript for recording") } // Keep summary linked to the recording; ensure IDs/relationships are consistent @@ -398,7 +398,7 @@ class EnhancedFileManager: ObservableObject { // Persist changes do { try await appCoordinator.coreDataManager.saveContext() - AppLog.shared.fileManagement("Preserved summary (kept recording entry, removed transcript) for: \(relationships.recordingName)") + AppLog.shared.fileManagement("Preserved summary (kept recording entry, removed transcript)") } catch { AppLog.shared.fileManagement("Error saving preservation changes: \(error)", level: .error) } @@ -416,7 +416,7 @@ class EnhancedFileManager: ObservableObject { } else { // Delete everything (recording, transcript, and summary) await appCoordinator.deleteRecording(id: recordingId) - AppLog.shared.fileManagement("Deleted recording, transcript, and summary for: \(relationships.recordingName)") + AppLog.shared.fileManagement("Deleted recording, transcript, and summary") // Remove the relationship entirely await MainActor.run { @@ -425,7 +425,7 @@ class EnhancedFileManager: ObservableObject { } } - AppLog.shared.fileManagement("Recording deletion completed: \(relationships.recordingName)") + AppLog.shared.fileManagement("Recording deletion completed") } func deleteSummary(for url: URL) async throws { @@ -438,7 +438,7 @@ class EnhancedFileManager: ObservableObject { // This will now be handled by the coordinator // let manager = await summaryManager // await MainActor.run { manager.deleteSummary(for: url) } - AppLog.shared.fileManagement("Deleted summary for: \(relationships.recordingName)") + AppLog.shared.fileManagement("Deleted summary for recording") } // Update relationships @@ -470,7 +470,7 @@ class EnhancedFileManager: ObservableObject { if relationships.transcriptExists { // This will now be handled by the coordinator // transcriptManager.deleteTranscript(for: url) - AppLog.shared.fileManagement("Deleted transcript for: \(relationships.recordingName)") + AppLog.shared.fileManagement("Deleted transcript for recording") } // Update relationships diff --git a/BisonNotes AI/BisonNotes AI/FileImportManager.swift b/BisonNotes AI/BisonNotes AI/FileImportManager.swift index c0c054b..42cf8b2 100644 --- a/BisonNotes AI/BisonNotes AI/FileImportManager.swift +++ b/BisonNotes AI/BisonNotes AI/FileImportManager.swift @@ -252,7 +252,7 @@ class FileImportManager: NSObject, ObservableObject { do { let existingRecordings = try context.fetch(fetchRequest) if !existingRecordings.isEmpty { - AppLog.shared.fileManagement("Recording entry already exists: \(recordingName)", level: .debug) + AppLog.shared.fileManagement("Recording entry already exists for imported file", level: .debug) return } } catch { @@ -296,7 +296,7 @@ class FileImportManager: NSObject, ObservableObject { // Save the context do { try context.save() - AppLog.shared.fileManagement("Created Core Data entry for imported file: \(recordingName)") + AppLog.shared.fileManagement("Created Core Data entry for imported file") } catch { AppLog.shared.fileManagement("Failed to save Core Data entry: \(error)", level: .error) throw ImportError.copyFailed("Failed to save to database: \(error.localizedDescription)") diff --git a/BisonNotes AI/BisonNotes AI/FutureAIEngines.swift b/BisonNotes AI/BisonNotes AI/FutureAIEngines.swift index f47ec7d..15c0649 100644 --- a/BisonNotes AI/BisonNotes AI/FutureAIEngines.swift +++ b/BisonNotes AI/BisonNotes AI/FutureAIEngines.swift @@ -1579,7 +1579,7 @@ class GoogleAIStudioEngine: SummarizationEngine { ) } catch { logger.error("GoogleAIStudioEngine: Failed to parse JSON response: \(error)") - logger.error("GoogleAIStudioEngine: Raw response: \(response)") + logger.error("GoogleAIStudioEngine: Response length: \(response.count) chars, starts with valid JSON: \(response.hasPrefix("{"))") } } diff --git a/BisonNotes AI/BisonNotes AI/GoogleAIStudioService.swift b/BisonNotes AI/BisonNotes AI/GoogleAIStudioService.swift index 0b5a439..adf0b44 100644 --- a/BisonNotes AI/BisonNotes AI/GoogleAIStudioService.swift +++ b/BisonNotes AI/BisonNotes AI/GoogleAIStudioService.swift @@ -327,7 +327,6 @@ class GoogleAIStudioService: ObservableObject { } logger.info("GoogleAIStudioService: Raw response length: \(textPart.text.count) characters") - logger.info("GoogleAIStudioService: Raw response preview: \(textPart.text.prefix(200))...") // Try to parse as JSON first if let jsonData = textPart.text.data(using: .utf8) { @@ -337,7 +336,7 @@ class GoogleAIStudioService: ObservableObject { return formatStructuredResponse(summaryResponse) } catch { logger.warning("GoogleAIStudioService: Failed to parse JSON response: \(error)") - logger.warning("GoogleAIStudioService: Raw response: \(textPart.text)") + logger.warning("GoogleAIStudioService: Raw response length: \(textPart.text.count) chars, starts with valid JSON: \(textPart.text.hasPrefix("{"))") // Check if the response is truncated if textPart.text.contains("\"summary\"") && !textPart.text.hasSuffix("}") { diff --git a/BisonNotes AI/BisonNotes AI/MistralAISummarizationService.swift b/BisonNotes AI/BisonNotes AI/MistralAISummarizationService.swift index 04e3637..fb5553c 100644 --- a/BisonNotes AI/BisonNotes AI/MistralAISummarizationService.swift +++ b/BisonNotes AI/BisonNotes AI/MistralAISummarizationService.swift @@ -231,9 +231,8 @@ class MistralAISummarizationService { logger.error("Mistral API Error: \(errorResponse.error.message, privacy: .public)") throw SummarizationError.aiServiceUnavailable(service: "Mistral API Error: \(errorResponse.error.message)") } else { - let responseString = String(data: data, encoding: .utf8) ?? "Unable to decode response" - logger.error("Mistral API Error: HTTP \(httpResponse.statusCode, privacy: .public)") - throw SummarizationError.aiServiceUnavailable(service: "Mistral API Error: HTTP \(httpResponse.statusCode) - \(responseString)") + logger.error("Mistral API Error: HTTP \(httpResponse.statusCode, privacy: .public), response size: \(data.count, privacy: .public) bytes") + throw SummarizationError.aiServiceUnavailable(service: "Mistral API Error: HTTP \(httpResponse.statusCode)") } } diff --git a/BisonNotes AI/BisonNotes AI/Models/AppDataCoordinator.swift b/BisonNotes AI/BisonNotes AI/Models/AppDataCoordinator.swift index 43ce890..987e966 100644 --- a/BisonNotes AI/BisonNotes AI/Models/AppDataCoordinator.swift +++ b/BisonNotes AI/BisonNotes AI/Models/AppDataCoordinator.swift @@ -151,6 +151,9 @@ class AppDataCoordinator: ObservableObject { } func deleteSummary(id: UUID) async throws { + // Clean up supplemental data (notes + attachment files) before removing the Core Data entry. + try? SummaryAttachmentStore.shared.deleteAll(for: id) + try coreDataManager.deleteSummary(id: id) do { try await SummaryManager.shared.getiCloudManager().deleteSummaryFromiCloud(id) diff --git a/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift b/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift index d2dfadc..fb2fc9c 100644 --- a/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift +++ b/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift @@ -649,10 +649,15 @@ class CoreDataManager: ObservableObject { AppLog.shared.coreData("Recording not found for deletion: \(id)", level: .error) return } - + + // Clean up supplemental data (notes + attachment files) before the cascade delete removes the summary entry. + if let summaryId = recording.summaryId { + try? SummaryAttachmentStore.shared.deleteAll(for: summaryId) + } + // Core Data will handle cascade deletion of related transcript and summary context.delete(recording) - + do { try context.save() AppLog.shared.coreData("Recording deleted: \(id)") diff --git a/BisonNotes AI/BisonNotes AI/Models/RecordingWorkflowManager.swift b/BisonNotes AI/BisonNotes AI/Models/RecordingWorkflowManager.swift index ecb6cc5..0af4acb 100644 --- a/BisonNotes AI/BisonNotes AI/Models/RecordingWorkflowManager.swift +++ b/BisonNotes AI/BisonNotes AI/Models/RecordingWorkflowManager.swift @@ -298,9 +298,24 @@ class RecordingWorkflowManager: ObservableObject { // NOW clean up old summaries using context directly (only after new one is safely saved) if !existingSummaries.isEmpty { + // Migrate supplemental data (notes/attachments) from the most recent old summary + // to the new summary so user data is not lost on regeneration. + if let primaryOld = existingSummaries.first, let oldId = primaryOld.id { + do { + try SummaryAttachmentStore.shared.migrate(from: oldId, to: summaryData.id) + AppLog.shared.backgroundProcessing("Migrated supplemental data from \(oldId) to \(summaryData.id)", level: .debug) + } catch { + AppLog.shared.backgroundProcessing("Failed to migrate supplemental data from \(oldId): \(error)", level: .error) + } + } + var deletedCount = 0 for oldSummary in existingSummaries { let oldId = oldSummary.id?.uuidString ?? "nil" + // Clean up any remaining supplemental folders that were not migrated + if let oldUUID = oldSummary.id, oldUUID != existingSummaries.first?.id { + try? SummaryAttachmentStore.shared.deleteAll(for: oldUUID) + } context.delete(oldSummary) deletedCount += 1 AppLog.shared.backgroundProcessing("Deleted old summary \(oldId)", level: .debug) diff --git a/BisonNotes AI/BisonNotes AI/Models/SummaryAttachmentStore.swift b/BisonNotes AI/BisonNotes AI/Models/SummaryAttachmentStore.swift index aa25179..0319a78 100644 --- a/BisonNotes AI/BisonNotes AI/Models/SummaryAttachmentStore.swift +++ b/BisonNotes AI/BisonNotes AI/Models/SummaryAttachmentStore.swift @@ -91,6 +91,19 @@ final class SummaryAttachmentStore { } } + /// Moves supplemental data (notes + attachments) from one summary ID to another. + /// Used when a summary is regenerated and receives a new UUID so existing user + /// data is not orphaned. + func migrate(from oldSummaryId: UUID, to newSummaryId: UUID) throws { + let oldDir = storageDirectory(for: oldSummaryId) + guard fileManager.fileExists(atPath: oldDir.path) else { return } + let newDir = storageDirectory(for: newSummaryId) + if fileManager.fileExists(atPath: newDir.path) { + try fileManager.removeItem(at: newDir) + } + try fileManager.moveItem(at: oldDir, to: newDir) + } + func fileURL(for attachment: SummaryAttachment, summaryId: UUID) -> URL { attachmentsDirectory(for: summaryId).appendingPathComponent(attachment.storedFileName) } diff --git a/BisonNotes AI/BisonNotes AI/OllamaService.swift b/BisonNotes AI/BisonNotes AI/OllamaService.swift index 4b51465..111c201 100644 --- a/BisonNotes AI/BisonNotes AI/OllamaService.swift +++ b/BisonNotes AI/BisonNotes AI/OllamaService.swift @@ -2570,7 +2570,7 @@ class OllamaService: ObservableObject { } } - throw OllamaError.parsingError("Failed to parse JSON response: \(error.localizedDescription). Raw response: \(String(data: data, encoding: .utf8) ?? "Unable to decode raw response")") + throw OllamaError.parsingError("Failed to parse JSON response: \(error.localizedDescription). Response length: \(data.count) bytes") } } diff --git a/BisonNotes AI/BisonNotes AI/OnDeviceLLM/OnDeviceLLMDownloadManager.swift b/BisonNotes AI/BisonNotes AI/OnDeviceLLM/OnDeviceLLMDownloadManager.swift index a1b4497..392c19d 100644 --- a/BisonNotes AI/BisonNotes AI/OnDeviceLLM/OnDeviceLLMDownloadManager.swift +++ b/BisonNotes AI/BisonNotes AI/OnDeviceLLM/OnDeviceLLMDownloadManager.swift @@ -124,7 +124,7 @@ public class OnDeviceLLMDownloadManager: NSObject, ObservableObject { } AppLog.shared.summarization("[OnDeviceLLMDownloadManager] Starting download for \(modelToDownload.displayName)") - AppLog.shared.summarization("[OnDeviceLLMDownloadManager] URL: \(url)", level: .debug) + AppLog.shared.summarization("[OnDeviceLLMDownloadManager] Starting download from host: \(url.host ?? "unknown")", level: .debug) // Cancel any existing download downloadTask?.cancel() @@ -399,7 +399,7 @@ extension OnDeviceLLMDownloadManager: URLSessionDownloadDelegate { } public nonisolated func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { - AppLog.shared.summarization("[OnDeviceLLMDownloadManager] Redirect to: \(request.url?.absoluteString ?? "unknown")", level: .debug) + AppLog.shared.summarization("[OnDeviceLLMDownloadManager] Redirect to host: \(request.url?.host ?? "unknown")", level: .debug) completionHandler(request) } diff --git a/BisonNotes AI/BisonNotes AI/OpenAITranscribeService.swift b/BisonNotes AI/BisonNotes AI/OpenAITranscribeService.swift index 4c8ff6e..2fa20fe 100644 --- a/BisonNotes AI/BisonNotes AI/OpenAITranscribeService.swift +++ b/BisonNotes AI/BisonNotes AI/OpenAITranscribeService.swift @@ -362,14 +362,13 @@ class OpenAITranscribeService: NSObject, ObservableObject { AppLog.shared.transcription("HTTP response status: \(httpResponse.statusCode)", level: .debug) guard httpResponse.statusCode == 200 else { - let errorText = String(data: data, encoding: .utf8) ?? "Unknown error" - AppLog.shared.transcription("OpenAI API error: HTTP \(httpResponse.statusCode)", level: .error) - - // Try to parse error response + AppLog.shared.transcription("OpenAI API error: HTTP \(httpResponse.statusCode), response size: \(data.count) bytes", level: .error) + + // Try to parse error response for the error code/type only if let errorResponse = try? JSONDecoder().decode(OpenAIErrorResponse.self, from: data) { throw OpenAITranscribeError.apiError(errorResponse.error.message) } else { - throw OpenAITranscribeError.apiError("HTTP \(httpResponse.statusCode): \(errorText)") + throw OpenAITranscribeError.apiError("HTTP \(httpResponse.statusCode)") } } diff --git a/BisonNotes AI/BisonNotes AI/SummaryDetailView.swift b/BisonNotes AI/BisonNotes AI/SummaryDetailView.swift index f14c7d6..463833a 100644 --- a/BisonNotes AI/BisonNotes AI/SummaryDetailView.swift +++ b/BisonNotes AI/BisonNotes AI/SummaryDetailView.swift @@ -68,6 +68,7 @@ struct SummaryDetailView: View { @State private var noteDraft: String = "" @State private var isLoadingSupplemental = false @State private var noteSaveTask: Task? + @State private var showingNoteEditor = false @State private var showingTextAttachment = false @State private var showingPDFAttachment = false @State private var selectedAttachmentName: String = "" @@ -251,7 +252,7 @@ struct SummaryDetailView: View { deleteSummary() } } message: { - Text("Are you sure you want to delete this summary? This action cannot be undone. The audio file and transcript will remain unchanged.") + Text("Are you sure you want to delete this summary? Any notes and attached files will also be deleted. This action cannot be undone. The audio file and transcript will remain unchanged.") } .sheet(isPresented: $showingLocationDetail) { if let locationData = recording.locationData { @@ -354,6 +355,11 @@ struct SummaryDetailView: View { } } } + .sheet(isPresented: $showingNoteEditor) { + NoteEditorSheet(text: $noteDraft) { + saveUserNotes() + } + } .quickLookPreview($selectedAttachmentGenericURL) .alert("Attachment Error", isPresented: .constant(attachmentError != nil)) { Button("OK") { @@ -908,25 +914,40 @@ struct SummaryDetailView: View { } } - VStack(alignment: .leading, spacing: 8) { - Text("Note") - .font(.subheadline) - .fontWeight(.semibold) - - TextEditor(text: $noteDraft) - .frame(minHeight: 110) - .padding(6) - .background(Color(.systemGray6)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .onChange(of: noteDraft) { _, _ in - guard !isLoadingSupplemental else { return } - noteSaveTask?.cancel() - noteSaveTask = Task { - try? await Task.sleep(for: .milliseconds(500)) - guard !Task.isCancelled else { return } - saveUserNotes() + if noteDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Button { + showingNoteEditor = true + } label: { + Label("Add Note", systemImage: "note.text.badge.plus") + .font(.subheadline) + } + } else { + VStack(alignment: .leading, spacing: 6) { + HStack { + Image(systemName: "note.text") + .foregroundColor(.secondary) + Text("Note") + .font(.subheadline) + .fontWeight(.semibold) + Spacer() + Button("Edit") { + showingNoteEditor = true } + .font(.caption) } + + Text(noteDraft) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(3) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .onTapGesture { + showingNoteEditor = true + } + } } if attachments.isEmpty { @@ -3518,6 +3539,46 @@ private final class ExportActivityItem: NSObject, UIActivityItemSource { } } +// MARK: - Note Editor Sheet + +private struct NoteEditorSheet: View { + @Binding var text: String + var onSave: () -> Void + @Environment(\.dismiss) private var dismiss + @State private var draft: String = "" + @FocusState private var isFocused: Bool + + var body: some View { + NavigationView { + VStack(spacing: 0) { + TextEditor(text: $draft) + .focused($isFocused) + .padding() + } + .navigationTitle("Note") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + text = draft + onSave() + dismiss() + } + } + } + } + .onAppear { + draft = text + isFocused = true + } + } +} + // MARK: - Helper Functions struct SafeConfidenceHelper { @@ -3525,4 +3586,4 @@ struct SafeConfidenceHelper { guard confidence.isFinite else { return 0 } return Int(confidence * 100) } -} +} diff --git a/BisonNotes AI/BisonNotes AI/SummaryManager.swift b/BisonNotes AI/BisonNotes AI/SummaryManager.swift index 8364ae6..5563318 100644 --- a/BisonNotes AI/BisonNotes AI/SummaryManager.swift +++ b/BisonNotes AI/BisonNotes AI/SummaryManager.swift @@ -101,7 +101,7 @@ class SummaryManager: ObservableObject { DispatchQueue.main.async { // Only log if verbose logging is enabled if PerformanceOptimizer.shouldLogEngineInitialization() { - AppLog.shared.summarization("saveEnhancedSummary() is deprecated - updating UI only for \(summary.recordingName)", level: .debug) + AppLog.shared.summarization("saveEnhancedSummary() is deprecated - updating UI only", level: .debug) } // Remove any existing enhanced summary for this recording @@ -145,7 +145,7 @@ class SummaryManager: ObservableObject { // Only update UI state, not persistence self.enhancedSummaries.append(summary) if PerformanceOptimizer.shouldLogEngineInitialization() { - AppLog.shared.summarization("Added summary to UI state only for \(summary.recordingName)", level: .debug) + AppLog.shared.summarization("Added summary to UI state", level: .debug) } } } @@ -173,9 +173,7 @@ class SummaryManager: ObservableObject { // Only log if verbose logging is enabled if PerformanceOptimizer.shouldLogEngineInitialization() { - AppLog.shared.summarization("Checking enhanced summary \(index): \(summary.recordingName)", level: .debug) - AppLog.shared.summarization("Stored filename: \(summaryFilename)", level: .debug) - AppLog.shared.summarization("Stored name: \(summaryName)", level: .debug) + AppLog.shared.summarization("Checking enhanced summary \(index)", level: .debug) } // Try multiple comparison methods @@ -986,7 +984,7 @@ class SummaryManager: ObservableObject { self.enhancedSummaries.append(shortTranscriptSummary) } if PerformanceOptimizer.shouldLogEngineInitialization() { - AppLog.shared.summarization("Updated UI state for short transcript summary: \(shortTranscriptSummary.recordingName)", level: .debug) + AppLog.shared.summarization("Updated UI state for short transcript summary", level: .debug) } } @@ -1130,7 +1128,7 @@ class SummaryManager: ObservableObject { self.enhancedSummaries.append(enhancedSummary) } if PerformanceOptimizer.shouldLogEngineInitialization() { - AppLog.shared.summarization("Updated UI state for enhanced summary: \(enhancedSummary.recordingName)", level: .debug) + AppLog.shared.summarization("Updated UI state for enhanced summary", level: .debug) } } @@ -1224,7 +1222,7 @@ class SummaryManager: ObservableObject { self.enhancedSummaries.append(shortTranscriptSummary) } if PerformanceOptimizer.shouldLogEngineInitialization() { - AppLog.shared.summarization("Updated UI state for short transcript summary: \(shortTranscriptSummary.recordingName)", level: .debug) + AppLog.shared.summarization("Updated UI state for short transcript summary", level: .debug) } } @@ -1291,7 +1289,7 @@ class SummaryManager: ObservableObject { self.enhancedSummaries.append(enhancedSummary) } if PerformanceOptimizer.shouldLogEngineInitialization() { - AppLog.shared.summarization("Updated UI state for basic enhanced summary: \(enhancedSummary.recordingName)", level: .debug) + AppLog.shared.summarization("Updated UI state for basic enhanced summary", level: .debug) } } diff --git a/BisonNotes AI/BisonNotes AI/SummaryRegenerationManager.swift b/BisonNotes AI/BisonNotes AI/SummaryRegenerationManager.swift index 021769d..a7db25b 100644 --- a/BisonNotes AI/BisonNotes AI/SummaryRegenerationManager.swift +++ b/BisonNotes AI/BisonNotes AI/SummaryRegenerationManager.swift @@ -81,11 +81,11 @@ class SummaryRegenerationManager: ObservableObject { // Note: Old summary cleanup now happens in RecordingWorkflowManager.createSummary // Debug: Show what names we're comparing (bulk regeneration) - AppLog.shared.summarization("Bulk regeneration name check: old='\(summary.recordingName)', new='\(newEnhancedSummary.recordingName)', equal=\(newEnhancedSummary.recordingName == summary.recordingName)", level: .debug) + AppLog.shared.summarization("Bulk regeneration name check: nameChanged=\(newEnhancedSummary.recordingName != summary.recordingName)", level: .debug) // Update the recording name if it changed during regeneration if newEnhancedSummary.recordingName != summary.recordingName { - AppLog.shared.summarization("Bulk regeneration: Recording name updated from '\(summary.recordingName)' to '\(newEnhancedSummary.recordingName)'") + AppLog.shared.summarization("Bulk regeneration: Recording name was updated by AI") // Update recording name in Core Data try appCoordinator.coreDataManager.updateRecordingName( for: recordingId, @@ -112,15 +112,15 @@ class SummaryRegenerationManager: ObservableObject { if newSummaryId != nil { successful += 1 - AppLog.shared.summarization("Regenerated summary for: \(summary.recordingName)") + AppLog.shared.summarization("Regenerated summary for recording \(recordingId)") } else { failed += 1 - errors.append("\(summary.recordingName): Failed to save new summary") + errors.append("Recording \(recordingId): Failed to save new summary") } } catch { failed += 1 - errors.append("\(summary.recordingName): \(error.localizedDescription)") + errors.append("Recording \(recordingId): \(error.localizedDescription)") } // Small delay to show progress @@ -147,12 +147,12 @@ class SummaryRegenerationManager: ObservableObject { let recordingData = appCoordinator.getCompleteRecordingData(id: recordingId), let summary = recordingData.summary, let transcript = recordingData.transcript else { - AppLog.shared.summarization("No summary or transcript found for URL: \(recordingURL.lastPathComponent)", level: .error) + AppLog.shared.summarization("No summary or transcript found for recording URL", level: .error) return false } do { - AppLog.shared.summarization("Regenerating summary for: \(summary.recordingName)") + AppLog.shared.summarization("Regenerating summary for recording \(recordingId)") // Generate new summary using the current AI engine let newEnhancedSummary = try await summaryManager.generateEnhancedSummary( @@ -164,11 +164,11 @@ class SummaryRegenerationManager: ObservableObject { // Note: Old summary cleanup now happens in RecordingWorkflowManager.createSummary - AppLog.shared.summarization("Regeneration name check: old='\(summary.recordingName)', new='\(newEnhancedSummary.recordingName)', equal=\(newEnhancedSummary.recordingName == summary.recordingName)", level: .debug) + AppLog.shared.summarization("Regeneration name check: nameChanged=\(newEnhancedSummary.recordingName != summary.recordingName)", level: .debug) // Update the recording name if it changed during regeneration if newEnhancedSummary.recordingName != summary.recordingName { - AppLog.shared.summarization("Recording name updated from '\(summary.recordingName)' to '\(newEnhancedSummary.recordingName)'") + AppLog.shared.summarization("Recording name was updated by AI for recording \(recordingId)") // Update recording name in Core Data try appCoordinator.coreDataManager.updateRecordingName( for: recordingId, @@ -194,15 +194,15 @@ class SummaryRegenerationManager: ObservableObject { ) if newSummaryId != nil { - AppLog.shared.summarization("Successfully regenerated summary for: \(summary.recordingName)") + AppLog.shared.summarization("Successfully regenerated summary for recording \(recordingId)") return true } else { - AppLog.shared.summarization("Failed to save new summary for: \(summary.recordingName)", level: .error) + AppLog.shared.summarization("Failed to save new summary for recording \(recordingId)", level: .error) return false } } catch { - AppLog.shared.summarization("Failed to regenerate summary for \(summary.recordingName): \(error)", level: .error) + AppLog.shared.summarization("Failed to regenerate summary for recording \(recordingId): \(error.localizedDescription)", level: .error) return false } } diff --git a/BisonNotes AI/BisonNotes AI/TranscriptImportManager.swift b/BisonNotes AI/BisonNotes AI/TranscriptImportManager.swift index d5d168f..f876153 100644 --- a/BisonNotes AI/BisonNotes AI/TranscriptImportManager.swift +++ b/BisonNotes AI/BisonNotes AI/TranscriptImportManager.swift @@ -886,7 +886,7 @@ class TranscriptImportManager: NSObject, ObservableObject { // Save the context do { try context.save() - AppLog.shared.transcription("Created transcript entry for imported transcript: \(recording.recordingName ?? "unknown")") + AppLog.shared.transcription("Created transcript entry for imported transcript") } catch { AppLog.shared.transcription("Failed to save transcript entry: \(error)", level: .error) throw TranscriptImportError.databaseError("Failed to save transcript: \(error.localizedDescription)") diff --git a/BisonNotes AI/BisonNotes AI/Views/EnhancedDeleteDialog.swift b/BisonNotes AI/BisonNotes AI/Views/EnhancedDeleteDialog.swift index 64ec98a..95f0bcb 100644 --- a/BisonNotes AI/BisonNotes AI/Views/EnhancedDeleteDialog.swift +++ b/BisonNotes AI/BisonNotes AI/Views/EnhancedDeleteDialog.swift @@ -114,7 +114,7 @@ struct EnhancedDeleteDialog: View { .fontWeight(.medium) .foregroundColor(.primary) - Text("Delete recording, transcript, and summary") + Text("Delete recording, transcript, summary, and any notes or attached files") .font(.caption) .foregroundColor(.secondary) } diff --git a/BisonNotes AI/BisonNotes Share/ShareViewController.swift b/BisonNotes AI/BisonNotes Share/ShareViewController.swift index 0d6309f..05e0d01 100644 --- a/BisonNotes AI/BisonNotes Share/ShareViewController.swift +++ b/BisonNotes AI/BisonNotes Share/ShareViewController.swift @@ -78,7 +78,7 @@ class ShareViewController: UIViewController { return } - NSLog("📎 Share Extension: received temp file: \(url.lastPathComponent)") + NSLog("📎 Share Extension: received temp file with extension: \(url.pathExtension)") // Verify the file extension is one we support let ext = url.pathExtension.lowercased() @@ -172,7 +172,7 @@ class ShareViewController: UIViewController { do { try FileManager.default.copyItem(at: url, to: destination) - NSLog("✅ Share Extension: saved \(url.lastPathComponent) → \(destination.lastPathComponent)") + NSLog("✅ Share Extension: saved file with extension: \(url.pathExtension)") return true } catch { NSLog("❌ Share Extension: copy failed: \(error.localizedDescription)") diff --git a/BisonNotes AI/Shared/WatchAudioChunk.swift b/BisonNotes AI/Shared/WatchAudioChunk.swift index cde494c..4b32957 100644 --- a/BisonNotes AI/Shared/WatchAudioChunk.swift +++ b/BisonNotes AI/Shared/WatchAudioChunk.swift @@ -187,7 +187,7 @@ class WatchAudioChunkManager: ObservableObject { orderedChunks.append(chunk) } else { // Create a silent chunk for the missing sequence - print("⚠️ Creating silent chunk for missing sequence \(i)") + // Silent chunk created for missing sequence let silentChunk = createSilentChunk(sequenceNumber: i, sessionId: sessionId) orderedChunks.append(silentChunk) } @@ -218,7 +218,7 @@ class WatchAudioChunkManager: ObservableObject { // First try to get complete chunks without gaps if let completeChunks = getAllChunksInOrder(), completeChunks.count == totalChunksExpected { - print("✅ Combining \(completeChunks.count) complete audio chunks") + // Combining complete audio chunks var combinedData = Data() for chunk in completeChunks { combinedData.append(chunk.audioData) @@ -228,7 +228,7 @@ class WatchAudioChunkManager: ObservableObject { // If we have missing chunks, use gap filling if let chunksWithGaps = getAllChunksWithGapFilling() { - print("⚠️ Combining \(chunksWithGaps.count) audio chunks with \(getMissingChunks().count) gaps filled with silence") + // Combining audio chunks with gaps filled with silence var combinedData = Data() for chunk in chunksWithGaps { combinedData.append(chunk.audioData) From 3325d2e0d9b57ea8bd2fe1d0333927d27ea16646 Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:52:34 -0400 Subject: [PATCH 06/21] Add archive-to-cloud feature for audio recordings Export audio files to iCloud Drive, Dropbox, Google Drive, or any provider via the iOS Files picker. After export, optionally remove local audio while keeping transcripts, summaries, and metadata. - Add isArchived, archivedAt, archiveNote attributes to RecordingEntry - Add RecordingArchiveService for archive/restore/query operations - Add DocumentExportPicker (UIDocumentPickerViewController for export) - Add ArchiveConfirmationView with remove-local toggle - Add .archived case to FileAvailabilityStatus - Add archive fields to RecordingFile model - Add getStoredURL to CoreDataManager/AppDataCoordinator - Update RecordingsListView: archive selection mode, older-than picker, archive indicators on rows, info alert for offloaded recordings Co-Authored-By: Claude Opus 4.6 (1M context) --- .../BisonNotes_AI.xcdatamodel/contents | 3 + .../BisonNotes AI/DocumentExportPicker.swift | 43 +++ .../BisonNotes AI/EnhancedFileManager.swift | 13 +- .../Models/AppDataCoordinator.swift | 5 + .../Models/CoreDataManager.swift | 11 + .../Models/RecordingArchiveService.swift | 155 +++++++++++ .../BisonNotes AI/Models/RecordingFile.swift | 43 ++- .../Views/ArchiveConfirmationView.swift | 134 +++++++++ .../Views/RecordingsListView.swift | 255 ++++++++++++++++-- 9 files changed, 635 insertions(+), 27 deletions(-) create mode 100644 BisonNotes AI/BisonNotes AI/DocumentExportPicker.swift create mode 100644 BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift create mode 100644 BisonNotes AI/BisonNotes AI/Views/ArchiveConfirmationView.swift diff --git a/BisonNotes AI/BisonNotes AI/BisonNotes_AI.xcdatamodeld/BisonNotes_AI.xcdatamodel/contents b/BisonNotes AI/BisonNotes AI/BisonNotes_AI.xcdatamodeld/BisonNotes_AI.xcdatamodel/contents index 7fcd75b..4b98d6e 100644 --- a/BisonNotes AI/BisonNotes AI/BisonNotes_AI.xcdatamodeld/BisonNotes_AI.xcdatamodel/contents +++ b/BisonNotes AI/BisonNotes AI/BisonNotes_AI.xcdatamodeld/BisonNotes_AI.xcdatamodel/contents @@ -19,6 +19,9 @@ + + + diff --git a/BisonNotes AI/BisonNotes AI/DocumentExportPicker.swift b/BisonNotes AI/BisonNotes AI/DocumentExportPicker.swift new file mode 100644 index 0000000..eaf94d4 --- /dev/null +++ b/BisonNotes AI/BisonNotes AI/DocumentExportPicker.swift @@ -0,0 +1,43 @@ +// +// DocumentExportPicker.swift +// BisonNotes AI +// +// UIViewControllerRepresentable wrapping UIDocumentPickerViewController for exporting files. +// Used to archive audio recordings to iCloud Drive, Dropbox, Google Drive, etc. +// + +import SwiftUI +import UniformTypeIdentifiers + +struct DocumentExportPicker: UIViewControllerRepresentable { + let urls: [URL] + let onCompletion: (Bool) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onCompletion: onCompletion) + } + + func makeUIViewController(context: Context) -> UIDocumentPickerViewController { + let picker = UIDocumentPickerViewController(forExporting: urls) + picker.delegate = context.coordinator + return picker + } + + func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {} + + class Coordinator: NSObject, UIDocumentPickerDelegate { + let onCompletion: (Bool) -> Void + + init(onCompletion: @escaping (Bool) -> Void) { + self.onCompletion = onCompletion + } + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + onCompletion(true) + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + onCompletion(false) + } + } +} diff --git a/BisonNotes AI/BisonNotes AI/EnhancedFileManager.swift b/BisonNotes AI/BisonNotes AI/EnhancedFileManager.swift index d9e715c..c1c142f 100644 --- a/BisonNotes AI/BisonNotes AI/EnhancedFileManager.swift +++ b/BisonNotes AI/BisonNotes AI/EnhancedFileManager.swift @@ -53,8 +53,9 @@ enum FileAvailabilityStatus: String, CaseIterable { case recordingOnly = "Recording Only" case summaryOnly = "Summary Only" case transcriptOnly = "Transcript Only" + case archived = "Archived" case none = "None" - + var icon: String { switch self { case .complete: @@ -65,11 +66,13 @@ enum FileAvailabilityStatus: String, CaseIterable { return "doc.text" case .transcriptOnly: return "text.quote" + case .archived: + return "archivebox.fill" case .none: return "questionmark.circle" } } - + var color: String { switch self { case .complete: @@ -80,11 +83,13 @@ enum FileAvailabilityStatus: String, CaseIterable { return "orange" case .transcriptOnly: return "purple" + case .archived: + return "orange" case .none: return "gray" } } - + var description: String { switch self { case .complete: @@ -95,6 +100,8 @@ enum FileAvailabilityStatus: String, CaseIterable { return "Only summary available (recording deleted)" case .transcriptOnly: return "Only transcript available (recording deleted)" + case .archived: + return "Audio exported to external storage" case .none: return "No files available" } diff --git a/BisonNotes AI/BisonNotes AI/Models/AppDataCoordinator.swift b/BisonNotes AI/BisonNotes AI/Models/AppDataCoordinator.swift index 987e966..0b8655b 100644 --- a/BisonNotes AI/BisonNotes AI/Models/AppDataCoordinator.swift +++ b/BisonNotes AI/BisonNotes AI/Models/AppDataCoordinator.swift @@ -108,6 +108,11 @@ class AppDataCoordinator: ObservableObject { func getAbsoluteURL(for recording: RecordingEntry) -> URL? { return coreDataManager.getAbsoluteURL(for: recording) } + + /// Gets the stored URL for a recording without checking file existence (for archived recordings) + func getStoredURL(for recording: RecordingEntry) -> URL? { + return coreDataManager.getStoredURL(for: recording) + } /// Gets transcript entry for a recording func getTranscript(for recordingId: UUID) -> TranscriptEntry? { diff --git a/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift b/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift index fb2fc9c..085da4c 100644 --- a/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift +++ b/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift @@ -179,6 +179,17 @@ class CoreDataManager: ObservableObject { return nil } + /// Returns a URL derived from the stored recordingURL string without checking file existence. + /// Used for archived recordings where the local file may have been intentionally removed. + func getStoredURL(for recording: RecordingEntry) -> URL? { + guard let urlString = recording.recordingURL else { return nil } + + if let url = URL(string: urlString), url.scheme != nil { + return url + } + return relativePathToURL(urlString) + } + // MARK: - Location Data Helpers func getLocationData(for recording: RecordingEntry) -> LocationData? { diff --git a/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift b/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift new file mode 100644 index 0000000..d7935ef --- /dev/null +++ b/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift @@ -0,0 +1,155 @@ +// +// RecordingArchiveService.swift +// BisonNotes AI +// +// Service for archiving audio recordings to external storage. +// Manages export, local file cleanup, and restore from re-import. +// + +import Foundation +import CoreData + +@MainActor +class RecordingArchiveService: ObservableObject { + + static let shared = RecordingArchiveService() + + @Published var isArchiving = false + + private var viewContext: NSManagedObjectContext { + PersistenceController.shared.container.viewContext + } + + // MARK: - Archive Recordings + + /// Mark recordings as archived and optionally remove local audio files. + /// Call this AFTER the document export picker completes successfully. + func archiveRecordings(_ recordings: [RecordingEntry], removeLocal: Bool) { + let context = viewContext + let now = Date() + let formatter = DateFormatter() + formatter.dateStyle = .medium + let dateString = formatter.string(from: now) + + for recording in recordings { + recording.isArchived = true + recording.archivedAt = now + recording.archiveNote = "Exported to Files on \(dateString)" + recording.lastModified = now + + if removeLocal, let urlString = recording.recordingURL { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + let fileURL: URL? + + if urlString.hasPrefix("/") { + fileURL = URL(fileURLWithPath: urlString) + } else if let docs = documentsPath { + fileURL = docs.appendingPathComponent(urlString) + } else { + fileURL = nil + } + + if let url = fileURL, FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.removeItem(at: url) + AppLog.shared.recording("Archived: removed local audio \(url.lastPathComponent)") + } catch { + AppLog.shared.recording("Archived: failed to remove local audio: \(error.localizedDescription)", level: .error) + } + } + } + } + + do { + try context.save() + AppLog.shared.recording("Archived \(recordings.count) recording(s), removeLocal=\(removeLocal)") + } catch { + AppLog.shared.recording("Failed to save archive state: \(error.localizedDescription)", level: .error) + } + } + + // MARK: - Query + + /// Fetch non-archived recordings older than a given number of days. + func recordingsOlderThan(days: Int) -> [RecordingEntry] { + let ctx = viewContext + let cutoff = Calendar.current.date(byAdding: .day, value: -days, to: Date()) ?? Date() + + let request: NSFetchRequest = RecordingEntry.fetchRequest() + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "recordingDate < %@", cutoff as NSDate), + NSPredicate(format: "isArchived == NO OR isArchived == nil"), + NSPredicate(format: "recordingURL != nil") + ]) + request.sortDescriptors = [NSSortDescriptor(key: "recordingDate", ascending: true)] + + do { + return try ctx.fetch(request) + } catch { + AppLog.shared.recording("Failed to query recordings older than \(days) days: \(error.localizedDescription)", level: .error) + return [] + } + } + + // MARK: - Restore + + /// Clear archive flags when a user re-imports audio for an archived recording. + func restoreRecording(_ recording: RecordingEntry, newAudioURL: URL) { + let context = viewContext + + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + let relativePath: String + if let docs = documentsPath, newAudioURL.path.hasPrefix(docs.path) { + relativePath = String(newAudioURL.path.dropFirst(docs.path.count + 1)) + } else { + relativePath = newAudioURL.lastPathComponent + } + + recording.recordingURL = relativePath + recording.isArchived = false + recording.archivedAt = nil + recording.archiveNote = nil + recording.lastModified = Date() + + // Update file size from restored file + if let attrs = try? FileManager.default.attributesOfItem(atPath: newAudioURL.path), + let size = attrs[.size] as? Int64 { + recording.fileSize = size + } + + do { + try context.save() + AppLog.shared.recording("Restored archived recording: \(recording.recordingName ?? "unknown")") + } catch { + AppLog.shared.recording("Failed to restore recording: \(error.localizedDescription)", level: .error) + } + } + + // MARK: - Helpers + + /// Get absolute file URLs for recordings that still have local audio files. + func audioURLs(for recordings: [RecordingEntry]) -> [URL] { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + return recordings.compactMap { recording -> URL? in + guard let urlString = recording.recordingURL else { return nil } + let url: URL + if urlString.hasPrefix("/") { + url = URL(fileURLWithPath: urlString) + } else if let docs = documentsPath { + url = docs.appendingPathComponent(urlString) + } else { + return nil + } + return FileManager.default.fileExists(atPath: url.path) ? url : nil + } + } + + /// Calculate total file size for a set of recordings. + func totalFileSize(for recordings: [RecordingEntry]) -> Int64 { + let urls = audioURLs(for: recordings) + return urls.reduce(Int64(0)) { total, url in + let size = (try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? Int64 ?? 0 + return total + size + } + } +} diff --git a/BisonNotes AI/BisonNotes AI/Models/RecordingFile.swift b/BisonNotes AI/BisonNotes AI/Models/RecordingFile.swift index 8189973..cb8d3dd 100644 --- a/BisonNotes AI/BisonNotes AI/Models/RecordingFile.swift +++ b/BisonNotes AI/BisonNotes AI/Models/RecordingFile.swift @@ -11,6 +11,29 @@ struct RecordingFile: Identifiable, Equatable { let duration: TimeInterval let locationData: LocationData? + // Archive metadata + let isArchived: Bool + let archivedAt: Date? + let archiveNote: String? + let recordingId: UUID? + let storedFileSize: Int64 + + init(url: URL, name: String, date: Date, duration: TimeInterval, + locationData: LocationData? = nil, isArchived: Bool = false, + archivedAt: Date? = nil, archiveNote: String? = nil, + recordingId: UUID? = nil, storedFileSize: Int64 = 0) { + self.url = url + self.name = name + self.date = date + self.duration = duration + self.locationData = locationData + self.isArchived = isArchived + self.archivedAt = archivedAt + self.archiveNote = archiveNote + self.recordingId = recordingId + self.storedFileSize = storedFileSize + } + var dateString: String { return UserPreferences.shared.formatMediumDateTime(date) } @@ -28,17 +51,29 @@ struct RecordingFile: Identifiable, Equatable { } var fileSize: Int64 { - guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), - let size = attributes[.size] as? Int64 else { - return 0 + // For archived recordings whose local file is gone, use stored size + if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attributes[.size] as? Int64 { + return size } - return size + return storedFileSize } var fileSizeString: String { return ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file) } + var hasLocalAudio: Bool { + FileManager.default.fileExists(atPath: url.path) + } + + var archivedAtString: String? { + guard let archivedAt else { return nil } + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter.string(from: archivedAt) + } + // Equatable conformance - compare by URL since it's unique static func == (lhs: RecordingFile, rhs: RecordingFile) -> Bool { return lhs.url == rhs.url diff --git a/BisonNotes AI/BisonNotes AI/Views/ArchiveConfirmationView.swift b/BisonNotes AI/BisonNotes AI/Views/ArchiveConfirmationView.swift new file mode 100644 index 0000000..5c6dcb4 --- /dev/null +++ b/BisonNotes AI/BisonNotes AI/Views/ArchiveConfirmationView.swift @@ -0,0 +1,134 @@ +// +// ArchiveConfirmationView.swift +// BisonNotes AI +// +// Confirmation sheet shown before archiving recordings. +// Displays selection summary and options for local file removal. +// + +import SwiftUI + +struct ArchiveConfirmationView: View { + let recordingCount: Int + let totalSize: Int64 + let recordingNames: [String] + @Binding var removeLocal: Bool + let onConfirm: () -> Void + let onCancel: () -> Void + + var body: some View { + NavigationView { + VStack(spacing: 20) { + // Header + VStack(spacing: 8) { + Image(systemName: "archivebox.fill") + .font(.system(size: 40)) + .foregroundColor(.accentColor) + + Text("Archive \(recordingCount) Recording\(recordingCount == 1 ? "" : "s")") + .font(.title2) + .fontWeight(.bold) + + Text(fileSizeString) + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding(.top, 20) + + // Recording list preview (up to 5) + if !recordingNames.isEmpty { + VStack(alignment: .leading, spacing: 6) { + ForEach(recordingNames.prefix(5), id: \.self) { name in + HStack(spacing: 8) { + Image(systemName: "waveform") + .font(.caption) + .foregroundColor(.accentColor) + Text(name) + .font(.subheadline) + .lineLimit(1) + } + } + if recordingNames.count > 5 { + Text("and \(recordingNames.count - 5) more...") + .font(.caption) + .foregroundColor(.secondary) + .padding(.leading, 22) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.systemGray6)) + ) + .padding(.horizontal) + } + + // Options + VStack(spacing: 12) { + Toggle(isOn: $removeLocal) { + VStack(alignment: .leading, spacing: 2) { + Text("Remove local audio after export") + .font(.subheadline) + Text("Transcripts and summaries will be kept") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color(.systemGray6)) + ) + } + .padding(.horizontal) + + if removeLocal { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.caption) + Text("You'll need to re-import audio files to play them again") + .font(.caption) + .foregroundColor(.orange) + } + .padding(.horizontal) + } + + Spacer() + + // Actions + VStack(spacing: 12) { + Button(action: onConfirm) { + HStack { + Image(systemName: "square.and.arrow.up") + Text("Export to Files") + .fontWeight(.semibold) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Color.accentColor) + ) + } + + Button(action: onCancel) { + Text("Cancel") + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + .padding() + } + } + .padding(.horizontal) + .padding(.bottom, 20) + } + .navigationBarHidden(true) + } + } + + private var fileSizeString: String { + ByteCountFormatter.string(fromByteCount: totalSize, countStyle: .file) + } +} diff --git a/BisonNotes AI/BisonNotes AI/Views/RecordingsListView.swift b/BisonNotes AI/BisonNotes AI/Views/RecordingsListView.swift index 4993c6e..26c66c9 100644 --- a/BisonNotes AI/BisonNotes AI/Views/RecordingsListView.swift +++ b/BisonNotes AI/BisonNotes AI/Views/RecordingsListView.swift @@ -30,19 +30,19 @@ struct RecordingsListView: View { @State private var selectedRecordingForPlayer: AudioRecordingFile? enum SelectionAction { case combine - // Future actions can be added here, e.g.: - // case export - // case delete + case archive var instruction: String { switch self { case .combine: return "Select 2 recordings to combine" + case .archive: return "Select recordings to archive" } } var maxSelection: Int? { switch self { case .combine: return 2 + case .archive: return nil } } } @@ -54,6 +54,14 @@ struct RecordingsListView: View { @State private var recordingsToCombine: (first: AudioRecordingFile, second: AudioRecordingFile)? @State private var showSelectionWarning = false @State private var searchText = "" + @State private var showingArchiveConfirmation = false + @State private var showingArchiveExportPicker = false + @State private var showingArchiveOlderThan = false + @State private var archiveOlderThanDays = 30 + @State private var removeLocalAfterArchive = false + @State private var recordingsToArchive: [RecordingEntry] = [] + @State private var archiveExportURLs: [URL] = [] + @State private var archiveInfoRecording: AudioRecordingFile? @State private var showDateFilter = false @State private var dateFilterStart: Date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date() @State private var dateFilterEnd: Date = Date() @@ -79,6 +87,14 @@ struct RecordingsListView: View { .foregroundColor(.blue) } + if selectionAction == .archive && !selectedRecordings.isEmpty { + Button("Archive") { + prepareArchiveFromSelection() + } + .font(.headline) + .foregroundColor(.orange) + } + Button("Cancel") { isSelectionMode = false selectedRecordings.removeAll() @@ -93,8 +109,8 @@ struct RecordingsListView: View { .font(.title2) } - if recordings.count >= 2 { - Menu { + Menu { + if recordings.count >= 2 { Button(action: { selectionAction = .combine isSelectionMode = true @@ -103,10 +119,25 @@ struct RecordingsListView: View { }) { Label("Combine Recordings", systemImage: "link") } - } label: { - Image(systemName: "ellipsis.circle") - .font(.title2) } + + Button(action: { + selectionAction = .archive + isSelectionMode = true + selectedRecordings.removeAll() + showSelectionWarning = false + }) { + Label("Archive Selected", systemImage: "archivebox") + } + + Button(action: { + showingArchiveOlderThan = true + }) { + Label("Archive Older Than...", systemImage: "calendar.badge.clock") + } + } label: { + Image(systemName: "ellipsis.circle") + .font(.title2) } Button("Done") { @@ -205,6 +236,57 @@ struct RecordingsListView: View { .environmentObject(appCoordinator) } } + .sheet(isPresented: $showingArchiveConfirmation) { + ArchiveConfirmationView( + recordingCount: recordingsToArchive.count, + totalSize: RecordingArchiveService.shared.totalFileSize(for: recordingsToArchive), + recordingNames: recordingsToArchive.compactMap { $0.recordingName }, + removeLocal: $removeLocalAfterArchive, + onConfirm: { + showingArchiveConfirmation = false + archiveExportURLs = RecordingArchiveService.shared.audioURLs(for: recordingsToArchive) + if !archiveExportURLs.isEmpty { + showingArchiveExportPicker = true + } + }, + onCancel: { + showingArchiveConfirmation = false + recordingsToArchive = [] + } + ) + .presentationDetents([.medium, .large]) + } + .sheet(isPresented: $showingArchiveExportPicker) { + DocumentExportPicker(urls: archiveExportURLs) { success in + showingArchiveExportPicker = false + if success { + RecordingArchiveService.shared.archiveRecordings( + recordingsToArchive, + removeLocal: removeLocalAfterArchive + ) + isSelectionMode = false + selectedRecordings.removeAll() + loadRecordings() + } + recordingsToArchive = [] + archiveExportURLs = [] + } + } + .sheet(isPresented: $showingArchiveOlderThan) { + archiveOlderThanSheet + } + .alert("Audio Archived", isPresented: Binding( + get: { archiveInfoRecording != nil }, + set: { if !$0 { archiveInfoRecording = nil } } + )) { + Button("OK", role: .cancel) { archiveInfoRecording = nil } + } message: { + if let rec = archiveInfoRecording { + let note = rec.archiveNote ?? "Exported to Files" + let dateStr = rec.archivedAtString ?? "" + Text("\(note)\(dateStr.isEmpty ? "" : " on \(dateStr)")\n\nThe audio file is no longer stored locally. Transcripts and summaries are still available. Use \"Import Audio Files\" to restore.") + } + } } .onAppear { refreshFileRelationships() @@ -403,6 +485,11 @@ struct RecordingsListView: View { Button(action: { if isSelectionMode { toggleSelection(for: recording) + } else if recording.isArchived && !recording.hasLocalAudio { + // Archived with no local file — show info instead of player + selectedRecordingForPlayer = nil + // Show alert with archive info + archiveInfoRecording = recording } else { selectedRecordingForPlayer = recording } @@ -431,8 +518,28 @@ struct RecordingsListView: View { .foregroundColor(.secondary) } - // File availability indicator - if let relationships = enhancedFileManager.getFileRelationships(for: recording.url) { + // Archive or file availability indicator + if recording.isArchived { + HStack(spacing: 4) { + Image(systemName: "archivebox.fill") + .font(.caption) + .foregroundColor(.orange) + if let dateStr = recording.archivedAtString { + Text("Archived \(dateStr)") + .font(.caption2) + .foregroundColor(.orange) + } else { + Text("Archived") + .font(.caption2) + .foregroundColor(.orange) + } + if !recording.hasLocalAudio { + Text("(audio offloaded)") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } else if let relationships = enhancedFileManager.getFileRelationships(for: recording.url) { FileAvailabilityIndicator( status: relationships.availabilityStatus, showLabel: true, @@ -533,8 +640,17 @@ struct RecordingsListView: View { continue } - guard let url = appCoordinator.getAbsoluteURL(for: entry.recording) else { continue } - let key = url.lastPathComponent + // For archived recordings, use stored URL even if file is missing + let url: URL? + if entry.recording.isArchived { + url = appCoordinator.getAbsoluteURL(for: entry.recording) + ?? appCoordinator.getStoredURL(for: entry.recording) + } else { + url = appCoordinator.getAbsoluteURL(for: entry.recording) + } + + guard let resolvedURL = url else { continue } + let key = resolvedURL.lastPathComponent if let existing = bestByFilename[key] { bestByFilename[key] = score(existing) >= score(entry) ? existing : entry } else { @@ -546,24 +662,46 @@ struct RecordingsListView: View { recordings = deduped.compactMap { recordingData -> AudioRecordingFile? in let recording = recordingData.recording - guard let recordingName = recording.recordingName, - let recordingURL = appCoordinator.getAbsoluteURL(for: recording), - FileManager.default.fileExists(atPath: recordingURL.path) else { + guard let recordingName = recording.recordingName else { + return nil + } + + let isArchived = recording.isArchived + let recordingURL: URL? + + if isArchived { + recordingURL = appCoordinator.getAbsoluteURL(for: recording) + ?? appCoordinator.getStoredURL(for: recording) + } else { + recordingURL = appCoordinator.getAbsoluteURL(for: recording) + } + + guard let url = recordingURL else { AppLog.shared.recording("Skipping recording with missing data", level: .debug) return nil } - + + // Non-archived recordings must have a local file + if !isArchived && !FileManager.default.fileExists(atPath: url.path) { + AppLog.shared.recording("Skipping recording with missing file", level: .debug) + return nil + } let date = recording.recordingDate ?? recording.createdAt ?? Date() - let duration = recording.duration > 0 ? recording.duration : getRecordingDuration(url: recordingURL) + let duration = recording.duration > 0 ? recording.duration : getRecordingDuration(url: url) let locationData = appCoordinator.loadLocationData(for: recording) return AudioRecordingFile( - url: recordingURL, + url: url, name: recordingName, date: date, duration: duration, - locationData: locationData + locationData: locationData, + isArchived: isArchived, + archivedAt: recording.archivedAt, + archiveNote: recording.archiveNote, + recordingId: recording.id, + storedFileSize: recording.fileSize ) } .sorted { $0.date > $1.date } @@ -822,5 +960,82 @@ struct RecordingsListView: View { selectedRecordings.removeAll() showSelectionWarning = false } - + + // MARK: - Archive Helpers + + private func prepareArchiveFromSelection() { + let selectedURLs = selectedRecordings + let allRecordings = appCoordinator.getAllRecordingsWithData() + recordingsToArchive = allRecordings.compactMap { entry -> RecordingEntry? in + guard !entry.recording.isArchived, + let url = appCoordinator.getAbsoluteURL(for: entry.recording), + selectedURLs.contains(url) else { return nil } + return entry.recording + } + if !recordingsToArchive.isEmpty { + removeLocalAfterArchive = false + showingArchiveConfirmation = true + } + } + + private var archiveOlderThanSheet: some View { + NavigationView { + VStack(spacing: 20) { + Image(systemName: "calendar.badge.clock") + .font(.system(size: 40)) + .foregroundColor(.accentColor) + .padding(.top, 20) + + Text("Archive Older Than") + .font(.title2) + .fontWeight(.bold) + + Picker("Days", selection: $archiveOlderThanDays) { + Text("7 days").tag(7) + Text("14 days").tag(14) + Text("30 days").tag(30) + Text("60 days").tag(60) + Text("90 days").tag(90) + } + .pickerStyle(.wheel) + .frame(height: 120) + + let matchCount = RecordingArchiveService.shared.recordingsOlderThan(days: archiveOlderThanDays).count + Text("\(matchCount) recording\(matchCount == 1 ? "" : "s") match") + .font(.subheadline) + .foregroundColor(.secondary) + + Spacer() + + Button(action: { + showingArchiveOlderThan = false + recordingsToArchive = RecordingArchiveService.shared.recordingsOlderThan(days: archiveOlderThanDays) + if !recordingsToArchive.isEmpty { + removeLocalAfterArchive = false + showingArchiveConfirmation = true + } + }) { + Text("Continue") + .fontWeight(.semibold) + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding() + .background( + RoundedRectangle(cornerRadius: 12) + .fill(matchCount > 0 ? Color.accentColor : Color.gray) + ) + } + .disabled(matchCount == 0) + .padding(.horizontal) + + Button("Cancel") { + showingArchiveOlderThan = false + } + .foregroundColor(.secondary) + .padding(.bottom, 20) + } + .navigationBarHidden(true) + } + .presentationDetents([.medium]) + } } From 6626c543b10f1a5759d6355192c713d6b6cbc7d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Apr 2026 18:47:01 +0000 Subject: [PATCH 07/21] Fix audio offloading bug: preserve archived recordings in list and transcript views - cleanupRecordingsWithMissingFiles() now skips archived recordings so their recordingURL is never cleared on app relaunch (was causing them to vanish from the recordings list after the file was intentionally offloaded) - TranscriptViews.loadRecordings() now falls back to getStoredURL for archived recordings so transcripts remain visible even when the local audio is gone https://claude.ai/code/session_01Wu4tB7fuskn3ZuzhWQxdDP --- BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift | 9 +++++++-- BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift | 9 ++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift b/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift index 085da4c..9442254 100644 --- a/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift +++ b/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift @@ -953,10 +953,15 @@ class CoreDataManager: ObservableObject { func cleanupRecordingsWithMissingFiles() -> Int { let allRecordings = getAllRecordings() var cleanedCount = 0 - + for recording in allRecordings { + // Never touch archived recordings — their audio was intentionally offloaded + if recording.isArchived { + continue + } + guard let urlString = recording.recordingURL else { continue } - + // Skip if this is a summary-only recording (no URL expected) if recording.summary != nil && urlString.isEmpty { continue diff --git a/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift b/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift index 4753ce2..d09335d 100644 --- a/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift +++ b/BisonNotes AI/BisonNotes AI/Views/TranscriptViews.swift @@ -652,7 +652,14 @@ struct TranscriptsView: View { } for rd in recordingsWithData { - guard let url = appCoordinator.getAbsoluteURL(for: rd.recording) else { continue } + let resolvedURL: URL? + if rd.recording.isArchived { + resolvedURL = appCoordinator.getAbsoluteURL(for: rd.recording) + ?? appCoordinator.getStoredURL(for: rd.recording) + } else { + resolvedURL = appCoordinator.getAbsoluteURL(for: rd.recording) + } + guard let url = resolvedURL else { continue } let key = url.lastPathComponent let candidate = (recording: rd.recording, transcript: rd.transcript) if let existing = bestByFilename[key] { From c747edd3f7fcfb6cb2cd0658659b792d63ceffe9 Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:15:05 -0400 Subject: [PATCH 08/21] Address Codex review: deterministic summary picker and robust URL resolver - RecordingWorkflowManager: sort existing summaries by generatedAt desc so the "primary" one used for attachment migration is deterministically the most recent, preventing data loss when duplicate summary rows exist. - RecordingArchiveService: replace hasPrefix("/") path builder with a resolveLocalURL helper that also handles file:// URLs and percent-encoded relative paths, so archive cleanup actually removes legacy-formatted audio files instead of leaving them on disk. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Models/RecordingArchiveService.swift | 40 +++++++++---------- .../Models/RecordingWorkflowManager.swift | 3 ++ 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift b/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift index d7935ef..6c5e646 100644 --- a/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift +++ b/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift @@ -38,16 +38,7 @@ class RecordingArchiveService: ObservableObject { recording.lastModified = now if removeLocal, let urlString = recording.recordingURL { - let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first - let fileURL: URL? - - if urlString.hasPrefix("/") { - fileURL = URL(fileURLWithPath: urlString) - } else if let docs = documentsPath { - fileURL = docs.appendingPathComponent(urlString) - } else { - fileURL = nil - } + let fileURL = Self.resolveLocalURL(from: urlString) if let url = fileURL, FileManager.default.fileExists(atPath: url.path) { do { @@ -129,21 +120,30 @@ class RecordingArchiveService: ObservableObject { /// Get absolute file URLs for recordings that still have local audio files. func audioURLs(for recordings: [RecordingEntry]) -> [URL] { - let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first return recordings.compactMap { recording -> URL? in - guard let urlString = recording.recordingURL else { return nil } - let url: URL - if urlString.hasPrefix("/") { - url = URL(fileURLWithPath: urlString) - } else if let docs = documentsPath { - url = docs.appendingPathComponent(urlString) - } else { - return nil - } + guard let urlString = recording.recordingURL, + let url = Self.resolveLocalURL(from: urlString) else { return nil } return FileManager.default.fileExists(atPath: url.path) ? url : nil } } + /// Resolve a stored recordingURL string to a local file URL. + /// Handles absolute POSIX paths, file:// URLs (legacy format), and + /// Documents-relative paths with percent-encoding (e.g. "My%20Recording.m4a"). + private static func resolveLocalURL(from urlString: String) -> URL? { + if urlString.hasPrefix("/") { + return URL(fileURLWithPath: urlString) + } + if let parsed = URL(string: urlString), parsed.scheme != nil { + return parsed.isFileURL ? parsed : nil + } + guard let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + return nil + } + let decoded = urlString.removingPercentEncoding ?? urlString + return docs.appendingPathComponent(decoded) + } + /// Calculate total file size for a set of recordings. func totalFileSize(for recordings: [RecordingEntry]) -> Int64 { let urls = audioURLs(for: recordings) diff --git a/BisonNotes AI/BisonNotes AI/Models/RecordingWorkflowManager.swift b/BisonNotes AI/BisonNotes AI/Models/RecordingWorkflowManager.swift index 0af4acb..40d9ef5 100644 --- a/BisonNotes AI/BisonNotes AI/Models/RecordingWorkflowManager.swift +++ b/BisonNotes AI/BisonNotes AI/Models/RecordingWorkflowManager.swift @@ -225,6 +225,9 @@ class RecordingWorkflowManager: ObservableObject { // We'll delete these only AFTER successfully saving the new summary let existingSummaryFetch: NSFetchRequest = SummaryEntry.fetchRequest() existingSummaryFetch.predicate = NSPredicate(format: "recordingId == %@", recordingId as CVarArg) + // Sort most-recent-first so existingSummaries.first is deterministic and matches + // the summary most likely to hold the user's latest notes/attachments. + existingSummaryFetch.sortDescriptors = [NSSortDescriptor(key: "generatedAt", ascending: false)] let existingSummaries = (try? context.fetch(existingSummaryFetch)) ?? [] if !existingSummaries.isEmpty { AppLog.shared.backgroundProcessing("Found \(existingSummaries.count) existing summary(ies) to clean up after save", level: .debug) From 49e7019f80673c341ecc2936be7eb98808ffbb49 Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Sun, 19 Apr 2026 21:24:38 -0400 Subject: [PATCH 09/21] Add tokenized archive exports and restore-on-reimport flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export: stage audio to Library/Application Support/ArchiveStaging with names of the form -<8hex>.. Staging uses a fresh copy (not hardlink) so the iCloud File Provider sees clean xattrs, which fixes the "permission denied" error when archiving to iCloud Drive. Staged files are stamped with the original recordingDate as mtime so timestamps survive an iCloud round-trip. - Import: parse the archive token from the incoming filename, match it against the first 8 hex chars of RecordingEntry.id, and either copy and restore, clear archive flags (when local audio is still present), or throw ImportError.alreadyImported for true duplicates. New-entry imports now prefer the file's modification date for recordingDate. - Fix: convertToTranscriptData no longer returns nil for archived recordings whose local audio is gone — it falls back to getStoredURL so the Transcripts list keeps showing "Edit Transcript" rather than "Generate Transcript" after archiving. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../BisonNotes AI/FileImportManager.swift | 151 +++++++++++++-- .../Models/CoreDataManager.swift | 13 +- .../Models/RecordingArchiveService.swift | 172 +++++++++++++++++- .../Views/RecordingsListView.swift | 5 +- 4 files changed, 321 insertions(+), 20 deletions(-) diff --git a/BisonNotes AI/BisonNotes AI/FileImportManager.swift b/BisonNotes AI/BisonNotes AI/FileImportManager.swift index 42cf8b2..fe0fbec 100644 --- a/BisonNotes AI/BisonNotes AI/FileImportManager.swift +++ b/BisonNotes AI/BisonNotes AI/FileImportManager.swift @@ -95,23 +95,30 @@ class FileImportManager: NSObject, ObservableObject { guard supportedExtensions.contains(fileExtension) else { throw ImportError.unsupportedFormat(fileExtension) } - + + // If the filename carries an archive token, try to restore onto the + // original recording entry rather than create a duplicate. + if let restoreCandidate = matchArchivedRecording(for: sourceURL) { + try await restoreArchivedRecording(restoreCandidate, from: sourceURL) + return + } + // Get documents directory let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - + // Generate unique filename let filename = generateUniqueFilename(for: sourceURL) let destinationURL = documentsPath.appendingPathComponent(filename) - + // Check if file already exists if FileManager.default.fileExists(atPath: destinationURL.path) { throw ImportError.fileAlreadyExists(filename) } - + // Copy file to documents directory with comprehensive error handling for thumbnail issues do { try FileManager.default.copyItem(at: sourceURL, to: destinationURL) - + } catch { // Check if this is a thumbnail-related error that we can ignore if error.isThumbnailGenerationError { @@ -122,15 +129,120 @@ class FileImportManager: NSObject, ObservableObject { throw ImportError.copyFailed(error.localizedDescription) } } - + // Validate the copied file try validateAudioFile(at: destinationURL) - + // Create Core Data entry for the imported file try await createRecordingEntryForImportedFile(at: destinationURL) - + AppLog.shared.fileManagement("Successfully imported: \(filename)") } + + /// Decision about how to handle an incoming import URL based on the archive + /// token embedded in its filename. + private enum ArchiveMatchResult { + /// Audio is missing locally — copy the imported file into Documents and + /// relink it onto this recording entry, clearing archive flags. + case restoreWithCopy(RecordingEntry) + /// The recording already has its audio present (user archived without + /// removing local). Just clear the archive flags. + case clearFlagsOnly(RecordingEntry) + /// All matching recordings are healthy duplicates — user is re-importing + /// a file whose original is already on the device. + case alreadyImported(String) + } + + /// Decide how to handle an incoming import URL based on the archive token + /// embedded in its filename (if any). Returns nil when the file should go + /// through the regular new-entry import path. + private func matchArchivedRecording(for sourceURL: URL) -> ArchiveMatchResult? { + guard let parsed = RecordingArchiveService.parseArchiveToken(fromFilename: sourceURL.lastPathComponent) else { + return nil + } + + let fetchRequest: NSFetchRequest = RecordingEntry.fetchRequest() + let candidates: [RecordingEntry] + do { + candidates = try context.fetch(fetchRequest) + } catch { + AppLog.shared.fileManagement("Archive restore: fetch failed: \(error.localizedDescription)", level: .error) + return nil + } + + let matches = candidates.filter { recording in + guard let id = recording.id?.uuidString.replacingOccurrences(of: "-", with: "").lowercased() else { + return false + } + return id.hasPrefix(parsed.token) + } + + guard !matches.isEmpty else { return nil } + + if matches.count > 1 { + AppLog.shared.fileManagement("Archive restore: \(matches.count) UUID-prefix matches for token \(parsed.token)", level: .debug) + } + + func hasLocalAudio(_ recording: RecordingEntry) -> Bool { + guard let urlString = recording.recordingURL, + let url = RecordingArchiveService.resolveLocalURL(from: urlString) else { return false } + return FileManager.default.fileExists(atPath: url.path) + } + + // 1. Archived recording missing its audio — the pure restore case. + if let recording = matches.first(where: { $0.isArchived && !hasLocalAudio($0) }) { + return .restoreWithCopy(recording) + } + // 2. Non-archived recording whose local audio has gone missing — re-link. + if let recording = matches.first(where: { !$0.isArchived && !hasLocalAudio($0) }) { + return .restoreWithCopy(recording) + } + // 3. Archived but local audio still present (archive kept local copy). + // Reuse existing file; just flip the flags. + if let recording = matches.first(where: { $0.isArchived && hasLocalAudio($0) }) { + return .clearFlagsOnly(recording) + } + // 4. Every match is a healthy, non-archived recording — duplicate import. + let name = matches.first?.recordingName ?? parsed.baseName + return .alreadyImported(name) + } + + private func restoreArchivedRecording(_ match: ArchiveMatchResult, from sourceURL: URL) async throws { + switch match { + case .alreadyImported(let name): + throw ImportError.alreadyImported(name) + + case .clearFlagsOnly(let recording): + RecordingArchiveService.shared.clearArchiveFlags(for: recording) + NotificationCenter.default.post(name: NSNotification.Name("RecordingAdded"), object: nil) + AppLog.shared.fileManagement("Cleared archive flags for \(recording.recordingName ?? "unknown") (local audio still present)") + + case .restoreWithCopy(let recording): + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + let filename = generateUniqueFilename(for: sourceURL) + let destinationURL = documentsPath.appendingPathComponent(filename) + + if FileManager.default.fileExists(atPath: destinationURL.path) { + throw ImportError.fileAlreadyExists(filename) + } + + do { + try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + } catch { + if error.isThumbnailGenerationError { + AppLog.shared.fileManagement("Thumbnail generation warning: \(error.localizedDescription)", level: .debug) + } else { + throw ImportError.copyFailed(error.localizedDescription) + } + } + + try validateAudioFile(at: destinationURL) + + RecordingArchiveService.shared.restoreRecording(recording, newAudioURL: destinationURL) + NotificationCenter.default.post(name: NSNotification.Name("RecordingAdded"), object: nil) + AppLog.shared.fileManagement("Restored archived recording \(recording.recordingName ?? "unknown") from import \(sourceURL.lastPathComponent)") + } + } private func importVideoFile(from sourceURL: URL) async throws { let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] @@ -267,18 +379,24 @@ class FileImportManager: NSObject, ObservableObject { // Store relative path instead of absolute URL for resilience across app launches recordingEntry.recordingURL = urlToRelativePath(fileURL) - // Get file metadata + // Get file metadata. Prefer the file's modification date as the recording + // date: archives exported by this app stamp mtime with the original + // recording date, and iCloud preserves mtime across round-trips (while + // it resets creation date to upload time). do { - let resourceValues = try fileURL.resourceValues(forKeys: [.creationDateKey, .fileSizeKey]) - recordingEntry.recordingDate = resourceValues.creationDate ?? Date() - recordingEntry.createdAt = resourceValues.creationDate ?? Date() + let resourceValues = try fileURL.resourceValues(forKeys: [.creationDateKey, .contentModificationDateKey, .fileSizeKey]) + let originalDate = resourceValues.contentModificationDate + ?? resourceValues.creationDate + ?? Date() + recordingEntry.recordingDate = originalDate + recordingEntry.createdAt = originalDate recordingEntry.lastModified = Date() recordingEntry.fileSize = Int64(resourceValues.fileSize ?? 0) - + // Get duration let duration = await getAudioDuration(url: fileURL) recordingEntry.duration = duration - + } catch { AppLog.shared.fileManagement("Error getting file metadata: \(error)", level: .error) recordingEntry.recordingDate = Date() @@ -342,7 +460,8 @@ enum ImportError: LocalizedError { case fileAlreadyExists(String) case invalidAudioFile(String) case copyFailed(String) - + case alreadyImported(String) + var errorDescription: String? { switch self { case .unsupportedFormat(let format): @@ -353,6 +472,8 @@ enum ImportError: LocalizedError { return "Invalid audio file: \(reason)" case .copyFailed(let reason): return "Failed to copy file: \(reason)" + case .alreadyImported(let name): + return "Already imported: \(name). The original recording still has its audio on this device." } } } diff --git a/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift b/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift index 9442254..e91d3e1 100644 --- a/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift +++ b/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift @@ -685,9 +685,16 @@ class CoreDataManager: ObservableObject { private func convertToTranscriptData(transcriptEntry: TranscriptEntry, recordingEntry: RecordingEntry) -> TranscriptData? { guard let _ = transcriptEntry.id, - let recordingId = recordingEntry.id, - let url = getAbsoluteURL(for: recordingEntry) else { - AppLog.shared.coreData("Could not get absolute URL for recording ID: \(recordingEntry.id?.uuidString ?? "nil")", level: .error) + let recordingId = recordingEntry.id else { + AppLog.shared.coreData("Transcript missing id for recording: \(recordingEntry.id?.uuidString ?? "nil")", level: .error) + return nil + } + + // The transcript is valid even when the audio file is gone (archived + // recordings intentionally have no local audio). Fall back to the + // stored URL so the transcript stays visible in the Transcripts list. + guard let url = getAbsoluteURL(for: recordingEntry) ?? getStoredURL(for: recordingEntry) else { + AppLog.shared.coreData("Could not resolve any URL for recording ID: \(recordingEntry.id?.uuidString ?? "nil")", level: .error) return nil } diff --git a/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift b/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift index 6c5e646..7620582 100644 --- a/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift +++ b/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift @@ -116,6 +116,176 @@ class RecordingArchiveService: ObservableObject { } } + /// Clear archive flags on a recording whose local audio is already present. + /// Used when the user archived without removing local audio, then re-imports + /// the exported copy — no file copy needed, just flip the flags. + func clearArchiveFlags(for recording: RecordingEntry) { + let context = viewContext + recording.isArchived = false + recording.archivedAt = nil + recording.archiveNote = nil + recording.lastModified = Date() + + do { + try context.save() + AppLog.shared.recording("Cleared archive flags (local audio intact): \(recording.recordingName ?? "unknown")") + } catch { + AppLog.shared.recording("Failed to clear archive flags: \(error.localizedDescription)", level: .error) + } + } + + // MARK: - Export Staging + + /// Stage audio files for export with recognizable filenames of the form + /// `-.`, where TOKEN is the first 8 hex + /// characters of the recording's UUID. Re-imports use this token to match + /// the original recording and restore instead of creating a duplicate. + /// + /// Copies into a subdirectory of Library/Application Support, then stamps + /// the staged file's modification date with the original recording date so + /// timestamps survive an iCloud round-trip. + func prepareArchiveExportURLs(for recordings: [RecordingEntry]) -> [URL] { + guard let stagingDir = Self.archiveStagingDirectory else { + AppLog.shared.recording("Archive: no Library dir available for staging", level: .error) + return audioURLs(for: recordings) + } + // Clear any leftovers from a prior crashed run before staging. + try? FileManager.default.removeItem(at: stagingDir) + do { + try FileManager.default.createDirectory(at: stagingDir, withIntermediateDirectories: true) + } catch { + AppLog.shared.recording("Archive: failed to create staging dir: \(error.localizedDescription)", level: .error) + return audioURLs(for: recordings) + } + + var stagedURLs: [URL] = [] + var usedNames = Set() + for recording in recordings { + guard let urlString = recording.recordingURL, + let sourceURL = Self.resolveLocalURL(from: urlString), + FileManager.default.fileExists(atPath: sourceURL.path) else { continue } + + let stagedName = Self.uniqueStagedFilename(for: recording, source: sourceURL, claimed: &usedNames) + let destURL = stagingDir.appendingPathComponent(stagedName) + // Copy (not hardlink): the iCloud Drive File Provider extension reads + // the file via XPC from this sandbox location, and hardlinks share + // xattrs with the Documents source — which can cause "permission + // denied" surfacing in the picker. A fresh copy has clean attributes. + do { + try FileManager.default.copyItem(at: sourceURL, to: destURL) + } catch { + AppLog.shared.recording("Archive: failed to stage \(sourceURL.lastPathComponent): \(error.localizedDescription)", level: .error) + continue + } + + // Stamp the staged copy's modification date with the original recording + // date so it survives an iCloud round-trip (iCloud preserves mtime but + // resets creation time to upload time). On fallback-path imports we + // read mtime to restore the recording's original timestamp. + if let recordingDate = recording.recordingDate { + try? FileManager.default.setAttributes( + [.modificationDate: recordingDate], + ofItemAtPath: destURL.path + ) + } + + stagedURLs.append(destURL) + } + return stagedURLs + } + + /// Remove the archive staging directory. Safe to call even if nothing was staged. + func cleanupArchiveStaging() { + guard let dir = Self.archiveStagingDirectory else { return } + try? FileManager.default.removeItem(at: dir) + } + + /// Directory used to stage exported files with renamed, tokenized filenames. + /// Lives under `Library/Application Support` so it is outside Documents (not + /// surfaced in the Files app) while still readable by export providers via XPC. + static var archiveStagingDirectory: URL? { + guard let support = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return support.appendingPathComponent("ArchiveStaging", isDirectory: true) + } + + /// First 8 hex chars of the recording's UUID, lowercased. Returns nil if the + /// recording has no id (should not happen for persisted entries). + static func archiveToken(for recording: RecordingEntry) -> String? { + guard let uuid = recording.id?.uuidString else { return nil } + let hex = uuid.replacingOccurrences(of: "-", with: "").lowercased() + return hex.count >= 8 ? String(hex.prefix(8)) : nil + } + + /// Build a filesystem-safe base name from the recording's display name. + /// Falls back through the stored URL's filename and finally a literal "recording". + static func sanitizedFilenameBase(for recording: RecordingEntry) -> String { + if let name = recording.recordingName { + let sanitized = sanitizeForFilename(name) + if !sanitized.isEmpty { return sanitized } + } + if let urlString = recording.recordingURL, + let url = resolveLocalURL(from: urlString) { + let base = url.deletingPathExtension().lastPathComponent + let sanitized = sanitizeForFilename(base) + if !sanitized.isEmpty { return sanitized } + } + return "recording" + } + + /// Strip filesystem-reserved characters, collapse whitespace, and truncate so + /// the final `-.` comfortably fits under APFS's 255-byte + /// filename ceiling. + private static func sanitizeForFilename(_ raw: String) -> String { + let invalid = CharacterSet(charactersIn: "/\\:*?\"<>|") + let stripped = raw.components(separatedBy: invalid).joined(separator: "_") + let collapsed = stripped + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + let trimmed = collapsed.trimmingCharacters(in: CharacterSet(charactersIn: " ._-")) + return String(trimmed.prefix(200)) + } + + /// Build a unique staged filename for a recording, accounting for an unlikely + /// duplicate base name within the same batch. + private static func uniqueStagedFilename(for recording: RecordingEntry, + source: URL, + claimed: inout Set) -> String { + let ext = source.pathExtension.isEmpty ? "m4a" : source.pathExtension + let base = sanitizedFilenameBase(for: recording) + let token = archiveToken(for: recording) ?? "00000000" + var candidate = "\(base)-\(token).\(ext)" + var counter = 2 + while claimed.contains(candidate) { + candidate = "\(base)-\(token)_\(counter).\(ext)" + counter += 1 + } + claimed.insert(candidate) + return candidate + } + + /// Parse an imported filename for a trailing `-<8hex>.` archive token. + /// Returns (token, baseName) when present. Name and token are lowercased for + /// stable comparison against `recording.id.uuidString`. + static func parseArchiveToken(fromFilename filename: String) -> (token: String, baseName: String)? { + let name = (filename as NSString).deletingPathExtension + guard name.count > 9 else { return nil } + let tokenStart = name.index(name.endIndex, offsetBy: -8) + let delimiterIndex = name.index(before: tokenStart) + guard name[delimiterIndex] == "-" else { return nil } + let tokenSubstring = name[tokenStart...] + let hexChars = CharacterSet(charactersIn: "0123456789abcdefABCDEF") + guard tokenSubstring.unicodeScalars.allSatisfy(hexChars.contains) else { return nil } + let baseName = String(name[.. URL? { + static func resolveLocalURL(from urlString: String) -> URL? { if urlString.hasPrefix("/") { return URL(fileURLWithPath: urlString) } diff --git a/BisonNotes AI/BisonNotes AI/Views/RecordingsListView.swift b/BisonNotes AI/BisonNotes AI/Views/RecordingsListView.swift index 26c66c9..f5cdffd 100644 --- a/BisonNotes AI/BisonNotes AI/Views/RecordingsListView.swift +++ b/BisonNotes AI/BisonNotes AI/Views/RecordingsListView.swift @@ -244,9 +244,11 @@ struct RecordingsListView: View { removeLocal: $removeLocalAfterArchive, onConfirm: { showingArchiveConfirmation = false - archiveExportURLs = RecordingArchiveService.shared.audioURLs(for: recordingsToArchive) + archiveExportURLs = RecordingArchiveService.shared.prepareArchiveExportURLs(for: recordingsToArchive) if !archiveExportURLs.isEmpty { showingArchiveExportPicker = true + } else { + RecordingArchiveService.shared.cleanupArchiveStaging() } }, onCancel: { @@ -270,6 +272,7 @@ struct RecordingsListView: View { } recordingsToArchive = [] archiveExportURLs = [] + RecordingArchiveService.shared.cleanupArchiveStaging() } } .sheet(isPresented: $showingArchiveOlderThan) { From 6ea1f8400cf6b4122ef34f1859fa302abdf30a12 Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:10:09 -0400 Subject: [PATCH 10/21] Archive audio to tracked iCloud Drive locations - Add archive-location metadata so offloaded audio keeps a restorable pointer - Limit new archive destinations to iCloud Drive and leave local audio untouched for unsupported providers - Restore archived audio from the saved location, validate it, and delete the archived copy after restore - Add UI recovery for archived recordings that still have local audio - Document the iCloud-only archive workflow in README and WordPress guide --- .../BisonNotes_AI.xcdatamodel/contents | 16 +- .../BisonNotes AI/DocumentExportPicker.swift | 14 +- .../Models/RecordingArchiveService.swift | 469 +++++++++++++++++- BisonNotes AI/BisonNotes AI/Persistence.swift | 4 + .../Views/ArchiveConfirmationView.swift | 10 +- .../Views/RecordingsListView.swift | 119 ++++- README.md | 11 +- docs/bisonnotes-ai-guide.html | 16 +- 8 files changed, 630 insertions(+), 29 deletions(-) diff --git a/BisonNotes AI/BisonNotes AI/BisonNotes_AI.xcdatamodeld/BisonNotes_AI.xcdatamodel/contents b/BisonNotes AI/BisonNotes AI/BisonNotes_AI.xcdatamodeld/BisonNotes_AI.xcdatamodel/contents index 4b98d6e..0b5a292 100644 --- a/BisonNotes AI/BisonNotes AI/BisonNotes_AI.xcdatamodeld/BisonNotes_AI.xcdatamodel/contents +++ b/BisonNotes AI/BisonNotes AI/BisonNotes_AI.xcdatamodeld/BisonNotes_AI.xcdatamodel/contents @@ -74,10 +74,24 @@ + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/BisonNotes AI/BisonNotes AI/DocumentExportPicker.swift b/BisonNotes AI/BisonNotes AI/DocumentExportPicker.swift index eaf94d4..a9392f7 100644 --- a/BisonNotes AI/BisonNotes AI/DocumentExportPicker.swift +++ b/BisonNotes AI/BisonNotes AI/DocumentExportPicker.swift @@ -3,7 +3,8 @@ // BisonNotes AI // // UIViewControllerRepresentable wrapping UIDocumentPickerViewController for exporting files. -// Used to archive audio recordings to iCloud Drive, Dropbox, Google Drive, etc. +// Used to archive audio recordings to iCloud Drive through the system +// document picker. The archive service rejects non-iCloud destinations. // import SwiftUI @@ -11,7 +12,7 @@ import UniformTypeIdentifiers struct DocumentExportPicker: UIViewControllerRepresentable { let urls: [URL] - let onCompletion: (Bool) -> Void + let onCompletion: (Bool, [URL]) -> Void func makeCoordinator() -> Coordinator { Coordinator(onCompletion: onCompletion) @@ -20,24 +21,25 @@ struct DocumentExportPicker: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UIDocumentPickerViewController { let picker = UIDocumentPickerViewController(forExporting: urls) picker.delegate = context.coordinator + picker.shouldShowFileExtensions = true return picker } func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {} class Coordinator: NSObject, UIDocumentPickerDelegate { - let onCompletion: (Bool) -> Void + let onCompletion: (Bool, [URL]) -> Void - init(onCompletion: @escaping (Bool) -> Void) { + init(onCompletion: @escaping (Bool, [URL]) -> Void) { self.onCompletion = onCompletion } func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { - onCompletion(true) + onCompletion(true, urls) } func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { - onCompletion(false) + onCompletion(false, []) } } } diff --git a/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift b/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift index 7620582..3355321 100644 --- a/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift +++ b/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift @@ -2,12 +2,61 @@ // RecordingArchiveService.swift // BisonNotes AI // -// Service for archiving audio recordings to external storage. +// Service for archiving audio recordings to iCloud Drive. // Manages export, local file cleanup, and restore from re-import. // import Foundation import CoreData +import AVFoundation + +struct RecordingArchiveLocationInfo: Identifiable, Equatable { + let id: UUID + let recordingId: UUID + let providerDisplayName: String + let displayName: String + let exportedFilename: String + let destinationURLString: String? + let exportedAt: Date? + let fileSize: Int64 + let status: String + + var exportedAtString: String? { + guard let exportedAt else { return nil } + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter.string(from: exportedAt) + } +} + +enum RecordingArchiveError: LocalizedError { + case noArchiveLocation + case locationNotFound + case unableToResolveLocation + case sourceMissing(String) + case copyFailed(String) + case deleteFailed(String) + case invalidAudio(String) + + var errorDescription: String? { + switch self { + case .noArchiveLocation: + return "No archive location is saved for this recording." + case .locationNotFound: + return "The saved archive location could not be found." + case .unableToResolveLocation: + return "The saved archive location is no longer accessible." + case .sourceMissing(let name): + return "The archived audio file could not be found: \(name)" + case .copyFailed(let reason): + return "Could not download archived audio: \(reason)" + case .deleteFailed(let reason): + return "Downloaded audio, but could not remove the archived copy: \(reason)" + case .invalidAudio(let reason): + return "Downloaded file is not valid audio: \(reason)" + } + } +} @MainActor class RecordingArchiveService: ObservableObject { @@ -16,6 +65,11 @@ class RecordingArchiveService: ObservableObject { @Published var isArchiving = false + private static let archiveLocationEntityName = "RecordingArchiveLocationEntry" + private static let statusAvailable = "available" + private static let statusStaleBookmark = "staleBookmark" + private static let statusMissing = "missing" + private var viewContext: NSManagedObjectContext { PersistenceController.shared.container.viewContext } @@ -24,18 +78,39 @@ class RecordingArchiveService: ObservableObject { /// Mark recordings as archived and optionally remove local audio files. /// Call this AFTER the document export picker completes successfully. - func archiveRecordings(_ recordings: [RecordingEntry], removeLocal: Bool) { + /// New archive destinations are limited to iCloud Drive; older saved + /// locations from previous builds can still be restored. + @discardableResult + func archiveRecordings(_ recordings: [RecordingEntry], removeLocal: Bool, exportedURLs: [URL] = []) -> Int { let context = viewContext let now = Date() let formatter = DateFormatter() formatter.dateStyle = .medium let dateString = formatter.string(from: now) + let savedLocations = recordArchiveLocations(for: recordings, exportedURLs: exportedURLs, exportedAt: now) + let savedByRecordingId = Dictionary(grouping: savedLocations, by: \.recordingId) + var archivedCount = 0 for recording in recordings { + guard let recordingId = recording.id, + let locations = savedByRecordingId[recordingId], + !locations.isEmpty else { + recording.lastModified = now + AppLog.shared.recording("Archive: not marking \(recording.recordingName ?? "unknown") archived because no destination URL was saved", level: .error) + continue + } + recording.isArchived = true recording.archivedAt = now - recording.archiveNote = "Exported to Files on \(dateString)" + let firstLocation = locations[0] + let locationCount = locations.count + if locationCount > 1 { + recording.archiveNote = "Exported to \(locationCount) locations on \(dateString)" + } else { + recording.archiveNote = "Exported to \(firstLocation.providerDisplayName) on \(dateString)" + } recording.lastModified = now + archivedCount += 1 if removeLocal, let urlString = recording.recordingURL { let fileURL = Self.resolveLocalURL(from: urlString) @@ -53,10 +128,12 @@ class RecordingArchiveService: ObservableObject { do { try context.save() - AppLog.shared.recording("Archived \(recordings.count) recording(s), removeLocal=\(removeLocal)") + AppLog.shared.recording("Archived \(archivedCount) of \(recordings.count) recording(s), removeLocal=\(removeLocal)") } catch { AppLog.shared.recording("Failed to save archive state: \(error.localizedDescription)", level: .error) } + + return archivedCount } // MARK: - Query @@ -134,6 +211,390 @@ class RecordingArchiveService: ObservableObject { } } + // MARK: - Archive Locations + + func archiveLocations(for recordingId: UUID?) -> [RecordingArchiveLocationInfo] { + guard let recordingId else { return [] } + + let request = NSFetchRequest(entityName: Self.archiveLocationEntityName) + request.predicate = NSPredicate(format: "recordingId == %@", recordingId as CVarArg) + request.sortDescriptors = [NSSortDescriptor(key: "exportedAt", ascending: false)] + + do { + return try viewContext.fetch(request).compactMap(Self.locationInfo(from:)) + } catch { + AppLog.shared.recording("Archive: failed to fetch archive locations: \(error.localizedDescription)", level: .error) + return [] + } + } + + func primaryArchiveLocation(for recordingId: UUID?) -> RecordingArchiveLocationInfo? { + archiveLocations(for: recordingId).first + } + + @discardableResult + func restoreArchivedRecording(_ recording: RecordingEntry, from locationId: UUID? = nil) throws -> URL { + let locationObject: NSManagedObject + if let locationId { + guard let fetched = archiveLocationObject(id: locationId) else { + throw RecordingArchiveError.locationNotFound + } + locationObject = fetched + } else { + guard let recordingId = recording.id, + let first = archiveLocationObject(forRecordingId: recordingId) else { + throw RecordingArchiveError.noArchiveLocation + } + locationObject = first + } + + let sourceURL = try resolvedArchiveURL(from: locationObject) + let sourceName = sourceURL.lastPathComponent + let startedAccessing = sourceURL.startAccessingSecurityScopedResource() + defer { + if startedAccessing { + sourceURL.stopAccessingSecurityScopedResource() + } + } + + guard FileManager.default.fileExists(atPath: sourceURL.path) else { + locationObject.setValue(Self.statusMissing, forKey: "status") + locationObject.setValue(Date(), forKey: "lastVerifiedAt") + try? viewContext.save() + throw RecordingArchiveError.sourceMissing(sourceName) + } + + let destinationURL = try localRestoreDestination(for: recording, sourceURL: sourceURL) + var coordinatorError: NSError? + var operationError: Error? + var didCopy = false + let coordinator = NSFileCoordinator(filePresenter: nil) + coordinator.coordinate(readingItemAt: sourceURL, options: [], error: &coordinatorError) { coordinatedURL in + do { + try FileManager.default.copyItem(at: coordinatedURL, to: destinationURL) + didCopy = true + } catch { + operationError = error + } + } + + if let operationError { + throw RecordingArchiveError.copyFailed(operationError.localizedDescription) + } + if let coordinatorError { + throw RecordingArchiveError.copyFailed(coordinatorError.localizedDescription) + } + guard didCopy else { + throw RecordingArchiveError.copyFailed("The file provider did not return a readable file.") + } + + do { + try validateAudioFile(at: destinationURL) + } catch { + try? FileManager.default.removeItem(at: destinationURL) + throw error + } + + do { + try deleteArchivedSource(at: sourceURL) + } catch { + try? FileManager.default.removeItem(at: destinationURL) + throw error + } + + restoreRecording(recording, newAudioURL: destinationURL) + viewContext.delete(locationObject) + try viewContext.save() + return destinationURL + } + + private func deleteArchivedSource(at sourceURL: URL) throws { + var coordinatorError: NSError? + var operationError: Error? + var didDelete = false + let coordinator = NSFileCoordinator(filePresenter: nil) + + coordinator.coordinate(writingItemAt: sourceURL, options: .forDeleting, error: &coordinatorError) { coordinatedURL in + do { + try FileManager.default.removeItem(at: coordinatedURL) + didDelete = true + } catch { + operationError = error + } + } + + if let operationError { + throw RecordingArchiveError.deleteFailed(operationError.localizedDescription) + } + if let coordinatorError { + throw RecordingArchiveError.deleteFailed(coordinatorError.localizedDescription) + } + if !didDelete && FileManager.default.fileExists(atPath: sourceURL.path) { + throw RecordingArchiveError.deleteFailed("The file provider did not confirm deletion.") + } + } + + private func recordArchiveLocations(for recordings: [RecordingEntry], exportedURLs: [URL], exportedAt: Date) -> [RecordingArchiveLocationInfo] { + guard !exportedURLs.isEmpty else { return [] } + + let exportCandidates = expandedExportedURLs(for: recordings, exportedURLs: exportedURLs) + let recordingsByToken: [String: RecordingEntry] = Dictionary( + uniqueKeysWithValues: recordings.compactMap { recording in + guard let token = Self.archiveToken(for: recording) else { return nil } + return (token, recording) + } + ) + + var saved: [RecordingArchiveLocationInfo] = [] + for url in exportCandidates { + guard let parsed = Self.parseArchiveToken(fromFilename: url.lastPathComponent), + let recording = recordingsByToken[parsed.token], + let recordingId = recording.id else { + AppLog.shared.recording("Archive: exported URL did not match a staged recording: \(url.lastPathComponent)", level: .debug) + continue + } + + let startedAccessing = url.startAccessingSecurityScopedResource() + defer { + if startedAccessing { + url.stopAccessingSecurityScopedResource() + } + } + + guard Self.isSupportedArchiveDestination(url) else { + AppLog.shared.recording("Archive: rejected non-iCloud destination \(url.path)", level: .error) + continue + } + + let existingObject = archiveLocationObject(recordingId: recordingId, destinationURL: url) + let locationObject = existingObject + ?? NSEntityDescription.insertNewObject(forEntityName: Self.archiveLocationEntityName, into: viewContext) + + locationObject.setValue((locationObject.value(forKey: "id") as? UUID) ?? UUID(), forKey: "id") + locationObject.setValue(recordingId, forKey: "recordingId") + locationObject.setValue(Self.providerDisplayName(for: url), forKey: "providerDisplayName") + locationObject.setValue(Self.displayName(for: url), forKey: "displayName") + locationObject.setValue(url.lastPathComponent, forKey: "exportedFilename") + locationObject.setValue(url.absoluteString, forKey: "destinationURLString") + locationObject.setValue(exportedAt, forKey: "exportedAt") + locationObject.setValue(exportedAt, forKey: "lastVerifiedAt") + locationObject.setValue(Self.statusAvailable, forKey: "status") + + let bookmarkData = try? url.bookmarkData( + options: [.minimalBookmark], + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + + if bookmarkData == nil && !FileManager.default.fileExists(atPath: url.path) { + if existingObject == nil { + viewContext.delete(locationObject) + } + AppLog.shared.recording("Archive: skipped untrackable destination URL \(url.lastPathComponent)", level: .error) + continue + } + locationObject.setValue(bookmarkData, forKey: "bookmarkData") + + let size = ((try? FileManager.default.attributesOfItem(atPath: url.path))?[.size] as? Int64) + ?? recording.fileSize + locationObject.setValue(size, forKey: "fileSize") + + if let info = Self.locationInfo(from: locationObject) { + saved.append(info) + } + } + + return saved + } + + private func expandedExportedURLs(for recordings: [RecordingEntry], exportedURLs: [URL]) -> [URL] { + let directlyMatched = exportedURLs.filter { Self.parseArchiveToken(fromFilename: $0.lastPathComponent) != nil } + if !directlyMatched.isEmpty { + return directlyMatched + } + + // Some providers return the selected destination folder for multi-file + // exports instead of one URL per file. In that case, reconstruct the + // expected exported file URLs from the staged filenames. + guard exportedURLs.count == 1, + let destinationFolder = exportedURLs.first else { + return exportedURLs + } + + let expectedFilenames = expectedStagedFilenames(for: recordings) + guard !expectedFilenames.isEmpty else { + return exportedURLs + } + + return expectedFilenames.map { destinationFolder.appendingPathComponent($0) } + } + + private func expectedStagedFilenames(for recordings: [RecordingEntry]) -> [String] { + var usedNames = Set() + return recordings.compactMap { recording in + guard let urlString = recording.recordingURL, + let sourceURL = Self.resolveLocalURL(from: urlString), + FileManager.default.fileExists(atPath: sourceURL.path) else { + return nil + } + return Self.uniqueStagedFilename(for: recording, source: sourceURL, claimed: &usedNames) + } + } + + private func archiveLocationObject(forRecordingId recordingId: UUID) -> NSManagedObject? { + let request = NSFetchRequest(entityName: Self.archiveLocationEntityName) + request.predicate = NSPredicate(format: "recordingId == %@", recordingId as CVarArg) + request.sortDescriptors = [NSSortDescriptor(key: "exportedAt", ascending: false)] + request.fetchLimit = 1 + return try? viewContext.fetch(request).first + } + + private func archiveLocationObject(id: UUID) -> NSManagedObject? { + let request = NSFetchRequest(entityName: Self.archiveLocationEntityName) + request.predicate = NSPredicate(format: "id == %@", id as CVarArg) + request.fetchLimit = 1 + return try? viewContext.fetch(request).first + } + + private func archiveLocationObject(recordingId: UUID, destinationURL: URL) -> NSManagedObject? { + let request = NSFetchRequest(entityName: Self.archiveLocationEntityName) + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "recordingId == %@", recordingId as CVarArg), + NSPredicate(format: "destinationURLString == %@", destinationURL.absoluteString) + ]) + request.fetchLimit = 1 + return try? viewContext.fetch(request).first + } + + private func resolvedArchiveURL(from locationObject: NSManagedObject) throws -> URL { + if let bookmarkData = locationObject.value(forKey: "bookmarkData") as? Data { + var isStale = false + do { + let url = try URL( + resolvingBookmarkData: bookmarkData, + options: [.withoutUI], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + if isStale { + locationObject.setValue(Self.statusStaleBookmark, forKey: "status") + locationObject.setValue(Date(), forKey: "lastVerifiedAt") + try? viewContext.save() + } + return url + } catch { + AppLog.shared.recording("Archive: failed to resolve bookmark: \(error.localizedDescription)", level: .error) + } + } + + if let urlString = locationObject.value(forKey: "destinationURLString") as? String, + let url = URL(string: urlString) { + return url + } + + throw RecordingArchiveError.unableToResolveLocation + } + + private func localRestoreDestination(for recording: RecordingEntry, sourceURL: URL) throws -> URL { + let fileManager = FileManager.default + + if let urlString = recording.recordingURL, + let originalURL = Self.resolveLocalURL(from: urlString), + !fileManager.fileExists(atPath: originalURL.path) { + try fileManager.createDirectory( + at: originalURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + return originalURL + } + + guard let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { + throw RecordingArchiveError.copyFailed("Documents directory is unavailable.") + } + + let ext = sourceURL.pathExtension.isEmpty ? "m4a" : sourceURL.pathExtension + let base = sourceURL.deletingPathExtension().lastPathComponent + var candidate = documentsURL.appendingPathComponent("\(base).\(ext)") + var counter = 2 + while fileManager.fileExists(atPath: candidate.path) { + candidate = documentsURL.appendingPathComponent("\(base)_\(counter).\(ext)") + counter += 1 + } + return candidate + } + + private func validateAudioFile(at url: URL) throws { + do { + let player = try AVAudioPlayer(contentsOf: url) + if player.duration <= 0 { + throw RecordingArchiveError.invalidAudio("File has no audio content.") + } + } catch let archiveError as RecordingArchiveError { + throw archiveError + } catch { + throw RecordingArchiveError.invalidAudio(error.localizedDescription) + } + } + + private static func locationInfo(from object: NSManagedObject) -> RecordingArchiveLocationInfo? { + guard let id = object.value(forKey: "id") as? UUID, + let recordingId = object.value(forKey: "recordingId") as? UUID else { + return nil + } + + return RecordingArchiveLocationInfo( + id: id, + recordingId: recordingId, + providerDisplayName: object.value(forKey: "providerDisplayName") as? String ?? "External Storage", + displayName: object.value(forKey: "displayName") as? String ?? object.value(forKey: "exportedFilename") as? String ?? "Archived audio", + exportedFilename: object.value(forKey: "exportedFilename") as? String ?? "", + destinationURLString: object.value(forKey: "destinationURLString") as? String, + exportedAt: object.value(forKey: "exportedAt") as? Date, + fileSize: object.value(forKey: "fileSize") as? Int64 ?? 0, + status: object.value(forKey: "status") as? String ?? Self.statusAvailable + ) + } + + private static func providerDisplayName(for url: URL) -> String { + if isSupportedArchiveDestination(url) { + return "iCloud Drive" + } + let path = url.path.lowercased() + if path.contains("dropbox") { + return "Dropbox" + } + if path.contains("google drive") || path.contains("googledrive") { + return "Google Drive" + } + if path.contains("proton drive") || path.contains("protondrive") { + return "Proton Drive" + } + return "External Storage" + } + + private static func isSupportedArchiveDestination(_ url: URL) -> Bool { + if let values = try? url.resourceValues(forKeys: [.isUbiquitousItemKey]), + values.isUbiquitousItem == true { + return true + } + + let searchableURLText = [ + url.path, + url.absoluteString, + url.deletingLastPathComponent().path + ] + .joined(separator: " ") + .lowercased() + + return searchableURLText.contains("mobile documents") || + searchableURLText.contains("icloud") + } + + private static func displayName(for url: URL) -> String { + let parent = url.deletingLastPathComponent().lastPathComponent + return parent.isEmpty ? url.lastPathComponent : parent + } + // MARK: - Export Staging /// Stage audio files for export with recognizable filenames of the form diff --git a/BisonNotes AI/BisonNotes AI/Persistence.swift b/BisonNotes AI/BisonNotes AI/Persistence.swift index 5539e73..b76f92d 100644 --- a/BisonNotes AI/BisonNotes AI/Persistence.swift +++ b/BisonNotes AI/BisonNotes AI/Persistence.swift @@ -38,6 +38,10 @@ struct PersistenceController { if inMemory { container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") } + container.persistentStoreDescriptions.forEach { description in + description.setOption(true as NSNumber, forKey: NSMigratePersistentStoresAutomaticallyOption) + description.setOption(true as NSNumber, forKey: NSInferMappingModelAutomaticallyOption) + } container.loadPersistentStores(completionHandler: { (storeDescription, error) in if let error = error as NSError? { // Replace this implementation with code to handle the error appropriately. diff --git a/BisonNotes AI/BisonNotes AI/Views/ArchiveConfirmationView.swift b/BisonNotes AI/BisonNotes AI/Views/ArchiveConfirmationView.swift index 5c6dcb4..1b1e13f 100644 --- a/BisonNotes AI/BisonNotes AI/Views/ArchiveConfirmationView.swift +++ b/BisonNotes AI/BisonNotes AI/Views/ArchiveConfirmationView.swift @@ -32,6 +32,12 @@ struct ArchiveConfirmationView: View { Text(fileSizeString) .font(.subheadline) .foregroundColor(.secondary) + + Text("Archive copies are currently limited to iCloud Drive so the app can reliably track, restore, and clean them up.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) } .padding(.top, 20) @@ -88,7 +94,7 @@ struct ArchiveConfirmationView: View { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) .font(.caption) - Text("You'll need to re-import audio files to play them again") + Text("Use the download button to restore audio later") .font(.caption) .foregroundColor(.orange) } @@ -102,7 +108,7 @@ struct ArchiveConfirmationView: View { Button(action: onConfirm) { HStack { Image(systemName: "square.and.arrow.up") - Text("Export to Files") + Text("Choose iCloud Location") .fontWeight(.semibold) } .foregroundColor(.white) diff --git a/BisonNotes AI/BisonNotes AI/Views/RecordingsListView.swift b/BisonNotes AI/BisonNotes AI/Views/RecordingsListView.swift index f5cdffd..13ee17c 100644 --- a/BisonNotes AI/BisonNotes AI/Views/RecordingsListView.swift +++ b/BisonNotes AI/BisonNotes AI/Views/RecordingsListView.swift @@ -62,6 +62,8 @@ struct RecordingsListView: View { @State private var recordingsToArchive: [RecordingEntry] = [] @State private var archiveExportURLs: [URL] = [] @State private var archiveInfoRecording: AudioRecordingFile? + @State private var archiveRestoreError: String? + @State private var restoringArchiveRecordingId: UUID? @State private var showDateFilter = false @State private var dateFilterStart: Date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date() @State private var dateFilterEnd: Date = Date() @@ -259,13 +261,17 @@ struct RecordingsListView: View { .presentationDetents([.medium, .large]) } .sheet(isPresented: $showingArchiveExportPicker) { - DocumentExportPicker(urls: archiveExportURLs) { success in + DocumentExportPicker(urls: archiveExportURLs) { success, exportedURLs in showingArchiveExportPicker = false if success { - RecordingArchiveService.shared.archiveRecordings( + let archivedCount = RecordingArchiveService.shared.archiveRecordings( recordingsToArchive, - removeLocal: removeLocalAfterArchive + removeLocal: removeLocalAfterArchive, + exportedURLs: exportedURLs ) + if archivedCount == 0 { + archiveRestoreError = "The export completed, but the selected destination was not iCloud Drive or was not trackable. The audio was left local so you can archive it again to iCloud Drive." + } isSelectionMode = false selectedRecordings.removeAll() loadRecordings() @@ -285,11 +291,21 @@ struct RecordingsListView: View { Button("OK", role: .cancel) { archiveInfoRecording = nil } } message: { if let rec = archiveInfoRecording { - let note = rec.archiveNote ?? "Exported to Files" + let note = rec.archiveNote ?? "Exported to iCloud Drive" let dateStr = rec.archivedAtString ?? "" - Text("\(note)\(dateStr.isEmpty ? "" : " on \(dateStr)")\n\nThe audio file is no longer stored locally. Transcripts and summaries are still available. Use \"Import Audio Files\" to restore.") + let location = RecordingArchiveService.shared.primaryArchiveLocation(for: rec.recordingId) + let locationText = location.map { "\nSaved location: \($0.providerDisplayName) / \($0.displayName)" } ?? "" + Text("\(note)\(dateStr.isEmpty ? "" : " on \(dateStr)")\(locationText)\n\nThe audio file is no longer stored locally. Use the download button to restore it, or use \"Import Audio Files\" if the file was moved.") } } + .alert("Audio File Error", isPresented: Binding( + get: { archiveRestoreError != nil }, + set: { if !$0 { archiveRestoreError = nil } } + )) { + Button("OK", role: .cancel) { archiveRestoreError = nil } + } message: { + Text(archiveRestoreError ?? "Unknown error") + } } .onAppear { refreshFileRelationships() @@ -540,6 +556,21 @@ struct RecordingsListView: View { Text("(audio offloaded)") .font(.caption2) .foregroundColor(.secondary) + } else { + Text("(local audio present)") + .font(.caption2) + .foregroundColor(.secondary) + } + } + if let location = RecordingArchiveService.shared.primaryArchiveLocation(for: recording.recordingId) { + HStack(spacing: 4) { + Image(systemName: "externaldrive.badge.checkmark") + .font(.caption) + .foregroundColor(.secondary) + Text("\(location.providerDisplayName) / \(location.displayName)") + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) } } } else if let relationships = enhancedFileManager.getFileRelationships(for: recording.url) { @@ -571,14 +602,40 @@ struct RecordingsListView: View { // Action buttons - separate from main clickable area HStack(spacing: 12) { - Button(action: { - selectedRecordingForPlayer = recording - }) { - Image(systemName: "play.circle.fill") - .font(.title2) - .foregroundColor(.accentColor) + if recording.isArchived && !recording.hasLocalAudio { + Button(action: { + restoreArchivedAudio(recording) + }) { + if restoringArchiveRecordingId != nil && restoringArchiveRecordingId == recording.recordingId { + ProgressView() + .frame(width: 28, height: 28) + } else { + Image(systemName: "arrow.down.circle.fill") + .font(.title2) + .foregroundColor(.accentColor) + } + } + .buttonStyle(PlainButtonStyle()) + .disabled(restoringArchiveRecordingId != nil && restoringArchiveRecordingId == recording.recordingId) + } else if recording.isArchived && recording.hasLocalAudio { + Button(action: { + clearLocalArchiveState(recording) + }) { + Image(systemName: "checkmark.circle.fill") + .font(.title2) + .foregroundColor(.green) + } + .buttonStyle(PlainButtonStyle()) + } else { + Button(action: { + selectedRecordingForPlayer = recording + }) { + Image(systemName: "play.circle.fill") + .font(.title2) + .foregroundColor(.accentColor) + } + .buttonStyle(PlainButtonStyle()) } - .buttonStyle(PlainButtonStyle()) Button(action: { deletionData.recordingToDelete = recording @@ -966,12 +1023,46 @@ struct RecordingsListView: View { // MARK: - Archive Helpers + private func restoreArchivedAudio(_ recording: AudioRecordingFile) { + guard restoringArchiveRecordingId == nil else { return } + guard let recordingId = recording.recordingId, + let recordingEntry = appCoordinator.getRecording(id: recordingId) else { + archiveRestoreError = "Could not find this recording in storage." + return + } + + restoringArchiveRecordingId = recordingId + + Task { @MainActor in + do { + _ = try RecordingArchiveService.shared.restoreArchivedRecording(recordingEntry) + loadRecordings() + refreshFileRelationships() + selectedRecordingForPlayer = recordings.first { $0.recordingId == recordingId } + } catch { + archiveRestoreError = error.localizedDescription + } + restoringArchiveRecordingId = nil + } + } + + private func clearLocalArchiveState(_ recording: AudioRecordingFile) { + guard let recordingId = recording.recordingId, + let recordingEntry = appCoordinator.getRecording(id: recordingId) else { + archiveRestoreError = "Could not find this recording in storage." + return + } + + RecordingArchiveService.shared.clearArchiveFlags(for: recordingEntry) + loadRecordings() + refreshFileRelationships() + } + private func prepareArchiveFromSelection() { let selectedURLs = selectedRecordings let allRecordings = appCoordinator.getAllRecordingsWithData() recordingsToArchive = allRecordings.compactMap { entry -> RecordingEntry? in - guard !entry.recording.isArchived, - let url = appCoordinator.getAbsoluteURL(for: entry.recording), + guard let url = appCoordinator.getAbsoluteURL(for: entry.recording), selectedURLs.contains(url) else { return nil } return entry.recording } diff --git a/README.md b/README.md index 69e1c80..3e217b1 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ All external dependencies are resolved automatically via Swift Package Manager w - **Mistral AI (Free & Paid Tiers)**: Guided in-app setup wizard for Mistral's free tier -- transcription and summarization with no credit card required. Paid tiers available for higher rate limits. Cloud transcription via Voxtral Mini with speaker diarization support. - **On-Device Processing**: Complete privacy with FluidAudio Parakeet transcription and On-Device AI summarization (default for new installs) - **Audio Export**: Share any recording as an audio file via the iOS share sheet +- **Audio Archive to iCloud Drive**: Offload selected recordings, or recordings older than a chosen age, while keeping transcripts, summaries, and a saved restore pointer in the app. Third-party file providers are disabled for archive targets for now. - **Video Import**: Import video files; audio is automatically extracted to M4A - **Audio Cleanup**: Optional pre-transcription DSP processing — high-pass filter, noise gate, dynamic normalization, and peak limiting - **Live Transcription**: On-device live speech-to-text via SFSpeechRecognizer during recording; transcript auto-saved on stop @@ -91,7 +92,7 @@ All external dependencies are resolved automatically via Swift Package Manager w - Recording: `EnhancedAudioSessionManager`, `AudioFileChunkingService`, `AudioRecorderViewModel`, `RecordingCombiner` - Transcription: `FluidAudioManager` (Parakeet), `OpenAITranscribeService`, `MistralTranscribeService`, `WhisperService`, `WyomingWhisperClient`, `AWSTranscribeService`, `LiveTranscriptionService` - Summarization: `OpenAISummarizationService`, `MistralAISummarizationService`, `GoogleAIStudioService`, `AWSBedrockService`, `OnDeviceLLMService`, `AppleNativeEngine` -- Export: `PDFExportService`, `SummaryExportFormatter` +- Export: `PDFExportService`, `SummaryExportFormatter`, `RecordingArchiveService` - UI: `SummariesView`, `SummaryDetailView`, `TranscriptionProgressView`, `AITextView` (with MarkdownUI), `CombineRecordingsView` - Persistence: `Persistence`, `CoreDataManager`, models under `Models/` - Background: `BackgroundProcessingManager` @@ -99,6 +100,14 @@ All external dependencies are resolved automatically via Swift Package Manager w - Share Extension: `ShareViewController` (imports audio from other apps via share sheet) - Action Button: `StartRecordingIntent`, `ActionButtonLaunchManager`, `AppShortcuts` +## Audio Archive + +Audio archive is different from deleting an audio file. When a recording is archived, BisonNotes exports the audio file to iCloud Drive, stores the archive location in Core Data, and can optionally remove only the local audio file. The recording row, transcript, summary, tasks, reminders, and metadata stay in the app. + +Archived recordings show their saved iCloud Drive location and a download button when local audio has been offloaded. Restoring copies the audio back into the app, validates that it is playable audio, clears the archive state, and removes the archived iCloud Drive copy so there is not a second stale file left behind. If the app cannot save a trackable iCloud location, it leaves the local audio in place and does not mark the recording archived. + +For now, archive destinations are intentionally limited to iCloud Drive. Dropbox, Google Drive, Proton Drive, and other iOS File Provider extensions can appear in Files, but they have not been reliable enough for batch export, restore, and post-restore deletion. + ## Transcription Engines The app supports multiple transcription engines for converting audio to text: diff --git a/docs/bisonnotes-ai-guide.html b/docs/bisonnotes-ai-guide.html index 427cfd2..77f3bba 100644 --- a/docs/bisonnotes-ai-guide.html +++ b/docs/bisonnotes-ai-guide.html @@ -250,12 +250,14 @@

First Transcript & Summary

Managing & Deleting Recordings
-

Long press on any recording, or tap and use the "..." menu. Deletion options:

+

Long press on any recording, or tap and use the "..." menu. Storage options:

    +
  • Archive to iCloud Drive — Copies audio to iCloud Drive, keeps transcript/summary and a saved restore location, and can optionally remove only the local audio file.
  • Audio File Only — Keeps transcript/summary, removes audio. Good for saving storage.
  • Everything — Removes audio, transcript, and summary. Cannot be undone.
  • Summary Only — Keeps audio and transcript. Useful for regenerating with a different engine.
+
Archive note: Audio archive targets are currently limited to iCloud Drive. Other Files providers such as Dropbox, Google Drive, and Proton Drive are not used for new archives until restore and cleanup behavior is reliable.
Important: Deletion is permanent. Make sure you have backups if needed.
@@ -300,6 +302,15 @@

Import Existing Audio

  • Files are automatically added to your recordings library.
  • +

    Archive Audio to iCloud Drive

    +
      +
    1. Select one or more recordings, or choose the archive option for recordings older than a selected age.
    2. +
    3. Choose an iCloud Drive location when the document picker opens.
    4. +
    5. Optionally remove the local audio after export. Transcripts, summaries, tasks, reminders, and metadata remain in BisonNotes.
    6. +
    7. Use the download button on an archived recording to restore the audio. After restore, BisonNotes removes the archived iCloud Drive copy.
    8. +
    +
    Current scope: New audio archives are iCloud Drive only. Other Files providers may appear in iOS, but BisonNotes leaves local audio untouched if the selected archive destination is not iCloud Drive.
    +

    Import via Share Extension

    Share from other apps: Import audio files directly from Voice Memos, Files, and other apps using the iOS share sheet.
    @@ -915,6 +926,7 @@

    Advanced Features

    • Import/Export — M4A, MP3, WAV, CAF, AIFF, AIF
    • Audio Export (v1.8) — Share any recording as an audio file via the iOS share sheet
    • +
    • Audio Archive — Offload selected or older audio files to iCloud Drive, keep an in-app restore pointer, and remove the archived cloud copy after restore
    • Video Import (v1.8) — Import video files; audio is automatically extracted to M4A
    • Audio Cleanup (v1.8) — Optional pre-transcription DSP: high-pass filter, noise gate, dynamic normalization, peak limiting
    • Share Extension — Import from Voice Memos, Files, etc.
    • @@ -946,6 +958,7 @@

      Advanced Features

      • iCloud Backup — Full backup of all data to iCloud Drive
      • +
      • Audio Archive to iCloud Drive — Space-saving audio offload with tracked restore locations. Third-party Files providers are paused for archive targets for now.
      • Auto-Backup (v1.7) — Automatically backs up when new recordings are created
      • CloudKit Sync — Optional summary synchronization across devices with paginated queries and schema-safe fallback for reliability
      • Prompted after generating your first summary
      • @@ -994,6 +1007,7 @@

        Data Management

        • Export important recordings as PDF
        • Use iCloud Backup
        • +
        • Archive older audio to iCloud Drive before deleting local copies
        • Clean up old recordings
        • Use descriptive titles
        From 6a16081418ce7558378357c7666069666692e971 Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:10:14 -0400 Subject: [PATCH 11/21] Persist explicit recording start timestamps and use them for recording dates --- ...AudioRecorderViewModel+Interruptions.swift | 20 +++------- .../AudioRecorderViewModel+Segments.swift | 3 +- .../AudioRecorderViewModel+Utilities.swift | 38 ++++++++++++++++++- .../ViewModels/AudioRecorderViewModel.swift | 7 +++- 4 files changed, 51 insertions(+), 17 deletions(-) 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..619c9da 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 recordingStartedAt { + return recordingStartedAt + } + + 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 diff --git a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel.swift b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel.swift index df7e8f9..0059a0f 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: 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 = 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() } From eaf90b7d12477d1ec6152b10f870880676ffa598 Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:43:35 -0400 Subject: [PATCH 12/21] Clean up .recordingmeta sidecars on deletion and fix display name timestamp Address review feedback: the new .recordingmeta sidecar files were not being cleaned up when recordings were deleted, causing silent storage growth. Add cleanup to all four deletion paths (EnhancedFileManager, TranscriptViews, RecordingArchiveService, orphan cleanup). Also fix generateAppRecordingDisplayName() to use recordingStartedAt so the display name timestamp matches the persisted recordingDate. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../BisonNotes AI/EnhancedFileManager.swift | 30 +++++++++++-------- .../Models/RecordingArchiveService.swift | 5 ++++ .../AudioRecorderViewModel+Utilities.swift | 2 +- .../BisonNotes AI/Views/TranscriptViews.swift | 8 +++-- 4 files changed, 29 insertions(+), 16 deletions(-) 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+Utilities.swift b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift index 619c9da..5c994f6 100644 --- a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift +++ b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift @@ -328,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()) return "apprecording-\(timestamp)" } 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) } From e21905630cf0dfb2f7467118f6c268e96a268977 Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:48:35 -0400 Subject: [PATCH 13/21] Add clean audio export with user-friendly filenames Introduce a separate audio export pipeline that stages files with human-readable names (no archive tokens), for user-facing sharing and bulk export. Single-file export from AudioPlayerView and multi-select export from RecordingsListView, with proper cleanup and error handling for recordings missing local audio. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Models/RecordingArchiveService.swift | 113 ++++++++++++++++++ .../BisonNotes AI/Views/AudioPlayerView.swift | 38 +++++- .../Views/RecordingsListView.swift | 85 ++++++++++++- 3 files changed, 232 insertions(+), 4 deletions(-) diff --git a/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift b/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift index 3355321..ae4b0b2 100644 --- a/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift +++ b/BisonNotes AI/BisonNotes AI/Models/RecordingArchiveService.swift @@ -597,6 +597,93 @@ class RecordingArchiveService: ObservableObject { // MARK: - Export Staging + /// Stage audio files for a plain user export. Unlike archive staging, these + /// filenames do not include restore tokens because exporting should not + /// change archive state or create an import/restore marker. + func prepareAudioExportURLs(for recordings: [RecordingFile]) -> [URL] { + guard let stagingDir = Self.audioExportStagingDirectory else { + AppLog.shared.recording("Audio export: no Library dir available for staging", level: .error) + return recordings.map(\.url).filter { FileManager.default.fileExists(atPath: $0.path) } + } + + try? FileManager.default.removeItem(at: stagingDir) + do { + try FileManager.default.createDirectory(at: stagingDir, withIntermediateDirectories: true) + } catch { + AppLog.shared.recording("Audio export: failed to create staging dir: \(error.localizedDescription)", level: .error) + return recordings.map(\.url).filter { FileManager.default.fileExists(atPath: $0.path) } + } + + var stagedURLs: [URL] = [] + var usedNames = Set() + for recording in recordings { + guard let stagedURL = stageAudioExport( + sourceURL: recording.url, + title: recording.name, + recordingDate: recording.date, + stagingDir: stagingDir, + claimed: &usedNames + ) else { continue } + stagedURLs.append(stagedURL) + } + + return stagedURLs + } + + func prepareAudioExportURL(sourceURL: URL, title: String, recordingDate: Date?) -> URL? { + guard let stagingDir = Self.audioExportStagingDirectory else { + AppLog.shared.recording("Audio export: no Library dir available for staging", level: .error) + return FileManager.default.fileExists(atPath: sourceURL.path) ? sourceURL : nil + } + + try? FileManager.default.removeItem(at: stagingDir) + do { + try FileManager.default.createDirectory(at: stagingDir, withIntermediateDirectories: true) + } catch { + AppLog.shared.recording("Audio export: failed to create staging dir: \(error.localizedDescription)", level: .error) + return FileManager.default.fileExists(atPath: sourceURL.path) ? sourceURL : nil + } + + var usedNames = Set() + return stageAudioExport( + sourceURL: sourceURL, + title: title, + recordingDate: recordingDate, + stagingDir: stagingDir, + claimed: &usedNames + ) + } + + func cleanupAudioExportStaging() { + guard let dir = Self.audioExportStagingDirectory else { return } + try? FileManager.default.removeItem(at: dir) + } + + private func stageAudioExport(sourceURL: URL, + title: String, + recordingDate: Date?, + stagingDir: URL, + claimed: inout Set) -> URL? { + guard FileManager.default.fileExists(atPath: sourceURL.path) else { return nil } + + let stagedName = Self.uniqueAudioExportFilename(title: title, source: sourceURL, claimed: &claimed) + let destURL = stagingDir.appendingPathComponent(stagedName) + + do { + try FileManager.default.copyItem(at: sourceURL, to: destURL) + if let recordingDate { + try? FileManager.default.setAttributes( + [.modificationDate: recordingDate], + ofItemAtPath: destURL.path + ) + } + return destURL + } catch { + AppLog.shared.recording("Audio export: failed to stage \(sourceURL.lastPathComponent): \(error.localizedDescription)", level: .error) + return nil + } + } + /// Stage audio files for export with recognizable filenames of the form /// `-.`, where TOKEN is the first 8 hex /// characters of the recording's UUID. Re-imports use this token to match @@ -674,6 +761,16 @@ class RecordingArchiveService: ObservableObject { return support.appendingPathComponent("ArchiveStaging", isDirectory: true) } + static var audioExportStagingDirectory: URL? { + guard let support = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + return support.appendingPathComponent("AudioExportStaging", isDirectory: true) + } + /// First 8 hex chars of the recording's UUID, lowercased. Returns nil if the /// recording has no id (should not happen for persisted entries). static func archiveToken(for recording: RecordingEntry) -> String? { @@ -730,6 +827,22 @@ class RecordingArchiveService: ObservableObject { return candidate } + private static func uniqueAudioExportFilename(title: String, + source: URL, + claimed: inout Set) -> String { + let ext = source.pathExtension.isEmpty ? "m4a" : source.pathExtension + let sanitizedTitle = sanitizeForFilename(title) + let base = sanitizedTitle.isEmpty ? "recording" : sanitizedTitle + var candidate = "\(base).\(ext)" + var counter = 2 + while claimed.contains(candidate) { + candidate = "\(base) \(counter).\(ext)" + counter += 1 + } + claimed.insert(candidate) + return candidate + } + /// Parse an imported filename for a trailing `-<8hex>.` archive token. /// Returns (token, baseName) when present. Name and token are lowercased for /// stable comparison against `recording.id.uuidString`. diff --git a/BisonNotes AI/BisonNotes AI/Views/AudioPlayerView.swift b/BisonNotes AI/BisonNotes AI/Views/AudioPlayerView.swift index 6db80ec..0ab14d1 100644 --- a/BisonNotes AI/BisonNotes AI/Views/AudioPlayerView.swift +++ b/BisonNotes AI/BisonNotes AI/Views/AudioPlayerView.swift @@ -19,12 +19,14 @@ struct AudioPlayerView: View { @State private var currentSavedTitle: String = "" @State private var isUpdatingTitle = false @State private var titleUpdateError: String? + @State private var audioExportURL: URL? + @State private var audioExportError: String? var body: some View { VStack(spacing: 20) { HStack { Spacer() - Button(action: { showingShareSheet = true }) { + Button(action: prepareAudioExport) { Image(systemName: "square.and.arrow.up") .font(.title3) .foregroundColor(.accentColor) @@ -121,8 +123,13 @@ struct AudioPlayerView: View { .padding() .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color(.systemBackground)) - .sheet(isPresented: $showingShareSheet) { - ShareSheet(activityItems: [recording.url]) + .sheet(isPresented: $showingShareSheet, onDismiss: { + audioExportURL = nil + RecordingArchiveService.shared.cleanupAudioExportStaging() + }) { + if let audioExportURL { + ShareSheet(activityItems: [audioExportURL]) + } } .onAppear { AppLog.shared.recording("AudioPlayerView appeared", level: .debug) @@ -140,6 +147,16 @@ struct AudioPlayerView: View { } message: { Text(titleUpdateError ?? "Unknown error") } + .alert("Unable to Export Audio", isPresented: Binding( + get: { audioExportError != nil }, + set: { if !$0 { audioExportError = nil } } + )) { + Button("OK", role: .cancel) { + audioExportError = nil + } + } message: { + Text(audioExportError ?? "Unknown error") + } .onDisappear { AppLog.shared.recording("AudioPlayerView disappeared", level: .debug) if recorderVM.isPlaying { @@ -183,6 +200,21 @@ struct AudioPlayerView: View { let newTime = min(currentTime + 15.0, duration) recorderVM.seekToTime(newTime) } + + private func prepareAudioExport() { + let exportTitle = currentSavedTitle.isEmpty ? recording.name : currentSavedTitle + guard let stagedURL = RecordingArchiveService.shared.prepareAudioExportURL( + sourceURL: recording.url, + title: exportTitle, + recordingDate: recording.date + ) else { + audioExportError = "The audio file could not be prepared for export." + return + } + + audioExportURL = stagedURL + showingShareSheet = true + } private func formatTime(_ time: TimeInterval) -> String { let minutes = Int(time) / 60 diff --git a/BisonNotes AI/BisonNotes AI/Views/RecordingsListView.swift b/BisonNotes AI/BisonNotes AI/Views/RecordingsListView.swift index 13ee17c..a391a40 100644 --- a/BisonNotes AI/BisonNotes AI/Views/RecordingsListView.swift +++ b/BisonNotes AI/BisonNotes AI/Views/RecordingsListView.swift @@ -31,18 +31,20 @@ struct RecordingsListView: View { enum SelectionAction { case combine case archive + case export var instruction: String { switch self { case .combine: return "Select 2 recordings to combine" case .archive: return "Select recordings to archive" + case .export: return "Select recordings to export" } } var maxSelection: Int? { switch self { case .combine: return 2 - case .archive: return nil + case .archive, .export: return nil } } } @@ -61,6 +63,9 @@ struct RecordingsListView: View { @State private var removeLocalAfterArchive = false @State private var recordingsToArchive: [RecordingEntry] = [] @State private var archiveExportURLs: [URL] = [] + @State private var showingAudioExportPicker = false + @State private var audioExportURLs: [URL] = [] + @State private var audioExportSkippedCount = 0 @State private var archiveInfoRecording: AudioRecordingFile? @State private var archiveRestoreError: String? @State private var restoringArchiveRecordingId: UUID? @@ -97,6 +102,14 @@ struct RecordingsListView: View { .foregroundColor(.orange) } + if selectionAction == .export && !selectedRecordings.isEmpty { + Button("Export") { + prepareExportFromSelection() + } + .font(.headline) + .foregroundColor(.accentColor) + } + Button("Cancel") { isSelectionMode = false selectedRecordings.removeAll() @@ -132,6 +145,15 @@ struct RecordingsListView: View { Label("Archive Selected", systemImage: "archivebox") } + Button(action: { + selectionAction = .export + isSelectionMode = true + selectedRecordings.removeAll() + showSelectionWarning = false + }) { + Label("Export Selected", systemImage: "square.and.arrow.up") + } + Button(action: { showingArchiveOlderThan = true }) { @@ -281,6 +303,22 @@ struct RecordingsListView: View { RecordingArchiveService.shared.cleanupArchiveStaging() } } + .sheet(isPresented: $showingAudioExportPicker) { + DocumentExportPicker(urls: audioExportURLs) { success, _ in + showingAudioExportPicker = false + if success { + if audioExportSkippedCount > 0 { + let skippedText = audioExportSkippedCount == 1 ? "1 selected recording was" : "\(audioExportSkippedCount) selected recordings were" + archiveRestoreError = "\(skippedText) not exported because the audio is not stored locally. Restore archived audio before exporting it." + } + isSelectionMode = false + selectedRecordings.removeAll() + } + audioExportURLs = [] + audioExportSkippedCount = 0 + RecordingArchiveService.shared.cleanupAudioExportStaging() + } + } .sheet(isPresented: $showingArchiveOlderThan) { archiveOlderThanSheet } @@ -602,6 +640,18 @@ struct RecordingsListView: View { // Action buttons - separate from main clickable area HStack(spacing: 12) { + if recording.hasLocalAudio { + Button(action: { + prepareExport(for: recording) + }) { + Image(systemName: "square.and.arrow.up") + .font(.title2) + .foregroundColor(.accentColor) + } + .buttonStyle(PlainButtonStyle()) + .accessibilityLabel("Export Audio") + } + if recording.isArchived && !recording.hasLocalAudio { Button(action: { restoreArchivedAudio(recording) @@ -1072,6 +1122,39 @@ struct RecordingsListView: View { } } + private func prepareExportFromSelection() { + let selectedURLs = selectedRecordings + let selected = recordings.filter { selectedURLs.contains($0.url) } + let exportableRecordings = selected.filter { $0.hasLocalAudio } + + audioExportSkippedCount = selected.count - exportableRecordings.count + audioExportURLs = RecordingArchiveService.shared.prepareAudioExportURLs(for: exportableRecordings) + + guard !audioExportURLs.isEmpty else { + audioExportSkippedCount = 0 + archiveRestoreError = "No local audio files are available to export. Restore archived audio before exporting it." + return + } + + showingAudioExportPicker = true + } + + private func prepareExport(for recording: AudioRecordingFile) { + guard recording.hasLocalAudio else { + archiveRestoreError = "This audio file is not stored locally. Restore archived audio before exporting it." + return + } + + audioExportSkippedCount = 0 + audioExportURLs = RecordingArchiveService.shared.prepareAudioExportURLs(for: [recording]) + guard !audioExportURLs.isEmpty else { + archiveRestoreError = "The audio file could not be prepared for export." + return + } + + showingAudioExportPicker = true + } + private var archiveOlderThanSheet: some View { NavigationView { VStack(spacing: 20) { From b6c66b63bb28de9b9a2ef7ae777ea1bc5370a09a Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 23:11:59 +0000 Subject: [PATCH 14/21] Scope recordingStartedAt timestamp to its recording URL Change recordingStartedAt from Date? to (url: URL, date: Date)? so currentRecordingDate(for:) only returns the in-memory start time when the provided URL matches the active recording URL. Previously, an async merge that completed after a new recording started would pick up the new session's timestamp for the old merged file, corrupting its recordingDate. Co-authored-by: Tim Champ --- .../ViewModels/AudioRecorderViewModel+Utilities.swift | 6 +++--- .../BisonNotes AI/ViewModels/AudioRecorderViewModel.swift | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift index 5c994f6..ec345bc 100644 --- a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift +++ b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel+Utilities.swift @@ -283,8 +283,8 @@ extension AudioRecorderViewModel { } func currentRecordingDate(for url: URL?) -> Date { - if let recordingStartedAt { - return recordingStartedAt + if let entry = recordingStartedAt, let url, entry.url == url { + return entry.date } if let url, @@ -328,7 +328,7 @@ extension AudioRecorderViewModel { func generateAppRecordingDisplayName() -> String { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - let timestamp = formatter.string(from: recordingStartedAt ?? 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 0059a0f..348f7ea 100644 --- a/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel.swift +++ b/BisonNotes AI/BisonNotes AI/ViewModels/AudioRecorderViewModel.swift @@ -82,7 +82,7 @@ class AudioRecorderViewModel: NSObject, ObservableObject { // Track last checkpoint time for periodic data flushing var lastCheckpointTime: Date = Date.distantPast - var recordingStartedAt: Date? + 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 @@ -457,7 +457,7 @@ class AudioRecorderViewModel: NSObject, ObservableObject { let audioFilename = documentsPath.appendingPathComponent(generateAppRecordingFilename()) let recordingStartDate = Date() recordingURL = audioFilename - recordingStartedAt = recordingStartDate + recordingStartedAt = (url: audioFilename, date: recordingStartDate) persistRecordingCapturedAt(recordingStartDate, for: audioFilename) // Initialize segment tracking for this new recording From 406a20999bf165647a485ca88f538753456ac761 Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:32:19 -0400 Subject: [PATCH 15/21] Add MLX Swift on-device summarization engine with memory-safe iOS inference New AI engine using Apple's MLX framework and PrismML's Ternary Bonsai models (4B and 8B, 2-bit quantized) for fully on-device transcript summarization. Engine (MLXSwiftEngine.swift): - MLX Swift service actor with model loading, chunked inference, and Markdown-based prompt matching the proven Mac summarize.py approach - Metal buffer cache capped at 32MB (Memory.cacheLimit) to prevent the multi-GB cache accumulation that caused OOM crashes on iOS - Pre-flight memory check via os_proc_available_memory() before model load - 4-bit KV cache quantization (kvBits:4) to reduce inference overhead - Memory warning detection with graceful abort between chunks - Model unloaded and Metal cache cleared after each summarization run - Download manager with Hub API integration for pre-downloading models Settings (MLXSwiftSettingsView.swift): - Model picker with 4B and 8B options, RAM-gated (6GB+ / 8GB+) - Download/delete per model with progress tracking - Temperature, Top-K, Top-P, repetition penalty, max output controls - Advanced settings with context size display and custom model ID field Crash prevention: - BackgroundProcessingManager skips auto-resume after crash detection - EnhancedLoggingSystem tracks session lifecycle for crash detection - CoreDataManager orphan recording logs reduced to debug level Integration: - AIEngineType.mlxSwift added with full settings/factory/metadata support - SummaryMetadataCodec recognizes MLX/Bonsai engine names - SimpleSettingsView preserves MLX selection in on-device flow - Version bumped to 1.10 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../BisonNotes AI.xcodeproj/project.pbxproj | 61 +- .../xcshareddata/swiftpm/Package.resolved | 24 +- .../BisonNotes AI/AISettingsView.swift | 30 +- .../BackgroundProcessingManager.swift | 51 + .../BisonNotes AI/BisonNotesAIApp.swift | 11 +- .../BisonNotes AI/EnhancedLoggingSystem.swift | 8 +- .../BisonNotes AI/FutureAIEngines.swift | 13 +- BisonNotes AI/BisonNotes AI/Info.plist | 2 +- .../BisonNotes AI/MLXSwiftEngine.swift | 974 ++++++++++++++++++ .../BisonNotes AI/MLXSwiftSettingsView.swift | 438 ++++++++ .../Models/CoreDataManager.swift | 4 +- .../Models/SummaryMetadataCodec.swift | 3 + .../BisonNotes AI/Views/AITextView.swift | 2 +- .../BisonNotes AI/Views/SettingsView.swift | 9 +- .../Views/SimpleSettingsView.swift | 18 +- 15 files changed, 1610 insertions(+), 38 deletions(-) create mode 100644 BisonNotes AI/BisonNotes AI/MLXSwiftEngine.swift create mode 100644 BisonNotes AI/BisonNotes AI/MLXSwiftSettingsView.swift diff --git a/BisonNotes AI/BisonNotes AI.xcodeproj/project.pbxproj b/BisonNotes AI/BisonNotes AI.xcodeproj/project.pbxproj index d0dde21..d1f92bc 100644 --- a/BisonNotes AI/BisonNotes AI.xcodeproj/project.pbxproj +++ b/BisonNotes AI/BisonNotes AI.xcodeproj/project.pbxproj @@ -33,6 +33,8 @@ 14FDF90E2E3F8D5600BBD2FA /* AWSTranscribe in Frameworks */ = {isa = PBXBuildFile; productRef = 14FDF90D2E3F8D5600BBD2FA /* AWSTranscribe */; }; 14FDF9102E3F8D5600BBD2FA /* AWSTranscribeStreaming in Frameworks */ = {isa = PBXBuildFile; productRef = 14FDF90F2E3F8D5600BBD2FA /* AWSTranscribeStreaming */; }; 14FDF9122E3F90FB00BBD2FA /* AWSBedrockRuntime in Frameworks */ = {isa = PBXBuildFile; productRef = 14FDF9112E3F90FB00BBD2FA /* AWSBedrockRuntime */; }; + 14B0A0012F80000000BBD2FA /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = 14B0A0042F80000000BBD2FA /* MLXLLM */; }; + 14B0A0022F80000000BBD2FA /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 14B0A0052F80000000BBD2FA /* MLXLMCommon */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -304,6 +306,8 @@ 14FDF90C2E3F8D5600BBD2FA /* AWSS3 in Frameworks */, 14FDF9122E3F90FB00BBD2FA /* AWSBedrockRuntime in Frameworks */, 1435711D2F2137E1000BFF00 /* Textual in Frameworks */, + 14B0A0012F80000000BBD2FA /* MLXLLM in Frameworks */, + 14B0A0022F80000000BBD2FA /* MLXLMCommon in Frameworks */, 14FDF90E2E3F8D5600BBD2FA /* AWSTranscribe in Frameworks */, 143196D12F128D16003BA61E /* llama.xcframework in Frameworks */, 1485FED52E5271970044121F /* WatchConnectivity.framework in Frameworks */, @@ -542,6 +546,8 @@ 14FDF9112E3F90FB00BBD2FA /* AWSBedrockRuntime */, 1435711C2F2137E1000BFF00 /* Textual */, 14FA00022F30000000BBD2FA /* FluidAudio */, + 14B0A0042F80000000BBD2FA /* MLXLLM */, + 14B0A0052F80000000BBD2FA /* MLXLMCommon */, ); productName = "BisonNotes AI"; productReference = 14DBC83D2E34F7B500DAD442 /* BisonNotes AI.app */; @@ -671,6 +677,7 @@ 14FDF9062E3F8D5600BBD2FA /* XCRemoteSwiftPackageReference "aws-sdk-swift" */, 1435711B2F2137E1000BFF00 /* XCRemoteSwiftPackageReference "textual" */, 14FA00032F30000000BBD2FA /* XCRemoteSwiftPackageReference "FluidAudio" */, + 14B0A0032F80000000BBD2FA /* XCRemoteSwiftPackageReference "mlx-swift-lm" */, ); preferredProjectObjectVersion = 77; productRefGroup = 14DBC83E2E34F7B500DAD442 /* Products */; @@ -884,7 +891,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "Bison-Networking.BisonNotes-AI.watchkitapp.widget"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -916,7 +923,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "Bison-Networking.BisonNotes-AI.watchkitapp.widget"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -948,7 +955,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "Bison-Networking.BisonNotes-AI.BisonNotes-Share"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -978,7 +985,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "Bison-Networking.BisonNotes-AI.BisonNotes-Share"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1013,7 +1020,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "Bison-Networking.BisonNotes-AI.watchkitapp"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -1047,7 +1054,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "Bison-Networking.BisonNotes-AI.watchkitapp"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -1067,7 +1074,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 4W55VW7UXX; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "Bison-Networking.BisonNotes-AI-Watch-AppTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -1087,7 +1094,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 4W55VW7UXX; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "Bison-Networking.BisonNotes-AI-Watch-AppTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -1106,7 +1113,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 4W55VW7UXX; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "Bison-Networking.BisonNotes-AI-Watch-AppUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -1125,7 +1132,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 4W55VW7UXX; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "Bison-Networking.BisonNotes-AI-Watch-AppUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = watchos; @@ -1284,7 +1291,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "Bison-Networking.BisonNotes-AI"; PRODUCT_NAME = "BisonNotes AI"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1321,7 +1328,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "Bison-Networking.BisonNotes-AI"; PRODUCT_NAME = "BisonNotes AI"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1343,7 +1350,7 @@ DEVELOPMENT_TEAM = 4W55VW7UXX; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "Bison-Networking.Audio-JournalTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1366,7 +1373,7 @@ DEVELOPMENT_TEAM = 4W55VW7UXX; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.5; - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "Bison-Networking.Audio-JournalTests"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1387,7 +1394,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 4W55VW7UXX; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "Bison-Networking.Audio-JournalUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1408,7 +1415,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 4W55VW7UXX; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "Bison-Networking.Audio-JournalUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1442,7 +1449,7 @@ "@executable_path/../../Frameworks", "@executable_path/../../../../Frameworks", ); - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "Bison-Networking.BisonNotes-AI.controls"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -1480,7 +1487,7 @@ "@executable_path/../../Frameworks", "@executable_path/../../../../Frameworks", ); - MARKETING_VERSION = 1.9; + MARKETING_VERSION = 1.10; PRODUCT_BUNDLE_IDENTIFIER = "Bison-Networking.BisonNotes-AI.controls"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -1618,9 +1625,27 @@ minimumVersion = 1.5.12; }; }; + 14B0A0032F80000000BBD2FA /* XCRemoteSwiftPackageReference "mlx-swift-lm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ml-explore/mlx-swift-lm/"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 2.31.3; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 14B0A0042F80000000BBD2FA /* MLXLLM */ = { + isa = XCSwiftPackageProductDependency; + package = 14B0A0032F80000000BBD2FA /* XCRemoteSwiftPackageReference "mlx-swift-lm" */; + productName = MLXLLM; + }; + 14B0A0052F80000000BBD2FA /* MLXLMCommon */ = { + isa = XCSwiftPackageProductDependency; + package = 14B0A0032F80000000BBD2FA /* XCRemoteSwiftPackageReference "mlx-swift-lm" */; + productName = MLXLMCommon; + }; 1435711C2F2137E1000BFF00 /* Textual */ = { isa = XCSwiftPackageProductDependency; package = 1435711B2F2137E1000BFF00 /* XCRemoteSwiftPackageReference "textual" */; diff --git a/BisonNotes AI/BisonNotes AI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/BisonNotes AI/BisonNotes AI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c65ca73..78dbb70 100644 --- a/BisonNotes AI/BisonNotes AI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/BisonNotes AI/BisonNotes AI.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "fdd5dc351a9904d8e4c74694345993460e330abac0061e1eff58daeeb52af6fa", + "originHash" : "8ea88e0a3f9ae58eeaee7daf30b2d6f869eab6954ae0a9106b52aaa25efa718a", "pins" : [ { "identity" : "async-http-client", @@ -46,6 +46,24 @@ "version" : "0.13.2" } }, + { + "identity" : "mlx-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ml-explore/mlx-swift", + "state" : { + "revision" : "61b9e011e09a62b489f6bd647958f1555bdf2896", + "version" : "0.31.3" + } + }, + { + "identity" : "mlx-swift-lm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ml-explore/mlx-swift-lm/", + "state" : { + "revision" : "25b00d4e22e61ec9c41efda47990cd2084ec87ff", + "version" : "2.31.3" + } + }, { "identity" : "smithy-swift", "kind" : "remoteSourceControl", @@ -276,8 +294,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/huggingface/swift-transformers", "state" : { - "revision" : "b38443e44d93eca770f2eb68e2a4d0fa100f9aa2", - "version" : "1.3.0" + "revision" : "58c4bc11963a140358d791f678a60a2745a23146", + "version" : "1.2.1" } }, { diff --git a/BisonNotes AI/BisonNotes AI/AISettingsView.swift b/BisonNotes AI/BisonNotes AI/AISettingsView.swift index 2e8065a..1cca962 100644 --- a/BisonNotes AI/BisonNotes AI/AISettingsView.swift +++ b/BisonNotes AI/BisonNotes AI/AISettingsView.swift @@ -92,6 +92,9 @@ final class AISettingsViewModel: ObservableObject { case .onDeviceLLM: UserDefaults.standard.set(true, forKey: OnDeviceLLMModelInfo.SettingsKeys.enableOnDeviceLLM) AppLog.shared.general("Auto-enabled On-Device AI engine") + case .mlxSwift: + UserDefaults.standard.set(true, forKey: MLXSwiftSettingsKeys.enabled) + AppLog.shared.general("Auto-enabled experimental MLX Swift engine") case .appleNative: AppLog.shared.general("Selected Apple Native engine") } @@ -117,6 +120,7 @@ struct AISettingsView: View { @State private var showingMistralAISettings = false @State private var showingAWSBedrockSettings = false @State private var showingOnDeviceLLMSettings = false + @State private var showingMLXSwiftSettings = false @State private var showingMistralOnboarding = false @State private var engineStatuses: [String: EngineAvailabilityStatus] = [:] @State private var isRefreshingStatus = false @@ -205,6 +209,13 @@ struct AISettingsView: View { let isEnabled = UserDefaults.standard.bool(forKey: OnDeviceLLMModelInfo.SettingsKeys.enableOnDeviceLLM) let isModelReady = OnDeviceLLMDownloadManager.shared.isModelReady return isEnabled && isModelReady + case .mlxSwift: + let isEnabled = UserDefaults.standard.bool(forKey: MLXSwiftSettingsKeys.enabled) + #if targetEnvironment(simulator) + return isEnabled + #else + return isEnabled && DeviceCapabilities.supportsOnDeviceLLM + #endif case .appleNative: return AppleNativeEngine.modelAvailable } @@ -236,6 +247,9 @@ struct AISettingsView: View { return "Claude 4.5 Haiku" case .onDeviceLLM: return OnDeviceLLMModelInfo.selectedModel.displayName + case .mlxSwift: + let model = UserDefaults.standard.string(forKey: MLXSwiftSettingsKeys.modelId) ?? MLXSwiftSettingsKeys.defaultModelId + return model.components(separatedBy: "/").last ?? model case .appleNative: return "Foundation Models" } @@ -324,6 +338,11 @@ struct AISettingsView: View { OnDeviceLLMSettingsView() } } + .sheet(isPresented: $showingMLXSwiftSettings) { + NavigationStack { + MLXSwiftSettingsView() + } + } .fullScreenCover(isPresented: $showingMistralOnboarding) { MistralOnboardingView(onSetupComplete: { refreshEngineStatuses() @@ -464,7 +483,7 @@ private extension AISettingsView { AIEngineType.availableCases.filter { engine in switch category { case .onDevice: - return [.onDeviceLLM, .appleNative].contains(engine) + return [.onDeviceLLM, .mlxSwift, .appleNative].contains(engine) case .cloud: return [.openAI, .googleAIStudio, .mistralAI, .awsBedrock, .openAICompatible].contains(engine) case .selfHosted: @@ -501,6 +520,7 @@ private extension AISettingsView { func shortDescription(for engine: AIEngineType) -> String { switch engine { case .onDeviceLLM: return "Private, no internet after download" + case .mlxSwift: return "Experimental MLX local summaries" case .appleNative: return "Apple Foundation Models, fully on-device" case .openAI: return "High quality summaries" case .googleAIStudio: return "Gemini model support" @@ -533,6 +553,8 @@ private extension AISettingsView { case .onDeviceLLM: guard DeviceCapabilities.supportsOnDeviceLLM else { return } showingOnDeviceLLMSettings = true + case .mlxSwift: + showingMLXSwiftSettings = true case .appleNative: break // No separate settings sheet — configured via Apple Intelligence system settings } @@ -541,6 +563,7 @@ private extension AISettingsView { func iconName(for engine: AIEngineType) -> String { switch engine { case .onDeviceLLM: return "iphone.gen3" + case .mlxSwift: return "cpu" case .appleNative: // apple.intelligence requires iOS 18.1+ if #available(iOS 18.1, *) { return "apple.intelligence" } @@ -560,6 +583,10 @@ private extension AISettingsView { Text("Not Supported") .font(.caption2.weight(.medium)) .foregroundColor(.secondary) + } else if engine == .mlxSwift { + Text((status?.isAvailable ?? false) ? "Experimental" : "Setup") + .font(.caption2.weight(.medium)) + .foregroundColor((status?.isAvailable ?? false) ? .orange : .secondary) } else if engine == .mistralAI && !(status?.isAvailable ?? false) { HStack(spacing: 4) { Text("Free") @@ -583,6 +610,7 @@ private extension AISettingsView { func engineColor(for engine: AIEngineType) -> Color { switch engine { case .onDeviceLLM: return .indigo + case .mlxSwift: return .orange case .appleNative: return .mint case .openAI: return .blue case .googleAIStudio: return .purple diff --git a/BisonNotes AI/BisonNotes AI/BackgroundProcessingManager.swift b/BisonNotes AI/BisonNotes AI/BackgroundProcessingManager.swift index 3eed351..b4bb534 100644 --- a/BisonNotes AI/BisonNotes AI/BackgroundProcessingManager.swift +++ b/BisonNotes AI/BisonNotes AI/BackgroundProcessingManager.swift @@ -342,6 +342,9 @@ class BackgroundProcessingManager: ObservableObject { private init() { loadJobsFromCoreData() + if AppLog.shared.previousSessionCrashed { + failUnfinishedJobsAfterCrash() + } setupNotifications() setupAppLifecycleObservers() setupPerformanceOptimization() @@ -349,6 +352,10 @@ class BackgroundProcessingManager: ObservableObject { // Resume interrupted jobs and start processing queued jobs on initialization Task { + guard !AppLog.shared.previousSessionCrashed else { + AppLog.shared.backgroundProcessing("Skipping automatic job resume because previous session crashed", level: .error) + return + } await resumeInterruptedJobs() if !activeJobs.filter({ $0.status == .queued }).isEmpty { await processNextJob() @@ -665,6 +672,35 @@ class BackgroundProcessingManager: ObservableObject { } } } + + private func failUnfinishedJobsAfterCrash() { + let message = "Not restarted because the previous app session crashed." + var failedCount = 0 + + activeJobs = activeJobs.map { job in + guard !job.status.isTerminal else { return job } + + failedCount += 1 + let failedJob = job.withStatus(.failed(message)) + + if let jobEntry = coreDataManager.getProcessingJob(id: failedJob.id) { + jobEntry.status = failedJob.status.displayName + jobEntry.progress = failedJob.progress + jobEntry.error = message + jobEntry.completionTime = failedJob.completionTime + jobEntry.lastModified = Date() + coreDataManager.updateProcessingJob(jobEntry) + } + + return failedJob + } + + if failedCount > 0 { + processingStatus = .ready + currentJob = nil + AppLog.shared.backgroundProcessing("Marked \(failedCount) unfinished job(s) failed after crash to prevent automatic restart", level: .error) + } + } func processNextJob() async { // Don't start a new job if one is already running @@ -1856,6 +1892,11 @@ class BackgroundProcessingManager: ObservableObject { // Clear notification badge await clearNotificationBadge() + guard !AppLog.shared.previousSessionCrashed else { + AppLog.shared.backgroundProcessing("Skipping automatic foreground job recovery because previous session crashed", level: .error) + return + } + // Check if any jobs completed while in background await checkForCompletedJobs() @@ -1874,6 +1915,11 @@ class BackgroundProcessingManager: ObservableObject { /// Resume jobs that were interrupted due to background limitations private func resumeInterruptedJobs(notify: Bool = true) async { + guard !AppLog.shared.previousSessionCrashed else { + AppLog.shared.backgroundProcessing("Skipping interrupted job resume because previous session crashed", level: .error) + return + } + // Find interrupted jobs (using the new .interrupted status) let interruptedJobs = activeJobs.filter { $0.status.isInterrupted } @@ -2536,6 +2582,11 @@ class BackgroundProcessingManager: ObservableObject { AppLog.shared.backgroundProcessing("Reconciled \(reconciledCount) stale/orphaned processing job(s)") objectWillChange.send() + guard !AppLog.shared.previousSessionCrashed else { + AppLog.shared.backgroundProcessing("Leaving reconciled jobs stopped because previous session crashed", level: .error) + return + } + // Re-queue interrupted jobs after reconciliation (suppress notifications from periodic monitor). await resumeInterruptedJobs(notify: false) if currentJob == nil && activeJobs.contains(where: { $0.status == .queued }) { diff --git a/BisonNotes AI/BisonNotes AI/BisonNotesAIApp.swift b/BisonNotes AI/BisonNotes AI/BisonNotesAIApp.swift index 58db379..588230a 100644 --- a/BisonNotes AI/BisonNotes AI/BisonNotesAIApp.swift +++ b/BisonNotes AI/BisonNotes AI/BisonNotesAIApp.swift @@ -359,13 +359,14 @@ struct BisonNotesAIApp: App { _ = OnDeviceAIDownloadMonitor.shared } .onOpenURL(perform: handleOpenURL) - .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)) { _ in AppLog.shared.markCleanShutdown() } - .onReceive(NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification)) { _ in + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willTerminateNotification)) { _ in AppLog.shared.markCleanShutdown() } .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + AppLog.shared.markSessionActive() // Clear badge when the user actively opens the app. Using the // scene-phase notification here (rather than AppDelegate // applicationDidBecomeActive) ensures this fires reliably in @@ -687,6 +688,12 @@ struct BisonNotesAIApp: App { // Check for pending transcription/summarization jobs Task { let backgroundManager = BackgroundProcessingManager.shared + + guard !AppLog.shared.previousSessionCrashed else { + AppLog.shared.general("Skipping background job processing because previous session crashed", level: .error) + task.setTaskCompleted(success: true) + return + } // Process any queued jobs if !backgroundManager.activeJobs.filter({ $0.status == .queued }).isEmpty { diff --git a/BisonNotes AI/BisonNotes AI/EnhancedLoggingSystem.swift b/BisonNotes AI/BisonNotes AI/EnhancedLoggingSystem.swift index f00cec8..d7ae192 100644 --- a/BisonNotes AI/BisonNotes AI/EnhancedLoggingSystem.swift +++ b/BisonNotes AI/BisonNotes AI/EnhancedLoggingSystem.swift @@ -76,7 +76,13 @@ class AppLog { UserDefaults.standard.set(false, forKey: Self.cleanShutdownKey) } - /// Call when app enters background or resigns active — marks this session as clean. + /// Call when app becomes active. A later foreground crash should not inherit a + /// previous clean background transition from the same launch. + func markSessionActive() { + UserDefaults.standard.set(false, forKey: Self.cleanShutdownKey) + } + + /// Call when app enters background or terminates — marks this session as clean. func markCleanShutdown() { UserDefaults.standard.set(true, forKey: Self.cleanShutdownKey) } diff --git a/BisonNotes AI/BisonNotes AI/FutureAIEngines.swift b/BisonNotes AI/BisonNotes AI/FutureAIEngines.swift index 15c0649..afc15ca 100644 --- a/BisonNotes AI/BisonNotes AI/FutureAIEngines.swift +++ b/BisonNotes AI/BisonNotes AI/FutureAIEngines.swift @@ -1739,6 +1739,8 @@ class AIEngineFactory { return GoogleAIStudioEngine() case .onDeviceLLM: return OnDeviceLLMEngine() + case .mlxSwift: + return MLXSwiftEngine() case .appleNative: return AppleNativeEngine() } @@ -1764,13 +1766,14 @@ enum AIEngineType: String, CaseIterable { case localLLM = "Ollama" case googleAIStudio = "Google AI Studio" case onDeviceLLM = "On-Device AI" + case mlxSwift = "MLX Swift" case appleNative = "Apple Native" /// Returns all available engine types based on device capabilities static var availableCases: [AIEngineType] { return allCases.filter { engineType in - // Hide on-device LLM if device doesn't have sufficient RAM - if engineType == .onDeviceLLM { + // Hide on-device engines if device doesn't have sufficient RAM + if engineType == .onDeviceLLM || engineType == .mlxSwift { return DeviceCapabilities.supportsOnDeviceLLM } return true @@ -1793,6 +1796,8 @@ enum AIEngineType: String, CaseIterable { return "Advanced AI-powered summaries using Google's Gemini models" case .onDeviceLLM: return "Privacy-focused on-device AI processing using local AI models" + case .mlxSwift: + return "Experimental on-device AI processing using MLX Swift and Ternary Bonsai" case .appleNative: return "Uses Apple's on-device Foundation Models runtime for private summaries" } @@ -1800,7 +1805,7 @@ enum AIEngineType: String, CaseIterable { var isComingSoon: Bool { switch self { - case .localLLM, .openAI, .openAICompatible, .googleAIStudio, .mistralAI, .awsBedrock, .onDeviceLLM, .appleNative: + case .localLLM, .openAI, .openAICompatible, .googleAIStudio, .mistralAI, .awsBedrock, .onDeviceLLM, .mlxSwift, .appleNative: return false } } @@ -1821,6 +1826,8 @@ enum AIEngineType: String, CaseIterable { return ["Google AI Studio API Key", "Internet Connection", "Usage Credits"] case .onDeviceLLM: return ["Downloaded LLM Model (~2 GB)", "No Internet Required", "A16+ Chip Recommended"] + case .mlxSwift: + return ["Experimental Toggle Enabled", "First-use Model Download (~2.3 GB)", "Apple Silicon / 6GB+ RAM Recommended"] case .appleNative: return ["Apple Intelligence-supported device", "iOS/iPadOS/macOS/visionOS 26+", "No Internet Required"] } diff --git a/BisonNotes AI/BisonNotes AI/Info.plist b/BisonNotes AI/BisonNotes AI/Info.plist index 291be7a..c48d14f 100644 --- a/BisonNotes AI/BisonNotes AI/Info.plist +++ b/BisonNotes AI/BisonNotes AI/Info.plist @@ -80,7 +80,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.9 + 1.10 CFBundleVersion 1 LSApplicationQueriesSchemes diff --git a/BisonNotes AI/BisonNotes AI/MLXSwiftEngine.swift b/BisonNotes AI/BisonNotes AI/MLXSwiftEngine.swift new file mode 100644 index 0000000..3c90c36 --- /dev/null +++ b/BisonNotes AI/BisonNotes AI/MLXSwiftEngine.swift @@ -0,0 +1,974 @@ +// +// MLXSwiftEngine.swift +// BisonNotes AI +// +// Experimental MLX Swift summarization engine. +// + +import Foundation + +// MARK: - Settings Keys + +enum MLXSwiftSettingsKeys { + static let enabled = "mlxSwiftExperimentalEnabled" + static let modelId = "mlxSwiftModelId" + static let maxTokens = "mlxSwiftMaxTokens" + static let contextTokens = "mlxSwiftContextTokens" + static let temperature = "mlxSwiftTemperature" + static let topK = "mlxSwiftTopK" + static let topP = "mlxSwiftTopP" + static let repetitionPenalty = "mlxSwiftRepeatPenalty" + + static let defaultModelId = "prism-ml/Ternary-Bonsai-4B-mlx-2bit" + static let defaultMaxTokens = 2700 + static let defaultTemperature: Double = 0.7 + static let defaultTopK = 40 + static let defaultTopP: Double = 0.95 + static let defaultRepetitionPenalty: Double = 1.1 +} + +// MARK: - Download Manager + +@MainActor +final class MLXSwiftDownloadManager: ObservableObject { + static let shared = MLXSwiftDownloadManager() + + @Published var isDownloading = false + @Published var downloadProgress: Double = 0 + @Published var downloadError: String? + @Published private(set) var isModelDownloaded = false + + private var downloadTask: Task? + + var modelId: String { + UserDefaults.standard.string(forKey: MLXSwiftSettingsKeys.modelId) + ?? MLXSwiftSettingsKeys.defaultModelId + } + + var modelDisplayName: String { + modelId.components(separatedBy: "/").last ?? modelId + } + + init() { + refreshModelStatus() + } + + func refreshModelStatus() { + #if canImport(MLXLLM) && canImport(MLXLMCommon) + isModelDownloaded = checkModelExists() + #else + isModelDownloaded = false + #endif + } + + func startDownload() { + guard !isDownloading else { return } + isDownloading = true + downloadError = nil + downloadProgress = 0 + + downloadTask = Task { [weak self] in + guard let self else { return } + do { + #if canImport(MLXLLM) && canImport(MLXLMCommon) + try await self.performDownload() + #else + throw NSError(domain: "MLXSwift", code: -1, + userInfo: [NSLocalizedDescriptionKey: "MLX libraries not available"]) + #endif + self.isDownloading = false + self.isModelDownloaded = true + AppLog.shared.summarization("[MLXSwift] Model pre-download complete: \(self.modelId)") + } catch { + if !Task.isCancelled { + self.downloadError = error.localizedDescription + self.isDownloading = false + AppLog.shared.summarization("[MLXSwift] Download failed: \(error.localizedDescription)", level: .error) + } + } + } + } + + func cancelDownload() { + downloadTask?.cancel() + downloadTask = nil + isDownloading = false + downloadProgress = 0 + downloadError = nil + } + + func deleteModel() { + #if canImport(MLXLLM) && canImport(MLXLMCommon) + do { + try removeModelFiles() + isModelDownloaded = false + AppLog.shared.summarization("[MLXSwift] Model deleted: \(modelId)") + } catch { + downloadError = "Failed to delete: \(error.localizedDescription)" + AppLog.shared.summarization("[MLXSwift] Delete failed: \(error.localizedDescription)", level: .error) + } + #endif + } +} + +// MARK: - Engine + +final class MLXSwiftEngine: SummarizationEngine, ConnectionTestable { + var name: String { "MLX Swift" } + var engineType: String { "MLX Swift" } + var description: String { + "Experimental on-device summarization with MLX Swift." + } + let version = "Experimental" + + var metadataName: String { + UserDefaults.standard.string(forKey: MLXSwiftSettingsKeys.modelId) + ?? MLXSwiftSettingsKeys.defaultModelId + } + + var isAvailable: Bool { + guard UserDefaults.standard.bool(forKey: MLXSwiftSettingsKeys.enabled) else { + return false + } + + #if targetEnvironment(simulator) + return true + #else + return DeviceCapabilities.supportsOnDeviceLLM + #endif + } + + #if canImport(MLXLLM) && canImport(MLXLMCommon) + private let service = MLXSwiftService() + #endif + + func testConnection() async -> Bool { + isAvailable + } + + func generateSummary(from text: String, contentType: ContentType) async throws -> String { + let result = try await processComplete(text: text) + return result.summary + } + + func extractTasks(from text: String) async throws -> [TaskItem] { + let result = try await processComplete(text: text) + return result.tasks + } + + func extractReminders(from text: String) async throws -> [ReminderItem] { + let result = try await processComplete(text: text) + return result.reminders + } + + func extractTitles(from text: String) async throws -> [TitleItem] { + let result = try await processComplete(text: text) + return result.titles + } + + func classifyContent(_ text: String) async throws -> ContentType { + ContentAnalyzer.classifyContent(text) + } + + func processComplete(text: String) async throws -> ( + summary: String, + tasks: [TaskItem], + reminders: [ReminderItem], + titles: [TitleItem], + contentType: ContentType + ) { + guard isAvailable else { + throw SummarizationError.configurationRequired( + message: "MLX Swift is not enabled. Enable it in AI Settings before using it." + ) + } + + #if canImport(MLXLLM) && canImport(MLXLMCommon) + return try await service.processComplete(text: text) + #else + throw SummarizationError.configurationRequired( + message: "MLX Swift libraries are not linked. Add the mlx-swift-lm package and link MLXLLM/MLXLMCommon." + ) + #endif + } +} + +// MARK: - Conditional MLXLLM Implementation + +#if canImport(MLXLLM) && canImport(MLXLMCommon) +import MLXLLM +import MLX +import MLXLMCommon +import UIKit +import os + +// MARK: MLX Memory Configuration for iOS + +/// Configure MLX's Metal buffer cache for iOS jetsam constraints. +/// Must be called before any model loading. +private func configureMLXMemoryForIOS() { + // The default cache limit scales with device RAM and can grow to several GB + // during inference as intermediate buffers accumulate. On iOS this competes + // with the jetsam budget. Apple's own docs recommend small cache sizes + // (as low as 2 MB) for memory-constrained environments. + // + // 32 MB allows meaningful buffer reuse during token generation (where + // intermediate sizes repeat) without letting the cache eat into headroom. + let cacheLimit = 32 * 1024 * 1024 // 32 MB + Memory.cacheLimit = cacheLimit + + let snapshot = Memory.snapshot() + AppLog.shared.summarization( + "[MLXSwift] Memory config: cacheLimit=\(cacheLimit / (1024*1024))MB, " + + "active=\(snapshot.activeMemory / (1024*1024))MB, " + + "cache=\(snapshot.cacheMemory / (1024*1024))MB" + ) +} + +// MARK: Download Manager Hub Integration + +extension MLXSwiftDownloadManager { + func performDownload() async throws { + let id = modelId + let config = ModelConfiguration(id: id) + _ = try await downloadModel(hub: defaultHubApi, configuration: config) { [weak self] progress in + Task { @MainActor [weak self] in + self?.downloadProgress = progress.fractionCompleted + } + } + } + + func checkModelExists() -> Bool { + checkModelExists(modelId: modelId) + } + + func checkModelExists(modelId: String) -> Bool { + let config = ModelConfiguration(id: modelId) + let dir = config.modelDirectory(hub: defaultHubApi) + let configFile = dir.appendingPathComponent("config.json") + return FileManager.default.fileExists(atPath: configFile.path) + } + + func removeModelFiles() throws { + let config = ModelConfiguration(id: modelId) + let dir = config.modelDirectory(hub: defaultHubApi) + if FileManager.default.fileExists(atPath: dir.path) { + try FileManager.default.removeItem(at: dir) + } + } +} + +// MARK: MLX Service Actor + +private actor MLXSwiftService { + private var modelContainer: ModelContainer? + private var loadedModelId: String? + private var memoryObserver: NSObjectProtocol? + private var receivedMemoryWarning = false + + /// Maximum input tokens per chunk for MLX inference. With the Metal buffer + /// cache capped, memory growth during inference stays bounded. This limit + /// prevents extremely long single prompts from exceeding KV cache capacity. + private static let mlxMaxInputTokens = 12288 + + init() {} + + private func ensureMemoryObserver() { + guard memoryObserver == nil else { return } + + let observer = NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + Task { await self.handleMemoryWarning() } + } + self.memoryObserver = observer + } + + deinit { + if let memoryObserver { + NotificationCenter.default.removeObserver(memoryObserver) + } + } + + private func handleMemoryWarning() { + AppLog.shared.summarization("[MLXSwift] Memory warning received", level: .error) + receivedMemoryWarning = true + // Don't nil modelContainer here — if inference is running, the ChatSession + // holds its own reference so it wouldn't free anything. Instead, we check + // receivedMemoryWarning between chunks and after inference completes. + } + + /// Unloads the model and clears the Metal buffer cache. Called after inference + /// completes to return memory to the system. + func unloadModel() { + if modelContainer != nil { + let before = Memory.snapshot() + modelContainer = nil + loadedModelId = nil + Memory.clearCache() + let after = Memory.snapshot() + AppLog.shared.summarization( + "[MLXSwift] Model unloaded — freed " + + "\((before.activeMemory + before.cacheMemory - after.activeMemory - after.cacheMemory) / (1024*1024))MB " + + "(active: \(after.activeMemory / (1024*1024))MB, cache: \(after.cacheMemory / (1024*1024))MB)" + ) + } + receivedMemoryWarning = false + } + + /// Check memory state and throw if we received a warning during processing. + private func checkMemoryPressure() throws { + guard !receivedMemoryWarning else { + AppLog.shared.summarization("[MLXSwift] Aborting due to memory pressure", level: .error) + throw SummarizationError.configurationRequired( + message: "The device is running low on memory. Close other apps and try again, or use a cloud AI engine for large transcripts." + ) + } + } + + func processComplete(text: String) async throws -> ( + summary: String, + tasks: [TaskItem], + reminders: [ReminderItem], + titles: [TitleItem], + contentType: ContentType + ) { + ensureMemoryObserver() + receivedMemoryWarning = false + + let tokenCount = TokenManager.getTokenCount(text) + // Use a hard cap well below the device context window to keep peak memory + // manageable. MLX loads the full model into Metal buffers (no mmap), so + // every token of KV cache and activation memory is additive. + let inputLimit = min(Self.mlxMaxInputTokens, max(1500, configuredContextTokens - configuredMaxTokens - 700)) + + AppLog.shared.summarization("[MLXSwift] Transcript: \(tokenCount) tokens, input limit: \(inputLimit) tokens/chunk") + + let result: (summary: String, tasks: [TaskItem], reminders: [ReminderItem], titles: [TitleItem], contentType: ContentType) + if tokenCount > inputLimit { + AppLog.shared.summarization("[MLXSwift] Chunking transcript into ~\(inputLimit)-token pieces", level: .debug) + result = try await processChunked(text: text, maxTokens: inputLimit) + } else { + result = try await runCompletePrompt(transcript: text, contentHint: ContentAnalyzer.classifyContent(text)) + } + + // Unload model after processing to free Metal memory for the rest of the app. + // Next summarization will reload it. + unloadModel() + return result + } + + private func processChunked(text: String, maxTokens: Int) async throws -> ( + summary: String, + tasks: [TaskItem], + reminders: [ReminderItem], + titles: [TitleItem], + contentType: ContentType + ) { + let chunks = TokenManager.chunkText(text, maxTokens: maxTokens) + var chunkResults: [MLXSwiftStructuredResponse] = [] + + for (index, chunk) in chunks.enumerated() { + // Bail out if iOS has signaled memory pressure between chunks + try checkMemoryPressure() + + AppLog.shared.summarization("[MLXSwift] Processing chunk \(index + 1)/\(chunks.count)", level: .debug) + let result = try await runCompletePrompt( + transcript: chunk, + contentHint: ContentAnalyzer.classifyContent(chunk) + ) + chunkResults.append( + MLXSwiftStructuredResponse( + summary: result.summary, + tasks: result.tasks, + reminders: result.reminders, + titles: result.titles, + contentType: result.contentType + ) + ) + } + + return try await consolidate(chunkResults: chunkResults, originalContentType: ContentAnalyzer.classifyContent(text)) + } + + private func runCompletePrompt(transcript: String, contentHint: ContentType) async throws -> ( + summary: String, + tasks: [TaskItem], + reminders: [ReminderItem], + titles: [TitleItem], + contentType: ContentType + ) { + let wordCount = transcript.split(separator: " ").count + let targetWords = max(200, Int(Double(wordCount) * 0.15)) + + let prompt = """ + Analyze the following transcript and extract the actual content discussed. \ + Base your response ONLY on what is actually mentioned in the transcript. + + 1. A STRUCTURED OUTLINE SUMMARY + - CRITICAL: The summary must be approximately \(targetWords) words long. + - Use sections: Overview, Key Facts, Important Notes, Conclusions. + - Expand on details using nested bullet points. + - Write about what was ACTUALLY discussed in the transcript, not generic examples. + 2. A list of actionable tasks (personal items only) - ONLY include tasks that are actually mentioned + 3. Time-sensitive reminders and deadlines - ONLY include reminders that are actually mentioned + 4. 3-5 suggested titles - Based on the ACTUAL topics discussed + + IMPORTANT FORMATTING RULES: + - For tasks, reminders, and titles: Start each line with "- " followed directly by the text + - Do NOT use prefixes like [Task 1], [Reminder 1], or [Title 1] + - Do NOT include placeholder or example text - only extract what is actually in the transcript + - If no tasks are mentioned, leave the Tasks section empty + - If no reminders are mentioned, leave the Reminders section empty + + Format your response with clear sections: + + ## Summary + ### 1. Overview + [Write a detailed overview based on what was actually discussed] + + ### 2. Key Facts & Details + - [Extract specific facts, numbers, dates, and names that were mentioned] + + ### 3. Important Notes + - [Extract important context or observations from the transcript] + + ### 4. Conclusions + [Summarize conclusions or decisions that were actually made] + + ## Tasks + [Only include tasks that are explicitly mentioned in the transcript. If none, leave empty.] + + ## Reminders + [Only include reminders with dates/times that are explicitly mentioned. If none, leave empty.] + + ## Suggested Titles + [Generate titles based on the actual main topics discussed in the transcript] + + Transcript: + \(transcript) + """ + + let rawResponse = try await generate(prompt: prompt) + return MLXSwiftResponseParser.parseMarkdown(rawResponse, fallbackText: transcript) + } + + private func consolidate( + chunkResults: [MLXSwiftStructuredResponse], + originalContentType: ContentType + ) async throws -> ( + summary: String, + tasks: [TaskItem], + reminders: [ReminderItem], + titles: [TitleItem], + contentType: ContentType + ) { + let encodedChunks = chunkResults.enumerated().map { index, result in + """ + Chunk \(index + 1): + Summary: + \(result.summary) + + Tasks: + \(result.tasks.map { "- \($0.text)" }.joined(separator: "\n")) + + Reminders: + \(result.reminders.map { "- \($0.text)" }.joined(separator: "\n")) + + Titles: + \(result.titles.map { "- \($0.text)" }.joined(separator: "\n")) + """ + }.joined(separator: "\n\n") + + let prompt = """ + Merge these partial transcript analyses into one cohesive final result. + + Rules: + - Combine the summaries into one unified summary preserving all key details. + - Deduplicate tasks and reminders across chunks. + - Keep the best 3-5 titles. + - Write about what was ACTUALLY discussed, not generic examples. + + Use this format: + + ## Summary + ### 1. Overview + [Combined overview] + + ### 2. Key Facts & Details + - [Merged facts from all chunks] + + ### 3. Important Notes + - [Merged notes] + + ### 4. Conclusions + [Combined conclusions] + + ## Tasks + [Deduplicated tasks, or empty if none] + + ## Reminders + [Deduplicated reminders, or empty if none] + + ## Suggested Titles + [Best 3-5 titles] + + Partial analyses: + \(encodedChunks) + """ + + let rawResponse = try await generate(prompt: prompt) + return MLXSwiftResponseParser.parseMarkdown(rawResponse, fallbackText: encodedChunks) + } + + private static let systemInstruction = """ + You are an AI assistant specialized in processing audio transcripts. \ + Analyze the ACTUAL CONTENT of the transcript and extract only what is explicitly mentioned. + + CRITICAL: + - Extract ONLY information that appears in the transcript itself + - Do NOT generate placeholder text, examples, or generic content + - Do NOT include tasks, reminders, or titles unless they are actually mentioned + - If no tasks are mentioned, return an empty tasks section + - If no reminders are mentioned, return an empty reminders section + - Base titles on the ACTUAL topics discussed, not generic examples + + Provide: + 1. A comprehensive summary using Markdown formatting based on what was actually discussed + 2. Actionable tasks (personal items only) - ONLY if explicitly mentioned in the transcript + 3. Time-sensitive reminders (personal appointments and deadlines) - ONLY if explicitly mentioned + 4. Suggested titles based on the ACTUAL main topics discussed + + Be thorough but concise. Focus on information that is personally relevant to the speaker \ + and actually appears in the transcript. + """ + + private func generate(prompt: String) async throws -> String { + let container = try await loadContainer() + let parameters = GenerateParameters( + maxTokens: configuredMaxTokens, + maxKVSize: configuredContextTokens, + kvBits: 4, + kvGroupSize: 64, + temperature: configuredTemperature, + topP: configuredTopP, + topK: configuredTopK, + repetitionPenalty: configuredRepetitionPenalty, + prefillStepSize: 256 + ) + let session = ChatSession( + container, + instructions: Self.systemInstruction, + generateParameters: parameters + ) + + let response = try await session.respond(to: prompt) + return MLXSwiftResponseParser.stripThinking(from: response) + } + + /// Minimum available memory (in bytes) required before attempting to load the model. + /// The 2-bit 8B model is ~2.3 GB on disk. With the Metal buffer cache capped at + /// 32 MB and 4-bit KV quantization, total overhead stays modest. 2.5 GB gives + /// the model weights room to load with buffer for inference overhead. + private static let minimumAvailableMemory: UInt64 = 2_500_000_000 + + private func loadContainer() async throws -> ModelContainer { + let modelId = UserDefaults.standard.string(forKey: MLXSwiftSettingsKeys.modelId) + ?? MLXSwiftSettingsKeys.defaultModelId + + if let modelContainer, loadedModelId == modelId { + return modelContainer + } + + // Configure MLX's Metal buffer cache for iOS before first load + configureMLXMemoryForIOS() + + // Clear any stale cached buffers from a previous load + Memory.clearCache() + + // Check available memory before loading to avoid jetsam (OOM) kills + let available = os_proc_available_memory() + AppLog.shared.summarization("[MLXSwift] Available memory before load: \(available / 1_000_000) MB") + guard available >= Self.minimumAvailableMemory else { + let availableMB = available / 1_000_000 + let requiredMB = Self.minimumAvailableMemory / 1_000_000 + AppLog.shared.summarization("[MLXSwift] Insufficient memory: \(availableMB) MB available, \(requiredMB) MB required", level: .error) + throw SummarizationError.configurationRequired( + message: "Not enough free memory to load the MLX model. Close other apps and try again. (\(availableMB) MB available, \(requiredMB) MB needed)" + ) + } + + AppLog.shared.summarization("[MLXSwift] Loading model: \(modelId)") + let container = try await loadModelContainer(id: modelId) { progress in + if progress.totalUnitCount > 0 { + let percent = Int((Double(progress.completedUnitCount) / Double(progress.totalUnitCount)) * 100) + AppLog.shared.summarization("[MLXSwift] Model download/load progress: \(percent)%", level: .debug) + } + } + + modelContainer = container + loadedModelId = modelId + + let postLoad = Memory.snapshot() + AppLog.shared.summarization( + "[MLXSwift] Model loaded: \(modelId) — " + + "active=\(postLoad.activeMemory / (1024*1024))MB, " + + "cache=\(postLoad.cacheMemory / (1024*1024))MB, " + + "peak=\(postLoad.peakMemory / (1024*1024))MB" + ) + return container + } + + // MARK: Configured Parameters + + private var configuredMaxTokens: Int { + if UserDefaults.standard.object(forKey: MLXSwiftSettingsKeys.maxTokens) != nil { + return UserDefaults.standard.integer(forKey: MLXSwiftSettingsKeys.maxTokens) + } + return MLXSwiftSettingsKeys.defaultMaxTokens + } + + private var configuredContextTokens: Int { + if UserDefaults.standard.object(forKey: MLXSwiftSettingsKeys.contextTokens) != nil { + return UserDefaults.standard.integer(forKey: MLXSwiftSettingsKeys.contextTokens) + } + return DeviceCapabilities.onDeviceLLMContextSize + } + + private var configuredTemperature: Float { + if UserDefaults.standard.object(forKey: MLXSwiftSettingsKeys.temperature) != nil { + return Float(UserDefaults.standard.double(forKey: MLXSwiftSettingsKeys.temperature)) + } + return Float(MLXSwiftSettingsKeys.defaultTemperature) + } + + private var configuredTopK: Int { + if UserDefaults.standard.object(forKey: MLXSwiftSettingsKeys.topK) != nil { + return UserDefaults.standard.integer(forKey: MLXSwiftSettingsKeys.topK) + } + return MLXSwiftSettingsKeys.defaultTopK + } + + private var configuredTopP: Float { + if UserDefaults.standard.object(forKey: MLXSwiftSettingsKeys.topP) != nil { + return Float(UserDefaults.standard.double(forKey: MLXSwiftSettingsKeys.topP)) + } + return Float(MLXSwiftSettingsKeys.defaultTopP) + } + + private var configuredRepetitionPenalty: Float { + if UserDefaults.standard.object(forKey: MLXSwiftSettingsKeys.repetitionPenalty) != nil { + return Float(UserDefaults.standard.double(forKey: MLXSwiftSettingsKeys.repetitionPenalty)) + } + return Float(MLXSwiftSettingsKeys.defaultRepetitionPenalty) + } +} + +// MARK: Response Types + +private struct MLXSwiftStructuredResponse { + let summary: String + let tasks: [TaskItem] + let reminders: [ReminderItem] + let titles: [TitleItem] + let contentType: ContentType +} + +private enum MLXSwiftResponseParser { + static func parse( + _ rawResponse: String, + fallbackText: String + ) -> ( + summary: String, + tasks: [TaskItem], + reminders: [ReminderItem], + titles: [TitleItem], + contentType: ContentType + ) { + let cleaned = stripThinking(from: rawResponse) + if let data = extractJSONObject(from: cleaned).data(using: .utf8), + let decoded = try? JSONDecoder().decode(CompleteResponse.self, from: data) { + return decoded.toSummaryResult(fallbackText: fallbackText) + } + + AppLog.shared.summarization("[MLXSwift] Could not parse structured JSON, using raw response as summary", level: .error) + let fallbackSummary = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + return ( + summary: fallbackSummary.isEmpty ? "## Summary\n\nNo summary was generated." : fallbackSummary, + tasks: [], + reminders: [], + titles: [], + contentType: ContentAnalyzer.classifyContent(fallbackText) + ) + } + + /// Parse Markdown-formatted response with ## sections into structured result. + /// Falls back to JSON parsing, then raw text. + static func parseMarkdown( + _ rawResponse: String, + fallbackText: String + ) -> ( + summary: String, + tasks: [TaskItem], + reminders: [ReminderItem], + titles: [TitleItem], + contentType: ContentType + ) { + let cleaned = stripThinking(from: rawResponse) + let contentType = ContentAnalyzer.classifyContent(fallbackText) + + // Split into sections by ## headers + let sections = extractMarkdownSections(from: cleaned) + + // Extract summary: everything under "## Summary" (or the whole text if no sections found) + let summary = sections["summary"]?.trimmingCharacters(in: .whitespacesAndNewlines) + ?? cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + + // Extract tasks: bullet lines under "## Tasks" + let tasks: [TaskItem] = extractBulletItems(from: sections["tasks"]).map { text in + TaskItem( + text: text, + priority: .medium, + timeReference: nil, + category: .general, + confidence: 0.7 + ) + } + + // Extract reminders: bullet lines under "## Reminders" + let reminders: [ReminderItem] = extractBulletItems(from: sections["reminders"]).map { text in + let timeRef = ReminderItem.TimeReference.fromReminderText(text) + return ReminderItem( + text: text, + timeReference: timeRef, + urgency: .later, + confidence: 0.7 + ) + } + + // Extract titles: bullet lines under "## Suggested Titles" + let titles: [TitleItem] = extractBulletItems(from: sections["titles"]).map { text in + // Strip bold markers and quotes from title text + let cleanTitle = text + .replacingOccurrences(of: "**", with: "") + .replacingOccurrences(of: "\"", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + return TitleItem( + text: cleanTitle, + confidence: 0.8, + category: .general + ) + } + + if summary.isEmpty { + // No Markdown sections found — try JSON fallback + return parse(cleaned, fallbackText: fallbackText) + } + + return ( + summary: summary, + tasks: tasks, + reminders: reminders, + titles: titles, + contentType: contentType + ) + } + + /// Split Markdown text into named sections by ## headers. + /// Returns lowercased section names mapped to their content. + private static func extractMarkdownSections(from text: String) -> [String: String] { + var sections: [String: String] = [:] + var currentKey: String? + var currentContent: [String] = [] + + for line in text.components(separatedBy: "\n") { + if line.hasPrefix("## ") { + // Save previous section + if let key = currentKey { + sections[key] = currentContent.joined(separator: "\n") + } + let header = line.dropFirst(3).trimmingCharacters(in: .whitespaces).lowercased() + // Normalize header names + if header.contains("summary") { + currentKey = "summary" + } else if header.contains("task") { + currentKey = "tasks" + } else if header.contains("reminder") { + currentKey = "reminders" + } else if header.contains("title") { + currentKey = "titles" + } else { + currentKey = header + } + currentContent = [] + } else { + currentContent.append(line) + } + } + // Save last section + if let key = currentKey { + sections[key] = currentContent.joined(separator: "\n") + } + + return sections + } + + /// Extract bullet-pointed items (lines starting with "- ") from a section. + private static func extractBulletItems(from section: String?) -> [String] { + guard let section, !section.isEmpty else { return [] } + return section + .components(separatedBy: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { $0.hasPrefix("- ") } + .map { String($0.dropFirst(2)).trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + static func stripThinking(from text: String) -> String { + text.replacingOccurrences( + of: #"(?is).*?"#, + with: "", + options: .regularExpression + ) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func extractJSONObject(from text: String) -> String { + var cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines) + cleaned = cleaned.replacingOccurrences(of: #"^```(?:json)?\s*"#, with: "", options: [.regularExpression, .caseInsensitive]) + cleaned = cleaned.replacingOccurrences(of: #"\s*```$"#, with: "", options: .regularExpression) + + guard let start = cleaned.firstIndex(of: "{"), + let end = cleaned.lastIndex(of: "}"), + start <= end else { + return cleaned + } + + return String(cleaned[start...end]) + } +} + +private struct CompleteResponse: Decodable { + var summary: String? + var tasks: [TaskDTO]? + var reminders: [ReminderDTO]? + var titles: [TitleDTO]? + var contentType: String? + + func toSummaryResult(fallbackText: String) -> ( + summary: String, + tasks: [TaskItem], + reminders: [ReminderItem], + titles: [TitleItem], + contentType: ContentType + ) { + let parsedContentType = ContentType(rawValue: contentType ?? "") + ?? ContentAnalyzer.classifyContent(fallbackText) + let parsedTasks = (tasks ?? []).compactMap { $0.toTaskItem() } + let parsedReminders = (reminders ?? []).compactMap { $0.toReminderItem() } + let parsedTitles = (titles ?? []).compactMap { $0.toTitleItem() } + let trimmedSummary = (summary ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + + return ( + summary: trimmedSummary.isEmpty ? "## Summary\n\nNo summary was generated." : trimmedSummary, + tasks: parsedTasks, + reminders: parsedReminders, + titles: parsedTitles, + contentType: parsedContentType + ) + } +} + +private struct TaskDTO: Decodable { + var text: String? + var priority: String? + var timeReference: String? + var category: String? + var confidence: Double? + + func toTaskItem() -> TaskItem? { + guard let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { + return nil + } + + return TaskItem( + text: text, + priority: TaskItem.Priority(rawValue: priority ?? "") ?? .medium, + timeReference: timeReference?.nilIfBlank, + category: TaskItem.TaskCategory(rawValue: category ?? "") ?? .general, + confidence: confidence ?? 0.7 + ) + } +} + +private struct ReminderDTO: Decodable { + var text: String? + var timeReference: String? + var urgency: String? + var confidence: Double? + + func toReminderItem() -> ReminderItem? { + guard let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { + return nil + } + + let timeText = timeReference?.nilIfBlank ?? ReminderItem.TimeReference.fromReminderText(text).displayText + return ReminderItem( + text: text, + timeReference: ReminderItem.TimeReference.fromReminderText(timeText), + urgency: ReminderItem.Urgency(rawValue: urgency ?? "") ?? .later, + confidence: confidence ?? 0.7 + ) + } +} + +private struct TitleDTO: Decodable { + var text: String? + var confidence: Double? + var category: String? + + init(from decoder: Decoder) throws { + if let singleValue = try? decoder.singleValueContainer(), + let titleText = try? singleValue.decode(String.self) { + self.text = titleText + self.confidence = 0.7 + self.category = TitleItem.TitleCategory.general.rawValue + return + } + + let container = try decoder.container(keyedBy: CodingKeys.self) + self.text = try container.decodeIfPresent(String.self, forKey: .text) + self.confidence = try container.decodeIfPresent(Double.self, forKey: .confidence) + self.category = try container.decodeIfPresent(String.self, forKey: .category) + } + + private enum CodingKeys: String, CodingKey { + case text + case confidence + case category + } + + func toTitleItem() -> TitleItem? { + guard let text = text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { + return nil + } + + return TitleItem( + text: text, + confidence: confidence ?? 0.7, + category: TitleItem.TitleCategory(rawValue: category ?? "") ?? .general + ) + } +} + +#endif + +private extension String { + var nilIfBlank: String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/BisonNotes AI/BisonNotes AI/MLXSwiftSettingsView.swift b/BisonNotes AI/BisonNotes AI/MLXSwiftSettingsView.swift new file mode 100644 index 0000000..c56929a --- /dev/null +++ b/BisonNotes AI/BisonNotes AI/MLXSwiftSettingsView.swift @@ -0,0 +1,438 @@ +// +// MLXSwiftSettingsView.swift +// BisonNotes AI +// +// Settings for the experimental MLX Swift summarization engine. +// + +import SwiftUI + +// MARK: - Available MLX Models + +struct MLXModelOption: Identifiable { + let id: String // HuggingFace model ID + let displayName: String + let description: String + let downloadSize: String + let parameters: String + let contextWindow: Int + /// Minimum device RAM in GB required to run this model + let requiredRAM: Double + + static let available: [MLXModelOption] = [ + MLXModelOption( + id: "prism-ml/Ternary-Bonsai-4B-mlx-2bit", + displayName: "Ternary Bonsai 4B", + description: "Fast, memory-efficient model for on-device summaries.", + downloadSize: "~1.1 GB", + parameters: "4B", + contextWindow: 16_384, + requiredRAM: 6.0 + ), + MLXModelOption( + id: "prism-ml/Ternary-Bonsai-8B-mlx-2bit", + displayName: "Ternary Bonsai 8B", + description: "Slower but higher quality summaries.", + downloadSize: "~2.3 GB", + parameters: "8B", + contextWindow: 16_384, + requiredRAM: 8.0 + ), + ] +} + +// MARK: - MLX Swift Settings View + +struct MLXSwiftSettingsView: View { + + // MARK: - State + + @Environment(\.dismiss) private var dismiss + + @AppStorage(MLXSwiftSettingsKeys.enabled) private var isEnabled = false + @AppStorage(MLXSwiftSettingsKeys.modelId) private var modelId = MLXSwiftSettingsKeys.defaultModelId + @AppStorage(MLXSwiftSettingsKeys.temperature) private var temperature = MLXSwiftSettingsKeys.defaultTemperature + @AppStorage(MLXSwiftSettingsKeys.maxTokens) private var maxTokens = MLXSwiftSettingsKeys.defaultMaxTokens + @AppStorage(MLXSwiftSettingsKeys.topK) private var topK = MLXSwiftSettingsKeys.defaultTopK + @AppStorage(MLXSwiftSettingsKeys.topP) private var topP = MLXSwiftSettingsKeys.defaultTopP + @AppStorage(MLXSwiftSettingsKeys.repetitionPenalty) private var repetitionPenalty = MLXSwiftSettingsKeys.defaultRepetitionPenalty + + @StateObject private var downloadManager = MLXSwiftDownloadManager.shared + + @State private var showingDeleteConfirmation = false + @State private var modelToDelete: MLXModelOption? + @State private var showingAdvancedSettings = false + + // MARK: - Body + + var body: some View { + let isSupported = DeviceCapabilities.supportsOnDeviceLLM + + return Form { + // Info Section + Section { + if isSupported { + Text("Process transcripts locally using Apple's MLX framework. Models download from Hugging Face on first use. No internet connection required after download.") + .font(.caption) + .foregroundColor(.secondary) + + let deviceRAM = DeviceCapabilities.totalRAMInGB + if deviceRAM >= 4.0 && deviceRAM < 6.0 { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + .font(.caption) + VStack(alignment: .leading, spacing: 4) { + Text("Limited Memory") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.orange) + Text("Your device has 4-6GB RAM. The 4B model is recommended. The 8B model may not fit in memory.") + .font(.caption2) + .foregroundColor(.secondary) + } + } + .padding(.top, 4) + } + } else { + Text("MLX Swift requires a device with 4GB+ RAM and Apple Silicon. Your device does not meet this requirement.") + .font(.caption) + .foregroundColor(.red) + } + } + + // Model Selection Section + Section("Model") { + ForEach(MLXModelOption.available.filter { DeviceCapabilities.totalRAMInGB >= $0.requiredRAM }) { model in + modelRow(for: model) + } + } + + // Download Progress (if downloading) + if downloadManager.isDownloading { + Section("Download Progress") { + downloadProgressView + } + } + + // Model Status Section + Section("Model Status") { + modelStatusView + } + + // Generation Settings + Section("Generation Settings") { + temperatureSlider + } + + // Advanced Settings + Section { + DisclosureGroup("Advanced Settings", isExpanded: $showingAdvancedSettings) { + advancedSettingsView + } + } + } + .navigationTitle("MLX Swift") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { + dismiss() + } + } + } + .alert("Delete Model?", isPresented: $showingDeleteConfirmation, presenting: modelToDelete) { model in + Button("Delete", role: .destructive) { + // Temporarily point the download manager at this model to delete it + let previousId = modelId + modelId = model.id + downloadManager.refreshModelStatus() + downloadManager.deleteModel() + modelId = previousId + downloadManager.refreshModelStatus() + } + Button("Cancel", role: .cancel) {} + } message: { model in + Text("This will delete \(model.displayName) (\(model.downloadSize)) from your device. You can re-download it later.") + } + .onChange(of: modelId) { + downloadManager.refreshModelStatus() + } + } + + // MARK: - Model Row + + @ViewBuilder + private func modelRow(for model: MLXModelOption) -> some View { + let isSelected = modelId == model.id + let isDownloaded = isModelDownloaded(model) + + Button { + if isDownloaded && !isSelected { + modelId = model.id + isEnabled = true + UserDefaults.standard.set(AIEngineType.mlxSwift.rawValue, forKey: "SelectedAIEngine") + SummaryManager.shared.setEngine(AIEngineType.mlxSwift.rawValue) + } + } label: { + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(model.displayName) + .font(.headline) + + if isSelected && isDownloaded { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.caption) + } + } + + Text(model.description) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + + Spacer() + } + + HStack(spacing: 16) { + Label(model.downloadSize, systemImage: "arrow.down.circle") + .font(.caption2) + .foregroundColor(.secondary) + + Label(model.parameters, systemImage: "cpu") + .font(.caption2) + .foregroundColor(.secondary) + + Label("\(model.contextWindow / 1024)k context", systemImage: "text.alignleft") + .font(.caption2) + .foregroundColor(.secondary) + } + } + .padding(.trailing, 36) + } + .buttonStyle(.plain) + .overlay(alignment: .trailing) { + modelActionButton(for: model, isDownloaded: isDownloaded) + } + .padding(.vertical, 4) + } + + @ViewBuilder + private func modelActionButton(for model: MLXModelOption, isDownloaded: Bool) -> some View { + if isDownloaded { + Menu { + Button(role: .destructive) { + modelToDelete = model + showingDeleteConfirmation = true + } label: { + Label("Delete", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis.circle") + .font(.title2) + .foregroundColor(.blue) + } + } else if downloadManager.isDownloading && downloadManager.modelId == model.id { + Button { + downloadManager.cancelDownload() + } label: { + Image(systemName: "xmark.circle") + .font(.title2) + .foregroundColor(.red) + } + } else { + Button { + modelId = model.id + downloadManager.refreshModelStatus() + downloadManager.startDownload() + } label: { + Image(systemName: "arrow.down.circle") + .font(.title2) + .foregroundColor(.blue) + } + .disabled(downloadManager.isDownloading) + } + } + + /// Check if a specific model is downloaded (independent of the currently selected model). + private func isModelDownloaded(_ model: MLXModelOption) -> Bool { + #if canImport(MLXLLM) && canImport(MLXLMCommon) + // Use the download manager's check but for a specific model ID + if model.id == downloadManager.modelId { + return downloadManager.isModelDownloaded + } + // For non-selected models, check the file system directly + return MLXSwiftDownloadManager.shared.checkModelExists(modelId: model.id) + #else + return false + #endif + } + + // MARK: - Download Progress View + + @ViewBuilder + private var downloadProgressView: some View { + VStack(spacing: 12) { + Text("Downloading \(downloadManager.modelDisplayName)") + .font(.subheadline) + .fontWeight(.medium) + + ProgressView(value: downloadManager.downloadProgress) + .progressViewStyle(.linear) + + Text("\(Int(downloadManager.downloadProgress * 100))%") + .font(.caption) + .foregroundColor(.secondary) + + if let error = downloadManager.downloadError { + Text(error) + .font(.caption) + .foregroundColor(.red) + } + + Button("Cancel Download") { + downloadManager.cancelDownload() + } + .foregroundColor(.red) + } + } + + // MARK: - Model Status View + + @ViewBuilder + private var modelStatusView: some View { + if downloadManager.isModelDownloaded { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + VStack(alignment: .leading) { + Text("Ready") + .font(.subheadline) + .fontWeight(.medium) + Text("Using \(downloadManager.modelDisplayName)") + .font(.caption) + .foregroundColor(.secondary) + } + } + } else { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + VStack(alignment: .leading) { + Text("No Model Downloaded") + .font(.subheadline) + .fontWeight(.medium) + Text("Download a model to use MLX Swift processing") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + + // MARK: - Temperature Slider + + @ViewBuilder + private var temperatureSlider: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Temperature") + Spacer() + Text(String(format: "%.2f", temperature)) + .foregroundColor(.secondary) + } + + Slider(value: $temperature, in: 0.0...1.0, step: 0.05) + + Text("Lower values produce more focused output, higher values more creative") + .font(.caption2) + .foregroundColor(.secondary) + } + } + + // MARK: - Advanced Settings + + @ViewBuilder + private var advancedSettingsView: some View { + // Context Size (automatically determined by device RAM) + HStack { + Text("Context Size") + Spacer() + Text("\(DeviceCapabilities.onDeviceLLMContextSize) tokens") + .foregroundColor(.secondary) + } + Text("Automatically set based on device RAM: \(DeviceCapabilities.onDeviceLLMContextSize == 8192 ? "8k" : "16k") for devices with \(DeviceCapabilities.onDeviceLLMContextSize == 8192 ? "<8GB" : "\u{2265}8GB") RAM") + .font(.caption2) + .foregroundColor(.secondary) + + // Max Output Tokens + Stepper(value: $maxTokens, in: 512...4096, step: 128) { + HStack { + Text("Max Output") + Spacer() + Text("\(maxTokens) tokens") + .foregroundColor(.secondary) + } + } + + // Top-K + Stepper(value: $topK, in: 1...100, step: 5) { + HStack { + Text("Top-K") + Spacer() + Text("\(topK)") + .foregroundColor(.secondary) + } + } + + // Top-P + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Top-P") + Spacer() + Text(String(format: "%.2f", topP)) + .foregroundColor(.secondary) + } + Slider(value: $topP, in: 0.1...1.0, step: 0.05) + } + + // Repeat Penalty + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Repeat Penalty") + Spacer() + Text(String(format: "%.2f", repetitionPenalty)) + .foregroundColor(.secondary) + } + Slider(value: $repetitionPenalty, in: 1.0...2.0, step: 0.05) + } + + // Custom model ID + TextField("Custom Hugging Face Model ID", text: $modelId) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.callout.monospaced()) + + Button("Reset to Defaults") { + modelId = MLXSwiftSettingsKeys.defaultModelId + temperature = MLXSwiftSettingsKeys.defaultTemperature + maxTokens = MLXSwiftSettingsKeys.defaultMaxTokens + topK = MLXSwiftSettingsKeys.defaultTopK + topP = MLXSwiftSettingsKeys.defaultTopP + repetitionPenalty = MLXSwiftSettingsKeys.defaultRepetitionPenalty + } + .foregroundColor(.blue) + } +} + +// MARK: - Preview + +#Preview { + NavigationStack { + MLXSwiftSettingsView() + } +} diff --git a/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift b/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift index e91d3e1..5a90a6a 100644 --- a/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift +++ b/BisonNotes AI/BisonNotes AI/Models/CoreDataManager.swift @@ -175,7 +175,7 @@ class CoreDataManager: ObservableObject { } } - AppLog.shared.coreData("File not found anywhere for recording ID: \(recording.id?.uuidString ?? "nil")", level: .error) + AppLog.shared.coreData("File not found anywhere for recording ID: \(recording.id?.uuidString ?? "nil")", level: .debug) return nil } @@ -694,7 +694,7 @@ class CoreDataManager: ObservableObject { // recordings intentionally have no local audio). Fall back to the // stored URL so the transcript stays visible in the Transcripts list. guard let url = getAbsoluteURL(for: recordingEntry) ?? getStoredURL(for: recordingEntry) else { - AppLog.shared.coreData("Could not resolve any URL for recording ID: \(recordingEntry.id?.uuidString ?? "nil")", level: .error) + AppLog.shared.coreData("Could not resolve any URL for recording ID: \(recordingEntry.id?.uuidString ?? "nil")", level: .debug) return nil } diff --git a/BisonNotes AI/BisonNotes AI/Models/SummaryMetadataCodec.swift b/BisonNotes AI/BisonNotes AI/Models/SummaryMetadataCodec.swift index 1ed5ce4..b2929fb 100644 --- a/BisonNotes AI/BisonNotes AI/Models/SummaryMetadataCodec.swift +++ b/BisonNotes AI/BisonNotes AI/Models/SummaryMetadataCodec.swift @@ -16,6 +16,7 @@ struct AIEngineTypeConstants { static let ollama = "Ollama" static let appleIntelligence = "Apple Intelligence" // Kept for legacy metadata parsing static let onDeviceAI = "On-Device AI" + static let mlxSwift = "MLX Swift" static let aiAssistant = "AI Assistant" } @@ -58,6 +59,8 @@ enum SummaryMetadataCodec { return AIEngineTypeConstants.ollama } else if methodLower.contains("apple") || methodLower.contains("intelligence") { return AIEngineTypeConstants.appleIntelligence + } else if methodLower.contains("mlx") || methodLower.contains("bonsai") || methodLower.contains("ternary") { + return AIEngineTypeConstants.mlxSwift } else if methodLower.contains("device") || methodLower.contains("gemma") || methodLower.contains("phi") || methodLower.contains("qwen") || methodLower.contains("llama") || methodLower.contains("mistral") || methodLower.contains("olmo") || methodLower.contains("alpaca") { diff --git a/BisonNotes AI/BisonNotes AI/Views/AITextView.swift b/BisonNotes AI/BisonNotes AI/Views/AITextView.swift index 5873ccd..9a9eaff 100644 --- a/BisonNotes AI/BisonNotes AI/Views/AITextView.swift +++ b/BisonNotes AI/BisonNotes AI/Views/AITextView.swift @@ -41,7 +41,7 @@ enum AIService { return .ollama } else if engineLower.contains("whisper") { return .whisper - } else if engineLower.contains("device") || engineLower.contains("apple") || modelLower.contains("intelligence") || modelLower.contains("gemma") || modelLower.contains("phi") || modelLower.contains("qwen") || modelLower.contains("llama") || modelLower.contains("mistral") || modelLower.contains("olmo") || modelLower.contains("alpaca") { + } else if engineLower.contains("device") || engineLower.contains("apple") || engineLower.contains("mlx") || modelLower.contains("intelligence") || modelLower.contains("gemma") || modelLower.contains("phi") || modelLower.contains("qwen") || modelLower.contains("llama") || modelLower.contains("mistral") || modelLower.contains("olmo") || modelLower.contains("alpaca") || modelLower.contains("bonsai") || modelLower.contains("ternary") { return .onDevice } else { // Default to bedrock for unknown services diff --git a/BisonNotes AI/BisonNotes AI/Views/SettingsView.swift b/BisonNotes AI/BisonNotes AI/Views/SettingsView.swift index f424ed1..c94b6b9 100644 --- a/BisonNotes AI/BisonNotes AI/Views/SettingsView.swift +++ b/BisonNotes AI/BisonNotes AI/Views/SettingsView.swift @@ -90,6 +90,11 @@ struct SettingsView: View { .onChange(of: enableExperimentalModels) { oldValue, newValue in // Refresh model list when experimental models toggle changes OnDeviceLLMDownloadManager.shared.refreshModelStatus() + + if !newValue && selectedAIEngine == AIEngineType.mlxSwift.rawValue { + selectedAIEngine = AIEngineType.onDeviceLLM.rawValue + SummaryManager.shared.setEngine(AIEngineType.onDeviceLLM.rawValue) + } } .sheet(isPresented: $showingAISettings) { AISettingsView() @@ -800,10 +805,10 @@ struct SettingsView: View { VStack(spacing: 8) { HStack { VStack(alignment: .leading, spacing: 4) { - Text("Enable Experimental On-Device AI Models") + Text("Enable experimental summary models and MLX AI engine") .font(.body) .foregroundColor(.primary) - Text("Allow use of experimental models (LFM 2.5 for 4GB+, Qwen3.5 2B for 6GB+, Qwen3.5 4B for 8GB+). These models are unreliable and may produce empty summaries. For devices with <6GB RAM, this enables on-device AI with only LFM 2.5 available.") + Text("Allow experimental local summary models and show the MLX Swift AI engine in AI settings. These models are unreliable and may produce empty summaries. For devices with <6GB RAM, this enables on-device AI with only LFM 2.5 available.") .font(.caption) .foregroundColor(.secondary) } diff --git a/BisonNotes AI/BisonNotes AI/Views/SimpleSettingsView.swift b/BisonNotes AI/BisonNotes AI/Views/SimpleSettingsView.swift index b72827e..e4dcf4f 100644 --- a/BisonNotes AI/BisonNotes AI/Views/SimpleSettingsView.swift +++ b/BisonNotes AI/BisonNotes AI/Views/SimpleSettingsView.swift @@ -595,12 +595,16 @@ struct SimpleSettingsView: View { if aiEngine == "Mistral AI" { selectedOption = .mistralAI } + // MLX Swift is an on-device summary engine, so show the main on-device setup option. + else if aiEngine == AIEngineType.mlxSwift.rawValue { + selectedOption = .onDeviceLLM + } // Check if On-Device AI is selected for AI and on-device transcription (FluidAudio/Parakeet) - else if transcriptionEngine == TranscriptionEngine.fluidAudio.rawValue && aiEngine == "On-Device AI" { + else if transcriptionEngine == TranscriptionEngine.fluidAudio.rawValue && aiEngine == AIEngineType.onDeviceLLM.rawValue { selectedOption = .onDeviceLLM } // Check if Apple Native (Foundation Models) is selected — also fully on-device - else if transcriptionEngine == TranscriptionEngine.fluidAudio.rawValue && aiEngine == "Apple Native" { + else if transcriptionEngine == TranscriptionEngine.fluidAudio.rawValue && aiEngine == AIEngineType.appleNative.rawValue { selectedOption = .onDeviceLLM } // Any other permutation should show Advanced & Other Options @@ -672,8 +676,14 @@ struct SimpleSettingsView: View { UserDefaults.standard.set(TranscriptionEngine.fluidAudio.rawValue, forKey: "selectedTranscriptionEngine") UserDefaults.standard.set(true, forKey: FluidAudioModelInfo.SettingsKeys.enableFluidAudio) - // Set AI engine to On-Device AI for summaries - UserDefaults.standard.set("On-Device AI", forKey: "SelectedAIEngine") + // Set AI engine to On-Device AI for summaries unless the user already chose + // another on-device summary engine from advanced settings. + let currentAI = UserDefaults.standard.string(forKey: "SelectedAIEngine") + if currentAI == AIEngineType.mlxSwift.rawValue { + UserDefaults.standard.set(true, forKey: MLXSwiftSettingsKeys.enabled) + } else if currentAI != AIEngineType.appleNative.rawValue { + UserDefaults.standard.set(AIEngineType.onDeviceLLM.rawValue, forKey: "SelectedAIEngine") + } // Enable On-Device LLM UserDefaults.standard.set(true, forKey: OnDeviceLLMModelInfo.SettingsKeys.enableOnDeviceLLM) From ecf7e5b882e36b539b89ae23fe833ea7c280b3c0 Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:16:50 -0400 Subject: [PATCH 16/21] Fix experimental mode toggle to fully disable MLX and handle active experimental engines - Set MLXSwiftSettingsKeys.enabled = false when experimental is disabled so MLX no longer appears available anywhere (not just deselected as active engine) - Filter MLX Swift out of the AI engine list in AISettingsView when experimental is off; add @AppStorage observer so the list updates reactively - Guard MLXSwiftSettingsView from opening when experimental is disabled - Migrate selected on-device model to first available non-experimental model when experimental is disabled; preserve stored ID for re-enable - On <6GB devices where all on-device models are experimental, fall back to Apple Native when experimental is disabled (both MLX and On-Device AI become unusable) Co-Authored-By: Claude Sonnet 4.6 --- .../BisonNotes AI/AISettingsView.swift | 3 ++ .../BisonNotes AI/Views/SettingsView.swift | 41 +++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/BisonNotes AI/BisonNotes AI/AISettingsView.swift b/BisonNotes AI/BisonNotes AI/AISettingsView.swift index 1cca962..dbaffdd 100644 --- a/BisonNotes AI/BisonNotes AI/AISettingsView.swift +++ b/BisonNotes AI/BisonNotes AI/AISettingsView.swift @@ -111,6 +111,7 @@ struct AISettingsView: View { @EnvironmentObject var appCoordinator: AppDataCoordinator @StateObject private var errorHandler = ErrorHandler() @AppStorage(SummarizationTimeouts.storageKey) private var summarizationTimeout: Double = SummarizationTimeouts.defaultTimeout + @AppStorage(OnDeviceLLMModelInfo.SettingsKeys.enableExperimentalModels) private var enableExperimentalModels = false @Environment(\.dismiss) private var dismiss @State private var showingOllamaSettings = false @@ -481,6 +482,7 @@ private extension AISettingsView { func engines(in category: EngineCategory) -> [AIEngineType] { AIEngineType.availableCases.filter { engine in + if engine == .mlxSwift && !enableExperimentalModels { return false } switch category { case .onDevice: return [.onDeviceLLM, .mlxSwift, .appleNative].contains(engine) @@ -554,6 +556,7 @@ private extension AISettingsView { guard DeviceCapabilities.supportsOnDeviceLLM else { return } showingOnDeviceLLMSettings = true case .mlxSwift: + guard enableExperimentalModels else { return } showingMLXSwiftSettings = true case .appleNative: break // No separate settings sheet — configured via Apple Intelligence system settings diff --git a/BisonNotes AI/BisonNotes AI/Views/SettingsView.swift b/BisonNotes AI/BisonNotes AI/Views/SettingsView.swift index c94b6b9..23aaba0 100644 --- a/BisonNotes AI/BisonNotes AI/Views/SettingsView.swift +++ b/BisonNotes AI/BisonNotes AI/Views/SettingsView.swift @@ -88,12 +88,45 @@ struct SettingsView: View { AppLog.shared.log("SettingsView: Updated AI engine to '\(newEngine)'", level: .debug, category: .general) } .onChange(of: enableExperimentalModels) { oldValue, newValue in - // Refresh model list when experimental models toggle changes OnDeviceLLMDownloadManager.shared.refreshModelStatus() - if !newValue && selectedAIEngine == AIEngineType.mlxSwift.rawValue { - selectedAIEngine = AIEngineType.onDeviceLLM.rawValue - SummaryManager.shared.setEngine(AIEngineType.onDeviceLLM.rawValue) + if !newValue { + // Disable the MLX engine itself so it no longer appears available + UserDefaults.standard.set(false, forKey: MLXSwiftSettingsKeys.enabled) + + // Migrate away from any experimental on-device model that is no longer available + let currentModelId = UserDefaults.standard.string(forKey: OnDeviceLLMModelInfo.SettingsKeys.selectedModelId) ?? "" + if !OnDeviceLLMModelInfo.availableModels.contains(where: { $0.id == currentModelId }) { + if let firstAvailable = OnDeviceLLMModelInfo.availableModels.first { + UserDefaults.standard.set(firstAvailable.id, forKey: OnDeviceLLMModelInfo.SettingsKeys.selectedModelId) + } + // If availableModels is empty (e.g. <6GB device), leave the stored ID as-is; + // it will be re-used if experimental is re-enabled later. + } + + // Determine the best fallback engine when on-device AI has no usable models. + // On <6GB devices, all on-device models are experimental, so both MLX and + // On-Device AI become unusable when experimental mode is disabled. + let onDeviceHasModels = !OnDeviceLLMModelInfo.availableModels.isEmpty + let fallbackEngine: String + if onDeviceHasModels { + fallbackEngine = AIEngineType.onDeviceLLM.rawValue + } else { + // <6GB device: no non-experimental on-device models remain; prefer Apple Native + fallbackEngine = AIEngineType.appleNative.rawValue + } + + // Switch away from MLX Swift + if selectedAIEngine == AIEngineType.mlxSwift.rawValue { + selectedAIEngine = fallbackEngine + SummaryManager.shared.setEngine(fallbackEngine) + } + + // Switch away from On-Device AI if it no longer has any usable models + if selectedAIEngine == AIEngineType.onDeviceLLM.rawValue && !onDeviceHasModels { + selectedAIEngine = fallbackEngine + SummaryManager.shared.setEngine(fallbackEngine) + } } } .sheet(isPresented: $showingAISettings) { From da4ad78b3e5817cdf4398cb4d8fa912f74297207 Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:14:22 -0400 Subject: [PATCH 17/21] Swap Transcripts and Summaries order in iPad sidebar Co-Authored-By: Claude Sonnet 4.6 --- BisonNotes AI/BisonNotes AI/Views/AdaptiveNavigationView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BisonNotes AI/BisonNotes AI/Views/AdaptiveNavigationView.swift b/BisonNotes AI/BisonNotes AI/Views/AdaptiveNavigationView.swift index 4ab3aef..d0a5e53 100644 --- a/BisonNotes AI/BisonNotes AI/Views/AdaptiveNavigationView.swift +++ b/BisonNotes AI/BisonNotes AI/Views/AdaptiveNavigationView.swift @@ -46,8 +46,8 @@ struct AdaptiveNavigationWrapper: View { enum SidebarItem: String, CaseIterable, Identifiable { case record = "Record" - case summaries = "Summaries" case transcripts = "Transcripts" + case summaries = "Summaries" case settings = "Setup" var id: String { rawValue } From fbe81aae8227572f06affedc5bb4dea3861b71fd Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Sat, 9 May 2026 19:13:09 -0400 Subject: [PATCH 18/21] Grant write permissions to @claude mention workflow Same fix as claude-code-review: pull-requests and issues need to be write so Claude can actually reply when @-mentioned in a PR or issue. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/claude.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 79fe056..2ba07af 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -20,8 +20,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: read - issues: read + pull-requests: write + issues: write id-token: write actions: read # Required for Claude to read CI results on PRs steps: From 86123c82bb19dee9c9284753c4f3a46d89ca24be Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Sat, 9 May 2026 19:14:22 -0400 Subject: [PATCH 19/21] Restore write permissions for Claude Code Review workflow The install-github-app PR (#89) reverted permissions back to read. Re-apply write so the action can post review output. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/claude-code-review.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 4f6145b..0064826 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -21,8 +21,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: read - issues: read + pull-requests: write + issues: write id-token: write steps: From 1b6944260583a63a2c5509a54a55505386e39b5b Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Sat, 9 May 2026 19:18:05 -0400 Subject: [PATCH 20/21] Revert workflow permissions and enable display_report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the earlier write-permission change — the Claude Code Action authenticates as the GitHub App, so the workflow's GITHUB_TOKEN perms weren't the cause of silent reviews. Restore the original read perms. Add display_report: true so the review summary always appears on the PR, even when there are no inline comments to post. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/claude-code-review.yml | 6 ++++-- .github/workflows/claude.yml | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 0064826..092fcdb 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -21,8 +21,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: write - issues: write + pull-requests: read + issues: read id-token: write steps: @@ -39,6 +39,8 @@ jobs: plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + # Always post the review summary to the PR, even when no inline comments are needed. + display_report: true # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 2ba07af..79fe056 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -20,8 +20,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: write - issues: write + pull-requests: read + issues: read id-token: write actions: read # Required for Claude to read CI results on PRs steps: From 7b99e0915c09e09e1116023382a6b9067018875b Mon Sep 17 00:00:00 2001 From: Tim Champ <95320419+bisonbet@users.noreply.github.com> Date: Sat, 9 May 2026 19:56:36 -0400 Subject: [PATCH 21/21] Post Claude code review as a sticky PR comment Add use_sticky_comment: true so the review summary is posted directly on the PR (and updated on subsequent runs), not just hidden in the Actions step summary. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/claude-code-review.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 092fcdb..a003077 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -39,7 +39,9 @@ jobs: plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' - # Always post the review summary to the PR, even when no inline comments are needed. + # Post (and update) a single sticky comment on the PR with the review summary. + use_sticky_comment: true + # Also write the report to the GitHub Actions step summary as a backup. display_report: true # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options