// // MastodonCachePersistentStore.swift // Tusker // // Created by Shadowfacts on 4/11/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import Foundation import CoreData import Pachyderm import Combine class MastodonCachePersistentStore: NSPersistentContainer { private static let managedObjectModel: NSManagedObjectModel = { let url = Bundle.main.url(forResource: "Tusker", withExtension: "momd")! return NSManagedObjectModel(contentsOf: url)! }() private(set) lazy var backgroundContext: NSManagedObjectContext = { let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) context.parent = self.viewContext return context }() private(set) lazy var prefetchBackgroundContext: NSManagedObjectContext = { let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType) context.parent = self.viewContext return context }() let statusSubject = PassthroughSubject() let accountSubject = PassthroughSubject() let relationshipSubject = PassthroughSubject() init(for accountInfo: LocalData.UserAccountInfo?, transient: Bool = false) { if transient { super.init(name: "transient_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel) let storeDescription = NSPersistentStoreDescription() storeDescription.type = NSInMemoryStoreType persistentStoreDescriptions = [storeDescription] } else { super.init(name: "\(accountInfo!.id)_cache", managedObjectModel: MastodonCachePersistentStore.managedObjectModel) } loadPersistentStores { (description, error) in if let error = error { fatalError("Unable to load persistent store: \(error)") } } NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext) } func status(for id: String, in context: NSManagedObjectContext? = nil) -> StatusMO? { let context = context ?? viewContext let request: NSFetchRequest = StatusMO.fetchRequest() request.predicate = NSPredicate(format: "id = %@", id) request.fetchLimit = 1 if let result = try? context.fetch(request), let status = result.first { return status } else { return nil } } @discardableResult private func upsert(status: Status, context: NSManagedObjectContext) -> StatusMO { if let statusMO = self.status(for: status.id, in: context) { statusMO.updateFrom(apiStatus: status, container: self) return statusMO } else { return StatusMO(apiStatus: status, container: self, context: context) } } func addOrUpdate(status: Status, context: NSManagedObjectContext? = nil, completion: ((StatusMO) -> Void)? = nil) { let context = context ?? backgroundContext context.perform { let statusMO = self.upsert(status: status, context: context) if context.hasChanges { try! context.save() } completion?(statusMO) self.statusSubject.send(status.id) } } func addAll(statuses: [Status], completion: (() -> Void)? = nil) { backgroundContext.perform { statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) } if self.backgroundContext.hasChanges { try! self.backgroundContext.save() } statuses.forEach { self.statusSubject.send($0.id) } completion?() } } func account(for id: String, in context: NSManagedObjectContext? = nil) -> AccountMO? { let context = context ?? viewContext let request: NSFetchRequest = AccountMO.fetchRequest() request.predicate = NSPredicate(format: "id = %@", id) request.fetchLimit = 1 if let result = try? context.fetch(request), let account = result.first { return account } else { return nil } } @discardableResult private func upsert(account: Account) -> AccountMO { if let accountMO = self.account(for: account.id, in: self.backgroundContext) { accountMO.updateFrom(apiAccount: account, container: self) return accountMO } else { return AccountMO(apiAccount: account, container: self, context: self.backgroundContext) } } func addOrUpdate(account: Account, completion: ((AccountMO) -> Void)? = nil) { backgroundContext.perform { let accountMO = self.upsert(account: account) if self.backgroundContext.hasChanges { try! self.backgroundContext.save() } completion?(accountMO) self.accountSubject.send(account.id) } } func relationship(forAccount id: String, in context: NSManagedObjectContext? = nil) -> RelationshipMO? { let context = context ?? viewContext let request: NSFetchRequest = RelationshipMO.fetchRequest() request.predicate = NSPredicate(format: "accountID = %@", id) request.fetchLimit = 1 if let result = try? context.fetch(request), let relationship = result.first { return relationship } else { return nil } } @discardableResult private func upsert(relationship: Relationship) -> RelationshipMO { if let relationshipMO = self.relationship(forAccount: relationship.id, in: self.backgroundContext) { relationshipMO.updateFrom(apiRelationship: relationship, container: self) return relationshipMO } else { let relationshipMO = RelationshipMO(apiRelationship: relationship, container: self, context: self.backgroundContext) return relationshipMO } } func addOrUpdate(relationship: Relationship, completion: ((RelationshipMO) -> Void)? = nil) { backgroundContext.perform { let relationshipMO = self.upsert(relationship: relationship) if self.backgroundContext.hasChanges { try! self.backgroundContext.save() } completion?(relationshipMO) self.relationshipSubject.send(relationship.id) } } func addAll(accounts: [Account], completion: (() -> Void)? = nil) { backgroundContext.perform { accounts.forEach { self.upsert(account: $0) } if self.backgroundContext.hasChanges { try! self.backgroundContext.save() } completion?() accounts.forEach { self.accountSubject.send($0.id) } } } func addAll(notifications: [Pachyderm.Notification], completion: (() -> Void)? = nil) { backgroundContext.perform { let statuses = notifications.compactMap { $0.status } // filter out mentions, otherwise we would double increment the reference count of those accounts // since the status has the same account as the notification let accounts = notifications.filter { $0.kind != .mention }.map { $0.account } statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) } accounts.forEach { self.upsert(account: $0) } if self.backgroundContext.hasChanges { try! self.backgroundContext.save() } completion?() statuses.forEach { self.statusSubject.send($0.id) } accounts.forEach { self.accountSubject.send($0.id) } } } func performBatchUpdates(_ block: @escaping (_ context: NSManagedObjectContext, _ addAccounts: ([Account]) -> Void, _ addStatuses: ([Status]) -> Void) -> Void, completion: (() -> Void)? = nil) { backgroundContext.perform { var updatedAccounts = [String]() var updatedStatuses = [String]() block(self.backgroundContext, { (accounts) in accounts.forEach { self.upsert(account: $0) } updatedAccounts.append(contentsOf: accounts.map { $0.id }) }, { (statuses) in statuses.forEach { self.upsert(status: $0, context: self.backgroundContext) } updatedStatuses.append(contentsOf: statuses.map { $0.id }) }) updatedAccounts.forEach(self.accountSubject.send) updatedStatuses.forEach(self.statusSubject.send) if self.backgroundContext.hasChanges { try! self.backgroundContext.save() } completion?() } } @objc private func managedObjectsDidChange(_ notification: Foundation.Notification) { let changes = hasChangedSavedHashtagsOrInstances(notification) if changes.hashtags { NotificationCenter.default.post(name: .savedHashtagsChanged, object: nil) } if changes.instances { NotificationCenter.default.post(name: .savedInstancesChanged, object: nil) } } private func hasChangedSavedHashtagsOrInstances(_ notification: Foundation.Notification) -> (hashtags: Bool, instances: Bool) { var changes: (hashtags: Bool, instances: Bool) = (false, false) if let inserted = notification.userInfo?[NSInsertedObjectsKey] as? Set { for object in inserted { if object is SavedHashtag { changes.hashtags = true } else if object is SavedInstance { changes.instances = true } if changes.hashtags && changes.instances { return changes } } } if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set { for object in deleted { if object is SavedHashtag { changes.hashtags = true } else if object is SavedInstance { changes.instances = true } if changes.hashtags && changes.instances { return changes } } } return changes } }