Files
meetingnotes/meetingnotes/Managers/LocalStorageManager.swift
T

509 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 let audioRetentionInterval: TimeInterval = 3 * 24 * 60 * 60
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 expirationDate = now.addingTimeInterval(-audioRetentionInterval)
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
}
}