From 2b38b883feb7ce96509a66a4c706de72809fafa0 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 9 Jan 2022 17:13:30 -0500 Subject: [PATCH] Sync and show items --- Fervor/Feed.swift | 4 +- Fervor/FervorClient.swift | 55 ++++++++++++- Fervor/Item.swift | 22 ++--- Fervor/ItemsSyncUpdate.swift | 30 +++++++ Reader.xcodeproj/project.pbxproj | 20 +++++ Reader/CoreData/ManagedObjectExtensions.swift | 20 +++++ Reader/CoreData/PersistentContainer.swift | 66 ++++++++++++++- Reader/FervorController.swift | 16 +++- Reader/Item.swift | 27 ++++++ .../Reader.xcdatamodel/contents | 14 ++-- Reader/SceneDelegate.swift | 2 +- Reader/Screens/Home/HomeViewController.swift | 32 ++++---- .../Screens/Items/ItemsViewController.swift | 82 +++++++++++++++++++ 13 files changed, 350 insertions(+), 40 deletions(-) create mode 100644 Fervor/ItemsSyncUpdate.swift create mode 100644 Reader/Item.swift create mode 100644 Reader/Screens/Items/ItemsViewController.swift diff --git a/Fervor/Feed.swift b/Fervor/Feed.swift index f10294d..380a706 100644 --- a/Fervor/Feed.swift +++ b/Fervor/Feed.swift @@ -10,7 +10,7 @@ import Foundation public struct Feed: Decodable { public let id: FervorID public let title: String - public let url: URL + public let url: URL? public let serviceURL: URL? public let feedURL: URL public let lastUpdated: Date @@ -21,7 +21,7 @@ public struct Feed: Decodable { self.id = try container.decode(FervorID.self, forKey: .id) self.title = try container.decode(String.self, forKey: .title) - self.url = try container.decode(URL.self, forKey: .url) + self.url = try container.decode(URL?.self, forKey: .url) self.serviceURL = try container.decodeIfPresent(URL.self, forKey: .serviceURL) self.feedURL = try container.decode(URL.self, forKey: .feedURL) self.lastUpdated = try container.decode(Date.self, forKey: .lastUpdated) diff --git a/Fervor/FervorClient.swift b/Fervor/FervorClient.swift index adf9b05..c88b2e0 100644 --- a/Fervor/FervorClient.swift +++ b/Fervor/FervorClient.swift @@ -15,7 +15,22 @@ public class FervorClient { private let decoder: JSONDecoder = { let d = JSONDecoder() - d.dateDecodingStrategy = .iso8601 + let withFractionalSeconds = ISO8601DateFormatter() + withFractionalSeconds.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let without = ISO8601DateFormatter() + without.formatOptions = [.withInternetDateTime] + // because fucking ISO8601DateFormatter isn't a DateFormatter + d.dateDecodingStrategy = .custom({ decoder in + let s = try decoder.singleValueContainer().decode(String.self) + // try both because Elixir's DateTime.to_iso8601 omits the .0 if the date doesn't have fractional seconds + if let d = withFractionalSeconds.date(from: s) { + return d + } else if let d = without.date(from: s) { + return d + } else { + throw DateDecodingError() + } + }) return d }() @@ -25,9 +40,10 @@ public class FervorClient { self.session = session } - private func buildURL(path: String) -> URL { + private func buildURL(path: String, queryItems: [URLQueryItem] = []) -> URL { var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)! components.path = path + components.queryItems = queryItems return components.url! } @@ -36,7 +52,10 @@ public class FervorClient { if let accessToken = accessToken { request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } - let (data, _) = try await session.data(for: request, delegate: nil) + let (data, response) = try await session.data(for: request, delegate: nil) + if (response as! HTTPURLResponse).statusCode == 404 { + throw Error.notFound + } let decoded = try decoder.decode(T.self, from: data) return decoded } @@ -75,6 +94,31 @@ public class FervorClient { return try await performRequest(request) } + public func syncItems(lastSync: Date?) async throws -> ItemsSyncUpdate { + let request = URLRequest(url: buildURL(path: "/api/v1/items/sync", queryItems: [ + URLQueryItem(name: "last_sync", value: lastSync?.formatted(.iso8601)) + ])) + return try await performRequest(request) + } + + public func items(feed id: FervorID) async throws -> [Item] { + let request = URLRequest(url: buildURL(path: "/api/v1/feeds/\(id)/items")) + return try await performRequest(request) + } + + public func item(id: FervorID) async throws -> Item? { + let request = URLRequest(url: buildURL(path: "/api/v1/items/\(id)")) + do { + return try await performRequest(request) + } catch { + if let error = error as? Error, case .notFound = error { + return nil + } else { + throw error + } + } + } + public struct Auth { public let accessToken: String public let refreshToken: String? @@ -83,6 +127,11 @@ public class FervorClient { public enum Error: Swift.Error { case urlSession(Swift.Error) case decode(Swift.Error) + case notFound } } + +private struct DateDecodingError: Error { + +} diff --git a/Fervor/Item.swift b/Fervor/Item.swift index cefbe2c..53cb27d 100644 --- a/Fervor/Item.swift +++ b/Fervor/Item.swift @@ -8,17 +8,17 @@ import Foundation public struct Item: Decodable { - let id: FervorID - let feedID: FervorID - let title: String - let author: String - let published: Date? - let createdAt: Date? - let content: String? - let summary: String? - let url: URL - let serviceURL: URL? - let read: Bool? + public let id: FervorID + public let feedID: FervorID + public let title: String + public let author: String + public let published: Date? + public let createdAt: Date? + public let content: String? + public let summary: String? + public let url: URL + public let serviceURL: URL? + public let read: Bool? public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) diff --git a/Fervor/ItemsSyncUpdate.swift b/Fervor/ItemsSyncUpdate.swift new file mode 100644 index 0000000..b695353 --- /dev/null +++ b/Fervor/ItemsSyncUpdate.swift @@ -0,0 +1,30 @@ +// +// ItemsSyncUpdate.swift +// Fervor +// +// Created by Shadowfacts on 1/9/22. +// + +import Foundation + +public struct ItemsSyncUpdate: Decodable { + + public let syncTimestamp: Date + public let delete: [FervorID] + public let upsert: [Item] + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.syncTimestamp = try container.decode(Date.self, forKey: .syncTimestamp) + self.delete = try container.decode([FervorID].self, forKey: .delete) + self.upsert = try container.decode([Item].self, forKey: .upsert) + } + + private enum CodingKeys: String, CodingKey { + case syncTimestamp = "sync_timestamp" + case delete + case upsert + } + +} diff --git a/Reader.xcodeproj/project.pbxproj b/Reader.xcodeproj/project.pbxproj index b25a28a..33b37f2 100644 --- a/Reader.xcodeproj/project.pbxproj +++ b/Reader.xcodeproj/project.pbxproj @@ -33,6 +33,9 @@ D6C68834272CD44900874C10 /* Fervor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68833272CD44900874C10 /* Fervor.swift */; }; D6C68856272CD7C600874C10 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68855272CD7C600874C10 /* Item.swift */; }; D6C68858272CD8CD00874C10 /* Group.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68857272CD8CD00874C10 /* Group.swift */; }; + D6E2434C278B456A0005E546 /* ItemsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E2434B278B456A0005E546 /* ItemsViewController.swift */; }; + D6E24350278B62F60005E546 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E2434F278B62F60005E546 /* Item.swift */; }; + D6E24352278B6DF90005E546 /* ItemsSyncUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E24351278B6DF90005E546 /* ItemsSyncUpdate.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -103,6 +106,9 @@ D6C68833272CD44900874C10 /* Fervor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fervor.swift; sourceTree = ""; }; D6C68855272CD7C600874C10 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = ""; }; D6C68857272CD8CD00874C10 /* Group.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Group.swift; sourceTree = ""; }; + D6E2434B278B456A0005E546 /* ItemsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsViewController.swift; sourceTree = ""; }; + D6E2434F278B62F60005E546 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = ""; }; + D6E24351278B6DF90005E546 /* ItemsSyncUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsSyncUpdate.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -143,6 +149,7 @@ children = ( D65B18BF2750533E004A9448 /* Home */, D65B18B027504691004A9448 /* Login */, + D6E2434A278B455C0005E546 /* Items */, ); path = Screens; sourceTree = ""; @@ -207,6 +214,7 @@ D6C687F9272CD27700874C10 /* LaunchScreen.storyboard */, D6C687FC272CD27700874C10 /* Info.plist */, D6C687F4272CD27600874C10 /* Reader.xcdatamodeld */, + D6E2434F278B62F60005E546 /* Item.swift */, ); path = Reader; sourceTree = ""; @@ -240,11 +248,20 @@ D6C68857272CD8CD00874C10 /* Group.swift */, D6C6882F272CD2CF00874C10 /* Instance.swift */, D6C68855272CD7C600874C10 /* Item.swift */, + D6E24351278B6DF90005E546 /* ItemsSyncUpdate.swift */, D65B18BB27504FE7004A9448 /* Token.swift */, ); path = Fervor; sourceTree = ""; }; + D6E2434A278B455C0005E546 /* Items */ = { + isa = PBXGroup; + children = ( + D6E2434B278B456A0005E546 /* ItemsViewController.swift */, + ); + path = Items; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -423,6 +440,8 @@ D6C687EC272CD27600874C10 /* AppDelegate.swift in Sources */, D6C687F6272CD27600874C10 /* Reader.xcdatamodeld in Sources */, D6A8A33727766EA100CCEC72 /* ManagedObjectExtensions.swift in Sources */, + D6E2434C278B456A0005E546 /* ItemsViewController.swift in Sources */, + D6E24350278B62F60005E546 /* Item.swift in Sources */, D65B18BE275051A1004A9448 /* LocalData.swift in Sources */, D65B18B22750469D004A9448 /* LoginViewController.swift in Sources */, D65B18C127505348004A9448 /* HomeViewController.swift in Sources */, @@ -454,6 +473,7 @@ D65B18BC27504FE7004A9448 /* Token.swift in Sources */, D6C68856272CD7C600874C10 /* Item.swift in Sources */, D6C68830272CD2CF00874C10 /* Instance.swift in Sources */, + D6E24352278B6DF90005E546 /* ItemsSyncUpdate.swift in Sources */, D6C68832272CD40600874C10 /* Feed.swift in Sources */, D6C68858272CD8CD00874C10 /* Group.swift in Sources */, D6C68834272CD44900874C10 /* Fervor.swift in Sources */, diff --git a/Reader/CoreData/ManagedObjectExtensions.swift b/Reader/CoreData/ManagedObjectExtensions.swift index 970a8ec..23ff369 100644 --- a/Reader/CoreData/ManagedObjectExtensions.swift +++ b/Reader/CoreData/ManagedObjectExtensions.swift @@ -36,3 +36,23 @@ extension Feed { } } + +extension Item { + + func updateFromServer(_ serverItem: Fervor.Item) { + guard self.id == nil || self.id == serverItem.id else { return } + self.id = serverItem.id + self.author = serverItem.author + self.content = serverItem.content + self.title = serverItem.title + self.read = serverItem.read ?? false + self.published = serverItem.published + self.url = serverItem.url + if self.feed?.id != serverItem.feedID { + let feedReq = Feed.fetchRequest() + feedReq.predicate = NSPredicate(format: "id = %@", serverItem.feedID) + self.feed = try! self.managedObjectContext!.fetch(feedReq).first! + } + } + +} diff --git a/Reader/CoreData/PersistentContainer.swift b/Reader/CoreData/PersistentContainer.swift index 2471cde..c5ed416 100644 --- a/Reader/CoreData/PersistentContainer.swift +++ b/Reader/CoreData/PersistentContainer.swift @@ -7,6 +7,7 @@ import CoreData import Fervor +import OSLog class PersistentContainer: NSPersistentContainer { @@ -22,7 +23,13 @@ class PersistentContainer: NSPersistentContainer { return context }() - init(account: LocalData.Account) { + private weak var fervorController: FervorController? + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "PersistentContainer") + + init(account: LocalData.Account, fervorController: FervorController) { + self.fervorController = fervorController + super.init(name: "\(account.id)", managedObjectModel: PersistentContainer.managedObjectModel) loadPersistentStores { description, error in @@ -32,6 +39,27 @@ class PersistentContainer: NSPersistentContainer { } } + func lastSyncDate() async throws -> Date? { + return try await backgroundContext.perform { + let state = try self.backgroundContext.fetch(SyncState.fetchRequest()).first + return state?.lastSync + } + } + + 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 self.viewContext.save() + } + } + func sync(serverGroups: [Fervor.Group], serverFeeds: [Fervor.Feed]) async throws { try await backgroundContext.perform { let existingGroups = try self.backgroundContext.fetch(Group.fetchRequest()) @@ -64,4 +92,40 @@ class PersistentContainer: NSPersistentContainer { } } + func syncItems(_ syncUpdate: ItemsSyncUpdate) 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) + 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.backgroundContext]) + // todo: does the background/view contexts need to get saved then? + } + + let req = Item.fetchRequest() + req.predicate = NSPredicate(format: "id in %@", syncUpdate.upsert.map(\.id)) + let existing = try self.backgroundContext.fetch(req) + self.logger.debug("syncItems: updating \(existing.count, privacy: .public) items, inserting \(syncUpdate.upsert.count - existing.count, privacy: .public)") + // todo: this feels like it'll be slow when there are many items + for item in syncUpdate.upsert { + if let existing = existing.first(where: { $0.id == item.id }) { + existing.updateFromServer(item) + } else { + let mo = Item(context: self.backgroundContext) + mo.updateFromServer(item) + } + } + + if self.backgroundContext.hasChanges { + try self.backgroundContext.save() + try self.viewContext.save() + } + } + } + } diff --git a/Reader/FervorController.swift b/Reader/FervorController.swift index 5f84491..5888363 100644 --- a/Reader/FervorController.swift +++ b/Reader/FervorController.swift @@ -7,6 +7,7 @@ import Foundation import Fervor +import OSLog class FervorController { @@ -14,7 +15,9 @@ class FervorController { let instanceURL: URL - private let client: FervorClient + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "FervorController") + + let client: FervorClient private(set) var clientID: String? private(set) var clientSecret: String? private(set) var accessToken: String? @@ -34,7 +37,7 @@ class FervorController { self.client.accessToken = account.accessToken - self.persistentContainer = PersistentContainer(account: account) + self.persistentContainer = PersistentContainer(account: account, fervorController: self) } func register() async throws -> ClientRegistration { @@ -50,10 +53,17 @@ class FervorController { accessToken = token.accessToken } - func syncGroupsAndFeeds() async { + func syncAll() async { + logger.info("Syncing groups and feeds") async let groups = try! client.groups() async let feeds = try! client.feeds() try! await persistentContainer.sync(serverGroups: groups, serverFeeds: feeds) + + let lastSync = try! await persistentContainer.lastSyncDate() + logger.info("Syncing items with last sync date: \(String(describing: lastSync), privacy: .public)") + let update = try! await client.syncItems(lastSync: lastSync) + try! await persistentContainer.syncItems(update) + try! await persistentContainer.updateLastSyncDate(update.syncTimestamp) } } diff --git a/Reader/Item.swift b/Reader/Item.swift new file mode 100644 index 0000000..031856a --- /dev/null +++ b/Reader/Item.swift @@ -0,0 +1,27 @@ +// +// Item.swift +// Reader +// +// Created by Shadowfacts on 1/9/22. +// + +import CoreData + +@objc(Item) +public final class Item: NSManagedObject { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "Item") + } + + @NSManaged public var author: String? + @NSManaged public var content: String? + @NSManaged public var id: String? + @NSManaged public var title: String? + @NSManaged public var read: Bool + @NSManaged public var published: Date? + @NSManaged public var url: URL? + + @NSManaged public var feed: Feed? + +} diff --git a/Reader/Reader.xcdatamodeld/Reader.xcdatamodel/contents b/Reader/Reader.xcdatamodeld/Reader.xcdatamodel/contents index 48be3a4..2d37a4a 100644 --- a/Reader/Reader.xcdatamodeld/Reader.xcdatamodel/contents +++ b/Reader/Reader.xcdatamodeld/Reader.xcdatamodel/contents @@ -4,7 +4,7 @@ - + @@ -13,19 +13,23 @@ - + - + - + + + + - + + \ No newline at end of file diff --git a/Reader/SceneDelegate.swift b/Reader/SceneDelegate.swift index 5c068a9..b826843 100644 --- a/Reader/SceneDelegate.swift +++ b/Reader/SceneDelegate.swift @@ -70,7 +70,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window!.rootViewController = nav Task(priority: .userInitiated) { - await self.fervorController.syncGroupsAndFeeds() + await self.fervorController.syncAll() await self.fetchFeeds() } } diff --git a/Reader/Screens/Home/HomeViewController.swift b/Reader/Screens/Home/HomeViewController.swift index 50caf80..48912c5 100644 --- a/Reader/Screens/Home/HomeViewController.swift +++ b/Reader/Screens/Home/HomeViewController.swift @@ -8,7 +8,7 @@ import UIKit import CoreData -class HomeViewController: UIViewController, UICollectionViewDelegate { +class HomeViewController: UIViewController { let fervorController: FervorController @@ -50,8 +50,6 @@ class HomeViewController: UIViewController, UICollectionViewDelegate { dataSource = createDataSource() - // todo: maybe NSFetchedResultsController to automatically update when the database changes? -// applyInitialSnapshot() var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.groups, .feeds]) dataSource.apply(snapshot, animatingDifferences: false) @@ -83,17 +81,6 @@ class HomeViewController: UIViewController, UICollectionViewDelegate { return dataSource } -// private func applyInitialSnapshot() { -// let groups = try! fervorController.persistentContainer.viewContext.fetch(Group.fetchRequest()) -// let feeds = try! fervorController.persistentContainer.viewContext.fetch(Feed.fetchRequest()) -// -// var snapshot = NSDiffableDataSourceSnapshot() -// snapshot.appendSections([.groups, .feeds]) -// snapshot.appendItems(groups.map { .group($0) }, toSection: .groups) -// snapshot.appendItems(feeds.map { .feed($0) }, toSection: .feeds) -// dataSource.apply(snapshot, animatingDifferences: false) -// } - } extension HomeViewController { @@ -134,3 +121,20 @@ extension HomeViewController: NSFetchedResultsControllerDelegate { dataSource.apply(snapshot, animatingDifferences: false) } } + +extension HomeViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + collectionView.deselectItem(at: indexPath, animated: true) + guard let item = dataSource.itemIdentifier(for: indexPath) else { + return + } + let req = Reader.Item.fetchRequest() + switch item { + case .group(let group): + req.predicate = NSPredicate(format: "feed in %@", group.feeds!) + case .feed(let feed): + req.predicate = NSPredicate(format: "feed = %@", feed) + } + show(ItemsViewController(fervorController: fervorController, fetchRequest: req), sender: nil) + } +} diff --git a/Reader/Screens/Items/ItemsViewController.swift b/Reader/Screens/Items/ItemsViewController.swift new file mode 100644 index 0000000..8ef89ba --- /dev/null +++ b/Reader/Screens/Items/ItemsViewController.swift @@ -0,0 +1,82 @@ +// +// ItemsViewController.swift +// Reader +// +// Created by Shadowfacts on 1/9/22. +// + +import UIKit +import CoreData + +class ItemsViewController: UIViewController { + + let fervorController: FervorController + let fetchRequest: NSFetchRequest + + private var collectionView: UICollectionView! + private var dataSource: UICollectionViewDiffableDataSource! + private var resultsController: NSFetchedResultsController! + + init(fervorController: FervorController, fetchRequest: NSFetchRequest) { + self.fervorController = fervorController + self.fetchRequest = fetchRequest + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + let configuration = UICollectionLayoutListConfiguration(appearance: .plain) + let layout = UICollectionViewCompositionalLayout.list(using: configuration) + collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) + collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(collectionView) + + dataSource = createDataSource() + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.items]) + dataSource.apply(snapshot, animatingDifferences: false) + + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)] + resultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: fervorController.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil) + resultsController.delegate = self + try! resultsController.performFetch() + } + + private func createDataSource() -> UICollectionViewDiffableDataSource { + let listCell = UICollectionView.CellRegistration { cell, indexPath, item in + var config = cell.defaultContentConfiguration() + config.text = item.title + cell.contentConfiguration = config + + cell.accessories = [.disclosureIndicator()] + } + let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, item in + return collectionView.dequeueConfiguredReusableCell(using: listCell, for: indexPath, item: item) + } + return dataSource + } + +} + +extension ItemsViewController { + enum Section: Hashable { + case items + } +} + +extension ItemsViewController: NSFetchedResultsControllerDelegate { + func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { + var snapshot = self.dataSource.snapshot() + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .items)) + // use resultsController here instead of controller so we don't have to cast + snapshot.appendItems(resultsController.fetchedObjects!, toSection: .items) + self.dataSource.apply(snapshot, animatingDifferences: false) + } +}