Sync and show items

This commit is contained in:
Shadowfacts 2022-01-09 17:13:30 -05:00
parent 8acc303a80
commit 2b38b883fe
13 changed files with 350 additions and 40 deletions

View File

@ -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)

View File

@ -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 {
}

View File

@ -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)

View File

@ -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
}
}

View File

@ -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 = "<group>"; };
D6C68855272CD7C600874C10 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = "<group>"; };
D6C68857272CD8CD00874C10 /* Group.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Group.swift; sourceTree = "<group>"; };
D6E2434B278B456A0005E546 /* ItemsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsViewController.swift; sourceTree = "<group>"; };
D6E2434F278B62F60005E546 /* Item.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Item.swift; sourceTree = "<group>"; };
D6E24351278B6DF90005E546 /* ItemsSyncUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsSyncUpdate.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -143,6 +149,7 @@
children = (
D65B18BF2750533E004A9448 /* Home */,
D65B18B027504691004A9448 /* Login */,
D6E2434A278B455C0005E546 /* Items */,
);
path = Screens;
sourceTree = "<group>";
@ -207,6 +214,7 @@
D6C687F9272CD27700874C10 /* LaunchScreen.storyboard */,
D6C687FC272CD27700874C10 /* Info.plist */,
D6C687F4272CD27600874C10 /* Reader.xcdatamodeld */,
D6E2434F278B62F60005E546 /* Item.swift */,
);
path = Reader;
sourceTree = "<group>";
@ -240,11 +248,20 @@
D6C68857272CD8CD00874C10 /* Group.swift */,
D6C6882F272CD2CF00874C10 /* Instance.swift */,
D6C68855272CD7C600874C10 /* Item.swift */,
D6E24351278B6DF90005E546 /* ItemsSyncUpdate.swift */,
D65B18BB27504FE7004A9448 /* Token.swift */,
);
path = Fervor;
sourceTree = "<group>";
};
D6E2434A278B455C0005E546 /* Items */ = {
isa = PBXGroup;
children = (
D6E2434B278B456A0005E546 /* ItemsViewController.swift */,
);
path = Items;
sourceTree = "<group>";
};
/* 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 */,

View File

@ -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!
}
}
}

View File

@ -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<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.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()
}
}
}
}

View File

@ -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)
}
}

27
Reader/Item.swift Normal file
View File

@ -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<Item> {
return NSFetchRequest<Item>(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?
}

View File

@ -4,7 +4,7 @@
<attribute name="id" attributeType="String"/>
<attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="title" attributeType="String"/>
<attribute name="url" attributeType="URI"/>
<attribute name="url" optional="YES" attributeType="URI"/>
<relationship name="groups" toMany="YES" deletionRule="Nullify" destinationEntity="Group" inverseName="feeds" inverseEntity="Group"/>
<relationship name="items" toMany="YES" deletionRule="Nullify" destinationEntity="Item" inverseName="feed" inverseEntity="Item"/>
</entity>
@ -13,19 +13,23 @@
<attribute name="title" attributeType="String"/>
<relationship name="feeds" toMany="YES" deletionRule="Nullify" destinationEntity="Feed" inverseName="groups" inverseEntity="Feed"/>
</entity>
<entity name="Item" representedClassName="Item" syncable="YES" codeGenerationType="class">
<entity name="Item" representedClassName="Item" syncable="YES">
<attribute name="author" optional="YES" attributeType="String"/>
<attribute name="content" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="id" attributeType="String"/>
<attribute name="published" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="read" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="read" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="title" optional="YES" attributeType="String"/>
<attribute name="url" optional="YES" attributeType="URI"/>
<relationship name="feed" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="items" inverseEntity="Feed"/>
</entity>
<entity name="SyncState" representedClassName="SyncState" syncable="YES" codeGenerationType="class">
<attribute name="lastSync" attributeType="Date" usesScalarValueType="NO"/>
</entity>
<elements>
<element name="Group" positionX="-63" positionY="-18" width="128" height="74"/>
<element name="Feed" positionX="-54" positionY="9" width="128" height="119"/>
<element name="Group" positionX="-63" positionY="-18" width="128" height="74"/>
<element name="Item" positionX="-45" positionY="63" width="128" height="149"/>
<element name="SyncState" positionX="-63" positionY="90" width="128" height="44"/>
</elements>
</model>

View File

@ -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()
}
}

View File

@ -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<Section, Item>()
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<Section, Item>()
// 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)
}
}

View File

@ -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<Item>
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
private var resultsController: NSFetchedResultsController<Item>!
init(fervorController: FervorController, fetchRequest: NSFetchRequest<Item>) {
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<Section, Item>()
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<Section, Item> {
let listCell = UICollectionView.CellRegistration<UICollectionViewListCell, Item> { cell, indexPath, item in
var config = cell.defaultContentConfiguration()
config.text = item.title
cell.contentConfiguration = config
cell.accessories = [.disclosureIndicator()]
}
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(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<NSFetchRequestResult>, 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)
}
}