509 lines
20 KiB
Swift
509 lines
20 KiB
Swift
// 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
|
||
}
|
||
}
|
||
|
||
// 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? {
|
||
if let name = meeting.recoveryAudioFolderName,
|
||
let folder = recoveryAudioFolder(named: name) {
|
||
return folder
|
||
}
|
||
|
||
let claimedFolderNames = Set(
|
||
loadMeetings()
|
||
.filter { $0.id != meeting.id }
|
||
.compactMap(\.recoveryAudioFolderName)
|
||
)
|
||
guard let folders = try? FileManager.default.contentsOfDirectory(
|
||
at: recoveryDirectory,
|
||
includingPropertiesForKeys: [.isDirectoryKey, .creationDateKey, .contentModificationDateKey],
|
||
options: [.skipsHiddenFiles]
|
||
) else {
|
||
return nil
|
||
}
|
||
|
||
let candidates = folders.compactMap { folder -> (url: URL, distance: TimeInterval)? in
|
||
let folderValues = try? folder.resourceValues(
|
||
forKeys: [.isDirectoryKey, .creationDateKey, .contentModificationDateKey]
|
||
)
|
||
let files = recoveryAudioFiles(in: folder)
|
||
guard folderValues?.isDirectory == true,
|
||
!claimedFolderNames.contains(folder.lastPathComponent),
|
||
!files.isEmpty else {
|
||
return nil
|
||
}
|
||
let dates = files.compactMap { file -> Date? in
|
||
let values = try? file.url.resourceValues(forKeys: [.creationDateKey, .contentModificationDateKey])
|
||
return values?.creationDate ?? values?.contentModificationDate
|
||
}
|
||
let referenceDate = dates.min()
|
||
?? folderValues?.creationDate
|
||
?? folderValues?.contentModificationDate
|
||
guard let referenceDate else { return nil }
|
||
return (folder, abs(referenceDate.timeIntervalSince(meeting.date)))
|
||
}
|
||
|
||
// This fallback links recovery files created by older app versions.
|
||
return candidates
|
||
.filter { $0.distance <= 12 * 60 * 60 }
|
||
.min(by: { $0.distance < $1.distance })?
|
||
.url
|
||
}
|
||
|
||
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<UUID>()
|
||
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
|
||
}
|
||
}
|