Sync and show items
This commit is contained in:
parent
8acc303a80
commit
2b38b883fe
|
@ -10,7 +10,7 @@ import Foundation
|
||||||
public struct Feed: Decodable {
|
public struct Feed: Decodable {
|
||||||
public let id: FervorID
|
public let id: FervorID
|
||||||
public let title: String
|
public let title: String
|
||||||
public let url: URL
|
public let url: URL?
|
||||||
public let serviceURL: URL?
|
public let serviceURL: URL?
|
||||||
public let feedURL: URL
|
public let feedURL: URL
|
||||||
public let lastUpdated: Date
|
public let lastUpdated: Date
|
||||||
|
@ -21,7 +21,7 @@ public struct Feed: Decodable {
|
||||||
|
|
||||||
self.id = try container.decode(FervorID.self, forKey: .id)
|
self.id = try container.decode(FervorID.self, forKey: .id)
|
||||||
self.title = try container.decode(String.self, forKey: .title)
|
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.serviceURL = try container.decodeIfPresent(URL.self, forKey: .serviceURL)
|
||||||
self.feedURL = try container.decode(URL.self, forKey: .feedURL)
|
self.feedURL = try container.decode(URL.self, forKey: .feedURL)
|
||||||
self.lastUpdated = try container.decode(Date.self, forKey: .lastUpdated)
|
self.lastUpdated = try container.decode(Date.self, forKey: .lastUpdated)
|
||||||
|
|
|
@ -15,7 +15,22 @@ public class FervorClient {
|
||||||
|
|
||||||
private let decoder: JSONDecoder = {
|
private let decoder: JSONDecoder = {
|
||||||
let d = 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
|
return d
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -25,9 +40,10 @@ public class FervorClient {
|
||||||
self.session = session
|
self.session = session
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildURL(path: String) -> URL {
|
private func buildURL(path: String, queryItems: [URLQueryItem] = []) -> URL {
|
||||||
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
|
var components = URLComponents(url: instanceURL, resolvingAgainstBaseURL: false)!
|
||||||
components.path = path
|
components.path = path
|
||||||
|
components.queryItems = queryItems
|
||||||
return components.url!
|
return components.url!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,7 +52,10 @@ public class FervorClient {
|
||||||
if let accessToken = accessToken {
|
if let accessToken = accessToken {
|
||||||
request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
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)
|
let decoded = try decoder.decode(T.self, from: data)
|
||||||
return decoded
|
return decoded
|
||||||
}
|
}
|
||||||
|
@ -75,6 +94,31 @@ public class FervorClient {
|
||||||
return try await performRequest(request)
|
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 struct Auth {
|
||||||
public let accessToken: String
|
public let accessToken: String
|
||||||
public let refreshToken: String?
|
public let refreshToken: String?
|
||||||
|
@ -83,6 +127,11 @@ public class FervorClient {
|
||||||
public enum Error: Swift.Error {
|
public enum Error: Swift.Error {
|
||||||
case urlSession(Swift.Error)
|
case urlSession(Swift.Error)
|
||||||
case decode(Swift.Error)
|
case decode(Swift.Error)
|
||||||
|
case notFound
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct DateDecodingError: Error {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -8,17 +8,17 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public struct Item: Decodable {
|
public struct Item: Decodable {
|
||||||
let id: FervorID
|
public let id: FervorID
|
||||||
let feedID: FervorID
|
public let feedID: FervorID
|
||||||
let title: String
|
public let title: String
|
||||||
let author: String
|
public let author: String
|
||||||
let published: Date?
|
public let published: Date?
|
||||||
let createdAt: Date?
|
public let createdAt: Date?
|
||||||
let content: String?
|
public let content: String?
|
||||||
let summary: String?
|
public let summary: String?
|
||||||
let url: URL
|
public let url: URL
|
||||||
let serviceURL: URL?
|
public let serviceURL: URL?
|
||||||
let read: Bool?
|
public let read: Bool?
|
||||||
|
|
||||||
public init(from decoder: Decoder) throws {
|
public init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -33,6 +33,9 @@
|
||||||
D6C68834272CD44900874C10 /* Fervor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68833272CD44900874C10 /* Fervor.swift */; };
|
D6C68834272CD44900874C10 /* Fervor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68833272CD44900874C10 /* Fervor.swift */; };
|
||||||
D6C68856272CD7C600874C10 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68855272CD7C600874C10 /* Item.swift */; };
|
D6C68856272CD7C600874C10 /* Item.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68855272CD7C600874C10 /* Item.swift */; };
|
||||||
D6C68858272CD8CD00874C10 /* Group.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C68857272CD8CD00874C10 /* Group.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
@ -103,6 +106,9 @@
|
||||||
D6C68833272CD44900874C10 /* Fervor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fervor.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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 */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
@ -143,6 +149,7 @@
|
||||||
children = (
|
children = (
|
||||||
D65B18BF2750533E004A9448 /* Home */,
|
D65B18BF2750533E004A9448 /* Home */,
|
||||||
D65B18B027504691004A9448 /* Login */,
|
D65B18B027504691004A9448 /* Login */,
|
||||||
|
D6E2434A278B455C0005E546 /* Items */,
|
||||||
);
|
);
|
||||||
path = Screens;
|
path = Screens;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -207,6 +214,7 @@
|
||||||
D6C687F9272CD27700874C10 /* LaunchScreen.storyboard */,
|
D6C687F9272CD27700874C10 /* LaunchScreen.storyboard */,
|
||||||
D6C687FC272CD27700874C10 /* Info.plist */,
|
D6C687FC272CD27700874C10 /* Info.plist */,
|
||||||
D6C687F4272CD27600874C10 /* Reader.xcdatamodeld */,
|
D6C687F4272CD27600874C10 /* Reader.xcdatamodeld */,
|
||||||
|
D6E2434F278B62F60005E546 /* Item.swift */,
|
||||||
);
|
);
|
||||||
path = Reader;
|
path = Reader;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -240,11 +248,20 @@
|
||||||
D6C68857272CD8CD00874C10 /* Group.swift */,
|
D6C68857272CD8CD00874C10 /* Group.swift */,
|
||||||
D6C6882F272CD2CF00874C10 /* Instance.swift */,
|
D6C6882F272CD2CF00874C10 /* Instance.swift */,
|
||||||
D6C68855272CD7C600874C10 /* Item.swift */,
|
D6C68855272CD7C600874C10 /* Item.swift */,
|
||||||
|
D6E24351278B6DF90005E546 /* ItemsSyncUpdate.swift */,
|
||||||
D65B18BB27504FE7004A9448 /* Token.swift */,
|
D65B18BB27504FE7004A9448 /* Token.swift */,
|
||||||
);
|
);
|
||||||
path = Fervor;
|
path = Fervor;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D6E2434A278B455C0005E546 /* Items */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D6E2434B278B456A0005E546 /* ItemsViewController.swift */,
|
||||||
|
);
|
||||||
|
path = Items;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXHeadersBuildPhase section */
|
/* Begin PBXHeadersBuildPhase section */
|
||||||
|
@ -423,6 +440,8 @@
|
||||||
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 */,
|
D6A8A33727766EA100CCEC72 /* ManagedObjectExtensions.swift in Sources */,
|
||||||
|
D6E2434C278B456A0005E546 /* ItemsViewController.swift in Sources */,
|
||||||
|
D6E24350278B62F60005E546 /* Item.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 */,
|
||||||
|
@ -454,6 +473,7 @@
|
||||||
D65B18BC27504FE7004A9448 /* Token.swift in Sources */,
|
D65B18BC27504FE7004A9448 /* Token.swift in Sources */,
|
||||||
D6C68856272CD7C600874C10 /* Item.swift in Sources */,
|
D6C68856272CD7C600874C10 /* Item.swift in Sources */,
|
||||||
D6C68830272CD2CF00874C10 /* Instance.swift in Sources */,
|
D6C68830272CD2CF00874C10 /* Instance.swift in Sources */,
|
||||||
|
D6E24352278B6DF90005E546 /* ItemsSyncUpdate.swift in Sources */,
|
||||||
D6C68832272CD40600874C10 /* Feed.swift in Sources */,
|
D6C68832272CD40600874C10 /* Feed.swift in Sources */,
|
||||||
D6C68858272CD8CD00874C10 /* Group.swift in Sources */,
|
D6C68858272CD8CD00874C10 /* Group.swift in Sources */,
|
||||||
D6C68834272CD44900874C10 /* Fervor.swift in Sources */,
|
D6C68834272CD44900874C10 /* Fervor.swift in Sources */,
|
||||||
|
|
|
@ -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!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import CoreData
|
import CoreData
|
||||||
import Fervor
|
import Fervor
|
||||||
|
import OSLog
|
||||||
|
|
||||||
class PersistentContainer: NSPersistentContainer {
|
class PersistentContainer: NSPersistentContainer {
|
||||||
|
|
||||||
|
@ -22,7 +23,13 @@ class PersistentContainer: NSPersistentContainer {
|
||||||
return context
|
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)
|
super.init(name: "\(account.id)", managedObjectModel: PersistentContainer.managedObjectModel)
|
||||||
|
|
||||||
loadPersistentStores { description, error in
|
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 {
|
func sync(serverGroups: [Fervor.Group], serverFeeds: [Fervor.Feed]) async throws {
|
||||||
try await backgroundContext.perform {
|
try await backgroundContext.perform {
|
||||||
let existingGroups = try self.backgroundContext.fetch(Group.fetchRequest())
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Fervor
|
import Fervor
|
||||||
|
import OSLog
|
||||||
|
|
||||||
class FervorController {
|
class FervorController {
|
||||||
|
|
||||||
|
@ -14,7 +15,9 @@ class FervorController {
|
||||||
|
|
||||||
let instanceURL: URL
|
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 clientID: String?
|
||||||
private(set) var clientSecret: String?
|
private(set) var clientSecret: String?
|
||||||
private(set) var accessToken: String?
|
private(set) var accessToken: String?
|
||||||
|
@ -34,7 +37,7 @@ class FervorController {
|
||||||
|
|
||||||
self.client.accessToken = account.accessToken
|
self.client.accessToken = account.accessToken
|
||||||
|
|
||||||
self.persistentContainer = PersistentContainer(account: account)
|
self.persistentContainer = PersistentContainer(account: account, fervorController: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
func register() async throws -> ClientRegistration {
|
func register() async throws -> ClientRegistration {
|
||||||
|
@ -50,10 +53,17 @@ class FervorController {
|
||||||
accessToken = token.accessToken
|
accessToken = token.accessToken
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncGroupsAndFeeds() async {
|
func syncAll() async {
|
||||||
|
logger.info("Syncing groups and feeds")
|
||||||
async let groups = try! client.groups()
|
async let groups = try! client.groups()
|
||||||
async let feeds = try! client.feeds()
|
async let feeds = try! client.feeds()
|
||||||
try! await persistentContainer.sync(serverGroups: groups, serverFeeds: 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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?
|
||||||
|
|
||||||
|
}
|
|
@ -4,7 +4,7 @@
|
||||||
<attribute name="id" attributeType="String"/>
|
<attribute name="id" attributeType="String"/>
|
||||||
<attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="lastUpdated" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="title" attributeType="String"/>
|
<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="groups" toMany="YES" deletionRule="Nullify" destinationEntity="Group" inverseName="feeds" inverseEntity="Group"/>
|
||||||
<relationship name="items" toMany="YES" deletionRule="Nullify" destinationEntity="Item" inverseName="feed" inverseEntity="Item"/>
|
<relationship name="items" toMany="YES" deletionRule="Nullify" destinationEntity="Item" inverseName="feed" inverseEntity="Item"/>
|
||||||
</entity>
|
</entity>
|
||||||
|
@ -13,19 +13,23 @@
|
||||||
<attribute name="title" attributeType="String"/>
|
<attribute name="title" attributeType="String"/>
|
||||||
<relationship name="feeds" toMany="YES" deletionRule="Nullify" destinationEntity="Feed" inverseName="groups" inverseEntity="Feed"/>
|
<relationship name="feeds" toMany="YES" deletionRule="Nullify" destinationEntity="Feed" inverseName="groups" inverseEntity="Feed"/>
|
||||||
</entity>
|
</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="author" optional="YES" attributeType="String"/>
|
||||||
<attribute name="content" 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="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="title" optional="YES" attributeType="String"/>
|
||||||
<attribute name="url" optional="YES" attributeType="URI"/>
|
<attribute name="url" optional="YES" attributeType="URI"/>
|
||||||
<relationship name="feed" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="items" inverseEntity="Feed"/>
|
<relationship name="feed" maxCount="1" deletionRule="Nullify" destinationEntity="Feed" inverseName="items" inverseEntity="Feed"/>
|
||||||
</entity>
|
</entity>
|
||||||
|
<entity name="SyncState" representedClassName="SyncState" syncable="YES" codeGenerationType="class">
|
||||||
|
<attribute name="lastSync" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
|
</entity>
|
||||||
<elements>
|
<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="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="Item" positionX="-45" positionY="63" width="128" height="149"/>
|
||||||
|
<element name="SyncState" positionX="-63" positionY="90" width="128" height="44"/>
|
||||||
</elements>
|
</elements>
|
||||||
</model>
|
</model>
|
|
@ -70,7 +70,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
window!.rootViewController = nav
|
window!.rootViewController = nav
|
||||||
|
|
||||||
Task(priority: .userInitiated) {
|
Task(priority: .userInitiated) {
|
||||||
await self.fervorController.syncGroupsAndFeeds()
|
await self.fervorController.syncAll()
|
||||||
await self.fetchFeeds()
|
await self.fetchFeeds()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import CoreData
|
import CoreData
|
||||||
|
|
||||||
class HomeViewController: UIViewController, UICollectionViewDelegate {
|
class HomeViewController: UIViewController {
|
||||||
|
|
||||||
let fervorController: FervorController
|
let fervorController: FervorController
|
||||||
|
|
||||||
|
@ -50,8 +50,6 @@ class HomeViewController: UIViewController, UICollectionViewDelegate {
|
||||||
|
|
||||||
dataSource = createDataSource()
|
dataSource = createDataSource()
|
||||||
|
|
||||||
// todo: maybe NSFetchedResultsController to automatically update when the database changes?
|
|
||||||
// applyInitialSnapshot()
|
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
snapshot.appendSections([.groups, .feeds])
|
snapshot.appendSections([.groups, .feeds])
|
||||||
dataSource.apply(snapshot, animatingDifferences: false)
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
@ -83,17 +81,6 @@ class HomeViewController: UIViewController, UICollectionViewDelegate {
|
||||||
return dataSource
|
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 {
|
extension HomeViewController {
|
||||||
|
@ -134,3 +121,20 @@ extension HomeViewController: NSFetchedResultsControllerDelegate {
|
||||||
dataSource.apply(snapshot, animatingDifferences: false)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue