feat: meeting data migration and backup
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,15 +30,19 @@ class LocalStorageManager {
|
|||||||
/// - Returns: True if successful, false otherwise
|
/// - Returns: True if successful, false otherwise
|
||||||
func saveMeeting(_ meeting: Meeting) -> Bool {
|
func saveMeeting(_ meeting: Meeting) -> Bool {
|
||||||
let fileURL = meetingsDirectory.appendingPathComponent("\(meeting.id.uuidString).json")
|
let fileURL = meetingsDirectory.appendingPathComponent("\(meeting.id.uuidString).json")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.outputFormatting = .prettyPrinted
|
encoder.outputFormatting = [.prettyPrinted]
|
||||||
encoder.dateEncodingStrategy = .iso8601
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
|
|
||||||
let data = try encoder.encode(meeting)
|
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)")
|
print("✅ Saved meeting: \(meeting.id)")
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
@@ -58,12 +62,41 @@ class LocalStorageManager {
|
|||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
decoder.dateDecodingStrategy = .iso8601
|
decoder.dateDecodingStrategy = .iso8601
|
||||||
|
|
||||||
|
var didCreateBackup = false
|
||||||
|
|
||||||
let meetings = fileURLs.compactMap { url -> Meeting? in
|
let meetings = fileURLs.compactMap { url -> Meeting? in
|
||||||
guard let data = try? Data(contentsOf: url),
|
guard let data = try? Data(contentsOf: url),
|
||||||
let meeting = try? decoder.decode(Meeting.self, from: data) else {
|
let meeting = try? decoder.decode(Meeting.self, from: data) else {
|
||||||
print("⚠️ Failed to decode meeting at: \(url)")
|
print("⚠️ Failed to decode meeting at: \(url)")
|
||||||
return nil
|
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 meeting
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,21 +69,30 @@ struct Meeting: Codable, Identifiable, Hashable {
|
|||||||
var transcriptChunks: [TranscriptChunk]
|
var transcriptChunks: [TranscriptChunk]
|
||||||
var userNotes: String
|
var userNotes: String
|
||||||
var generatedNotes: 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(),
|
init(id: UUID = UUID(),
|
||||||
date: Date = Date(),
|
date: Date = Date(),
|
||||||
title: String = "",
|
title: String = "",
|
||||||
transcriptChunks: [TranscriptChunk] = [],
|
transcriptChunks: [TranscriptChunk] = [],
|
||||||
userNotes: String = "",
|
userNotes: String = "",
|
||||||
generatedNotes: String = "") {
|
generatedNotes: String = "",
|
||||||
|
dataVersion: Int = Meeting.currentDataVersion) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.date = date
|
self.date = date
|
||||||
self.title = title
|
self.title = title
|
||||||
self.transcriptChunks = transcriptChunks
|
self.transcriptChunks = transcriptChunks
|
||||||
self.userNotes = userNotes
|
self.userNotes = userNotes
|
||||||
self.generatedNotes = generatedNotes
|
self.generatedNotes = generatedNotes
|
||||||
|
self.dataVersion = dataVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `Codable` conformance now uses the compiler-synthesised implementation.
|
||||||
|
|
||||||
// Computed property for backward compatibility with existing code
|
// Computed property for backward compatibility with existing code
|
||||||
var transcript: String {
|
var transcript: String {
|
||||||
return transcriptChunks
|
return transcriptChunks
|
||||||
|
|||||||
Reference in New Issue
Block a user