// // FervorController.swift // Reader // // Created by Shadowfacts on 11/25/21. // import Foundation import Fervor import OSLog import Combine import Persistence 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 token: Token? nonisolated let persistentContainer: PersistentContainer! nonisolated let syncState = PassthroughSubject() private var lastSyncState = SyncState.done private var cancellables = Set() init(instanceURL: URL, account: LocalData.Account?, session: URLSession = .shared) async { self.instanceURL = instanceURL self.client = FervorClient(instanceURL: instanceURL, accessToken: account?.token.accessToken, session: session) self.account = account self.clientID = account?.clientID self.clientSecret = account?.clientSecret if let account = account { self.persistentContainer = PersistentContainer(account: account) } else { self.persistentContainer = nil } } convenience init(account: LocalData.Account, session: URLSession = .shared) async { await self.init(instanceURL: account.instanceURL, account: account, session: session) } 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 { token = try await client.token(authCode: authCode, redirectURI: FervorController.oauthRedirectURI, clientID: clientID!, clientSecret: clientSecret!) } func syncAll() async throws { guard lastSyncState.isFinished else { return } do { 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, setProgress: { count, total in self.setSyncState(.updateItems(current: count, total: total)) }) try await persistentContainer.updateLastSyncDate(update.syncTimestamp) await ExcerptGenerator.generateAll(self, setProgress: { count, total in self.setSyncState(.excerpts(current: count, total: total)) }) await WidgetHelper.updateWidgetData(fervorController: self) setSyncState(.done) } catch { setSyncState(.error(error)) throw error } } @MainActor 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: Persistence.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)") } } @MainActor func fetchItem(id: String) async throws -> Persistence.Item? { guard let serverItem = try await client.item(id: id) else { return nil } let item = Persistence.Item(context: persistentContainer.viewContext) item.updateFromServer(serverItem) try persistentContainer.saveViewContext() return item } } extension FervorController { enum SyncState { case groupsAndFeeds case items case updateItems(current: Int, total: Int) case excerpts(current: Int, total: Int) case error(Error) case done var isFinished: Bool { switch self { case .error(_), .done: return true default: return false } } } }