// // FervorController.swift // Reader // // Created by Shadowfacts on 11/25/21. // @preconcurrency import Foundation import Fervor @preconcurrency import OSLog import Combine actor FervorController { static let oauthRedirectURI = URL(string: "frenzy://oauth-callback")! let instanceURL: URL private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "FervorController") let client: FervorClient nonisolated let account: LocalData.Account? private(set) var clientID: String? private(set) var clientSecret: String? private(set) var accessToken: String? nonisolated let persistentContainer: PersistentContainer! nonisolated let syncState = PassthroughSubject() private var lastSyncState = SyncState.done private var cancellables = Set() init(instanceURL: URL, account: LocalData.Account?) async { self.instanceURL = instanceURL self.client = FervorClient(instanceURL: instanceURL, accessToken: account?.accessToken) self.account = account self.clientID = account?.clientID self.clientSecret = account?.clientSecret if let account = account { self.persistentContainer = PersistentContainer(account: account) } else { self.persistentContainer = nil } persistentContainer?.fervorController = self } convenience init(account: LocalData.Account) async { await self.init(instanceURL: account.instanceURL, account: account) } private func setSyncState(_ state: SyncState) { lastSyncState = state syncState.send(state) } func register() async throws -> ClientRegistration { let registration = try await client.register(clientName: "Frenzy iOS", website: nil, redirectURI: FervorController.oauthRedirectURI) clientID = registration.clientID clientSecret = registration.clientSecret return registration } func getToken(authCode: String) async throws { let token = try await client.token(authCode: authCode, redirectURI: FervorController.oauthRedirectURI, clientID: clientID!, clientSecret: clientSecret!) accessToken = token.accessToken } func syncAll() async throws { guard lastSyncState == .done else { return } // always return to .done, even if we throw and stop syncing early defer { setSyncState(.done) } setSyncState(.groupsAndFeeds) 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) setSyncState(.items) 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, setSyncState: setSyncState(_:)) try await persistentContainer.updateLastSyncDate(update.syncTimestamp) setSyncState(.excerpts) await ExcerptGenerator.generateAll(self) } func syncReadToServer() async { var count = 0 // try to sync items which failed last time let req = Item.fetchRequest() req.predicate = NSPredicate(format: "needsReadStateSync = YES") if var needsSync = try? persistentContainer.viewContext.fetch(req) { let firstReadIndex = needsSync.partition(by: \.read) let unreadIDs = needsSync[..() if !unreadIDs.isEmpty { do { let ids = try await client.unread(ids: unreadIDs) updatedIDs.formUnion(ids) } catch { logger.error("Failed to sync unread state: \(String(describing: error), privacy: .public)") } } if !readIDs.isEmpty { do { let ids = try await client.read(ids: readIDs) updatedIDs.formUnion(ids) } catch { logger.error("Failed to sync read state: \(String(describing: error), privacy: .public)") } } count += updatedIDs.count for item in needsSync where updatedIDs.contains(item.id!) { item.needsReadStateSync = false } } logger.info("Synced \(count, privacy: .public) read/unread to server") do { try persistentContainer.viewContext.save() } catch { logger.error("Failed to save view context: \(String(describing: error), privacy: .public)") } } @MainActor func markItem(_ item: Item, read: Bool) async { item.read = read do { let f = read ? client.read(item:) : client.unread(item:) _ = try await f(item.id!) item.needsReadStateSync = false } catch { logger.error("Failed to mark item (un)read: \(String(describing: error), privacy: .public)") item.needsReadStateSync = true } do { try self.persistentContainer.saveViewContext() } catch { logger.error("Failed to save view context: \(String(describing: error), privacy: .public)") } } } extension FervorController { enum SyncState: Equatable { case groupsAndFeeds case items case updateItems(current: Int, total: Int) case excerpts case done } }