From 858f934fb0aa05e277364d7e7798d1d1865cc44a Mon Sep 17 00:00:00 2001 From: Owen Gretzinger Date: Fri, 11 Jul 2025 14:44:45 -0400 Subject: [PATCH] feat: meeting data migration and backup --- notetaker/Managers/DataMigrationManager.swift | 54 +++++++++++++++++++ notetaker/Managers/LocalStorageManager.swift | 43 +++++++++++++-- notetaker/Models/Meeting.swift | 11 +++- 3 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 notetaker/Managers/DataMigrationManager.swift diff --git a/notetaker/Managers/DataMigrationManager.swift b/notetaker/Managers/DataMigrationManager.swift new file mode 100644 index 0000000..9597ed9 --- /dev/null +++ b/notetaker/Managers/DataMigrationManager.swift @@ -0,0 +1,54 @@ +// DataMigrationManager.swift +// Handles data migration between different app versions + +import Foundation + +/// Manages data migration between different app versions +class DataMigrationManager { + static let shared = DataMigrationManager() + + private init() {} + + /// Migrates a meeting from an older version to the current version + /// - Parameter meeting: The meeting to migrate + /// - Returns: The migrated meeting, or nil if migration failed + func migrateMeeting(_ meeting: Meeting) -> Meeting? { + // No releases prior to version 1 – any older file is considered unsupported. + guard meeting.dataVersion >= 1 else { + print("🚫 Cannot migrate meeting \(meeting.id) – unsupported data version \(meeting.dataVersion)") + return nil + } + + // Future migrations can be added here as `switch` cases. + if meeting.dataVersion < Meeting.currentDataVersion { + print("⚠️ No migration path for versions \(meeting.dataVersion + 1)...\(Meeting.currentDataVersion)") + return nil + } + + return meeting + } + + // Future migrateXToVersionY helpers will go here as needed + + /// Performs a backup of the meetings directory before migration + /// - Returns: The backup directory URL, or nil if backup failed + func backupMeetingsDirectory() -> URL? { + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let meetingsDirectory = documentsDirectory.appendingPathComponent("Meetings") + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd_HH-mm-ss" + let timestamp = formatter.string(from: Date()) + + let backupDirectory = documentsDirectory.appendingPathComponent("Meetings_Backup_\(timestamp)") + + do { + try FileManager.default.copyItem(at: meetingsDirectory, to: backupDirectory) + print("✅ Created backup at: \(backupDirectory)") + return backupDirectory + } catch { + print("❌ Failed to create backup: \(error)") + return nil + } + } +} \ No newline at end of file diff --git a/notetaker/Managers/LocalStorageManager.swift b/notetaker/Managers/LocalStorageManager.swift index 3e1772a..2df2ee9 100644 --- a/notetaker/Managers/LocalStorageManager.swift +++ b/notetaker/Managers/LocalStorageManager.swift @@ -30,15 +30,19 @@ class LocalStorageManager { /// - 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.outputFormatting = [.prettyPrinted] encoder.dateEncodingStrategy = .iso8601 - + let data = try encoder.encode(meeting) - try data.write(to: fileURL) - + + // Write atomically using a temp file then replace + let tmpURL = fileURL.appendingPathExtension("tmp") + try data.write(to: tmpURL, options: .atomic) + try FileManager.default.replaceItem(at: fileURL, withItemAt: tmpURL, backupItemName: nil, options: [], resultingItemURL: nil) + print("✅ Saved meeting: \(meeting.id)") return true } catch { @@ -58,12 +62,41 @@ class LocalStorageManager { 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 } diff --git a/notetaker/Models/Meeting.swift b/notetaker/Models/Meeting.swift index e5bdf07..20c90a6 100644 --- a/notetaker/Models/Meeting.swift +++ b/notetaker/Models/Meeting.swift @@ -69,21 +69,30 @@ struct Meeting: Codable, Identifiable, Hashable { var transcriptChunks: [TranscriptChunk] var userNotes: String var generatedNotes: String + // MARK: - Data versioning + /// Version of this Meeting record on disk. Useful for migration. + var dataVersion: Int + /// Current app data version. Increment whenever you make a breaking change to `Meeting` that requires migration. + static let currentDataVersion = 1 init(id: UUID = UUID(), date: Date = Date(), title: String = "", transcriptChunks: [TranscriptChunk] = [], userNotes: String = "", - generatedNotes: String = "") { + generatedNotes: String = "", + dataVersion: Int = Meeting.currentDataVersion) { self.id = id self.date = date self.title = title self.transcriptChunks = transcriptChunks self.userNotes = userNotes self.generatedNotes = generatedNotes + self.dataVersion = dataVersion } + // `Codable` conformance now uses the compiler-synthesised implementation. + // Computed property for backward compatibility with existing code var transcript: String { return transcriptChunks