From c8612d59d78dc89e4270bae2a133d3c5353fe83e Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 13 Nov 2022 23:42:42 -0600 Subject: [PATCH] Add a process to wipe out a zone and reload it from a local database --- Account/Sources/Account/Account.swift | 9 + Account/Sources/Account/AccountManager.swift | 16 ++ .../CloudKit/CloudKitAccountDelegate.swift | 154 +++++++++++++++++- .../CloudKitUploadArticlesOperation.swift | 71 ++++++++ .../ArticlesDatabase/ArticlesDatabase.swift | 13 +- .../ArticlesDatabase/ArticlesTable.swift | 21 ++- Mac/Preferences/Accounts/AccountsDetail.xib | 18 +- .../AccountsDetailViewController.swift | 26 +++ .../xcshareddata/swiftpm/Package.resolved | 8 +- 9 files changed, 314 insertions(+), 22 deletions(-) create mode 100644 Account/Sources/Account/CloudKit/CloudKitUploadArticlesOperation.swift diff --git a/Account/Sources/Account/Account.swift b/Account/Sources/Account/Account.swift index b3f7ccad8..7d61a67d0 100644 --- a/Account/Sources/Account/Account.swift +++ b/Account/Sources/Account/Account.swift @@ -419,6 +419,15 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, delegate.receiveRemoteNotification(for: self, userInfo: userInfo, completion: completion) } + public func wipeCloudKitArticlesZoneAndReload(completion: @escaping (Result) -> Void) { + guard let cloudKitAccountDelegate = delegate as? CloudKitAccountDelegate else { + completion(.success(())) + return + } + + cloudKitAccountDelegate.wipeArticlesZoneAndReload(for: self, completion: completion) + } + public func refreshAll(completion: @escaping (Result) -> Void) { delegate.refreshAll(for: self, completion: completion) } diff --git a/Account/Sources/Account/AccountManager.swift b/Account/Sources/Account/AccountManager.swift index 836cf0603..a7ab383ed 100644 --- a/Account/Sources/Account/AccountManager.swift +++ b/Account/Sources/Account/AccountManager.swift @@ -245,6 +245,22 @@ public final class AccountManager: UnreadCountProvider { } } + public func wipeCloudKitArticlesZoneAndReload(errorHandler: @escaping (Error) -> Void, completion: (() -> Void)? = nil) { + guard let cloudKitAccount = activeAccounts.first(where: { $0.type == .cloudKit }) else { + completion?() + return + } + + cloudKitAccount.wipeCloudKitArticlesZoneAndReload { result in + switch result { + case .success: + completion?() + case .failure(let error): + errorHandler(error) + } + } + } + public func refreshAll(errorHandler: @escaping (Error) -> Void, completion: (() -> Void)? = nil) { guard let reachability = try? Reachability(hostname: "apple.com"), reachability.connection != .unavailable else { return } diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift index 07079b5fe..c59979380 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift @@ -40,7 +40,7 @@ final class CloudKitAccountDelegate: AccountDelegate, Logging { private let mainThreadOperationQueue = MainThreadOperationQueue() - private lazy var refresher: LocalAccountRefresher = { + private lazy var standardRefresher: LocalAccountRefresher = { let refresher = LocalAccountRefresher() refresher.delegate = self return refresher @@ -73,6 +73,29 @@ final class CloudKitAccountDelegate: AccountDelegate, Logging { mainThreadOperationQueue.add(op) } + func wipeArticlesZoneAndReload(for account: Account, completion: @escaping (Result) -> Void) { + guard refreshProgress.isComplete else { + completion(.success(())) + return + } + + articlesZone.deleteZoneRecord { result in + switch result { + case .success: + self.articlesZone.createZoneRecord { result in + switch result { + case .success: + self.reloadArticlesZone(for: account, completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + case .failure(let error): + completion(.failure(error)) + } + } + } + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { guard refreshProgress.isComplete else { completion(.success(())) @@ -462,7 +485,7 @@ final class CloudKitAccountDelegate: AccountDelegate, Logging { // MARK: Suspend and Resume (for iOS) func suspendNetwork() { - refresher.suspend() + standardRefresher.suspend() } func suspendDatabase() { @@ -470,13 +493,65 @@ final class CloudKitAccountDelegate: AccountDelegate, Logging { } func resume() { - refresher.resume() + standardRefresher.resume() database.resume() } } +// MARK: Private + private extension CloudKitAccountDelegate { + func reloadArticlesZone(for account: Account, completion: @escaping (Result) -> Void) { + account.fetchArticlesAsync(.starred()) { result in + switch result { + case .success(let articles): + self.upload(articles: articles) { result in + switch result { + case .success: + account.fetchArticlesAsync(.unread()) { result in + switch result { + case .success(let articles): + self.upload(articles: articles) { result in + switch result { + case .success: + let allCloudKitFeeds = account.flattenedWebFeeds() + allCloudKitFeeds.forEach{ $0.dropConditionalGetInfo() } + + let reloadRefresher = LocalAccountRefresher() + reloadRefresher.delegate = ReloadAccountRefresherDelegate(self) + + self.combinedRefresh(account, allCloudKitFeeds, reloadRefresher, completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + case .failure(let error): + completion(.failure(error)) + } + } + case .failure(let error): + completion(.failure(error)) + } + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + func upload(articles: Set
, completion: @escaping (Result) -> Void) { + let op = CloudKitUploadArticlesOperation(articlesZone: articlesZone, articles: articles) + op.completionBlock = { mainThreadOperaion in + if let error = op.error { + completion(.failure(error)) + } else { + completion(.success(())) + } + } + mainThreadOperationQueue.add(op) + } + func initialRefreshAll(for account: Account, completion: @escaping (Result) -> Void) { func fail(_ error: Error) { @@ -501,7 +576,7 @@ private extension CloudKitAccountDelegate { switch result { case .success: - self.combinedRefresh(account, webFeeds) { result in + self.combinedRefresh(account, webFeeds, self.standardRefresher) { result in self.refreshProgress.clear() switch result { case .success: @@ -547,7 +622,7 @@ private extension CloudKitAccountDelegate { case .success: self.refreshProgress.completeTask() self.refreshProgress.isIndeterminate = false - self.combinedRefresh(account, webFeeds) { result in + self.combinedRefresh(account, webFeeds, self.standardRefresher) { result in self.sendArticleStatus(for: account, showProgress: true) { _ in self.refreshProgress.clear() if case .failure(let error) = result { @@ -570,7 +645,7 @@ private extension CloudKitAccountDelegate { } - func combinedRefresh(_ account: Account, _ webFeeds: Set, completion: @escaping (Result) -> Void) { + func combinedRefresh(_ account: Account, _ webFeeds: Set, _ refresher: LocalAccountRefresher, completion: @escaping (Result) -> Void) { var refresherWebFeeds = Set() let group = DispatchGroup() @@ -923,3 +998,70 @@ extension CloudKitAccountDelegate: LocalAccountRefresherDelegate { } +class ReloadAccountRefresherDelegate: LocalAccountRefresherDelegate, Logging { + + weak var cloudKitAccountDelegate: CloudKitAccountDelegate? + + init(_ cloudKitAccountDelegate: CloudKitAccountDelegate) { + self.cloudKitAccountDelegate = cloudKitAccountDelegate + } + + func localAccountRefresher(_ refresher: LocalAccountRefresher, requestCompletedFor: WebFeed) { + } + + func localAccountRefresher(_ refresher: LocalAccountRefresher, articleChanges: ArticleChanges, completion: @escaping () -> Void) { + let newArticleCount = articleChanges.newArticles?.count ?? 0 + let updatedArticleCount = articleChanges.updatedArticles?.count ?? 0 + let unchangedArticleCount = articleChanges.unchangedArticles?.count ?? 0 + let incomingArticleCount = articleChanges.incomingArticles?.count ?? 0 + let deletedArticleCount = articleChanges.deletedArticles?.count ?? 0 + + cloudKitAccountDelegate?.logger.debug( + """ + Uploading \(newArticleCount, privacy: .public) new articles, \(updatedArticleCount, privacy: .public) updated articles, \ + and \(unchangedArticleCount, privacy: .public) unchanged articles out of \(incomingArticleCount, privacy: .public) total articles \ + (\(deletedArticleCount, privacy: .public) were deleted and not uploaded.) + """ + ) + + let group = DispatchGroup() + + if let newArticles = articleChanges.newArticles { + group.enter() + cloudKitAccountDelegate?.upload(articles: newArticles) { result in + if case .failure(let error) = result { + self.logger.error("An error occurred uploading new articles: \(error.localizedDescription, privacy: .public)") + } + group.leave() + } + } + + if let updatedArticles = articleChanges.updatedArticles { + group.enter() + cloudKitAccountDelegate?.upload(articles: updatedArticles) { result in + if case .failure(let error) = result { + self.logger.error("An error occurred uploading updated articles: \(error.localizedDescription, privacy: .public)") + } + group.leave() + } + } + + if let unchangedArticles = articleChanges.unchangedArticles { + // If the article is unchanged and unread, then we've already uploaded it + let unchangedReadArticles = unchangedArticles.filter { $0.status.read } + + group.enter() + cloudKitAccountDelegate?.upload(articles: unchangedReadArticles) { result in + if case .failure(let error) = result { + self.logger.error("An error occurred uploading already read articles: \(error.localizedDescription, privacy: .public)") + } + group.leave() + } + } + + group.notify(queue: .main) { + completion() + } + } + +} diff --git a/Account/Sources/Account/CloudKit/CloudKitUploadArticlesOperation.swift b/Account/Sources/Account/CloudKit/CloudKitUploadArticlesOperation.swift new file mode 100644 index 000000000..6c8bde3f7 --- /dev/null +++ b/Account/Sources/Account/CloudKit/CloudKitUploadArticlesOperation.swift @@ -0,0 +1,71 @@ +// +// CloudKitUploadArticlesOperation.swift +// +// +// Created by Maurice Parker on 11/13/22. +// + +import Foundation +import RSCore +import Articles +import SyncDatabase + +class CloudKitUploadArticlesOperation: MainThreadOperation, Logging { + + // MainThreadOperation + public var isCanceled = false + public var id: Int? + public weak var operationDelegate: MainThreadOperationDelegate? + public var name: String? = "CloudKitUploadArticlesOperation" + public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock? + + private weak var articlesZone: CloudKitArticlesZone? + private let articles: Set
+ + public var error: Error? + + init(articlesZone: CloudKitArticlesZone, articles: Set
) { + self.articlesZone = articlesZone + self.articles = articles + } + + func run() { + guard let articlesZone = articlesZone else { + self.operationDelegate?.operationDidComplete(self) + return + } + + logger.debug("Uploading \(self.articles.count, privacy: .public) articles...") + + let statusUpdates = articles.compactMap { article in + return CloudKitArticleStatusUpdate(articleID: article.articleID, statuses: [SyncStatus(article: article)], article: article) + } + + articlesZone.modifyArticles(statusUpdates) { result in + self.logger.debug("Done uploading articles.") + switch result { + case .success: + self.operationDelegate?.operationDidComplete(self) + case .failure(let error): + self.error = error + self.operationDelegate?.cancelOperation(self) + } + } + } + +} + +extension SyncStatus { + + init(article: Article) { + switch true { + case article.status.starred: + self.init(articleID: article.articleID, key: .starred, flag: true) + case article.status.read: + self.init(articleID: article.articleID, key: .read, flag: true) + default: + self.init(articleID: article.articleID, key: .read, flag: false) + } + } + +} diff --git a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift index e32a83198..1d51befca 100644 --- a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift +++ b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift @@ -25,17 +25,28 @@ public typealias SingleUnreadCountResult = Result public typealias SingleUnreadCountCompletionBlock = (SingleUnreadCountResult) -> Void public struct ArticleChanges { + public let incomingArticles: Set
? public let newArticles: Set
? public let updatedArticles: Set
? public let deletedArticles: Set
? + public var unchangedArticles: Set
? { + guard let incomingArticles = incomingArticles else { return nil } + return incomingArticles + .subtracting(newArticles ?? Set
()) + .subtracting(updatedArticles ?? Set
()) + .subtracting(deletedArticles ?? Set
()) + } + public init() { + self.incomingArticles = Set
() self.newArticles = Set
() self.updatedArticles = Set
() self.deletedArticles = Set
() } - public init(newArticles: Set
?, updatedArticles: Set
?, deletedArticles: Set
?) { + public init(incomingArticles: Set
?, newArticles: Set
?, updatedArticles: Set
?, deletedArticles: Set
?) { + self.incomingArticles = incomingArticles self.newArticles = newArticles self.updatedArticles = updatedArticles self.deletedArticles = deletedArticles diff --git a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift index 94d75d94a..592893c30 100644 --- a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift +++ b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift @@ -198,7 +198,7 @@ final class ArticlesTable: DatabaseTable { func update(_ parsedItems: Set, _ webFeedID: String, _ deleteOlder: Bool, _ completion: @escaping UpdateArticlesCompletionBlock) { precondition(retentionStyle == .feedBased) if parsedItems.isEmpty { - callUpdateArticlesCompletionBlock(nil, nil, nil, completion) + callUpdateArticlesCompletionBlock(nil, nil, nil, nil, completion) return } @@ -222,7 +222,7 @@ final class ArticlesTable: DatabaseTable { let incomingArticles = Article.articlesWithParsedItems(parsedItems, webFeedID, self.accountID, statusesDictionary) //2 if incomingArticles.isEmpty { - self.callUpdateArticlesCompletionBlock(nil, nil, nil, completion) + self.callUpdateArticlesCompletionBlock(nil, nil, nil, nil, completion) return } @@ -243,7 +243,7 @@ final class ArticlesTable: DatabaseTable { articlesToDelete = Set
() } - self.callUpdateArticlesCompletionBlock(newArticles, updatedArticles, articlesToDelete, completion) //7 + self.callUpdateArticlesCompletionBlock(incomingArticles, newArticles, updatedArticles, articlesToDelete, completion) //7 self.addArticlesToCache(newArticles) self.addArticlesToCache(updatedArticles) @@ -278,7 +278,7 @@ final class ArticlesTable: DatabaseTable { func update(_ webFeedIDsAndItems: [String: Set], _ read: Bool, _ completion: @escaping UpdateArticlesCompletionBlock) { precondition(retentionStyle == .syncSystem) if webFeedIDsAndItems.isEmpty { - callUpdateArticlesCompletionBlock(nil, nil, nil, completion) + callUpdateArticlesCompletionBlock(nil, nil, nil, nil, completion) return } @@ -304,13 +304,13 @@ final class ArticlesTable: DatabaseTable { let allIncomingArticles = Article.articlesWithWebFeedIDsAndItems(webFeedIDsAndItems, self.accountID, statusesDictionary) //2 if allIncomingArticles.isEmpty { - self.callUpdateArticlesCompletionBlock(nil, nil, nil, completion) + self.callUpdateArticlesCompletionBlock(nil, nil, nil, nil, completion) return } let incomingArticles = self.filterIncomingArticles(allIncomingArticles) //3 if incomingArticles.isEmpty { - self.callUpdateArticlesCompletionBlock(nil, nil, nil, completion) + self.callUpdateArticlesCompletionBlock(nil, nil, nil, nil, completion) return } @@ -321,7 +321,7 @@ final class ArticlesTable: DatabaseTable { let newArticles = self.findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5 let updatedArticles = self.findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6 - self.callUpdateArticlesCompletionBlock(newArticles, updatedArticles, nil, completion) //7 + self.callUpdateArticlesCompletionBlock(incomingArticles, newArticles, updatedArticles, nil, completion) //7 self.addArticlesToCache(newArticles) self.addArticlesToCache(updatedArticles) @@ -914,8 +914,11 @@ private extension ArticlesTable { // MARK: - Saving Parsed Items - func callUpdateArticlesCompletionBlock(_ newArticles: Set
?, _ updatedArticles: Set
?, _ deletedArticles: Set
?, _ completion: @escaping UpdateArticlesCompletionBlock) { - let articleChanges = ArticleChanges(newArticles: newArticles, updatedArticles: updatedArticles, deletedArticles: deletedArticles) + func callUpdateArticlesCompletionBlock(_ incomingArticles: Set
?, + _ newArticles: Set
?, + _ updatedArticles: Set
?, + _ deletedArticles: Set
?, _ completion: @escaping UpdateArticlesCompletionBlock) { + let articleChanges = ArticleChanges(incomingArticles: incomingArticles, newArticles: newArticles, updatedArticles: updatedArticles, deletedArticles: deletedArticles) DispatchQueue.main.async { completion(.success(articleChanges)) } diff --git a/Mac/Preferences/Accounts/AccountsDetail.xib b/Mac/Preferences/Accounts/AccountsDetail.xib index f8bc00994..d7478335a 100644 --- a/Mac/Preferences/Accounts/AccountsDetail.xib +++ b/Mac/Preferences/Accounts/AccountsDetail.xib @@ -1,8 +1,8 @@ - + - + @@ -15,6 +15,7 @@ + @@ -130,9 +131,22 @@ + + + + diff --git a/Mac/Preferences/Accounts/AccountsDetailViewController.swift b/Mac/Preferences/Accounts/AccountsDetailViewController.swift index 3fe5487ee..3492510f2 100644 --- a/Mac/Preferences/Accounts/AccountsDetailViewController.swift +++ b/Mac/Preferences/Accounts/AccountsDetailViewController.swift @@ -17,6 +17,7 @@ final class AccountsDetailViewController: NSViewController, NSTextFieldDelegate @IBOutlet weak var limitationsAndSolutionsRow: NSGridRow! @IBOutlet weak var limitationsAndSolutionsTextField: NSTextField! @IBOutlet weak var credentialsButton: NSButton! + @IBOutlet weak var wipeCloudKitArticlesAndReloadButton: NSButton! private var accountsWindowController: NSWindowController? private var account: Account? @@ -55,6 +56,7 @@ final class AccountsDetailViewController: NSViewController, NSTextFieldDelegate limitationsAndSolutionsTextField.attributedStringValue = attrString } else { limitationsAndSolutionsRow.isHidden = true + wipeCloudKitArticlesAndReloadButton.isHidden = true } credentialsButton.isHidden = hidesCredentialsButton @@ -100,4 +102,28 @@ final class AccountsDetailViewController: NSViewController, NSTextFieldDelegate } + @IBAction func wipeCloudKitArticlesAndReload(_ sender: Any) { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = NSLocalizedString("Wipe And Reload Articles?", comment: "Wipe And Reload Articles") + alert.informativeText = NSLocalizedString("Are you sure you want to wipe and reload the iCloud Articles? Only articles in RSS feeds and Starred articles will be reloaded.", + comment: "Wipe And Reload Articles") + + alert.addButton(withTitle: NSLocalizedString("Wipe And Reload", comment: "Wipe And Reload")) + alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "Cancel Delete Account")) + + alert.beginSheetModal(for: view.window!) { [weak self] result in + if result == NSApplication.ModalResponse.alertFirstButtonReturn { + guard let self = self else { return } + + self.wipeCloudKitArticlesAndReloadButton.isEnabled = false + AccountManager.shared.wipeCloudKitArticlesZoneAndReload(errorHandler: ErrorHandler.present) { + self.wipeCloudKitArticlesAndReloadButton.isEnabled = true + } + } + } + + + } + } diff --git a/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 89207c710..f4f3b8f74 100644 --- a/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -60,8 +60,8 @@ "repositoryURL": "https://github.com/Ranchero-Software/RSCore.git", "state": { "branch": null, - "revision": "fd64fb77de2c4b6a87a971d353e7eea75100f694", - "version": "1.1.3" + "revision": "917610ce4af5a22d1f1647ace571cc1b359839ba", + "version": "1.1.4" } }, { @@ -96,8 +96,8 @@ "repositoryURL": "https://github.com/Ranchero-Software/RSWeb.git", "state": { "branch": null, - "revision": "c8d6212b08ae86142105e828fda391a6503a2ea7", - "version": "1.0.6" + "revision": "aca2db763e3404757b273821f058bed2bbe02fcf", + "version": "1.0.7" } }, {