diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift b/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift index 045b882e3..61191ff31 100644 --- a/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift +++ b/Account/Sources/Account/ReaderAPI/ReaderAPIAccountDelegate.swift @@ -106,232 +106,108 @@ final class ReaderAPIAccountDelegate: AccountDelegate { func refreshAll(for account: Account) async throws { - try await withCheckedThrowingContinuation { continuation in - self.refreshAll(for: account) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - private func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { refreshProgress.addToNumberOfTasksAndRemaining(6) - - refreshAccount(account) { result in - switch result { - case .success(): - self.sendArticleStatus(for: account) { _ in - self.refreshProgress.completeTask() - self.caller.retrieveItemIDs(type: .allForAccount) { result in - self.refreshProgress.completeTask() - - switch result { - - case .success(let articleIDs): - - Task { @MainActor in - try? await account.markAsRead(Set(articleIDs)) - self.refreshArticleStatus(for: account) { _ in - self.refreshProgress.completeTask() - self.refreshMissingArticles(account) { - self.refreshProgress.clear() - DispatchQueue.main.async { - completion(.success(())) - } - } - } - } - - case .failure(let error): - completion(.failure(error)) - } - } - } - - case .failure(let error): - - Task { @MainActor in - self.refreshProgress.clear() - - let wrappedError = AccountError.wrappedError(error: error, account: account) - if wrappedError.isCredentialsError, let basicCredentials = try? account.retrieveCredentials(type: .readerBasic), let endpoint = account.endpointURL { - self.caller.credentials = basicCredentials - - do { - if let apiCredentials = try await self.caller.validateCredentials(endpoint: endpoint) { - try? account.storeCredentials(apiCredentials) - self.caller.credentials = apiCredentials - self.refreshAll(for: account, completion: completion) - } - else { - completion(.failure(wrappedError)) - } - } catch { - completion(.failure(wrappedError)) - } - - } else { - completion(.failure(wrappedError)) + + do { + try await refreshAccount(account) + + try await sendArticleStatus(for: account) + refreshProgress.completeTask() + + let articleIDs = try await caller.retrieveItemIDs(type: .allForAccount) + refreshProgress.completeTask() + + try? await account.markAsRead(Set(articleIDs)) + try? await refreshArticleStatus(for: account) + refreshProgress.completeTask() + + try? await refreshMissingArticles(account) + refreshProgress.clear() + + } catch { + refreshProgress.clear() + + let wrappedError = AccountError.wrappedError(error: error, account: account) + if wrappedError.isCredentialsError, let basicCredentials = try? account.retrieveCredentials(type: .readerBasic), let endpoint = account.endpointURL { + + self.caller.credentials = basicCredentials + + do { + if let apiCredentials = try await caller.validateCredentials(endpoint: endpoint) { + try? account.storeCredentials(apiCredentials) + caller.credentials = apiCredentials + try await refreshAll(for: account) + return } + throw wrappedError + } catch { + throw wrappedError } + + } else { + throw wrappedError } } } func syncArticleStatus(for account: Account) async throws { + guard variant != .inoreader else { return } - try await withCheckedThrowingContinuation { continuation in - sendArticleStatus(for: account) { result in - switch result { - case .success: - self.refreshArticleStatus(for: account) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - case .failure(let error): - continuation.resume(throwing: error) - } - } - } + try await sendArticleStatus(for: account) + try await refreshArticleStatus(for: account) } public func sendArticleStatus(for account: Account) async throws { - try await withCheckedThrowingContinuation { continuation in - self.sendArticleStatus(for: account) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - private func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { - os_log(.debug, log: log, "Sending article statuses...") - Task { @MainActor in + let syncStatuses = (try await self.database.selectForProcessing()) ?? Set() - do { + let createUnreadStatuses = syncStatuses.filter { $0.key == SyncStatus.Key.read && $0.flag == false } + let deleteUnreadStatuses = syncStatuses.filter { $0.key == SyncStatus.Key.read && $0.flag == true } + let createStarredStatuses = syncStatuses.filter { $0.key == SyncStatus.Key.starred && $0.flag == true } + let deleteStarredStatuses = syncStatuses.filter { $0.key == SyncStatus.Key.starred && $0.flag == false } - let syncStatuses = (try await self.database.selectForProcessing()) ?? Set() + try await sendArticleStatuses(createUnreadStatuses, apiCall: caller.createUnreadEntries) + try await sendArticleStatuses(deleteUnreadStatuses, apiCall: caller.deleteUnreadEntries) + try await sendArticleStatuses(createStarredStatuses, apiCall: caller.createStarredEntries) + try await sendArticleStatuses(deleteStarredStatuses, apiCall: caller.deleteStarredEntries) - @MainActor func processStatuses(_ syncStatuses: [SyncStatus]) { - - let createUnreadStatuses = syncStatuses.filter { $0.key == SyncStatus.Key.read && $0.flag == false } - let deleteUnreadStatuses = syncStatuses.filter { $0.key == SyncStatus.Key.read && $0.flag == true } - let createStarredStatuses = syncStatuses.filter { $0.key == SyncStatus.Key.starred && $0.flag == true } - let deleteStarredStatuses = syncStatuses.filter { $0.key == SyncStatus.Key.starred && $0.flag == false } - - let group = DispatchGroup() - - group.enter() - self.sendArticleStatuses(createUnreadStatuses, apiCall: self.caller.createUnreadEntries) { - group.leave() - } - - group.enter() - self.sendArticleStatuses(deleteUnreadStatuses, apiCall: self.caller.deleteUnreadEntries) { - group.leave() - } - - group.enter() - self.sendArticleStatuses(createStarredStatuses, apiCall: self.caller.createStarredEntries) { - group.leave() - } - - group.enter() - self.sendArticleStatuses(deleteStarredStatuses, apiCall: self.caller.deleteStarredEntries) { - group.leave() - } - - group.notify(queue: DispatchQueue.main) { - os_log(.debug, log: self.log, "Done sending article statuses.") - completion(.success(())) - } - } - - processStatuses(Array(syncStatuses)) - } catch { - completion(.failure(error)) - } - } + os_log(.debug, log: self.log, "Done sending article statuses.") } - + func refreshArticleStatus(for account: Account) async throws { - try await withCheckedThrowingContinuation { continuation in - self.refreshArticleStatus(for: account) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - private func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { os_log(.debug, log: log, "Refreshing article statuses...") - let group = DispatchGroup() var errorOccurred = false - group.enter() - caller.retrieveItemIDs(type: .unread) { result in - switch result { - case .success(let articleIDs): - self.syncArticleReadState(account: account, articleIDs: articleIDs) { - group.leave() - } - case .failure(let error): - errorOccurred = true - os_log(.info, log: self.log, "Retrieving unread entries failed: %@.", error.localizedDescription) - group.leave() - } - - } - - group.enter() - caller.retrieveItemIDs(type: .starred) { result in - switch result { - case .success(let articleIDs): - self.syncArticleStarredState(account: account, articleIDs: articleIDs) { - group.leave() - } - case .failure(let error): - errorOccurred = true - os_log(.info, log: self.log, "Retrieving starred entries failed: %@.", error.localizedDescription) - group.leave() - } + let articleIDs = try await caller.retrieveItemIDs(type: .unread) + do { + try await syncArticleReadState(account: account, articleIDs: articleIDs) + } catch { + errorOccurred = true + os_log(.info, log: self.log, "Retrieving unread entries failed: %@.", error.localizedDescription) } - - group.notify(queue: DispatchQueue.main) { - os_log(.debug, log: self.log, "Done refreshing article statuses.") - if errorOccurred { - completion(.failure(ReaderAPIAccountDelegateError.unknown)) - } else { - completion(.success(())) - } + + do { + let articleIDs = try await caller.retrieveItemIDs(type: .starred) + try await syncArticleStarredState(account: account, articleIDs: articleIDs) + } catch { + errorOccurred = true + os_log(.info, log: self.log, "Retrieving starred entries failed: %@.", error.localizedDescription) + } + + os_log(.debug, log: self.log, "Done refreshing article statuses.") + if errorOccurred { + throw ReaderAPIAccountDelegateError.unknown } } - + func importOPML(for account:Account, opmlFile: URL) async throws { } @@ -345,479 +221,250 @@ final class ReaderAPIAccountDelegate: AccountDelegate { func renameFolder(for account: Account, with folder: Folder, to name: String) async throws { - try await withCheckedThrowingContinuation { continuation in - - self.renameFolder(for: account, with: folder, to: name) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - private func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> Void) { - refreshProgress.addToNumberOfTasksAndRemaining(1) - caller.renameTag(oldName: folder.name ?? "", newName: name) { result in - self.refreshProgress.completeTask() - switch result { - case .success: - DispatchQueue.main.async { - folder.externalID = "user/-/label/\(name)" - folder.name = name - completion(.success(())) - } - case .failure(let error): - DispatchQueue.main.async { - let wrappedError = AccountError.wrappedError(error: error, account: account) - completion(.failure(wrappedError)) - } - } - } + do { + try await caller.renameTag(oldName: folder.name ?? "", newName: name) + folder.externalID = "user/-/label/\(name)" + folder.name = name + + refreshProgress.completeTask() + } catch { + refreshProgress.completeTask() + + let wrappedError = AccountError.wrappedError(error: error, account: account) + throw wrappedError + } } func removeFolder(for account: Account, with folder: Folder) async throws { - try await withCheckedThrowingContinuation { continuation in - - self.removeFolder(for: account, with: folder) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - private func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) { - - let group = DispatchGroup() - for feed in folder.topLevelFeeds { - + if feed.folderRelationship?.count ?? 0 > 1 { - - if let feedExternalID = feed.externalID { - group.enter() - refreshProgress.addToNumberOfTasksAndRemaining(1) - caller.deleteTagging(subscriptionID: feedExternalID, tagName: folder.nameForDisplay) { result in - self.refreshProgress.completeTask() - group.leave() - switch result { - case .success: - DispatchQueue.main.async { - self.clearFolderRelationship(for: feed, folderExternalID: folder.externalID) - } - case .failure(let error): - os_log(.error, log: self.log, "Remove feed error: %@.", error.localizedDescription) - } - } - } - - } else { - + if let subscriptionID = feed.externalID { - group.enter() + refreshProgress.addToNumberOfTasksAndRemaining(1) - caller.deleteSubscription(subscriptionID: subscriptionID) { result in - self.refreshProgress.completeTask() - group.leave() - switch result { - case .success: - DispatchQueue.main.async { - account.clearFeedMetadata(feed) - } - case .failure(let error): - os_log(.error, log: self.log, "Remove feed error: %@.", error.localizedDescription) - } + + do { + try await caller.deleteTagging(subscriptionID: subscriptionID, tagName: folder.nameForDisplay) + clearFolderRelationship(for: feed, folderExternalID: folder.externalID) + refreshProgress.completeTask() + } catch { + refreshProgress.completeTask() + os_log(.error, log: self.log, "Remove feed error: %@.", error.localizedDescription) } - } - - } - - } - - group.notify(queue: DispatchQueue.main) { - if self.variant == .theOldReader { - account.removeFolder(folder: folder) - completion(.success(())) + } else { - self.caller.deleteTag(folder: folder) { result in - switch result { - case .success: - account.removeFolder(folder: folder) - completion(.success(())) - case .failure(let error): - completion(.failure(error)) + + if let subscriptionID = feed.externalID { + refreshProgress.addToNumberOfTasksAndRemaining(1) + + do { + try await caller.deleteSubscription(subscriptionID: subscriptionID) + account.clearFeedMetadata(feed) + + refreshProgress.completeTask() + } catch { + + refreshProgress.completeTask() + os_log(.error, log: self.log, "Remove feed error: %@.", error.localizedDescription) } } } } - + + if self.variant == .theOldReader { + account.removeFolder(folder: folder) + } else { + try await caller.deleteTag(folder: folder) + account.removeFolder(folder: folder) + } } - + func createFeed(for account: Account, url: String, name: String?, container: Container, validateFeed: Bool) async throws -> Feed { - try await withCheckedThrowingContinuation { continuation in - self.createFeed(for: account, url: url, name: name, container: container, validateFeed: validateFeed) { result in - switch result { - case .success(let feed): - continuation.resume(returning: feed) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - private func createFeed(for account: Account, url: String, name: String?, container: Container, validateFeed: Bool, completion: @escaping (Result) -> Void) { guard let url = URL(string: url) else { - completion(.failure(ReaderAPIAccountDelegateError.invalidParameter)) - return + throw ReaderAPIAccountDelegateError.invalidParameter } - + refreshProgress.addToNumberOfTasksAndRemaining(2) - - FeedFinder.find(url: url) { result in - self.refreshProgress.completeTask() - switch result { - case .success(let feedSpecifiers): - let feedSpecifiers = feedSpecifiers.filter { !$0.urlString.contains("json") } - guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers) else { - self.refreshProgress.clear() - completion(.failure(AccountError.createErrorNotFound)) - return - } + do { - self.caller.createSubscription(url: bestFeedSpecifier.urlString, name: name, folder: container as? Folder) { result in - self.refreshProgress.completeTask() - switch result { - case .success(let subResult): - switch subResult { - case .created(let subscription): - self.createFeed(account: account, subscription: subscription, name: name, container: container, completion: completion) - case .notFound: - DispatchQueue.main.async { - completion(.failure(AccountError.createErrorNotFound)) - } - } - case .failure(let error): - DispatchQueue.main.async { - let wrappedError = AccountError.wrappedError(error: error, account: account) - completion(.failure(wrappedError)) - } - } - - } - case .failure: - self.refreshProgress.clear() - completion(.failure(AccountError.createErrorNotFound)) + let feedSpecifiers = try await FeedFinder.find(url: url) + refreshProgress.completeTask() + + let filteredFeedSpecifiers = feedSpecifiers.filter { !$0.urlString.contains("json") } + guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: filteredFeedSpecifiers) else { + refreshProgress.clear() + throw AccountError.createErrorNotFound } - + + let subResult = try await caller.createSubscription(url: bestFeedSpecifier.urlString, name: name, folder: container as? Folder) + refreshProgress.completeTask() + + switch subResult { + case .created(let subscription): + return try await createFeed(account: account, subscription: subscription, name: name, container: container) + case .notFound: + throw AccountError.createErrorNotFound + } + + } catch { + refreshProgress.clear() + throw AccountError.createErrorNotFound } - } - + func renameFeed(for account: Account, with feed: Feed, to name: String) async throws { - try await withCheckedThrowingContinuation { continuation in - - self.renameFeed(for: account, with: feed, to: name) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - private func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result) -> Void) { - // This error should never happen guard let subscriptionID = feed.externalID else { - completion(.failure(ReaderAPIAccountDelegateError.invalidParameter)) - return + assert(feed.externalID != nil) + throw ReaderAPIAccountDelegateError.invalidParameter } refreshProgress.addToNumberOfTasksAndRemaining(1) - caller.renameSubscription(subscriptionID: subscriptionID, newName: name) { result in - self.refreshProgress.completeTask() - switch result { - case .success: - DispatchQueue.main.async { - feed.editedName = name - completion(.success(())) - } - case .failure(let error): - DispatchQueue.main.async { - let wrappedError = AccountError.wrappedError(error: error, account: account) - completion(.failure(wrappedError)) - } - } - } + do { + try await caller.renameSubscription(subscriptionID: subscriptionID, newName: name) + feed.editedName = name + refreshProgress.completeTask() + } catch { + refreshProgress.completeTask() + let wrappedError = AccountError.wrappedError(error: error, account: account) + throw wrappedError + } } func removeFeed(for account: Account, with feed: Feed, from container: any Container) async throws { - try await withCheckedThrowingContinuation { continuation in - - self.removeFeed(for: account, with: feed, from: container) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - private func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result) -> Void) { guard let subscriptionID = feed.externalID else { - completion(.failure(ReaderAPIAccountDelegateError.invalidParameter)) - return + assert(feed.externalID != nil) + throw ReaderAPIAccountDelegateError.invalidParameter } refreshProgress.addToNumberOfTasksAndRemaining(1) - caller.deleteSubscription(subscriptionID: subscriptionID) { result in - self.refreshProgress.completeTask() - switch result { - case .success: - DispatchQueue.main.async { - account.clearFeedMetadata(feed) - account.removeFeed(feed) - if let folders = account.folders { - for folder in folders { - folder.removeFeed(feed) - } - } - completion(.success(())) - } - case .failure(let error): - DispatchQueue.main.async { - let wrappedError = AccountError.wrappedError(error: error, account: account) - completion(.failure(wrappedError)) + + do { + try await caller.deleteSubscription(subscriptionID: subscriptionID) + + account.clearFeedMetadata(feed) + account.removeFeed(feed) + if let folders = account.folders { + for folder in folders { + folder.removeFeed(feed) } } + + refreshProgress.completeTask() + } catch { + refreshProgress.completeTask() + let wrappedError = AccountError.wrappedError(error: error, account: account) + throw wrappedError } } - func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container) async throws { + func moveFeed(for account: Account, with feed: Feed, from sourceContainer: Container, to destinationContainer: Container) async throws { - try await withCheckedThrowingContinuation { continuation in - self.moveFeed(for: account, with: feed, from: from, to: to) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - private func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result) -> Void) { - if from is Account { - addFeed(for: account, with: feed, to: to, completion: completion) + if sourceContainer is Account { + try await addFeed(for: account, with: feed, to: destinationContainer) } else { + guard - let subscriptionId = feed.externalID, - let fromTag = (from as? Folder)?.name, - let toTag = (to as? Folder)?.name + let subscriptionID = feed.externalID, + let sourceTag = (sourceContainer as? Folder)?.name, + let destinationTag = (destinationContainer as? Folder)?.name else { - completion(.failure(ReaderAPIAccountDelegateError.invalidParameter)) - return + throw ReaderAPIAccountDelegateError.invalidParameter } - + refreshProgress.addToNumberOfTasksAndRemaining(1) - caller.moveSubscription(subscriptionID: subscriptionId, fromTag: fromTag, toTag: toTag) { result in - self.refreshProgress.completeTask() - switch result { - case .success: - from.removeFeed(feed) - to.addFeed(feed) - completion(.success(())) - case .failure(let error): - completion(.failure(error)) - } + + do { + try await caller.moveSubscription(subscriptionID: subscriptionID, sourceTag: sourceTag, destinationTag: destinationTag) + refreshProgress.completeTask() + sourceContainer.removeFeed(feed) + destinationContainer.addFeed(feed) + refreshProgress.completeTask() + } catch { + refreshProgress.completeTask() + throw error } } } - + func addFeed(for account: Account, with feed: Feed, to container: any Container) async throws { - try await withCheckedThrowingContinuation { continuation in - - self.addFeed(for: account, with: feed, to: container) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - private func addFeed(for account: Account, with feed: Feed, to container: Container, completion: @escaping (Result) -> Void) { if let folder = container as? Folder, let feedExternalID = feed.externalID { + refreshProgress.addToNumberOfTasksAndRemaining(1) - caller.createTagging(subscriptionID: feedExternalID, tagName: folder.name ?? "") { result in - self.refreshProgress.completeTask() - switch result { - case .success: - DispatchQueue.main.async { - self.saveFolderRelationship(for: feed, folderExternalID: folder.externalID, feedExternalID: feedExternalID) - account.removeFeed(feed) - folder.addFeed(feed) - completion(.success(())) - } - case .failure(let error): - DispatchQueue.main.async { - let wrappedError = AccountError.wrappedError(error: error, account: account) - completion(.failure(wrappedError)) - } - } + + do { + + try await caller.createTagging(subscriptionID: feedExternalID, tagName: folder.name ?? "") + + self.saveFolderRelationship(for: feed, folderExternalID: folder.externalID, feedExternalID: feedExternalID) + account.removeFeed(feed) + folder.addFeed(feed) + + refreshProgress.completeTask() + + } catch { + + refreshProgress.completeTask() + let wrappedError = AccountError.wrappedError(error: error, account: account) + throw wrappedError } } else { - DispatchQueue.main.async { - if let account = container as? Account { - account.addFeedIfNotInAnyFolder(feed) - } - completion(.success(())) + + if let account = container as? Account { + account.addFeedIfNotInAnyFolder(feed) } } } - + func restoreFeed(for account: Account, feed: Feed, container: any Container) async throws { - try await withCheckedThrowingContinuation { continuation in - - self.restoreFeed(for: account, feed: feed, container: container) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - private func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result) -> Void) { - if let existingFeed = account.existingFeed(withURL: feed.url) { - Task { @MainActor in - - do { - try await account.addFeed(existingFeed, to: container) - completion(.success(())) - } catch { - completion(.failure(error)) - } - } - } else { - createFeed(for: account, url: feed.url, name: feed.editedName, container: container, validateFeed: true) { result in - switch result { - case .success: - completion(.success(())) - case .failure(let error): - completion(.failure(error)) - } - } + try await account.addFeed(existingFeed, to: container) + } + else { + try await createFeed(for: account, url: feed.url, name: feed.editedName, container: container, validateFeed: true) } - } - + func restoreFolder(for account: Account, folder: Folder) async throws { - try await withCheckedThrowingContinuation { continuation in - self.restoreFolder(for: account, folder: folder) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - private func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result) -> Void) { - - let group = DispatchGroup() - for feed in folder.topLevelFeeds { folder.topLevelFeeds.remove(feed) - group.enter() - restoreFeed(for: account, feed: feed, container: folder) { result in - group.leave() - switch result { - case .success: - break - case .failure(let error): - os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription) - } + do { + try await restoreFeed(for: account, feed: feed, container: folder) + } catch { + os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription) } - } - - group.notify(queue: DispatchQueue.main) { - account.addFolder(folder) - completion(.success(())) - } - + + account.addFolder(folder) } func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) async throws { - try await withCheckedThrowingContinuation { continuation in - self.markArticles(for: account, articles: articles, statusKey: statusKey, flag: flag) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } + let articles = try await account.update(articles: articles, statusKey: statusKey, flag: flag) + + let syncStatuses = articles.map { article in + return SyncStatus(articleID: article.articleID, key: SyncStatus.Key(statusKey), flag: flag) } - } - private func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool, completion: @escaping (Result) -> Void) { + try await self.database.insertStatuses(syncStatuses) - Task { @MainActor in - - do { - let articles = try await account.update(articles: articles, statusKey: statusKey, flag: flag) - - let syncStatuses = articles.map { article in - return SyncStatus(articleID: article.articleID, key: SyncStatus.Key(statusKey), flag: flag) - } - - try? await self.database.insertStatuses(syncStatuses) - - if let count = try? await self.database.selectPendingCount(), count > 100 { - self.sendArticleStatus(for: account) { _ in } - } - - completion(.success(())) - - } catch { - completion(.failure(error)) - } + if let count = try await self.database.selectPendingCount(), count > 100 { + try await sendArticleStatus(for: account) } } @@ -868,30 +515,18 @@ final class ReaderAPIAccountDelegate: AccountDelegate { private extension ReaderAPIAccountDelegate { - func refreshAccount(_ account: Account, completion: @escaping (Result) -> Void) { - caller.retrieveTags { result in - switch result { - case .success(let tags): - self.refreshProgress.completeTask() - self.caller.retrieveSubscriptions { result in - self.refreshProgress.completeTask() - switch result { - case .success(let subscriptions): - MainActor.assumeIsolated { - BatchUpdate.shared.perform { - self.syncFolders(account, tags) - self.syncFeeds(account, subscriptions) - self.syncFeedFolderRelationship(account, subscriptions) - } - } - completion(.success(())) - case .failure(let error): - completion(.failure(error)) - } - } - case .failure(let error): - completion(.failure(error)) - } + func refreshAccount(_ account: Account) async throws { + + let tags = try await caller.retrieveTags() + refreshProgress.completeTask() + + let subscriptions = try await caller.retrieveSubscriptions() + refreshProgress.completeTask() + + BatchUpdate.shared.perform { + self.syncFolders(account, tags) + self.syncFeeds(account, subscriptions) + self.syncFeedFolderRelationship(account, subscriptions) } } @@ -1065,43 +700,26 @@ private extension ReaderAPIAccountDelegate { return d } - func sendArticleStatuses(_ statuses: [SyncStatus], apiCall: ([String], @escaping (Result) -> Void) -> Void, completion: @escaping (() -> Void)) { + func sendArticleStatuses(_ statuses: Set, apiCall: ([String]) async throws -> Void) async { + guard !statuses.isEmpty else { - completion() return } - - let group = DispatchGroup() - + let articleIDs = statuses.compactMap { $0.articleID } let articleIDGroups = articleIDs.chunked(into: 1000) for articleIDGroup in articleIDGroups { - - group.enter() - apiCall(articleIDGroup) { result in - switch result { - case .success: - Task { - try? await self.database.deleteSelectedForProcessing(articleIDGroup.map { $0 } ) - group.leave() - } - case .failure(let error): - os_log(.error, log: self.log, "Article status sync call failed: %@.", error.localizedDescription) - Task { - try? await self.database.resetSelectedForProcessing(articleIDGroup.map { $0 } ) - group.leave() - } - } + + do { + let _ = try await apiCall(articleIDGroup) + try? await database.deleteSelectedForProcessing(articleIDGroup.map { $0 } ) + } catch { + os_log(.error, log: self.log, "Article status sync call failed: %@.", error.localizedDescription) + try? await database.resetSelectedForProcessing(articleIDGroup.map { $0 } ) } - } - - group.notify(queue: DispatchQueue.main) { - completion() - } - } - + func clearFolderRelationship(for feed: Feed, folderExternalID: String?) { guard var folderRelationship = feed.folderRelationship, let folderExternalID = folderExternalID else { return } folderRelationship[folderExternalID] = nil @@ -1118,118 +736,81 @@ private extension ReaderAPIAccountDelegate { } } - func createFeed( account: Account, subscription sub: ReaderAPISubscription, name: String?, container: Container, completion: @escaping (Result) -> Void) { + func createFeed( account: Account, subscription sub: ReaderAPISubscription, name: String?, container: Container) async throws -> Feed { - Task { @MainActor in + let feed = account.createFeed(with: sub.name, url: sub.url, feedID: String(sub.feedID), homePageURL: sub.homePageURL) + feed.externalID = String(sub.feedID) - let feed = account.createFeed(with: sub.name, url: sub.url, feedID: String(sub.feedID), homePageURL: sub.homePageURL) - feed.externalID = String(sub.feedID) - - do { - try await account.addFeed(feed, to: container) - if let name { - try await self.renameFeed(for: account, with: feed, to: name) - } - self.initialFeedDownload(account: account, feed: feed, completion: completion) - } catch { - completion(.failure(error)) - } + try await account.addFeed(feed, to: container) + if let name { + try await renameFeed(for: account, with: feed, to: name) } + try await initialFeedDownload(account: account, feed: feed) + + return feed } - func initialFeedDownload( account: Account, feed: Feed, completion: @escaping (Result) -> Void) { + + @discardableResult + func initialFeedDownload( account: Account, feed: Feed) async throws -> Feed { + refreshProgress.addToNumberOfTasksAndRemaining(5) // Download the initial articles - self.caller.retrieveItemIDs(type: .allForFeed, feedID: feed.feedID) { result in - self.refreshProgress.completeTask() - switch result { + let articleIDs = try await caller.retrieveItemIDs(type: .allForFeed, feedID: feed.feedID) - case .success(let articleIDs): + refreshProgress.completeTask() - Task { @MainActor in - try? await account.markAsRead(Set(articleIDs)) - self.refreshProgress.completeTask() - self.refreshArticleStatus(for: account) { _ in - self.refreshProgress.completeTask() - self.refreshMissingArticles(account) { - self.refreshProgress.clear() - DispatchQueue.main.async { - completion(.success(feed)) - } + try? await account.markAsRead(Set(articleIDs)) + refreshProgress.completeTask() - } - } - } + try? await refreshArticleStatus(for: account) + refreshProgress.completeTask() - case .failure(let error): - completion(.failure(error)) - } + await refreshMissingArticles(account) + refreshProgress.clear() - } - + return feed } - func refreshMissingArticles(_ account: Account, completion: @escaping VoidCompletionBlock) { + func refreshMissingArticles(_ account: Account) async { - Task { @MainActor in + do { + let fetchedArticleIDs = (try? await account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate()) ?? Set() - do { - let fetchedArticleIDs = try await account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate() ?? Set() - - guard !fetchedArticleIDs.isEmpty else { - completion() - return - } - - os_log(.debug, log: self.log, "Refreshing missing articles...") - let group = DispatchGroup() - - let articleIDs = Array(fetchedArticleIDs) - let chunkedArticleIDs = articleIDs.chunked(into: 150) - - self.refreshProgress.addToNumberOfTasksAndRemaining(chunkedArticleIDs.count - 1) - - for chunk in chunkedArticleIDs { - group.enter() - self.caller.retrieveEntries(articleIDs: chunk) { result in - self.refreshProgress.completeTask() - - switch result { - case .success(let entries): - self.processEntries(account: account, entries: entries) { - group.leave() - } - - case .failure(let error): - os_log(.error, log: self.log, "Refresh missing articles failed: %@.", error.localizedDescription) - group.leave() - } - } - } - - group.notify(queue: DispatchQueue.main) { - self.refreshProgress.completeTask() - os_log(.debug, log: self.log, "Done refreshing missing articles.") - completion() - } - - } catch { - self.refreshProgress.completeTask() - completion() + if fetchedArticleIDs.isEmpty { + return } + + os_log(.debug, log: self.log, "Refreshing missing articles...") + + let articleIDs = Array(fetchedArticleIDs) + let chunkedArticleIDs = articleIDs.chunked(into: 150) + + refreshProgress.addToNumberOfTasksAndRemaining(chunkedArticleIDs.count - 1) + + for chunk in chunkedArticleIDs { + + do { + let entries = try await caller.retrieveEntries(articleIDs: chunk) + refreshProgress.completeTask() + await processEntries(account: account, entries: entries) + } catch { + os_log(.error, log: self.log, "Refresh missing articles failed: %@.", error.localizedDescription) + } + } + + refreshProgress.completeTask() + os_log(.debug, log: self.log, "Done refreshing missing articles.") } } - func processEntries(account: Account, entries: [ReaderAPIEntry]?, completion: @escaping VoidCompletionBlock) { + func processEntries(account: Account, entries: [ReaderAPIEntry]?) async { let parsedItems = mapEntriesToParsedItems(account: account, entries: entries) let feedIDsAndItems = Dictionary(grouping: parsedItems, by: { item in item.feedURL } ).mapValues { Set($0) } - Task { @MainActor in - try? await account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true) - completion() - } + try? await account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true) } func mapEntriesToParsedItems(account: Account, entries: [ReaderAPIEntry]?) -> Set { @@ -1273,10 +854,9 @@ private extension ReaderAPIAccountDelegate { } - func syncArticleReadState(account: Account, articleIDs: [String]?, completion: @escaping (() -> Void)) { + func syncArticleReadState(account: Account, articleIDs: [String]?) async throws { guard let articleIDs else { - completion() return } @@ -1288,7 +868,6 @@ private extension ReaderAPIAccountDelegate { let updatableReaderUnreadArticleIDs = Set(articleIDs).subtracting(pendingArticleIDs) guard let currentUnreadArticleIDs = try await account.fetchUnreadArticleIDs() else { - completion() return } @@ -1299,8 +878,6 @@ private extension ReaderAPIAccountDelegate { // Mark articles as read let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(updatableReaderUnreadArticleIDs) try? await account.markAsRead(deltaReadArticleIDs) - - completion() } catch { os_log(.error, log: self.log, "Sync Article Read Status failed: %@.", error.localizedDescription) @@ -1308,39 +885,32 @@ private extension ReaderAPIAccountDelegate { } } - func syncArticleStarredState(account: Account, articleIDs: [String]?, completion: @escaping (() -> Void)) { + func syncArticleStarredState(account: Account, articleIDs: [String]?) async { guard let articleIDs else { - completion() return } - Task { @MainActor in + do { - do { + let pendingArticleIDs = (try await self.database.selectPendingStarredStatusArticleIDs()) ?? Set() - let pendingArticleIDs = (try await self.database.selectPendingStarredStatusArticleIDs()) ?? Set() + let updatableReaderUnreadArticleIDs = Set(articleIDs).subtracting(pendingArticleIDs) - let updatableReaderUnreadArticleIDs = Set(articleIDs).subtracting(pendingArticleIDs) - - guard let currentStarredArticleIDs = try await account.fetchStarredArticleIDs() else { - completion() - return - } - - // Mark articles as starred - let deltaStarredArticleIDs = updatableReaderUnreadArticleIDs.subtracting(currentStarredArticleIDs) - try? await account.markAsStarred(deltaStarredArticleIDs) - - // Mark articles as unstarred - let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(updatableReaderUnreadArticleIDs) - try? await account.markAsUnstarred(deltaUnstarredArticleIDs) - - completion() - - } catch { - os_log(.error, log: self.log, "Sync Article Starred Status failed: %@.", error.localizedDescription) + guard let currentStarredArticleIDs = try await account.fetchStarredArticleIDs() else { + return } + + // Mark articles as starred + let deltaStarredArticleIDs = updatableReaderUnreadArticleIDs.subtracting(currentStarredArticleIDs) + try? await account.markAsStarred(deltaStarredArticleIDs) + + // Mark articles as unstarred + let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(updatableReaderUnreadArticleIDs) + try? await account.markAsUnstarred(deltaUnstarredArticleIDs) + + } catch { + os_log(.error, log: self.log, "Sync Article Starred Status failed: %@.", error.localizedDescription) } } } diff --git a/Account/Sources/Account/ReaderAPI/ReaderAPICaller.swift b/Account/Sources/Account/ReaderAPI/ReaderAPICaller.swift index cbcf41230..5a824b8b8 100644 --- a/Account/Sources/Account/ReaderAPI/ReaderAPICaller.swift +++ b/Account/Sources/Account/ReaderAPI/ReaderAPICaller.swift @@ -140,162 +140,107 @@ enum CreateReaderAPISubscriptionResult { } } - func requestAuthorizationToken(endpoint: URL, completion: @escaping (Result) -> Void) { + func requestAuthorizationToken(endpoint: URL) async throws -> String { + // If we have a token already, use it - if let accessToken = accessToken { - completion(.success(accessToken)) - return + if let accessToken { + return accessToken } - + // Otherwise request one. - guard let credentials = credentials else { - completion(.failure(CredentialsError.incompleteCredentials)) - return + guard let credentials else { + throw CredentialsError.incompleteCredentials } - + var request = URLRequest(url: endpoint.appendingPathComponent(ReaderAPIEndpoints.token.rawValue), credentials: credentials) addVariantHeaders(&request) - - transport.send(request: request) { result in - switch result { - case .success(let (_, data)): - guard let resultData = data else { - completion(.failure(TransportError.noData)) - break - } - - // Convert the return data to UTF8 and then parse out the Auth token - guard let accessToken = String(data: resultData, encoding: .utf8) else { - completion(.failure(TransportError.noData)) - break - } - - self.accessToken = accessToken - completion(.success(accessToken)) - case .failure(let error): - completion(.failure(error)) - } + + let (_, data) = try await transport.send(request: request) + + // Convert the return data to UTF8 and then parse out the Auth token + guard let data, let accessToken = String(data: data, encoding: .utf8) else { + throw TransportError.noData } + + self.accessToken = accessToken + return accessToken } - - func retrieveTags(completion: @escaping (Result<[ReaderAPITag]?, Error>) -> Void) { + func retrieveTags() async throws -> [ReaderAPITag]? { + guard let baseURL = apiBaseURL else { - completion(.failure(CredentialsError.incompleteCredentials)) - return + throw CredentialsError.incompleteCredentials } - + var url = baseURL .appendingPathComponent(ReaderAPIEndpoints.tagList.rawValue) .appendingQueryItem(URLQueryItem(name: "output", value: "json")) - + if variant == .inoreader { url = url?.appendingQueryItem(URLQueryItem(name: "types", value: "1")) } - + guard let callURL = url else { - completion(.failure(TransportError.noURL)) - return + throw TransportError.noURL } var request = URLRequest(url: callURL, credentials: credentials) addVariantHeaders(&request) - transport.send(request: request, resultType: ReaderAPITagContainer.self) { result in - switch result { - case .success(let (_, wrapper)): - completion(.success(wrapper?.tags)) - case .failure(let error): - completion(.failure(error)) - } - } - + let (_, wrapper) = try await transport.send(request: request, resultType: ReaderAPITagContainer.self) + return wrapper?.tags } - func renameTag(oldName: String, newName: String, completion: @escaping (Result) -> Void) { + func renameTag(oldName: String, newName: String) async throws { + guard let baseURL = apiBaseURL else { - completion(.failure(CredentialsError.incompleteCredentials)) - return + throw CredentialsError.incompleteCredentials } - - self.requestAuthorizationToken(endpoint: baseURL) { (result) in - switch result { - case .success(let token): - var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.renameTag.rawValue), credentials: self.credentials) - self.addVariantHeaders(&request) - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpMethod = "POST" - - guard let encodedOldName = self.encodeForURLPath(oldName), let encodedNewName = self.encodeForURLPath(newName) else { - completion(.failure(ReaderAPIAccountDelegateError.invalidParameter)) - return - } - - let oldTagName = "user/-/label/\(encodedOldName)" - let newTagName = "user/-/label/\(encodedNewName)" - let postData = "T=\(token)&s=\(oldTagName)&dest=\(newTagName)".data(using: String.Encoding.utf8) - - self.transport.send(request: request, method: HTTPMethod.post, payload: postData!, completion: { (result) in - switch result { - case .success: - completion(.success(())) - break - case .failure(let error): - completion(.failure(error)) - break - } - }) - - - case .failure(let error): - completion(.failure(error)) - } + + let token = try await requestAuthorizationToken(endpoint: baseURL) + + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.renameTag.rawValue), credentials: self.credentials) + self.addVariantHeaders(&request) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + + guard let encodedOldName = self.encodeForURLPath(oldName), let encodedNewName = self.encodeForURLPath(newName) else { + throw ReaderAPIAccountDelegateError.invalidParameter } + + let oldTagName = "user/-/label/\(encodedOldName)" + let newTagName = "user/-/label/\(encodedNewName)" + let postData = "T=\(token)&s=\(oldTagName)&dest=\(newTagName)".data(using: String.Encoding.utf8) + + try await transport.send(request: request, method: HTTPMethod.post, payload: postData!) } - - @MainActor func deleteTag(folder: Folder, completion: @escaping (Result) -> Void) { + + + func deleteTag(folder: Folder) async throws { + guard let baseURL = apiBaseURL else { - completion(.failure(CredentialsError.incompleteCredentials)) - return + throw CredentialsError.incompleteCredentials } - guard let folderExternalID = folder.externalID else { - completion(.failure(ReaderAPIAccountDelegateError.invalidParameter)) - return + throw ReaderAPIAccountDelegateError.invalidParameter } - self.requestAuthorizationToken(endpoint: baseURL) { (result) in - switch result { - case .success(let token): - var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.disableTag.rawValue), credentials: self.credentials) - self.addVariantHeaders(&request) - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpMethod = "POST" + let token = try await self.requestAuthorizationToken(endpoint: baseURL) + + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.disableTag.rawValue), credentials: self.credentials) + self.addVariantHeaders(&request) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + + let postData = "T=\(token)&s=\(folderExternalID)".data(using: String.Encoding.utf8) - let postData = "T=\(token)&s=\(folderExternalID)".data(using: String.Encoding.utf8) - - self.transport.send(request: request, method: HTTPMethod.post, payload: postData!, completion: { (result) in - switch result { - case .success: - completion(.success(())) - break - case .failure(let error): - completion(.failure(error)) - break - } - }) - - - case .failure(let error): - completion(.failure(error)) - } - } + try await self.transport.send(request: request, method: HTTPMethod.post, payload: postData!) } - func retrieveSubscriptions(completion: @escaping (Result<[ReaderAPISubscription]?, Error>) -> Void) { + func retrieveSubscriptions() async throws -> [ReaderAPISubscription]? { + guard let baseURL = apiBaseURL else { - completion(.failure(CredentialsError.incompleteCredentials)) - return + throw CredentialsError.incompleteCredentials } let url = baseURL @@ -303,266 +248,178 @@ enum CreateReaderAPISubscriptionResult { .appendingQueryItem(URLQueryItem(name: "output", value: "json")) guard let callURL = url else { - completion(.failure(TransportError.noURL)) - return + throw TransportError.noURL } var request = URLRequest(url: callURL, credentials: credentials) addVariantHeaders(&request) - transport.send(request: request, resultType: ReaderAPISubscriptionContainer.self) { result in - switch result { - case .success(let (_, container)): - completion(.success(container?.subscriptions)) - case .failure(let error): - completion(.failure(error)) - } - } + let (_, container) = try await transport.send(request: request, resultType: ReaderAPISubscriptionContainer.self) + return container?.subscriptions } - func createSubscription(url: String, name: String?, folder: Folder?, completion: @escaping (Result) -> Void) { + func createSubscription(url: String, name: String?, folder: Folder?) async throws -> CreateReaderAPISubscriptionResult { + guard let baseURL = apiBaseURL else { - completion(.failure(CredentialsError.incompleteCredentials)) - return + throw CredentialsError.incompleteCredentials } + + let token = try await self.requestAuthorizationToken(endpoint: baseURL) + + let callURL = baseURL + .appendingPathComponent(ReaderAPIEndpoints.subscriptionAdd.rawValue) - func findSubscription(streamID: String, completion: @escaping (Result) -> Void) { - // There is no call to get a single subscription entry, so we get them all, - // look up the one we just subscribed to and return that - self.retrieveSubscriptions(completion: { (result) in - switch result { - case .success(let subscriptions): - guard let subscriptions = subscriptions else { - completion(.failure(AccountError.createErrorNotFound)) - return - } + var request = URLRequest(url: callURL, credentials: self.credentials) + self.addVariantHeaders(&request) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" - guard let subscription = subscriptions.first(where: { (sub) -> Bool in - sub.feedID == streamID - }) else { - completion(.failure(AccountError.createErrorNotFound)) - return - } - - completion(.success(.created(subscription))) - - case .failure(let error): - completion(.failure(error)) - } - }) - } - - - self.requestAuthorizationToken(endpoint: baseURL) { (result) in - switch result { - case .success(let token): - let callURL = baseURL - .appendingPathComponent(ReaderAPIEndpoints.subscriptionAdd.rawValue) - - var request = URLRequest(url: callURL, credentials: self.credentials) - self.addVariantHeaders(&request) - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpMethod = "POST" - - guard let encodedFeedURL = self.encodeForURLPath(url) else { - completion(.failure(ReaderAPIAccountDelegateError.invalidParameter)) - return - } - let postData = "T=\(token)&quickadd=\(encodedFeedURL)".data(using: String.Encoding.utf8) - - self.transport.send(request: request, method: HTTPMethod.post, data: postData!, resultType: ReaderAPIQuickAddResult.self, completion: { (result) in - switch result { - case .success(let (_, subResult)): - - switch subResult?.numResults { - case 0: - completion(.success(.notFound)) - default: - guard let streamId = subResult?.streamId else { - completion(.failure(AccountError.createErrorNotFound)) - return - } - - findSubscription(streamID: streamId, completion: completion) - } - - case .failure(let error): - completion(.failure(error)) - } - - }) - - case .failure(let error): - completion(.failure(error)) - } - + guard let encodedFeedURL = self.encodeForURLPath(url) else { + throw ReaderAPIAccountDelegateError.invalidParameter } + let postData = "T=\(token)&quickadd=\(encodedFeedURL)".data(using: String.Encoding.utf8) + + let (_, subResult) = try await self.transport.send(request: request, method: HTTPMethod.post, data: postData!, resultType: ReaderAPIQuickAddResult.self) + + guard let subResult else { + return .notFound + } + if subResult.numResults == 0 { + return .notFound + } + + // There is no call to get a single subscription entry, so we get them all, + // look up the one we just subscribed to and return that + guard let subscriptions = try await retrieveSubscriptions() else { + throw AccountError.createErrorNotFound + } + guard let subscription = subscriptions.first(where: { $0.feedID == subResult.streamId }) else { + throw AccountError.createErrorNotFound + } + + return .created(subscription) } - func renameSubscription(subscriptionID: String, newName: String, completion: @escaping (Result) -> Void) { - changeSubscription(subscriptionID: subscriptionID, title: newName, completion: completion) + func renameSubscription(subscriptionID: String, newName: String) async throws { + + try await changeSubscription(subscriptionID: subscriptionID, title: newName) } - func deleteSubscription(subscriptionID: String, completion: @escaping (Result) -> Void) { + func deleteSubscription(subscriptionID: String) async throws { + guard let baseURL = apiBaseURL else { - completion(.failure(CredentialsError.incompleteCredentials)) - return - } - - self.requestAuthorizationToken(endpoint: baseURL) { (result) in - switch result { - case .success(let token): - var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), credentials: self.credentials) - self.addVariantHeaders(&request) - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpMethod = "POST" - - let postData = "T=\(token)&s=\(subscriptionID)&ac=unsubscribe".data(using: String.Encoding.utf8) - - self.transport.send(request: request, method: HTTPMethod.post, payload: postData!, completion: { (result) in - switch result { - case .success: - completion(.success(())) - break - case .failure(let error): - completion(.failure(error)) - break - } - }) - - case .failure(let error): - completion(.failure(error)) - } + throw CredentialsError.incompleteCredentials } + + let token = try await self.requestAuthorizationToken(endpoint: baseURL) + + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), credentials: self.credentials) + self.addVariantHeaders(&request) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + + let postData = "T=\(token)&s=\(subscriptionID)&ac=unsubscribe".data(using: String.Encoding.utf8) + + try await self.transport.send(request: request, method: HTTPMethod.post, payload: postData!) + } + + func createTagging(subscriptionID: String, tagName: String) async throws { + + try await changeSubscription(subscriptionID: subscriptionID, addTagName: tagName) } - func createTagging(subscriptionID: String, tagName: String, completion: @escaping (Result) -> Void) { - changeSubscription(subscriptionID: subscriptionID, addTagName: tagName, completion: completion) + func deleteTagging(subscriptionID: String, tagName: String) async throws { + + try await changeSubscription(subscriptionID: subscriptionID, removeTagName: tagName) } - func deleteTagging(subscriptionID: String, tagName: String, completion: @escaping (Result) -> Void) { - changeSubscription(subscriptionID: subscriptionID, removeTagName: tagName, completion: completion) + func moveSubscription(subscriptionID: String, sourceTag: String, destinationTag: String) async throws { + + try await changeSubscription(subscriptionID: subscriptionID, removeTagName: sourceTag, addTagName: destinationTag) } - func moveSubscription(subscriptionID: String, fromTag: String, toTag: String, completion: @escaping (Result) -> Void) { - changeSubscription(subscriptionID: subscriptionID, removeTagName: fromTag, addTagName: toTag, completion: completion) - } - - private func changeSubscription(subscriptionID: String, removeTagName: String? = nil, addTagName: String? = nil, title: String? = nil, completion: @escaping (Result) -> Void) { + private func changeSubscription(subscriptionID: String, removeTagName: String? = nil, addTagName: String? = nil, title: String? = nil) async throws { + guard removeTagName != nil || addTagName != nil || title != nil else { - completion(.failure(ReaderAPIAccountDelegateError.invalidParameter)) - return - } - + throw ReaderAPIAccountDelegateError.invalidParameter + } guard let baseURL = apiBaseURL else { - completion(.failure(CredentialsError.incompleteCredentials)) + throw CredentialsError.incompleteCredentials return } - self.requestAuthorizationToken(endpoint: baseURL) { (result) in - switch result { - case .success(let token): - var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), credentials: self.credentials) - self.addVariantHeaders(&request) - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpMethod = "POST" - - var postString = "T=\(token)&s=\(subscriptionID)&ac=edit" - if let fromLabel = self.encodeForURLPath(removeTagName) { - postString += "&r=user/-/label/\(fromLabel)" - } - if let toLabel = self.encodeForURLPath(addTagName) { - postString += "&a=user/-/label/\(toLabel)" - } - if let encodedTitle = self.encodeForURLPath(title) { - postString += "&t=\(encodedTitle)" - } - let postData = postString.data(using: String.Encoding.utf8) - - self.transport.send(request: request, method: HTTPMethod.post, payload: postData!, completion: { (result) in - switch result { - case .success: - completion(.success(())) - break - case .failure(let error): - completion(.failure(error)) - break - } - }) - - case .failure(let error): - completion(.failure(error)) - } + let token = try await requestAuthorizationToken(endpoint: baseURL) + + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), credentials: self.credentials) + self.addVariantHeaders(&request) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + + var postString = "T=\(token)&s=\(subscriptionID)&ac=edit" + if let fromLabel = self.encodeForURLPath(removeTagName) { + postString += "&r=user/-/label/\(fromLabel)" } + if let toLabel = self.encodeForURLPath(addTagName) { + postString += "&a=user/-/label/\(toLabel)" + } + if let encodedTitle = self.encodeForURLPath(title) { + postString += "&t=\(encodedTitle)" + } + let postData = postString.data(using: String.Encoding.utf8) + + try await transport.send(request: request, method: HTTPMethod.post, payload: postData!) } - func retrieveEntries(articleIDs: [String], completion: @escaping (Result<([ReaderAPIEntry]?), Error>) -> Void) { - + func retrieveEntries(articleIDs: [String]) async throws -> [ReaderAPIEntry]? { + guard !articleIDs.isEmpty else { - completion(.success(([ReaderAPIEntry]()))) - return + return [ReaderAPIEntry]() } - guard let baseURL = apiBaseURL else { - completion(.failure(CredentialsError.incompleteCredentials)) - return - } - - self.requestAuthorizationToken(endpoint: baseURL) { (result) in - switch result { - case .success(let token): - var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.contents.rawValue), credentials: self.credentials) - self.addVariantHeaders(&request) - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpMethod = "POST" - - // Get ids from above into hex representation of value - let idsToFetch = articleIDs.map({ articleID -> String in - if self.variant == .theOldReader { - return "i=tag:google.com,2005:reader/item/\(articleID)" - } else { - let idValue = Int(articleID)! - let idHexString = String(idValue, radix: 16, uppercase: false) - return "i=tag:google.com,2005:reader/item/\(idHexString)" - } - }).joined(separator:"&") - - let postData = "T=\(token)&output=json&\(idsToFetch)".data(using: String.Encoding.utf8) - - self.transport.send(request: request, method: HTTPMethod.post, data: postData!, resultType: ReaderAPIEntryWrapper.self, completion: { (result) in - switch result { - case .success(let (_, entryWrapper)): - guard let entryWrapper = entryWrapper else { - completion(.failure(ReaderAPIAccountDelegateError.invalidResponse)) - return - } - - completion(.success((entryWrapper.entries))) - case .failure(let error): - completion(.failure(error)) - } - }) - - - case .failure(let error): - completion(.failure(error)) - } + throw CredentialsError.incompleteCredentials } + let token = try await requestAuthorizationToken(endpoint: baseURL) + + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.contents.rawValue), credentials: self.credentials) + self.addVariantHeaders(&request) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + + // Get ids from above into hex representation of value + let idsToFetch = articleIDs.map({ articleID -> String in + if self.variant == .theOldReader { + return "i=tag:google.com,2005:reader/item/\(articleID)" + } else { + let idValue = Int(articleID)! + let idHexString = String(idValue, radix: 16, uppercase: false) + return "i=tag:google.com,2005:reader/item/\(idHexString)" + } + }).joined(separator:"&") + + let postData = "T=\(token)&output=json&\(idsToFetch)".data(using: String.Encoding.utf8) + + let (_, entryWrapper) = try await transport.send(request: request, method: HTTPMethod.post, data: postData!, resultType: ReaderAPIEntryWrapper.self) + + guard let entryWrapper else { + throw ReaderAPIAccountDelegateError.invalidResponse + } + + return entryWrapper.entries } - func retrieveItemIDs(type: ItemIDType, feedID: String? = nil, completion: @escaping ((Result<[String], Error>) -> Void)) { + func retrieveItemIDs(type: ItemIDType, feedID: String? = nil) async throws -> [String] { + guard let baseURL = apiBaseURL else { - completion(.failure(CredentialsError.incompleteCredentials)) - return + throw CredentialsError.incompleteCredentials } - + var queryItems = [ URLQueryItem(name: "n", value: "1000"), URLQueryItem(name: "output", value: "json") ] - + switch type { case .allForAccount: let since: Date = { @@ -572,14 +429,13 @@ enum CreateReaderAPISubscriptionResult { return Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date() } }() - + let sinceTimeInterval = since.timeIntervalSince1970 queryItems.append(URLQueryItem(name: "ot", value: String(Int(sinceTimeInterval)))) queryItems.append(URLQueryItem(name: "s", value: ReaderStreams.readingList.rawValue)) case .allForFeed: - guard let feedID = feedID else { - completion(.failure(ReaderAPIAccountDelegateError.invalidParameter)) - return + guard let feedID else { + throw ReaderAPIAccountDelegateError.invalidParameter } let sinceTimeInterval = (Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date()).timeIntervalSince1970 queryItems.append(URLQueryItem(name: "ot", value: String(Int(sinceTimeInterval)))) @@ -590,48 +446,42 @@ enum CreateReaderAPISubscriptionResult { case .starred: queryItems.append(URLQueryItem(name: "s", value: ReaderState.starred.rawValue)) } - + let url = baseURL .appendingPathComponent(ReaderAPIEndpoints.itemIds.rawValue) .appendingQueryItems(queryItems) - + guard let callURL = url else { - completion(.failure(TransportError.noURL)) - return + throw TransportError.noURL } - + var request: URLRequest = URLRequest(url: callURL, credentials: credentials) addVariantHeaders(&request) - self.transport.send(request: request, resultType: ReaderAPIReferenceWrapper.self) { result in - switch result { - case .success(let (response, entries)): - guard let entriesItemRefs = entries?.itemRefs, entriesItemRefs.count > 0 else { - completion(.success([String]())) - return - } - let dateInfo = HTTPDateInfo(urlResponse: response) - let itemIDs = entriesItemRefs.compactMap { $0.itemId } - self.retrieveItemIDs(type: type, url: callURL, dateInfo: dateInfo, itemIDs: itemIDs, continuation: entries?.continuation, completion: completion) - case .failure(let error): - completion(.failure(error)) - } + let (response, entries) = try await transport.send(request: request, resultType: ReaderAPIReferenceWrapper.self) + + guard let entriesItemRefs = entries?.itemRefs, entriesItemRefs.count > 0 else { + return [String]() } + + let dateInfo = HTTPDateInfo(urlResponse: response) + let itemIDs = entriesItemRefs.compactMap { $0.itemId } + + return try await retrieveItemIDs(type: type, url: callURL, dateInfo: dateInfo, itemIDs: itemIDs, continuation: entries?.continuation) } - func retrieveItemIDs(type: ItemIDType, url: URL, dateInfo: HTTPDateInfo?, itemIDs: [String], continuation: String?, completion: @escaping ((Result<[String], Error>) -> Void)) { - guard let continuation = continuation else { + func retrieveItemIDs(type: ItemIDType, url: URL, dateInfo: HTTPDateInfo?, itemIDs: [String], continuation: String?) async throws -> [String] { + + guard let continuation else { if type == .allForAccount { self.accountMetadata?.lastArticleFetchStartTime = dateInfo?.date self.accountMetadata?.lastArticleFetchEndTime = Date() } - completion(.success(itemIDs)) - return + return itemIDs } guard var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - completion(.failure(ReaderAPIAccountDelegateError.invalidParameter)) - return + throw ReaderAPIAccountDelegateError.invalidParameter } var queryItems = urlComponents.queryItems!.filter({ $0.name != "c" }) @@ -639,45 +489,43 @@ enum CreateReaderAPISubscriptionResult { urlComponents.queryItems = queryItems guard let callURL = urlComponents.url else { - completion(.failure(TransportError.noURL)) - return + throw TransportError.noURL } var request: URLRequest = URLRequest(url: callURL, credentials: credentials) addVariantHeaders(&request) - self.transport.send(request: request, resultType: ReaderAPIReferenceWrapper.self) { result in - switch result { - case .success(let (_, entries)): - guard let entriesItemRefs = entries?.itemRefs, entriesItemRefs.count > 0 else { - self.retrieveItemIDs(type: type, url: callURL, dateInfo: dateInfo, itemIDs: itemIDs, continuation: entries?.continuation, completion: completion) - return - } - var totalItemIDs = itemIDs - totalItemIDs.append(contentsOf: entriesItemRefs.compactMap { $0.itemId }) - self.retrieveItemIDs(type: type, url: callURL, dateInfo: dateInfo, itemIDs: totalItemIDs, continuation: entries?.continuation, completion: completion) - case .failure(let error): - completion(.failure(error)) - } + let (_, entries) = try await self.transport.send(request: request, resultType: ReaderAPIReferenceWrapper.self) + + guard let entriesItemRefs = entries?.itemRefs, entriesItemRefs.count > 0 else { + return try await retrieveItemIDs(type: type, url: callURL, dateInfo: dateInfo, itemIDs: itemIDs, continuation: entries?.continuation) } + + var totalItemIDs = itemIDs + totalItemIDs.append(contentsOf: entriesItemRefs.compactMap { $0.itemId }) + + return try await retrieveItemIDs(type: type, url: callURL, dateInfo: dateInfo, itemIDs: totalItemIDs, continuation: entries?.continuation) } - func createUnreadEntries(entries: [String], completion: @escaping (Result) -> Void) { - updateStateToEntries(entries: entries, state: .read, add: false, completion: completion) + func createUnreadEntries(entries: [String]) async throws { + + try await updateStateToEntries(entries: entries, state: .read, add: false) } - func deleteUnreadEntries(entries: [String], completion: @escaping (Result) -> Void) { - updateStateToEntries(entries: entries, state: .read, add: true, completion: completion) - } - - func createStarredEntries(entries: [String], completion: @escaping (Result) -> Void) { - updateStateToEntries(entries: entries, state: .starred, add: true, completion: completion) - } - - func deleteStarredEntries(entries: [String], completion: @escaping (Result) -> Void) { - updateStateToEntries(entries: entries, state: .starred, add: false, completion: completion) - } + func deleteUnreadEntries(entries: [String]) async throws { + try await updateStateToEntries(entries: entries, state: .read, add: true) + } + + func createStarredEntries(entries: [String]) async throws { + + try await updateStateToEntries(entries: entries, state: .starred, add: true) + } + + func deleteStarredEntries(entries: [String]) async throws { + + try await updateStateToEntries(entries: entries, state: .starred, add: false) + } } // MARK: Private @@ -696,51 +544,35 @@ private extension ReaderAPICaller { } } - private func updateStateToEntries(entries: [String], state: ReaderState, add: Bool, completion: @escaping (Result) -> Void) { + private func updateStateToEntries(entries: [String], state: ReaderState, add: Bool) async throws { + guard let baseURL = apiBaseURL else { - completion(.failure(CredentialsError.incompleteCredentials)) - return + throw CredentialsError.incompleteCredentials } - self.requestAuthorizationToken(endpoint: baseURL) { (result) in - switch result { - case .success(let token): - // Do POST asking for data about all the new articles - var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.editTag.rawValue), credentials: self.credentials) - self.addVariantHeaders(&request) - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.httpMethod = "POST" - - // Get ids from above into hex representation of value - let idsToFetch = entries.compactMap({ idValue -> String? in - if self.variant == .theOldReader { - return "i=tag:google.com,2005:reader/item/\(idValue)" - } else { - guard let intValue = Int(idValue) else { return nil } - let idHexString = String(format: "%.16llx", intValue) - return "i=tag:google.com,2005:reader/item/\(idHexString)" - } - }).joined(separator:"&") - - let actionIndicator = add ? "a" : "r" - - let postData = "T=\(token)&\(idsToFetch)&\(actionIndicator)=\(state.rawValue)".data(using: String.Encoding.utf8) - - self.transport.send(request: request, method: HTTPMethod.post, payload: postData!, completion: { (result) in - switch result { - case .success: - completion(.success(())) - case .failure(let error): - completion(.failure(error)) - } - }) - - - case .failure(let error): - completion(.failure(error)) - } - } - } - + let token = try await requestAuthorizationToken(endpoint: baseURL) + // Do POST asking for data about all the new articles + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.editTag.rawValue), credentials: self.credentials) + self.addVariantHeaders(&request) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + + // Get ids from above into hex representation of value + let idsToFetch = entries.compactMap({ idValue -> String? in + if self.variant == .theOldReader { + return "i=tag:google.com,2005:reader/item/\(idValue)" + } else { + guard let intValue = Int(idValue) else { return nil } + let idHexString = String(format: "%.16llx", intValue) + return "i=tag:google.com,2005:reader/item/\(idHexString)" + } + }).joined(separator:"&") + + let actionIndicator = add ? "a" : "r" + + let postData = "T=\(token)&\(idsToFetch)&\(actionIndicator)=\(state.rawValue)".data(using: String.Encoding.utf8) + + try await transport.send(request: request, method: HTTPMethod.post, payload: postData!) + } }