Sync groups and feeds

This commit is contained in:
Shadowfacts 2021-12-25 14:04:45 -05:00
parent 15699b0b85
commit 3ca42e9916
9 changed files with 197 additions and 5 deletions

View File

@ -8,4 +8,4 @@
import Foundation import Foundation
// todo: fervor: ids should be strings // todo: fervor: ids should be strings
public typealias FervorID = Int public typealias FervorID = String

View File

@ -13,7 +13,11 @@ public class FervorClient {
let session: URLSession let session: URLSession
public var accessToken: String? public var accessToken: String?
private let decoder = JSONDecoder() private let decoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}()
public init(instanceURL: URL, accessToken: String?, session: URLSession = .shared) { public init(instanceURL: URL, accessToken: String?, session: URLSession = .shared) {
self.instanceURL = instanceURL self.instanceURL = instanceURL
@ -61,6 +65,16 @@ public class FervorClient {
return try await performRequest(request) return try await performRequest(request)
} }
public func groups() async throws -> [Group] {
let request = URLRequest(url: buildURL(path: "/api/v1/groups"))
return try await performRequest(request)
}
public func feeds() async throws -> [Feed] {
let request = URLRequest(url: buildURL(path: "/api/v1/feeds"))
return try await performRequest(request)
}
public struct Auth { public struct Auth {
public let accessToken: String public let accessToken: String
public let refreshToken: String? public let refreshToken: String?

View File

@ -15,6 +15,8 @@
D65B18BC27504FE7004A9448 /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18BB27504FE7004A9448 /* Token.swift */; }; D65B18BC27504FE7004A9448 /* Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18BB27504FE7004A9448 /* Token.swift */; };
D65B18BE275051A1004A9448 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18BD275051A1004A9448 /* LocalData.swift */; }; D65B18BE275051A1004A9448 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18BD275051A1004A9448 /* LocalData.swift */; };
D65B18C127505348004A9448 /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18C027505348004A9448 /* HomeViewController.swift */; }; D65B18C127505348004A9448 /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18C027505348004A9448 /* HomeViewController.swift */; };
D6A8A33427766C2800CCEC72 /* PersistentContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A8A33327766C2800CCEC72 /* PersistentContainer.swift */; };
D6A8A33727766EA100CCEC72 /* ManagedObjectExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A8A33627766EA100CCEC72 /* ManagedObjectExtensions.swift */; };
D6C687EC272CD27600874C10 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C687EB272CD27600874C10 /* AppDelegate.swift */; }; D6C687EC272CD27600874C10 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C687EB272CD27600874C10 /* AppDelegate.swift */; };
D6C687EE272CD27600874C10 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C687ED272CD27600874C10 /* SceneDelegate.swift */; }; D6C687EE272CD27600874C10 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C687ED272CD27600874C10 /* SceneDelegate.swift */; };
D6C687F6272CD27600874C10 /* Reader.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D6C687F4272CD27600874C10 /* Reader.xcdatamodeld */; }; D6C687F6272CD27600874C10 /* Reader.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D6C687F4272CD27600874C10 /* Reader.xcdatamodeld */; };
@ -80,6 +82,8 @@
D65B18BB27504FE7004A9448 /* Token.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Token.swift; sourceTree = "<group>"; }; D65B18BB27504FE7004A9448 /* Token.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Token.swift; sourceTree = "<group>"; };
D65B18BD275051A1004A9448 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = "<group>"; }; D65B18BD275051A1004A9448 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = "<group>"; };
D65B18C027505348004A9448 /* HomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = "<group>"; }; D65B18C027505348004A9448 /* HomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewController.swift; sourceTree = "<group>"; };
D6A8A33327766C2800CCEC72 /* PersistentContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentContainer.swift; sourceTree = "<group>"; };
D6A8A33627766EA100CCEC72 /* ManagedObjectExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedObjectExtensions.swift; sourceTree = "<group>"; };
D6C687E8272CD27600874C10 /* Reader.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Reader.app; sourceTree = BUILT_PRODUCTS_DIR; }; D6C687E8272CD27600874C10 /* Reader.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Reader.app; sourceTree = BUILT_PRODUCTS_DIR; };
D6C687EB272CD27600874C10 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; D6C687EB272CD27600874C10 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
D6C687ED272CD27600874C10 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; }; D6C687ED272CD27600874C10 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
@ -159,6 +163,15 @@
path = Home; path = Home;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D6A8A33527766E9300CCEC72 /* CoreData */ = {
isa = PBXGroup;
children = (
D6A8A33327766C2800CCEC72 /* PersistentContainer.swift */,
D6A8A33627766EA100CCEC72 /* ManagedObjectExtensions.swift */,
);
path = CoreData;
sourceTree = "<group>";
};
D6C687DF272CD27600874C10 = { D6C687DF272CD27600874C10 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -188,6 +201,7 @@
D6C687ED272CD27600874C10 /* SceneDelegate.swift */, D6C687ED272CD27600874C10 /* SceneDelegate.swift */,
D65B18B527504920004A9448 /* FervorController.swift */, D65B18B527504920004A9448 /* FervorController.swift */,
D65B18BD275051A1004A9448 /* LocalData.swift */, D65B18BD275051A1004A9448 /* LocalData.swift */,
D6A8A33527766E9300CCEC72 /* CoreData */,
D65B18AF2750468B004A9448 /* Screens */, D65B18AF2750468B004A9448 /* Screens */,
D6C687F7272CD27700874C10 /* Assets.xcassets */, D6C687F7272CD27700874C10 /* Assets.xcassets */,
D6C687F9272CD27700874C10 /* LaunchScreen.storyboard */, D6C687F9272CD27700874C10 /* LaunchScreen.storyboard */,
@ -404,9 +418,11 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D6A8A33427766C2800CCEC72 /* PersistentContainer.swift in Sources */,
D65B18B627504920004A9448 /* FervorController.swift in Sources */, D65B18B627504920004A9448 /* FervorController.swift in Sources */,
D6C687EC272CD27600874C10 /* AppDelegate.swift in Sources */, D6C687EC272CD27600874C10 /* AppDelegate.swift in Sources */,
D6C687F6272CD27600874C10 /* Reader.xcdatamodeld in Sources */, D6C687F6272CD27600874C10 /* Reader.xcdatamodeld in Sources */,
D6A8A33727766EA100CCEC72 /* ManagedObjectExtensions.swift in Sources */,
D65B18BE275051A1004A9448 /* LocalData.swift in Sources */, D65B18BE275051A1004A9448 /* LocalData.swift in Sources */,
D65B18B22750469D004A9448 /* LoginViewController.swift in Sources */, D65B18B22750469D004A9448 /* LoginViewController.swift in Sources */,
D65B18C127505348004A9448 /* HomeViewController.swift in Sources */, D65B18C127505348004A9448 /* HomeViewController.swift in Sources */,

View File

@ -0,0 +1,38 @@
//
// ManagedObjectExtensions.swift
// Reader
//
// Created by Shadowfacts on 12/24/21.
//
import Foundation
import Fervor
extension Group {
func updateFromServer(_ serverGroup: Fervor.Group) {
guard self.id == nil || self.id == serverGroup.id else { return }
self.id = serverGroup.id
self.title = serverGroup.title
// feeds relationships will be updated after feeds are created in PersistentContainer.sync
}
}
extension Feed {
func updateFromServer(_ serverFeed: Fervor.Feed) {
guard self.id == nil || self.id == serverFeed.id else { return }
self.id = serverFeed.id
self.title = serverFeed.title
self.url = serverFeed.url
self.lastUpdated = serverFeed.lastUpdated
// todo: check this
self.removeFromGroups(self.groups!.filtered(using: NSPredicate(format: "NOT id IN %@", serverFeed.groupIDs)) as NSSet)
let groupsToAddReq = Group.fetchRequest()
groupsToAddReq.predicate = NSPredicate(format: "id IN %@", serverFeed.groupIDs.filter { g in !self.groups!.contains { ($0 as! Group).id == g } })
let groupsToAdd = try! self.managedObjectContext!.fetch(groupsToAddReq)
self.addToGroups(NSSet(array: groupsToAdd))
}
}

View File

@ -0,0 +1,65 @@
//
// PersistentContainer.swift
// Reader
//
// Created by Shadowfacts on 12/24/21.
//
import CoreData
import Fervor
class PersistentContainer: NSPersistentContainer {
private static let managedObjectModel: NSManagedObjectModel = {
let url = Bundle.main.url(forResource: "Reader", withExtension: "momd")!
return NSManagedObjectModel(contentsOf: url)!
}()
private(set) lazy var backgroundContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.parent = self.viewContext
return context
}()
init(account: LocalData.Account) {
super.init(name: "\(account.id)", managedObjectModel: PersistentContainer.managedObjectModel)
loadPersistentStores { description, error in
if let error = error {
fatalError("Unable to load persistent store: \(error)")
}
}
}
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)
}
}
if self.backgroundContext.hasChanges {
try self.backgroundContext.save()
}
}
}
}

View File

@ -19,6 +19,8 @@ class FervorController {
private(set) var clientSecret: String? private(set) var clientSecret: String?
private(set) var accessToken: String? private(set) var accessToken: String?
private(set) var persistentContainer: PersistentContainer!
init(instanceURL: URL) { init(instanceURL: URL) {
self.instanceURL = instanceURL self.instanceURL = instanceURL
self.client = FervorClient(instanceURL: instanceURL, accessToken: nil) self.client = FervorClient(instanceURL: instanceURL, accessToken: nil)
@ -29,6 +31,10 @@ class FervorController {
self.clientID = account.clientID self.clientID = account.clientID
self.clientSecret = account.clientSecret self.clientSecret = account.clientSecret
self.accessToken = account.accessToken self.accessToken = account.accessToken
self.client.accessToken = account.accessToken
self.persistentContainer = PersistentContainer(account: account)
} }
func register() async throws -> ClientRegistration { func register() async throws -> ClientRegistration {
@ -44,4 +50,10 @@ class FervorController {
accessToken = token.accessToken accessToken = token.accessToken
} }
func syncGroupsAndFeeds() async {
async let groups = try! client.groups()
async let feeds = try! client.feeds()
try! await persistentContainer.sync(serverGroups: groups, serverFeeds: feeds)
}
} }

View File

@ -28,11 +28,20 @@ struct LocalData {
} }
struct Account: Codable { struct Account: Codable {
let id: UUID
let instanceURL: URL let instanceURL: URL
let clientID: String let clientID: String
let clientSecret: String let clientSecret: String
let accessToken: String let accessToken: String
// todo: refresh tokens // todo: refresh tokens
init(instanceURL: URL, clientID: String, clientSecret: String, accessToken: String) {
self.id = UUID()
self.instanceURL = instanceURL
self.clientID = clientID
self.clientSecret = clientSecret
self.accessToken = accessToken
}
} }
} }

View File

@ -1,4 +1,31 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="1" systemVersion="11A491" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="false" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="19574" systemVersion="21C52" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<elements/> <entity name="Feed" representedClassName="Feed" syncable="YES" codeGenerationType="class">
<attribute name="id" attributeType="String"/>
<attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="title" attributeType="String"/>
<attribute name="url" 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>
<entity name="Group" representedClassName="Group" syncable="YES" codeGenerationType="class">
<attribute name="id" attributeType="String"/>
<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">
<attribute name="author" optional="YES" attributeType="String"/>
<attribute name="content" optional="YES" attributeType="String"/>
<attribute name="id" optional="YES" attributeType="String"/>
<attribute name="published" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="read" optional="YES" 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>
<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="Item" positionX="-45" positionY="63" width="128" height="149"/>
</elements>
</model> </model>

View File

@ -44,6 +44,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// Called when the scene has moved from an inactive state to an active state. // Called when the scene has moved from an inactive state to an active state.
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
} }
@MainActor
private func fetchFeeds() async {
let feeds = try! self.fervorController.persistentContainer.viewContext.fetch(Feed.fetchRequest())
print(feeds)
}
func sceneWillResignActive(_ scene: UIScene) { func sceneWillResignActive(_ scene: UIScene) {
// Called when the scene will move from an active state to an inactive state. // Called when the scene will move from an active state to an inactive state.
@ -68,6 +74,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
let home = HomeViewController(fervorController: fervorController) let home = HomeViewController(fervorController: fervorController)
let nav = UINavigationController(rootViewController: home) let nav = UINavigationController(rootViewController: home)
window!.rootViewController = nav window!.rootViewController = nav
Task(priority: .userInitiated) {
await self.fervorController.syncGroupsAndFeeds()
await self.fetchFeeds()
}
} }
} }
@ -75,7 +86,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
extension SceneDelegate: LoginViewControllerDelegate { extension SceneDelegate: LoginViewControllerDelegate {
func didLogin(with controller: FervorController) { func didLogin(with controller: FervorController) {
LocalData.account = .init(instanceURL: controller.instanceURL, clientID: controller.clientID!, clientSecret: controller.clientSecret!, accessToken: controller.accessToken!) LocalData.account = .init(instanceURL: controller.instanceURL, clientID: controller.clientID!, clientSecret: controller.clientSecret!, accessToken: controller.accessToken!)
fervorController = controller fervorController = FervorController(account: LocalData.account!)
createAppUI() createAppUI()
} }
} }