// LocalStorageManager.swift // Handles local storage of meetings and app data import Foundation import AppKit /// Manages local file storage for meetings and app data class LocalStorageManager { static let shared = LocalStorageManager() struct MeetingImportResult { let importedCount: Int let skippedCount: Int } private let documentsDirectory: URL private let meetingsDirectory: URL private let templatesDirectory: URL private let recoveryDirectory: URL private init() { // Get the app's documents directory documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! // Create meetings subdirectory meetingsDirectory = documentsDirectory.appendingPathComponent("Meetings") // Create templates subdirectory templatesDirectory = documentsDirectory.appendingPathComponent("Templates") recoveryDirectory = documentsDirectory.appendingPathComponent("Meetingnotes Audio") // Ensure directories exist try? FileManager.default.createDirectory(at: meetingsDirectory, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: templatesDirectory, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: recoveryDirectory, withIntermediateDirectories: true) migrateLegacyRecoveryAudio() purgeExpiredAudioFolders() } // MARK: - Meeting Management /// Saves a meeting to local storage /// - Parameter meeting: The meeting to save /// - Returns: True if successful, false otherwise func saveMeeting(_ meeting: Meeting) -> Bool { let fileURL = meetingsDirectory.appendingPathComponent("\(meeting.id.uuidString).json") do { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted] encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(meeting) // Write atomically using a temp file then replace let tmpURL = fileURL.appendingPathExtension("tmp") try data.write(to: tmpURL, options: .atomic) if FileManager.default.fileExists(atPath: fileURL.path) { try FileManager.default.replaceItem( at: fileURL, withItemAt: tmpURL, backupItemName: nil, options: [], resultingItemURL: nil ) } else { try FileManager.default.moveItem(at: tmpURL, to: fileURL) } print("✅ Saved meeting: \(meeting.id)") return true } catch { print("❌ Failed to save meeting: \(error)") return false } } /// Loads all meetings from local storage /// - Returns: Array of meetings, sorted by date (newest first) func loadMeetings() -> [Meeting] { do { let fileURLs = try FileManager.default.contentsOfDirectory(at: meetingsDirectory, includingPropertiesForKeys: nil) .filter { $0.pathExtension == "json" } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 var didCreateBackup = false let meetings = fileURLs.compactMap { url -> Meeting? in guard let data = try? Data(contentsOf: url), let meeting = try? decoder.decode(Meeting.self, from: data) else { print("⚠️ Failed to decode meeting at: \(url)") return nil } // Forward-compatibility guard – skip if file was written by a newer build if meeting.dataVersion > Meeting.currentDataVersion { print("🚫 Meeting \(meeting.id) written by newer app version (\(meeting.dataVersion)). Skipping load.") return nil } // Check if migration is needed if meeting.dataVersion < Meeting.currentDataVersion { // Create backup **once** before we start mutating anything if !didCreateBackup { _ = DataMigrationManager.shared.backupMeetingsDirectory() didCreateBackup = true } if let migratedMeeting = DataMigrationManager.shared.migrateMeeting(meeting) { if saveMeeting(migratedMeeting) { print("✅ Migrated and saved meeting: \(migratedMeeting.id)") return migratedMeeting } print("❌ Failed to save migrated meeting: \(migratedMeeting.id)") } else { print("❌ Failed to migrate meeting: \(meeting.id)") } // Return original if anything failed return meeting } return meeting } return meetings.sorted { $0.date > $1.date } } catch { print("❌ Failed to load meetings: \(error)") return [] } } /// Deletes a meeting from local storage /// - Parameter meeting: The meeting to delete /// - Returns: True if successful, false otherwise func deleteMeeting(_ meeting: Meeting) -> Bool { let fileURL = meetingsDirectory.appendingPathComponent("\(meeting.id.uuidString).json") do { try FileManager.default.removeItem(at: fileURL) print("✅ Deleted meeting: \(meeting.id)") return true } catch { print("❌ Failed to delete meeting: \(error)") return false } } func mergeMeeting(_ continuation: Meeting, into previous: Meeting) -> Meeting? { guard continuation.id != previous.id, continuation.date >= previous.date else { return nil } let previousFolder = recoveryDirectory.appendingPathComponent(previous.id.uuidString, isDirectory: true) let continuationFolder = recoveryAudioFolder(named: continuation.id.uuidString) var copiedAudioURLs: [URL] = [] var createdPreviousFolder = false if let continuationFolder { if !FileManager.default.fileExists(atPath: previousFolder.path) { do { try FileManager.default.createDirectory(at: previousFolder, withIntermediateDirectories: true) createdPreviousFolder = true } catch { return nil } } for audioFile in recoveryAudioFiles(in: continuationFolder) { let destination = previousFolder.appendingPathComponent(audioFile.url.lastPathComponent) if FileManager.default.fileExists(atPath: destination.path) { guard FileManager.default.contentsEqual( atPath: audioFile.url.path, andPath: destination.path ) else { rollbackMergedAudio(copiedAudioURLs, removeFolder: createdPreviousFolder ? previousFolder : nil) return nil } continue } do { try FileManager.default.copyItem(at: audioFile.url, to: destination) copiedAudioURLs.append(destination) } catch { rollbackMergedAudio(copiedAudioURLs, removeFolder: createdPreviousFolder ? previousFolder : nil) return nil } } } var merged = previous var seenChunkIDs = Set() merged.transcriptChunks = (previous.transcriptChunks + continuation.transcriptChunks) .filter { seenChunkIDs.insert($0.id).inserted } .sorted { if $0.timestamp == $1.timestamp { return $0.id.uuidString < $1.id.uuidString } return $0.timestamp < $1.timestamp } merged.userNotes = mergedText(previous.userNotes, continuation.userNotes) merged.generatedNotes = mergedText(previous.generatedNotes, continuation.generatedNotes) merged.templateId = previous.templateId ?? continuation.templateId if !recoveryAudioFiles(in: previousFolder).isEmpty { merged.recoveryAudioFolderName = previous.id.uuidString } guard saveMeeting(merged) else { rollbackMergedAudio(copiedAudioURLs, removeFolder: createdPreviousFolder ? previousFolder : nil) return nil } guard deleteMeeting(continuation) else { _ = saveMeeting(previous) rollbackMergedAudio(copiedAudioURLs, removeFolder: createdPreviousFolder ? previousFolder : nil) return nil } if let continuationFolder { try? FileManager.default.removeItem(at: continuationFolder) } return merged } private func mergedText(_ first: String, _ second: String) -> String { let first = first.trimmingCharacters(in: .whitespacesAndNewlines) let second = second.trimmingCharacters(in: .whitespacesAndNewlines) guard !first.isEmpty else { return second } guard !second.isEmpty, second != first else { return first } return first + "\n\n---\n\n" + second } private func rollbackMergedAudio(_ copiedURLs: [URL], removeFolder folder: URL?) { for url in copiedURLs { try? FileManager.default.removeItem(at: url) } if let folder, recoveryAudioFiles(in: folder).isEmpty { try? FileManager.default.removeItem(at: folder) } } // MARK: - Recovery Audio func preserveAudioFiles(_ urls: [URL], for meetingID: UUID) -> URL? { guard !urls.isEmpty else { return nil } purgeExpiredAudioFolders() let folder = recoveryDirectory.appendingPathComponent(meetingID.uuidString, isDirectory: true) do { try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) } catch { return nil } var preservedCount = 0 for url in urls { let destination = folder.appendingPathComponent(url.lastPathComponent) do { if FileManager.default.fileExists(atPath: destination.path) { try FileManager.default.removeItem(at: destination) } try FileManager.default.moveItem(at: url, to: destination) preservedCount += 1 } catch { continue } } return preservedCount > 0 ? folder : nil } func purgeExpiredAudioFolders(now: Date = Date()) { guard let folders = try? FileManager.default.contentsOfDirectory( at: recoveryDirectory, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles] ) else { return } let retentionInterval = TimeInterval(UserDefaultsManager.shared.audioRetentionDays) * 24 * 60 * 60 let expirationDate = now.addingTimeInterval(-retentionInterval) for folder in folders { guard (try? folder.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } let audioFiles = recoveryAudioFiles(in: folder) let newestDate = audioFiles.compactMap { file -> Date? in let values = try? file.url.resourceValues(forKeys: [.contentModificationDateKey, .creationDateKey]) return values?.contentModificationDate ?? values?.creationDate }.max() guard let newestDate else { try? FileManager.default.removeItem(at: folder) continue } if newestDate < expirationDate { try? FileManager.default.removeItem(at: folder) } } } @MainActor func showAudioFolderInFinder(_ folder: URL? = nil) { let target = folder ?? recoveryDirectory try? FileManager.default.createDirectory(at: target, withIntermediateDirectories: true) NSWorkspace.shared.open(target) } func recoveryAudioFolder(named name: String) -> URL? { let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedName.isEmpty, URL(fileURLWithPath: trimmedName).lastPathComponent == trimmedName else { return nil } let folder = recoveryDirectory.appendingPathComponent(trimmedName, isDirectory: true) var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: folder.path, isDirectory: &isDirectory), isDirectory.boolValue, !recoveryAudioFiles(in: folder).isEmpty else { return nil } return folder } func recoveryAudioFiles(in folder: URL) -> [(url: URL, source: AudioSource)] { guard let files = try? FileManager.default.contentsOfDirectory( at: folder, includingPropertiesForKeys: [.creationDateKey, .contentModificationDateKey, .isRegularFileKey], options: [.skipsHiddenFiles] ) else { return [] } return files.compactMap { url in let name = url.lastPathComponent.lowercased() guard ["m4a", "mp3", "wav", "flac", "webm", "mp4"].contains(url.pathExtension.lowercased()) else { return nil } if name.contains("-mic.") { return (url, .mic) } if name.contains("-system.") { return (url, .system) } return nil } } func findRecoveryAudioFolder(for meeting: Meeting) -> URL? { let canonicalName = meeting.id.uuidString guard meeting.recoveryAudioFolderName == nil || meeting.recoveryAudioFolderName?.caseInsensitiveCompare(canonicalName) == .orderedSame else { return nil } return recoveryAudioFolder(named: canonicalName) } func deleteRecoveryAudioFolder(_ folder: URL) { guard folder.deletingLastPathComponent().standardizedFileURL == recoveryDirectory.standardizedFileURL else { return } try? FileManager.default.removeItem(at: folder) } private func migrateLegacyRecoveryAudio() { let legacyDirectory = documentsDirectory.appendingPathComponent("Meetingnotes-Recovery", isDirectory: true) guard let folders = try? FileManager.default.contentsOfDirectory( at: legacyDirectory, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles] ) else { return } for folder in folders { let destination = recoveryDirectory.appendingPathComponent(folder.lastPathComponent, isDirectory: true) guard !FileManager.default.fileExists(atPath: destination.path) else { continue } try? FileManager.default.moveItem(at: folder, to: destination) } try? FileManager.default.removeItem(at: legacyDirectory) } /// Imports meeting JSON files from a folder selected by the user. func importMeetings(from directory: URL) throws -> MeetingImportResult { let didStartAccess = directory.startAccessingSecurityScopedResource() defer { if didStartAccess { directory.stopAccessingSecurityScopedResource() } } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let existingIDs = Set(loadMeetings().map(\.id)) var importedIDs = Set() var skippedCount = 0 guard let enumerator = FileManager.default.enumerator( at: directory, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles, .skipsPackageDescendants] ) else { throw CocoaError(.fileReadUnknown) } for case let fileURL as URL in enumerator where fileURL.pathExtension.lowercased() == "json" { do { let values = try fileURL.resourceValues(forKeys: [.isRegularFileKey]) guard values.isRegularFile == true else { continue } let data = try Data(contentsOf: fileURL) let meeting = try decoder.decode(Meeting.self, from: data) guard meeting.dataVersion <= Meeting.currentDataVersion, !existingIDs.contains(meeting.id), !importedIDs.contains(meeting.id), saveMeeting(meeting) else { skippedCount += 1 continue } importedIDs.insert(meeting.id) } catch { skippedCount += 1 } } return MeetingImportResult(importedCount: importedIDs.count, skippedCount: skippedCount) } // MARK: - Template Management /// Saves a note template to local storage /// - Parameter template: The template to save /// - Returns: True if successful, false otherwise func saveTemplate(_ template: NoteTemplate) -> Bool { let fileURL = templatesDirectory.appendingPathComponent("\(template.id.uuidString).json") do { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted] let data = try encoder.encode(template) // Write atomically using a temp file then replace let tmpURL = fileURL.appendingPathExtension("tmp") try data.write(to: tmpURL, options: .atomic) if FileManager.default.fileExists(atPath: fileURL.path) { try FileManager.default.replaceItem( at: fileURL, withItemAt: tmpURL, backupItemName: nil, options: [], resultingItemURL: nil ) } else { try FileManager.default.moveItem(at: tmpURL, to: fileURL) } print("✅ Saved template: \(template.id)") return true } catch { print("❌ Failed to save template: \(error)") return false } } /// Loads all templates from local storage /// - Returns: Array of templates, empty if none found func loadTemplates() -> [NoteTemplate] { var templates: [NoteTemplate] = [] do { let fileURLs = try FileManager.default.contentsOfDirectory(at: templatesDirectory, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) let decoder = JSONDecoder() for fileURL in fileURLs { guard fileURL.pathExtension == "json" else { continue } do { let data = try Data(contentsOf: fileURL) let template = try decoder.decode(NoteTemplate.self, from: data) templates.append(template) print("✅ Loaded template: \(template.id)") } catch { print("❌ Failed to load template from \(fileURL): \(error)") } } } catch { print("❌ Failed to read templates directory: \(error)") } // Always ensure all default templates are available let defaultTemplates = NoteTemplate.defaultTemplates() let existingTitles = Set(templates.map { $0.title }) // Add any missing default templates for defaultTemplate in defaultTemplates { if !existingTitles.contains(defaultTemplate.title) { _ = saveTemplate(defaultTemplate) templates.append(defaultTemplate) print("✅ Added missing default template: \(defaultTemplate.title)") } } return templates.sorted { $0.title < $1.title } } func preferredTemplateID(in templates: [NoteTemplate]? = nil) -> UUID? { let availableTemplates = templates ?? loadTemplates() if let selectedTemplateID = UserDefaultsManager.shared.selectedTemplateId, availableTemplates.contains(where: { $0.id == selectedTemplateID }) { return selectedTemplateID } let fallbackTemplateID = availableTemplates.first(where: { $0.title == "Standard Meeting" })?.id ?? availableTemplates.first?.id UserDefaultsManager.shared.selectedTemplateId = fallbackTemplateID return fallbackTemplateID } /// Deletes a template from local storage /// - Parameter template: The template to delete /// - Returns: True if successful, false otherwise func deleteTemplate(_ template: NoteTemplate) -> Bool { // Don't allow deletion of default templates if template.isDefault { print("⚠️ Cannot delete default template") return false } let fileURL = templatesDirectory.appendingPathComponent("\(template.id.uuidString).json") do { try FileManager.default.removeItem(at: fileURL) print("✅ Deleted template: \(template.id)") return true } catch { print("❌ Failed to delete template: \(error)") return false } } // MARK: - Settings Management /// Saves non-sensitive settings to local storage /// - Parameter settings: The settings to save (sensitive data should use Keychain) func saveSettings(_ settings: Settings) -> Bool { // For now, all settings are stored in Keychain // This method is here for future non-sensitive settings return true } /// Gets the app's documents directory URL var documentsDirectoryURL: URL { documentsDirectory } /// Gets the meetings directory URL var meetingsDirectoryURL: URL { meetingsDirectory } }