183 lines
7.9 KiB
Swift
183 lines
7.9 KiB
Swift
//
|
|
// PersistentContainer.swift
|
|
// Reader
|
|
//
|
|
// Created by Shadowfacts on 12/24/21.
|
|
//
|
|
|
|
import CoreData
|
|
import Fervor
|
|
import OSLog
|
|
|
|
// todo: is this actually sendable?
|
|
public class PersistentContainer: NSPersistentContainer, @unchecked Sendable {
|
|
|
|
private static let managedObjectModel: NSManagedObjectModel = {
|
|
let url = Bundle.module.url(forResource: "Reader", withExtension: "momd")!
|
|
return NSManagedObjectModel(contentsOf: url)!
|
|
}()
|
|
|
|
public private(set) lazy var backgroundContext: NSManagedObjectContext = {
|
|
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
|
|
// the background context needs to be parented directly to the PSC
|
|
// if it's parented to the viewContext, it blocks the viewContext (and potentially the main thread) when it needs to look things up
|
|
context.persistentStoreCoordinator = self.persistentStoreCoordinator
|
|
context.automaticallyMergesChangesFromParent = true
|
|
context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
|
return context
|
|
}()
|
|
|
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentContainer")
|
|
|
|
public init(account: LocalData.Account) {
|
|
super.init(name: account.persistenceKey, managedObjectModel: PersistentContainer.managedObjectModel)
|
|
|
|
let groupContainerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.net.shadowfacts.Reader")!
|
|
let containerAppSupportURL = groupContainerURL
|
|
.appendingPathComponent("Library", isDirectory: true)
|
|
.appendingPathComponent("Application Support", isDirectory: true)
|
|
try! FileManager.default.createDirectory(at: containerAppSupportURL, withIntermediateDirectories: true)
|
|
|
|
let containerStoreURL = containerAppSupportURL
|
|
.appendingPathComponent(name)
|
|
.appendingPathExtension("sqlite")
|
|
|
|
if let existingAppSupport = try? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false) {
|
|
let existingStore = existingAppSupport
|
|
.appendingPathComponent(name)
|
|
.appendingPathExtension("sqlite")
|
|
.relativePath
|
|
.removingPercentEncoding!
|
|
if FileManager.default.fileExists(atPath: existingStore) {
|
|
for ext in ["", "-shm", "-wal"] {
|
|
try! FileManager.default.moveItem(atPath: existingStore + ext, toPath: containerStoreURL.relativePath + ext)
|
|
}
|
|
}
|
|
}
|
|
|
|
let desc = NSPersistentStoreDescription(url: containerStoreURL)
|
|
desc.type = NSSQLiteStoreType
|
|
|
|
persistentStoreDescriptions = [desc]
|
|
loadPersistentStores { description, error in
|
|
if let error = error {
|
|
fatalError("Unable to load persistent store: \(error)")
|
|
}
|
|
}
|
|
|
|
viewContext.automaticallyMergesChangesFromParent = true
|
|
viewContext.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump
|
|
}
|
|
|
|
@MainActor
|
|
public func saveViewContext() throws {
|
|
if viewContext.hasChanges {
|
|
try viewContext.save()
|
|
}
|
|
}
|
|
|
|
public func lastSyncDate() async throws -> Date? {
|
|
return try await backgroundContext.perform {
|
|
let state = try self.backgroundContext.fetch(SyncState.fetchRequest()).first
|
|
return state?.lastSync
|
|
}
|
|
}
|
|
|
|
public func updateLastSyncDate(_ date: Date) async throws {
|
|
try await backgroundContext.perform {
|
|
if let state = try self.backgroundContext.fetch(SyncState.fetchRequest()).first {
|
|
state.lastSync = date
|
|
} else {
|
|
let state = SyncState(context: self.backgroundContext)
|
|
state.lastSync = date
|
|
}
|
|
|
|
try self.backgroundContext.save()
|
|
}
|
|
try await self.saveViewContext()
|
|
}
|
|
|
|
public func sync(serverGroups: [Fervor.Group], serverFeeds: [Fervor.Feed]) async throws {
|
|
try await backgroundContext.perform {
|
|
let existingGroups = try self.backgroundContext.fetch(Group.fetchRequest())
|
|
for group in serverGroups {
|
|
if let existing = existingGroups.first(where: { $0.id == group.id }) {
|
|
existing.updateFromServer(group)
|
|
} else {
|
|
let mo = Group(context: self.backgroundContext)
|
|
mo.updateFromServer(group)
|
|
}
|
|
}
|
|
for removed in existingGroups where !serverGroups.contains(where: { $0.id == removed.id }) {
|
|
self.backgroundContext.delete(removed)
|
|
}
|
|
|
|
let existingFeeds = try self.backgroundContext.fetch(Feed.fetchRequest())
|
|
for feed in serverFeeds {
|
|
if let existing = existingFeeds.first(where: { $0.id == feed.id }) {
|
|
existing.updateFromServer(feed)
|
|
} else {
|
|
let mo = Feed(context: self.backgroundContext)
|
|
mo.updateFromServer(feed)
|
|
}
|
|
}
|
|
for removed in existingFeeds where !serverFeeds.contains(where: { $0.id == removed.id }) {
|
|
self.backgroundContext.delete(removed)
|
|
}
|
|
|
|
if self.backgroundContext.hasChanges {
|
|
try self.backgroundContext.save()
|
|
}
|
|
}
|
|
try await self.saveViewContext()
|
|
}
|
|
|
|
public func upsertItems(_ items: [Fervor.Item]) async throws {
|
|
try await backgroundContext.perform {
|
|
try self.doUpsertItems(items, setProgress: nil)
|
|
}
|
|
}
|
|
|
|
private func doUpsertItems(_ items: [Fervor.Item], setProgress: ((_ current: Int, _ total: Int) -> Void)?) throws {
|
|
let req = Item.fetchRequest()
|
|
req.predicate = NSPredicate(format: "id in %@", items.map(\.id))
|
|
let existing = try self.backgroundContext.fetch(req)
|
|
self.logger.debug("doUpsertItems: updating \(existing.count, privacy: .public) items, inserting \(items.count - existing.count, privacy: .public)")
|
|
// todo: this feels like it'll be slow when there are many items
|
|
for (index, item) in items.enumerated() {
|
|
setProgress?(index, items.count)
|
|
if let existing = existing.first(where: { $0.id == item.id }) {
|
|
existing.updateFromServer(item)
|
|
} else {
|
|
let mo = Item(context: self.backgroundContext)
|
|
mo.updateFromServer(item)
|
|
}
|
|
}
|
|
}
|
|
|
|
public func syncItems(_ syncUpdate: ItemsSyncUpdate, setProgress: @escaping (_ current: Int, _ total: Int) -> Void) async throws {
|
|
try await backgroundContext.perform {
|
|
self.logger.debug("syncItems: deleting \(syncUpdate.delete.count, privacy: .public) items")
|
|
let deleteReq = Item.fetchRequest()
|
|
deleteReq.predicate = NSPredicate(format: "id in %@", syncUpdate.delete)
|
|
let delete = NSBatchDeleteRequest(fetchRequest: deleteReq as! NSFetchRequest<NSFetchRequestResult>)
|
|
delete.resultType = .resultTypeObjectIDs
|
|
let result = try self.backgroundContext.execute(delete)
|
|
|
|
if let deleteResult = result as? NSBatchDeleteResult,
|
|
let objectIDs = deleteResult.result as? [NSManagedObjectID] {
|
|
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: [NSDeletedObjectsKey: objectIDs], into: [self.viewContext])
|
|
// todo: does the background/view contexts need to get saved then?
|
|
}
|
|
|
|
try self.doUpsertItems(syncUpdate.upsert, setProgress: setProgress)
|
|
|
|
if self.backgroundContext.hasChanges {
|
|
try self.backgroundContext.save()
|
|
}
|
|
}
|
|
try await self.saveViewContext()
|
|
}
|
|
|
|
}
|