182 lines
6.8 KiB
Swift
182 lines
6.8 KiB
Swift
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
|
|
private let dateProvider: () -> Date
|
|
private let localBaseDirectoryProvider: () -> URL?
|
|
private let iCloudBaseDirectoryProvider: () -> URL?
|
|
private let exportProvider: () -> String
|
|
private let dateFormatter: DateFormatter = {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyyMMdd-HHmmss"
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
return formatter
|
|
}()
|
|
|
|
private init() {
|
|
fileManager = .default
|
|
dateProvider = Date.init
|
|
localBaseDirectoryProvider = { [fileManager] in
|
|
fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
|
}
|
|
iCloudBaseDirectoryProvider = { [fileManager] in
|
|
fileManager.url(forUbiquityContainerIdentifier: nil)
|
|
}
|
|
exportProvider = {
|
|
let context = CoreDataStack.shared.viewContext
|
|
let sources = BackupService.fetchAllSources(in: context)
|
|
let categories = BackupService.fetchAllCategories(in: context)
|
|
return ExportService.shared.exportToJSON(sources: sources, categories: categories)
|
|
}
|
|
}
|
|
|
|
init(
|
|
fileManager: FileManager = .default,
|
|
dateProvider: @escaping () -> Date,
|
|
localBaseDirectoryProvider: @escaping () -> URL?,
|
|
iCloudBaseDirectoryProvider: @escaping () -> URL?,
|
|
exportProvider: @escaping () -> String
|
|
) {
|
|
self.fileManager = fileManager
|
|
self.dateProvider = dateProvider
|
|
self.localBaseDirectoryProvider = localBaseDirectoryProvider
|
|
self.iCloudBaseDirectoryProvider = iCloudBaseDirectoryProvider
|
|
self.exportProvider = exportProvider
|
|
}
|
|
|
|
func createBackup(retentionCount: Int, includeICloud: Bool) -> [BackupRecord] {
|
|
let timestamp = dateFormatter.string(from: dateProvider())
|
|
let fileName = "backup-\(timestamp).json"
|
|
|
|
let content = exportProvider()
|
|
|
|
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 = localBaseDirectoryProvider() else {
|
|
return nil
|
|
}
|
|
let dir = base.appendingPathComponent("Backups", isDirectory: true)
|
|
ensureDirectoryExists(dir)
|
|
return dir
|
|
}
|
|
|
|
private func iCloudBackupDirectory() -> URL? {
|
|
guard let base = iCloudBaseDirectoryProvider() 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 static 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 static 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)) ?? []
|
|
}
|
|
}
|