From fbfb00cd05f667c7d9c7ce56cbceacc0cd6f6572 Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Mon, 15 Apr 2024 21:02:35 -0700 Subject: [PATCH] Convert functions in CloudKitAccountDelegate to async await. --- Account/Sources/Account/Account.swift | 1 - .../CloudKitAccountDelegate.swift | 909 ++++++------------ .../CloudKit/CloudKitAccountZone.swift | 288 +++--- .../CloudKit/CloudKitArticlesZone.swift | 6 +- Web/Sources/Web/DownloadProgress.swift | 4 + 5 files changed, 432 insertions(+), 776 deletions(-) diff --git a/Account/Sources/Account/Account.swift b/Account/Sources/Account/Account.swift index 7a8c01698..82ccf2b5d 100644 --- a/Account/Sources/Account/Account.swift +++ b/Account/Sources/Account/Account.swift @@ -704,7 +704,6 @@ public enum FetchType { case .searchWithArticleIDs(let searchString, let articleIDs): return try await articlesMatching(searchString: searchString, articleIDs: articleIDs) } - } @MainActor public func articles(feed: Feed) async throws -> Set
{ diff --git a/Account/Sources/Account/AccountDelegates/CloudKitAccountDelegate.swift b/Account/Sources/Account/AccountDelegates/CloudKitAccountDelegate.swift index 77a15e015..969730589 100644 --- a/Account/Sources/Account/AccountDelegates/CloudKitAccountDelegate.swift +++ b/Account/Sources/Account/AccountDelegates/CloudKitAccountDelegate.swift @@ -96,17 +96,7 @@ enum CloudKitAccountDelegateError: LocalizedError { guard refreshProgress.isComplete, Reachability.internetIsReachable else { return } - - try await withCheckedThrowingContinuation { continuation in - self.standardRefreshAll(for: account) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } + try await standardRefreshAll(for: account) } func syncArticleStatus(for account: Account) async throws { @@ -200,400 +190,207 @@ enum CloudKitAccountDelegateError: LocalizedError { try await standardRefreshAll(for: account) } - func createFeed(for account: Account, url: String, name: String?, container: Container, validateFeed: Bool) async throws -> Feed { + @discardableResult + func createFeed(for account: Account, url urlString: 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 urlString: String, name: String?, container: Container, validateFeed: Bool, completion: @escaping (Result) -> Void) { guard let url = URL(string: urlString) else { - completion(.failure(LocalAccountDelegateError.invalidParameter)) - return + throw LocalAccountDelegateError.invalidParameter } let editedName = name == nil || name!.isEmpty ? nil : name - createRSSFeed(for: account, url: url, editedName: editedName, container: container, validateFeed: validateFeed, completion: completion) + return try await createRSSFeed(for: account, url: url, editedName: editedName, container: container, validateFeed: validateFeed) } 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) { - let editedName = name.isEmpty ? nil : name refreshProgress.addToNumberOfTasksAndRemaining(1) - accountZone.renameFeed(feed, editedName: editedName) { result in - self.refreshProgress.completeTask() - switch result { - case .success: - feed.editedName = name - completion(.success(())) - case .failure(let error): - self.processAccountError(account, error) - completion(.failure(error)) - } + defer { refreshProgress.completeTask() } + + let editedName = name.isEmpty ? nil : name + + do { + try await accountZone.renameFeed(feed, editedName: editedName) + feed.editedName = name + } catch { + processAccountError(account, error) + throw error } } - func removeFeed(for account: Account, with feed: Feed, from container: any Container) async throws { + func removeFeed(for account: Account, with feed: Feed, from container: Container) async throws { - try await withCheckedThrowingContinuation { continuation in + do { + try await removeFeedFromCloud(for: account, with: feed, from: container) + account.clearFeedMetadata(feed) + container.removeFeed(feed) - self.removeFeed(for: account, with: feed, from: container) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } + } catch { - private func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result) -> Void) { - removeFeedFromCloud(for: account, with: feed, from: container) { result in - switch result { - case .success: + switch error { + case CloudKitZoneError.corruptAccount: + // We got into a bad state and should remove the feed to clear up the bad data account.clearFeedMetadata(feed) container.removeFeed(feed) - completion(.success(())) - case .failure(let error): - switch error { - case CloudKitZoneError.corruptAccount: - // We got into a bad state and should remove the feed to clear up the bad data - account.clearFeedMetadata(feed) - container.removeFeed(feed) - default: - completion(.failure(error)) - } + default: + throw error } } } - 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 fromContainer: Container, to toContainer: Container, completion: @escaping (Result) -> Void) { refreshProgress.addToNumberOfTasksAndRemaining(1) - accountZone.moveFeed(feed, from: fromContainer, to: toContainer) { result in - self.refreshProgress.completeTask() - switch result { - case .success: - fromContainer.removeFeed(feed) - toContainer.addFeed(feed) - completion(.success(())) - case .failure(let error): - self.processAccountError(account, error) - completion(.failure(error)) - } + defer { refreshProgress.completeTask() } + + do { + try await accountZone.moveFeed(feed, from: sourceContainer, to: destinationContainer) + sourceContainer.removeFeed(feed) + destinationContainer.addFeed(feed) + } catch { + processAccountError(account, error) + 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) { refreshProgress.addToNumberOfTasksAndRemaining(1) - accountZone.addFeed(feed, to: container) { result in - self.refreshProgress.completeTask() - switch result { - case .success: - container.addFeed(feed) - completion(.success(())) - case .failure(let error): - self.processAccountError(account, error) - completion(.failure(error)) - } + defer { refreshProgress.completeTask() } + + do { + try await accountZone.addFeed(feed, to: container) + container.addFeed(feed) + } catch { + processAccountError(account, error) + throw error } } - + func restoreFeed(for account: Account, feed: Feed, container: any Container) async throws { - try await withCheckedThrowingContinuation { continuation in - - self.createFeed(for: account, url: feed.url, name: feed.editedName, container: container, validateFeed: true) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } + try await createFeed(for: account, url: feed.url, name: feed.editedName, container: container, validateFeed: true) } func createFolder(for account: Account, name: String) async throws -> Folder { - try await withCheckedThrowingContinuation { continuation in - - self.createFolder(for: account, name: name) { result in - switch result { - case .success(let folder): - continuation.resume(returning: folder) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - private func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { refreshProgress.addToNumberOfTasksAndRemaining(1) - accountZone.createFolder(name: name) { result in - self.refreshProgress.completeTask() - switch result { - case .success(let externalID): - if let folder = account.ensureFolder(with: name) { - folder.externalID = externalID - completion(.success(folder)) - } else { - completion(.failure(FeedbinAccountDelegateError.invalidParameter)) - } - case .failure(let error): - self.processAccountError(account, error) - completion(.failure(error)) - } + defer { refreshProgress.completeTask() } + + var externalID: String! + + do { + externalID = try await accountZone.createFolder(name: name) + } catch { + processAccountError(account, error) + throw error + } + + if let folder = account.ensureFolder(with: name) { + folder.externalID = externalID + return folder + } else { + throw CloudKitAccountDelegateError.invalidParameter } } - + 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) - accountZone.renameFolder(folder, to: name) { result in - self.refreshProgress.completeTask() - switch result { - case .success: - folder.name = name - completion(.success(())) - case .failure(let error): - self.processAccountError(account, error) - completion(.failure(error)) - } + defer { refreshProgress.completeTask() } + + do { + try await accountZone.renameFolder(folder, to: name) + folder.name = name + } catch { + processAccountError(account, error) + throw error } } func removeFolder(for account: Account, with folder: Folder) async throws { - try await withCheckedThrowingContinuation { continuation in + refreshProgress.addToNumberOfTasksAndRemaining(1) + defer { refreshProgress.completeTask() } - self.removeFolder(for: account, with: folder) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) + do { + let feedExternalIDs = try await accountZone.findFeedExternalIDs(for: folder) + + let feeds = feedExternalIDs.compactMap { account.existingFeed(withExternalID: $0) } + var errorOccurred = false + refreshProgress.addToNumberOfTasksAndRemaining(feeds.count) + + for feed in feeds { + do { + try await removeFeedFromCloud(for: account, with: feed, from: folder) + } catch { + os_log(.error, log: self.log, "Remove folder, remove feed error: %@.", error.localizedDescription) + errorOccurred = true } + refreshProgress.completeTask() } + + if errorOccurred { + throw CloudKitAccountDelegateError.unknown + } + + try await accountZone.removeFolder(folder) + account.removeFolder(folder: folder) + + } catch { + processAccountError(account, error) + throw error } } - private func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) { - - refreshProgress.addToNumberOfTasksAndRemaining(2) - accountZone.findFeedExternalIDs(for: folder) { result in - self.refreshProgress.completeTask() - switch result { - case .success(let feedExternalIDs): - - let feeds = feedExternalIDs.compactMap { account.existingFeed(withExternalID: $0) } - let group = DispatchGroup() - var errorOccurred = false - - for feed in feeds { - group.enter() - self.removeFeedFromCloud(for: account, with: feed, from: folder) { result in - group.leave() - if case .failure(let error) = result { - os_log(.error, log: self.log, "Remove folder, remove feed error: %@.", error.localizedDescription) - errorOccurred = true - } - } - } - - group.notify(queue: DispatchQueue.global(qos: .background)) { - DispatchQueue.main.async { - guard !errorOccurred else { - self.refreshProgress.completeTask() - completion(.failure(CloudKitAccountDelegateError.unknown)) - return - } - - self.accountZone.removeFolder(folder) { result in - self.refreshProgress.completeTask() - switch result { - case .success: - account.removeFolder(folder: folder) - completion(.success(())) - case .failure(let error): - completion(.failure(error)) - } - } - } - } - - case .failure(let error): - self.refreshProgress.completeTask() - self.refreshProgress.completeTask() - self.processAccountError(account, error) - completion(.failure(error)) - } - } - - } - 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) { guard let name = folder.name else { - completion(.failure(LocalAccountDelegateError.invalidParameter)) - return + throw CloudKitAccountDelegateError.invalidParameter } - + let feedsToRestore = folder.topLevelFeeds refreshProgress.addToNumberOfTasksAndRemaining(1 + feedsToRestore.count) - - accountZone.createFolder(name: name) { result in - self.refreshProgress.completeTask() - switch result { - case .success(let externalID): - folder.externalID = externalID - account.addFolder(folder) - - let group = DispatchGroup() - for feed in feedsToRestore { - - folder.topLevelFeeds.remove(feed) - group.enter() + do { + let externalID = try await accountZone.createFolder(name: name) - Task { @MainActor in - do { - try await self.restoreFeed(for: account, feed: feed, container: folder) - } catch { - os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription) - } - group.leave() - } + folder.externalID = externalID + account.addFolder(folder) + + for feed in feedsToRestore { + + folder.topLevelFeeds.remove(feed) + + do { + try await self.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(())) - } - - case .failure(let error): - self.processAccountError(account, error) - completion(.failure(error)) + + refreshProgress.completeTask() } + + account.addFolder(folder) + refreshProgress.completeTask() + + } catch { + processAccountError(account, error) + throw error } } 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 + 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) { - - 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, showProgress: false) { _ in } - } - - completion(.success(())) - - } catch { - completion(.failure(error)) - } + try? await database.insertStatuses(syncStatuses) + if let count = try? await self.database.selectPendingCount(), count > 100 { + try await sendArticleStatus(for: account, showProgress: false) } } @@ -609,17 +406,20 @@ enum CloudKitAccountDelegateError: LocalizedError { // Check to see if this is a new account and initialize anything we need if account.externalID == nil { - accountZone.findOrCreateAccount() { result in - switch result { - case .success(let externalID): + + Task { + + do { + let externalID = try await accountZone.findOrCreateAccount() account.externalID = externalID - self.initialRefreshAll(for: account) { _ in } - case .failure(let error): + try await self.initialRefreshAll(for: account) + } catch { os_log(.error, log: self.log, "Error adding account container: %@", error.localizedDescription) } + + accountZone.subscribeToZoneChanges() + articlesZone.subscribeToZoneChanges() } - accountZone.subscribeToZoneChanges() - articlesZone.subscribeToZoneChanges() } } @@ -659,225 +459,141 @@ enum CloudKitAccountDelegateError: LocalizedError { private extension CloudKitAccountDelegate { - func initialRefreshAll(for account: Account, completion: @escaping (Result) -> Void) { - - func fail(_ error: Error) { - self.processAccountError(account, error) - self.refreshProgress.clear() - completion(.failure(error)) - } - - refreshProgress.addToNumberOfTasksAndRemaining(3) - accountZone.fetchChangesInZone() { result in - self.refreshProgress.completeTask() + func initialRefreshAll(for account: Account) async throws { + + refreshProgress.addTask() + defer { refreshProgress.completeTask() } + + do { + try await accountZone.fetchChangesInZone() let feeds = account.flattenedFeeds() - self.refreshProgress.addToNumberOfTasksAndRemaining(feeds.count) + refreshProgress.addToNumberOfTasksAndRemaining(feeds.count) - switch result { - case .success: - self.refreshArticleStatus(for: account) { result in - self.refreshProgress.completeTask() - switch result { - case .success: - - self.combinedRefresh(account, feeds) { result in - self.refreshProgress.clear() - switch result { - case .success: - account.metadata.lastArticleFetchEndTime = Date() - case .failure(let error): - fail(error) - } - } + try await refreshArticleStatus(for: account) - case .failure(let error): - fail(error) - } - } - case .failure(let error): - fail(error) - } + try await combinedRefresh(account, feeds) + account.metadata.lastArticleFetchEndTime = Date() + } catch { + processAccountError(account, error) + refreshProgress.clear() + throw error } - } func standardRefreshAll(for account: Account) async throws { - try await withCheckedThrowingContinuation { continuation in - self.standardRefreshAll(for: account) { result in - switch result { - case .success: - continuation.resume() - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - func standardRefreshAll(for account: Account, completion: @escaping (Result) -> Void) { - let intialFeedsCount = account.flattenedFeeds().count refreshProgress.addToNumberOfTasksAndRemaining(3 + intialFeedsCount) - func fail(_ error: Error) { - self.processAccountError(account, error) - self.refreshProgress.clear() - completion(.failure(error)) - } - - accountZone.fetchChangesInZone() { result in - switch result { - case .success: - - self.refreshProgress.completeTask() - let feeds = account.flattenedFeeds() - self.refreshProgress.addToNumberOfTasksAndRemaining(feeds.count - intialFeedsCount) - - self.refreshArticleStatus(for: account) { result in - switch result { - case .success: - self.refreshProgress.completeTask() - self.combinedRefresh(account, feeds) { result in - self.sendArticleStatus(for: account, showProgress: true) { _ in - self.refreshProgress.clear() - if case .failure(let error) = result { - fail(error) - } else { - account.metadata.lastArticleFetchEndTime = Date() - completion(.success(())) - } - } - } - case .failure(let error): - fail(error) - } - } + do { - case .failure(let error): - fail(error) - } - } - - } + try await accountZone.fetchChangesInZone() + refreshProgress.completeTask() - func combinedRefresh(_ account: Account, _ feeds: Set, completion: @escaping (Result) -> Void) { - - Task { @MainActor in - await self.refresher.refreshFeeds(feeds) - completion(.success(())) + let feeds = account.flattenedFeeds() + refreshProgress.addToNumberOfTasksAndRemaining(feeds.count - intialFeedsCount) + + try await refreshArticleStatus(for: account) + refreshProgress.completeTask() + + try await combinedRefresh(account, feeds) + try await sendArticleStatus(for: account, showProgress: true) + + account.metadata.lastArticleFetchEndTime = Date() + + refreshProgress.clear() + + } catch { + refreshProgress.completeTask() + processAccountError(account, error) + refreshProgress.clear() + throw error } } - - func createRSSFeed(for account: Account, url: URL, editedName: String?, container: Container, validateFeed: Bool, completion: @escaping (Result) -> Void) { - func addDeadFeed() { - let feed = account.createFeed(with: editedName, url: url.absoluteString, feedID: url.absoluteString, homePageURL: nil) + func combinedRefresh(_ account: Account, _ feeds: Set) async throws { + + await refresher.refreshFeeds(feeds) + } + + func createRSSFeed(for account: Account, url: URL, editedName: String?, container: Container, validateFeed: Bool) async throws -> Feed { + + func addDeadFeed() async throws -> Feed { + + let feed = account.createFeed(with: editedName, + url: url.absoluteString, + feedID: url.absoluteString, + homePageURL: nil) container.addFeed(feed) - self.accountZone.createFeed(url: url.absoluteString, - name: editedName, - editedName: nil, - homePageURL: nil, - container: container) { result in - - self.refreshProgress.completeTask() - switch result { - case .success(let externalID): - feed.externalID = externalID - completion(.success(feed)) - case .failure(let error): - container.removeFeed(feed) - completion(.failure(error)) - } + do { + let externalID = try await accountZone.createFeed(url: url.absoluteString, + name: editedName, + editedName: nil, homePageURL: nil, + container: container) + feed.externalID = externalID + return feed + } catch { + container.removeFeed(feed) + throw error } } - refreshProgress.addToNumberOfTasksAndRemaining(5) - FeedFinder.find(url: url) { result in - - MainActor.assumeIsolated { - self.refreshProgress.completeTask() - switch result { - case .success(let feedSpecifiers): - guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), let url = URL(string: bestFeedSpecifier.urlString) else { - self.refreshProgress.completeTasks(3) - if validateFeed { - self.refreshProgress.completeTask() - completion(.failure(AccountError.createErrorNotFound)) - } else { - addDeadFeed() - } - return - } + refreshProgress.addTask() + defer { refreshProgress.completeTask() } - if account.hasFeed(withURL: bestFeedSpecifier.urlString) { - self.refreshProgress.completeTasks(4) - completion(.failure(AccountError.createErrorAlreadySubscribed)) - return - } + var feedSpecifiers: Set? - let feed = account.createFeed(with: nil, url: url.absoluteString, feedID: url.absoluteString, homePageURL: nil) - feed.editedName = editedName - container.addFeed(feed) - - InitialFeedDownloader.download(url) { parsedFeed in - MainActor.assumeIsolated { - self.refreshProgress.completeTask() - - if let parsedFeed { - - Task { @MainActor in - - do { - try await account.update(feed: feed, with: parsedFeed) - - self.accountZone.createFeed(url: bestFeedSpecifier.urlString, - name: parsedFeed.title, - editedName: editedName, - homePageURL: parsedFeed.homePageURL, - container: container) { result in - - self.refreshProgress.completeTask() - switch result { - case .success(let externalID): - feed.externalID = externalID - self.sendNewArticlesToTheCloud(account, feed) - completion(.success(feed)) - case .failure(let error): - container.removeFeed(feed) - self.refreshProgress.completeTasks(2) - completion(.failure(error)) - } - - } - } catch { - container.removeFeed(feed) - self.refreshProgress.completeTasks(3) - completion(.failure(error)) - } - } - } else { - self.refreshProgress.completeTasks(3) - container.removeFeed(feed) - completion(.failure(AccountError.createErrorNotFound)) - } - } - } - - case .failure: - self.refreshProgress.completeTasks(3) - if validateFeed { - self.refreshProgress.completeTask() - completion(.failure(AccountError.createErrorNotFound)) - return - } else { - addDeadFeed() - } - } + do { + feedSpecifiers = try await FeedFinder.find(url: url) + } catch { + if validateFeed { + throw AccountError.createErrorNotFound + } else { + return try await addDeadFeed() } } + + guard let feedSpecifiers, let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), let url = URL(string: bestFeedSpecifier.urlString) else { + if validateFeed { + throw AccountError.createErrorNotFound + } else { + return try await addDeadFeed() + } + } + + if account.hasFeed(withURL: bestFeedSpecifier.urlString) { + throw AccountError.createErrorAlreadySubscribed + } + + let feed = account.createFeed(with: nil, url: url.absoluteString, feedID: url.absoluteString, homePageURL: nil) + feed.editedName = editedName + container.addFeed(feed) + + guard let parsedFeed = await InitialFeedDownloader.download(url) else { + container.removeFeed(feed) + throw AccountError.createErrorNotFound + } + + do { + try await account.update(feed: feed, with: parsedFeed) + + let externalID = try await self.accountZone.createFeed(url: bestFeedSpecifier.urlString, + name: parsedFeed.title, + editedName: editedName, + homePageURL: parsedFeed.homePageURL, + container: container) + + feed.externalID = externalID + sendNewArticlesToTheCloud(account, feed) + + return feed + + } catch { + container.removeFeed(feed) + throw error + } } func sendNewArticlesToTheCloud(_ account: Account, _ feed: Feed) { @@ -886,17 +602,12 @@ private extension CloudKitAccountDelegate { do { let articles = try await account.articles(for: .feed(feed)) - self.storeArticleChanges(new: articles, updated: Set
(), deleted: Set
()) { - self.refreshProgress.completeTask() - self.sendArticleStatus(for: account, showProgress: true) { result in - switch result { - case .success: - self.articlesZone.fetchChangesInZone() { _ in } - case .failure(let error): - os_log(.error, log: self.log, "CloudKit Feed send articles error: %@.", error.localizedDescription) - } - } - } + + await self.storeArticleChanges(new: articles, updated: Set
(), deleted: Set
()) + self.refreshProgress.completeTask() + + try await self.sendArticleStatus(for: account, showProgress: true) + try await self.articlesZone.fetchChangesInZone() } catch { os_log(.error, log: self.log, "CloudKit Feed send articles error: %@.", error.localizedDescription) @@ -913,51 +624,47 @@ private extension CloudKitAccountDelegate { } } - func storeArticleChanges(new: Set
?, updated: Set
?, deleted: Set
?, completion: @escaping () -> Void) { + func storeArticleChanges(new: Set
?, updated: Set
?, deleted: Set
?) async { + // New records with a read status aren't really new, they just didn't have the read article stored - let group = DispatchGroup() - if let new = new { + if let new { let filteredNew = new.filter { $0.status.read == false } - group.enter() - insertSyncStatuses(articles: filteredNew, statusKey: .new, flag: true) { - group.leave() - } + await insertSyncStatuses(articles: filteredNew, statusKey: .new, flag: true) } - group.enter() - insertSyncStatuses(articles: updated, statusKey: .new, flag: false) { - group.leave() - } - - group.enter() - insertSyncStatuses(articles: deleted, statusKey: .deleted, flag: true) { - group.leave() - } - - group.notify(queue: DispatchQueue.global(qos: .userInitiated)) { - DispatchQueue.main.async { - completion() - } - } + await insertSyncStatuses(articles: updated, statusKey: .new, flag: false) + await insertSyncStatuses(articles: deleted, statusKey: .deleted, flag: true) } - func insertSyncStatuses(articles: Set
?, statusKey: SyncStatus.Key, flag: Bool, completion: @escaping () -> Void) { - guard let articles = articles, !articles.isEmpty else { - completion() + func insertSyncStatuses(articles: Set
?, statusKey: SyncStatus.Key, flag: Bool) async { + + guard let articles, !articles.isEmpty else { return } + let syncStatuses = articles.map { article in return SyncStatus(articleID: article.articleID, key: statusKey, flag: flag) } - Task { @MainActor in + try? await database.insertStatuses(syncStatuses) + } - try? await self.database.insertStatuses(syncStatuses) - completion() + func sendArticleStatus(for account: Account, showProgress: Bool) async throws { + + try await withCheckedThrowingContinuation { continuation in + + self.sendArticleStatus(for: account, showProgress: showProgress) { result in + switch result { + case .success: + continuation.resume() + case .failure(let error): + continuation.resume(throwing: error) + } + } } } - func sendArticleStatus(for account: Account, showProgress: Bool, completion: @escaping ((Result) -> Void)) { + private func sendArticleStatus(for account: Account, showProgress: Bool, completion: @escaping ((Result) -> Void)) { let op = CloudKitSendStatusOperation(account: account, articlesZone: articlesZone, refreshProgress: refreshProgress, @@ -978,43 +685,41 @@ private extension CloudKitAccountDelegate { } - func removeFeedFromCloud(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result) -> Void) { - refreshProgress.addToNumberOfTasksAndRemaining(2) - accountZone.removeFeed(feed, from: container) { result in - self.refreshProgress.completeTask() - switch result { - case .success: - guard let feedExternalID = feed.externalID else { - completion(.success(())) - return - } - self.articlesZone.deleteArticles(feedExternalID) { result in - feed.dropConditionalGetInfo() - self.refreshProgress.completeTask() - completion(result) - } - case .failure(let error): - self.refreshProgress.completeTask() - self.processAccountError(account, error) - completion(.failure(error)) + func removeFeedFromCloud(for account: Account, with feed: Feed, from container: Container) async throws { + + refreshProgress.addToNumberOfTasksAndRemaining(1) + defer { refreshProgress.completeTask() } + + do { + try await accountZone.removeFeed(feed, from: container) + + guard let feedExternalID = feed.externalID else { + return } + + try await articlesZone.deleteArticles(feedExternalID) + feed.dropConditionalGetInfo() + + } catch { + self.processAccountError(account, error) + throw error } } - } extension CloudKitAccountDelegate: LocalAccountRefresherDelegate { - + func localAccountRefresher(_ refresher: LocalAccountRefresher, requestCompletedFor: Feed) { + refreshProgress.completeTask() } - - func localAccountRefresher(_ refresher: LocalAccountRefresher, articleChanges: ArticleChanges, completion: @escaping () -> Void) { - self.storeArticleChanges(new: articleChanges.newArticles, - updated: articleChanges.updatedArticles, - deleted: articleChanges.deletedArticles, - completion: completion) - } - -} + func localAccountRefresher(_ refresher: LocalAccountRefresher, articleChanges: ArticleChanges, completion: @escaping () -> Void) { + + Task { @MainActor in + await storeArticleChanges(new: articleChanges.newArticles, + updated: articleChanges.updatedArticles, + deleted: articleChanges.deletedArticles) + } + } +} diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift b/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift index f1aea3edb..33a6828b7 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift @@ -94,7 +94,8 @@ enum CloudKitAccountZoneError: LocalizedError { } /// Persist a web feed record to iCloud and return the external key - func createFeed(url: String, name: String?, editedName: String?, homePageURL: String?, container: Container, completion: @escaping (Result) -> Void) { + func createFeed(url: String, name: String?, editedName: String?, homePageURL: String?, container: Container) async throws -> String { + let recordID = CKRecord.ID(recordName: url.md5String, zoneID: zoneID) let record = CKRecord(recordType: CloudKitFeed.recordType, recordID: recordID) record[CloudKitFeed.Fields.url] = url @@ -107,260 +108,205 @@ enum CloudKitAccountZoneError: LocalizedError { } guard let containerExternalID = container.externalID else { - completion(.failure(CloudKitZoneError.corruptAccount)) - return + throw CloudKitZoneError.corruptAccount } record[CloudKitFeed.Fields.containerExternalIDs] = [containerExternalID] - save(record) { result in - switch result { - case .success: - completion(.success(record.externalID)) - case .failure(let error): - completion(.failure(error)) - } - } + try await save(record) + return record.externalID } /// Rename the given web feed - func renameFeed(_ feed: Feed, editedName: String?, completion: @escaping (Result) -> Void) { + func renameFeed(_ feed: Feed, editedName: String?) async throws { + guard let externalID = feed.externalID else { - completion(.failure(CloudKitZoneError.corruptAccount)) - return + throw CloudKitZoneError.corruptAccount } let recordID = CKRecord.ID(recordName: externalID, zoneID: zoneID) let record = CKRecord(recordType: CloudKitFeed.recordType, recordID: recordID) record[CloudKitFeed.Fields.editedName] = editedName - save(record) { result in - switch result { - case .success: - completion(.success(())) - case .failure(let error): - completion(.failure(error)) - } - } + try await save(record) } - /// Removes a web feed from a container and optionally deletes it, calling the completion with true if deleted - func removeFeed(_ feed: Feed, from: Container, completion: @escaping (Result) -> Void) { + /// Removes a web feed from a container and optionally deletes it, returning true if deleted + @discardableResult + func removeFeed(_ feed: Feed, from: Container) async throws -> Bool { + guard let fromContainerExternalID = from.externalID else { - completion(.failure(CloudKitZoneError.corruptAccount)) - return + throw CloudKitZoneError.corruptAccount } - - fetch(externalID: feed.externalID) { result in - switch result { - case .success(let record): - - if let containerExternalIDs = record[CloudKitFeed.Fields.containerExternalIDs] as? [String] { - var containerExternalIDSet = Set(containerExternalIDs) - containerExternalIDSet.remove(fromContainerExternalID) - - if containerExternalIDSet.isEmpty { - self.delete(externalID: feed.externalID) { result in - switch result { - case .success: - completion(.success(true)) - case .failure(let error): - completion(.failure(error)) - } - } - - } else { - - record[CloudKitFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet) - self.save(record) { result in - switch result { - case .success: - completion(.success(false)) - case .failure(let error): - completion(.failure(error)) - } - } - - } - } - - case .failure(let error): - if let ckError = ((error as? CloudKitError)?.error as? CKError), ckError.code == .unknownItem { - completion(.success(true)) + + do { + let record = try await fetch(externalID: feed.externalID) + + if let containerExternalIDs = record[CloudKitFeed.Fields.containerExternalIDs] as? [String] { + + var containerExternalIDSet = Set(containerExternalIDs) + containerExternalIDSet.remove(fromContainerExternalID) + + if containerExternalIDSet.isEmpty { + try await delete(externalID: feed.externalID) + return true } else { - completion(.failure(error)) + record[CloudKitFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet) + try await save(record) + return false } } + + return false + } catch { + if let ckError = ((error as? CloudKitError)?.error as? CKError), ckError.code == .unknownItem { + return true + } else { + throw error + } } } - func moveFeed(_ feed: Feed, from: Container, to: Container, completion: @escaping (Result) -> Void) { + func moveFeed(_ feed: Feed, from: Container, to: Container) async throws { + guard let fromContainerExternalID = from.externalID, let toContainerExternalID = to.externalID else { - completion(.failure(CloudKitZoneError.corruptAccount)) - return + throw CloudKitZoneError.corruptAccount } - - fetch(externalID: feed.externalID) { result in - switch result { - case .success(let record): - if let containerExternalIDs = record[CloudKitFeed.Fields.containerExternalIDs] as? [String] { - var containerExternalIDSet = Set(containerExternalIDs) - containerExternalIDSet.remove(fromContainerExternalID) - containerExternalIDSet.insert(toContainerExternalID) - record[CloudKitFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet) - self.save(record, completion: completion) - } - case .failure(let error): - completion(.failure(error)) - } + + let record = try await fetch(externalID: feed.externalID) + + if let containerExternalIDs = record[CloudKitFeed.Fields.containerExternalIDs] as? [String] { + var containerExternalIDSet = Set(containerExternalIDs) + containerExternalIDSet.remove(fromContainerExternalID) + containerExternalIDSet.insert(toContainerExternalID) + record[CloudKitFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet) + try await save(record) } } - - func addFeed(_ feed: Feed, to: Container, completion: @escaping (Result) -> Void) { + + func addFeed(_ feed: Feed, to: Container) async throws { + guard let toContainerExternalID = to.externalID else { - completion(.failure(CloudKitZoneError.corruptAccount)) - return + throw CloudKitZoneError.corruptAccount } - - fetch(externalID: feed.externalID) { result in - switch result { - case .success(let record): - if let containerExternalIDs = record[CloudKitFeed.Fields.containerExternalIDs] as? [String] { - var containerExternalIDSet = Set(containerExternalIDs) - containerExternalIDSet.insert(toContainerExternalID) - record[CloudKitFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet) - self.save(record, completion: completion) - } - case .failure(let error): - completion(.failure(error)) - } + + let record = try await fetch(externalID: feed.externalID) + + if let containerExternalIDs = record[CloudKitFeed.Fields.containerExternalIDs] as? [String] { + var containerExternalIDSet = Set(containerExternalIDs) + containerExternalIDSet.insert(toContainerExternalID) + record[CloudKitFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet) + try await save(record) } } - - func findFeedExternalIDs(for folder: Folder, completion: @escaping (Result<[String], Error>) -> Void) { + + func findFeedExternalIDs(for folder: Folder) async throws -> [String] { + guard let folderExternalID = folder.externalID else { - completion(.failure(CloudKitAccountZoneError.unknown)) - return + throw CloudKitAccountZoneError.unknown } - + let predicate = NSPredicate(format: "containerExternalIDs CONTAINS %@", folderExternalID) let ckQuery = CKQuery(recordType: CloudKitFeed.recordType, predicate: predicate) - - query(ckQuery) { result in - switch result { - case .success(let records): - let feedExternalIDs = records.map { $0.externalID } - completion(.success(feedExternalIDs)) - case .failure(let error): - completion(.failure(error)) - } - } + + let records = try await query(ckQuery) + + let feedExternalIDs = records.map { $0.externalID } + return feedExternalIDs } - - func findOrCreateAccount(completion: @escaping (Result) -> Void) { + + func findOrCreateAccount() async throws -> String { + + guard let database else { + throw CloudKitAccountZoneError.unknown + } + let predicate = NSPredicate(format: "isAccount = \"1\"") let ckQuery = CKQuery(recordType: CloudKitContainer.recordType, predicate: predicate) - - database?.perform(ckQuery, inZoneWith: zoneID) { [weak self] records, error in - guard let self = self else { return } - + + do { + let records = try await database.perform(ckQuery, inZoneWith: zoneID) + if records.count > 0 { + return records[0].externalID + } else { + return try await createContainer(name: "Account", isAccount: true) + } + + } catch { + switch CloudKitZoneResult.resolve(error) { - case .success: - DispatchQueue.main.async { - if records!.count > 0 { - completion(.success(records![0].externalID)) - } else { - self.createContainer(name: "Account", isAccount: true, completion: completion) - } - } + case .retry(let timeToWait): - self.retryIfPossible(after: timeToWait) { - self.findOrCreateAccount(completion: completion) - } + await delay(for: timeToWait) + return try await findOrCreateAccount() + case .zoneNotFound, .userDeletedZone: - self.createZoneRecord() { result in - switch result { - case .success: - MainActor.assumeIsolated { - self.findOrCreateAccount(completion: completion) - } - case .failure(let error): - DispatchQueue.main.async { - completion(.failure(CloudKitError(error))) - } - } - } + try await createZoneRecord() + return try await findOrCreateAccount() + default: - self.createContainer(name: "Account", isAccount: true, completion: completion) + return try await createContainer(name: "Account", isAccount: true) } } - } - func createFolder(name: String, completion: @escaping (Result) -> Void) { - createContainer(name: name, isAccount: false, completion: completion) + func createFolder(name: String) async throws -> String { + + return try await createContainer(name: name, isAccount: false) } - func renameFolder(_ folder: Folder, to name: String, completion: @escaping (Result) -> Void) { + func renameFolder(_ folder: Folder, to name: String) async throws { + guard let externalID = folder.externalID else { - completion(.failure(CloudKitZoneError.corruptAccount)) - return + throw CloudKitZoneError.corruptAccount } let recordID = CKRecord.ID(recordName: externalID, zoneID: zoneID) let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: recordID) record[CloudKitContainer.Fields.name] = name - - save(record) { result in - switch result { - case .success: - completion(.success(())) - case .failure(let error): - completion(.failure(error)) - } - } + + try await save(record) } - func removeFolder(_ folder: Folder, completion: @escaping (Result) -> Void) { - delete(externalID: folder.externalID, completion: completion) + func removeFolder(_ folder: Folder) async throws { + + try await delete(externalID: folder.externalID) } - } private extension CloudKitAccountZone { func newFeedCKRecord(feedSpecifier: RSOPMLFeedSpecifier, containerExternalID: String) -> CKRecord { + let record = CKRecord(recordType: CloudKitFeed.recordType, recordID: generateRecordID()) record[CloudKitFeed.Fields.url] = feedSpecifier.feedURL + if let editedName = feedSpecifier.title { record[CloudKitFeed.Fields.editedName] = editedName } if let homePageURL = feedSpecifier.homePageURL { record[CloudKitFeed.Fields.homePageURL] = homePageURL } + record[CloudKitFeed.Fields.containerExternalIDs] = [containerExternalID] + return record } func newContainerCKRecord(name: String) -> CKRecord { + let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: generateRecordID()) record[CloudKitContainer.Fields.name] = name record[CloudKitContainer.Fields.isAccount] = "0" return record } - func createContainer(name: String, isAccount: Bool, completion: @escaping (Result) -> Void) { + func createContainer(name: String, isAccount: Bool) async throws -> String { + let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: generateRecordID()) record[CloudKitContainer.Fields.name] = name record[CloudKitContainer.Fields.isAccount] = isAccount ? "1" : "0" - save(record) { result in - switch result { - case .success: - completion(.success(record.externalID)) - case .failure(let error): - completion(.failure(error)) - } - } + try await save(record) + return record.externalID } - } diff --git a/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift b/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift index 6a7808e33..beaa550e0 100644 --- a/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift +++ b/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift @@ -110,10 +110,12 @@ final class CloudKitArticlesZone: CloudKitZone { } } - func deleteArticles(_ feedExternalID: String, completion: @escaping ((Result) -> Void)) { + func deleteArticles(_ feedExternalID: String) async throws { + let predicate = NSPredicate(format: "webFeedExternalID = %@", feedExternalID) let ckQuery = CKQuery(recordType: CloudKitArticleStatus.recordType, predicate: predicate) - delete(ckQuery: ckQuery, completion: completion) + + try await delete(ckQuery: ckQuery) } @MainActor func modifyArticles(_ statusUpdates: [CloudKitArticleStatusUpdate], completion: @escaping ((Result) -> Void)) { diff --git a/Web/Sources/Web/DownloadProgress.swift b/Web/Sources/Web/DownloadProgress.swift index 758692566..21ea35db1 100755 --- a/Web/Sources/Web/DownloadProgress.swift +++ b/Web/Sources/Web/DownloadProgress.swift @@ -63,6 +63,10 @@ public extension Notification.Name { numberOfTasks = numberOfTasks + n } + public func addTask() { + addToNumberOfTasks(1) + } + public func addToNumberOfTasksAndRemaining(_ n: Int) { assert(Thread.isMainThread) numberOfTasks = numberOfTasks + n