Add premium backups with retention and iCloud support

This commit is contained in:
alexandrev-tibco
2026-02-01 11:23:41 +01:00
parent f97f8026bc
commit b5ba6c47a8
4 changed files with 418 additions and 1 deletions
@@ -0,0 +1,151 @@
import Foundation
import CoreData
enum BackupLocation: String {
case local = "On device"
case iCloud = "iCloud"
}
struct BackupRecord: Identifiable {
let id: String
let url: URL
let date: Date
let size: Int64
let location: BackupLocation
}
class BackupService {
static let shared = BackupService()
private let fileManager = FileManager.default
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmmss"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter
}()
private init() {}
func createBackup(retentionCount: Int, includeICloud: Bool) -> [BackupRecord] {
let timestamp = dateFormatter.string(from: Date())
let fileName = "backup-\(timestamp).json"
let context = CoreDataStack.shared.viewContext
let sources = fetchAllSources(in: context)
let categories = fetchAllCategories(in: context)
let content = ExportService.shared.exportToJSON(sources: sources, categories: categories)
var records: [BackupRecord] = []
if let localDir = backupDirectory() {
let localURL = localDir.appendingPathComponent(fileName)
write(content: content, to: localURL)
pruneBackups(in: localDir, keep: retentionCount, location: .local)
records.append(contentsOf: listBackups(in: localDir, location: .local))
}
if includeICloud, let iCloudDir = iCloudBackupDirectory() {
let iCloudURL = iCloudDir.appendingPathComponent(fileName)
write(content: content, to: iCloudURL)
pruneBackups(in: iCloudDir, keep: retentionCount, location: .iCloud)
records.append(contentsOf: listBackups(in: iCloudDir, location: .iCloud))
}
return records.sorted { $0.date > $1.date }
}
func listAllBackups(includeICloud: Bool) -> [BackupRecord] {
var records: [BackupRecord] = []
if let localDir = backupDirectory() {
records.append(contentsOf: listBackups(in: localDir, location: .local))
}
if includeICloud, let iCloudDir = iCloudBackupDirectory() {
records.append(contentsOf: listBackups(in: iCloudDir, location: .iCloud))
}
return records.sorted { $0.date > $1.date }
}
private func backupDirectory() -> URL? {
guard let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
return nil
}
let dir = base.appendingPathComponent("Backups", isDirectory: true)
ensureDirectoryExists(dir)
return dir
}
private func iCloudBackupDirectory() -> URL? {
guard let base = fileManager.url(forUbiquityContainerIdentifier: nil) else { return nil }
let dir = base.appendingPathComponent("Documents/Backups", isDirectory: true)
ensureDirectoryExists(dir)
return dir
}
private func ensureDirectoryExists(_ url: URL) {
if !fileManager.fileExists(atPath: url.path) {
try? fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil)
}
}
private func write(content: String, to url: URL) {
do {
try content.write(to: url, atomically: true, encoding: .utf8)
} catch {
print("Backup write failed: \(error)")
}
}
private func listBackups(in directory: URL, location: BackupLocation) -> [BackupRecord] {
guard let files = try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey]) else {
return []
}
return files.compactMap { url in
guard url.lastPathComponent.hasPrefix("backup-"),
url.pathExtension.lowercased() == "json" else {
return nil
}
let name = url.deletingPathExtension().lastPathComponent
let date = parseDate(from: name) ?? (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? Date()
let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize).map { Int64($0) } ?? 0
return BackupRecord(
id: "\(location.rawValue)-\(name)",
url: url,
date: date,
size: size,
location: location
)
}
}
private func pruneBackups(in directory: URL, keep: Int, location: BackupLocation) {
guard keep > 0 else { return }
let backups = listBackups(in: directory, location: location).sorted { $0.date > $1.date }
let toDelete = backups.dropFirst(keep)
for backup in toDelete {
try? fileManager.removeItem(at: backup.url)
}
}
private func parseDate(from filename: String) -> Date? {
let parts = filename.split(separator: "-")
guard parts.count >= 3 else { return nil }
let dateString = "\(parts[1])-\(parts[2])"
return dateFormatter.date(from: dateString)
}
private func fetchAllSources(in context: NSManagedObjectContext) -> [InvestmentSource] {
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \InvestmentSource.name, ascending: true)]
return (try? context.fetch(request)) ?? []
}
private func fetchAllCategories(in context: NSManagedObjectContext) -> [Category] {
let request: NSFetchRequest<Category> = Category.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Category.name, ascending: true)]
return (try? context.fetch(request)) ?? []
}
}