Add a process to wipe out a zone and reload it from a local database

This commit is contained in:
Maurice Parker 2022-11-13 23:42:42 -06:00
parent 97d139d5ae
commit c8612d59d7
9 changed files with 314 additions and 22 deletions

View File

@ -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, Error>) -> Void) {
guard let cloudKitAccountDelegate = delegate as? CloudKitAccountDelegate else {
completion(.success(()))
return
}
cloudKitAccountDelegate.wipeArticlesZoneAndReload(for: self, completion: completion)
}
public func refreshAll(completion: @escaping (Result<Void, Error>) -> Void) {
delegate.refreshAll(for: self, completion: completion)
}

View File

@ -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 }

View File

@ -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, Error>) -> 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, Error>) -> 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, Error>) -> 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<Article>, completion: @escaping (Result<Void, Error>) -> 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, Error>) -> 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<WebFeed>, completion: @escaping (Result<Void, Error>) -> Void) {
func combinedRefresh(_ account: Account, _ webFeeds: Set<WebFeed>, _ refresher: LocalAccountRefresher, completion: @escaping (Result<Void, Error>) -> Void) {
var refresherWebFeeds = Set<WebFeed>()
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()
}
}
}

View File

@ -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<Article>
public var error: Error?
init(articlesZone: CloudKitArticlesZone, articles: Set<Article>) {
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)
}
}
}

View File

@ -25,17 +25,28 @@ public typealias SingleUnreadCountResult = Result<Int, DatabaseError>
public typealias SingleUnreadCountCompletionBlock = (SingleUnreadCountResult) -> Void
public struct ArticleChanges {
public let incomingArticles: Set<Article>?
public let newArticles: Set<Article>?
public let updatedArticles: Set<Article>?
public let deletedArticles: Set<Article>?
public var unchangedArticles: Set<Article>? {
guard let incomingArticles = incomingArticles else { return nil }
return incomingArticles
.subtracting(newArticles ?? Set<Article>())
.subtracting(updatedArticles ?? Set<Article>())
.subtracting(deletedArticles ?? Set<Article>())
}
public init() {
self.incomingArticles = Set<Article>()
self.newArticles = Set<Article>()
self.updatedArticles = Set<Article>()
self.deletedArticles = Set<Article>()
}
public init(newArticles: Set<Article>?, updatedArticles: Set<Article>?, deletedArticles: Set<Article>?) {
public init(incomingArticles: Set<Article>?, newArticles: Set<Article>?, updatedArticles: Set<Article>?, deletedArticles: Set<Article>?) {
self.incomingArticles = incomingArticles
self.newArticles = newArticles
self.updatedArticles = updatedArticles
self.deletedArticles = deletedArticles

View File

@ -198,7 +198,7 @@ final class ArticlesTable: DatabaseTable {
func update(_ parsedItems: Set<ParsedItem>, _ 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<Article>()
}
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<ParsedItem>], _ 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<Article>?, _ updatedArticles: Set<Article>?, _ deletedArticles: Set<Article>?, _ completion: @escaping UpdateArticlesCompletionBlock) {
let articleChanges = ArticleChanges(newArticles: newArticles, updatedArticles: updatedArticles, deletedArticles: deletedArticles)
func callUpdateArticlesCompletionBlock(_ incomingArticles: Set<Article>?,
_ newArticles: Set<Article>?,
_ updatedArticles: Set<Article>?,
_ deletedArticles: Set<Article>?, _ completion: @escaping UpdateArticlesCompletionBlock) {
let articleChanges = ArticleChanges(incomingArticles: incomingArticles, newArticles: newArticles, updatedArticles: updatedArticles, deletedArticles: deletedArticles)
DispatchQueue.main.async {
completion(.success(articleChanges))
}

View File

@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="21225" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="21507" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="21225"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="21507"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
@ -15,6 +15,7 @@
<outlet property="nameTextField" destination="TT0-Kf-YTC" id="oMG-jn-Qn0"/>
<outlet property="typeLabel" destination="XYX-iz-hnq" id="SKM-et-3h3"/>
<outlet property="view" destination="3ki-rg-6yb" id="ttM-4E-OLN"/>
<outlet property="wipeCloudKitArticlesAndReloadButton" destination="56k-80-63v" id="bc2-1h-o2d"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
@ -130,9 +131,22 @@
</connections>
</buttonCell>
</button>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="56k-80-63v">
<rect key="frame" x="32" y="38" width="266" height="32"/>
<buttonCell key="cell" type="push" title="Wipe iCloud Articles and Reload Them" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="yOR-Lx-8cZ">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<action selector="wipeCloudKitArticlesAndReload:" target="-2" id="luN-qw-nZg"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstItem="56k-80-63v" firstAttribute="centerX" secondItem="ft2-Mb-5LD" secondAttribute="centerX" id="GpX-Fs-EEb"/>
<constraint firstItem="56k-80-63v" firstAttribute="top" secondItem="nVy-H3-bFO" secondAttribute="bottom" constant="16" id="JGg-ll-aeC"/>
<constraint firstItem="nVy-H3-bFO" firstAttribute="leading" secondItem="ft2-Mb-5LD" secondAttribute="leading" constant="20" symbolic="YES" id="SQe-pg-1hl"/>
<constraint firstItem="gLh-gl-ZGQ" firstAttribute="centerX" secondItem="ft2-Mb-5LD" secondAttribute="centerX" id="VwD-aL-g9a"/>
<constraint firstAttribute="trailing" secondItem="nVy-H3-bFO" secondAttribute="trailing" constant="20" symbolic="YES" id="Wsq-ar-poP"/>
<constraint firstItem="gLh-gl-ZGQ" firstAttribute="top" secondItem="nVy-H3-bFO" secondAttribute="bottom" constant="8" id="a0S-2S-3dR"/>
<constraint firstItem="gLh-gl-ZGQ" firstAttribute="centerX" secondItem="ft2-Mb-5LD" secondAttribute="centerX" id="cW8-YT-BEn"/>

View File

@ -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
}
}
}
}
}

View File

@ -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"
}
},
{