From bd75df7294d0e3a5f96e81890391e2576c5609d7 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Tue, 24 Mar 2020 16:42:46 -0500 Subject: [PATCH 01/98] Add pointer interaction for timeline header button. Issue #1943 --- .../MasterTimelineTitleView.swift | 39 +++++++++++++++++++ .../MasterTimelineViewController.swift | 5 +-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/iOS/MasterTimeline/MasterTimelineTitleView.swift b/iOS/MasterTimeline/MasterTimelineTitleView.swift index 3046f079a..ee8eccb78 100644 --- a/iOS/MasterTimeline/MasterTimelineTitleView.swift +++ b/iOS/MasterTimeline/MasterTimelineTitleView.swift @@ -13,5 +13,44 @@ class MasterTimelineTitleView: UIView { @IBOutlet weak var iconView: IconView! @IBOutlet weak var label: UILabel! @IBOutlet weak var unreadCountView: MasterTimelineUnreadCountView! + + @available(iOS 13.4, *) + private lazy var pointerInteraction: UIPointerInteraction = { + UIPointerInteraction(delegate: self) + }() + + func buttonize() { + heightAnchor.constraint(equalToConstant: 40.0).isActive = true + accessibilityTraits = .button + if #available(iOS 13.4, *) { + addInteraction(pointerInteraction) + } + } + + func debuttonize() { + accessibilityTraits.remove(.button) + if #available(iOS 13.4, *) { + removeInteraction(pointerInteraction) + } + } + +} + +extension MasterTimelineTitleView: UIPointerInteractionDelegate { + + @available(iOS 13.4, *) + func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? { + + let params = UIPreviewParameters() + var rect = self.bounds + rect.origin.x = rect.origin.x - 10 + rect.size.width = rect.width + 20 + let path = UIBezierPath(roundedRect: rect, cornerRadius: 10.0) + params.visiblePath = path + + let preview = UITargetedPreview(view: self, parameters: params) + + return UIPointerStyle(effect: .automatic(preview), shape: .path(path)) + } } diff --git a/iOS/MasterTimeline/MasterTimelineViewController.swift b/iOS/MasterTimeline/MasterTimelineViewController.swift index 4cb77da86..8f7b41c24 100644 --- a/iOS/MasterTimeline/MasterTimelineViewController.swift +++ b/iOS/MasterTimeline/MasterTimelineViewController.swift @@ -564,12 +564,11 @@ private extension MasterTimelineViewController { updateTitleUnreadCount() if coordinator.timelineFeed is WebFeed { - titleView.heightAnchor.constraint(equalToConstant: 44.0).isActive = true + titleView.buttonize() titleView.addGestureRecognizer(feedTapGestureRecognizer) - titleView.accessibilityTraits = .button } else { + titleView.debuttonize() titleView.removeGestureRecognizer(feedTapGestureRecognizer) - titleView.accessibilityTraits.remove(.button) } navigationItem.titleView = titleView From 450ddbd364904bd44df7b1fe29637589d375c757 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Tue, 24 Mar 2020 18:27:54 -0500 Subject: [PATCH 02/98] Refactored new pointer interaction code to be more simple. --- iOS/MasterTimeline/MasterTimelineTitleView.swift | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/iOS/MasterTimeline/MasterTimelineTitleView.swift b/iOS/MasterTimeline/MasterTimelineTitleView.swift index ee8eccb78..76e4fbbaa 100644 --- a/iOS/MasterTimeline/MasterTimelineTitleView.swift +++ b/iOS/MasterTimeline/MasterTimelineTitleView.swift @@ -40,17 +40,11 @@ extension MasterTimelineTitleView: UIPointerInteractionDelegate { @available(iOS 13.4, *) func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? { - - let params = UIPreviewParameters() - var rect = self.bounds + var rect = self.frame rect.origin.x = rect.origin.x - 10 rect.size.width = rect.width + 20 - let path = UIBezierPath(roundedRect: rect, cornerRadius: 10.0) - params.visiblePath = path - - let preview = UITargetedPreview(view: self, parameters: params) - return UIPointerStyle(effect: .automatic(preview), shape: .path(path)) + return UIPointerStyle(effect: .automatic(UITargetedPreview(view: self)), shape: .roundedRect(rect)) } } From 1d464718649284402c808b5ef4c107c469e02631 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Tue, 24 Mar 2020 18:40:36 -0500 Subject: [PATCH 03/98] Add step to list out available applications --- .github/workflows/build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4c8984b2d..373d14d84 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,6 +25,9 @@ jobs: with: submodules: recursive + - name: List Available Applications + run: ls /Applications + - name: Switch to Xcode 11 run: sudo xcode-select -s /Applications/Xcode_11.app From 6889d2a61a96311e10a38ce074869f21a564e873 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Tue, 24 Mar 2020 18:42:18 -0500 Subject: [PATCH 04/98] Switch to using XCode 11.4 to build --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 373d14d84..deba53731 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,7 +29,7 @@ jobs: run: ls /Applications - name: Switch to Xcode 11 - run: sudo xcode-select -s /Applications/Xcode_11.app + run: sudo xcode-select -s /Applications/Xcode_11.4.app - name: Show Build Version run: xcodebuild -version From 3c0862fe832fd81510c6f3306010de34364942c0 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Tue, 24 Mar 2020 19:32:16 -0500 Subject: [PATCH 05/98] Update iOS destination for new SDK --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index deba53731..c0e58f182 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ jobs: matrix: run-config: - { scheme: 'NetNewsWire', destination: 'platform=macOS'} - - { scheme: 'NetNewsWire-iOS', destination: 'platform=iOS Simulator,OS=13.0,name=iPhone 11' } + - { scheme: 'NetNewsWire-iOS', destination: 'platform=iOS Simulator,OS=13.4,name=iPhone 11' } steps: - name: Checkout Project From 6c06c7791cc40b7a69f3a3b231b258c0ee965a1d Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Wed, 25 Mar 2020 08:55:02 -0500 Subject: [PATCH 06/98] Add interaction to buttons that were missing it. Issue #1945 --- iOS/Account/Account.storyboard | 12 ++--- .../Cell/MasterFeedTableViewCell.swift | 7 ++- .../MasterFeedTableViewSectionHeader.swift | 34 +++++++++---- iOS/MasterFeed/MasterFeedViewController.swift | 49 ++++++++++++------- 4 files changed, 66 insertions(+), 36 deletions(-) diff --git a/iOS/Account/Account.storyboard b/iOS/Account/Account.storyboard index 52982af01..b68e2fc00 100644 --- a/iOS/Account/Account.storyboard +++ b/iOS/Account/Account.storyboard @@ -1,8 +1,8 @@ - + - + @@ -57,7 +57,7 @@ - + @@ -206,7 +206,7 @@ - + @@ -291,7 +291,7 @@ - + @@ -315,7 +315,7 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/Account/CloudKitAccountViewController.swift b/iOS/Account/CloudKitAccountViewController.swift new file mode 100644 index 000000000..e26135ffd --- /dev/null +++ b/iOS/Account/CloudKitAccountViewController.swift @@ -0,0 +1,47 @@ +// +// CloudKitAccountViewController.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 3/28/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import UIKit +import Account + +class CloudKitAccountViewController: UITableViewController { + + weak var delegate: AddAccountDismissDelegate? + + override func viewDidLoad() { + super.viewDidLoad() + + tableView.register(ImageHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader") + } + + @IBAction func cancel(_ sender: Any) { + dismiss(animated: true, completion: nil) + delegate?.dismiss() + } + + @IBAction func add(_ sender: Any) { + _ = AccountManager.shared.createAccount(type: .cloudKit) + dismiss(animated: true, completion: nil) + delegate?.dismiss() + } + + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: section) + } + + override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + if section == 0 { + let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "SectionHeader") as! ImageHeaderView + headerView.imageView.image = AppAssets.image(for: .cloudKit) + return headerView + } else { + return super.tableView(tableView, viewForHeaderInSection: section) + } + } + +} diff --git a/iOS/AppAssets.swift b/iOS/AppAssets.swift index 17473357b..58d600aef 100644 --- a/iOS/AppAssets.swift +++ b/iOS/AppAssets.swift @@ -19,6 +19,10 @@ struct AppAssets { return UIImage(named: "accountLocalPhone")! }() + static var accountCloudKitImage: UIImage = { + return UIImage(named: "accountCloudKit")! + }() + static var accountFeedbinImage: UIImage = { return UIImage(named: "accountFeedbin")! }() @@ -234,6 +238,8 @@ struct AppAssets { } else { return AppAssets.accountLocalPhoneImage } + case .cloudKit: + return AppAssets.accountCloudKitImage case .feedbin: return AppAssets.accountFeedbinImage case .feedly: @@ -244,8 +250,6 @@ struct AppAssets { return AppAssets.accountFreshRSSImage case .newsBlur: return AppAssets.accountNewsBlurImage - default: - return nil } } diff --git a/iOS/Resources/Assets.xcassets/accountCloudKit.imageset/Contents.json b/iOS/Resources/Assets.xcassets/accountCloudKit.imageset/Contents.json new file mode 100644 index 000000000..fc07d2975 --- /dev/null +++ b/iOS/Resources/Assets.xcassets/accountCloudKit.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "icloud.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iOS/Resources/Assets.xcassets/accountCloudKit.imageset/icloud.pdf b/iOS/Resources/Assets.xcassets/accountCloudKit.imageset/icloud.pdf new file mode 100644 index 0000000000000000000000000000000000000000..74406f4cbf5e9bdd80f425de4421e074993302ef GIT binary patch literal 4251 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 dtAYQ! Date: Sun, 29 Mar 2020 03:43:20 -0500 Subject: [PATCH 17/98] Implement CloudKit feed add. --- Frameworks/Account/Account.swift | 3 +- .../Account/Account.xcodeproj/project.pbxproj | 8 +- .../CloudKit/CloudKitAccountDelegate.swift | 47 ++++++------ .../Account/CloudKit/CloudKitResult.swift | 30 ++++++-- .../Account/CloudKit/CloudKitZone.swift | 16 ++-- .../Account/CloudKit/CloudKitZoneResult.swift | 74 +++++++++++++++++++ .../AddFeed/AddFeedController.swift | 4 - 7 files changed, 138 insertions(+), 44 deletions(-) create mode 100644 Frameworks/Account/CloudKit/CloudKitZoneResult.swift diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 2291fee56..103140166 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -545,7 +545,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, let feed = WebFeed(account: self, url: url, metadata: metadata) feed.name = name feed.homePageURL = homePageURL - return feed } @@ -683,7 +682,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } func update(_ webFeed: WebFeed, with parsedFeed: ParsedFeed, _ completion: @escaping DatabaseCompletionBlock) { - // Used only by an On My Mac account. + // Used only by an On My Mac and iCloud accounts. webFeed.takeSettings(from: parsedFeed) let webFeedIDsAndItems = [webFeed.webFeedID: parsedFeed.items] update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: false, completion: completion) diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 2573a06a5..b57f1def1 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -56,7 +56,7 @@ 51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */; }; 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC8FCB237EC055004F8B56 /* Feed.swift */; }; 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */; }; - 51C034DF242D65D20014DC71 /* CloudKitResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C034DE242D65D20014DC71 /* CloudKitResult.swift */; }; + 51C034DF242D65D20014DC71 /* CloudKitZoneResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */; }; 51C034E1242D660D0014DC71 /* CKError+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C034E0242D660D0014DC71 /* CKError+Extensions.swift */; }; 51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D58754227F53BE00900287 /* FeedbinTag.swift */; }; 51D5875A227F630B00900287 /* tags_delete.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58757227F630B00900287 /* tags_delete.json */; }; @@ -287,7 +287,7 @@ 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBehaviors.swift; sourceTree = ""; }; 51BC8FCB237EC055004F8B56 /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = ""; }; 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerIdentifier.swift; sourceTree = ""; }; - 51C034DE242D65D20014DC71 /* CloudKitResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitResult.swift; sourceTree = ""; }; + 51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitZoneResult.swift; sourceTree = ""; }; 51C034E0242D660D0014DC71 /* CKError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKError+Extensions.swift"; sourceTree = ""; }; 51D58754227F53BE00900287 /* FeedbinTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinTag.swift; sourceTree = ""; }; 51D58757227F630B00900287 /* tags_delete.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_delete.json; sourceTree = ""; }; @@ -514,8 +514,8 @@ 51C034E0242D660D0014DC71 /* CKError+Extensions.swift */, 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */, 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */, - 51C034DE242D65D20014DC71 /* CloudKitResult.swift */, 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */, + 51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */, ); path = CloudKit; sourceTree = ""; @@ -1184,7 +1184,7 @@ 3B826DAC2385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift in Sources */, 769F295938E5A30D03DFF88F /* NewsBlurAccountDelegate.swift in Sources */, 769F2BA02EF5F329CDE45F5A /* NewsBlurAPICaller.swift in Sources */, - 51C034DF242D65D20014DC71 /* CloudKitResult.swift in Sources */, + 51C034DF242D65D20014DC71 /* CloudKitZoneResult.swift in Sources */, 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */, 179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */, 179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */, diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 2f49f8234..c138a19bc 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -45,14 +45,6 @@ final class CloudKitAccountDelegate: AccountDelegate { return refresher.progress } -// init() { -// accountZone.startUp() { result in -// if case .failure(let error) = result { -// os_log(.error, log: self.log, "Account zone startup error: %@.", error.localizedDescription) -// } -// } -// } - init(dataFolder: String) { accountZone = CloudKitAccountZone(container: container) let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3") @@ -140,22 +132,35 @@ final class CloudKitAccountDelegate: AccountDelegate { return } - let feed = account.createWebFeed(with: nil, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) - - InitialFeedDownloader.download(url) { parsedFeed in - self.refreshProgress.completeTask() + self.accountZone.createFeed(url: urlString, editedName: name) { result in + switch result { + case .success(let externalID): + + let feed = account.createWebFeed(with: nil, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) + + InitialFeedDownloader.download(url) { parsedFeed in + self.refreshProgress.completeTask() - if let parsedFeed = parsedFeed { - account.update(feed, with: parsedFeed, {_ in}) + if let parsedFeed = parsedFeed { + account.update(feed, with: parsedFeed, {_ in + + feed.editedName = name + feed.externalID = externalID + + container.addWebFeed(feed) + completion(.success(feed)) + + }) + } + + } + + case .failure(let error): + self.refreshProgress.completeTask() + completion(.failure(error)) // TODO: need to handle userDeletedZone } - - feed.editedName = name - - container.addWebFeed(feed) - completion(.success(feed)) - } - + case .failure: self.refreshProgress.completeTask() completion(.failure(AccountError.createErrorNotFound)) diff --git a/Frameworks/Account/CloudKit/CloudKitResult.swift b/Frameworks/Account/CloudKit/CloudKitResult.swift index 7f6592f63..c1ce67f86 100644 --- a/Frameworks/Account/CloudKit/CloudKitResult.swift +++ b/Frameworks/Account/CloudKit/CloudKitResult.swift @@ -9,17 +9,17 @@ import Foundation import CloudKit -enum CloudKitResult { +enum CloudKitZoneResult { case success case retry(afterSeconds: Double) - case chunk + case limitExceeded case changeTokenExpired - case partialFailure + case partialFailure(errors: [CKRecord.ID: CKError]) case serverRecordChanged case noZone case failure(error: Error) - static func resolve(_ error: Error?) -> CloudKitResult { + static func resolve(_ error: Error?) -> CloudKitZoneResult { guard error != nil else { return .success } @@ -39,11 +39,17 @@ enum CloudKitResult { case .serverRecordChanged: return .serverRecordChanged case .partialFailure: - return .partialFailure + if let partialErrors = ckError.userInfo[CKPartialErrorsByItemIDKey] as? [CKRecord.ID: CKError] { + if anyZoneErrors(partialErrors) { + return .noZone + } else { + return .partialFailure(errors: partialErrors) + } + } else { + return .failure(error: error!) + } case .limitExceeded: - return .chunk - case .zoneNotFound, .userDeletedZone: - return .noZone + return .limitExceeded default: return .failure(error: error!) } @@ -51,3 +57,11 @@ enum CloudKitResult { } } + +private extension CloudKitZoneResult { + + static func anyZoneErrors(_ errors: [CKRecord.ID: CKError]) -> Bool { + return errors.values.contains(where: { $0.code == .zoneNotFound || $0.code == .userDeletedZone } ) + } + +} diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 680afe349..af865fbaa 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -9,6 +9,7 @@ import CloudKit public enum CloudKitZoneError: Error { + case userDeletedZone case unknown } @@ -146,14 +147,13 @@ extension CloudKitZone { guard let self = self else { return } - switch CloudKitResult.resolve(error) { + switch CloudKitZoneResult.resolve(error) { case .success: DispatchQueue.main.async { completion(.success(())) } - case .noZone: + case .zoneNotFound: self.createZoneRecord() { result in - // TODO: Need to rebuild (push) zone data here... switch result { case .success: self.modify(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete, completion: completion) @@ -161,11 +161,15 @@ extension CloudKitZone { completion(.failure(error)) } } + case .userDeletedZone: + DispatchQueue.main.async { + completion(.failure(CloudKitZoneError.userDeletedZone)) + } case .retry(let timeToWait): self.retryOperationIfPossible(retryAfter: timeToWait) { self.modify(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete, completion: completion) } - case .chunk: + case .limitExceeded: /// CloudKit says maximum number of items in a single request is 400. /// So I think 300 should be fine by them. let chunkedRecords = recordsToStore.chunked(into: 300) @@ -173,7 +177,9 @@ extension CloudKitZone { self.modify(recordsToStore: chunk, recordIDsToDelete: recordIDsToDelete, completion: completion) } default: - return + DispatchQueue.main.async { + completion(.failure(error!)) + } } } diff --git a/Frameworks/Account/CloudKit/CloudKitZoneResult.swift b/Frameworks/Account/CloudKit/CloudKitZoneResult.swift new file mode 100644 index 000000000..5de070641 --- /dev/null +++ b/Frameworks/Account/CloudKit/CloudKitZoneResult.swift @@ -0,0 +1,74 @@ +// +// CloudKitResult.swift +// Account +// +// Created by Maurice Parker on 3/26/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import CloudKit + +enum CloudKitZoneResult { + case success + case retry(afterSeconds: Double) + case limitExceeded + case changeTokenExpired + case partialFailure(errors: [CKRecord.ID: CKError]) + case serverRecordChanged + case zoneNotFound + case userDeletedZone + case failure(error: Error) + + static func resolve(_ error: Error?) -> CloudKitZoneResult { + + guard error != nil else { return .success } + + guard let ckError = error as? CKError else { + return .failure(error: error!) + } + + switch ckError.code { + case .serviceUnavailable, .requestRateLimited, .zoneBusy: + if let retry = ckError.userInfo[CKErrorRetryAfterKey] as? Double { + return .retry(afterSeconds: retry) + } else { + return .failure(error: error!) + } + case .changeTokenExpired: + return .changeTokenExpired + case .serverRecordChanged: + return .serverRecordChanged + case .partialFailure: + if let partialErrors = ckError.userInfo[CKPartialErrorsByItemIDKey] as? [CKRecord.ID: CKError] { + if let zoneResult = anyZoneErrors(partialErrors) { + return zoneResult + } else { + return .partialFailure(errors: partialErrors) + } + } else { + return .failure(error: error!) + } + case .limitExceeded: + return .limitExceeded + default: + return .failure(error: error!) + } + + } + +} + +private extension CloudKitZoneResult { + + static func anyZoneErrors(_ errors: [CKRecord.ID: CKError]) -> CloudKitZoneResult? { + if errors.values.contains(where: { $0.code == .zoneNotFound } ) { + return .zoneNotFound + } + if errors.values.contains(where: { $0.code == .userDeletedZone } ) { + return .userDeletedZone + } + return nil + } + +} diff --git a/Mac/MainWindow/AddFeed/AddFeedController.swift b/Mac/MainWindow/AddFeed/AddFeedController.swift index 77071e7cd..8282126ac 100644 --- a/Mac/MainWindow/AddFeed/AddFeedController.swift +++ b/Mac/MainWindow/AddFeed/AddFeedController.swift @@ -58,16 +58,12 @@ class AddFeedController: AddFeedWindowControllerDelegate { return } - BatchUpdate.shared.start() - account.createWebFeed(url: url.absoluteString, name: title, container: container) { result in DispatchQueue.main.async { self.endShowingProgress() } - BatchUpdate.shared.end() - switch result { case .success(let feed): NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.webFeed: feed]) From 573cee0fd6a980798b96a15c47d89fc9ce3594cd Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 29 Mar 2020 08:52:59 -0500 Subject: [PATCH 18/98] Added delete feed functionality. --- .../CloudKit/CloudKitAccountDelegate.swift | 11 +++++-- .../CloudKit/CloudKitAccountZone.swift | 18 ++++++++++-- .../Account/CloudKit/CloudKitZone.swift | 29 +++++++++---------- 3 files changed, 39 insertions(+), 19 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index c138a19bc..a031b820f 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -176,8 +176,15 @@ final class CloudKitAccountDelegate: AccountDelegate { } func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result) -> Void) { - container.removeWebFeed(feed) - completion(.success(())) + accountZone.removeWebFeed(feed) { result in + switch result { + case .success: + container.removeWebFeed(feed) + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } } func moveWebFeed(for account: Account, with feed: WebFeed, from: Container, to: Container, completion: @escaping (Result) -> Void) { diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index e215c734a..fd326f5a4 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -31,7 +31,7 @@ final class CloudKitAccountZone: CloudKitZone { self.database = container.privateCloudDatabase } - /// Persist a feed record to iCloud and return the external key + /// Persist a web feed record to iCloud and return the external key func createFeed(url: String, editedName: String?, completion: @escaping (Result) -> Void) { let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: generateRecordID()) record[CloudKitWebFeed.Fields.url] = url @@ -39,9 +39,23 @@ final class CloudKitAccountZone: CloudKitZone { record[CloudKitWebFeed.Fields.editedName] = editedName } - save(recordToStore: record, completion: completion) + save(record: record) { result in + switch result { + case .success: + completion(.success(record.recordID.recordName)) + case .failure(let error): + completion(.failure(error)) + } + } } + func removeWebFeed(_ webFeed: WebFeed, completion: @escaping (Result) -> Void) { + guard let externalID = webFeed.externalID else { + completion(.failure(CloudKitZoneError.invalidParameter)) + return + } + delete(externalID: externalID, completion: completion) + } // private func fetchChangesInZones(_ callback: ((Error?) -> Void)? = nil) { // let changesOp = CKFetchRecordZoneChangesOperation(recordZoneIDs: zoneIds, optionsByRecordZoneID: zoneIdOptions) // changesOp.fetchAllChanges = true diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index af865fbaa..57137a8b9 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -10,6 +10,7 @@ import CloudKit public enum CloudKitZoneError: Error { case userDeletedZone + case invalidParameter case unknown } @@ -114,21 +115,19 @@ extension CloudKitZone { // }) // } - public func save(recordToStore: CKRecord, completion: @escaping (Result) -> Void) { - modify(recordsToStore: [recordToStore], recordIDsToDelete: []) { result in - switch result { - case .success: - completion(.success(recordToStore.recordID.recordName)) - case .failure(let error): - completion(.failure(error)) - } - } + public func save(record: CKRecord, completion: @escaping (Result) -> Void) { + modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion) } + public func delete(externalID: String, completion: @escaping (Result) -> Void) { + let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID) + modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion) + } + /// Sync local data to CloudKit /// For more about the savePolicy: https://developer.apple.com/documentation/cloudkit/ckrecordsavepolicy - public func modify(recordsToStore: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result) -> Void) { - let op = CKModifyRecordsOperation(recordsToSave: recordsToStore, recordIDsToDelete: recordIDsToDelete) + public func modify(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result) -> Void) { + let op = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete) let config = CKOperation.Configuration() config.isLongLived = true @@ -156,7 +155,7 @@ extension CloudKitZone { self.createZoneRecord() { result in switch result { case .success: - self.modify(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete, completion: completion) + self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion) case .failure(let error): completion(.failure(error)) } @@ -167,14 +166,14 @@ extension CloudKitZone { } case .retry(let timeToWait): self.retryOperationIfPossible(retryAfter: timeToWait) { - self.modify(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete, completion: completion) + self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion) } case .limitExceeded: /// CloudKit says maximum number of items in a single request is 400. /// So I think 300 should be fine by them. - let chunkedRecords = recordsToStore.chunked(into: 300) + let chunkedRecords = recordsToSave.chunked(into: 300) for chunk in chunkedRecords { - self.modify(recordsToStore: chunk, recordIDsToDelete: recordIDsToDelete, completion: completion) + self.modify(recordsToSave: chunk, recordIDsToDelete: recordIDsToDelete, completion: completion) } default: DispatchQueue.main.async { From 3b31f2562d724bf66b6d4595f6fdbf6a788713f1 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 29 Mar 2020 11:53:52 -0500 Subject: [PATCH 19/98] Stub out fetching feed changes. --- .../Account/Account.xcodeproj/project.pbxproj | 8 + .../CloudKit/CKRecord+Extensions.swift | 18 ++ .../CloudKit/CloudKitAccountDelegate.swift | 3 +- .../CloudKit/CloudKitAccountZone.swift | 60 +------ .../CloudKitAccountZoneDelegate.swift | 46 +++++ .../Account/CloudKit/CloudKitZone.swift | 162 +++++++++++------- 6 files changed, 178 insertions(+), 119 deletions(-) create mode 100644 Frameworks/Account/CloudKit/CKRecord+Extensions.swift create mode 100644 Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index b57f1def1..fbc41bc77 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -34,6 +34,8 @@ 510BD111232C3801002692E4 /* AccountMetadataFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510BD110232C3801002692E4 /* AccountMetadataFile.swift */; }; 510BD113232C3E9D002692E4 /* WebFeedMetadataFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510BD112232C3E9D002692E4 /* WebFeedMetadataFile.swift */; }; 511B9804237CD4270028BCAA /* FeedIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 511B9803237CD4270028BCAA /* FeedIdentifier.swift */; }; + 512DD4CB2431000600C17B1F /* CKRecord+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */; }; + 512DD4CD2431098700C17B1F /* CloudKitAccountZoneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */; }; 513323082281070D00C30F19 /* AccountFeedbinSyncTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 513323072281070C00C30F19 /* AccountFeedbinSyncTest.swift */; }; 5133230A2281082F00C30F19 /* subscriptions_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 513323092281082F00C30F19 /* subscriptions_initial.json */; }; 5133230C2281088A00C30F19 /* subscriptions_add.json in Resources */ = {isa = PBXBuildFile; fileRef = 5133230B2281088A00C30F19 /* subscriptions_add.json */; }; @@ -264,6 +266,8 @@ 510BD110232C3801002692E4 /* AccountMetadataFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMetadataFile.swift; sourceTree = ""; }; 510BD112232C3E9D002692E4 /* WebFeedMetadataFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebFeedMetadataFile.swift; sourceTree = ""; }; 511B9803237CD4270028BCAA /* FeedIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedIdentifier.swift; sourceTree = ""; }; + 512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKRecord+Extensions.swift"; sourceTree = ""; }; + 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAccountZoneDelegate.swift; sourceTree = ""; }; 513323072281070C00C30F19 /* AccountFeedbinSyncTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFeedbinSyncTest.swift; sourceTree = ""; }; 513323092281082F00C30F19 /* subscriptions_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = subscriptions_initial.json; sourceTree = ""; }; 5133230B2281088A00C30F19 /* subscriptions_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = subscriptions_add.json; sourceTree = ""; }; @@ -512,8 +516,10 @@ isa = PBXGroup; children = ( 51C034E0242D660D0014DC71 /* CKError+Extensions.swift */, + 512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */, 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */, 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */, + 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */, 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */, 51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */, ); @@ -1076,6 +1082,7 @@ 9EA643D5239306AC0018A28C /* FeedlyFeedsSearchResponse.swift in Sources */, 9EAEC60E2332FEC20085D7C9 /* FeedlyFeed.swift in Sources */, 5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */, + 512DD4CB2431000600C17B1F /* CKRecord+Extensions.swift in Sources */, 3B826DAF2385C81C00FC1ADB /* FeedWranglerGenericResult.swift in Sources */, 9ECC9A85234DC16E009B5144 /* FeedlyAccountDelegateError.swift in Sources */, 9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */, @@ -1174,6 +1181,7 @@ 515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */, 844B297F210CE37E004020B3 /* UnreadCountProvider.swift in Sources */, 9E1773D5234570E30056A5A8 /* FeedlyEntryParser.swift in Sources */, + 512DD4CD2431098700C17B1F /* CloudKitAccountZoneDelegate.swift in Sources */, 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */, 9E1D1555233431A600F4944C /* FeedlyOperation.swift in Sources */, 84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */, diff --git a/Frameworks/Account/CloudKit/CKRecord+Extensions.swift b/Frameworks/Account/CloudKit/CKRecord+Extensions.swift new file mode 100644 index 000000000..bb1696a89 --- /dev/null +++ b/Frameworks/Account/CloudKit/CKRecord+Extensions.swift @@ -0,0 +1,18 @@ +// +// CKRecord+Extensions.swift +// Account +// +// Created by Maurice Parker on 3/29/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import CloudKit + +extension CKRecord.ID { + + var externalID: String { + return recordName + } + +} diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index a031b820f..815b14c89 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -132,7 +132,7 @@ final class CloudKitAccountDelegate: AccountDelegate { return } - self.accountZone.createFeed(url: urlString, editedName: name) { result in + self.accountZone.createWebFeed(url: urlString, editedName: name) { result in switch result { case .success(let externalID): @@ -231,6 +231,7 @@ final class CloudKitAccountDelegate: AccountDelegate { } func accountDidInitialize(_ account: Account) { + accountZone.delegate = CloudKitAcountZoneDelegate(account: account) } func accountWillBeDeleted(_ account: Account) { diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index fd326f5a4..670b2c556 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -17,6 +17,7 @@ final class CloudKitAccountZone: CloudKitZone { let container: CKContainer let database: CKDatabase + var delegate: CloudKitZoneDelegate? = nil struct CloudKitWebFeed { static let recordType = "WebFeed" @@ -32,7 +33,7 @@ final class CloudKitAccountZone: CloudKitZone { } /// Persist a web feed record to iCloud and return the external key - func createFeed(url: String, editedName: String?, completion: @escaping (Result) -> Void) { + func createWebFeed(url: String, editedName: String?, completion: @escaping (Result) -> Void) { let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: generateRecordID()) record[CloudKitWebFeed.Fields.url] = url if let editedName = editedName { @@ -42,7 +43,7 @@ final class CloudKitAccountZone: CloudKitZone { save(record: record) { result in switch result { case .success: - completion(.success(record.recordID.recordName)) + completion(.success(record.recordID.externalID)) case .failure(let error): completion(.failure(error)) } @@ -56,60 +57,5 @@ final class CloudKitAccountZone: CloudKitZone { } delete(externalID: externalID, completion: completion) } -// private func fetchChangesInZones(_ callback: ((Error?) -> Void)? = nil) { -// let changesOp = CKFetchRecordZoneChangesOperation(recordZoneIDs: zoneIds, optionsByRecordZoneID: zoneIdOptions) -// changesOp.fetchAllChanges = true -// -// changesOp.recordZoneChangeTokensUpdatedBlock = { [weak self] zoneId, token, _ in -// guard let self = self else { return } -// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == zoneId }) else { return } -// syncObject.zoneChangesToken = token -// } -// -// changesOp.recordChangedBlock = { [weak self] record in -// /// The Cloud will return the modified record since the last zoneChangesToken, we need to do local cache here. -// /// Handle the record: -// guard let self = self else { return } -// guard let syncObject = self.syncObjects.first(where: { $0.recordType == record.recordType }) else { return } -// syncObject.add(record: record) -// } -// -// changesOp.recordWithIDWasDeletedBlock = { [weak self] recordId, _ in -// guard let self = self else { return } -// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == recordId.zoneID }) else { return } -// syncObject.delete(recordID: recordId) -// } -// -// changesOp.recordZoneFetchCompletionBlock = { [weak self](zoneId ,token, _, _, error) in -// guard let self = self else { return } -// switch ErrorHandler.shared.resultType(with: error) { -// case .success: -// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == zoneId }) else { return } -// syncObject.zoneChangesToken = token -// case .retry(let timeToWait, _): -// ErrorHandler.shared.retryOperationIfPossible(retryAfter: timeToWait, block: { -// self.fetchChangesInZones(callback) -// }) -// case .recoverableError(let reason, _): -// switch reason { -// case .changeTokenExpired: -// /// The previousServerChangeToken value is too old and the client must re-sync from scratch -// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == zoneId }) else { return } -// syncObject.zoneChangesToken = nil -// self.fetchChangesInZones(callback) -// default: -// return -// } -// default: -// return -// } -// } -// -// changesOp.fetchRecordZoneChangesCompletionBlock = { error in -// callback?(error) -// } -// -// database.add(changesOp) -// } } diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift new file mode 100644 index 000000000..f2194820c --- /dev/null +++ b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -0,0 +1,46 @@ +// +// CloudKitAccountZoneDelegate.swift +// Account +// +// Created by Maurice Parker on 3/29/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import CloudKit + +class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { + + weak var account: Account? + + init(account: Account) { + self.account = account + } + + func cloudKitDidChange(record: CKRecord) { + switch record.recordType { + case CloudKitAccountZone.CloudKitWebFeed.recordType: + addWebFeed(record) + default: + assertionFailure("Unknown record type: \(record.recordType)") + } + } + + func cloudKitDidDelete(recordType: CKRecord.RecordType, recordID: CKRecord.ID) { + switch recordType { + case CloudKitAccountZone.CloudKitWebFeed.recordType: + removeWebFeed(recordID.externalID) + default: + assertionFailure("Unknown record type: \(recordID.externalID)") + } + } + + func addWebFeed(_ record: CKRecord) { + + } + + func removeWebFeed(_ externalID: String) { + + } + +} diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 57137a8b9..75be66fa1 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -8,18 +8,24 @@ import CloudKit -public enum CloudKitZoneError: Error { +enum CloudKitZoneError: Error { case userDeletedZone case invalidParameter case unknown } -public protocol CloudKitZone: class { +protocol CloudKitZoneDelegate: class { + func cloudKitDidChange(record: CKRecord); + func cloudKitDidDelete(recordType: CKRecord.RecordType, recordID: CKRecord.ID) +} + +protocol CloudKitZone: class { static var zoneID: CKRecordZone.ID { get } var container: CKContainer { get } var database: CKDatabase { get } + var delegate: CloudKitZoneDelegate? { get set } // func prepare() @@ -38,57 +44,10 @@ public protocol CloudKitZone: class { extension CloudKitZone { - var changeTokenKey: String { - return "cloudkit.server.token.\(Self.zoneID.zoneName)" - } - - var changeToken: CKServerChangeToken? { - get { - guard let tokenData = UserDefaults.standard.object(forKey: changeTokenKey) as? Data else { return nil } - return try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: tokenData) - } - set { - guard let token = newValue, let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: false) else { - UserDefaults.standard.removeObject(forKey: changeTokenKey) - return - } - UserDefaults.standard.set(data, forKey: changeTokenKey) - } - } - - var zoneConfiguration: CKFetchRecordZoneChangesOperation.ZoneConfiguration { - let config = CKFetchRecordZoneChangesOperation.ZoneConfiguration() - config.previousServerChangeToken = changeToken - return config - } - func generateRecordID() -> CKRecord.ID { return CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID) } - func createZoneRecord(completion: @escaping (Result) -> Void) { - database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in - if let error = error { - DispatchQueue.main.async { - completion(.failure(error)) - } - } else { - DispatchQueue.main.async { - completion(.success(())) - } - } - } - } - - // func prepare() { - // syncObjects.forEach { - // $0.pipeToEngine = { [weak self] recordsToStore, recordIDsToDelete in - // guard let self = self else { return } - // self.syncRecordsToCloudKit(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete) - // } - // } - // } - func resumeLongLivedOperationIfPossible() { container.fetchAllLongLivedOperationIDs { [weak self]( opeIDs, error) in guard let self = self, error == nil, let ids = opeIDs else { return } @@ -96,9 +55,6 @@ extension CloudKitZone { self.container.fetchLongLivedOperation(withID: id, completionHandler: { [weak self](ope, error) in guard let self = self, error == nil else { return } if let modifyOp = ope as? CKModifyRecordsOperation { - modifyOp.modifyRecordsCompletionBlock = { (_,_,_) in - print("Resume modify records success!") - } self.container.add(modifyOp) } }) @@ -115,18 +71,16 @@ extension CloudKitZone { // }) // } - public func save(record: CKRecord, completion: @escaping (Result) -> Void) { + func save(record: CKRecord, completion: @escaping (Result) -> Void) { modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion) } - public func delete(externalID: String, completion: @escaping (Result) -> Void) { + func delete(externalID: String, completion: @escaping (Result) -> Void) { let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID) modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion) } - /// Sync local data to CloudKit - /// For more about the savePolicy: https://developer.apple.com/documentation/cloudkit/ckrecordsavepolicy - public func modify(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result) -> Void) { + func modify(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result) -> Void) { let op = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete) let config = CKOperation.Configuration() @@ -169,8 +123,6 @@ extension CloudKitZone { self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion) } case .limitExceeded: - /// CloudKit says maximum number of items in a single request is 400. - /// So I think 300 should be fine by them. let chunkedRecords = recordsToSave.chunked(into: 300) for chunk in chunkedRecords { self.modify(recordsToSave: chunk, recordIDsToDelete: recordIDsToDelete, completion: completion) @@ -185,12 +137,100 @@ extension CloudKitZone { database.add(op) } + func fetchChangesInZones(completion: @escaping (Result) -> Void) { + let zoneConfig = CKFetchRecordZoneChangesOperation.ZoneConfiguration() + zoneConfig.previousServerChangeToken = changeToken + let op = CKFetchRecordZoneChangesOperation(recordZoneIDs: [Self.zoneID], configurationsByRecordZoneID: [Self.zoneID: zoneConfig]) + op.fetchAllChanges = true + + op.recordZoneChangeTokensUpdatedBlock = { [weak self] zoneId, token, _ in + guard let self = self else { return } + self.changeToken = token + } + + op.recordChangedBlock = { [weak self] record in + guard let self = self else { return } + self.delegate?.cloudKitDidChange(record: record) + } + + op.recordWithIDWasDeletedBlock = { [weak self] recordId, recordType in + guard let self = self else { return } + self.delegate?.cloudKitDidDelete(recordType: recordType, recordID: recordId) + } + + op.recordZoneFetchCompletionBlock = { [weak self](zoneId ,token, _, _, error) in + guard let self = self else { return } + + switch CloudKitZoneResult.resolve(error) { + case .success: + self.changeToken = token + case .retry(let timeToWait): + self.retryOperationIfPossible(retryAfter: timeToWait) { + self.fetchChangesInZones(completion: completion) + } + default: + return + } + } + + op.fetchRecordZoneChangesCompletionBlock = { error in + if let error = error { + completion(.failure(error)) + } else { + completion(.success(())) + } + } + + database.add(op) + } + +} + +private extension CloudKitZone { + + var changeTokenKey: String { + return "cloudkit.server.token.\(Self.zoneID.zoneName)" + } + + var changeToken: CKServerChangeToken? { + get { + guard let tokenData = UserDefaults.standard.object(forKey: changeTokenKey) as? Data else { return nil } + return try? NSKeyedUnarchiver.unarchivedObject(ofClass: CKServerChangeToken.self, from: tokenData) + } + set { + guard let token = newValue, let data = try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: false) else { + UserDefaults.standard.removeObject(forKey: changeTokenKey) + return + } + UserDefaults.standard.set(data, forKey: changeTokenKey) + } + } + + var zoneConfiguration: CKFetchRecordZoneChangesOperation.ZoneConfiguration { + let config = CKFetchRecordZoneChangesOperation.ZoneConfiguration() + config.previousServerChangeToken = changeToken + return config + } + + func createZoneRecord(completion: @escaping (Result) -> Void) { + database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in + if let error = error { + DispatchQueue.main.async { + completion(.failure(error)) + } + } else { + DispatchQueue.main.async { + completion(.success(())) + } + } + } + } + func retryOperationIfPossible(retryAfter: Double, block: @escaping () -> ()) { let delayTime = DispatchTime.now() + retryAfter DispatchQueue.main.asyncAfter(deadline: delayTime, execute: { block() }) } - -} +} From 2afdd26c9d520470bc8fa3c54869ee02e99a22c3 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 29 Mar 2020 12:00:02 -0500 Subject: [PATCH 20/98] Change function names using the find suffix to use the existing suffix to match precedent. --- Frameworks/Account/Account.swift | 2 +- Frameworks/Account/AccountManager.swift | 2 +- Mac/MainWindow/Timeline/TimelineViewController.swift | 2 +- Shared/Data/AddWebFeedDefaultContainer.swift | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 103140166..4ecc12369 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -516,7 +516,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, return ensureFolder(with: folderName) } - public func findFolder(withDisplayName displayName: String) -> Folder? { + public func existingFolder(withDisplayName displayName: String) -> Folder? { return folders?.first(where: { $0.nameForDisplay == displayName }) } diff --git a/Frameworks/Account/AccountManager.swift b/Frameworks/Account/AccountManager.swift index 321e8fb5f..bcaace17f 100644 --- a/Frameworks/Account/AccountManager.swift +++ b/Frameworks/Account/AccountManager.swift @@ -72,7 +72,7 @@ public final class AccountManager: UnreadCountProvider { return lastArticleFetchEndTime } - public func findActiveAccount(forDisplayName displayName: String) -> Account? { + public func existingActiveAccount(forDisplayName displayName: String) -> Account? { return AccountManager.shared.activeAccounts.first(where: { $0.nameForDisplay == displayName }) } diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift index 2400d555c..7b2ca80b4 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController.swift @@ -476,7 +476,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr if isReadFiltered ?? false { if let accountName = userInfo[ArticlePathKey.accountName] as? String, - let account = AccountManager.shared.findActiveAccount(forDisplayName: accountName) { + let account = AccountManager.shared.existingActiveAccount(forDisplayName: accountName) { exceptionArticleFetcher = SingleArticleFetcher(account: account, articleID: articleID) fetchAndReplaceArticlesSync() } diff --git a/Shared/Data/AddWebFeedDefaultContainer.swift b/Shared/Data/AddWebFeedDefaultContainer.swift index 41e158ce9..0275f6159 100644 --- a/Shared/Data/AddWebFeedDefaultContainer.swift +++ b/Shared/Data/AddWebFeedDefaultContainer.swift @@ -14,7 +14,7 @@ struct AddWebFeedDefaultContainer { static var defaultContainer: Container? { if let accountID = AppDefaults.addWebFeedAccountID, let account = AccountManager.shared.activeAccounts.first(where: { $0.accountID == accountID }) { - if let folderName = AppDefaults.addWebFeedFolderName, let folder = account.findFolder(withDisplayName: folderName) { + if let folderName = AppDefaults.addWebFeedFolderName, let folder = account.existingFolder(withDisplayName: folderName) { return folder } else { return substituteContainerIfNeeded(account: account) From c0e1fbfff3c1f9f5187de0b4b1432bad110235a3 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 29 Mar 2020 12:07:54 -0500 Subject: [PATCH 21/98] Add external id lookups for folders and web feeds. --- Frameworks/Account/Account.swift | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 4ecc12369..a5834223c 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -143,14 +143,21 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, return nil } - private var webFeedDictionaryNeedsUpdate = true + private var webFeedDictionariesNeedUpdate = true private var _idToWebFeedDictionary = [String: WebFeed]() var idToWebFeedDictionary: [String: WebFeed] { - if webFeedDictionaryNeedsUpdate { + if webFeedDictionariesNeedUpdate { rebuildWebFeedDictionaries() } return _idToWebFeedDictionary } + private var _externalIDToWebFeedDictionary = [String: WebFeed]() + var externalIDToWebFeedDictionary: [String: WebFeed] { + if webFeedDictionariesNeedUpdate { + rebuildWebFeedDictionaries() + } + return _externalIDToWebFeedDictionary + } var username: String? { get { @@ -520,6 +527,10 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, return folders?.first(where: { $0.nameForDisplay == displayName }) } + public func existingFolder(withExternalID externalID: String) -> Folder? { + return folders?.first(where: { $0.externalID == externalID }) + } + func newWebFeed(with opmlFeedSpecifier: RSOPMLFeedSpecifier) -> WebFeed { let feedURL = opmlFeedSpecifier.feedURL let metadata = webFeedMetadata(feedURL: feedURL, webFeedID: feedURL) @@ -532,6 +543,10 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, return feed } + public func existingWebFeed(withExternalID externalID: String) -> WebFeed? { + return externalIDToWebFeedDictionary[externalID] + } + public func addWebFeed(_ feed: WebFeed, to container: Container, completion: @escaping (Result) -> Void) { delegate.addWebFeed(for: self, with: feed, to: container, completion: completion) } @@ -678,7 +693,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, // Or feeds inside folders were added or deleted. opmlFile.markAsDirty() flattenedWebFeedsNeedUpdate = true - webFeedDictionaryNeedsUpdate = true + webFeedDictionariesNeedUpdate = true } func update(_ webFeed: WebFeed, with parsedFeed: ParsedFeed, _ completion: @escaping DatabaseCompletionBlock) { @@ -1149,13 +1164,18 @@ private extension Account { func rebuildWebFeedDictionaries() { var idDictionary = [String: WebFeed]() - + var externalIDDictionary = [String: WebFeed]() + flattenedWebFeeds().forEach { (feed) in idDictionary[feed.webFeedID] = feed + if let externalID = feed.externalID { + externalIDDictionary[externalID] = feed + } } _idToWebFeedDictionary = idDictionary - webFeedDictionaryNeedsUpdate = false + _externalIDToWebFeedDictionary = externalIDDictionary + webFeedDictionariesNeedUpdate = false } func updateUnreadCount() { From 4f425c9c866e07d4102ef287a0c747b73ab8f283 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 29 Mar 2020 17:12:34 -0500 Subject: [PATCH 22/98] Implement web feed sync between devices. --- .../CloudKit/CKRecord+Extensions.swift | 8 ++ .../CloudKit/CloudKitAccountDelegate.swift | 29 ++++-- .../CloudKit/CloudKitAccountZone.swift | 13 ++- .../CloudKitAccountZoneDelegate.swift | 50 +++++++++- .../Account/CloudKit/CloudKitZone.swift | 92 ++++++++++--------- .../AccountsAddCloudKitWindowController.swift | 3 +- .../CloudKitAccountViewController.swift | 3 +- iOS/Settings/AddAccountViewController.swift | 2 +- 8 files changed, 138 insertions(+), 62 deletions(-) diff --git a/Frameworks/Account/CloudKit/CKRecord+Extensions.swift b/Frameworks/Account/CloudKit/CKRecord+Extensions.swift index bb1696a89..fc97d2dd7 100644 --- a/Frameworks/Account/CloudKit/CKRecord+Extensions.swift +++ b/Frameworks/Account/CloudKit/CKRecord+Extensions.swift @@ -9,6 +9,14 @@ import Foundation import CloudKit +extension CKRecord { + + var externalID: String { + return recordID.externalID + } + +} + extension CKRecord.ID { var externalID: String { diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 815b14c89..1029c2539 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -49,12 +49,20 @@ final class CloudKitAccountDelegate: AccountDelegate { accountZone = CloudKitAccountZone(container: container) let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3") database = SyncDatabase(databaseFilePath: databaseFilePath) + accountZone.refreshProgress = refreshProgress } func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { - refresher.refreshFeeds(account.flattenedWebFeeds()) { - account.metadata.lastArticleFetchEndTime = Date() - completion(.success(())) + accountZone.fetchChangesInZone() { result in + switch result { + case .success: + self.refresher.refreshFeeds(account.flattenedWebFeeds()) { + account.metadata.lastArticleFetchEndTime = Date() + completion(.success(())) + } + case .failure(let error): + completion(.failure(error)) + } } } @@ -119,11 +127,10 @@ final class CloudKitAccountDelegate: AccountDelegate { switch result { case .success(let feedSpecifiers): - guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), - let url = URL(string: bestFeedSpecifier.urlString) else { - self.refreshProgress.completeTask() - completion(.failure(AccountError.createErrorNotFound)) - return + guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), let url = URL(string: bestFeedSpecifier.urlString) else { + self.refreshProgress.completeTask() + completion(.failure(AccountError.createErrorNotFound)) + return } if account.hasWebFeed(withURL: bestFeedSpecifier.urlString) { @@ -132,7 +139,7 @@ final class CloudKitAccountDelegate: AccountDelegate { return } - self.accountZone.createWebFeed(url: urlString, editedName: name) { result in + self.accountZone.createWebFeed(url: bestFeedSpecifier.urlString, editedName: name) { result in switch result { case .success(let externalID): @@ -231,10 +238,12 @@ final class CloudKitAccountDelegate: AccountDelegate { } func accountDidInitialize(_ account: Account) { - accountZone.delegate = CloudKitAcountZoneDelegate(account: account) + accountZone.delegate = CloudKitAcountZoneDelegate(account: account, refreshProgress: refreshProgress) + accountZone.resumeLongLivedOperationIfPossible() } func accountWillBeDeleted(_ account: Account) { + accountZone.resetChangeToken() } static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL? = nil, completion: (Result) -> Void) { diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index 670b2c556..6fbff3139 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -7,6 +7,8 @@ // import Foundation +import os.log +import RSWeb import CloudKit final class CloudKitAccountZone: CloudKitZone { @@ -15,8 +17,11 @@ final class CloudKitAccountZone: CloudKitZone { return CKRecordZone.ID(zoneName: "Account", ownerName: CKCurrentUserDefaultName) } - let container: CKContainer - let database: CKDatabase + var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") + + weak var container: CKContainer? + weak var database: CKDatabase? + weak var refreshProgress: DownloadProgress? var delegate: CloudKitZoneDelegate? = nil struct CloudKitWebFeed { @@ -27,7 +32,7 @@ final class CloudKitAccountZone: CloudKitZone { } } - init(container: CKContainer) { + init(container: CKContainer) { self.container = container self.database = container.privateCloudDatabase } @@ -43,7 +48,7 @@ final class CloudKitAccountZone: CloudKitZone { save(record: record) { result in switch result { case .success: - completion(.success(record.recordID.externalID)) + completion(.success(record.externalID)) case .failure(let error): completion(.failure(error)) } diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift index f2194820c..7ba75fa8b 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -7,20 +7,26 @@ // import Foundation +import os.log +import RSWeb import CloudKit class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { + private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") + weak var account: Account? + weak var refreshProgress: DownloadProgress? - init(account: Account) { + init(account: Account, refreshProgress: DownloadProgress) { self.account = account + self.refreshProgress = refreshProgress } func cloudKitDidChange(record: CKRecord) { switch record.recordType { case CloudKitAccountZone.CloudKitWebFeed.recordType: - addWebFeed(record) + addOrUpdateWebFeed(record) default: assertionFailure("Unknown record type: \(record.recordType)") } @@ -35,12 +41,48 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { } } - func addWebFeed(_ record: CKRecord) { + func addOrUpdateWebFeed(_ record: CKRecord) { + guard let account = account else { return } + let editedName = record[CloudKitAccountZone.CloudKitWebFeed.Fields.editedName] as? String + + if let webFeed = account.existingWebFeed(withExternalID: record.externalID) { + webFeed.editedName = editedName + } else { + if let urlString = record[CloudKitAccountZone.CloudKitWebFeed.Fields.url] as? String, let url = URL(string: urlString) { + downloadAndAddWebFeed(url: url, editedName: editedName, externalID: record.externalID) + } else { + os_log(.error, log: self.log, "Failed to add or update web feed.") + } + } } func removeWebFeed(_ externalID: String) { - + if let webFeed = account?.existingWebFeed(withExternalID: externalID) { + account?.removeWebFeed(webFeed) + } + } + +} + +private extension CloudKitAcountZoneDelegate { + + func downloadAndAddWebFeed(url: URL, editedName: String?, externalID: String) { + guard let account = account else { return } + + let webFeed = account.createWebFeed(with: editedName, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) + webFeed.editedName = editedName + webFeed.externalID = externalID + account.addWebFeed(webFeed) + + refreshProgress?.addToNumberOfTasksAndRemaining(1) + InitialFeedDownloader.download(url) { parsedFeed in + self.refreshProgress?.completeTask() + if let parsedFeed = parsedFeed { + account.update(webFeed, with: parsedFeed, {_ in }) + } + } + } } diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 75be66fa1..83417d527 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -7,6 +7,8 @@ // import CloudKit +import os.log +import RSWeb enum CloudKitZoneError: Error { case userDeletedZone @@ -23,39 +25,33 @@ protocol CloudKitZone: class { static var zoneID: CKRecordZone.ID { get } - var container: CKContainer { get } - var database: CKDatabase { get } + var log: OSLog { get } + + var container: CKContainer? { get } + var database: CKDatabase? { get } + var refreshProgress: DownloadProgress? { get set } var delegate: CloudKitZoneDelegate? { get set } - - // func prepare() - - // func fetchChangesInDatabase(_ callback: ((Error?) -> Void)?) - - /// The CloudKit Best Practice is out of date, now use this: - /// https://developer.apple.com/documentation/cloudkit/ckoperation - /// Which problem does this func solve? E.g.: - /// 1.(Offline) You make a local change, involve a operation - /// 2. App exits or ejected by user - /// 3. Back to app again - /// The operation resumes! All works like a magic! - func resumeLongLivedOperationIfPossible() - + } extension CloudKitZone { + func resetChangeToken() { + changeToken = nil + } + func generateRecordID() -> CKRecord.ID { return CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID) } func resumeLongLivedOperationIfPossible() { - container.fetchAllLongLivedOperationIDs { [weak self]( opeIDs, error) in - guard let self = self, error == nil, let ids = opeIDs else { return } - for id in ids { - self.container.fetchLongLivedOperation(withID: id, completionHandler: { [weak self](ope, error) in - guard let self = self, error == nil else { return } + guard let container = container else { return } + container.fetchAllLongLivedOperationIDs { (opIDs, error) in + guard let opIDs = opIDs else { return } + for opID in opIDs { + container.fetchLongLivedOperation(withID: opID, completionHandler: { (ope, error) in if let modifyOp = ope as? CKModifyRecordsOperation { - self.container.add(modifyOp) + container.add(modifyOp) } }) } @@ -134,10 +130,13 @@ extension CloudKitZone { } } - database.add(op) + database?.add(op) } - func fetchChangesInZones(completion: @escaping (Result) -> Void) { + func fetchChangesInZone(completion: @escaping (Result) -> Void) { + + refreshProgress?.addToNumberOfTasksAndRemaining(1) + let zoneConfig = CKFetchRecordZoneChangesOperation.ZoneConfiguration() zoneConfig.previousServerChangeToken = changeToken let op = CKFetchRecordZoneChangesOperation(recordZoneIDs: [Self.zoneID], configurationsByRecordZoneID: [Self.zoneID: zoneConfig]) @@ -145,43 +144,54 @@ extension CloudKitZone { op.recordZoneChangeTokensUpdatedBlock = { [weak self] zoneId, token, _ in guard let self = self else { return } - self.changeToken = token + DispatchQueue.main.async { + self.changeToken = token + } } op.recordChangedBlock = { [weak self] record in guard let self = self else { return } - self.delegate?.cloudKitDidChange(record: record) + DispatchQueue.main.async { + self.delegate?.cloudKitDidChange(record: record) + } } op.recordWithIDWasDeletedBlock = { [weak self] recordId, recordType in guard let self = self else { return } - self.delegate?.cloudKitDidDelete(recordType: recordType, recordID: recordId) + DispatchQueue.main.async { + self.delegate?.cloudKitDidDelete(recordType: recordType, recordID: recordId) + } } - op.recordZoneFetchCompletionBlock = { [weak self](zoneId ,token, _, _, error) in + op.recordZoneFetchCompletionBlock = { [weak self] zoneId ,token, _, _, error in guard let self = self else { return } switch CloudKitZoneResult.resolve(error) { case .success: - self.changeToken = token + DispatchQueue.main.async { + self.changeToken = token + } case .retry(let timeToWait): self.retryOperationIfPossible(retryAfter: timeToWait) { - self.fetchChangesInZones(completion: completion) + self.fetchChangesInZone(completion: completion) } default: - return - } - } - - op.fetchRecordZoneChangesCompletionBlock = { error in - if let error = error { - completion(.failure(error)) - } else { - completion(.success(())) + os_log(.error, log: self.log, "%@ zone fetch changes error: %@.", zoneId.zoneName, error?.localizedDescription ?? "Unknown") } } - database.add(op) + op.fetchRecordZoneChangesCompletionBlock = { [weak self] error in + DispatchQueue.main.async { + self?.refreshProgress?.completeTask() + if let error = error { + completion(.failure(error)) + } else { + completion(.success(())) + } + } + } + + database?.add(op) } } @@ -213,7 +223,7 @@ private extension CloudKitZone { } func createZoneRecord(completion: @escaping (Result) -> Void) { - database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in + database?.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in if let error = error { DispatchQueue.main.async { completion(.failure(error)) diff --git a/Mac/Preferences/Accounts/AccountsAddCloudKitWindowController.swift b/Mac/Preferences/Accounts/AccountsAddCloudKitWindowController.swift index 5bd0e8916..3ce698537 100644 --- a/Mac/Preferences/Accounts/AccountsAddCloudKitWindowController.swift +++ b/Mac/Preferences/Accounts/AccountsAddCloudKitWindowController.swift @@ -35,7 +35,8 @@ class AccountsAddCloudKitWindowController: NSWindowController { } @IBAction func create(_ sender: Any) { - _ = AccountManager.shared.createAccount(type: .cloudKit) + let account = AccountManager.shared.createAccount(type: .cloudKit) + account.refreshAll(completion: { _ in }) hostWindow!.endSheet(window!, returnCode: NSApplication.ModalResponse.OK) } diff --git a/iOS/Account/CloudKitAccountViewController.swift b/iOS/Account/CloudKitAccountViewController.swift index e26135ffd..9c026143a 100644 --- a/iOS/Account/CloudKitAccountViewController.swift +++ b/iOS/Account/CloudKitAccountViewController.swift @@ -25,7 +25,8 @@ class CloudKitAccountViewController: UITableViewController { } @IBAction func add(_ sender: Any) { - _ = AccountManager.shared.createAccount(type: .cloudKit) + let account = AccountManager.shared.createAccount(type: .cloudKit) + account.refreshAll(completion: { _ in }) dismiss(animated: true, completion: nil) delegate?.dismiss() } diff --git a/iOS/Settings/AddAccountViewController.swift b/iOS/Settings/AddAccountViewController.swift index e3d55dc1f..20a59d6c6 100644 --- a/iOS/Settings/AddAccountViewController.swift +++ b/iOS/Settings/AddAccountViewController.swift @@ -47,7 +47,7 @@ class AddAccountViewController: UITableViewController, AddAccountDismissDelegate cell.accountNameLabel?.text = Account.defaultLocalAccountName cell.accountImage?.image = AppAssets.image(for: .onMyMac) case .cloudKit: - cell.accountNameLabel?.text = NSLocalizedString("CloudKit", comment: "CloudKit") + cell.accountNameLabel?.text = NSLocalizedString("iCloud", comment: "iCloud") cell.accountImage?.image = AppAssets.accountCloudKitImage case .feedbin: cell.accountNameLabel?.text = NSLocalizedString("Feedbin", comment: "Feedbin") From e2d8db6f26c1e984fb1ab4d6507e4facc16d3fad Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 29 Mar 2020 17:53:11 -0500 Subject: [PATCH 23/98] Added feed rename sync to iCloud. --- .../CloudKit/CloudKitAccountDelegate.swift | 12 +++++++++-- .../CloudKit/CloudKitAccountZone.swift | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 1029c2539..45b49173b 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -178,8 +178,16 @@ final class CloudKitAccountDelegate: AccountDelegate { } func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result) -> Void) { - feed.editedName = name - completion(.success(())) + let editedName = name.isEmpty ? nil : name + accountZone.renameWebFeed(feed, editedName: editedName) { result in + switch result { + case .success: + feed.editedName = name + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } } func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result) -> Void) { diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index 6fbff3139..70b1b9aac 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -55,6 +55,27 @@ final class CloudKitAccountZone: CloudKitZone { } } + func renameWebFeed(_ webFeed: WebFeed, editedName: String?, completion: @escaping (Result) -> Void) { + guard let externalID = webFeed.externalID else { + completion(.failure(CloudKitZoneError.invalidParameter)) + return + } + + let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID) + let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: recordID) + record[CloudKitWebFeed.Fields.editedName] = editedName + + save(record: record) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } + + /// Deletes a web feed from iCloud func removeWebFeed(_ webFeed: WebFeed, completion: @escaping (Result) -> Void) { guard let externalID = webFeed.externalID else { completion(.failure(CloudKitZoneError.invalidParameter)) From 2c4ee99dc2811c608087af44d225e23ee86f0f12 Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Sun, 29 Mar 2020 18:51:03 -0700 Subject: [PATCH 24/98] Create and use ArticlesDatabase.RetentionStyle enum. --- Frameworks/Account/Account.swift | 3 ++- .../ArticlesDatabase/ArticlesDatabase.swift | 24 +++++++++++++++---- .../ArticlesDatabase/ArticlesTable.swift | 7 ++++-- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 271677817..584c051df 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -251,7 +251,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, self.dataFolder = dataFolder let databaseFilePath = (dataFolder as NSString).appendingPathComponent("DB.sqlite3") - self.database = ArticlesDatabase(databaseFilePath: databaseFilePath, accountID: accountID) + let retentionStyle: ArticlesDatabase.RetentionStyle = type == .onMyMac ? .feedBased : .syncSystem + self.database = ArticlesDatabase(databaseFilePath: databaseFilePath, accountID: accountID, retentionStyle: retentionStyle) switch type { case .onMyMac: diff --git a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift index ec94b2e35..d6b79d33b 100644 --- a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift +++ b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift @@ -43,14 +43,21 @@ public typealias ArticleStatusesResultBlock = (ArticleStatusesResult) -> Void public final class ArticlesDatabase { + public enum RetentionStyle { + case feedBased // Local and iCloud: article retention is defined by contents of feed + case syncSystem // Feedbin, Feedly, etc.: article retention is defined by external system + } + private let articlesTable: ArticlesTable private let queue: DatabaseQueue private let operationQueue = MainThreadOperationQueue() + private let retentionStyle: RetentionStyle - public init(databaseFilePath: String, accountID: String) { + public init(databaseFilePath: String, accountID: String, retentionStyle: RetentionStyle) { let queue = DatabaseQueue(databasePath: databaseFilePath) self.queue = queue - self.articlesTable = ArticlesTable(name: DatabaseTableName.articles, accountID: accountID, queue: queue) + self.articlesTable = ArticlesTable(name: DatabaseTableName.articles, accountID: accountID, queue: queue, retentionStyle: retentionStyle) + self.retentionStyle = retentionStyle try! queue.runCreateStatements(ArticlesDatabase.tableCreationStatements) queue.runInDatabase { databaseResult in @@ -62,7 +69,6 @@ public final class ArticlesDatabase { database.executeStatements("DROP TABLE if EXISTS tags;DROP INDEX if EXISTS tags_tagName_index;DROP INDEX if EXISTS articles_feedID_index;DROP INDEX if EXISTS statuses_read_index;DROP TABLE if EXISTS attachments;DROP TABLE if EXISTS attachmentsLookup;") } -// queue.vacuumIfNeeded(daysBetweenVacuums: 9) // TODO: restore this after we do database cleanups. DispatchQueue.main.async { self.articlesTable.indexUnindexedArticles() } @@ -183,8 +189,14 @@ public final class ArticlesDatabase { // MARK: - Saving and Updating Articles - /// Update articles and save new ones. + /// Update articles and save new ones — for feed-based systems (local and iCloud). + public func update(with feed: ParsedFeed, completion: @escaping UpdateArticlesCompletionBlock) { + precondition(retentionStyle == .feedBased) + } + + /// Update articles and save new ones — for sync systems (Feedbin, Feedly, etc.). public func update(webFeedIDsAndItems: [String: Set], defaultRead: Bool, completion: @escaping UpdateArticlesCompletionBlock) { + precondition(retentionStyle == .syncSystem) articlesTable.update(webFeedIDsAndItems, defaultRead, completion) } @@ -219,6 +231,7 @@ public final class ArticlesDatabase { articlesTable.createStatusesIfNeeded(articleIDs, completion) } +#if os(iOS) // MARK: - Suspend and Resume (for iOS) /// Cancel current operations and close the database. @@ -239,7 +252,8 @@ public final class ArticlesDatabase { queue.resume() operationQueue.resume() } - +#endif + // MARK: - Caches /// Call to free up some memory. Should be done when the app is backgrounded, for instance. diff --git a/Frameworks/ArticlesDatabase/ArticlesTable.swift b/Frameworks/ArticlesDatabase/ArticlesTable.swift index 35b52d583..8b78d22fe 100644 --- a/Frameworks/ArticlesDatabase/ArticlesTable.swift +++ b/Frameworks/ArticlesDatabase/ArticlesTable.swift @@ -19,6 +19,8 @@ final class ArticlesTable: DatabaseTable { private let queue: DatabaseQueue private let statusesTable: StatusesTable private let authorsLookupTable: DatabaseLookupTable + private let retentionStyle: ArticlesDatabase.RetentionStyle + private var articlesCache = [String: Article]() private lazy var searchTable: SearchTable = { @@ -30,13 +32,14 @@ final class ArticlesTable: DatabaseTable { private typealias ArticlesFetchMethod = (FMDatabase) -> Set
- init(name: String, accountID: String, queue: DatabaseQueue) { + init(name: String, accountID: String, queue: DatabaseQueue, retentionStyle: ArticlesDatabase.RetentionStyle) { self.name = name self.accountID = accountID self.queue = queue self.statusesTable = StatusesTable(queue: queue) - + self.retentionStyle = retentionStyle + let authorsTable = AuthorsTable(name: DatabaseTableName.authors) self.authorsLookupTable = DatabaseLookupTable(name: DatabaseTableName.authorsLookup, objectIDKey: DatabaseKey.articleID, relatedObjectIDKey: DatabaseKey.authorID, relatedTable: authorsTable, relationshipName: RelationshipName.authors) } From 85b24ff92d07c4fa179bc6a68118fcf77f958d56 Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Sun, 29 Mar 2020 18:53:15 -0700 Subject: [PATCH 25/98] Add parentheses in the right places to make Xcode 11.4 happy with our tuples. --- Frameworks/Account/Feedly/FeedlyAPICaller.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Frameworks/Account/Feedly/FeedlyAPICaller.swift b/Frameworks/Account/Feedly/FeedlyAPICaller.swift index dfd101325..d52c83bb8 100644 --- a/Frameworks/Account/Feedly/FeedlyAPICaller.swift +++ b/Frameworks/Account/Feedly/FeedlyAPICaller.swift @@ -302,7 +302,7 @@ final class FeedlyAPICaller { transport.send(request: request, resultType: [FeedlyFeed].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in switch result { - case .success(let httpResponse, _): + case .success((let httpResponse, _)): if httpResponse.statusCode == 200 { completion(.success(())) } else { @@ -364,7 +364,7 @@ extension FeedlyAPICaller: FeedlyAddFeedToCollectionService { transport.send(request: request, resultType: [FeedlyFeed].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in switch result { - case .success(_, let collectionFeeds): + case .success((_, let collectionFeeds)): if let feeds = collectionFeeds { completion(.success(feeds)) } else { From cf98ff49ea617b60cf4d864580cf2b1361ea16f5 Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Sun, 29 Mar 2020 23:20:01 -0700 Subject: [PATCH 26/98] Implement retention policy for feed-based accounts (local, iCloud). --- Frameworks/Account/Account.swift | 91 +++++++++++-------- .../ArticlesDatabase/ArticlesDatabase.swift | 7 +- .../ArticlesDatabase/ArticlesTable.swift | 78 ++++++++++++++++ .../Extensions/Article+Database.swift | 11 ++- 4 files changed, 147 insertions(+), 40 deletions(-) diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 584c051df..165bc9893 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -677,56 +677,41 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } func update(_ webFeed: WebFeed, with parsedFeed: ParsedFeed, _ completion: @escaping DatabaseCompletionBlock) { - // Used only by an On My Mac account. + // Used only by an On My Mac or iCloud account. + precondition(Thread.isMainThread) + precondition(type == .onMyMac) // TODO: allow iCloud + webFeed.takeSettings(from: parsedFeed) - let webFeedIDsAndItems = [webFeed.webFeedID: parsedFeed.items] - update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: false, completion: completion) + let parsedItems = parsedFeed.items + guard !parsedItems.isEmpty else { + completion(nil) + return + } + + database.update(with: parsedItems, webFeedID: webFeed.webFeedID) { updateArticlesResult in + switch updateArticlesResult { + case .success(let newAndUpdatedArticles): + self.sendNotificationAbout(newAndUpdatedArticles) + completion(nil) + case .failure(let databaseError): + completion(databaseError) + } + } } func update(webFeedIDsAndItems: [String: Set], defaultRead: Bool, completion: @escaping DatabaseCompletionBlock) { + // Used only by syncing systems. precondition(Thread.isMainThread) + precondition(type != .onMyMac) // TODO: also make sure type != iCloud guard !webFeedIDsAndItems.isEmpty else { completion(nil) return } database.update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: defaultRead) { updateArticlesResult in - - func sendNotificationAbout(newArticles: Set
?, updatedArticles: Set
?) { - var webFeeds = Set() - - if let newArticles = newArticles { - webFeeds.formUnion(Set(newArticles.compactMap { $0.webFeed })) - } - if let updatedArticles = updatedArticles { - webFeeds.formUnion(Set(updatedArticles.compactMap { $0.webFeed })) - } - - var shouldSendNotification = false - var userInfo = [String: Any]() - - if let newArticles = newArticles, !newArticles.isEmpty { - shouldSendNotification = true - userInfo[UserInfoKey.newArticles] = newArticles - self.updateUnreadCounts(for: webFeeds) { - NotificationCenter.default.post(name: .DownloadArticlesDidUpdateUnreadCounts, object: self, userInfo: nil) - } - } - - if let updatedArticles = updatedArticles, !updatedArticles.isEmpty { - shouldSendNotification = true - userInfo[UserInfoKey.updatedArticles] = updatedArticles - } - - if shouldSendNotification { - userInfo[UserInfoKey.webFeeds] = webFeeds - NotificationCenter.default.post(name: .AccountDidDownloadArticles, object: self, userInfo: userInfo) - } - } - switch updateArticlesResult { case .success(let newAndUpdatedArticles): - sendNotificationAbout(newArticles: newAndUpdatedArticles.newArticles, updatedArticles: newAndUpdatedArticles.updatedArticles) + self.sendNotificationAbout(newAndUpdatedArticles) completion(nil) case .failure(let databaseError): completion(databaseError) @@ -1246,6 +1231,38 @@ private extension Account { feed.unreadCount = unreadCount } } + + func sendNotificationAbout(_ newAndUpdatedArticles: NewAndUpdatedArticles) { + var webFeeds = Set() + + if let newArticles = newAndUpdatedArticles.newArticles { + webFeeds.formUnion(Set(newArticles.compactMap { $0.webFeed })) + } + if let updatedArticles = newAndUpdatedArticles.updatedArticles { + webFeeds.formUnion(Set(updatedArticles.compactMap { $0.webFeed })) + } + + var shouldSendNotification = false + var userInfo = [String: Any]() + + if let newArticles = newAndUpdatedArticles.newArticles, !newArticles.isEmpty { + shouldSendNotification = true + userInfo[UserInfoKey.newArticles] = newArticles + self.updateUnreadCounts(for: webFeeds) { + NotificationCenter.default.post(name: .DownloadArticlesDidUpdateUnreadCounts, object: self, userInfo: nil) + } + } + + if let updatedArticles = newAndUpdatedArticles.updatedArticles, !updatedArticles.isEmpty { + shouldSendNotification = true + userInfo[UserInfoKey.updatedArticles] = updatedArticles + } + + if shouldSendNotification { + userInfo[UserInfoKey.webFeeds] = webFeeds + NotificationCenter.default.post(name: .AccountDidDownloadArticles, object: self, userInfo: userInfo) + } + } } // MARK: - Container Overrides diff --git a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift index d6b79d33b..4ca73e177 100644 --- a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift +++ b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift @@ -190,8 +190,9 @@ public final class ArticlesDatabase { // MARK: - Saving and Updating Articles /// Update articles and save new ones — for feed-based systems (local and iCloud). - public func update(with feed: ParsedFeed, completion: @escaping UpdateArticlesCompletionBlock) { + public func update(with parsedItems: Set, webFeedID: String, completion: @escaping UpdateArticlesCompletionBlock) { precondition(retentionStyle == .feedBased) + articlesTable.update(parsedItems, webFeedID, completion) } /// Update articles and save new ones — for sync systems (Feedbin, Feedly, etc.). @@ -268,7 +269,9 @@ public final class ArticlesDatabase { /// Calls the various clean-up functions. public func cleanupDatabaseAtStartup(subscribedToWebFeedIDs: Set) { - articlesTable.deleteOldArticles() + if retentionStyle == .syncSystem { + articlesTable.deleteOldArticles() + } articlesTable.deleteArticlesNotInSubscribedToFeedIDs(subscribedToWebFeedIDs) } } diff --git a/Frameworks/ArticlesDatabase/ArticlesTable.swift b/Frameworks/ArticlesDatabase/ArticlesTable.swift index 8b78d22fe..9aa7bc06b 100644 --- a/Frameworks/ArticlesDatabase/ArticlesTable.swift +++ b/Frameworks/ArticlesDatabase/ArticlesTable.swift @@ -172,7 +172,78 @@ final class ArticlesTable: DatabaseTable { // MARK: - Updating + func update(_ parsedItems: Set, _ webFeedID: String, _ completion: @escaping UpdateArticlesCompletionBlock) { + precondition(retentionStyle == .feedBased) + if parsedItems.isEmpty { + callUpdateArticlesCompletionBlock(nil, nil, completion) + return + } + + // 1. Ensure statuses for all the incoming articles. + // 2. Create incoming articles with parsedItems. + // 3. Ignore incoming articles that are userDeleted + // 4. Fetch all articles for the feed. + // 5. Create array of Articles not in database and save them. + // 6. Create array of updated Articles and save what’s changed. + // 7. Call back with new and updated Articles. + // 8. Delete Articles in database no longer present in the feed. + // 9. Update search index. + + self.queue.runInTransaction { (databaseResult) in + + func makeDatabaseCalls(_ database: FMDatabase) { + let articleIDs = parsedItems.articleIDs() + + let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, false, database) //1 + assert(statusesDictionary.count == articleIDs.count) + + let allIncomingArticles = Article.articlesWithParsedItems(parsedItems, webFeedID, self.accountID, statusesDictionary) //2 + let incomingArticles = Set(allIncomingArticles.filter { !($0.status.userDeleted) }) //3 + if incomingArticles.isEmpty { + self.callUpdateArticlesCompletionBlock(nil, nil, completion) + return + } + + let fetchedArticles = self.fetchArticlesForFeedID(webFeedID, withLimits: false, database) //4 + let fetchedArticlesDictionary = fetchedArticles.dictionary() + + let newArticles = self.findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5 + let updatedArticles = self.findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6 + + self.callUpdateArticlesCompletionBlock(newArticles, updatedArticles, completion) //7 + + self.addArticlesToCache(newArticles) + self.addArticlesToCache(updatedArticles) + + // 8. Delete articles no longer in feed. + let articleIDsToDelete = fetchedArticles.articleIDs().filter { !(articleIDs.contains($0)) } + if !articleIDsToDelete.isEmpty { + self.removeArticles(articleIDsToDelete, database) + self.removeArticleIDsFromCache(articleIDsToDelete) + } + + // 9. Update search index. + if let newArticles = newArticles { + self.searchTable.indexNewArticles(newArticles, database) + } + if let updatedArticles = updatedArticles { + self.searchTable.indexUpdatedArticles(updatedArticles, database) + } + } + + switch databaseResult { + case .success(let database): + makeDatabaseCalls(database) + case .failure(let databaseError): + DispatchQueue.main.async { + completion(.failure(databaseError)) + } + } + } + } + func update(_ webFeedIDsAndItems: [String: Set], _ read: Bool, _ completion: @escaping UpdateArticlesCompletionBlock) { + precondition(retentionStyle == .syncSystem) if webFeedIDsAndItems.isEmpty { callUpdateArticlesCompletionBlock(nil, nil, completion) return @@ -853,6 +924,12 @@ private extension ArticlesTable { } } + func removeArticleIDsFromCache(_ articleIDs: Set) { + for articleID in articleIDs { + articlesCache[articleID] = nil + } + } + func articleIsIgnorable(_ article: Article) -> Bool { // Ignorable articles: either userDeleted==1 or (not starred and arrival date > 4 months). if article.status.userDeleted { @@ -866,6 +943,7 @@ private extension ArticlesTable { func filterIncomingArticles(_ articles: Set
) -> Set
{ // Drop Articles that we can ignore. + precondition(retentionStyle == .syncSystem) return Set(articles.filter{ !articleIsIgnorable($0) }) } diff --git a/Frameworks/ArticlesDatabase/Extensions/Article+Database.swift b/Frameworks/ArticlesDatabase/Extensions/Article+Database.swift index 47b88caf7..d3e78c687 100644 --- a/Frameworks/ArticlesDatabase/Extensions/Article+Database.swift +++ b/Frameworks/ArticlesDatabase/Extensions/Article+Database.swift @@ -112,8 +112,12 @@ extension Article { // return Set(parsedItems.map{ Article(parsedItem: $0, maximumDateAllowed: maximumDateAllowed, accountID: accountID, feedID: feedID, status: statusesDictionary[$0.articleID]!) }) // } + private static func _maximumDateAllowed() -> Date { + return Date().addingTimeInterval(60 * 60 * 24) // Allow dates up to about 24 hours ahead of now + } + static func articlesWithWebFeedIDsAndItems(_ webFeedIDsAndItems: [String: Set], _ accountID: String, _ statusesDictionary: [String: ArticleStatus]) -> Set
{ - let maximumDateAllowed = Date().addingTimeInterval(60 * 60 * 24) // Allow dates up to about 24 hours ahead of now + let maximumDateAllowed = _maximumDateAllowed() var feedArticles = Set
() for (webFeedID, parsedItems) in webFeedIDsAndItems { for parsedItem in parsedItems { @@ -124,6 +128,11 @@ extension Article { } return feedArticles } + + static func articlesWithParsedItems(_ parsedItems: Set, _ webFeedID: String, _ accountID: String, _ statusesDictionary: [String: ArticleStatus]) -> Set
{ + let maximumDateAllowed = _maximumDateAllowed() + return Set(parsedItems.map{ Article(parsedItem: $0, maximumDateAllowed: maximumDateAllowed, accountID: accountID, webFeedID: webFeedID, status: statusesDictionary[$0.articleID]!) }) + } } extension Article: DatabaseObject { From 187121298ec0fae8b926cbce0956df56ef143bbe Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Mon, 30 Mar 2020 02:48:25 -0500 Subject: [PATCH 27/98] Added support for CloudKit push notifications (subscriptions). --- Frameworks/Account/Account.swift | 6 +- Frameworks/Account/AccountDelegate.swift | 2 + Frameworks/Account/AccountManager.swift | 18 +++++- .../CloudKit/CloudKitAccountDelegate.swift | 25 ++++++++- .../Account/CloudKit/CloudKitZone.swift | 55 ++++++++++++++----- .../FeedWranglerAccountDelegate.swift | 4 ++ .../Feedbin/FeedbinAccountDelegate.swift | 10 +++- .../Feedly/FeedlyAccountDelegate.swift | 4 ++ .../LocalAccount/LocalAccountDelegate.swift | 4 ++ .../NewsBlur/NewsBlurAccountDelegate.swift | 4 ++ .../ReaderAPI/ReaderAPIAccountDelegate.swift | 6 +- Mac/AppDelegate.swift | 6 ++ iOS/AppDelegate.swift | 19 ++++--- iOS/Resources/Info.plist | 2 + 14 files changed, 135 insertions(+), 30 deletions(-) diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index a5834223c..0fc726544 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -377,8 +377,12 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, grantingType.requestOAuthAccessToken(with: response, transport: transport, completion: completion) } + public func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + delegate.receiveRemoteNotification(for: self, userInfo: userInfo, completion: completion) + } + public func refreshAll(completion: @escaping (Result) -> Void) { - self.delegate.refreshAll(for: self, completion: completion) + delegate.refreshAll(for: self, completion: completion) } public func syncArticleStatus(completion: ((Result) -> Void)? = nil) { diff --git a/Frameworks/Account/AccountDelegate.swift b/Frameworks/Account/AccountDelegate.swift index edef2fb1c..0f0025287 100644 --- a/Frameworks/Account/AccountDelegate.swift +++ b/Frameworks/Account/AccountDelegate.swift @@ -22,6 +22,8 @@ protocol AccountDelegate { var refreshProgress: DownloadProgress { get } + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) diff --git a/Frameworks/Account/AccountManager.swift b/Frameworks/Account/AccountManager.swift index bcaace17f..c2b1a5da0 100644 --- a/Frameworks/Account/AccountManager.swift +++ b/Frameworks/Account/AccountManager.swift @@ -184,7 +184,22 @@ public final class AccountManager: UnreadCountProvider { accounts.forEach { $0.resume() } } - public func refreshAll(errorHandler: @escaping (Error) -> Void, completion: (() ->Void)? = nil) { + public func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: (() -> Void)? = nil) { + let group = DispatchGroup() + + activeAccounts.forEach { account in + group.enter() + account.receiveRemoteNotification(userInfo: userInfo) { + group.leave() + } + } + + group.notify(queue: DispatchQueue.main) { + completion?() + } + } + + public func refreshAll(errorHandler: @escaping (Error) -> Void, completion: (() -> Void)? = nil) { let group = DispatchGroup() activeAccounts.forEach { account in @@ -203,7 +218,6 @@ public final class AccountManager: UnreadCountProvider { group.notify(queue: DispatchQueue.main) { completion?() } - } public func syncArticleStatusAll(completion: (() -> Void)? = nil) { diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 45b49173b..950d4cd40 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -30,6 +30,7 @@ final class CloudKitAccountDelegate: AccountDelegate { return CKContainer(identifier: "iCloud.\(orgID).NetNewsWire") }() + private lazy var zones = [accountZone] private let accountZone: CloudKitAccountZone private let refresher = LocalAccountRefresher() @@ -52,6 +53,21 @@ final class CloudKitAccountDelegate: AccountDelegate { accountZone.refreshProgress = refreshProgress } + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + let group = DispatchGroup() + + zones.forEach { zone in + group.enter() + zone.receiveRemoteNotification(userInfo: userInfo) { + group.leave() + } + } + + group.notify(queue: DispatchQueue.main) { + completion() + } + } + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { accountZone.fetchChangesInZone() { result in switch result { @@ -247,11 +263,16 @@ final class CloudKitAccountDelegate: AccountDelegate { func accountDidInitialize(_ account: Account) { accountZone.delegate = CloudKitAcountZoneDelegate(account: account, refreshProgress: refreshProgress) - accountZone.resumeLongLivedOperationIfPossible() + zones.forEach { zone in + zone.resumeLongLivedOperationIfPossible() + zone.subscribe() + } } func accountWillBeDeleted(_ account: Account) { - accountZone.resetChangeToken() + zones.forEach { zone in + zone.resetChangeToken() + } } static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL? = nil, completion: (Result) -> Void) { diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 83417d527..062f24607 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -58,14 +58,43 @@ extension CloudKitZone { } } - // func startObservingRemoteChanges() { - // NotificationCenter.default.addObserver(forName: Notifications.cloudKitDataDidChangeRemotely.name, object: nil, queue: nil, using: { [weak self](_) in - // guard let self = self else { return } - // DispatchQueue.global(qos: .utility).async { - // self.fetchChangesInDatabase(nil) - // } - // }) - // } + func subscribe() { + + let subscription = CKRecordZoneSubscription(zoneID: Self.zoneID) + + let info = CKSubscription.NotificationInfo() + info.shouldSendContentAvailable = true + subscription.notificationInfo = info + + database?.save(subscription) { _, error in + switch CloudKitZoneResult.resolve(error) { + case .success: + break + case .retry(let timeToWait): + self.retryOperationIfPossible(retryAfter: timeToWait) { + self.subscribe() + } + default: + os_log(.error, log: self.log, "%@ zone fetch changes error: %@.", Self.zoneID.zoneName, error?.localizedDescription ?? "Unknown") + } + } + + } + + func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + let note = CKRecordZoneNotification(fromRemoteNotificationDictionary: userInfo) + guard note?.recordZoneID?.zoneName == Self.zoneID.zoneName else { + completion() + return + } + + fetchChangesInZone() { result in + if case .failure(let error) = result { + os_log(.error, log: self.log, "%@ zone remote notification fetch error: %@.", Self.zoneID.zoneName, error.localizedDescription) + } + completion() + } + } func save(record: CKRecord, completion: @escaping (Result) -> Void) { modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion) @@ -142,7 +171,7 @@ extension CloudKitZone { let op = CKFetchRecordZoneChangesOperation(recordZoneIDs: [Self.zoneID], configurationsByRecordZoneID: [Self.zoneID: zoneConfig]) op.fetchAllChanges = true - op.recordZoneChangeTokensUpdatedBlock = { [weak self] zoneId, token, _ in + op.recordZoneChangeTokensUpdatedBlock = { [weak self] zoneID, token, _ in guard let self = self else { return } DispatchQueue.main.async { self.changeToken = token @@ -156,14 +185,14 @@ extension CloudKitZone { } } - op.recordWithIDWasDeletedBlock = { [weak self] recordId, recordType in + op.recordWithIDWasDeletedBlock = { [weak self] recordID, recordType in guard let self = self else { return } DispatchQueue.main.async { - self.delegate?.cloudKitDidDelete(recordType: recordType, recordID: recordId) + self.delegate?.cloudKitDidDelete(recordType: recordType, recordID: recordID) } } - op.recordZoneFetchCompletionBlock = { [weak self] zoneId ,token, _, _, error in + op.recordZoneFetchCompletionBlock = { [weak self] zoneID ,token, _, _, error in guard let self = self else { return } switch CloudKitZoneResult.resolve(error) { @@ -176,7 +205,7 @@ extension CloudKitZone { self.fetchChangesInZone(completion: completion) } default: - os_log(.error, log: self.log, "%@ zone fetch changes error: %@.", zoneId.zoneName, error?.localizedDescription ?? "Unknown") + os_log(.error, log: self.log, "%@ zone fetch changes error: %@.", zoneID.zoneName, error?.localizedDescription ?? "Unknown") } } diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index 9c760fd88..23fe144d7 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -60,6 +60,10 @@ final class FeedWranglerAccountDelegate: AccountDelegate { caller.logout() { _ in } } + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + completion() + } + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { refreshProgress.addToNumberOfTasksAndRemaining(6) diff --git a/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift b/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift index af2cda43a..512fd3d1e 100644 --- a/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift +++ b/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift @@ -41,6 +41,8 @@ final class FeedbinAccountDelegate: AccountDelegate { caller.accountMetadata = accountMetadata } } + + var refreshProgress = DownloadProgress(numberOfTasks: 0) init(dataFolder: String, transport: Transport?) { @@ -71,9 +73,11 @@ final class FeedbinAccountDelegate: AccountDelegate { } } - - var refreshProgress = DownloadProgress(numberOfTasks: 0) - + + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + completion() + } + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { refreshProgress.addToNumberOfTasksAndRemaining(5) diff --git a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift index 369e237f8..09438c871 100644 --- a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift +++ b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift @@ -95,6 +95,10 @@ final class FeedlyAccountDelegate: AccountDelegate { // MARK: Account API + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + completion() + } + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { assert(Thread.isMainThread) diff --git a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift index 4fcec7b45..a92ec95ae 100644 --- a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift +++ b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift @@ -31,6 +31,10 @@ final class LocalAccountDelegate: AccountDelegate { return refresher.progress } + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + completion() + } + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { refresher.refreshFeeds(account.flattenedWebFeeds()) { account.metadata.lastArticleFetchEndTime = Date() diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 716ee1870..6e6cc5c0d 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -57,6 +57,10 @@ final class NewsBlurAccountDelegate: AccountDelegate { database = SyncDatabase(databaseFilePath: dataFolder.appending("/DB.sqlite3")) } + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + completion() + } + func refreshAll(for account: Account, completion: @escaping (Result) -> ()) { self.refreshProgress.addToNumberOfTasksAndRemaining(5) diff --git a/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift b/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift index ab36e98d6..dcc29b442 100644 --- a/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift +++ b/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift @@ -47,6 +47,8 @@ final class ReaderAPIAccountDelegate: AccountDelegate { } } + var refreshProgress = DownloadProgress(numberOfTasks: 0) + init(dataFolder: String, transport: Transport?) { let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3") @@ -77,7 +79,9 @@ final class ReaderAPIAccountDelegate: AccountDelegate { } - var refreshProgress = DownloadProgress(numberOfTasks: 0) + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + completion() + } func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index b771a5530..3edb0d1a6 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -267,6 +267,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, CrashReporter.check(appName: "NetNewsWire") } #endif + + NSApplication.shared.registerForRemoteNotifications() } func application(_ application: NSApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([NSUserActivityRestoring]) -> Void) -> Bool { @@ -302,6 +304,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, saveState() } + func application(_ application: NSApplication, didReceiveRemoteNotification userInfo: [String : Any]) { + AccountManager.shared.receiveRemoteNotification(userInfo: userInfo) + } + func applicationWillTerminate(_ notification: Notification) { shuttingDown = true saveState() diff --git a/iOS/AppDelegate.swift b/iOS/AppDelegate.swift index e6f25a9ce..965e34917 100644 --- a/iOS/AppDelegate.swift +++ b/iOS/AppDelegate.swift @@ -91,15 +91,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD self.unreadCount = AccountManager.shared.unreadCount } - UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .sound, .alert]) { (granted, error) in - if granted { - DispatchQueue.main.async { - UIApplication.shared.registerForRemoteNotifications() - } - } - } - UNUserNotificationCenter.current().delegate = self + UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .sound, .alert]) { _, _ in } + UIApplication.shared.registerForRemoteNotifications() + userNotificationManager = UserNotificationManager() extensionContainersFile = ExtensionContainersFile() @@ -115,6 +110,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } + func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + DispatchQueue.main.async { + AccountManager.shared.receiveRemoteNotification(userInfo: userInfo) { + completionHandler(.newData) + } + } + } + func applicationWillTerminate(_ application: UIApplication) { shuttingDown = true } diff --git a/iOS/Resources/Info.plist b/iOS/Resources/Info.plist index 9251a4909..2baef99bc 100644 --- a/iOS/Resources/Info.plist +++ b/iOS/Resources/Info.plist @@ -92,6 +92,8 @@ UIBackgroundModes fetch + processing + remote-notification UILaunchStoryboardName LaunchScreenPhone From d9e5350804511c1de606b41d4e058b398e8a80e4 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Mon, 30 Mar 2020 03:00:52 -0500 Subject: [PATCH 28/98] Hide credentials section for iCloud. --- iOS/Inspector/AccountInspectorViewController.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/iOS/Inspector/AccountInspectorViewController.swift b/iOS/Inspector/AccountInspectorViewController.swift index 70022f211..cc7c5be5b 100644 --- a/iOS/Inspector/AccountInspectorViewController.swift +++ b/iOS/Inspector/AccountInspectorViewController.swift @@ -107,8 +107,7 @@ extension AccountInspectorViewController { return true } switch account.type { - case .onMyMac, - .feedly: + case .onMyMac, .cloudKit, .feedly: return true default: return false From d0852d89547e231934a27db18c749725caa69d5d Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Mon, 30 Mar 2020 10:56:52 -0500 Subject: [PATCH 29/98] Fix scenario where incorrect platform specific icon image could be returned. --- Shared/Extensions/RSImage-AppIcons.swift | 5 ++--- Shared/Images/WebFeedIconDownloader.swift | 9 ++++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/Shared/Extensions/RSImage-AppIcons.swift b/Shared/Extensions/RSImage-AppIcons.swift index b0a778e7b..df5670732 100644 --- a/Shared/Extensions/RSImage-AppIcons.swift +++ b/Shared/Extensions/RSImage-AppIcons.swift @@ -27,11 +27,10 @@ extension RSImage { } extension IconImage { - static var appIcon: IconImage? { + static var appIcon: IconImage? = { if let image = RSImage.appIconImage { return IconImage(image) } - return nil - } + }() } diff --git a/Shared/Images/WebFeedIconDownloader.swift b/Shared/Images/WebFeedIconDownloader.swift index d62f5201c..bb67e2706 100644 --- a/Shared/Images/WebFeedIconDownloader.swift +++ b/Shared/Images/WebFeedIconDownloader.swift @@ -67,6 +67,10 @@ public final class WebFeedIconDownloader { return cachedImage } + if let hpURLString = feed.homePageURL, let hpURL = URL(string: hpURLString), hpURL.host == "nnw.ranchero.com" { + return IconImage.appIcon + } + func checkHomePageURL() { guard let homePageURL = feed.homePageURL else { return @@ -124,11 +128,6 @@ private extension WebFeedIconDownloader { func icon(forHomePageURL homePageURL: String, feed: WebFeed, _ imageResultBlock: @escaping (RSImage?) -> Void) { - if let url = URL(string: homePageURL), url.host == "nnw.ranchero.com" { - imageResultBlock(RSImage.appIconImage) - return - } - if homePagesWithNoIconURLCache.contains(homePageURL) || homePagesWithUglyIcons.contains(homePageURL) { imageResultBlock(nil) return From 53e947ee4c95869364d65e842b077e0e1ea73562 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Mon, 30 Mar 2020 13:35:02 -0500 Subject: [PATCH 30/98] Rename addFolder to createFolder to be more consistent. --- Frameworks/Account/Account.swift | 2 +- Frameworks/Account/AccountDelegate.swift | 2 +- Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift | 2 +- .../Account/FeedWrangler/FeedWranglerAccountDelegate.swift | 2 +- Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift | 2 +- Frameworks/Account/Feedly/FeedlyAccountDelegate.swift | 2 +- Frameworks/Account/LocalAccount/LocalAccountDelegate.swift | 2 +- Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift | 4 ++-- Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index deeb8db92..17704e763 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -589,7 +589,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } public func addFolder(_ name: String, completion: @escaping (Result) -> Void) { - delegate.addFolder(for: self, name: name, completion: completion) + delegate.createFolder(for: self, name: name, completion: completion) } public func removeFolder(_ folder: Folder, completion: @escaping (Result) -> Void) { diff --git a/Frameworks/Account/AccountDelegate.swift b/Frameworks/Account/AccountDelegate.swift index 0f0025287..a7c201cab 100644 --- a/Frameworks/Account/AccountDelegate.swift +++ b/Frameworks/Account/AccountDelegate.swift @@ -30,7 +30,7 @@ protocol AccountDelegate { func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result) -> Void) - func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) + func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> Void) func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 950d4cd40..b197431a0 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -234,7 +234,7 @@ final class CloudKitAccountDelegate: AccountDelegate { completion(.success(())) } - func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { if let folder = account.ensureFolder(with: name) { completion(.success(folder)) } else { diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index 23fe144d7..47313096e 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -283,7 +283,7 @@ final class FeedWranglerAccountDelegate: AccountDelegate { fatalError() } - func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { fatalError() } diff --git a/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift b/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift index 512fd3d1e..478454054 100644 --- a/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift +++ b/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift @@ -269,7 +269,7 @@ final class FeedbinAccountDelegate: AccountDelegate { } - func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { if let folder = account.ensureFolder(with: name) { completion(.success(folder)) } else { diff --git a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift index 09438c871..743706414 100644 --- a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift +++ b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift @@ -216,7 +216,7 @@ final class FeedlyAccountDelegate: AccountDelegate { } } - func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { let progress = refreshProgress progress.addToNumberOfTasksAndRemaining(1) diff --git a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift index a92ec95ae..2250c0cf4 100644 --- a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift +++ b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift @@ -167,7 +167,7 @@ final class LocalAccountDelegate: AccountDelegate { completion(.success(())) } - func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { if let folder = account.ensureFolder(with: name) { completion(.success(folder)) } else { diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 6e6cc5c0d..609dd3085 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -339,7 +339,7 @@ final class NewsBlurAccountDelegate: AccountDelegate { completion(.success(())) } - func addFolder(for account: Account, name: String, completion: @escaping (Result) -> ()) { + func createFolder(for account: Account, name: String, completion: @escaping (Result) -> ()) { self.refreshProgress.addToNumberOfTasksAndRemaining(1) caller.addFolder(named: name) { result in @@ -546,7 +546,7 @@ final class NewsBlurAccountDelegate: AccountDelegate { let group = DispatchGroup() group.enter() - addFolder(for: account, name: folderName) { result in + createFolder(for: account, name: folderName) { result in group.leave() switch result { case .success(let folder): diff --git a/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift b/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift index dcc29b442..a449a5301 100644 --- a/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift +++ b/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift @@ -206,7 +206,7 @@ final class ReaderAPIAccountDelegate: AccountDelegate { func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result) -> Void) { } - func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { if let folder = account.ensureFolder(with: name) { completion(.success(folder)) } else { From 766eb507bfd78c9d39a342ef2b01ac527ae31111 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Mon, 30 Mar 2020 15:15:45 -0500 Subject: [PATCH 31/98] Add container handling code --- Frameworks/Account/Account.swift | 9 +++ Frameworks/Account/AccountMetadata.swift | 9 +++ .../CloudKit/CloudKitAccountDelegate.swift | 66 +++++++++++++++--- .../CloudKit/CloudKitAccountZone.swift | 69 ++++++++++++++++++- .../CloudKitAccountZoneDelegate.swift | 20 ++++++ .../Account/CloudKit/CloudKitZone.swift | 38 +++++++++- Frameworks/Account/Container.swift | 3 +- 7 files changed, 199 insertions(+), 15 deletions(-) diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 17704e763..c3ad99300 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -136,6 +136,15 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, public var topLevelWebFeeds = Set() public var folders: Set? = Set() + public var externalID: String? { + get { + return metadata.externalID + } + set { + metadata.externalID = newValue + } + } + public var sortedFolders: [Folder]? { if let folders = folders { return Array(folders).sorted(by: { $0.nameForDisplay < $1.nameForDisplay }) diff --git a/Frameworks/Account/AccountMetadata.swift b/Frameworks/Account/AccountMetadata.swift index 7c5f378f9..705424ca4 100644 --- a/Frameworks/Account/AccountMetadata.swift +++ b/Frameworks/Account/AccountMetadata.swift @@ -23,6 +23,7 @@ final class AccountMetadata: Codable { case lastArticleFetchStartTime = "lastArticleFetch" case lastArticleFetchEndTime case endpointURL + case externalID } var name: String? { @@ -81,6 +82,14 @@ final class AccountMetadata: Codable { } } + var externalID: String? { + didSet { + if externalID != oldValue { + valueDidChange(.externalID) + } + } + } + weak var delegate: AccountMetadataDelegate? func valueDidChange(_ key: CodingKeys) { diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index b197431a0..64b98b0e4 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -235,26 +235,60 @@ final class CloudKitAccountDelegate: AccountDelegate { } func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { - if let folder = account.ensureFolder(with: name) { - completion(.success(folder)) - } else { - completion(.failure(FeedbinAccountDelegateError.invalidParameter)) + accountZone.createFolder(name: name) { result in + 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): + completion(.failure(error)) + } } } func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> Void) { - folder.name = name - completion(.success(())) + accountZone.renameFolder(folder, to: name) { result in + switch result { + case .success: + folder.name = name + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } } func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) { - account.removeFolder(folder) - completion(.success(())) + accountZone.removeFolder(folder) { result in + switch result { + case .success: + account.removeFolder(folder) + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } } func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result) -> Void) { - account.addFolder(folder) - completion(.success(())) + guard let name = folder.name else { + completion(.failure(LocalAccountDelegateError.invalidParameter)) + return + } + + accountZone.createFolder(name: name) { result in + switch result { + case .success(let externalID): + folder.externalID = externalID + account.addFolder(folder) + case .failure(let error): + completion(.failure(error)) + } + } } func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { @@ -263,6 +297,18 @@ final class CloudKitAccountDelegate: AccountDelegate { func accountDidInitialize(_ account: Account) { accountZone.delegate = CloudKitAcountZoneDelegate(account: account, refreshProgress: refreshProgress) + + if account.externalID == nil { + accountZone.findOrCreateAccount() { result in + switch result { + case .success(let externalID): + account.externalID = externalID + case .failure(let error): + os_log(.error, log: self.log, "Error adding account container: %@", error.localizedDescription) + } + } + } + zones.forEach { zone in zone.resumeLongLivedOperationIfPossible() zone.subscribe() diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index 70b1b9aac..9c8fc173b 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -32,6 +32,14 @@ final class CloudKitAccountZone: CloudKitZone { } } + struct CloudKitContainer { + static let recordType = "Container" + struct Fields { + static let isAccount = "isAccount" + static let name = "name" + } + } + init(container: CKContainer) { self.container = container self.database = container.privateCloudDatabase @@ -77,11 +85,68 @@ final class CloudKitAccountZone: CloudKitZone { /// Deletes a web feed from iCloud func removeWebFeed(_ webFeed: WebFeed, completion: @escaping (Result) -> Void) { - guard let externalID = webFeed.externalID else { + delete(externalID: webFeed.externalID , completion: completion) + } + + func findOrCreateAccount(completion: @escaping (Result) -> Void) { + let predicate = NSPredicate(format: "isAccount = true") + let ckQuery = CKQuery(recordType: CloudKitContainer.recordType, predicate: predicate) + + query(ckQuery) { result in + switch result { + case .success(let records): + completion(.success(records[0].externalID)) + case .failure: + self.createContainer(name: "Account", isAccount: true, completion: completion) + } + } + } + + func createFolder(name: String, completion: @escaping (Result) -> Void) { + createContainer(name: name, isAccount: false, completion: completion) + } + + func renameFolder(_ folder: Folder, to name: String, completion: @escaping (Result) -> Void) { + guard let externalID = folder.externalID else { completion(.failure(CloudKitZoneError.invalidParameter)) return } - delete(externalID: externalID, completion: completion) + + let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID) + let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: recordID) + record[CloudKitContainer.Fields.name] = name + + save(record: record) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func removeFolder(_ folder: Folder, completion: @escaping (Result) -> Void) { + delete(externalID: folder.externalID, completion: completion) + } + +} + +private extension CloudKitAccountZone { + + func createContainer(name: String, isAccount: Bool, completion: @escaping (Result) -> Void) { + let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: generateRecordID()) + record[CloudKitContainer.Fields.name] = name + record[CloudKitContainer.Fields.isAccount] = isAccount + + save(record: record) { result in + switch result { + case .success: + completion(.success(record.externalID)) + case .failure(let error): + completion(.failure(error)) + } + } } } diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift index 7ba75fa8b..e758421e9 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -27,6 +27,8 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { switch record.recordType { case CloudKitAccountZone.CloudKitWebFeed.recordType: addOrUpdateWebFeed(record) + case CloudKitAccountZone.CloudKitContainer.recordType: + addOrUpdateContainer(record) default: assertionFailure("Unknown record type: \(record.recordType)") } @@ -36,6 +38,8 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { switch recordType { case CloudKitAccountZone.CloudKitWebFeed.recordType: removeWebFeed(recordID.externalID) + case CloudKitAccountZone.CloudKitContainer.recordType: + removeContainer(recordID.externalID) default: assertionFailure("Unknown record type: \(recordID.externalID)") } @@ -63,6 +67,22 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { } } + func addOrUpdateContainer(_ record: CKRecord) { + guard let account = account, let name = record[CloudKitAccountZone.CloudKitContainer.Fields.name] as? String else { return } + + if let folder = account.existingFolder(withExternalID: record.externalID) { + folder.name = name + } else { + account.ensureFolder(with: name) + } + } + + func removeContainer(_ externalID: String) { + if let folder = account?.existingFolder(withExternalID: externalID) { + account?.removeFolder(folder) + } + } + } private extension CloudKitAcountZoneDelegate { diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 062f24607..5c53edbb2 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -100,11 +100,40 @@ extension CloudKitZone { modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion) } - func delete(externalID: String, completion: @escaping (Result) -> Void) { + func delete(externalID: String?, completion: @escaping (Result) -> Void) { + guard let externalID = externalID else { + completion(.failure(CloudKitZoneError.invalidParameter)) + return + } + let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID) modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion) } + func query(_ query: CKQuery, completion: @escaping (Result<[CKRecord], Error>) -> Void) { + guard let database = database else { + completion(.failure(CloudKitZoneError.unknown)) + return + } + + database.perform(query, inZoneWith: Self.zoneID) { records, error in + switch CloudKitZoneResult.resolve(error) { + case .success: + if let records = records { + completion(.success(records)) + } else { + completion(.failure(CloudKitZoneError.unknown)) + } + case .retry(let timeToWait): + self.retryOperationIfPossible(retryAfter: timeToWait) { + self.query(query, completion: completion) + } + default: + completion(.failure(error!)) + } + } + } + func modify(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result) -> Void) { let op = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete) @@ -252,7 +281,12 @@ private extension CloudKitZone { } func createZoneRecord(completion: @escaping (Result) -> Void) { - database?.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in + guard let database = database else { + completion(.failure(CloudKitZoneError.unknown)) + return + } + + database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in if let error = error { DispatchQueue.main.async { completion(.failure(error)) diff --git a/Frameworks/Account/Container.swift b/Frameworks/Account/Container.swift index f9dbaacb1..4405b0439 100644 --- a/Frameworks/Account/Container.swift +++ b/Frameworks/Account/Container.swift @@ -21,7 +21,8 @@ public protocol Container: class, ContainerIdentifiable { var account: Account? { get } var topLevelWebFeeds: Set { get set } var folders: Set? { get set } - + var externalID: String? { get set } + func hasAtLeastOneWebFeed() -> Bool func objectIsChild(_ object: AnyObject) -> Bool From 41acb716bd93c4e0c0c944db1fa50283e6480fea Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Mon, 30 Mar 2020 15:42:42 -0500 Subject: [PATCH 32/98] Remove activity donation for next unread. Issue #1957 --- iOS/SceneCoordinator.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index 0929f16c4..4408a5f30 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -927,7 +927,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { } if selectNextUnreadArticleInTimeline() { - activityManager.selectingNextUnread() return } @@ -936,9 +935,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { } selectNextUnreadFeed() { - if self.selectNextUnreadArticleInTimeline() { - self.activityManager.selectingNextUnread() - } + self.selectNextUnreadArticleInTimeline() } } From c3506e9329f1222516dac8b1934a97c3c445b96c Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sun, 22 Mar 2020 19:52:48 -0400 Subject: [PATCH 33/98] Add NewsBlur to Preferences --- Mac/AppAssets.swift | 6 + .../Accounts/AccountsAddViewController.swift | 9 +- Mac/Preferences/Accounts/AccountsNewsBlur.xib | 185 ++++++++++++++++++ .../AccountsNewsBlurWindowController.swift | 110 +++++++++++ .../accountNewsBlur.imageset/Contents.json | 15 ++ .../accountNewsBlur.imageset/newsblur-512.png | Bin 0 -> 54138 bytes NetNewsWire.xcodeproj/project.pbxproj | 12 ++ .../NewsBlurAccountViewController.swift | 8 +- 8 files changed, 340 insertions(+), 5 deletions(-) create mode 100644 Mac/Preferences/Accounts/AccountsNewsBlur.xib create mode 100644 Mac/Preferences/Accounts/AccountsNewsBlurWindowController.swift create mode 100644 Mac/Resources/Assets.xcassets/accountNewsBlur.imageset/Contents.json create mode 100644 Mac/Resources/Assets.xcassets/accountNewsBlur.imageset/newsblur-512.png diff --git a/Mac/AppAssets.swift b/Mac/AppAssets.swift index c537ee21b..a9d62921f 100644 --- a/Mac/AppAssets.swift +++ b/Mac/AppAssets.swift @@ -40,6 +40,10 @@ struct AppAssets { static var accountFreshRSS: RSImage! = { return RSImage(named: "accountFreshRSS") }() + + static var accountNewsBlur: RSImage! = { + return RSImage(named: "accountNewsBlur") + }() static var articleExtractor: RSImage! = { return RSImage(named: "articleExtractor") @@ -151,6 +155,8 @@ struct AppAssets { return AppAssets.accountFeedWrangler case .freshRSS: return AppAssets.accountFreshRSS + case .newsBlur: + return AppAssets.accountNewsBlur default: return nil } diff --git a/Mac/Preferences/Accounts/AccountsAddViewController.swift b/Mac/Preferences/Accounts/AccountsAddViewController.swift index 4923272be..1207852ce 100644 --- a/Mac/Preferences/Accounts/AccountsAddViewController.swift +++ b/Mac/Preferences/Accounts/AccountsAddViewController.swift @@ -17,7 +17,7 @@ class AccountsAddViewController: NSViewController { private var accountsAddWindowController: NSWindowController? #if DEBUG - private var addableAccountTypes: [AccountType] = [.onMyMac, .cloudKit, .feedbin, .feedly, .feedWrangler, .freshRSS] + private var addableAccountTypes: [AccountType] = [.onMyMac, .cloudKit, .feedbin, .feedly, .feedWrangler, .freshRSS, .newsBlur] #else private var addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly] #endif @@ -80,6 +80,9 @@ extension AccountsAddViewController: NSTableViewDelegate { case .feedly: cell.accountNameLabel?.stringValue = NSLocalizedString("Feedly", comment: "Feedly") cell.accountImageView?.image = AppAssets.accountFeedly + case .newsBlur: + cell.accountNameLabel?.stringValue = NSLocalizedString("NewsBlur", comment: "NewsBlur") + cell.accountImageView?.image = AppAssets.accountNewsBlur default: break } @@ -127,6 +130,10 @@ extension AccountsAddViewController: NSTableViewDelegate { addAccount.delegate = self addAccount.presentationAnchor = self.view.window! MainThreadOperationQueue.shared.add(addAccount) + case .newsBlur: + let accountsNewsBlurWindowController = AccountsNewsBlurWindowController() + accountsNewsBlurWindowController.runSheetOnWindow(self.view.window!) + accountsAddWindowController = accountsNewsBlurWindowController default: break } diff --git a/Mac/Preferences/Accounts/AccountsNewsBlur.xib b/Mac/Preferences/Accounts/AccountsNewsBlur.xib new file mode 100644 index 000000000..3b6027ed8 --- /dev/null +++ b/Mac/Preferences/Accounts/AccountsNewsBlur.xib @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NSAllRomanInputSourcesLocaleIdentifier + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mac/Preferences/Accounts/AccountsNewsBlurWindowController.swift b/Mac/Preferences/Accounts/AccountsNewsBlurWindowController.swift new file mode 100644 index 000000000..cae19a31a --- /dev/null +++ b/Mac/Preferences/Accounts/AccountsNewsBlurWindowController.swift @@ -0,0 +1,110 @@ +// +// AccountsNewsBlurWindowController.swift +// NetNewsWire +// +// Created by Anh Quang Do on 2020-03-22. +// Copyright (c) 2020 Ranchero Software. All rights reserved. +// + +import AppKit +import Account +import RSWeb + +class AccountsNewsBlurWindowController: NSWindowController { + @IBOutlet weak var progressIndicator: NSProgressIndicator! + @IBOutlet weak var usernameTextField: NSTextField! + @IBOutlet weak var passwordTextField: NSSecureTextField! + @IBOutlet weak var errorMessageLabel: NSTextField! + @IBOutlet weak var actionButton: NSButton! + + var account: Account? + + private weak var hostWindow: NSWindow? + + convenience init() { + self.init(windowNibName: NSNib.Name("AccountsNewsBlur")) + } + + override func windowDidLoad() { + if let account = account, let credentials = try? account.retrieveCredentials(type: .newsBlurBasic) { + usernameTextField.stringValue = credentials.username + actionButton.title = NSLocalizedString("Update", comment: "Update") + } else { + actionButton.title = NSLocalizedString("Create", comment: "Create") + } + } + + // MARK: API + + func runSheetOnWindow(_ hostWindow: NSWindow, completion: ((NSApplication.ModalResponse) -> Void)? = nil) { + self.hostWindow = hostWindow + hostWindow.beginSheet(window!, completionHandler: completion) + } + + // MARK: Actions + + @IBAction func cancel(_ sender: Any) { + hostWindow!.endSheet(window!, returnCode: NSApplication.ModalResponse.cancel) + } + + @IBAction func action(_ sender: Any) { + self.errorMessageLabel.stringValue = "" + + guard !usernameTextField.stringValue.isEmpty else { + self.errorMessageLabel.stringValue = NSLocalizedString("Username required.", comment: "Credentials Error") + return + } + + actionButton.isEnabled = false + progressIndicator.isHidden = false + progressIndicator.startAnimation(self) + + let credentials = Credentials(type: .newsBlurBasic, username: usernameTextField.stringValue, secret: passwordTextField.stringValue) + Account.validateCredentials(type: .newsBlur, credentials: credentials) { [weak self] result in + + guard let self = self else { return } + + self.actionButton.isEnabled = true + self.progressIndicator.isHidden = true + self.progressIndicator.stopAnimation(self) + + switch result { + case .success(let validatedCredentials): + guard let validatedCredentials = validatedCredentials else { + self.errorMessageLabel.stringValue = NSLocalizedString("Invalid email/password combination.", comment: "Credentials Error") + return + } + var newAccount = false + if self.account == nil { + self.account = AccountManager.shared.createAccount(type: .newsBlur) + newAccount = true + } + + do { + try self.account?.removeCredentials(type: .newsBlurBasic) + try self.account?.removeCredentials(type: .newsBlurSessionId) + try self.account?.storeCredentials(credentials) + try self.account?.storeCredentials(validatedCredentials) + if newAccount { + self.account?.refreshAll() { result in + switch result { + case .success: + break + case .failure(let error): + NSApplication.shared.presentError(error) + } + } + } + self.hostWindow?.endSheet(self.window!, returnCode: NSApplication.ModalResponse.OK) + } catch { + self.errorMessageLabel.stringValue = NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error") + } + + case .failure: + + self.errorMessageLabel.stringValue = NSLocalizedString("Network error. Try again later.", comment: "Credentials Error") + + } + } + } +} diff --git a/Mac/Resources/Assets.xcassets/accountNewsBlur.imageset/Contents.json b/Mac/Resources/Assets.xcassets/accountNewsBlur.imageset/Contents.json new file mode 100644 index 000000000..99f78349c --- /dev/null +++ b/Mac/Resources/Assets.xcassets/accountNewsBlur.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "newsblur-512.png" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template" + } +} \ No newline at end of file diff --git a/Mac/Resources/Assets.xcassets/accountNewsBlur.imageset/newsblur-512.png b/Mac/Resources/Assets.xcassets/accountNewsBlur.imageset/newsblur-512.png new file mode 100644 index 0000000000000000000000000000000000000000..5fab67691f90e4ff35fd51ae1cc4d44ff821d230 GIT binary patch literal 54138 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 AYybcN literal 0 HcmV?d00001 diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 4cdb13a9d..9cc10115d 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 179DB1DFBCF9177104B12E0F /* AccountsNewsBlurWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBBA2B22A659F81EED6F9 /* AccountsNewsBlurWindowController.swift */; }; + 179DB3CE822BFCC2D774D9F4 /* AccountsNewsBlurWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBBA2B22A659F81EED6F9 /* AccountsNewsBlurWindowController.swift */; }; 3B3A32A5238B820900314204 /* FeedWranglerAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A328B238B820900314204 /* FeedWranglerAccountViewController.swift */; }; 3B826DCB2385C84800FC1ADB /* AccountsFeedWrangler.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3B826DB02385C84800FC1ADB /* AccountsFeedWrangler.xib */; }; 3B826DCC2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DCA2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift */; }; @@ -665,6 +667,8 @@ B2B80778239C4C7000F191E0 /* RSImage-AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */; }; B2B80779239C4C7300F191E0 /* RSImage-AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */; }; B528F81E23333C7E00E735DD /* page.html in Resources */ = {isa = PBXBuildFile; fileRef = B528F81D23333C7E00E735DD /* page.html */; }; + BDCB516724282C8A00102A80 /* AccountsNewsBlur.xib in Resources */ = {isa = PBXBuildFile; fileRef = BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */; }; + BDCB516824282C8A00102A80 /* AccountsNewsBlur.xib in Resources */ = {isa = PBXBuildFile; fileRef = BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */; }; C5A6ED5223C9AF4300AB6BE2 /* TitleActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A6ED5123C9AF4300AB6BE2 /* TitleActivityItemSource.swift */; }; C5A6ED6D23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A6ED6C23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift */; }; D553738B20186C20006D8857 /* Article+Scriptability.swift in Sources */ = {isa = PBXBuildFile; fileRef = D553737C20186C1F006D8857 /* Article+Scriptability.swift */; }; @@ -1255,6 +1259,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 179DBBA2B22A659F81EED6F9 /* AccountsNewsBlurWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountsNewsBlurWindowController.swift; sourceTree = ""; }; 3B3A328B238B820900314204 /* FeedWranglerAccountViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAccountViewController.swift; sourceTree = ""; }; 3B826DB02385C84800FC1ADB /* AccountsFeedWrangler.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = AccountsFeedWrangler.xib; sourceTree = ""; }; 3B826DCA2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountsFeedWranglerWindowController.swift; sourceTree = ""; }; @@ -1626,6 +1631,7 @@ B24EFD5923310109006C6242 /* WKPreferencesPrivate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WKPreferencesPrivate.h; sourceTree = ""; }; B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RSImage-AppIcons.swift"; sourceTree = ""; }; B528F81D23333C7E00E735DD /* page.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = page.html; sourceTree = ""; }; + BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = AccountsNewsBlur.xib; sourceTree = ""; }; C5A6ED5123C9AF4300AB6BE2 /* TitleActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleActivityItemSource.swift; sourceTree = ""; }; C5A6ED6C23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivityViewController-Extensions.swift"; sourceTree = ""; }; D519E74722EE553300923F27 /* NetNewsWire_safariextension_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_safariextension_target.xcconfig; sourceTree = ""; }; @@ -2630,6 +2636,8 @@ 5144EA4F227B8E4500D19003 /* AccountsFeedbinWindowController.swift */, 3B826DB02385C84800FC1ADB /* AccountsFeedWrangler.xib */, 3B826DCA2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift */, + BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */, + 179DBBA2B22A659F81EED6F9 /* AccountsNewsBlurWindowController.swift */, 55E15BC1229D65A900D6602A /* AccountsReaderAPI.xib */, 55E15BCA229D65A900D6602A /* AccountsReaderAPIWindowController.swift */, 5144EA352279FC3D00D19003 /* AccountsAddLocal.xib */, @@ -3501,6 +3509,7 @@ 65ED4066235DEF6C0081F399 /* TimelineTableView.xib in Resources */, 65ED4067235DEF6C0081F399 /* page.html in Resources */, 65ED4068235DEF6C0081F399 /* MainWindow.storyboard in Resources */, + BDCB516824282C8A00102A80 /* AccountsNewsBlur.xib in Resources */, 3B826DCD2385C89600FC1ADB /* AccountsFeedWrangler.xib in Resources */, 65ED4069235DEF6C0081F399 /* AccountsReaderAPI.xib in Resources */, 65ED406A235DEF6C0081F399 /* newsfoot.js in Resources */, @@ -3593,6 +3602,7 @@ 3B826DCB2385C84800FC1ADB /* AccountsFeedWrangler.xib in Resources */, 55E15BCB229D65A900D6602A /* AccountsReaderAPI.xib in Resources */, 49F40DF82335B71000552BF4 /* newsfoot.js in Resources */, + BDCB516724282C8A00102A80 /* AccountsNewsBlur.xib in Resources */, 5103A9982421643300410853 /* blank.html in Resources */, 84BAE64921CEDAF20046DB56 /* CrashReporterWindow.xib in Resources */, 84C9FC8E22629E8F00D921D6 /* Credits.rtf in Resources */, @@ -3909,6 +3919,7 @@ 65ED403E235DEF6C0081F399 /* TimelineCellAppearance.swift in Sources */, 65ED403F235DEF6C0081F399 /* ArticleRenderer.swift in Sources */, 65ED4040235DEF6C0081F399 /* GeneralPrefencesViewController.swift in Sources */, + 179DB1DFBCF9177104B12E0F /* AccountsNewsBlurWindowController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4226,6 +4237,7 @@ 849A97761ED9EC04007D329B /* TimelineCellAppearance.swift in Sources */, 849A977F1ED9EC42007D329B /* ArticleRenderer.swift in Sources */, 84C9FC7822629E1200D921D6 /* GeneralPrefencesViewController.swift in Sources */, + 179DB3CE822BFCC2D774D9F4 /* AccountsNewsBlurWindowController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS/Account/NewsBlurAccountViewController.swift b/iOS/Account/NewsBlurAccountViewController.swift index 8a5cb2ddf..9810f8196 100644 --- a/iOS/Account/NewsBlurAccountViewController.swift +++ b/iOS/Account/NewsBlurAccountViewController.swift @@ -29,7 +29,7 @@ class NewsBlurAccountViewController: UITableViewController { usernameTextField.delegate = self passwordTextField.delegate = self - if let account = account, let credentials = try? account.retrieveCredentials(type: .basic) { + if let account = account, let credentials = try? account.retrieveCredentials(type: .newsBlurBasic) { actionButton.setTitle(NSLocalizedString("Update Credentials", comment: "Update Credentials"), for: .normal) actionButton.isEnabled = true usernameTextField.text = credentials.username @@ -90,7 +90,7 @@ class NewsBlurAccountViewController: UITableViewController { let credentials = Credentials(type: .newsBlurBasic, username: trimmedUsername, secret: password) Account.validateCredentials(type: .newsBlur, credentials: credentials) { result in - self.stopAnimtatingActivityIndicator() + self.stopAnimatingActivityIndicator() self.enableNavigation() switch result { @@ -105,7 +105,7 @@ class NewsBlurAccountViewController: UITableViewController { do { do { - try self.account?.removeCredentials(type: .basic) + try self.account?.removeCredentials(type: .newsBlurBasic) } catch {} try self.account?.storeCredentials(credentials) @@ -158,7 +158,7 @@ class NewsBlurAccountViewController: UITableViewController { activityIndicator.startAnimating() } - private func stopAnimtatingActivityIndicator() { + private func stopAnimatingActivityIndicator() { self.activityIndicator.isHidden = true self.activityIndicator.stopAnimating() } From 1ee3c3d85a5f696eee0804693b4f44e3fdd6a35c Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Mon, 30 Mar 2020 18:03:56 -0500 Subject: [PATCH 34/98] Made sure the special account container didn't get created locally. --- Frameworks/Account/CloudKit/CloudKitAccountZone.swift | 2 +- .../Account/CloudKit/CloudKitAccountZoneDelegate.swift | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index 9c8fc173b..7e768021b 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -137,7 +137,7 @@ private extension CloudKitAccountZone { func createContainer(name: String, isAccount: Bool, completion: @escaping (Result) -> Void) { let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: generateRecordID()) record[CloudKitContainer.Fields.name] = name - record[CloudKitContainer.Fields.isAccount] = isAccount + record[CloudKitContainer.Fields.isAccount] = isAccount ? "true" : "false" save(record: record) { result in switch result { diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift index e758421e9..391380fde 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -68,12 +68,16 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { } func addOrUpdateContainer(_ record: CKRecord) { - guard let account = account, let name = record[CloudKitAccountZone.CloudKitContainer.Fields.name] as? String else { return } + guard let account = account, + let name = record[CloudKitAccountZone.CloudKitContainer.Fields.name] as? String, + let isAccount = record[CloudKitAccountZone.CloudKitContainer.Fields.isAccount] as? String, + isAccount != "true" else { return } if let folder = account.existingFolder(withExternalID: record.externalID) { folder.name = name } else { - account.ensureFolder(with: name) + let folder = account.ensureFolder(with: name) + folder?.externalID = record.externalID } } From 6d3e6914df75d98012ccee40c0ba905072f3afe5 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Mon, 30 Mar 2020 18:14:07 -0500 Subject: [PATCH 35/98] Fix account container lookup so that it doesn't keep creating records. --- Frameworks/Account/CloudKit/CloudKitAccountZone.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index 7e768021b..71c9e558c 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -89,7 +89,7 @@ final class CloudKitAccountZone: CloudKitZone { } func findOrCreateAccount(completion: @escaping (Result) -> Void) { - let predicate = NSPredicate(format: "isAccount = true") + let predicate = NSPredicate(format: "isAccount = \"true\"") let ckQuery = CKQuery(recordType: CloudKitContainer.recordType, predicate: predicate) query(ckQuery) { result in From 203b83d64dd349fd19a68254975091e151b655a7 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Mon, 30 Mar 2020 21:11:57 -0500 Subject: [PATCH 36/98] Enable adding feeds to folders. --- Frameworks/Account/Account.swift | 16 +++- .../CloudKit/CloudKitAccountDelegate.swift | 7 +- .../CloudKit/CloudKitAccountZone.swift | 42 ++++++++--- .../CloudKitAccountZoneDelegate.swift | 73 +++++++++++++------ .../Account/CloudKit/CloudKitZone.swift | 4 +- Frameworks/Account/Container.swift | 10 +++ 6 files changed, 113 insertions(+), 39 deletions(-) diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index c3ad99300..8e802625c 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -511,6 +511,13 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, return delegate.markArticles(for: self, articles: articles, statusKey: statusKey, flag: flag) } + func existingContainer(withExternalID externalID: String) -> Container? { + guard self.externalID != externalID else { + return self + } + return existingFolder(withExternalID: externalID) + } + @discardableResult func ensureFolder(with name: String) -> Folder? { // TODO: support subfolders, maybe, some day @@ -561,10 +568,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, return feed } - public func existingWebFeed(withExternalID externalID: String) -> WebFeed? { - return externalIDToWebFeedDictionary[externalID] - } - public func addWebFeed(_ feed: WebFeed, to container: Container, completion: @escaping (Result) -> Void) { delegate.addWebFeed(for: self, with: feed, to: container, completion: completion) } @@ -1315,6 +1318,11 @@ extension Account { public func existingWebFeed(withWebFeedID webFeedID: String) -> WebFeed? { return idToWebFeedDictionary[webFeedID] } + + public func existingWebFeed(withExternalID externalID: String) -> WebFeed? { + return externalIDToWebFeedDictionary[externalID] + } + } // MARK: - OPMLRepresentable diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 64b98b0e4..4c9959808 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -155,9 +155,9 @@ final class CloudKitAccountDelegate: AccountDelegate { return } - self.accountZone.createWebFeed(url: bestFeedSpecifier.urlString, editedName: name) { result in + self.accountZone.createWebFeed(url: bestFeedSpecifier.urlString, editedName: name, container: container) { result in switch result { - case .success(let externalID): + case .success(let containerWebFeed): let feed = account.createWebFeed(with: nil, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) @@ -168,7 +168,8 @@ final class CloudKitAccountDelegate: AccountDelegate { account.update(feed, with: parsedFeed, {_ in feed.editedName = name - feed.externalID = externalID + feed.externalID = containerWebFeed.webFeedExternalID + feed.folderRelationship?[containerWebFeed.containerWebFeedExternalID] = containerWebFeed.containerExternalID container.addWebFeed(feed) completion(.success(feed)) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index 71c9e558c..d45ab3833 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -13,6 +13,8 @@ import CloudKit final class CloudKitAccountZone: CloudKitZone { + typealias ContainerWebFeed = (webFeedExternalID: String, containerWebFeedExternalID: String, containerExternalID: String) + static var zoneID: CKRecordZone.ID { return CKRecordZone.ID(zoneName: "Account", ownerName: CKCurrentUserDefaultName) } @@ -40,29 +42,51 @@ final class CloudKitAccountZone: CloudKitZone { } } + struct CloudKitContainerWebFeed { + static let recordType = "ContainerWebFeed" + struct Fields { + static let container = "container" + static let webFeed = "webFeed" + } + } + init(container: CKContainer) { self.container = container self.database = container.privateCloudDatabase } /// Persist a web feed record to iCloud and return the external key - func createWebFeed(url: String, editedName: String?, completion: @escaping (Result) -> Void) { - let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: generateRecordID()) - record[CloudKitWebFeed.Fields.url] = url + func createWebFeed(url: String, editedName: String?, container: Container, completion: @escaping (Result) -> Void) { + let webFeedRecord = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: generateRecordID()) + webFeedRecord[CloudKitWebFeed.Fields.url] = url if let editedName = editedName { - record[CloudKitWebFeed.Fields.editedName] = editedName + webFeedRecord[CloudKitWebFeed.Fields.editedName] = editedName } - save(record: record) { result in + guard let containerExternalID = container.externalID else { + completion(.failure(CloudKitZoneError.invalidParameter)) + return + } + + let containerRecordID = CKRecord.ID(recordName: containerExternalID, zoneID: Self.zoneID) + let containerWebFeedRecord = CKRecord(recordType: CloudKitContainerWebFeed.recordType, recordID: generateRecordID()) + containerWebFeedRecord[CloudKitContainerWebFeed.Fields.container] = CKRecord.Reference(recordID: containerRecordID, action: .deleteSelf) + containerWebFeedRecord[CloudKitContainerWebFeed.Fields.webFeed] = CKRecord.Reference(record: webFeedRecord, action: .deleteSelf) + + save([webFeedRecord, containerWebFeedRecord]) { result in switch result { case .success: - completion(.success(record.externalID)) + let cwf = ContainerWebFeed(webFeedExternalID: webFeedRecord.externalID, + containerWebFeedExternalID: containerWebFeedRecord.externalID, + containerExternalID: containerExternalID) + completion(.success(cwf)) case .failure(let error): completion(.failure(error)) } } } + /// Rename the given web feed func renameWebFeed(_ webFeed: WebFeed, editedName: String?, completion: @escaping (Result) -> Void) { guard let externalID = webFeed.externalID else { completion(.failure(CloudKitZoneError.invalidParameter)) @@ -73,7 +97,7 @@ final class CloudKitAccountZone: CloudKitZone { let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: recordID) record[CloudKitWebFeed.Fields.editedName] = editedName - save(record: record) { result in + save([record]) { result in switch result { case .success: completion(.success(())) @@ -116,7 +140,7 @@ final class CloudKitAccountZone: CloudKitZone { let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: recordID) record[CloudKitContainer.Fields.name] = name - save(record: record) { result in + save([record]) { result in switch result { case .success: completion(.success(())) @@ -139,7 +163,7 @@ private extension CloudKitAccountZone { record[CloudKitContainer.Fields.name] = name record[CloudKitContainer.Fields.isAccount] = isAccount ? "true" : "false" - save(record: record) { result in + save([record]) { result in switch result { case .success: completion(.success(record.externalID)) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift index 391380fde..f100f30c6 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -13,6 +13,9 @@ import CloudKit class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { + private typealias UnclaimedWebFeed = (url: String, editedName: String?) + private var unclaimedWebFeeds = [String: UnclaimedWebFeed]() + private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") weak var account: Account? @@ -29,6 +32,8 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { addOrUpdateWebFeed(record) case CloudKitAccountZone.CloudKitContainer.recordType: addOrUpdateContainer(record) + case CloudKitAccountZone.CloudKitContainerWebFeed.recordType: + addOrUpdateContainerWebFeed(record) default: assertionFailure("Unknown record type: \(record.recordType)") } @@ -37,9 +42,11 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { func cloudKitDidDelete(recordType: CKRecord.RecordType, recordID: CKRecord.ID) { switch recordType { case CloudKitAccountZone.CloudKitWebFeed.recordType: - removeWebFeed(recordID.externalID) + break case CloudKitAccountZone.CloudKitContainer.recordType: removeContainer(recordID.externalID) + case CloudKitAccountZone.CloudKitContainerWebFeed.recordType: + removeContainerWebFeed(recordID.externalID) default: assertionFailure("Unknown record type: \(recordID.externalID)") } @@ -53,20 +60,12 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { if let webFeed = account.existingWebFeed(withExternalID: record.externalID) { webFeed.editedName = editedName } else { - if let urlString = record[CloudKitAccountZone.CloudKitWebFeed.Fields.url] as? String, let url = URL(string: urlString) { - downloadAndAddWebFeed(url: url, editedName: editedName, externalID: record.externalID) - } else { - os_log(.error, log: self.log, "Failed to add or update web feed.") + if let urlString = record[CloudKitAccountZone.CloudKitWebFeed.Fields.url] as? String { + unclaimedWebFeeds[record.externalID] = UnclaimedWebFeed(url: urlString, editedName: editedName) } } } - func removeWebFeed(_ externalID: String) { - if let webFeed = account?.existingWebFeed(withExternalID: externalID) { - account?.removeWebFeed(webFeed) - } - } - func addOrUpdateContainer(_ record: CKRecord) { guard let account = account, let name = record[CloudKitAccountZone.CloudKitContainer.Fields.name] as? String, @@ -87,17 +86,34 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { } } -} - -private extension CloudKitAcountZoneDelegate { - - func downloadAndAddWebFeed(url: URL, editedName: String?, externalID: String) { - guard let account = account else { return } + func addOrUpdateContainerWebFeed(_ record: CKRecord) { + guard let account = account, + let containerReference = record[CloudKitAccountZone.CloudKitContainerWebFeed.Fields.container] as? CKRecord.Reference, + let webFeedReference = record[CloudKitAccountZone.CloudKitContainerWebFeed.Fields.webFeed] as? CKRecord.Reference else { return } - let webFeed = account.createWebFeed(with: editedName, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) - webFeed.editedName = editedName - webFeed.externalID = externalID - account.addWebFeed(webFeed) + let containerWebFeedExternalID = record.externalID + let containerExternalID = containerReference.recordID.externalID + let webFeedExternalID = webFeedReference.recordID.externalID + + guard let container = account.existingContainer(withExternalID: containerExternalID) else { return } + + if let webFeed = account.existingWebFeed(withExternalID: webFeedExternalID) { + webFeed.folderRelationship?[containerWebFeedExternalID] = containerExternalID + container.addWebFeed(webFeed) + return + } + + guard let unclaimedWebFeed = unclaimedWebFeeds[webFeedExternalID] else { return } + unclaimedWebFeeds.removeValue(forKey: webFeedExternalID) + + let webFeed = account.createWebFeed(with: nil, url: unclaimedWebFeed.url, webFeedID: unclaimedWebFeed.url, homePageURL: nil) + webFeed.editedName = unclaimedWebFeed.editedName + webFeed.externalID = webFeedExternalID + webFeed.folderRelationship = [String: String]() + webFeed.folderRelationship![containerWebFeedExternalID] = containerExternalID + container.addWebFeed(webFeed) + + guard let url = URL(string: unclaimedWebFeed.url) else { return } refreshProgress?.addToNumberOfTasksAndRemaining(1) InitialFeedDownloader.download(url) { parsedFeed in @@ -106,7 +122,22 @@ private extension CloudKitAcountZoneDelegate { account.update(webFeed, with: parsedFeed, {_ in }) } } + } + + func removeContainerWebFeed(_ containerWebFeedExternalID: String) { + guard let account = account, + let webFeed = account.flattenedWebFeeds().first(where: { $0.folderRelationship?.keys.contains(containerWebFeedExternalID) ?? false }), + let containerExternalId = webFeed.folderRelationship?[containerWebFeedExternalID] else { return } + + webFeed.folderRelationship?.removeValue(forKey: containerWebFeedExternalID) + guard account.externalID != containerExternalId else { + account.removeWebFeed(webFeed) + return + } + + guard let folder = account.existingFolder(withExternalID: containerExternalId) else { return } + folder.removeWebFeed(webFeed) } } diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 5c53edbb2..1f0fa7cd9 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -96,8 +96,8 @@ extension CloudKitZone { } } - func save(record: CKRecord, completion: @escaping (Result) -> Void) { - modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion) + func save(_ records: [CKRecord], completion: @escaping (Result) -> Void) { + modify(recordsToSave: records, recordIDsToDelete: [], completion: completion) } func delete(externalID: String?, completion: @escaping (Result) -> Void) { diff --git a/Frameworks/Account/Container.swift b/Frameworks/Account/Container.swift index 4405b0439..7c4eda9c8 100644 --- a/Frameworks/Account/Container.swift +++ b/Frameworks/Account/Container.swift @@ -39,6 +39,7 @@ public protocol Container: class, ContainerIdentifiable { func hasWebFeed(withURL url: String) -> Bool func existingWebFeed(withWebFeedID: String) -> WebFeed? func existingWebFeed(withURL url: String) -> WebFeed? + func existingWebFeed(withExternalID externalID: String) -> WebFeed? func existingFolder(with name: String) -> Folder? func existingFolder(withID: Int) -> Folder? @@ -117,6 +118,15 @@ public extension Container { } return nil } + + func existingWebFeed(withExternalID externalID: String) -> WebFeed? { + for feed in flattenedWebFeeds() { + if feed.externalID == externalID { + return feed + } + } + return nil + } func existingFolder(with name: String) -> Folder? { guard let folders = folders else { From df1faa368f3807c4fc99271d087fdb90df6cfd4b Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Tue, 31 Mar 2020 02:20:47 -0500 Subject: [PATCH 37/98] Refactored add feed code to be more reliable. --- Frameworks/Account/Account.swift | 13 +++ .../CloudKit/CloudKitAccountDelegate.swift | 5 +- .../CloudKit/CloudKitAccountZone.swift | 36 +++----- .../CloudKitAccountZoneDelegate.swift | 92 +++++++++---------- .../Account/CloudKit/CloudKitZone.swift | 4 +- 5 files changed, 70 insertions(+), 80 deletions(-) diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 8e802625c..f05cd1855 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -518,6 +518,19 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, return existingFolder(withExternalID: externalID) } + func existingContainers(withWebFeed webFeed: WebFeed) -> [Container] { + var containers = [Container]() + if topLevelWebFeeds.contains(webFeed) { + containers.append(self) + } + folders?.forEach { folder in + if folder.topLevelWebFeeds.contains(webFeed) { + containers.append(folder) + } + } + return containers + } + @discardableResult func ensureFolder(with name: String) -> Folder? { // TODO: support subfolders, maybe, some day diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 4c9959808..4b07d9cbf 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -157,7 +157,7 @@ final class CloudKitAccountDelegate: AccountDelegate { self.accountZone.createWebFeed(url: bestFeedSpecifier.urlString, editedName: name, container: container) { result in switch result { - case .success(let containerWebFeed): + case .success(let externalID): let feed = account.createWebFeed(with: nil, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) @@ -168,8 +168,7 @@ final class CloudKitAccountDelegate: AccountDelegate { account.update(feed, with: parsedFeed, {_ in feed.editedName = name - feed.externalID = containerWebFeed.webFeedExternalID - feed.folderRelationship?[containerWebFeed.containerWebFeedExternalID] = containerWebFeed.containerExternalID + feed.externalID = externalID container.addWebFeed(feed) completion(.success(feed)) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index d45ab3833..9f3db714a 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -31,6 +31,7 @@ final class CloudKitAccountZone: CloudKitZone { struct Fields { static let url = "url" static let editedName = "editedName" + static let containerExternalIDs = "folderExternalIDs" } } @@ -42,44 +43,29 @@ final class CloudKitAccountZone: CloudKitZone { } } - struct CloudKitContainerWebFeed { - static let recordType = "ContainerWebFeed" - struct Fields { - static let container = "container" - static let webFeed = "webFeed" - } - } - init(container: CKContainer) { self.container = container self.database = container.privateCloudDatabase } /// Persist a web feed record to iCloud and return the external key - func createWebFeed(url: String, editedName: String?, container: Container, completion: @escaping (Result) -> Void) { - let webFeedRecord = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: generateRecordID()) - webFeedRecord[CloudKitWebFeed.Fields.url] = url + func createWebFeed(url: String, editedName: String?, container: Container, completion: @escaping (Result) -> Void) { + let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: generateRecordID()) + record[CloudKitWebFeed.Fields.url] = url if let editedName = editedName { - webFeedRecord[CloudKitWebFeed.Fields.editedName] = editedName + record[CloudKitWebFeed.Fields.editedName] = editedName } guard let containerExternalID = container.externalID else { completion(.failure(CloudKitZoneError.invalidParameter)) return } - - let containerRecordID = CKRecord.ID(recordName: containerExternalID, zoneID: Self.zoneID) - let containerWebFeedRecord = CKRecord(recordType: CloudKitContainerWebFeed.recordType, recordID: generateRecordID()) - containerWebFeedRecord[CloudKitContainerWebFeed.Fields.container] = CKRecord.Reference(recordID: containerRecordID, action: .deleteSelf) - containerWebFeedRecord[CloudKitContainerWebFeed.Fields.webFeed] = CKRecord.Reference(record: webFeedRecord, action: .deleteSelf) + record[CloudKitWebFeed.Fields.containerExternalIDs] = [containerExternalID] - save([webFeedRecord, containerWebFeedRecord]) { result in + save(record) { result in switch result { case .success: - let cwf = ContainerWebFeed(webFeedExternalID: webFeedRecord.externalID, - containerWebFeedExternalID: containerWebFeedRecord.externalID, - containerExternalID: containerExternalID) - completion(.success(cwf)) + completion(.success(record.externalID)) case .failure(let error): completion(.failure(error)) } @@ -97,7 +83,7 @@ final class CloudKitAccountZone: CloudKitZone { let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: recordID) record[CloudKitWebFeed.Fields.editedName] = editedName - save([record]) { result in + save(record) { result in switch result { case .success: completion(.success(())) @@ -140,7 +126,7 @@ final class CloudKitAccountZone: CloudKitZone { let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: recordID) record[CloudKitContainer.Fields.name] = name - save([record]) { result in + save(record) { result in switch result { case .success: completion(.success(())) @@ -163,7 +149,7 @@ private extension CloudKitAccountZone { record[CloudKitContainer.Fields.name] = name record[CloudKitContainer.Fields.isAccount] = isAccount ? "true" : "false" - save([record]) { result in + save(record) { result in switch result { case .success: completion(.success(record.externalID)) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift index f100f30c6..62dd94f5f 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -13,9 +13,6 @@ import CloudKit class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { - private typealias UnclaimedWebFeed = (url: String, editedName: String?) - private var unclaimedWebFeeds = [String: UnclaimedWebFeed]() - private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") weak var account: Account? @@ -32,8 +29,6 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { addOrUpdateWebFeed(record) case CloudKitAccountZone.CloudKitContainer.recordType: addOrUpdateContainer(record) - case CloudKitAccountZone.CloudKitContainerWebFeed.recordType: - addOrUpdateContainerWebFeed(record) default: assertionFailure("Unknown record type: \(record.recordType)") } @@ -42,27 +37,33 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { func cloudKitDidDelete(recordType: CKRecord.RecordType, recordID: CKRecord.ID) { switch recordType { case CloudKitAccountZone.CloudKitWebFeed.recordType: - break + removeWebFeed(recordID.externalID) case CloudKitAccountZone.CloudKitContainer.recordType: removeContainer(recordID.externalID) - case CloudKitAccountZone.CloudKitContainerWebFeed.recordType: - removeContainerWebFeed(recordID.externalID) default: assertionFailure("Unknown record type: \(recordID.externalID)") } } func addOrUpdateWebFeed(_ record: CKRecord) { - guard let account = account else { return } + guard let account = account, + let urlString = record[CloudKitAccountZone.CloudKitWebFeed.Fields.url] as? String, + let containerExternalIDs = record[CloudKitAccountZone.CloudKitWebFeed.Fields.containerExternalIDs] as? [String], + let defaultContainerExternalID = containerExternalIDs.first, + let url = URL(string: urlString) else { return } let editedName = record[CloudKitAccountZone.CloudKitWebFeed.Fields.editedName] as? String if let webFeed = account.existingWebFeed(withExternalID: record.externalID) { - webFeed.editedName = editedName + updateWebFeed(webFeed, editedName: editedName, containerExternalIDs: containerExternalIDs) } else { - if let urlString = record[CloudKitAccountZone.CloudKitWebFeed.Fields.url] as? String { - unclaimedWebFeeds[record.externalID] = UnclaimedWebFeed(url: urlString, editedName: editedName) - } + addWebFeed(url: url, editedName: editedName, webFeedExternalID: record.externalID, containerExternalID: defaultContainerExternalID) + } + } + + func removeWebFeed(_ externalID: String) { + if let webFeed = account?.existingWebFeed(withExternalID: externalID) { + account?.removeWebFeed(webFeed) } } @@ -86,35 +87,41 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { } } - func addOrUpdateContainerWebFeed(_ record: CKRecord) { - guard let account = account, - let containerReference = record[CloudKitAccountZone.CloudKitContainerWebFeed.Fields.container] as? CKRecord.Reference, - let webFeedReference = record[CloudKitAccountZone.CloudKitContainerWebFeed.Fields.webFeed] as? CKRecord.Reference else { return } +} + +private extension CloudKitAcountZoneDelegate { + + func updateWebFeed(_ webFeed: WebFeed, editedName: String?, containerExternalIDs: [String]) { + guard let account = account else { return } + webFeed.editedName = editedName - let containerWebFeedExternalID = record.externalID - let containerExternalID = containerReference.recordID.externalID - let webFeedExternalID = webFeedReference.recordID.externalID + let existingContainers = account.existingContainers(withWebFeed: webFeed) + let existingContainerExternalIds = existingContainers.compactMap { $0.externalID } + + let diff = containerExternalIDs.difference(from: existingContainerExternalIds) - guard let container = account.existingContainer(withExternalID: containerExternalID) else { return } - - if let webFeed = account.existingWebFeed(withExternalID: webFeedExternalID) { - webFeed.folderRelationship?[containerWebFeedExternalID] = containerExternalID - container.addWebFeed(webFeed) - return + for change in diff { + switch change { + case .remove(_, let externalID, _): + if let container = existingContainers.first(where: { $0.externalID == externalID }) { + container.removeWebFeed(webFeed) + } + case .insert(_, let externalID, _): + if let container = existingContainers.first(where: { $0.externalID == externalID }) { + container.addWebFeed(webFeed) + } + } } + } + + func addWebFeed(url: URL, editedName: String?, webFeedExternalID: String, containerExternalID: String) { + guard let account = account, let container = account.existingContainer(withExternalID: containerExternalID) else { return } - guard let unclaimedWebFeed = unclaimedWebFeeds[webFeedExternalID] else { return } - unclaimedWebFeeds.removeValue(forKey: webFeedExternalID) - - let webFeed = account.createWebFeed(with: nil, url: unclaimedWebFeed.url, webFeedID: unclaimedWebFeed.url, homePageURL: nil) - webFeed.editedName = unclaimedWebFeed.editedName + let webFeed = account.createWebFeed(with: editedName, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) + webFeed.editedName = editedName webFeed.externalID = webFeedExternalID - webFeed.folderRelationship = [String: String]() - webFeed.folderRelationship![containerWebFeedExternalID] = containerExternalID container.addWebFeed(webFeed) - guard let url = URL(string: unclaimedWebFeed.url) else { return } - refreshProgress?.addToNumberOfTasksAndRemaining(1) InitialFeedDownloader.download(url) { parsedFeed in self.refreshProgress?.completeTask() @@ -122,22 +129,7 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { account.update(webFeed, with: parsedFeed, {_ in }) } } - } - - func removeContainerWebFeed(_ containerWebFeedExternalID: String) { - guard let account = account, - let webFeed = account.flattenedWebFeeds().first(where: { $0.folderRelationship?.keys.contains(containerWebFeedExternalID) ?? false }), - let containerExternalId = webFeed.folderRelationship?[containerWebFeedExternalID] else { return } - - webFeed.folderRelationship?.removeValue(forKey: containerWebFeedExternalID) - guard account.externalID != containerExternalId else { - account.removeWebFeed(webFeed) - return - } - - guard let folder = account.existingFolder(withExternalID: containerExternalId) else { return } - folder.removeWebFeed(webFeed) } } diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 1f0fa7cd9..cf998ad9c 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -96,8 +96,8 @@ extension CloudKitZone { } } - func save(_ records: [CKRecord], completion: @escaping (Result) -> Void) { - modify(recordsToSave: records, recordIDsToDelete: [], completion: completion) + func save(_ record: CKRecord, completion: @escaping (Result) -> Void) { + modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion) } func delete(externalID: String?, completion: @escaping (Result) -> Void) { From 90376dac030157dc9700b4d85f51b01a50af3599 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Tue, 31 Mar 2020 03:30:53 -0500 Subject: [PATCH 38/98] Implement add, move, delete folder operations for feeds. --- .../CloudKit/CloudKitAccountDelegate.swift | 31 +++++--- .../CloudKit/CloudKitAccountZone.swift | 68 +++++++++++++++++- .../CloudKitAccountZoneDelegate.swift | 6 +- .../Account/CloudKit/CloudKitZone.swift | 70 +++++++++++++------ 4 files changed, 141 insertions(+), 34 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 4b07d9cbf..c33330707 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -207,7 +207,7 @@ final class CloudKitAccountDelegate: AccountDelegate { } func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result) -> Void) { - accountZone.removeWebFeed(feed) { result in + accountZone.removeWebFeed(feed, from: container) { result in switch result { case .success: container.removeWebFeed(feed) @@ -218,20 +218,33 @@ final class CloudKitAccountDelegate: AccountDelegate { } } - func moveWebFeed(for account: Account, with feed: WebFeed, from: Container, to: Container, completion: @escaping (Result) -> Void) { - from.removeWebFeed(feed) - to.addWebFeed(feed) - completion(.success(())) + func moveWebFeed(for account: Account, with feed: WebFeed, from fromContainer: Container, to toContainer: Container, completion: @escaping (Result) -> Void) { + accountZone.moveWebFeed(feed, from: fromContainer, to: toContainer) { result in + switch result { + case .success: + fromContainer.removeWebFeed(feed) + toContainer.addWebFeed(feed) + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } } func addWebFeed(for account: Account, with feed: WebFeed, to container: Container, completion: @escaping (Result) -> Void) { - container.addWebFeed(feed) - completion(.success(())) + accountZone.addWebFeed(feed, to: container) { result in + switch result { + case .success: + container.addWebFeed(feed) + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } } func restoreWebFeed(for account: Account, feed: WebFeed, container: Container, completion: @escaping (Result) -> Void) { - container.addWebFeed(feed) - completion(.success(())) + addWebFeed(for: account, with: feed, to: container, completion: completion) } func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index 9f3db714a..ede217556 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -94,8 +94,72 @@ final class CloudKitAccountZone: CloudKitZone { } /// Deletes a web feed from iCloud - func removeWebFeed(_ webFeed: WebFeed, completion: @escaping (Result) -> Void) { - delete(externalID: webFeed.externalID , completion: completion) + func removeWebFeed(_ webFeed: WebFeed, from: Container, completion: @escaping (Result) -> Void) { + guard let fromContainerExternalID = from.externalID else { + completion(.failure(CloudKitZoneError.invalidParameter)) + return + } + + fetch(externalID: webFeed.externalID) { result in + switch result { + case .success(let record): + if let containerExternalIDs = record[CloudKitWebFeed.Fields.containerExternalIDs] as? [String] { + var containerExternalIDSet = Set(containerExternalIDs) + containerExternalIDSet.remove(fromContainerExternalID) + if containerExternalIDSet.isEmpty { + self.delete(externalID: webFeed.externalID , completion: completion) + } else { + record[CloudKitWebFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet) + self.save(record, completion: completion) + } + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + func moveWebFeed(_ webFeed: WebFeed, from: Container, to: Container, completion: @escaping (Result) -> Void) { + guard let fromContainerExternalID = from.externalID, let toContainerExternalID = to.externalID else { + completion(.failure(CloudKitZoneError.invalidParameter)) + return + } + + fetch(externalID: webFeed.externalID) { result in + switch result { + case .success(let record): + if let containerExternalIDs = record[CloudKitWebFeed.Fields.containerExternalIDs] as? [String] { + var containerExternalIDSet = Set(containerExternalIDs) + containerExternalIDSet.remove(fromContainerExternalID) + containerExternalIDSet.insert(toContainerExternalID) + record[CloudKitWebFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet) + self.save(record, completion: completion) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + func addWebFeed(_ webFeed: WebFeed, to: Container, completion: @escaping (Result) -> Void) { + guard let toContainerExternalID = to.externalID else { + completion(.failure(CloudKitZoneError.invalidParameter)) + return + } + + fetch(externalID: webFeed.externalID) { result in + switch result { + case .success(let record): + if let containerExternalIDs = record[CloudKitWebFeed.Fields.containerExternalIDs] as? [String] { + var containerExternalIDSet = Set(containerExternalIDs) + containerExternalIDSet.insert(toContainerExternalID) + record[CloudKitWebFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet) + self.save(record, completion: completion) + } + case .failure(let error): + completion(.failure(error)) + } + } } func findOrCreateAccount(completion: @escaping (Result) -> Void) { diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift index 62dd94f5f..240050330 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -62,8 +62,8 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { } func removeWebFeed(_ externalID: String) { - if let webFeed = account?.existingWebFeed(withExternalID: externalID) { - account?.removeWebFeed(webFeed) + if let webFeed = account?.existingWebFeed(withExternalID: externalID), let containers = account?.existingContainers(withWebFeed: webFeed) { + containers.forEach { $0.removeWebFeed(webFeed) } } } @@ -107,7 +107,7 @@ private extension CloudKitAcountZoneDelegate { container.removeWebFeed(webFeed) } case .insert(_, let externalID, _): - if let container = existingContainers.first(where: { $0.externalID == externalID }) { + if let container = account.existingContainer(withExternalID: externalID) { container.addWebFeed(webFeed) } } diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index cf998ad9c..83dcff1a1 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -71,7 +71,7 @@ extension CloudKitZone { case .success: break case .retry(let timeToWait): - self.retryOperationIfPossible(retryAfter: timeToWait) { + self.retryIfPossible(after: timeToWait) { self.subscribe() } default: @@ -96,20 +96,6 @@ extension CloudKitZone { } } - func save(_ record: CKRecord, completion: @escaping (Result) -> Void) { - modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion) - } - - func delete(externalID: String?, completion: @escaping (Result) -> Void) { - guard let externalID = externalID else { - completion(.failure(CloudKitZoneError.invalidParameter)) - return - } - - let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID) - modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion) - } - func query(_ query: CKQuery, completion: @escaping (Result<[CKRecord], Error>) -> Void) { guard let database = database else { completion(.failure(CloudKitZoneError.unknown)) @@ -125,7 +111,7 @@ extension CloudKitZone { completion(.failure(CloudKitZoneError.unknown)) } case .retry(let timeToWait): - self.retryOperationIfPossible(retryAfter: timeToWait) { + self.retryIfPossible(after: timeToWait) { self.query(query, completion: completion) } default: @@ -134,6 +120,50 @@ extension CloudKitZone { } } + func fetch(externalID: String?, completion: @escaping (Result) -> Void) { + guard let externalID = externalID else { + completion(.failure(CloudKitZoneError.invalidParameter)) + return + } + + let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID) + + database?.fetch(withRecordID: recordID) { record, error in + switch CloudKitZoneResult.resolve(error) { + case .success: + DispatchQueue.main.async { + if let record = record { + completion(.success(record)) + } else { + completion(.failure(CloudKitZoneError.unknown)) + } + } + case .retry(let timeToWait): + self.retryIfPossible(after: timeToWait) { + self.fetch(externalID: externalID, completion: completion) + } + default: + DispatchQueue.main.async { + completion(.failure(error!)) + } + } + } + } + + func save(_ record: CKRecord, completion: @escaping (Result) -> Void) { + modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion) + } + + func delete(externalID: String?, completion: @escaping (Result) -> Void) { + guard let externalID = externalID else { + completion(.failure(CloudKitZoneError.invalidParameter)) + return + } + + let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID) + modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion) + } + func modify(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result) -> Void) { let op = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete) @@ -173,7 +203,7 @@ extension CloudKitZone { completion(.failure(CloudKitZoneError.userDeletedZone)) } case .retry(let timeToWait): - self.retryOperationIfPossible(retryAfter: timeToWait) { + self.retryIfPossible(after: timeToWait) { self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion) } case .limitExceeded: @@ -230,7 +260,7 @@ extension CloudKitZone { self.changeToken = token } case .retry(let timeToWait): - self.retryOperationIfPossible(retryAfter: timeToWait) { + self.retryIfPossible(after: timeToWait) { self.fetchChangesInZone(completion: completion) } default: @@ -299,8 +329,8 @@ private extension CloudKitZone { } } - func retryOperationIfPossible(retryAfter: Double, block: @escaping () -> ()) { - let delayTime = DispatchTime.now() + retryAfter + func retryIfPossible(after: Double, block: @escaping () -> ()) { + let delayTime = DispatchTime.now() + after DispatchQueue.main.asyncAfter(deadline: delayTime, execute: { block() }) From 218df326f4c71b6174791b8fb30239590e8ae99a Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Tue, 31 Mar 2020 10:32:04 -0500 Subject: [PATCH 39/98] Fix issue where out of order records was dropping web feeds. --- .../CloudKitAccountZoneDelegate.swift | 56 ++++++++++++++++--- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift index 240050330..ab721ed84 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -13,6 +13,9 @@ import CloudKit class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { + private typealias UnclaimedWebFeed = (url: URL, editedName: String?, webFeedExternalID: String) + private var unclaimedWebFeeds = [String: [UnclaimedWebFeed]]() + private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") weak var account: Account? @@ -49,7 +52,6 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { guard let account = account, let urlString = record[CloudKitAccountZone.CloudKitWebFeed.Fields.url] as? String, let containerExternalIDs = record[CloudKitAccountZone.CloudKitWebFeed.Fields.containerExternalIDs] as? [String], - let defaultContainerExternalID = containerExternalIDs.first, let url = URL(string: urlString) else { return } let editedName = record[CloudKitAccountZone.CloudKitWebFeed.Fields.editedName] as? String @@ -57,7 +59,17 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { if let webFeed = account.existingWebFeed(withExternalID: record.externalID) { updateWebFeed(webFeed, editedName: editedName, containerExternalIDs: containerExternalIDs) } else { - addWebFeed(url: url, editedName: editedName, webFeedExternalID: record.externalID, containerExternalID: defaultContainerExternalID) + var webFeed: WebFeed? = nil + for containerExternalID in containerExternalIDs { + if let container = account.existingContainer(withExternalID: containerExternalID) { + if webFeed == nil { + webFeed = createWebFeed(url: url, editedName: editedName, webFeedExternalID: record.externalID) + } + container.addWebFeed(webFeed!) + } else { + addUnclaimedWebFeed(url: url, editedName: editedName, webFeedExternalID: record.externalID, containerExternalID: containerExternalID) + } + } } } @@ -73,12 +85,26 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { let isAccount = record[CloudKitAccountZone.CloudKitContainer.Fields.isAccount] as? String, isAccount != "true" else { return } - if let folder = account.existingFolder(withExternalID: record.externalID) { - folder.name = name - } else { - let folder = account.ensureFolder(with: name) + var folder = account.existingFolder(withExternalID: record.externalID) + folder?.name = name + + if folder == nil { + folder = account.ensureFolder(with: name) folder?.externalID = record.externalID } + + if let folder = folder, let containerExternalID = folder.externalID, let unclaimedWebFeeds = unclaimedWebFeeds[containerExternalID] { + for unclaimedWebFeed in unclaimedWebFeeds { + var webFeed = account.existingWebFeed(withExternalID: unclaimedWebFeed.webFeedExternalID) + if webFeed == nil { + webFeed = createWebFeed(url: unclaimedWebFeed.url, editedName: unclaimedWebFeed.editedName, webFeedExternalID: unclaimedWebFeed.webFeedExternalID) + } + if let webFeed = webFeed { + folder.addWebFeed(webFeed) + } + } + self.unclaimedWebFeeds.removeValue(forKey: containerExternalID) + } } func removeContainer(_ externalID: String) { @@ -114,13 +140,12 @@ private extension CloudKitAcountZoneDelegate { } } - func addWebFeed(url: URL, editedName: String?, webFeedExternalID: String, containerExternalID: String) { - guard let account = account, let container = account.existingContainer(withExternalID: containerExternalID) else { return } + func createWebFeed(url: URL, editedName: String?, webFeedExternalID: String) -> WebFeed? { + guard let account = account else { return nil } let webFeed = account.createWebFeed(with: editedName, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) webFeed.editedName = editedName webFeed.externalID = webFeedExternalID - container.addWebFeed(webFeed) refreshProgress?.addToNumberOfTasksAndRemaining(1) InitialFeedDownloader.download(url) { parsedFeed in @@ -130,6 +155,19 @@ private extension CloudKitAcountZoneDelegate { } } + return webFeed + } + + + func addUnclaimedWebFeed(url: URL, editedName: String?, webFeedExternalID: String, containerExternalID: String) { + if var unclaimedWebFeeds = self.unclaimedWebFeeds[containerExternalID] { + unclaimedWebFeeds.append(UnclaimedWebFeed(url: url, editedName: editedName, webFeedExternalID: webFeedExternalID)) + self.unclaimedWebFeeds[containerExternalID] = unclaimedWebFeeds + } else { + var unclaimedWebFeeds = [UnclaimedWebFeed]() + unclaimedWebFeeds.append(UnclaimedWebFeed(url: url, editedName: editedName, webFeedExternalID: webFeedExternalID)) + self.unclaimedWebFeeds[containerExternalID] = unclaimedWebFeeds + } } } From 1be5dc8a54c5733e2ad1ed7f3a52214175627a34 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Tue, 31 Mar 2020 11:07:54 -0500 Subject: [PATCH 40/98] Implemented feed and folder restore so that undo works. --- .../CloudKit/CloudKitAccountDelegate.swift | 37 ++++++++++++++- .../Account/CloudKit/CloudKitZone.swift | 47 +++++++++++++------ 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index c33330707..90443596c 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -180,7 +180,7 @@ final class CloudKitAccountDelegate: AccountDelegate { case .failure(let error): self.refreshProgress.completeTask() - completion(.failure(error)) // TODO: need to handle userDeletedZone + completion(.failure(error)) } } @@ -244,7 +244,16 @@ final class CloudKitAccountDelegate: AccountDelegate { } func restoreWebFeed(for account: Account, feed: WebFeed, container: Container, completion: @escaping (Result) -> Void) { - addWebFeed(for: account, with: feed, to: container, completion: completion) + accountZone.createWebFeed(url: feed.url, editedName: feed.editedName, container: container) { result in + switch result { + case .success(let externalID): + feed.externalID = externalID + container.addWebFeed(feed) + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } } func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { @@ -298,6 +307,30 @@ final class CloudKitAccountDelegate: AccountDelegate { case .success(let externalID): folder.externalID = externalID account.addFolder(folder) + + let group = DispatchGroup() + for feed in folder.topLevelWebFeeds { + + folder.topLevelWebFeeds.remove(feed) + + group.enter() + self.restoreWebFeed(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) + } + } + + } + + group.notify(queue: DispatchQueue.main) { + account.addFolder(folder) + completion(.success(())) + } + case .failure(let error): completion(.failure(error)) } diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 83dcff1a1..2ed4486f2 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -102,20 +102,28 @@ extension CloudKitZone { return } - database.perform(query, inZoneWith: Self.zoneID) { records, error in + refreshProgress?.addToNumberOfTasksAndRemaining(1) + + database.perform(query, inZoneWith: Self.zoneID) { [weak self] records, error in switch CloudKitZoneResult.resolve(error) { case .success: - if let records = records { - completion(.success(records)) - } else { - completion(.failure(CloudKitZoneError.unknown)) + DispatchQueue.main.async { + self?.refreshProgress?.completeTask() + if let records = records { + completion(.success(records)) + } else { + completion(.failure(CloudKitZoneError.unknown)) + } } case .retry(let timeToWait): - self.retryIfPossible(after: timeToWait) { - self.query(query, completion: completion) + self?.retryIfPossible(after: timeToWait) { + self?.query(query, completion: completion) } default: - completion(.failure(error!)) + DispatchQueue.main.async { + self?.refreshProgress?.completeTask() + completion(.failure(error!)) + } } } } @@ -128,10 +136,12 @@ extension CloudKitZone { let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID) - database?.fetch(withRecordID: recordID) { record, error in + refreshProgress?.addToNumberOfTasksAndRemaining(1) + database?.fetch(withRecordID: recordID) { [weak self] record, error in switch CloudKitZoneResult.resolve(error) { case .success: DispatchQueue.main.async { + self?.refreshProgress?.completeTask() if let record = record { completion(.success(record)) } else { @@ -139,11 +149,12 @@ extension CloudKitZone { } } case .retry(let timeToWait): - self.retryIfPossible(after: timeToWait) { - self.fetch(externalID: externalID, completion: completion) + self?.retryIfPossible(after: timeToWait) { + self?.fetch(externalID: externalID, completion: completion) } default: DispatchQueue.main.async { + self?.refreshProgress?.completeTask() completion(.failure(error!)) } } @@ -187,6 +198,7 @@ extension CloudKitZone { switch CloudKitZoneResult.resolve(error) { case .success: DispatchQueue.main.async { + self.refreshProgress?.completeTask() completion(.success(())) } case .zoneNotFound: @@ -195,11 +207,15 @@ extension CloudKitZone { case .success: self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion) case .failure(let error): - completion(.failure(error)) + DispatchQueue.main.async { + self.refreshProgress?.completeTask() + completion(.failure(error)) + } } } case .userDeletedZone: DispatchQueue.main.async { + self.refreshProgress?.completeTask() completion(.failure(CloudKitZoneError.userDeletedZone)) } case .retry(let timeToWait): @@ -213,18 +229,18 @@ extension CloudKitZone { } default: DispatchQueue.main.async { + self.refreshProgress?.completeTask() completion(.failure(error!)) } } } - + + refreshProgress?.addToNumberOfTasksAndRemaining(1) database?.add(op) } func fetchChangesInZone(completion: @escaping (Result) -> Void) { - refreshProgress?.addToNumberOfTasksAndRemaining(1) - let zoneConfig = CKFetchRecordZoneChangesOperation.ZoneConfiguration() zoneConfig.previousServerChangeToken = changeToken let op = CKFetchRecordZoneChangesOperation(recordZoneIDs: [Self.zoneID], configurationsByRecordZoneID: [Self.zoneID: zoneConfig]) @@ -279,6 +295,7 @@ extension CloudKitZone { } } + refreshProgress?.addToNumberOfTasksAndRemaining(1) database?.add(op) } From 31e06cd24a5f6e55d3dc3051414c5e7d1de3c5cc Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Tue, 31 Mar 2020 11:18:52 -0500 Subject: [PATCH 41/98] Add batch update block to fetch to prevent the Feeds list from dancing around. --- Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 90443596c..5b1edbabd 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -69,14 +69,17 @@ final class CloudKitAccountDelegate: AccountDelegate { } func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { + BatchUpdate.shared.start() accountZone.fetchChangesInZone() { result in switch result { case .success: self.refresher.refreshFeeds(account.flattenedWebFeeds()) { + BatchUpdate.shared.end() account.metadata.lastArticleFetchEndTime = Date() completion(.success(())) } case .failure(let error): + BatchUpdate.shared.end() completion(.failure(error)) } } From cdde8e4b0926fec8952ee540f731a74295a6ff46 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Tue, 31 Mar 2020 15:21:53 -0500 Subject: [PATCH 42/98] Refactored the OPML load code so that the normalization step is separate from the add step. --- Frameworks/Account/Account.swift | 48 +++++---------- .../Account/Account.xcodeproj/project.pbxproj | 4 ++ .../CloudKit/CloudKitAccountDelegate.swift | 2 +- .../LocalAccount/LocalAccountDelegate.swift | 2 +- Frameworks/Account/OPMLFile.swift | 2 +- Frameworks/Account/OPMLNormalizer.swift | 60 +++++++++++++++++++ 6 files changed, 82 insertions(+), 36 deletions(-) create mode 100644 Frameworks/Account/OPMLNormalizer.swift diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index f05cd1855..a487953bb 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -468,43 +468,25 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, delegate.accountWillBeDeleted(self) } - func loadOPMLItems(_ items: [RSOPMLItem], parentFolder: Folder?) { - var feedsToAdd = Set() - - items.forEach { (item) in - + func addOPMLItems(_ items: [RSOPMLItem]) { + for item in items { if let feedSpecifier = item.feedSpecifier { - let feed = newWebFeed(with: feedSpecifier) - feedsToAdd.insert(feed) - return - } - - guard let folderName = item.titleFromAttributes else { - // Folder doesn’t have a name, so it won’t be created, and its items will go one level up. - if let itemChildren = item.children { - loadOPMLItems(itemChildren, parentFolder: parentFolder) - } - return - } - - if let folder = ensureFolder(with: folderName) { - folder.externalID = item.attributes?["nnw_externalID"] as? String - if let itemChildren = item.children { - loadOPMLItems(itemChildren, parentFolder: folder) + addWebFeed(newWebFeed(with: feedSpecifier)) + } else { + if let title = item.titleFromAttributes, let folder = ensureFolder(with: title) { + folder.externalID = item.attributes?["nnw_externalID"] as? String + item.children?.forEach { itemChild in + if let feedSpecifier = itemChild.feedSpecifier { + folder.addWebFeed(newWebFeed(with: feedSpecifier)) + } + } } } } - - if let parentFolder = parentFolder { - for feed in feedsToAdd { - parentFolder.addWebFeed(feed) - } - } else { - for feed in feedsToAdd { - addWebFeed(feed) - } - } - + } + + func loadOPMLItems(_ items: [RSOPMLItem]) { + addOPMLItems(OPMLNormalizer.normalize(items)) } public func markArticles(_ articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index fbc41bc77..fcbb97a28 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -55,6 +55,7 @@ 5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5165D71E22835E9800D9D53D /* HTMLFeedFinder.swift */; }; 5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5165D73022837F3400D9D53D /* InitialFeedDownloader.swift */; }; 5170743C232AEDB500A461A3 /* OPMLFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5170743B232AEDB500A461A3 /* OPMLFile.swift */; }; + 519E84A62433D49000D238B0 /* OPMLNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */; }; 51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */; }; 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC8FCB237EC055004F8B56 /* Feed.swift */; }; 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */; }; @@ -288,6 +289,7 @@ 5165D73022837F3400D9D53D /* InitialFeedDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InitialFeedDownloader.swift; sourceTree = ""; }; 5170743B232AEDB500A461A3 /* OPMLFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLFile.swift; sourceTree = ""; }; 518B2EA52351306200400001 /* Account_project_test.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_test.xcconfig; sourceTree = ""; }; + 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLNormalizer.swift; sourceTree = ""; }; 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBehaviors.swift; sourceTree = ""; }; 51BC8FCB237EC055004F8B56 /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = ""; }; 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerIdentifier.swift; sourceTree = ""; }; @@ -670,6 +672,7 @@ 51E3EB40229AF61B00645299 /* AccountError.swift */, 846E77531F6F00E300A165E2 /* AccountManager.swift */, 5170743B232AEDB500A461A3 /* OPMLFile.swift */, + 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */, 84AF4EA3222CFDD100F6A800 /* AccountMetadata.swift */, 510BD110232C3801002692E4 /* AccountMetadataFile.swift */, 84F73CF0202788D80000BCEF /* ArticleFetcher.swift */, @@ -1102,6 +1105,7 @@ 5103A9D92422546800410853 /* CloudKitAccountDelegate.swift in Sources */, 5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */, 9E784EBE237E890600099B1B /* FeedlyLogoutOperation.swift in Sources */, + 519E84A62433D49000D238B0 /* OPMLNormalizer.swift in Sources */, 9EEEF71F23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift in Sources */, 9EBD49C223C67784005AD5CD /* FeedlyEntryIdentifierProviding.swift in Sources */, 846E77541F6F00E300A165E2 /* AccountManager.swift in Sources */, diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 5b1edbabd..b77d4af29 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -128,7 +128,7 @@ final class CloudKitAccountDelegate: AccountDelegate { } BatchUpdate.shared.perform { - account.loadOPMLItems(children, parentFolder: nil) + account.loadOPMLItems(children) } completion(.success(())) diff --git a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift index 2250c0cf4..3483d758e 100644 --- a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift +++ b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift @@ -85,7 +85,7 @@ final class LocalAccountDelegate: AccountDelegate { } BatchUpdate.shared.perform { - account.loadOPMLItems(children, parentFolder: nil) + account.loadOPMLItems(children) } completion(.success(())) diff --git a/Frameworks/Account/OPMLFile.swift b/Frameworks/Account/OPMLFile.swift index eb17aa033..134358c35 100644 --- a/Frameworks/Account/OPMLFile.swift +++ b/Frameworks/Account/OPMLFile.swift @@ -40,7 +40,7 @@ final class OPMLFile { } BatchUpdate.shared.perform { - account.loadOPMLItems(opmlItems, parentFolder: nil) + account.loadOPMLItems(opmlItems) } } diff --git a/Frameworks/Account/OPMLNormalizer.swift b/Frameworks/Account/OPMLNormalizer.swift new file mode 100644 index 000000000..bf540ce2a --- /dev/null +++ b/Frameworks/Account/OPMLNormalizer.swift @@ -0,0 +1,60 @@ +// +// OPMLNormalizer.swift +// Account +// +// Created by Maurice Parker on 3/31/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSParser + +final class OPMLNormalizer { + + var normalizedOPMLItems = [RSOPMLItem]() + + static func normalize(_ items: [RSOPMLItem]) -> [RSOPMLItem] { + let opmlNormalizer = OPMLNormalizer() + opmlNormalizer.loadOPMLItems(items) + return opmlNormalizer.normalizedOPMLItems + } + + private func loadOPMLItems(_ items: [RSOPMLItem], parentFolder: RSOPMLItem? = nil) { + var feedsToAdd = [RSOPMLItem]() + + items.forEach { (item) in + + if let _ = item.feedSpecifier { + if !feedsToAdd.contains(where: { $0.feedSpecifier?.feedURL == item.feedSpecifier?.feedURL } ) { + feedsToAdd.append(item) + } + return + } + + guard let _ = item.titleFromAttributes else { + // Folder doesn’t have a name, so it won’t be created, and its items will go one level up. + if let itemChildren = item.children { + loadOPMLItems(itemChildren, parentFolder: parentFolder) + } + return + } + + normalizedOPMLItems.append(item) + if let itemChildren = item.children { + loadOPMLItems(itemChildren, parentFolder: item) + } + } + + if let parentFolder = parentFolder { + for feed in feedsToAdd { + parentFolder.addChild(feed) + } + } else { + for feed in feedsToAdd { + normalizedOPMLItems.append(feed) + } + } + + } + +} From 0cafee7f59d322c7d38eb6510a51efebca4bd203 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Tue, 31 Mar 2020 16:47:02 -0500 Subject: [PATCH 43/98] Renamed old name left over from refactoring. --- Frameworks/Account/OPMLNormalizer.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Frameworks/Account/OPMLNormalizer.swift b/Frameworks/Account/OPMLNormalizer.swift index bf540ce2a..dee350ee3 100644 --- a/Frameworks/Account/OPMLNormalizer.swift +++ b/Frameworks/Account/OPMLNormalizer.swift @@ -15,11 +15,11 @@ final class OPMLNormalizer { static func normalize(_ items: [RSOPMLItem]) -> [RSOPMLItem] { let opmlNormalizer = OPMLNormalizer() - opmlNormalizer.loadOPMLItems(items) + opmlNormalizer.normalize(items) return opmlNormalizer.normalizedOPMLItems } - private func loadOPMLItems(_ items: [RSOPMLItem], parentFolder: RSOPMLItem? = nil) { + private func normalize(_ items: [RSOPMLItem], parentFolder: RSOPMLItem? = nil) { var feedsToAdd = [RSOPMLItem]() items.forEach { (item) in @@ -34,14 +34,14 @@ final class OPMLNormalizer { guard let _ = item.titleFromAttributes else { // Folder doesn’t have a name, so it won’t be created, and its items will go one level up. if let itemChildren = item.children { - loadOPMLItems(itemChildren, parentFolder: parentFolder) + normalize(itemChildren, parentFolder: parentFolder) } return } normalizedOPMLItems.append(item) if let itemChildren = item.children { - loadOPMLItems(itemChildren, parentFolder: item) + normalize(itemChildren, parentFolder: item) } } From 3f82a28d215d096e7307b367a222d9b090ee5e8a Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Tue, 31 Mar 2020 18:10:35 -0500 Subject: [PATCH 44/98] Add import OPML for CloudKit. --- .../CloudKit/CloudKitAccountDelegate.swift | 20 +++---- .../CloudKit/CloudKitAccountZone.swift | 54 ++++++++++++++++++- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index b77d4af29..8c8c2a7de 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -123,16 +123,13 @@ final class CloudKitAccountDelegate: AccountDelegate { return } - guard let children = loadDocument.children else { + guard let opmlItems = loadDocument.children, let rootExternalID = account.externalID else { return } - BatchUpdate.shared.perform { - account.loadOPMLItems(children) - } + let normalizedItems = OPMLNormalizer.normalize(opmlItems) - completion(.success(())) - + accountZone.importOPML(rootExternalID: rootExternalID, items: normalizedItems, completion: completion) } func createWebFeed(for account: Account, url urlString: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { @@ -163,19 +160,16 @@ final class CloudKitAccountDelegate: AccountDelegate { case .success(let externalID): let feed = account.createWebFeed(with: nil, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) - + feed.editedName = name + feed.externalID = externalID + container.addWebFeed(feed) + InitialFeedDownloader.download(url) { parsedFeed in self.refreshProgress.completeTask() if let parsedFeed = parsedFeed { account.update(feed, with: parsedFeed, {_ in - - feed.editedName = name - feed.externalID = externalID - - container.addWebFeed(feed) completion(.success(feed)) - }) } diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index ede217556..532577e64 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -9,6 +9,7 @@ import Foundation import os.log import RSWeb +import RSParser import CloudKit final class CloudKitAccountZone: CloudKitZone { @@ -31,7 +32,7 @@ final class CloudKitAccountZone: CloudKitZone { struct Fields { static let url = "url" static let editedName = "editedName" - static let containerExternalIDs = "folderExternalIDs" + static let containerExternalIDs = "containerExternalIDs" } } @@ -47,6 +48,40 @@ final class CloudKitAccountZone: CloudKitZone { self.container = container self.database = container.privateCloudDatabase } + + func importOPML(rootExternalID: String, items: [RSOPMLItem], completion: @escaping (Result) -> Void) { + var records = [CKRecord]() + var feedRecords = [String: CKRecord]() + + func processFeed(feedSpecifier: RSOPMLFeedSpecifier, containerExternalID: String) { + if let webFeedRecord = feedRecords[feedSpecifier.feedURL], var containerExternalIDs = webFeedRecord[CloudKitWebFeed.Fields.containerExternalIDs] as? [String] { + containerExternalIDs.append(containerExternalID) + webFeedRecord[CloudKitWebFeed.Fields.containerExternalIDs] = containerExternalIDs + } else { + let webFeedRecord = newWebFeedCKRecord(feedSpecifier: feedSpecifier, containerExternalID: containerExternalID) + records.append(webFeedRecord) + feedRecords[feedSpecifier.feedURL] = webFeedRecord + } + } + + for item in items { + if let feedSpecifier = item.feedSpecifier { + processFeed(feedSpecifier: feedSpecifier, containerExternalID: rootExternalID) + } else { + if let title = item.titleFromAttributes { + let containerRecord = newContainerCKRecord(name: title) + records.append(containerRecord) + item.children?.forEach { itemChild in + if let feedSpecifier = itemChild.feedSpecifier { + processFeed(feedSpecifier: feedSpecifier, containerExternalID: containerRecord.externalID) + } + } + } + } + } + + modify(recordsToSave: records, recordIDsToDelete: [], completion: completion) + } /// Persist a web feed record to iCloud and return the external key func createWebFeed(url: String, editedName: String?, container: Container, completion: @escaping (Result) -> Void) { @@ -208,6 +243,23 @@ final class CloudKitAccountZone: CloudKitZone { private extension CloudKitAccountZone { + func newWebFeedCKRecord(feedSpecifier: RSOPMLFeedSpecifier, containerExternalID: String) -> CKRecord { + let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: generateRecordID()) + record[CloudKitWebFeed.Fields.url] = feedSpecifier.feedURL + if let editedName = feedSpecifier.title { + record[CloudKitWebFeed.Fields.editedName] = editedName + } + record[CloudKitWebFeed.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] = "false" + return record + } + func createContainer(name: String, isAccount: Bool, completion: @escaping (Result) -> Void) { let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: generateRecordID()) record[CloudKitContainer.Fields.name] = name From c3b5d337c58d3e94f3c7137233111df43b26a096 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Tue, 31 Mar 2020 20:42:39 -0500 Subject: [PATCH 45/98] Change to use 0 and 1 for boolean. --- .../Account/CloudKit/CloudKitAccountDelegate.swift | 2 ++ .../Account/CloudKit/CloudKitAccountZone.swift | 12 ++++++++---- .../CloudKit/CloudKitAccountZoneDelegate.swift | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 8c8c2a7de..d0a1c0262 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -55,6 +55,7 @@ final class CloudKitAccountDelegate: AccountDelegate { func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { let group = DispatchGroup() + BatchUpdate.shared.start() zones.forEach { zone in group.enter() @@ -64,6 +65,7 @@ final class CloudKitAccountDelegate: AccountDelegate { } group.notify(queue: DispatchQueue.main) { + BatchUpdate.shared.end() completion() } } diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index 532577e64..4630a97cd 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -198,13 +198,17 @@ final class CloudKitAccountZone: CloudKitZone { } func findOrCreateAccount(completion: @escaping (Result) -> Void) { - let predicate = NSPredicate(format: "isAccount = \"true\"") + let predicate = NSPredicate(format: "isAccount = \"1\"") let ckQuery = CKQuery(recordType: CloudKitContainer.recordType, predicate: predicate) query(ckQuery) { result in switch result { case .success(let records): - completion(.success(records[0].externalID)) + if records.count > 0 { + completion(.success(records[0].externalID)) + } else { + self.createContainer(name: "Account", isAccount: true, completion: completion) + } case .failure: self.createContainer(name: "Account", isAccount: true, completion: completion) } @@ -256,14 +260,14 @@ private extension CloudKitAccountZone { func newContainerCKRecord(name: String) -> CKRecord { let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: generateRecordID()) record[CloudKitContainer.Fields.name] = name - record[CloudKitContainer.Fields.isAccount] = "false" + record[CloudKitContainer.Fields.isAccount] = "0" return record } func createContainer(name: String, isAccount: Bool, completion: @escaping (Result) -> Void) { let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: generateRecordID()) record[CloudKitContainer.Fields.name] = name - record[CloudKitContainer.Fields.isAccount] = isAccount ? "true" : "false" + record[CloudKitContainer.Fields.isAccount] = isAccount ? "1" : "0" save(record) { result in switch result { diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift index ab721ed84..62e0a4a22 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -83,7 +83,7 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { guard let account = account, let name = record[CloudKitAccountZone.CloudKitContainer.Fields.name] as? String, let isAccount = record[CloudKitAccountZone.CloudKitContainer.Fields.isAccount] as? String, - isAccount != "true" else { return } + isAccount != "1" else { return } var folder = account.existingFolder(withExternalID: record.externalID) folder?.name = name From 0ce1bf5ebc457a19d37a2d2357ec41276b469e80 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Tue, 31 Mar 2020 20:56:34 -0500 Subject: [PATCH 46/98] Added sync database integration --- .../Account/CloudKit/CloudKitAccountDelegate.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index d0a1c0262..2521a4ff3 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -337,6 +337,17 @@ final class CloudKitAccountDelegate: AccountDelegate { } func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { + let syncStatuses = articles.map { article in + return SyncStatus(articleID: article.articleID, key: statusKey, flag: flag) + } + database.insertStatuses(syncStatuses) + + database.selectPendingCount { result in + if let count = try? result.get(), count > 100 { + self.sendArticleStatus(for: account) { _ in } + } + } + return try? account.update(articles, statusKey: statusKey, flag: flag) } @@ -377,10 +388,11 @@ final class CloudKitAccountDelegate: AccountDelegate { } func suspendDatabase() { - // Nothing to do + database.suspend() } func resume() { refresher.resume() + database.resume() } } From 4941d60c1aba39bc808bb2e7e3e813af956ac7f3 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Wed, 1 Apr 2020 07:50:11 -0500 Subject: [PATCH 47/98] Put add accounts list into alphabetical order --- Mac/Preferences/Accounts/AccountsAddViewController.swift | 2 +- iOS/Settings/AddAccountViewController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Mac/Preferences/Accounts/AccountsAddViewController.swift b/Mac/Preferences/Accounts/AccountsAddViewController.swift index c6f77a785..646d2dd5f 100644 --- a/Mac/Preferences/Accounts/AccountsAddViewController.swift +++ b/Mac/Preferences/Accounts/AccountsAddViewController.swift @@ -17,7 +17,7 @@ class AccountsAddViewController: NSViewController { private var accountsAddWindowController: NSWindowController? #if DEBUG - private var addableAccountTypes: [AccountType] = [.onMyMac, .cloudKit, .feedbin, .feedly, .feedWrangler, .freshRSS, .newsBlur] + private var addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly, .feedWrangler, .freshRSS, .cloudKit, .newsBlur] #else private var addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly] #endif diff --git a/iOS/Settings/AddAccountViewController.swift b/iOS/Settings/AddAccountViewController.swift index 20a59d6c6..1a2cf52f3 100644 --- a/iOS/Settings/AddAccountViewController.swift +++ b/iOS/Settings/AddAccountViewController.swift @@ -17,7 +17,7 @@ protocol AddAccountDismissDelegate: UIViewController { class AddAccountViewController: UITableViewController, AddAccountDismissDelegate { #if DEBUG - private var addableAccountTypes: [AccountType] = [.onMyMac, .cloudKit, .feedbin, .feedly, .feedWrangler, .newsBlur] + private var addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly, .feedWrangler, .cloudKit, .newsBlur] #else private var addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly] #endif From 9e2ba8a36be4f5b9604613eea60fa8abdcb0dfef Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Wed, 1 Apr 2020 08:00:24 -0500 Subject: [PATCH 48/98] Delete dead code. --- Frameworks/Account/CloudKit/CloudKitAccountZone.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index 4630a97cd..dc996baed 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -14,8 +14,6 @@ import CloudKit final class CloudKitAccountZone: CloudKitZone { - typealias ContainerWebFeed = (webFeedExternalID: String, containerWebFeedExternalID: String, containerExternalID: String) - static var zoneID: CKRecordZone.ID { return CKRecordZone.ID(zoneName: "Account", ownerName: CKCurrentUserDefaultName) } @@ -25,7 +23,7 @@ final class CloudKitAccountZone: CloudKitZone { weak var container: CKContainer? weak var database: CKDatabase? weak var refreshProgress: DownloadProgress? - var delegate: CloudKitZoneDelegate? = nil + var delegate: CloudKitZoneDelegate? struct CloudKitWebFeed { static let recordType = "WebFeed" From 9a1b7f52250b0595df3db05fe432869cd33b2f63 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Wed, 1 Apr 2020 09:19:51 -0500 Subject: [PATCH 49/98] Update precondition to make sure iCloud doesn't call the wrong update method. --- Frameworks/Account/Account.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index a487953bb..64a872d49 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -738,7 +738,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, func update(webFeedIDsAndItems: [String: Set], defaultRead: Bool, completion: @escaping DatabaseCompletionBlock) { // Used only by syncing systems. precondition(Thread.isMainThread) - precondition(type != .onMyMac) // TODO: also make sure type != iCloud + precondition(type != .onMyMac && type != .cloudKit) guard !webFeedIDsAndItems.isEmpty else { completion(nil) return From 44231937cdb1e38d945d7abb1381e84dc5fe2092 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Wed, 1 Apr 2020 11:46:37 -0500 Subject: [PATCH 50/98] Add send statuses to CloudKit. --- .../Account/Account.xcodeproj/project.pbxproj | 8 +++ .../CloudKit/CloudKitAccountDelegate.swift | 71 +++++++++++++++++-- .../CloudKit/CloudKitArticlesZone.swift | 68 ++++++++++++++++++ .../CloudKitArticlesZoneDelegate.swift | 35 +++++++++ 4 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 Frameworks/Account/CloudKit/CloudKitArticlesZone.swift create mode 100644 Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index fcbb97a28..088b09f1e 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -56,6 +56,8 @@ 5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5165D73022837F3400D9D53D /* InitialFeedDownloader.swift */; }; 5170743C232AEDB500A461A3 /* OPMLFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5170743B232AEDB500A461A3 /* OPMLFile.swift */; }; 519E84A62433D49000D238B0 /* OPMLNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */; }; + 519E84A82434C5EF00D238B0 /* CloudKitArticlesZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */; }; + 519E84AA2434C60400D238B0 /* CloudKitArticlesZoneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A92434C60400D238B0 /* CloudKitArticlesZoneDelegate.swift */; }; 51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */; }; 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC8FCB237EC055004F8B56 /* Feed.swift */; }; 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */; }; @@ -290,6 +292,8 @@ 5170743B232AEDB500A461A3 /* OPMLFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLFile.swift; sourceTree = ""; }; 518B2EA52351306200400001 /* Account_project_test.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_test.xcconfig; sourceTree = ""; }; 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLNormalizer.swift; sourceTree = ""; }; + 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZone.swift; sourceTree = ""; }; + 519E84A92434C60400D238B0 /* CloudKitArticlesZoneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZoneDelegate.swift; sourceTree = ""; }; 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBehaviors.swift; sourceTree = ""; }; 51BC8FCB237EC055004F8B56 /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = ""; }; 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerIdentifier.swift; sourceTree = ""; }; @@ -522,6 +526,8 @@ 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */, 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */, 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */, + 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */, + 519E84A92434C60400D238B0 /* CloudKitArticlesZoneDelegate.swift */, 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */, 51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */, ); @@ -1085,6 +1091,7 @@ 9EA643D5239306AC0018A28C /* FeedlyFeedsSearchResponse.swift in Sources */, 9EAEC60E2332FEC20085D7C9 /* FeedlyFeed.swift in Sources */, 5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */, + 519E84AA2434C60400D238B0 /* CloudKitArticlesZoneDelegate.swift in Sources */, 512DD4CB2431000600C17B1F /* CKRecord+Extensions.swift in Sources */, 3B826DAF2385C81C00FC1ADB /* FeedWranglerGenericResult.swift in Sources */, 9ECC9A85234DC16E009B5144 /* FeedlyAccountDelegateError.swift in Sources */, @@ -1165,6 +1172,7 @@ 51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */, 9E1773D923458D590056A5A8 /* FeedlyResourceId.swift in Sources */, 9EE4CCFA234F106600FBAE4B /* FeedlyFeedContainerValidator.swift in Sources */, + 519E84A82434C5EF00D238B0 /* CloudKitArticlesZone.swift in Sources */, 552032FC229D5D5A009559E0 /* ReaderAPIUnreadEntry.swift in Sources */, 9EC688EA232B973C00A8D0A2 /* FeedlyAPICaller.swift in Sources */, 9E1773D32345700F0056A5A8 /* FeedlyLink.swift in Sources */, diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 2521a4ff3..77ca681ee 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -30,8 +30,9 @@ final class CloudKitAccountDelegate: AccountDelegate { return CKContainer(identifier: "iCloud.\(orgID).NetNewsWire") }() - private lazy var zones = [accountZone] + private lazy var zones: [CloudKitZone] = [accountZone, articlesZone] private let accountZone: CloudKitAccountZone + private let articlesZone: CloudKitArticlesZone private let refresher = LocalAccountRefresher() @@ -48,8 +49,11 @@ final class CloudKitAccountDelegate: AccountDelegate { init(dataFolder: String) { accountZone = CloudKitAccountZone(container: container) + articlesZone = CloudKitArticlesZone(container: container) + let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3") database = SyncDatabase(databaseFilePath: databaseFilePath) + accountZone.refreshProgress = refreshProgress } @@ -75,10 +79,29 @@ final class CloudKitAccountDelegate: AccountDelegate { accountZone.fetchChangesInZone() { result in switch result { case .success: - self.refresher.refreshFeeds(account.flattenedWebFeeds()) { - BatchUpdate.shared.end() - account.metadata.lastArticleFetchEndTime = Date() - completion(.success(())) + + self.sendArticleStatus(for: account) { result in + switch result { + case .success: + + self.refreshArticleStatus(for: account) { result in + switch result { + case .success: + + self.refresher.refreshFeeds(account.flattenedWebFeeds()) { + BatchUpdate.shared.end() + account.metadata.lastArticleFetchEndTime = Date() + completion(.success(())) + } + + case .failure(let error): + completion(.failure(error)) + } + } + case .failure(let error): + completion(.failure(error)) + } + } case .failure(let error): BatchUpdate.shared.end() @@ -88,11 +111,44 @@ final class CloudKitAccountDelegate: AccountDelegate { } func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { - completion(.success(())) + os_log(.debug, log: log, "Sending article statuses...") + + database.selectForProcessing { result in + + func processStatuses(_ syncStatuses: [SyncStatus]) { + self.articlesZone.sendArticleStatus(syncStatuses) { result in + switch result { + case .success: + os_log(.debug, log: self.log, "Done sending article statuses.") + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } + + switch result { + case .success(let syncStatuses): + processStatuses(syncStatuses) + case .failure(let databaseError): + completion(.failure(databaseError)) + } + } } + func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { - completion(.success(())) + os_log(.debug, log: log, "Refreshing article statuses...") + + articlesZone.fetchChangesInZone() { result in + os_log(.debug, log: self.log, "Done refreshing article statuses.") + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } } func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result) -> Void) { @@ -353,6 +409,7 @@ final class CloudKitAccountDelegate: AccountDelegate { func accountDidInitialize(_ account: Account) { accountZone.delegate = CloudKitAcountZoneDelegate(account: account, refreshProgress: refreshProgress) + articlesZone.delegate = CloudKitArticlesZoneDelegate(account: account) if account.externalID == nil { accountZone.findOrCreateAccount() { result in diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift new file mode 100644 index 000000000..27afa65a0 --- /dev/null +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift @@ -0,0 +1,68 @@ +// +// CloudKitArticlesZone.swift +// Account +// +// Created by Maurice Parker on 4/1/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import os.log +import RSWeb +import CloudKit +import SyncDatabase + +final class CloudKitArticlesZone: CloudKitZone { + + + static var zoneID: CKRecordZone.ID { + return CKRecordZone.ID(zoneName: "Articles", ownerName: CKCurrentUserDefaultName) + } + + var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") + + weak var container: CKContainer? + weak var database: CKDatabase? + weak var refreshProgress: DownloadProgress? = nil + var delegate: CloudKitZoneDelegate? = nil + + struct CloudKitArticleStatus { + static let recordType = "ArticleStatus" + struct Fields { + static let read = "read" + static let starred = "starred" + static let userDeleted = "userDeleted" + } + } + + init(container: CKContainer) { + self.container = container + self.database = container.privateCloudDatabase + } + + func sendArticleStatus(_ syncStatuses: [SyncStatus], completion: @escaping ((Result) -> Void)) { + var records = [String: CKRecord]() + + for status in syncStatuses { + + var record = records[status.articleID] + if record == nil { + let recordID = CKRecord.ID(recordName: status.articleID, zoneID: Self.zoneID) + record = CKRecord(recordType: CloudKitArticleStatus.recordType, recordID: recordID) + records[status.articleID] = record + } + + switch status.key { + case .read: + record![CloudKitArticleStatus.Fields.read] = status.flag ? "1" : "0" + case .starred: + record![CloudKitArticleStatus.Fields.starred] = status.flag ? "1" : "0" + case .userDeleted: + record![CloudKitArticleStatus.Fields.userDeleted] = status.flag ? "1" : "0" + } + } + + modify(recordsToSave: Array(records.values), recordIDsToDelete: [], completion: completion) + } + +} diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift new file mode 100644 index 000000000..5c6cd8f44 --- /dev/null +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift @@ -0,0 +1,35 @@ +// +// CloudKitArticlesZoneDelegate.swift +// Account +// +// Created by Maurice Parker on 4/1/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import os.log +import CloudKit + +class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate { + + private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") + + weak var account: Account? + + init(account: Account) { + self.account = account + } + + func cloudKitDidChange(record: CKRecord) { +// switch record.recordType { +// case CloudKitAccountZone.CloudKitWebFeed.recordType: +// default: +// assertionFailure("Unknown record type: \(record.recordType)") +// } + } + + func cloudKitDidDelete(recordType: CKRecord.RecordType, recordID: CKRecord.ID) { + // Article downloads clean up old articles and statuses + } + +} From 1ab21bd3e3dc5f329c4e3af8deb29f6d14db0e42 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Wed, 1 Apr 2020 12:22:59 -0500 Subject: [PATCH 51/98] Added batch update capabilities. --- .../Account/Account.xcodeproj/project.pbxproj | 8 +++---- .../CloudKitAccountZoneDelegate.swift | 18 ++++++++++----- .../CloudKitArticlesZoneDelegate.swift | 18 +++++++++------ .../Account/CloudKit/CloudKitZone.swift | 22 +++++++++++++++++-- 4 files changed, 48 insertions(+), 18 deletions(-) diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 088b09f1e..b71ebc288 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -57,7 +57,7 @@ 5170743C232AEDB500A461A3 /* OPMLFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5170743B232AEDB500A461A3 /* OPMLFile.swift */; }; 519E84A62433D49000D238B0 /* OPMLNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */; }; 519E84A82434C5EF00D238B0 /* CloudKitArticlesZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */; }; - 519E84AA2434C60400D238B0 /* CloudKitArticlesZoneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A92434C60400D238B0 /* CloudKitArticlesZoneDelegate.swift */; }; + 519E84AC2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */; }; 51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */; }; 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC8FCB237EC055004F8B56 /* Feed.swift */; }; 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */; }; @@ -293,7 +293,7 @@ 518B2EA52351306200400001 /* Account_project_test.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_test.xcconfig; sourceTree = ""; }; 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLNormalizer.swift; sourceTree = ""; }; 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZone.swift; sourceTree = ""; }; - 519E84A92434C60400D238B0 /* CloudKitArticlesZoneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZoneDelegate.swift; sourceTree = ""; }; + 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZoneDelegate.swift; sourceTree = ""; }; 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBehaviors.swift; sourceTree = ""; }; 51BC8FCB237EC055004F8B56 /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = ""; }; 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerIdentifier.swift; sourceTree = ""; }; @@ -527,7 +527,7 @@ 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */, 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */, 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */, - 519E84A92434C60400D238B0 /* CloudKitArticlesZoneDelegate.swift */, + 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */, 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */, 51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */, ); @@ -1091,7 +1091,7 @@ 9EA643D5239306AC0018A28C /* FeedlyFeedsSearchResponse.swift in Sources */, 9EAEC60E2332FEC20085D7C9 /* FeedlyFeed.swift in Sources */, 5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */, - 519E84AA2434C60400D238B0 /* CloudKitArticlesZoneDelegate.swift in Sources */, + 519E84AC2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift in Sources */, 512DD4CB2431000600C17B1F /* CKRecord+Extensions.swift in Sources */, 3B826DAF2385C81C00FC1ADB /* FeedWranglerGenericResult.swift in Sources */, 9ECC9A85234DC16E009B5144 /* FeedlyAccountDelegateError.swift in Sources */, diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift index 62e0a4a22..27056ecd1 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -37,16 +37,24 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { } } - func cloudKitDidDelete(recordType: CKRecord.RecordType, recordID: CKRecord.ID) { - switch recordType { + func cloudKitDidDelete(recordKey: CloudKitRecordKey) { + switch recordKey.recordType { case CloudKitAccountZone.CloudKitWebFeed.recordType: - removeWebFeed(recordID.externalID) + removeWebFeed(recordKey.recordID.externalID) case CloudKitAccountZone.CloudKitContainer.recordType: - removeContainer(recordID.externalID) + removeContainer(recordKey.recordID.externalID) default: - assertionFailure("Unknown record type: \(recordID.externalID)") + assertionFailure("Unknown record type: \(recordKey.recordType)") } } + + func cloudKitDidChange(records: [CKRecord]) { + // We don't batch process these records + } + + func cloudKitDidDelete(recordKeys: [CloudKitRecordKey]) { + // We don't batch process these records + } func addOrUpdateWebFeed(_ record: CKRecord) { guard let account = account, diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift index 5c6cd8f44..1774ae16f 100644 --- a/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift @@ -11,7 +11,7 @@ import os.log import CloudKit class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate { - + private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") weak var account: Account? @@ -21,14 +21,18 @@ class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate { } func cloudKitDidChange(record: CKRecord) { -// switch record.recordType { -// case CloudKitAccountZone.CloudKitWebFeed.recordType: -// default: -// assertionFailure("Unknown record type: \(record.recordType)") -// } + // Process everything in the batch method } - func cloudKitDidDelete(recordType: CKRecord.RecordType, recordID: CKRecord.ID) { + func cloudKitDidDelete(recordKey: CloudKitRecordKey) { + // Article downloads clean up old articles and statuses + } + + func cloudKitDidChange(records: [CKRecord]) { + // TODO + } + + func cloudKitDidDelete(recordKeys: [CloudKitRecordKey]) { // Article downloads clean up old articles and statuses } diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 2ed4486f2..6814538ae 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -18,9 +18,13 @@ enum CloudKitZoneError: Error { protocol CloudKitZoneDelegate: class { func cloudKitDidChange(record: CKRecord); - func cloudKitDidDelete(recordType: CKRecord.RecordType, recordID: CKRecord.ID) + func cloudKitDidDelete(recordKey: CloudKitRecordKey) + func cloudKitDidChange(records: [CKRecord]); + func cloudKitDidDelete(recordKeys: [CloudKitRecordKey]) } +typealias CloudKitRecordKey = (recordType: CKRecord.RecordType, recordID: CKRecord.ID) + protocol CloudKitZone: class { static var zoneID: CKRecordZone.ID { get } @@ -241,6 +245,9 @@ extension CloudKitZone { func fetchChangesInZone(completion: @escaping (Result) -> Void) { + var changedRecords = [CKRecord]() + var deletedRecordKeys = [CloudKitRecordKey]() + let zoneConfig = CKFetchRecordZoneChangesOperation.ZoneConfiguration() zoneConfig.previousServerChangeToken = changeToken let op = CKFetchRecordZoneChangesOperation(recordZoneIDs: [Self.zoneID], configurationsByRecordZoneID: [Self.zoneID: zoneConfig]) @@ -248,6 +255,7 @@ extension CloudKitZone { op.recordZoneChangeTokensUpdatedBlock = { [weak self] zoneID, token, _ in guard let self = self else { return } + DispatchQueue.main.async { self.changeToken = token } @@ -255,6 +263,8 @@ extension CloudKitZone { op.recordChangedBlock = { [weak self] record in guard let self = self else { return } + + changedRecords.append(record) DispatchQueue.main.async { self.delegate?.cloudKitDidChange(record: record) } @@ -262,8 +272,12 @@ extension CloudKitZone { op.recordWithIDWasDeletedBlock = { [weak self] recordID, recordType in guard let self = self else { return } + + let recordKey = CloudKitRecordKey(recordType: recordType, recordID: recordID) + deletedRecordKeys.append(recordKey) + DispatchQueue.main.async { - self.delegate?.cloudKitDidDelete(recordType: recordType, recordID: recordID) + self.delegate?.cloudKitDidDelete(recordKey: recordKey) } } @@ -287,6 +301,10 @@ extension CloudKitZone { op.fetchRecordZoneChangesCompletionBlock = { [weak self] error in DispatchQueue.main.async { self?.refreshProgress?.completeTask() + + self?.delegate?.cloudKitDidChange(records: changedRecords) + self?.delegate?.cloudKitDidDelete(recordKeys: deletedRecordKeys) + if let error = error { completion(.failure(error)) } else { From 694be77e966d2c4fe6ddb8ab4a17c31cdba7ed01 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Wed, 1 Apr 2020 14:10:07 -0500 Subject: [PATCH 52/98] Add CloudKit status syncing --- .../CloudKit/CloudKitAccountDelegate.swift | 7 ++- .../CloudKitArticlesZoneDelegate.swift | 51 ++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 77ca681ee..c769c526b 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -116,6 +116,11 @@ final class CloudKitAccountDelegate: AccountDelegate { database.selectForProcessing { result in func processStatuses(_ syncStatuses: [SyncStatus]) { + guard syncStatuses.count > 0 else { + completion(.success(())) + return + } + self.articlesZone.sendArticleStatus(syncStatuses) { result in switch result { case .success: @@ -409,7 +414,7 @@ final class CloudKitAccountDelegate: AccountDelegate { func accountDidInitialize(_ account: Account) { accountZone.delegate = CloudKitAcountZoneDelegate(account: account, refreshProgress: refreshProgress) - articlesZone.delegate = CloudKitArticlesZoneDelegate(account: account) + articlesZone.delegate = CloudKitArticlesZoneDelegate(account: account, database: database) if account.externalID == nil { accountZone.findOrCreateAccount() { result in diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift index 1774ae16f..5d469558f 100644 --- a/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift @@ -9,15 +9,18 @@ import Foundation import os.log import CloudKit +import SyncDatabase class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate { private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") weak var account: Account? + var database: SyncDatabase - init(account: Account) { + init(account: Account, database: SyncDatabase) { self.account = account + self.database = database } func cloudKitDidChange(record: CKRecord) { @@ -29,7 +32,28 @@ class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate { } func cloudKitDidChange(records: [CKRecord]) { - // TODO + database.selectPendingReadStatusArticleIDs() { result in + switch result { + case .success(let pendingReadStatusArticleIDs): + + self.database.selectPendingStarredStatusArticleIDs() { result in + switch result { + case .success(let pendingStarredStatusArticleIDs): + + self.process(records: records, + pendingReadStatusArticleIDs: pendingReadStatusArticleIDs, + pendingStarredStatusArticleIDs: pendingStarredStatusArticleIDs) + + case .failure(let error): + os_log(.error, log: self.log, "Error occurred geting pending starred records: %@", error.localizedDescription) + } + } + case .failure(let error): + os_log(.error, log: self.log, "Error occurred getting pending read status records: %@", error.localizedDescription) + } + + } + } func cloudKitDidDelete(recordKeys: [CloudKitRecordKey]) { @@ -37,3 +61,26 @@ class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate { } } + +private extension CloudKitArticlesZoneDelegate { + + func process(records: [CKRecord], pendingReadStatusArticleIDs: Set, pendingStarredStatusArticleIDs: Set) { + + let receivedUnreadArticleIDs = Set(records.filter( { $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.read] == "0" }).map({ $0.externalID })) + let receivedReadArticleIDs = Set(records.filter( { $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.read] == "1" }).map({ $0.externalID })) + let receivedUnstarredArticleIDs = Set(records.filter( { $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.starred] == "0" }).map({ $0.externalID })) + let receivedStarredArticleIDs = Set(records.filter( { $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.starred] == "1" }).map({ $0.externalID })) + + let updateableUnreadArticleIDs = receivedUnreadArticleIDs.subtracting(pendingReadStatusArticleIDs) + let updateableReadArticleIDs = receivedReadArticleIDs.subtracting(pendingReadStatusArticleIDs) + let updateableUnstarredArticleIDs = receivedUnstarredArticleIDs.subtracting(pendingStarredStatusArticleIDs) + let updateableStarredArticleIDs = receivedStarredArticleIDs.subtracting(pendingStarredStatusArticleIDs) + + account?.markAsUnread(updateableUnreadArticleIDs) + account?.markAsRead(updateableReadArticleIDs) + account?.markAsUnstarred(updateableUnstarredArticleIDs) + account?.markAsStarred(updateableStarredArticleIDs) + + } + +} From 9ffaa41d35d4b5031e43034c519eba78cfefa3c1 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Wed, 1 Apr 2020 14:55:22 -0500 Subject: [PATCH 53/98] Add missing post sync database updates. --- Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index c769c526b..a40eb3eb3 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -124,9 +124,11 @@ final class CloudKitAccountDelegate: AccountDelegate { self.articlesZone.sendArticleStatus(syncStatuses) { result in switch result { case .success: + self.database.deleteSelectedForProcessing(syncStatuses.map({ $0.articleID }) ) os_log(.debug, log: self.log, "Done sending article statuses.") completion(.success(())) case .failure(let error): + self.database.resetSelectedForProcessing(syncStatuses.map({ $0.articleID }) ) completion(.failure(error)) } } From 7e8892cda51ab8aa9ad0a9563680970020036103 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Wed, 1 Apr 2020 14:55:40 -0500 Subject: [PATCH 54/98] Beef up error handling for fetches. --- .../Account/CloudKit/CloudKitZone.swift | 49 +++++++++++++++---- .../Account/CloudKit/CloudKitZoneResult.swift | 15 ++++-- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 6814538ae..d0fc91278 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -299,18 +299,49 @@ extension CloudKitZone { } op.fetchRecordZoneChangesCompletionBlock = { [weak self] error in - DispatchQueue.main.async { - self?.refreshProgress?.completeTask() - - self?.delegate?.cloudKitDidChange(records: changedRecords) - self?.delegate?.cloudKitDidDelete(recordKeys: deletedRecordKeys) - - if let error = error { - completion(.failure(error)) - } else { + guard let self = self else { return } + + switch CloudKitZoneResult.resolve(error) { + case .success: + DispatchQueue.main.async { + self.refreshProgress?.completeTask() + self.delegate?.cloudKitDidChange(records: changedRecords) + self.delegate?.cloudKitDidDelete(recordKeys: deletedRecordKeys) completion(.success(())) } + case .zoneNotFound: + self.createZoneRecord() { result in + switch result { + case .success: + self.fetchChangesInZone(completion: completion) + case .failure(let error): + DispatchQueue.main.async { + self.refreshProgress?.completeTask() + completion(.failure(error)) + } + } + } + case .userDeletedZone: + DispatchQueue.main.async { + self.refreshProgress?.completeTask() + completion(.failure(CloudKitZoneError.userDeletedZone)) + } + case .retry(let timeToWait): + self.retryIfPossible(after: timeToWait) { + self.fetchChangesInZone(completion: completion) + } + case .changeTokenExpired: + DispatchQueue.main.async { + self.changeToken = nil + self.fetchChangesInZone(completion: completion) + } + default: + DispatchQueue.main.async { + self.refreshProgress?.completeTask() + completion(.failure(error!)) + } } + } refreshProgress?.addToNumberOfTasksAndRemaining(1) diff --git a/Frameworks/Account/CloudKit/CloudKitZoneResult.swift b/Frameworks/Account/CloudKit/CloudKitZoneResult.swift index 5de070641..e37a84ddd 100644 --- a/Frameworks/Account/CloudKit/CloudKitZoneResult.swift +++ b/Frameworks/Account/CloudKit/CloudKitZoneResult.swift @@ -14,7 +14,7 @@ enum CloudKitZoneResult { case retry(afterSeconds: Double) case limitExceeded case changeTokenExpired - case partialFailure(errors: [CKRecord.ID: CKError]) + case partialFailure(errors: [AnyHashable: CKError]) case serverRecordChanged case zoneNotFound case userDeletedZone @@ -35,13 +35,17 @@ enum CloudKitZoneResult { } else { return .failure(error: error!) } + case .zoneNotFound: + return .zoneNotFound + case .userDeletedZone: + return .userDeletedZone case .changeTokenExpired: return .changeTokenExpired case .serverRecordChanged: return .serverRecordChanged case .partialFailure: - if let partialErrors = ckError.userInfo[CKPartialErrorsByItemIDKey] as? [CKRecord.ID: CKError] { - if let zoneResult = anyZoneErrors(partialErrors) { + if let partialErrors = ckError.userInfo[CKPartialErrorsByItemIDKey] as? [AnyHashable: CKError] { + if let zoneResult = anyRequestErrors(partialErrors) { return zoneResult } else { return .partialFailure(errors: partialErrors) @@ -61,7 +65,10 @@ enum CloudKitZoneResult { private extension CloudKitZoneResult { - static func anyZoneErrors(_ errors: [CKRecord.ID: CKError]) -> CloudKitZoneResult? { + static func anyRequestErrors(_ errors: [AnyHashable: CKError]) -> CloudKitZoneResult? { + if errors.values.contains(where: { $0.code == .changeTokenExpired } ) { + return .changeTokenExpired + } if errors.values.contains(where: { $0.code == .zoneNotFound } ) { return .zoneNotFound } From 39aecd84fec9451028807e85b634b175004a323f Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Wed, 1 Apr 2020 15:39:07 -0500 Subject: [PATCH 55/98] Fixed chunked record handling. --- .../Account/CloudKit/CloudKitZone.swift | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index d0fc91278..17f97a7ac 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -227,10 +227,31 @@ extension CloudKitZone { self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion) } case .limitExceeded: + let chunkedRecords = recordsToSave.chunked(into: 300) + + let group = DispatchGroup() + var errorOccurred = false + for chunk in chunkedRecords { - self.modify(recordsToSave: chunk, recordIDsToDelete: recordIDsToDelete, completion: completion) + group.enter() + self.modify(recordsToSave: chunk, recordIDsToDelete: recordIDsToDelete) { result in + if case .failure(let error) = result { + os_log(.error, log: self.log, "%@ zone modify records error: %@.", Self.zoneID.zoneName, error.localizedDescription) + errorOccurred = true + } + group.leave() + } } + + group.notify(queue: DispatchQueue.main) { + if errorOccurred { + completion(.failure(CloudKitZoneError.unknown)) + } else { + completion(.success(())) + } + } + default: DispatchQueue.main.async { self.refreshProgress?.completeTask() From b3cf7ccdb7ae9c9da6eca9118b209f2ec02a9996 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Wed, 1 Apr 2020 15:39:29 -0500 Subject: [PATCH 56/98] Remove batch update blocks that were causing more harm than good. --- Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index a40eb3eb3..8dce7e1ed 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -59,7 +59,6 @@ final class CloudKitAccountDelegate: AccountDelegate { func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { let group = DispatchGroup() - BatchUpdate.shared.start() zones.forEach { zone in group.enter() @@ -69,7 +68,6 @@ final class CloudKitAccountDelegate: AccountDelegate { } group.notify(queue: DispatchQueue.main) { - BatchUpdate.shared.end() completion() } } @@ -77,9 +75,10 @@ final class CloudKitAccountDelegate: AccountDelegate { func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { BatchUpdate.shared.start() accountZone.fetchChangesInZone() { result in + BatchUpdate.shared.end() switch result { case .success: - + self.sendArticleStatus(for: account) { result in switch result { case .success: @@ -89,7 +88,6 @@ final class CloudKitAccountDelegate: AccountDelegate { case .success: self.refresher.refreshFeeds(account.flattenedWebFeeds()) { - BatchUpdate.shared.end() account.metadata.lastArticleFetchEndTime = Date() completion(.success(())) } From def48546a30282c5a39bffdd660c3b1890b36db7 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Wed, 1 Apr 2020 16:45:29 -0500 Subject: [PATCH 57/98] Change how initial refresh is triggered. --- Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift | 5 +++++ .../Accounts/AccountsAddCloudKitWindowController.swift | 3 +-- iOS/Account/CloudKitAccountViewController.swift | 3 +-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 8dce7e1ed..c6bd7d04d 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -421,6 +421,11 @@ final class CloudKitAccountDelegate: AccountDelegate { switch result { case .success(let externalID): account.externalID = externalID + self.refreshAll(for: account) { result in + if case .failure(let error) = result { + os_log(.error, log: self.log, "Error while doing intial refresh: %@", error.localizedDescription) + } + } case .failure(let error): os_log(.error, log: self.log, "Error adding account container: %@", error.localizedDescription) } diff --git a/Mac/Preferences/Accounts/AccountsAddCloudKitWindowController.swift b/Mac/Preferences/Accounts/AccountsAddCloudKitWindowController.swift index 3ce698537..5d0e3202f 100644 --- a/Mac/Preferences/Accounts/AccountsAddCloudKitWindowController.swift +++ b/Mac/Preferences/Accounts/AccountsAddCloudKitWindowController.swift @@ -35,8 +35,7 @@ class AccountsAddCloudKitWindowController: NSWindowController { } @IBAction func create(_ sender: Any) { - let account = AccountManager.shared.createAccount(type: .cloudKit) - account.refreshAll(completion: { _ in }) + let _ = AccountManager.shared.createAccount(type: .cloudKit) hostWindow!.endSheet(window!, returnCode: NSApplication.ModalResponse.OK) } diff --git a/iOS/Account/CloudKitAccountViewController.swift b/iOS/Account/CloudKitAccountViewController.swift index 9c026143a..a72f8203f 100644 --- a/iOS/Account/CloudKitAccountViewController.swift +++ b/iOS/Account/CloudKitAccountViewController.swift @@ -25,8 +25,7 @@ class CloudKitAccountViewController: UITableViewController { } @IBAction func add(_ sender: Any) { - let account = AccountManager.shared.createAccount(type: .cloudKit) - account.refreshAll(completion: { _ in }) + let _ = AccountManager.shared.createAccount(type: .cloudKit) dismiss(animated: true, completion: nil) delegate?.dismiss() } From 850d6b56230a7ba40d6baafd9a0fa6b8b1074870 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Wed, 1 Apr 2020 17:15:00 -0500 Subject: [PATCH 58/98] Format code fix. --- Frameworks/Account/CloudKit/CloudKitArticlesZone.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift index 27afa65a0..36860f1e7 100644 --- a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift @@ -14,7 +14,6 @@ import SyncDatabase final class CloudKitArticlesZone: CloudKitZone { - static var zoneID: CKRecordZone.ID { return CKRecordZone.ID(zoneName: "Articles", ownerName: CKCurrentUserDefaultName) } From ea78b5683dd9c3fc41c3f478ae31dfad8a3ee07a Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Wed, 1 Apr 2020 20:21:14 -0500 Subject: [PATCH 59/98] Fix background notification processing of CloudKit changes. --- .../CloudKit/CloudKitAccountDelegate.swift | 3 ++ .../CloudKitAccountZoneDelegate.swift | 8 +--- .../CloudKitArticlesZoneDelegate.swift | 43 +++++++++++++------ .../Account/CloudKit/CloudKitZone.swift | 7 +-- iOS/AppDelegate.swift | 2 + 5 files changed, 40 insertions(+), 23 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index c6bd7d04d..879b8bd0e 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -58,6 +58,8 @@ final class CloudKitAccountDelegate: AccountDelegate { } func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + os_log(.debug, log: log, "Processing remote notification...") + let group = DispatchGroup() zones.forEach { zone in @@ -68,6 +70,7 @@ final class CloudKitAccountDelegate: AccountDelegate { } group.notify(queue: DispatchQueue.main) { + os_log(.debug, log: self.log, "Done processing remote notification...") completion() } } diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift index 27056ecd1..b00561ad5 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -48,14 +48,10 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { } } - func cloudKitDidChange(records: [CKRecord]) { - // We don't batch process these records + func cloudKitDidModify(changed: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result) -> Void) { + completion(.success(())) } - func cloudKitDidDelete(recordKeys: [CloudKitRecordKey]) { - // We don't batch process these records - } - func addOrUpdateWebFeed(_ record: CKRecord) { guard let account = account, let urlString = record[CloudKitAccountZone.CloudKitWebFeed.Fields.url] as? String, diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift index 5d469558f..31bed3d58 100644 --- a/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift @@ -31,7 +31,8 @@ class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate { // Article downloads clean up old articles and statuses } - func cloudKitDidChange(records: [CKRecord]) { + func cloudKitDidModify(changed: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result) -> Void) { + database.selectPendingReadStatusArticleIDs() { result in switch result { case .success(let pendingReadStatusArticleIDs): @@ -40,9 +41,10 @@ class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate { switch result { case .success(let pendingStarredStatusArticleIDs): - self.process(records: records, + self.process(records: changed, pendingReadStatusArticleIDs: pendingReadStatusArticleIDs, - pendingStarredStatusArticleIDs: pendingStarredStatusArticleIDs) + pendingStarredStatusArticleIDs: pendingStarredStatusArticleIDs, + completion: completion) case .failure(let error): os_log(.error, log: self.log, "Error occurred geting pending starred records: %@", error.localizedDescription) @@ -56,15 +58,11 @@ class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate { } - func cloudKitDidDelete(recordKeys: [CloudKitRecordKey]) { - // Article downloads clean up old articles and statuses - } - } private extension CloudKitArticlesZoneDelegate { - func process(records: [CKRecord], pendingReadStatusArticleIDs: Set, pendingStarredStatusArticleIDs: Set) { + func process(records: [CKRecord], pendingReadStatusArticleIDs: Set, pendingStarredStatusArticleIDs: Set, completion: @escaping (Result) -> Void) { let receivedUnreadArticleIDs = Set(records.filter( { $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.read] == "0" }).map({ $0.externalID })) let receivedReadArticleIDs = Set(records.filter( { $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.read] == "1" }).map({ $0.externalID })) @@ -76,10 +74,31 @@ private extension CloudKitArticlesZoneDelegate { let updateableUnstarredArticleIDs = receivedUnstarredArticleIDs.subtracting(pendingStarredStatusArticleIDs) let updateableStarredArticleIDs = receivedStarredArticleIDs.subtracting(pendingStarredStatusArticleIDs) - account?.markAsUnread(updateableUnreadArticleIDs) - account?.markAsRead(updateableReadArticleIDs) - account?.markAsUnstarred(updateableUnstarredArticleIDs) - account?.markAsStarred(updateableStarredArticleIDs) + let group = DispatchGroup() + + group.enter() + account?.markAsUnread(updateableUnreadArticleIDs) { _ in + group.leave() + } + + group.enter() + account?.markAsRead(updateableReadArticleIDs) { _ in + group.leave() + } + + group.enter() + account?.markAsUnstarred(updateableUnstarredArticleIDs) { _ in + group.leave() + } + + group.enter() + account?.markAsStarred(updateableStarredArticleIDs) { _ in + group.leave() + } + + group.notify(queue: DispatchQueue.main) { + completion(.success(())) + } } diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 17f97a7ac..9cc28defa 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -19,8 +19,7 @@ enum CloudKitZoneError: Error { protocol CloudKitZoneDelegate: class { func cloudKitDidChange(record: CKRecord); func cloudKitDidDelete(recordKey: CloudKitRecordKey) - func cloudKitDidChange(records: [CKRecord]); - func cloudKitDidDelete(recordKeys: [CloudKitRecordKey]) + func cloudKitDidModify(changed: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result) -> Void); } typealias CloudKitRecordKey = (recordType: CKRecord.RecordType, recordID: CKRecord.ID) @@ -326,9 +325,7 @@ extension CloudKitZone { case .success: DispatchQueue.main.async { self.refreshProgress?.completeTask() - self.delegate?.cloudKitDidChange(records: changedRecords) - self.delegate?.cloudKitDidDelete(recordKeys: deletedRecordKeys) - completion(.success(())) + self.delegate?.cloudKitDidModify(changed: changedRecords, deleted: deletedRecordKeys, completion: completion) } case .zoneNotFound: self.createZoneRecord() { result in diff --git a/iOS/AppDelegate.swift b/iOS/AppDelegate.swift index 965e34917..a16e92ab2 100644 --- a/iOS/AppDelegate.swift +++ b/iOS/AppDelegate.swift @@ -112,7 +112,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { DispatchQueue.main.async { + self.resumeDatabaseProcessingIfNecessary() AccountManager.shared.receiveRemoteNotification(userInfo: userInfo) { + self.suspendApplication() completionHandler(.newData) } } From 40ea5243c6e75ab6c0dc9c5fc7237a280673dcca Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Wed, 1 Apr 2020 20:31:32 -0500 Subject: [PATCH 60/98] Removed notification we no longer needed to use to refresh the Feeds and Sidebar. --- Frameworks/Account/Account.swift | 5 +---- Mac/MainWindow/Sidebar/SidebarViewController.swift | 5 ----- iOS/SceneCoordinator.swift | 5 ----- 3 files changed, 1 insertion(+), 14 deletions(-) diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 64a872d49..93c78b199 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -27,7 +27,6 @@ public extension Notification.Name { static let AccountRefreshDidBegin = Notification.Name(rawValue: "AccountRefreshDidBegin") static let AccountRefreshDidFinish = Notification.Name(rawValue: "AccountRefreshDidFinish") static let AccountRefreshProgressDidChange = Notification.Name(rawValue: "AccountRefreshProgressDidChange") - static let DownloadArticlesDidUpdateUnreadCounts = Notification.Name(rawValue: "DownloadArticlesDidUpdateUnreadCounts") static let AccountDidDownloadArticles = Notification.Name(rawValue: "AccountDidDownloadArticles") static let AccountStateDidChange = Notification.Name(rawValue: "AccountStateDidChange") static let StatusesDidChange = Notification.Name(rawValue: "StatusesDidChange") @@ -1289,9 +1288,7 @@ private extension Account { if let newArticles = newAndUpdatedArticles.newArticles, !newArticles.isEmpty { shouldSendNotification = true userInfo[UserInfoKey.newArticles] = newArticles - self.updateUnreadCounts(for: webFeeds) { - NotificationCenter.default.post(name: .DownloadArticlesDidUpdateUnreadCounts, object: self, userInfo: nil) - } + self.updateUnreadCounts(for: webFeeds) } if let updatedArticles = newAndUpdatedArticles.updatedArticles, !updatedArticles.isEmpty { diff --git a/Mac/MainWindow/Sidebar/SidebarViewController.swift b/Mac/MainWindow/Sidebar/SidebarViewController.swift index 5ffe5ae31..e7cde17b1 100644 --- a/Mac/MainWindow/Sidebar/SidebarViewController.swift +++ b/Mac/MainWindow/Sidebar/SidebarViewController.swift @@ -75,7 +75,6 @@ protocol SidebarDelegate: class { NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(webFeedSettingDidChange(_:)), name: .WebFeedSettingDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(downloadArticlesDidUpdateUnreadCounts(_:)), name: .DownloadArticlesDidUpdateUnreadCounts, object: nil) outlineView.reloadData() @@ -218,10 +217,6 @@ protocol SidebarDelegate: class { revealAndSelectRepresentedObject(feed as AnyObject) } - @objc func downloadArticlesDidUpdateUnreadCounts(_ note: Notification) { - rebuildTreeAndRestoreSelection() - } - // MARK: - Actions @IBAction func delete(_ sender: AnyObject?) { diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index 0929f16c4..63aa7f138 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -301,7 +301,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { NotificationCenter.default.addObserver(self, selector: #selector(userDidDeleteAccount(_:)), name: .UserDidDeleteAccount, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(userDidAddFeed(_:)), name: .UserDidAddFeed, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(downloadArticlesDidUpdateUnreadCounts(_:)), name: .DownloadArticlesDidUpdateUnreadCounts, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(accountDidDownloadArticles(_:)), name: .AccountDidDownloadArticles, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil) @@ -535,10 +534,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { self.groupByFeed = AppDefaults.timelineGroupByFeed } - @objc func downloadArticlesDidUpdateUnreadCounts(_ note: Notification) { - rebuildBackingStores() - } - @objc func accountDidDownloadArticles(_ note: Notification) { guard let feeds = note.userInfo?[Account.UserInfoKey.webFeeds] as? Set else { return From 2924c0e6cc05ca62dba091313f29fb36587b4ba0 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Thu, 2 Apr 2020 12:00:10 -0500 Subject: [PATCH 61/98] Rework download progress so that the delegate always manages it to make for smoother progress bar progressions. --- .../CloudKit/CloudKitAccountDelegate.swift | 134 ++++++++++++------ .../CloudKit/CloudKitAccountZone.swift | 1 - .../CloudKitAccountZoneDelegate.swift | 1 - .../CloudKit/CloudKitArticlesZone.swift | 1 - .../Account/CloudKit/CloudKitZone.swift | 18 --- .../LocalAccount/LocalAccountDelegate.swift | 12 +- .../LocalAccount/LocalAccountRefresher.swift | 23 ++- 7 files changed, 114 insertions(+), 76 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 879b8bd0e..02636e56d 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -43,9 +43,7 @@ final class CloudKitAccountDelegate: AccountDelegate { var credentials: Credentials? var accountMetadata: AccountMetadata? - var refreshProgress: DownloadProgress { - return refresher.progress - } + var refreshProgress = DownloadProgress(numberOfTasks: 0) init(dataFolder: String) { accountZone = CloudKitAccountZone(container: container) @@ -53,8 +51,6 @@ final class CloudKitAccountDelegate: AccountDelegate { let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3") database = SyncDatabase(databaseFilePath: databaseFilePath) - - accountZone.refreshProgress = refreshProgress } func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { @@ -76,39 +72,7 @@ final class CloudKitAccountDelegate: AccountDelegate { } func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { - BatchUpdate.shared.start() - accountZone.fetchChangesInZone() { result in - BatchUpdate.shared.end() - switch result { - case .success: - - self.sendArticleStatus(for: account) { result in - switch result { - case .success: - - self.refreshArticleStatus(for: account) { result in - switch result { - case .success: - - self.refresher.refreshFeeds(account.flattenedWebFeeds()) { - account.metadata.lastArticleFetchEndTime = Date() - completion(.success(())) - } - - case .failure(let error): - completion(.failure(error)) - } - } - case .failure(let error): - completion(.failure(error)) - } - - } - case .failure(let error): - BatchUpdate.shared.end() - completion(.failure(error)) - } - } + refreshAll(for: account, downloadFeeds: true, completion: completion) } func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { @@ -204,9 +168,10 @@ final class CloudKitAccountDelegate: AccountDelegate { return } - refreshProgress.addToNumberOfTasksAndRemaining(1) + refreshProgress.addToNumberOfTasksAndRemaining(3) FeedFinder.find(url: url) { result in + self.refreshProgress.completeTask() switch result { case .success(let feedSpecifiers): guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), let url = URL(string: bestFeedSpecifier.urlString) else { @@ -222,6 +187,8 @@ final class CloudKitAccountDelegate: AccountDelegate { } self.accountZone.createWebFeed(url: bestFeedSpecifier.urlString, editedName: name, container: container) { result in + + self.refreshProgress.completeTask() switch result { case .success(let externalID): @@ -242,13 +209,12 @@ final class CloudKitAccountDelegate: AccountDelegate { } case .failure(let error): - self.refreshProgress.completeTask() completion(.failure(error)) } } case .failure: - self.refreshProgress.completeTask() + self.refreshProgress.clear() completion(.failure(AccountError.createErrorNotFound)) } @@ -258,7 +224,9 @@ final class CloudKitAccountDelegate: AccountDelegate { func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result) -> Void) { let editedName = name.isEmpty ? nil : name + refreshProgress.addToNumberOfTasksAndRemaining(1) accountZone.renameWebFeed(feed, editedName: editedName) { result in + self.refreshProgress.completeTask() switch result { case .success: feed.editedName = name @@ -270,7 +238,9 @@ final class CloudKitAccountDelegate: AccountDelegate { } func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result) -> Void) { + refreshProgress.addToNumberOfTasksAndRemaining(1) accountZone.removeWebFeed(feed, from: container) { result in + self.refreshProgress.completeTask() switch result { case .success: container.removeWebFeed(feed) @@ -282,7 +252,9 @@ final class CloudKitAccountDelegate: AccountDelegate { } func moveWebFeed(for account: Account, with feed: WebFeed, from fromContainer: Container, to toContainer: Container, completion: @escaping (Result) -> Void) { + refreshProgress.addToNumberOfTasksAndRemaining(1) accountZone.moveWebFeed(feed, from: fromContainer, to: toContainer) { result in + self.refreshProgress.completeTask() switch result { case .success: fromContainer.removeWebFeed(feed) @@ -295,7 +267,9 @@ final class CloudKitAccountDelegate: AccountDelegate { } func addWebFeed(for account: Account, with feed: WebFeed, to container: Container, completion: @escaping (Result) -> Void) { + refreshProgress.addToNumberOfTasksAndRemaining(1) accountZone.addWebFeed(feed, to: container) { result in + self.refreshProgress.completeTask() switch result { case .success: container.addWebFeed(feed) @@ -307,7 +281,9 @@ final class CloudKitAccountDelegate: AccountDelegate { } func restoreWebFeed(for account: Account, feed: WebFeed, container: Container, completion: @escaping (Result) -> Void) { + refreshProgress.addToNumberOfTasksAndRemaining(1) accountZone.createWebFeed(url: feed.url, editedName: feed.editedName, container: container) { result in + self.refreshProgress.completeTask() switch result { case .success(let externalID): feed.externalID = externalID @@ -320,7 +296,9 @@ final class CloudKitAccountDelegate: AccountDelegate { } 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) { @@ -336,7 +314,9 @@ final class CloudKitAccountDelegate: AccountDelegate { } 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 @@ -348,7 +328,9 @@ final class CloudKitAccountDelegate: AccountDelegate { } func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) { + refreshProgress.addToNumberOfTasksAndRemaining(1) accountZone.removeFolder(folder) { result in + self.refreshProgress.completeTask() switch result { case .success: account.removeFolder(folder) @@ -365,19 +347,24 @@ final class CloudKitAccountDelegate: AccountDelegate { return } + let feedsToRestore = folder.topLevelWebFeeds + 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 folder.topLevelWebFeeds { + for feed in feedsToRestore { folder.topLevelWebFeeds.remove(feed) group.enter() self.restoreWebFeed(for: account, feed: feed, container: folder) { result in + self.refreshProgress.completeTask() group.leave() switch result { case .success: @@ -424,7 +411,7 @@ final class CloudKitAccountDelegate: AccountDelegate { switch result { case .success(let externalID): account.externalID = externalID - self.refreshAll(for: account) { result in + self.refreshAll(for: account, downloadFeeds: false) { result in if case .failure(let error) = result { os_log(.error, log: self.log, "Error while doing intial refresh: %@", error.localizedDescription) } @@ -466,3 +453,64 @@ final class CloudKitAccountDelegate: AccountDelegate { database.resume() } } + +private extension CloudKitAccountDelegate { + + func refreshAll(for account: Account, downloadFeeds: Bool, completion: @escaping (Result) -> Void) { + + let intialWebFeedsCount = downloadFeeds ? account.flattenedWebFeeds().count : 0 + refreshProgress.addToNumberOfTasksAndRemaining(3 + intialWebFeedsCount) + + BatchUpdate.shared.start() + accountZone.fetchChangesInZone() { result in + BatchUpdate.shared.end() + switch result { + case .success: + + let webFeeds = account.flattenedWebFeeds() + if downloadFeeds { + self.refreshProgress.addToNumberOfTasksAndRemaining(webFeeds.count - intialWebFeedsCount) + } + + self.refreshProgress.completeTask() + self.sendArticleStatus(for: account) { result in + switch result { + case .success: + + self.refreshProgress.completeTask() + self.refreshArticleStatus(for: account) { result in + switch result { + case .success: + + self.refreshProgress.completeTask() + + guard downloadFeeds else { + completion(.success(())) + return + } + + self.refresher.refreshFeeds(webFeeds, feedCompletionBlock: { _ in self.refreshProgress.completeTask() }) { + account.metadata.lastArticleFetchEndTime = Date() + completion(.success(())) + } + + case .failure(let error): + completion(.failure(error)) + } + } + + case .failure(let error): + completion(.failure(error)) + } + + } + + case .failure(let error): + self.refreshProgress.clear() + BatchUpdate.shared.end() + completion(.failure(error)) + } + } + } + +} diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index dc996baed..30611af51 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -22,7 +22,6 @@ final class CloudKitAccountZone: CloudKitZone { weak var container: CKContainer? weak var database: CKDatabase? - weak var refreshProgress: DownloadProgress? var delegate: CloudKitZoneDelegate? struct CloudKitWebFeed { diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift index b00561ad5..6ab4004fa 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -162,7 +162,6 @@ private extension CloudKitAcountZoneDelegate { return webFeed } - func addUnclaimedWebFeed(url: URL, editedName: String?, webFeedExternalID: String, containerExternalID: String) { if var unclaimedWebFeeds = self.unclaimedWebFeeds[containerExternalID] { unclaimedWebFeeds.append(UnclaimedWebFeed(url: url, editedName: editedName, webFeedExternalID: webFeedExternalID)) diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift index 36860f1e7..af34f8f6f 100644 --- a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift @@ -22,7 +22,6 @@ final class CloudKitArticlesZone: CloudKitZone { weak var container: CKContainer? weak var database: CKDatabase? - weak var refreshProgress: DownloadProgress? = nil var delegate: CloudKitZoneDelegate? = nil struct CloudKitArticleStatus { diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 9cc28defa..15b91baf4 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -32,7 +32,6 @@ protocol CloudKitZone: class { var container: CKContainer? { get } var database: CKDatabase? { get } - var refreshProgress: DownloadProgress? { get set } var delegate: CloudKitZoneDelegate? { get set } } @@ -105,13 +104,10 @@ extension CloudKitZone { return } - refreshProgress?.addToNumberOfTasksAndRemaining(1) - database.perform(query, inZoneWith: Self.zoneID) { [weak self] records, error in switch CloudKitZoneResult.resolve(error) { case .success: DispatchQueue.main.async { - self?.refreshProgress?.completeTask() if let records = records { completion(.success(records)) } else { @@ -124,7 +120,6 @@ extension CloudKitZone { } default: DispatchQueue.main.async { - self?.refreshProgress?.completeTask() completion(.failure(error!)) } } @@ -139,12 +134,10 @@ extension CloudKitZone { let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID) - refreshProgress?.addToNumberOfTasksAndRemaining(1) database?.fetch(withRecordID: recordID) { [weak self] record, error in switch CloudKitZoneResult.resolve(error) { case .success: DispatchQueue.main.async { - self?.refreshProgress?.completeTask() if let record = record { completion(.success(record)) } else { @@ -157,7 +150,6 @@ extension CloudKitZone { } default: DispatchQueue.main.async { - self?.refreshProgress?.completeTask() completion(.failure(error!)) } } @@ -201,7 +193,6 @@ extension CloudKitZone { switch CloudKitZoneResult.resolve(error) { case .success: DispatchQueue.main.async { - self.refreshProgress?.completeTask() completion(.success(())) } case .zoneNotFound: @@ -211,14 +202,12 @@ extension CloudKitZone { self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion) case .failure(let error): DispatchQueue.main.async { - self.refreshProgress?.completeTask() completion(.failure(error)) } } } case .userDeletedZone: DispatchQueue.main.async { - self.refreshProgress?.completeTask() completion(.failure(CloudKitZoneError.userDeletedZone)) } case .retry(let timeToWait): @@ -253,13 +242,11 @@ extension CloudKitZone { default: DispatchQueue.main.async { - self.refreshProgress?.completeTask() completion(.failure(error!)) } } } - refreshProgress?.addToNumberOfTasksAndRemaining(1) database?.add(op) } @@ -324,7 +311,6 @@ extension CloudKitZone { switch CloudKitZoneResult.resolve(error) { case .success: DispatchQueue.main.async { - self.refreshProgress?.completeTask() self.delegate?.cloudKitDidModify(changed: changedRecords, deleted: deletedRecordKeys, completion: completion) } case .zoneNotFound: @@ -334,14 +320,12 @@ extension CloudKitZone { self.fetchChangesInZone(completion: completion) case .failure(let error): DispatchQueue.main.async { - self.refreshProgress?.completeTask() completion(.failure(error)) } } } case .userDeletedZone: DispatchQueue.main.async { - self.refreshProgress?.completeTask() completion(.failure(CloudKitZoneError.userDeletedZone)) } case .retry(let timeToWait): @@ -355,14 +339,12 @@ extension CloudKitZone { } default: DispatchQueue.main.async { - self.refreshProgress?.completeTask() completion(.failure(error!)) } } } - refreshProgress?.addToNumberOfTasksAndRemaining(1) database?.add(op) } diff --git a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift index 3483d758e..a1751ff16 100644 --- a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift +++ b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift @@ -18,6 +18,8 @@ public enum LocalAccountDelegateError: String, Error { final class LocalAccountDelegate: AccountDelegate { + private let refresher = LocalAccountRefresher() + let behaviors: AccountBehaviors = [] let isOPMLImportInProgress = false @@ -25,18 +27,16 @@ final class LocalAccountDelegate: AccountDelegate { var credentials: Credentials? var accountMetadata: AccountMetadata? - private let refresher = LocalAccountRefresher() - - var refreshProgress: DownloadProgress { - return refresher.progress - } + let refreshProgress = DownloadProgress(numberOfTasks: 0) func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { completion() } func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { - refresher.refreshFeeds(account.flattenedWebFeeds()) { + let webFeeds = account.flattenedWebFeeds() + refreshProgress.addToNumberOfTasksAndRemaining(webFeeds.count) + refresher.refreshFeeds(webFeeds, feedCompletionBlock: { _ in self.refreshProgress.completeTask() }) { account.metadata.lastArticleFetchEndTime = Date() completion(.success(())) } diff --git a/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift b/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift index 870113d3d..7d13f058b 100644 --- a/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift +++ b/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift @@ -14,6 +14,7 @@ import Articles final class LocalAccountRefresher { + private var feedCompletionBlock: ((WebFeed) -> Void)? private var completion: (() -> Void)? private var isSuspended = false @@ -21,11 +22,8 @@ final class LocalAccountRefresher { return DownloadSession(delegate: self) }() - var progress: DownloadProgress { - return downloadSession.progress - } - - public func refreshFeeds(_ feeds: Set, completion: @escaping () -> Void) { + public func refreshFeeds(_ feeds: Set, feedCompletionBlock: @escaping (WebFeed) -> Void, completion: @escaping () -> Void) { + self.feedCompletionBlock = feedCompletionBlock self.completion = completion downloadSession.downloadObjects(feeds as NSSet) } @@ -62,28 +60,37 @@ extension LocalAccountRefresher: DownloadSessionDelegate { } func downloadSession(_ downloadSession: DownloadSession, downloadDidCompleteForRepresentedObject representedObject: AnyObject, response: URLResponse?, data: Data, error: NSError?, completion: @escaping () -> Void) { - guard let feed = representedObject as? WebFeed, !data.isEmpty, !isSuspended else { + let feed = representedObject as! WebFeed + + guard !data.isEmpty, !isSuspended else { completion() + feedCompletionBlock?(feed) return } if let error = error { print("Error downloading \(feed.url) - \(error)") completion() + feedCompletionBlock?(feed) return } let dataHash = data.md5String if dataHash == feed.contentHash { completion() + feedCompletionBlock?(feed) return } let parserData = ParserData(url: feed.url, data: data) FeedParser.parse(parserData) { (parsedFeed, error) in + guard let account = feed.account, let parsedFeed = parsedFeed, error == nil else { + completion() + self.feedCompletionBlock?(feed) return } + account.update(feed, with: parsedFeed) { error in if error == nil { if let httpResponse = response as? HTTPURLResponse { @@ -93,7 +100,9 @@ extension LocalAccountRefresher: DownloadSessionDelegate { feed.contentHash = dataHash } completion() + self.feedCompletionBlock?(feed) } + } } @@ -122,6 +131,8 @@ extension LocalAccountRefresher: DownloadSessionDelegate { } func downloadSession(_ downloadSession: DownloadSession, didReceiveNotModifiedResponse: URLResponse, representedObject: AnyObject) { + let feed = representedObject as! WebFeed + feedCompletionBlock?(feed) } func downloadSessionDidCompleteDownloadObjects(_ downloadSession: DownloadSession) { From ff0c23d335d5b2c08193476124ca4e35a1840777 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Thu, 2 Apr 2020 12:00:38 -0500 Subject: [PATCH 62/98] Upgrade to latest RSWeb --- submodules/RSWeb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/RSWeb b/submodules/RSWeb index aa4fda94f..88d634f5f 160000 --- a/submodules/RSWeb +++ b/submodules/RSWeb @@ -1 +1 @@ -Subproject commit aa4fda94f0e81809ac23de4040512378132f9e5d +Subproject commit 88d634f5fd42aab203b6e53c7b551a92b03ffc97 From ef6a79489ade1f315bea4403c92fcfe3f2a3a145 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Thu, 2 Apr 2020 12:25:23 -0500 Subject: [PATCH 63/98] Add missing completion block calls. --- .../Account/LocalAccount/LocalAccountRefresher.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift b/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift index 7d13f058b..1823e0bb1 100644 --- a/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift +++ b/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift @@ -116,18 +116,26 @@ extension LocalAccountRefresher: DownloadSessionDelegate { } if data.isDefinitelyNotFeed() { + feedCompletionBlock?(feed) return false } if data.count > 4096 { let parserData = ParserData(url: feed.url, data: data) - return FeedParser.mightBeAbleToParseBasedOnPartialData(parserData) + if FeedParser.mightBeAbleToParseBasedOnPartialData(parserData) { + return true + } else { + feedCompletionBlock?(feed) + return false + } } return true } func downloadSession(_ downloadSession: DownloadSession, didReceiveUnexpectedResponse response: URLResponse, representedObject: AnyObject) { + let feed = representedObject as! WebFeed + feedCompletionBlock?(feed) } func downloadSession(_ downloadSession: DownloadSession, didReceiveNotModifiedResponse: URLResponse, representedObject: AnyObject) { From f97194b9be29b21b5ea6d26eb586db6020aad49e Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Thu, 2 Apr 2020 14:13:57 -0500 Subject: [PATCH 64/98] Removed long running operations support as I don't think we need it. --- .../CloudKit/CloudKitAccountDelegate.swift | 1 - Frameworks/Account/CloudKit/CloudKitZone.swift | 18 ------------------ 2 files changed, 19 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 02636e56d..6b3e45e2d 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -423,7 +423,6 @@ final class CloudKitAccountDelegate: AccountDelegate { } zones.forEach { zone in - zone.resumeLongLivedOperationIfPossible() zone.subscribe() } } diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 15b91baf4..02fe62f17 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -46,20 +46,6 @@ extension CloudKitZone { return CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID) } - func resumeLongLivedOperationIfPossible() { - guard let container = container else { return } - container.fetchAllLongLivedOperationIDs { (opIDs, error) in - guard let opIDs = opIDs else { return } - for opID in opIDs { - container.fetchLongLivedOperation(withID: opID, completionHandler: { (ope, error) in - if let modifyOp = ope as? CKModifyRecordsOperation { - container.add(modifyOp) - } - }) - } - } - } - func subscribe() { let subscription = CKRecordZoneSubscription(zoneID: Self.zoneID) @@ -173,10 +159,6 @@ extension CloudKitZone { func modify(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result) -> Void) { let op = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete) - let config = CKOperation.Configuration() - config.isLongLived = true - op.configuration = config - // We use .changedKeys savePolicy to do unlocked changes here cause my app is contentious and off-line first // Apple suggests using .ifServerRecordUnchanged save policy // For more, see Advanced CloudKit(https://developer.apple.com/videos/play/wwdc2014/231/) From d6b094b37ef3375345b29514a194aff48895dc24 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Thu, 2 Apr 2020 18:06:47 -0500 Subject: [PATCH 65/98] Save starred articles to iCloud. --- .../CloudKit/CloudKitAccountDelegate.swift | 35 +++++-- .../CloudKit/CloudKitArticlesZone.swift | 92 ++++++++++++++++++- 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 6b3e45e2d..c349a48d3 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -86,17 +86,34 @@ final class CloudKitAccountDelegate: AccountDelegate { return } - self.articlesZone.sendArticleStatus(syncStatuses) { result in - switch result { - case .success: - self.database.deleteSelectedForProcessing(syncStatuses.map({ $0.articleID }) ) - os_log(.debug, log: self.log, "Done sending article statuses.") - completion(.success(())) - case .failure(let error): - self.database.resetSelectedForProcessing(syncStatuses.map({ $0.articleID }) ) - completion(.failure(error)) + let starredArticleIDs = syncStatuses.filter({ $0.key == .starred && $0.flag == true }).map({ $0.articleID }) + account.fetchArticlesAsync(.articleIDs(Set(starredArticleIDs))) { result in + + func processWithArticles(_ starredArticles: Set
) { + + self.articlesZone.sendArticleStatus(syncStatuses, starredArticles: starredArticles) { result in + switch result { + case .success: + self.database.deleteSelectedForProcessing(syncStatuses.map({ $0.articleID }) ) + os_log(.debug, log: self.log, "Done sending article statuses.") + completion(.success(())) + case .failure(let error): + self.database.resetSelectedForProcessing(syncStatuses.map({ $0.articleID }) ) + completion(.failure(error)) + } + } + } + + switch result { + case .success(let starredArticles): + processWithArticles(starredArticles) + case .failure(let databaseError): + completion(.failure(databaseError)) + } + } + } switch result { diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift index af34f8f6f..a50cc4911 100644 --- a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift @@ -10,6 +10,7 @@ import Foundation import os.log import RSWeb import CloudKit +import Articles import SyncDatabase final class CloudKitArticlesZone: CloudKitZone { @@ -24,6 +25,37 @@ final class CloudKitArticlesZone: CloudKitZone { weak var database: CKDatabase? var delegate: CloudKitZoneDelegate? = nil + struct CloudKitArticle { + static let recordType = "Article" + struct Fields { + static let articleStatus = "articleStatus" + static let webFeedID = "webFeedID" + static let uniqueID = "uniqueID" + static let title = "title" + static let contentHTML = "contentHTML" + static let contentText = "contentText" + static let url = "url" + static let externalURL = "externalURL" + static let summary = "summary" + static let imageURL = "imageURL" + static let datePublished = "datePublished" + static let dateModified = "dateModified" + static let authors = "authors" + } + } + + struct CloudKitAuthor { + static let recordType = "Author" + struct Fields { + static let article = "article" + static let authorID = "authorID" + static let name = "name" + static let url = "url" + static let avatarURL = "avatarURL" + static let emailAddress = "emailAddress" + } + } + struct CloudKitArticleStatus { static let recordType = "ArticleStatus" struct Fields { @@ -38,7 +70,17 @@ final class CloudKitArticlesZone: CloudKitZone { self.database = container.privateCloudDatabase } - func sendArticleStatus(_ syncStatuses: [SyncStatus], completion: @escaping ((Result) -> Void)) { + func sendArticleStatus(_ syncStatuses: [SyncStatus], starredArticles: Set
, completion: @escaping ((Result) -> Void)) { + var records = makeStatusRecords(syncStatuses) + records.append(contentsOf: makeArticleRecords(starredArticles)) + modify(recordsToSave: records, recordIDsToDelete: [], completion: completion) + } + +} + +private extension CloudKitArticlesZone { + + func makeStatusRecords(_ syncStatuses: [SyncStatus]) -> [CKRecord] { var records = [String: CKRecord]() for status in syncStatuses { @@ -60,7 +102,53 @@ final class CloudKitArticlesZone: CloudKitZone { } } - modify(recordsToSave: Array(records.values), recordIDsToDelete: [], completion: completion) + return Array(records.values) + } + + func makeArticleRecords(_ articles: Set
) -> [CKRecord] { + var records = [CKRecord]() + + for article in articles { + + let record = CKRecord(recordType: CloudKitArticle.recordType, recordID: generateRecordID()) + + let articleStatusRecordID = CKRecord.ID(recordName: article.articleID, zoneID: Self.zoneID) + record[CloudKitArticle.Fields.articleStatus] = CKRecord.Reference(recordID: articleStatusRecordID, action: .deleteSelf) + record[CloudKitArticle.Fields.webFeedID] = article.webFeedID + record[CloudKitArticle.Fields.uniqueID] = article.uniqueID + record[CloudKitArticle.Fields.title] = article.title + record[CloudKitArticle.Fields.contentHTML] = article.contentHTML + record[CloudKitArticle.Fields.contentText] = article.contentText + record[CloudKitArticle.Fields.url] = article.url + record[CloudKitArticle.Fields.externalURL] = article.externalURL + record[CloudKitArticle.Fields.summary] = article.summary + record[CloudKitArticle.Fields.imageURL] = article.imageURL + record[CloudKitArticle.Fields.datePublished] = article.datePublished + record[CloudKitArticle.Fields.dateModified] = article.dateModified + + records.append(record) + + if let authors = article.authors { + for author in authors { + records.append(makeAuthorRecord(record, author)) + } + } + } + + return records } + func makeAuthorRecord(_ articleRecord: CKRecord, _ author: Author) -> CKRecord { + let record = CKRecord(recordType: CloudKitAuthor.recordType, recordID: generateRecordID()) + + record[CloudKitAuthor.Fields.article] = CKRecord.Reference(record: articleRecord, action: .deleteSelf) + record[CloudKitAuthor.Fields.authorID] = author.authorID + record[CloudKitAuthor.Fields.name] = author.name + record[CloudKitAuthor.Fields.url] = author.url + record[CloudKitAuthor.Fields.avatarURL] = author.avatarURL + record[CloudKitAuthor.Fields.emailAddress] = author.emailAddress + + return record + } + } From f143248e08774af4730bd1d7a5b85c5b6adfc382 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Fri, 3 Apr 2020 11:25:01 -0500 Subject: [PATCH 66/98] Enable passing starred articles between devices. --- Frameworks/Account/Account.swift | 12 +- .../CloudKit/CloudKitAccountDelegate.swift | 2 +- .../CloudKit/CloudKitArticlesZone.swift | 177 ++++++++++++++---- .../CloudKitArticlesZoneDelegate.swift | 24 ++- .../Account/CloudKit/CloudKitZone.swift | 42 ++++- 5 files changed, 220 insertions(+), 37 deletions(-) diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 93c78b199..288943296 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -722,8 +722,16 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, completion(nil) return } - - database.update(with: parsedItems, webFeedID: webFeed.webFeedID) { updateArticlesResult in + + update(webFeed.webFeedID, with: parsedItems, completion: completion) + } + + func update(_ webFeedID: String, with parsedItems: Set, completion: @escaping DatabaseCompletionBlock) { + // Used only by an On My Mac or iCloud account. + precondition(Thread.isMainThread) + precondition(type == .onMyMac || type == .cloudKit) + + database.update(with: parsedItems, webFeedID: webFeedID) { updateArticlesResult in switch updateArticlesResult { case .success(let newAndUpdatedArticles): self.sendNotificationAbout(newAndUpdatedArticles) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index c349a48d3..86aaafdff 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -421,7 +421,7 @@ final class CloudKitAccountDelegate: AccountDelegate { func accountDidInitialize(_ account: Account) { accountZone.delegate = CloudKitAcountZoneDelegate(account: account, refreshProgress: refreshProgress) - articlesZone.delegate = CloudKitArticlesZoneDelegate(account: account, database: database) + articlesZone.delegate = CloudKitArticlesZoneDelegate(account: account, database: database, articlesZone: articlesZone) if account.externalID == nil { accountZone.findOrCreateAccount() { result in diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift index a50cc4911..350807d7b 100644 --- a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift @@ -8,6 +8,7 @@ import Foundation import os.log +import RSParser import RSWeb import CloudKit import Articles @@ -72,8 +73,56 @@ final class CloudKitArticlesZone: CloudKitZone { func sendArticleStatus(_ syncStatuses: [SyncStatus], starredArticles: Set
, completion: @escaping ((Result) -> Void)) { var records = makeStatusRecords(syncStatuses) - records.append(contentsOf: makeArticleRecords(starredArticles)) - modify(recordsToSave: records, recordIDsToDelete: [], completion: completion) + makeArticleRecordsIfNecessary(starredArticles) { result in + switch result { + case .success(let articleRecords): + records.append(contentsOf: articleRecords) + self.modify(recordsToSave: records, recordIDsToDelete: [], completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func fetchArticle(articleID: String, completion: @escaping ((Result<(String, ParsedItem), Error>) -> Void)) { + + let statusRecordID = CKRecord.ID(recordName: articleID, zoneID: Self.zoneID) + let statusRecordRef = CKRecord.Reference(recordID: statusRecordID, action: .deleteSelf) + let predicate = NSPredicate(format: "articleStatus = %@", statusRecordRef) + let ckQuery = CKQuery(recordType: CloudKitArticle.recordType, predicate: predicate) + + query(ckQuery) { result in + + switch result { + case .success(let articleRecords): + if articleRecords.count == 1 { + let articleRecord = articleRecords[0] + + let articleRef = CKRecord.Reference(record: articleRecord, action: .deleteSelf) + let predicate = NSPredicate(format: "article = %@", articleRef) + let ckQuery = CKQuery(recordType: CloudKitAuthor.recordType, predicate: predicate) + + self.query(ckQuery) { result in + switch result { + case .success(let authorRecords): + if let webFeedID = articleRecord[CloudKitArticle.Fields.webFeedID] as? String, let parsedItem = self.makeParsedItem(articleRecord, authorRecords) { + completion(.success((webFeedID, parsedItem))) + } else { + completion(.failure(CloudKitZoneError.unknown)) + } + case .failure(let error): + completion(.failure(error)) + } + } + + } else { + completion(.failure(CloudKitZoneError.unknown)) + } + case .failure(let error): + completion(.failure(error)) + } + } + } } @@ -105,50 +154,114 @@ private extension CloudKitArticlesZone { return Array(records.values) } - func makeArticleRecords(_ articles: Set
) -> [CKRecord] { + func makeArticleRecordsIfNecessary(_ articles: Set
, completion: @escaping ((Result<[CKRecord], Error>) -> Void)) { + let group = DispatchGroup() + var errorOccurred = false var records = [CKRecord]() for article in articles { - let record = CKRecord(recordType: CloudKitArticle.recordType, recordID: generateRecordID()) + let statusRecordID = CKRecord.ID(recordName: article.articleID, zoneID: Self.zoneID) + let statusRecordRef = CKRecord.Reference(recordID: statusRecordID, action: .deleteSelf) + let predicate = NSPredicate(format: "articleStatus = %@", statusRecordRef) + let ckQuery = CKQuery(recordType: CloudKitArticle.recordType, predicate: predicate) - let articleStatusRecordID = CKRecord.ID(recordName: article.articleID, zoneID: Self.zoneID) - record[CloudKitArticle.Fields.articleStatus] = CKRecord.Reference(recordID: articleStatusRecordID, action: .deleteSelf) - record[CloudKitArticle.Fields.webFeedID] = article.webFeedID - record[CloudKitArticle.Fields.uniqueID] = article.uniqueID - record[CloudKitArticle.Fields.title] = article.title - record[CloudKitArticle.Fields.contentHTML] = article.contentHTML - record[CloudKitArticle.Fields.contentText] = article.contentText - record[CloudKitArticle.Fields.url] = article.url - record[CloudKitArticle.Fields.externalURL] = article.externalURL - record[CloudKitArticle.Fields.summary] = article.summary - record[CloudKitArticle.Fields.imageURL] = article.imageURL - record[CloudKitArticle.Fields.datePublished] = article.datePublished - record[CloudKitArticle.Fields.dateModified] = article.dateModified - - records.append(record) - - if let authors = article.authors { - for author in authors { - records.append(makeAuthorRecord(record, author)) + group.enter() + exists(ckQuery) { result in + switch result { + case .success(let recordFound): + if !recordFound { + records.append(contentsOf: self.makeArticleRecords(article)) + } + case .failure(let error): + errorOccurred = true + os_log(.error, log: self.log, "Error occurred while checking for existing articles: %@", error.localizedDescription) } + group.leave() + } + + } + + group.notify(queue: DispatchQueue.main) { + if errorOccurred { + completion(.failure(CloudKitZoneError.unknown)) + } else { + completion(.success(records)) + } + } + } + + func makeArticleRecords(_ article: Article) -> [CKRecord] { + var records = [CKRecord]() + + let articleRecord = CKRecord(recordType: CloudKitArticle.recordType, recordID: generateRecordID()) + + let articleStatusRecordID = CKRecord.ID(recordName: article.articleID, zoneID: Self.zoneID) + articleRecord[CloudKitArticle.Fields.articleStatus] = CKRecord.Reference(recordID: articleStatusRecordID, action: .deleteSelf) + articleRecord[CloudKitArticle.Fields.webFeedID] = article.webFeedID + articleRecord[CloudKitArticle.Fields.uniqueID] = article.uniqueID + articleRecord[CloudKitArticle.Fields.title] = article.title + articleRecord[CloudKitArticle.Fields.contentHTML] = article.contentHTML + articleRecord[CloudKitArticle.Fields.contentText] = article.contentText + articleRecord[CloudKitArticle.Fields.url] = article.url + articleRecord[CloudKitArticle.Fields.externalURL] = article.externalURL + articleRecord[CloudKitArticle.Fields.summary] = article.summary + articleRecord[CloudKitArticle.Fields.imageURL] = article.imageURL + articleRecord[CloudKitArticle.Fields.datePublished] = article.datePublished + articleRecord[CloudKitArticle.Fields.dateModified] = article.dateModified + + records.append(articleRecord) + + if let authors = article.authors { + for author in authors { + let authorRecord = CKRecord(recordType: CloudKitAuthor.recordType, recordID: generateRecordID()) + authorRecord[CloudKitAuthor.Fields.article] = CKRecord.Reference(record: articleRecord, action: .deleteSelf) + authorRecord[CloudKitAuthor.Fields.authorID] = author.authorID + authorRecord[CloudKitAuthor.Fields.name] = author.name + authorRecord[CloudKitAuthor.Fields.url] = author.url + authorRecord[CloudKitAuthor.Fields.avatarURL] = author.avatarURL + authorRecord[CloudKitAuthor.Fields.emailAddress] = author.emailAddress + records.append(authorRecord) } } return records } - func makeAuthorRecord(_ articleRecord: CKRecord, _ author: Author) -> CKRecord { - let record = CKRecord(recordType: CloudKitAuthor.recordType, recordID: generateRecordID()) + func makeParsedItem(_ articleRecord: CKRecord, _ authorRecords: [CKRecord]) -> ParsedItem? { + var parsedAuthors = Set() - record[CloudKitAuthor.Fields.article] = CKRecord.Reference(record: articleRecord, action: .deleteSelf) - record[CloudKitAuthor.Fields.authorID] = author.authorID - record[CloudKitAuthor.Fields.name] = author.name - record[CloudKitAuthor.Fields.url] = author.url - record[CloudKitAuthor.Fields.avatarURL] = author.avatarURL - record[CloudKitAuthor.Fields.emailAddress] = author.emailAddress + for authorRecord in authorRecords { + let parsedAuthor = ParsedAuthor(name: authorRecord[CloudKitAuthor.Fields.name] as? String, + url: authorRecord[CloudKitAuthor.Fields.url] as? String, + avatarURL: authorRecord[CloudKitAuthor.Fields.avatarURL] as? String, + emailAddress: authorRecord[CloudKitAuthor.Fields.emailAddress] as? String) + parsedAuthors.insert(parsedAuthor) + } - return record + guard let uniqueID = articleRecord[CloudKitArticle.Fields.uniqueID] as? String, + let feedURL = articleRecord[CloudKitArticle.Fields.webFeedID] as? String else { + return nil + } + + let parsedItem = ParsedItem(syncServiceID: nil, + uniqueID: uniqueID, + feedURL: feedURL, + url: articleRecord[CloudKitArticle.Fields.url] as? String, + externalURL: articleRecord[CloudKitArticle.Fields.externalURL] as? String, + title: articleRecord[CloudKitArticle.Fields.title] as? String, + contentHTML: articleRecord[CloudKitArticle.Fields.contentHTML] as? String, + contentText: articleRecord[CloudKitArticle.Fields.contentText] as? String, + summary: articleRecord[CloudKitArticle.Fields.summary] as? String, + imageURL: articleRecord[CloudKitArticle.Fields.imageURL] as? String, + bannerImageURL: articleRecord[CloudKitArticle.Fields.imageURL] as? String, + datePublished: articleRecord[CloudKitArticle.Fields.datePublished] as? Date, + dateModified: articleRecord[CloudKitArticle.Fields.dateModified] as? Date, + authors: parsedAuthors, + tags: nil, + attachments: nil) + + return parsedItem } } diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift index 31bed3d58..528916b62 100644 --- a/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift @@ -17,10 +17,12 @@ class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate { weak var account: Account? var database: SyncDatabase + weak var articlesZone: CloudKitArticlesZone? - init(account: Account, database: SyncDatabase) { + init(account: Account, database: SyncDatabase, articlesZone: CloudKitArticlesZone) { self.account = account self.database = database + self.articlesZone = articlesZone } func cloudKitDidChange(record: CKRecord) { @@ -95,6 +97,26 @@ private extension CloudKitArticlesZoneDelegate { account?.markAsStarred(updateableStarredArticleIDs) { _ in group.leave() } + + for updateableStarredArticleID in updateableStarredArticleIDs { + + group.enter() + articlesZone?.fetchArticle(articleID: updateableStarredArticleID) { result in + switch result { + case .success(let (webFeedID, parsedItem)): + self.account?.update(webFeedID, with: Set([parsedItem])) { databaseError in + group.leave() + if let databaseError = databaseError { + os_log(.error, log: self.log, "Error occurred while storing starred items: %@", databaseError.localizedDescription) + } + } + case .failure(let error): + group.leave() + os_log(.error, log: self.log, "Error occurred while retrieving starred items: %@", error.localizedDescription) + } + + } + } group.notify(queue: DispatchQueue.main) { completion(.success(())) diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 02fe62f17..1848c474d 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -38,15 +38,18 @@ protocol CloudKitZone: class { extension CloudKitZone { + /// Reset the change token used to determine what point in time we are doing changes fetches func resetChangeToken() { changeToken = nil } + /// Generates a new CKRecord.ID using a UUID for the record's name func generateRecordID() -> CKRecord.ID { return CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID) } - func subscribe() { + /// Subscribe to all changes that happen in this zone + func subscribe() { let subscription = CKRecordZoneSubscription(zoneID: Self.zoneID) @@ -69,6 +72,7 @@ extension CloudKitZone { } + /// Fetch and process any changes in the zone since the last time we checked when we get a remote notification. func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { let note = CKRecordZoneNotification(fromRemoteNotificationDictionary: userInfo) guard note?.recordZoneID?.zoneName == Self.zoneID.zoneName else { @@ -84,6 +88,37 @@ extension CloudKitZone { } } + /// Checks to see if the record described in the query exists by retrieving only the testField parameter field. + func exists(_ query: CKQuery, completion: @escaping (Result) -> Void) { + var recordFound = false + let op = CKQueryOperation(query: query) + op.desiredKeys = ["creationDate"] + + op.recordFetchedBlock = { record in + recordFound = true + } + + op.queryCompletionBlock = { [weak self] (_, error) in + switch CloudKitZoneResult.resolve(error) { + case .success: + DispatchQueue.main.async { + completion(.success(recordFound)) + } + case .retry(let timeToWait): + self?.retryIfPossible(after: timeToWait) { + self?.exists(query, completion: completion) + } + default: + DispatchQueue.main.async { + completion(.failure(error!)) + } + } + } + + database?.add(op) + } + + /// Issue a CKQuery and return the resulting CKRecords.s func query(_ query: CKQuery, completion: @escaping (Result<[CKRecord], Error>) -> Void) { guard let database = database else { completion(.failure(CloudKitZoneError.unknown)) @@ -112,6 +147,7 @@ extension CloudKitZone { } } + /// Fetch a CKRecord by using its externalID func fetch(externalID: String?, completion: @escaping (Result) -> Void) { guard let externalID = externalID else { completion(.failure(CloudKitZoneError.invalidParameter)) @@ -142,10 +178,12 @@ extension CloudKitZone { } } + /// Save the CKRecord func save(_ record: CKRecord, completion: @escaping (Result) -> Void) { modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion) } + /// Delete a CKRecord using its externalID func delete(externalID: String?, completion: @escaping (Result) -> Void) { guard let externalID = externalID else { completion(.failure(CloudKitZoneError.invalidParameter)) @@ -156,6 +194,7 @@ extension CloudKitZone { modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion) } + /// Modify and delete the supplied CKRecords and CKRecord.IDs func modify(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result) -> Void) { let op = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete) @@ -232,6 +271,7 @@ extension CloudKitZone { database?.add(op) } + /// Fetch all the changes in the CKZone since the last time we checked func fetchChangesInZone(completion: @escaping (Result) -> Void) { var changedRecords = [CKRecord]() From 10a87ccfb68d3ecae647c4e1062506fe61d34e18 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Fri, 3 Apr 2020 13:26:08 -0500 Subject: [PATCH 67/98] Refactored starred article passing to make it more reliable --- .../CloudKit/CloudKitArticlesZone.swift | 113 +++--------------- .../CloudKitArticlesZoneDelegate.swift | 76 ++++++++---- submodules/RSParser | 2 +- 3 files changed, 70 insertions(+), 121 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift index 350807d7b..6efe26fd0 100644 --- a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift @@ -41,19 +41,7 @@ final class CloudKitArticlesZone: CloudKitZone { static let imageURL = "imageURL" static let datePublished = "datePublished" static let dateModified = "dateModified" - static let authors = "authors" - } - } - - struct CloudKitAuthor { - static let recordType = "Author" - struct Fields { - static let article = "article" - static let authorID = "authorID" - static let name = "name" - static let url = "url" - static let avatarURL = "avatarURL" - static let emailAddress = "emailAddress" + static let parsedAuthors = "parsedAuthors" } } @@ -84,47 +72,6 @@ final class CloudKitArticlesZone: CloudKitZone { } } - func fetchArticle(articleID: String, completion: @escaping ((Result<(String, ParsedItem), Error>) -> Void)) { - - let statusRecordID = CKRecord.ID(recordName: articleID, zoneID: Self.zoneID) - let statusRecordRef = CKRecord.Reference(recordID: statusRecordID, action: .deleteSelf) - let predicate = NSPredicate(format: "articleStatus = %@", statusRecordRef) - let ckQuery = CKQuery(recordType: CloudKitArticle.recordType, predicate: predicate) - - query(ckQuery) { result in - - switch result { - case .success(let articleRecords): - if articleRecords.count == 1 { - let articleRecord = articleRecords[0] - - let articleRef = CKRecord.Reference(record: articleRecord, action: .deleteSelf) - let predicate = NSPredicate(format: "article = %@", articleRef) - let ckQuery = CKQuery(recordType: CloudKitAuthor.recordType, predicate: predicate) - - self.query(ckQuery) { result in - switch result { - case .success(let authorRecords): - if let webFeedID = articleRecord[CloudKitArticle.Fields.webFeedID] as? String, let parsedItem = self.makeParsedItem(articleRecord, authorRecords) { - completion(.success((webFeedID, parsedItem))) - } else { - completion(.failure(CloudKitZoneError.unknown)) - } - case .failure(let error): - completion(.failure(error)) - } - } - - } else { - completion(.failure(CloudKitZoneError.unknown)) - } - case .failure(let error): - completion(.failure(error)) - } - } - - } - } private extension CloudKitArticlesZone { @@ -210,58 +157,26 @@ private extension CloudKitArticlesZone { articleRecord[CloudKitArticle.Fields.datePublished] = article.datePublished articleRecord[CloudKitArticle.Fields.dateModified] = article.dateModified - records.append(articleRecord) + let encoder = JSONEncoder() + var parsedAuthors = [String]() if let authors = article.authors { for author in authors { - let authorRecord = CKRecord(recordType: CloudKitAuthor.recordType, recordID: generateRecordID()) - authorRecord[CloudKitAuthor.Fields.article] = CKRecord.Reference(record: articleRecord, action: .deleteSelf) - authorRecord[CloudKitAuthor.Fields.authorID] = author.authorID - authorRecord[CloudKitAuthor.Fields.name] = author.name - authorRecord[CloudKitAuthor.Fields.url] = author.url - authorRecord[CloudKitAuthor.Fields.avatarURL] = author.avatarURL - authorRecord[CloudKitAuthor.Fields.emailAddress] = author.emailAddress - records.append(authorRecord) + let parsedAuthor = ParsedAuthor(name: author.name, + url: author.url, + avatarURL: author.avatarURL, + emailAddress: author.emailAddress) + if let data = try? encoder.encode(parsedAuthor), let encodedParsedAuthor = String(data: data, encoding: .utf8) { + parsedAuthors.append(encodedParsedAuthor) + } } } + articleRecord[CloudKitArticle.Fields.parsedAuthors] = parsedAuthors + + records.append(articleRecord) return records } - - func makeParsedItem(_ articleRecord: CKRecord, _ authorRecords: [CKRecord]) -> ParsedItem? { - var parsedAuthors = Set() - - for authorRecord in authorRecords { - let parsedAuthor = ParsedAuthor(name: authorRecord[CloudKitAuthor.Fields.name] as? String, - url: authorRecord[CloudKitAuthor.Fields.url] as? String, - avatarURL: authorRecord[CloudKitAuthor.Fields.avatarURL] as? String, - emailAddress: authorRecord[CloudKitAuthor.Fields.emailAddress] as? String) - parsedAuthors.insert(parsedAuthor) - } - - guard let uniqueID = articleRecord[CloudKitArticle.Fields.uniqueID] as? String, - let feedURL = articleRecord[CloudKitArticle.Fields.webFeedID] as? String else { - return nil - } - - let parsedItem = ParsedItem(syncServiceID: nil, - uniqueID: uniqueID, - feedURL: feedURL, - url: articleRecord[CloudKitArticle.Fields.url] as? String, - externalURL: articleRecord[CloudKitArticle.Fields.externalURL] as? String, - title: articleRecord[CloudKitArticle.Fields.title] as? String, - contentHTML: articleRecord[CloudKitArticle.Fields.contentHTML] as? String, - contentText: articleRecord[CloudKitArticle.Fields.contentText] as? String, - summary: articleRecord[CloudKitArticle.Fields.summary] as? String, - imageURL: articleRecord[CloudKitArticle.Fields.imageURL] as? String, - bannerImageURL: articleRecord[CloudKitArticle.Fields.imageURL] as? String, - datePublished: articleRecord[CloudKitArticle.Fields.datePublished] as? Date, - dateModified: articleRecord[CloudKitArticle.Fields.dateModified] as? Date, - authors: parsedAuthors, - tags: nil, - attachments: nil) - - return parsedItem - } + } diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift index 528916b62..8cab47869 100644 --- a/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift @@ -8,6 +8,7 @@ import Foundation import os.log +import RSParser import CloudKit import SyncDatabase @@ -26,7 +27,7 @@ class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate { } func cloudKitDidChange(record: CKRecord) { - // Process everything in the batch method + } func cloudKitDidDelete(recordKey: CloudKitRecordKey) { @@ -65,12 +66,14 @@ class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate { private extension CloudKitArticlesZoneDelegate { func process(records: [CKRecord], pendingReadStatusArticleIDs: Set, pendingStarredStatusArticleIDs: Set, completion: @escaping (Result) -> Void) { - - let receivedUnreadArticleIDs = Set(records.filter( { $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.read] == "0" }).map({ $0.externalID })) - let receivedReadArticleIDs = Set(records.filter( { $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.read] == "1" }).map({ $0.externalID })) - let receivedUnstarredArticleIDs = Set(records.filter( { $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.starred] == "0" }).map({ $0.externalID })) - let receivedStarredArticleIDs = Set(records.filter( { $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.starred] == "1" }).map({ $0.externalID })) + let receivedUnreadArticleIDs = Set(records.filter({ $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.read] == "0" }).map({ $0.externalID })) + let receivedReadArticleIDs = Set(records.filter({ $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.read] == "1" }).map({ $0.externalID })) + let receivedUnstarredArticleIDs = Set(records.filter({ $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.starred] == "0" }).map({ $0.externalID })) + let receivedStarredArticleIDs = Set(records.filter({ $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.starred] == "1" }).map({ $0.externalID })) + + let receivedStarredArticles = records.filter({ $0.recordType == CloudKitArticlesZone.CloudKitArticle.recordType }) + let updateableUnreadArticleIDs = receivedUnreadArticleIDs.subtracting(pendingReadStatusArticleIDs) let updateableReadArticleIDs = receivedReadArticleIDs.subtracting(pendingReadStatusArticleIDs) let updateableUnstarredArticleIDs = receivedUnstarredArticleIDs.subtracting(pendingStarredStatusArticleIDs) @@ -98,23 +101,15 @@ private extension CloudKitArticlesZoneDelegate { group.leave() } - for updateableStarredArticleID in updateableStarredArticleIDs { - - group.enter() - articlesZone?.fetchArticle(articleID: updateableStarredArticleID) { result in - switch result { - case .success(let (webFeedID, parsedItem)): - self.account?.update(webFeedID, with: Set([parsedItem])) { databaseError in - group.leave() - if let databaseError = databaseError { - os_log(.error, log: self.log, "Error occurred while storing starred items: %@", databaseError.localizedDescription) - } - } - case .failure(let error): + for receivedStarredArticle in receivedStarredArticles { + if let parsedItem = makeParsedItem(receivedStarredArticle), let statusRef = receivedStarredArticle[CloudKitArticlesZone.CloudKitArticle.Fields.articleStatus] as? CKRecord.Reference { + group.enter() + self.account?.update(statusRef.recordID.externalID, with: Set([parsedItem])) { databaseError in group.leave() - os_log(.error, log: self.log, "Error occurred while retrieving starred items: %@", error.localizedDescription) + if let databaseError = databaseError { + os_log(.error, log: self.log, "Error occurred while storing starred items: %@", databaseError.localizedDescription) + } } - } } @@ -123,5 +118,44 @@ private extension CloudKitArticlesZoneDelegate { } } + + + func makeParsedItem(_ articleRecord: CKRecord) -> ParsedItem? { + var parsedAuthors = Set() + + let decoder = JSONDecoder() + + if let encodedParsedAuthors = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.parsedAuthors] as? [String] { + for encodedParsedAuthor in encodedParsedAuthors { + if let data = encodedParsedAuthor.data(using: .utf8), let parsedAuthor = try? decoder.decode(ParsedAuthor.self, from: data) { + parsedAuthors.insert(parsedAuthor) + } + } + } + + guard let uniqueID = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.uniqueID] as? String, + let feedURL = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.webFeedID] as? String else { + return nil + } + + let parsedItem = ParsedItem(syncServiceID: nil, + uniqueID: uniqueID, + feedURL: feedURL, + url: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.url] as? String, + externalURL: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.externalURL] as? String, + title: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.title] as? String, + contentHTML: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.contentHTML] as? String, + contentText: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.contentText] as? String, + summary: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.summary] as? String, + imageURL: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.imageURL] as? String, + bannerImageURL: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.imageURL] as? String, + datePublished: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.datePublished] as? Date, + dateModified: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.dateModified] as? Date, + authors: parsedAuthors, + tags: nil, + attachments: nil) + + return parsedItem + } } diff --git a/submodules/RSParser b/submodules/RSParser index 47ba87875..a977d8e84 160000 --- a/submodules/RSParser +++ b/submodules/RSParser @@ -1 +1 @@ -Subproject commit 47ba87875fbd026dccc2c4d4382a98cb4a1f1fbc +Subproject commit a977d8e84af8645fc8268ac843e8a79b3644b133 From f75e3e5ebf688138a42204a52f1495488fa8e86f Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Fri, 3 Apr 2020 13:42:59 -0500 Subject: [PATCH 68/98] Added new initialization parameter for Parsed Items. --- Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift | 1 + .../Account/FeedWrangler/FeedWranglerAccountDelegate.swift | 2 +- Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift | 2 +- Frameworks/Account/Feedly/Models/FeedlyEntryParser.swift | 1 + .../NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift | 2 +- Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift | 2 +- 6 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift index 8cab47869..53c30afc5 100644 --- a/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift @@ -144,6 +144,7 @@ private extension CloudKitArticlesZoneDelegate { url: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.url] as? String, externalURL: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.externalURL] as? String, title: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.title] as? String, + language: nil, contentHTML: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.contentHTML] as? String, contentText: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.contentText] as? String, summary: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.summary] as? String, diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index 47313096e..95910f410 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -515,7 +515,7 @@ private extension FeedWranglerAccountDelegate { let parsedItems = feedItems.map { (item: FeedWranglerFeedItem) -> ParsedItem in let itemID = String(item.feedItemID) // let authors = ... - let parsedItem = ParsedItem(syncServiceID: itemID, uniqueID: itemID, feedURL: String(item.feedID), url: nil, externalURL: item.url, title: item.title, contentHTML: item.body, contentText: nil, summary: nil, imageURL: nil, bannerImageURL: nil, datePublished: item.publishedDate, dateModified: item.updatedDate, authors: nil, tags: nil, attachments: nil) + let parsedItem = ParsedItem(syncServiceID: itemID, uniqueID: itemID, feedURL: String(item.feedID), url: nil, externalURL: item.url, title: item.title, language: nil, contentHTML: item.body, contentText: nil, summary: nil, imageURL: nil, bannerImageURL: nil, datePublished: item.publishedDate, dateModified: item.updatedDate, authors: nil, tags: nil, attachments: nil) return parsedItem } diff --git a/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift b/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift index 478454054..1e03828dc 100644 --- a/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift +++ b/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift @@ -1237,7 +1237,7 @@ private extension FeedbinAccountDelegate { let parsedItems: [ParsedItem] = entries.map { entry in let authors = Set([ParsedAuthor(name: entry.authorName, url: entry.jsonFeed?.jsonFeedAuthor?.url, avatarURL: entry.jsonFeed?.jsonFeedAuthor?.avatarURL, emailAddress: nil)]) - return ParsedItem(syncServiceID: String(entry.articleID), uniqueID: String(entry.articleID), feedURL: String(entry.feedID), url: entry.url, externalURL: nil, title: entry.title, contentHTML: entry.contentHTML, contentText: nil, summary: entry.summary, imageURL: nil, bannerImageURL: nil, datePublished: entry.parsedDatePublished, dateModified: nil, authors: authors, tags: nil, attachments: nil) + return ParsedItem(syncServiceID: String(entry.articleID), uniqueID: String(entry.articleID), feedURL: String(entry.feedID), url: entry.url, externalURL: nil, title: entry.title, language: nil, contentHTML: entry.contentHTML, contentText: nil, summary: entry.summary, imageURL: nil, bannerImageURL: nil, datePublished: entry.parsedDatePublished, dateModified: nil, authors: authors, tags: nil, attachments: nil) } return Set(parsedItems) diff --git a/Frameworks/Account/Feedly/Models/FeedlyEntryParser.swift b/Frameworks/Account/Feedly/Models/FeedlyEntryParser.swift index 8af6714c7..2da2edc84 100644 --- a/Frameworks/Account/Feedly/Models/FeedlyEntryParser.swift +++ b/Frameworks/Account/Feedly/Models/FeedlyEntryParser.swift @@ -98,6 +98,7 @@ struct FeedlyEntryParser { url: nil, externalURL: externalUrl, title: title, + language: nil, contentHTML: contentHMTL, contentText: contentText, summary: summary, diff --git a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift index 406d2ec70..1548d7c67 100644 --- a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift +++ b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift @@ -261,7 +261,7 @@ extension NewsBlurAccountDelegate { let parsedItems: [ParsedItem] = stories.map { story in let author = Set([ParsedAuthor(name: story.authorName, url: nil, avatarURL: nil, emailAddress: nil)]) - return ParsedItem(syncServiceID: story.storyID, uniqueID: String(story.storyID), feedURL: String(story.feedID), url: story.url, externalURL: nil, title: story.title, contentHTML: story.contentHTML, contentText: nil, summary: nil, imageURL: story.imageURL, bannerImageURL: nil, datePublished: story.datePublished, dateModified: nil, authors: author, tags: Set(story.tags ?? []), attachments: nil) + return ParsedItem(syncServiceID: story.storyID, uniqueID: String(story.storyID), feedURL: String(story.feedID), url: story.url, externalURL: nil, title: story.title, language: nil, contentHTML: story.contentHTML, contentText: nil, summary: nil, imageURL: story.imageURL, bannerImageURL: nil, datePublished: story.datePublished, dateModified: nil, authors: author, tags: Set(story.tags ?? []), attachments: nil) } return Set(parsedItems) diff --git a/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift b/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift index a449a5301..722df7842 100644 --- a/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift +++ b/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift @@ -936,7 +936,7 @@ private extension ReaderAPIAccountDelegate { // let authors = Set([ParsedAuthor(name: entry.authorName, url: entry.jsonFeed?.jsonFeedAuthor?.url, avatarURL: entry.jsonFeed?.jsonFeedAuthor?.avatarURL, emailAddress: nil)]) // let feed = account.idToFeedDictionary[entry.origin.streamId!]! // TODO clean this up - return ParsedItem(syncServiceID: entry.uniqueID(), uniqueID: entry.uniqueID(), feedURL: entry.origin.streamId!, url: nil, externalURL: entry.alternates.first?.url, title: entry.title, contentHTML: entry.summary.content, contentText: nil, summary: entry.summary.content, imageURL: nil, bannerImageURL: nil, datePublished: entry.parseDatePublished(), dateModified: nil, authors: nil, tags: nil, attachments: nil) + return ParsedItem(syncServiceID: entry.uniqueID(), uniqueID: entry.uniqueID(), feedURL: entry.origin.streamId!, url: nil, externalURL: entry.alternates.first?.url, title: entry.title, language: nil, contentHTML: entry.summary.content, contentText: nil, summary: entry.summary.content, imageURL: nil, bannerImageURL: nil, datePublished: entry.parseDatePublished(), dateModified: nil, authors: nil, tags: nil, attachments: nil) } return Set(parsedItems) From 260551ebb1c2f757b23190ed9a4039929c94efe2 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Fri, 3 Apr 2020 13:52:37 -0500 Subject: [PATCH 69/98] Added missing init param to test case. --- Frameworks/Account/AccountTests/Feedly/FeedlyTestSupport.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyTestSupport.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyTestSupport.swift index 46117de80..ea677dbe4 100644 --- a/Frameworks/Account/AccountTests/Feedly/FeedlyTestSupport.swift +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyTestSupport.swift @@ -287,6 +287,7 @@ class FeedlyTestSupport { url: "http://localhost/", externalURL: "http://localhost/\(pair.0)/articles/\(index).html", title: "Title\(index)", + language: nil, contentHTML: "Content \(index) HTML.", contentText: "Content \(index) Text", summary: nil, From c454aa88b832a20a70cfb37c91307eb022b4a676 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Fri, 3 Apr 2020 14:19:31 -0500 Subject: [PATCH 70/98] Fix feed handling that was causing starred article sharing to bug out. --- Frameworks/Account/CloudKit/CloudKitArticlesZone.swift | 4 ++-- .../Account/CloudKit/CloudKitArticlesZoneDelegate.swift | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift index 6efe26fd0..411684452 100644 --- a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift @@ -30,7 +30,7 @@ final class CloudKitArticlesZone: CloudKitZone { static let recordType = "Article" struct Fields { static let articleStatus = "articleStatus" - static let webFeedID = "webFeedID" + static let webFeedURL = "webFeedURL" static let uniqueID = "uniqueID" static let title = "title" static let contentHTML = "contentHTML" @@ -145,7 +145,7 @@ private extension CloudKitArticlesZone { let articleStatusRecordID = CKRecord.ID(recordName: article.articleID, zoneID: Self.zoneID) articleRecord[CloudKitArticle.Fields.articleStatus] = CKRecord.Reference(recordID: articleStatusRecordID, action: .deleteSelf) - articleRecord[CloudKitArticle.Fields.webFeedID] = article.webFeedID + articleRecord[CloudKitArticle.Fields.webFeedURL] = article.webFeed?.url articleRecord[CloudKitArticle.Fields.uniqueID] = article.uniqueID articleRecord[CloudKitArticle.Fields.title] = article.title articleRecord[CloudKitArticle.Fields.contentHTML] = article.contentHTML diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift index 53c30afc5..6b712b932 100644 --- a/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift @@ -102,9 +102,9 @@ private extension CloudKitArticlesZoneDelegate { } for receivedStarredArticle in receivedStarredArticles { - if let parsedItem = makeParsedItem(receivedStarredArticle), let statusRef = receivedStarredArticle[CloudKitArticlesZone.CloudKitArticle.Fields.articleStatus] as? CKRecord.Reference { + if let parsedItem = makeParsedItem(receivedStarredArticle) { group.enter() - self.account?.update(statusRef.recordID.externalID, with: Set([parsedItem])) { databaseError in + self.account?.update(parsedItem.feedURL, with: Set([parsedItem])) { databaseError in group.leave() if let databaseError = databaseError { os_log(.error, log: self.log, "Error occurred while storing starred items: %@", databaseError.localizedDescription) @@ -134,13 +134,13 @@ private extension CloudKitArticlesZoneDelegate { } guard let uniqueID = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.uniqueID] as? String, - let feedURL = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.webFeedID] as? String else { + let webFeedURL = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.webFeedURL] as? String else { return nil } let parsedItem = ParsedItem(syncServiceID: nil, uniqueID: uniqueID, - feedURL: feedURL, + feedURL: webFeedURL, url: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.url] as? String, externalURL: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.externalURL] as? String, title: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.title] as? String, From 4a2760ade3a11a6248dde36e1b1af3a7f88eb6c3 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Fri, 3 Apr 2020 17:29:26 -0500 Subject: [PATCH 71/98] Fix bug that could cause the progress indicator to not finish. --- Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift | 1 + Frameworks/Account/LocalAccount/LocalAccountRefresher.swift | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 86aaafdff..291530123 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -507,6 +507,7 @@ private extension CloudKitAccountDelegate { self.refresher.refreshFeeds(webFeeds, feedCompletionBlock: { _ in self.refreshProgress.completeTask() }) { account.metadata.lastArticleFetchEndTime = Date() + self.refreshProgress.clear() completion(.success(())) } diff --git a/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift b/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift index 1823e0bb1..28c8fa47e 100644 --- a/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift +++ b/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift @@ -107,7 +107,9 @@ extension LocalAccountRefresher: DownloadSessionDelegate { } func downloadSession(_ downloadSession: DownloadSession, shouldContinueAfterReceivingData data: Data, representedObject: AnyObject) -> Bool { - guard !isSuspended, let feed = representedObject as? WebFeed else { + let feed = representedObject as! WebFeed + guard !isSuspended else { + feedCompletionBlock?(feed) return false } From 850577d6bc2b2249d5d4a8e3c7efc8bd92e78063 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Fri, 3 Apr 2020 20:01:29 -0500 Subject: [PATCH 72/98] Change so that we only attempt to subscribe when creating new account. --- Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 291530123..47949fe90 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -437,11 +437,11 @@ final class CloudKitAccountDelegate: AccountDelegate { os_log(.error, log: self.log, "Error adding account container: %@", error.localizedDescription) } } + zones.forEach { zone in + zone.subscribe() + } } - zones.forEach { zone in - zone.subscribe() - } } func accountWillBeDeleted(_ account: Account) { From b7472fcdaa69145ab1e66198ea4dd131624922f8 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Fri, 3 Apr 2020 20:07:38 -0500 Subject: [PATCH 73/98] Remove duplicate remote notification registration. --- Mac/AppDelegate.swift | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index 3edb0d1a6..96a278b52 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -231,13 +231,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, refreshTimer = AccountRefreshTimer() syncTimer = ArticleStatusSyncTimer() - UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .sound, .alert]) { (granted, error) in - if granted { - DispatchQueue.main.async { - NSApplication.shared.registerForRemoteNotifications() - } - } - } + UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .sound, .alert]) { (granted, error) in } + NSApplication.shared.registerForRemoteNotifications() UNUserNotificationCenter.current().delegate = self userNotificationManager = UserNotificationManager() @@ -268,7 +263,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, } #endif - NSApplication.shared.registerForRemoteNotifications() } func application(_ application: NSApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([NSUserActivityRestoring]) -> Void) -> Bool { From fc020c06e8697cee3ee4238a400bbee16b80b429 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Fri, 3 Apr 2020 20:25:21 -0500 Subject: [PATCH 74/98] Make sure we clear the refresh progress on local accounts when it completes. --- Frameworks/Account/LocalAccount/LocalAccountDelegate.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift index a1751ff16..e99388d81 100644 --- a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift +++ b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift @@ -37,6 +37,7 @@ final class LocalAccountDelegate: AccountDelegate { let webFeeds = account.flattenedWebFeeds() refreshProgress.addToNumberOfTasksAndRemaining(webFeeds.count) refresher.refreshFeeds(webFeeds, feedCompletionBlock: { _ in self.refreshProgress.completeTask() }) { + self.refreshProgress.clear() account.metadata.lastArticleFetchEndTime = Date() completion(.success(())) } From 4d3e9b068ff0d7fe4ff224c83764cbb14fade4fb Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Fri, 3 Apr 2020 20:39:50 -0500 Subject: [PATCH 75/98] Add user deleted zone checks. --- Frameworks/Account/CloudKit/CloudKitZone.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 1848c474d..0c4f53bf4 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -139,6 +139,10 @@ extension CloudKitZone { self?.retryIfPossible(after: timeToWait) { self?.query(query, completion: completion) } + case .userDeletedZone: + DispatchQueue.main.async { + completion(.failure(CloudKitZoneError.userDeletedZone)) + } default: DispatchQueue.main.async { completion(.failure(error!)) @@ -170,6 +174,10 @@ extension CloudKitZone { self?.retryIfPossible(after: timeToWait) { self?.fetch(externalID: externalID, completion: completion) } + case .userDeletedZone: + DispatchQueue.main.async { + completion(.failure(CloudKitZoneError.userDeletedZone)) + } default: DispatchQueue.main.async { completion(.failure(error!)) From 6daedbf6e2a72ed8c9071423ed2c3600937fcf0b Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Fri, 3 Apr 2020 21:06:20 -0500 Subject: [PATCH 76/98] Removed extraneous batch update end. --- Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 47949fe90..b59aef760 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -524,7 +524,6 @@ private extension CloudKitAccountDelegate { case .failure(let error): self.refreshProgress.clear() - BatchUpdate.shared.end() completion(.failure(error)) } } From 4834399b8d79de495818eb2314ac4eb5cf59685e Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Fri, 3 Apr 2020 21:20:55 -0500 Subject: [PATCH 77/98] Beefed up CloudKit error handling. --- .../Account/Account.xcodeproj/project.pbxproj | 8 ++++---- ...ror+Extensions.swift => CloudKitError.swift} | 17 ++++++++++++++--- Frameworks/Account/CloudKit/CloudKitZone.swift | 12 ++++++------ .../Account/CloudKit/CloudKitZoneResult.swift | 6 +++--- 4 files changed, 27 insertions(+), 16 deletions(-) rename Frameworks/Account/CloudKit/{CKError+Extensions.swift => CloudKitError.swift} (95%) diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index b71ebc288..38efd6984 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ 5144EA49227B497600D19003 /* FeedbinAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA48227B497600D19003 /* FeedbinAPICaller.swift */; }; 5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA4D227B829A00D19003 /* FeedbinAccountDelegate.swift */; }; 514BF5202391B0DB00902FE8 /* SingleArticleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 514BF51F2391B0DB00902FE8 /* SingleArticleFetcher.swift */; }; + 5150FFFE243823B800C1A442 /* CloudKitError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5150FFFD243823B800C1A442 /* CloudKitError.swift */; }; 5154367B228EEB28005E1CDF /* FeedbinImportResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */; }; 515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515E4EB22324FF8C0057B0E7 /* CredentialsManager.swift */; }; 515E4EB62324FF8C0057B0E7 /* URLRequest+RSWeb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515E4EB32324FF8C0057B0E7 /* URLRequest+RSWeb.swift */; }; @@ -62,7 +63,6 @@ 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC8FCB237EC055004F8B56 /* Feed.swift */; }; 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */; }; 51C034DF242D65D20014DC71 /* CloudKitZoneResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */; }; - 51C034E1242D660D0014DC71 /* CKError+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C034E0242D660D0014DC71 /* CKError+Extensions.swift */; }; 51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D58754227F53BE00900287 /* FeedbinTag.swift */; }; 51D5875A227F630B00900287 /* tags_delete.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58757227F630B00900287 /* tags_delete.json */; }; 51D5875B227F630B00900287 /* tags_add.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58758227F630B00900287 /* tags_add.json */; }; @@ -277,6 +277,7 @@ 5144EA48227B497600D19003 /* FeedbinAPICaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinAPICaller.swift; sourceTree = ""; }; 5144EA4D227B829A00D19003 /* FeedbinAccountDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinAccountDelegate.swift; sourceTree = ""; }; 514BF51F2391B0DB00902FE8 /* SingleArticleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleArticleFetcher.swift; sourceTree = ""; }; + 5150FFFD243823B800C1A442 /* CloudKitError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitError.swift; sourceTree = ""; }; 5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinImportResult.swift; sourceTree = ""; }; 515E4EB22324FF8C0057B0E7 /* CredentialsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CredentialsManager.swift; sourceTree = ""; }; 515E4EB32324FF8C0057B0E7 /* URLRequest+RSWeb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URLRequest+RSWeb.swift"; sourceTree = ""; }; @@ -298,7 +299,6 @@ 51BC8FCB237EC055004F8B56 /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = ""; }; 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerIdentifier.swift; sourceTree = ""; }; 51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitZoneResult.swift; sourceTree = ""; }; - 51C034E0242D660D0014DC71 /* CKError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKError+Extensions.swift"; sourceTree = ""; }; 51D58754227F53BE00900287 /* FeedbinTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinTag.swift; sourceTree = ""; }; 51D58757227F630B00900287 /* tags_delete.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_delete.json; sourceTree = ""; }; 51D58758227F630B00900287 /* tags_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_add.json; sourceTree = ""; }; @@ -521,13 +521,13 @@ 5103A9D7242253DC00410853 /* CloudKit */ = { isa = PBXGroup; children = ( - 51C034E0242D660D0014DC71 /* CKError+Extensions.swift */, 512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */, 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */, 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */, 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */, 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */, 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */, + 5150FFFD243823B800C1A442 /* CloudKitError.swift */, 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */, 51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */, ); @@ -1099,6 +1099,7 @@ 51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */, 846E77451F6EF9B900A165E2 /* Container.swift in Sources */, 9EA643D3239305680018A28C /* FeedlySearchOperation.swift in Sources */, + 5150FFFE243823B800C1A442 /* CloudKitError.swift in Sources */, 9E5EC15D23E0D58500A4E503 /* FeedlyFeedParser.swift in Sources */, 9E1D15532334304B00F4944C /* FeedlyGetStreamContentsOperation.swift in Sources */, 9E12B0202334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift in Sources */, @@ -1181,7 +1182,6 @@ 9E964EB823754AC400A7AF2E /* OAuthAuthorizationClient+Feedly.swift in Sources */, 3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */, 9EF1B10923590E93000A486A /* FeedlyStreamIds.swift in Sources */, - 51C034E1242D660D0014DC71 /* CKError+Extensions.swift in Sources */, 84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */, 84CAD7161FDF2E22000F0755 /* FeedbinEntry.swift in Sources */, 5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */, diff --git a/Frameworks/Account/CloudKit/CKError+Extensions.swift b/Frameworks/Account/CloudKit/CloudKitError.swift similarity index 95% rename from Frameworks/Account/CloudKit/CKError+Extensions.swift rename to Frameworks/Account/CloudKit/CloudKitError.swift index cdce8cdfb..a92a4d167 100644 --- a/Frameworks/Account/CloudKit/CKError+Extensions.swift +++ b/Frameworks/Account/CloudKit/CloudKitError.swift @@ -1,18 +1,29 @@ // -// CKError+Extensions.swift +// CloudKitError.swift // Account // // Created by Maurice Parker on 3/26/20. // Copyright © 2020 Ranchero Software, LLC. All rights reserved. // +// Derived from https://github.com/caiyue1993/IceCream import Foundation import CloudKit -extension CKError: LocalizedError { +class CloudKitError: LocalizedError { + let error: Error + + init(_ error: Error) { + self.error = error + } + public var errorDescription: String? { - switch code { + guard let ckError = error as? CKError else { + return error.localizedDescription + } + + switch ckError.code { case .alreadyShared: return NSLocalizedString("Already Shared: a record or share cannot be saved because doing so would cause the same hierarchy of records to exist in multiple shares.", comment: "Known iCloud Error") case .assetFileModified: diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 0c4f53bf4..fb386713f 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -110,7 +110,7 @@ extension CloudKitZone { } default: DispatchQueue.main.async { - completion(.failure(error!)) + completion(.failure(CloudKitError(error!))) } } } @@ -145,7 +145,7 @@ extension CloudKitZone { } default: DispatchQueue.main.async { - completion(.failure(error!)) + completion(.failure(CloudKitError(error!))) } } } @@ -180,7 +180,7 @@ extension CloudKitZone { } default: DispatchQueue.main.async { - completion(.failure(error!)) + completion(.failure(CloudKitError(error!))) } } } @@ -271,7 +271,7 @@ extension CloudKitZone { default: DispatchQueue.main.async { - completion(.failure(error!)) + completion(.failure(CloudKitError(error!))) } } } @@ -369,7 +369,7 @@ extension CloudKitZone { } default: DispatchQueue.main.async { - completion(.failure(error!)) + completion(.failure(CloudKitError(error!))) } } @@ -415,7 +415,7 @@ private extension CloudKitZone { database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in if let error = error { DispatchQueue.main.async { - completion(.failure(error)) + completion(.failure(CloudKitError(error))) } } else { DispatchQueue.main.async { diff --git a/Frameworks/Account/CloudKit/CloudKitZoneResult.swift b/Frameworks/Account/CloudKit/CloudKitZoneResult.swift index e37a84ddd..ca1af57c1 100644 --- a/Frameworks/Account/CloudKit/CloudKitZoneResult.swift +++ b/Frameworks/Account/CloudKit/CloudKitZoneResult.swift @@ -33,7 +33,7 @@ enum CloudKitZoneResult { if let retry = ckError.userInfo[CKErrorRetryAfterKey] as? Double { return .retry(afterSeconds: retry) } else { - return .failure(error: error!) + return .failure(error: CloudKitError(ckError)) } case .zoneNotFound: return .zoneNotFound @@ -51,12 +51,12 @@ enum CloudKitZoneResult { return .partialFailure(errors: partialErrors) } } else { - return .failure(error: error!) + return .failure(error: CloudKitError(ckError)) } case .limitExceeded: return .limitExceeded default: - return .failure(error: error!) + return .failure(error: CloudKitError(ckError)) } } From cceec096a92a0176832ffeb8018fd57ade7af74c Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 4 Apr 2020 02:33:41 -0500 Subject: [PATCH 78/98] Added the public default zone. --- .../Account/Account.xcodeproj/project.pbxproj | 4 ++ .../Account/CloudKit/CloudKitPublicZone.swift | 58 +++++++++++++++++++ .../Account/CloudKit/CloudKitZone.swift | 15 ++++- 3 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 Frameworks/Account/CloudKit/CloudKitPublicZone.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 38efd6984..e8c367aeb 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ 5144EA49227B497600D19003 /* FeedbinAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA48227B497600D19003 /* FeedbinAPICaller.swift */; }; 5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA4D227B829A00D19003 /* FeedbinAccountDelegate.swift */; }; 514BF5202391B0DB00902FE8 /* SingleArticleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 514BF51F2391B0DB00902FE8 /* SingleArticleFetcher.swift */; }; + 515000002438682300C1A442 /* CloudKitPublicZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5150FFFF2438682300C1A442 /* CloudKitPublicZone.swift */; }; 5150FFFE243823B800C1A442 /* CloudKitError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5150FFFD243823B800C1A442 /* CloudKitError.swift */; }; 5154367B228EEB28005E1CDF /* FeedbinImportResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */; }; 515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515E4EB22324FF8C0057B0E7 /* CredentialsManager.swift */; }; @@ -278,6 +279,7 @@ 5144EA4D227B829A00D19003 /* FeedbinAccountDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinAccountDelegate.swift; sourceTree = ""; }; 514BF51F2391B0DB00902FE8 /* SingleArticleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleArticleFetcher.swift; sourceTree = ""; }; 5150FFFD243823B800C1A442 /* CloudKitError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitError.swift; sourceTree = ""; }; + 5150FFFF2438682300C1A442 /* CloudKitPublicZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitPublicZone.swift; sourceTree = ""; }; 5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinImportResult.swift; sourceTree = ""; }; 515E4EB22324FF8C0057B0E7 /* CredentialsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CredentialsManager.swift; sourceTree = ""; }; 515E4EB32324FF8C0057B0E7 /* URLRequest+RSWeb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URLRequest+RSWeb.swift"; sourceTree = ""; }; @@ -528,6 +530,7 @@ 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */, 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */, 5150FFFD243823B800C1A442 /* CloudKitError.swift */, + 5150FFFF2438682300C1A442 /* CloudKitPublicZone.swift */, 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */, 51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */, ); @@ -1110,6 +1113,7 @@ 84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */, 841974251F6DDCE4006346C4 /* AccountDelegate.swift in Sources */, 510BD113232C3E9D002692E4 /* WebFeedMetadataFile.swift in Sources */, + 515000002438682300C1A442 /* CloudKitPublicZone.swift in Sources */, 5103A9D92422546800410853 /* CloudKitAccountDelegate.swift in Sources */, 5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */, 9E784EBE237E890600099B1B /* FeedlyLogoutOperation.swift in Sources */, diff --git a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift new file mode 100644 index 000000000..653ac72d4 --- /dev/null +++ b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift @@ -0,0 +1,58 @@ +// +// CloudKitPublicZone.swift +// Account +// +// Created by Maurice Parker on 4/4/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import CloudKit +import os.log + +final class CloudKitPublicZone: CloudKitZone { + + static var zoneID: CKRecordZone.ID { + return CKRecordZone.default().zoneID + } + + var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") + + weak var container: CKContainer? + weak var database: CKDatabase? + var delegate: CloudKitZoneDelegate? + + struct CloudKitWebFeed { + static let recordType = "WebFeed" + struct Fields { + static let url = "url" + static let httpLastModified = "httpLastModified" + static let httpEtag = "httpEtag" + } + } + + struct CloudKitWebFeedCheck { + static let recordType = "UserSubscription" + struct Fields { + static let webFeed = "webFeed" + static let subscriptionID = "oldestPossibleCheckTime" + } + } + + struct CloudKitUserSubscription { + static let recordType = "UserSubscription" + struct Fields { + static let user = "user" + static let webFeed = "webFeed" + static let subscriptionID = "subscriptionID" + } + } + + func subscribe() {} + + func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + + } + + +} diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index fb386713f..20ba10960 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -34,6 +34,18 @@ protocol CloudKitZone: class { var database: CKDatabase? { get } var delegate: CloudKitZoneDelegate? { get set } + /// Reset the change token used to determine what point in time we are doing changes fetches + func resetChangeToken() + + /// Generates a new CKRecord.ID using a UUID for the record's name + func generateRecordID() -> CKRecord.ID + + /// Subscribe to changes at a zone level + func subscribe() + + /// Process a remove notification + func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) + } extension CloudKitZone { @@ -43,12 +55,10 @@ extension CloudKitZone { changeToken = nil } - /// Generates a new CKRecord.ID using a UUID for the record's name func generateRecordID() -> CKRecord.ID { return CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID) } - /// Subscribe to all changes that happen in this zone func subscribe() { let subscription = CKRecordZoneSubscription(zoneID: Self.zoneID) @@ -72,7 +82,6 @@ extension CloudKitZone { } - /// Fetch and process any changes in the zone since the last time we checked when we get a remote notification. func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { let note = CKRecordZoneNotification(fromRemoteNotificationDictionary: userInfo) guard note?.recordZoneID?.zoneName == Self.zoneID.zoneName else { From adefcc7c3f7df83f46c42bae580017dd5712b1d5 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 4 Apr 2020 05:02:33 -0500 Subject: [PATCH 79/98] Stub out subscription delete --- .../CloudKit/CloudKitAccountDelegate.swift | 12 ++++++-- .../CloudKit/CloudKitAccountZone.swift | 28 ++++++++++++++++--- .../Account/CloudKit/CloudKitPublicZone.swift | 18 ++++++------ 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index b59aef760..5df09c1d8 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -30,9 +30,10 @@ final class CloudKitAccountDelegate: AccountDelegate { return CKContainer(identifier: "iCloud.\(orgID).NetNewsWire") }() - private lazy var zones: [CloudKitZone] = [accountZone, articlesZone] + private lazy var zones: [CloudKitZone] = [accountZone, articlesZone, publicZone] private let accountZone: CloudKitAccountZone private let articlesZone: CloudKitArticlesZone + private let publicZone: CloudKitPublicZone private let refresher = LocalAccountRefresher() @@ -48,6 +49,7 @@ final class CloudKitAccountDelegate: AccountDelegate { init(dataFolder: String) { accountZone = CloudKitAccountZone(container: container) articlesZone = CloudKitArticlesZone(container: container) + publicZone = CloudKitPublicZone(container: container) let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3") database = SyncDatabase(databaseFilePath: databaseFilePath) @@ -259,9 +261,13 @@ final class CloudKitAccountDelegate: AccountDelegate { accountZone.removeWebFeed(feed, from: container) { result in self.refreshProgress.completeTask() switch result { - case .success: + case .success(let deleted): container.removeWebFeed(feed) - completion(.success(())) + if deleted { + self.publicZone.removeSubscription(feed, completion: completion) + } else { + completion(.success(())) + } case .failure(let error): completion(.failure(error)) } diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index 30611af51..132e99618 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -125,8 +125,8 @@ final class CloudKitAccountZone: CloudKitZone { } } - /// Deletes a web feed from iCloud - func removeWebFeed(_ webFeed: WebFeed, from: Container, completion: @escaping (Result) -> Void) { + /// Removes a web feed from a container and optionally deletes it, calling the completion with true if deleted + func removeWebFeed(_ webFeed: WebFeed, from: Container, completion: @escaping (Result) -> Void) { guard let fromContainerExternalID = from.externalID else { completion(.failure(CloudKitZoneError.invalidParameter)) return @@ -135,16 +135,36 @@ final class CloudKitAccountZone: CloudKitZone { fetch(externalID: webFeed.externalID) { result in switch result { case .success(let record): + if let containerExternalIDs = record[CloudKitWebFeed.Fields.containerExternalIDs] as? [String] { var containerExternalIDSet = Set(containerExternalIDs) containerExternalIDSet.remove(fromContainerExternalID) + if containerExternalIDSet.isEmpty { - self.delete(externalID: webFeed.externalID , completion: completion) + self.delete(externalID: webFeed.externalID) { result in + switch result { + case .success: + completion(.success(true)) + case .failure(let error): + completion(.failure(error)) + } + } + } else { + record[CloudKitWebFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet) - self.save(record, completion: completion) + self.save(record) { result in + switch result { + case .success: + completion(.success(false)) + case .failure(let error): + completion(.failure(error)) + } + } + } } + case .failure(let error): completion(.failure(error)) } diff --git a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift index 653ac72d4..c6ce1cffc 100644 --- a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift @@ -31,14 +31,6 @@ final class CloudKitPublicZone: CloudKitZone { } } - struct CloudKitWebFeedCheck { - static let recordType = "UserSubscription" - struct Fields { - static let webFeed = "webFeed" - static let subscriptionID = "oldestPossibleCheckTime" - } - } - struct CloudKitUserSubscription { static let recordType = "UserSubscription" struct Fields { @@ -48,11 +40,19 @@ final class CloudKitPublicZone: CloudKitZone { } } + init(container: CKContainer) { + self.container = container + self.database = container.publicCloudDatabase + } + func subscribe() {} func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { } - + func removeSubscription(_ webFeed: WebFeed, completion: @escaping (Result) -> Void) { + + } + } From 79adb1f34abd260f0b4f2bb19109e0ab2ff1f7f1 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 4 Apr 2020 05:15:43 -0500 Subject: [PATCH 80/98] Stub out add subscription --- .../Account/CloudKit/CloudKitAccountDelegate.swift | 9 ++++++++- Frameworks/Account/CloudKit/CloudKitPublicZone.swift | 8 ++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 5df09c1d8..46da00c64 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -187,7 +187,7 @@ final class CloudKitAccountDelegate: AccountDelegate { return } - refreshProgress.addToNumberOfTasksAndRemaining(3) + refreshProgress.addToNumberOfTasksAndRemaining(4) FeedFinder.find(url: url) { result in self.refreshProgress.completeTask() @@ -216,6 +216,13 @@ final class CloudKitAccountDelegate: AccountDelegate { feed.externalID = externalID container.addWebFeed(feed) + self.publicZone.createSubscription(feed) { result in + self.refreshProgress.completeTask() + if case .failure(let error) = result { + os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription) + } + } + InitialFeedDownloader.download(url) { parsedFeed in self.refreshProgress.completeTask() diff --git a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift index c6ce1cffc..0cfe3eee1 100644 --- a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift @@ -48,11 +48,15 @@ final class CloudKitPublicZone: CloudKitZone { func subscribe() {} func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { - + completion() + } + + func createSubscription(_ webFeed: WebFeed, completion: @escaping (Result) -> Void) { + completion(.success(())) } func removeSubscription(_ webFeed: WebFeed, completion: @escaping (Result) -> Void) { - + completion(.success(())) } } From c01cc7cb05a776184f46378fa50f722b48d1ea6f Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 4 Apr 2020 10:32:34 -0500 Subject: [PATCH 81/98] Remove warning messages. --- Mac/AppAssets.swift | 2 -- Mac/Preferences/Accounts/AccountsAddViewController.swift | 4 ---- 2 files changed, 6 deletions(-) diff --git a/Mac/AppAssets.swift b/Mac/AppAssets.swift index a9d62921f..777438f2c 100644 --- a/Mac/AppAssets.swift +++ b/Mac/AppAssets.swift @@ -157,8 +157,6 @@ struct AppAssets { return AppAssets.accountFreshRSS case .newsBlur: return AppAssets.accountNewsBlur - default: - return nil } } diff --git a/Mac/Preferences/Accounts/AccountsAddViewController.swift b/Mac/Preferences/Accounts/AccountsAddViewController.swift index 646d2dd5f..4699e64e1 100644 --- a/Mac/Preferences/Accounts/AccountsAddViewController.swift +++ b/Mac/Preferences/Accounts/AccountsAddViewController.swift @@ -83,8 +83,6 @@ extension AccountsAddViewController: NSTableViewDelegate { case .newsBlur: cell.accountNameLabel?.stringValue = NSLocalizedString("NewsBlur", comment: "NewsBlur") cell.accountImageView?.image = AppAssets.accountNewsBlur - default: - break } return cell } @@ -134,8 +132,6 @@ extension AccountsAddViewController: NSTableViewDelegate { let accountsNewsBlurWindowController = AccountsNewsBlurWindowController() accountsNewsBlurWindowController.runSheetOnWindow(self.view.window!) accountsAddWindowController = accountsNewsBlurWindowController - default: - break } tableView.selectRowIndexes([], byExtendingSelection: false) From 71b5c8bc863c3e50e76fa100ac64ee6b1baa85ae Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 4 Apr 2020 13:33:49 -0500 Subject: [PATCH 82/98] Add user feed subscription management. --- .../Account/Account.xcodeproj/project.pbxproj | 4 + .../CloudKit/CloudKitAccountDelegate.swift | 4 +- .../Account/CloudKit/CloudKitContainer.swift | 39 +++++++++ .../Account/CloudKit/CloudKitPublicZone.swift | 84 ++++++++++++++++++- .../Account/CloudKit/CloudKitZone.swift | 56 +++++++++---- 5 files changed, 168 insertions(+), 19 deletions(-) create mode 100644 Frameworks/Account/CloudKit/CloudKitContainer.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index e8c367aeb..c030be8d3 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -60,6 +60,7 @@ 519E84A62433D49000D238B0 /* OPMLNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */; }; 519E84A82434C5EF00D238B0 /* CloudKitArticlesZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */; }; 519E84AC2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */; }; + 51B544672438F410003F03BF /* CloudKitContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B544662438F410003F03BF /* CloudKitContainer.swift */; }; 51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */; }; 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC8FCB237EC055004F8B56 /* Feed.swift */; }; 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */; }; @@ -297,6 +298,7 @@ 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLNormalizer.swift; sourceTree = ""; }; 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZone.swift; sourceTree = ""; }; 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZoneDelegate.swift; sourceTree = ""; }; + 51B544662438F410003F03BF /* CloudKitContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitContainer.swift; sourceTree = ""; }; 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBehaviors.swift; sourceTree = ""; }; 51BC8FCB237EC055004F8B56 /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = ""; }; 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerIdentifier.swift; sourceTree = ""; }; @@ -529,6 +531,7 @@ 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */, 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */, 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */, + 51B544662438F410003F03BF /* CloudKitContainer.swift */, 5150FFFD243823B800C1A442 /* CloudKitError.swift */, 5150FFFF2438682300C1A442 /* CloudKitPublicZone.swift */, 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */, @@ -1156,6 +1159,7 @@ 84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */, 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */, 846E77501F6EF9C400A165E2 /* LocalAccountRefresher.swift in Sources */, + 51B544672438F410003F03BF /* CloudKitContainer.swift in Sources */, 9EA643CF2391D3560018A28C /* FeedlyAddExistingFeedOperation.swift in Sources */, 55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */, 9E1D154F233371DD00F4944C /* FeedlyGetCollectionsOperation.swift in Sources */, diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 46da00c64..ec6456f7f 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -436,7 +436,9 @@ final class CloudKitAccountDelegate: AccountDelegate { accountZone.delegate = CloudKitAcountZoneDelegate(account: account, refreshProgress: refreshProgress) articlesZone.delegate = CloudKitArticlesZoneDelegate(account: account, database: database, articlesZone: articlesZone) + // Check to see if this is a new account and initialize anything we need if account.externalID == nil { + CloudKitContainer.fetchUserRecordID() accountZone.findOrCreateAccount() { result in switch result { case .success(let externalID): @@ -451,7 +453,7 @@ final class CloudKitAccountDelegate: AccountDelegate { } } zones.forEach { zone in - zone.subscribe() + zone.subscribeToZoneChanges() } } diff --git a/Frameworks/Account/CloudKit/CloudKitContainer.swift b/Frameworks/Account/CloudKit/CloudKitContainer.swift new file mode 100644 index 000000000..a50390665 --- /dev/null +++ b/Frameworks/Account/CloudKit/CloudKitContainer.swift @@ -0,0 +1,39 @@ +// +// CloudKitContainer.swift +// Account +// +// Created by Maurice Parker on 4/4/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import CloudKit + +struct CloudKitContainer { + + private static let userRecordIDKey = "cloudkit.server.userRecordID" + + static var userRecordID: String? { + get { + return UserDefaults.standard.string(forKey: Self.userRecordIDKey) + } + set { + guard let userRecordID = newValue else { + UserDefaults.standard.removeObject(forKey: Self.userRecordIDKey) + return + } + UserDefaults.standard.set(userRecordID, forKey: Self.userRecordIDKey) + } + } + + static func fetchUserRecordID() { + guard Self.userRecordID == nil else { return } + CKContainer.default().fetchUserRecordID { recordID, error in + guard let recordID = recordID, error == nil else { + return + } + Self.userRecordID = recordID.recordName + } + } + +} diff --git a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift index 0cfe3eee1..7892d2146 100644 --- a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift @@ -31,10 +31,18 @@ final class CloudKitPublicZone: CloudKitZone { } } + struct CloudKitUserWebFeedCheck { + static let recordType = "WebFeedCheck" + struct Fields { + static let webFeed = "webFeed" + static let lastCheck = "lastCheck" + } + } + struct CloudKitUserSubscription { static let recordType = "UserSubscription" struct Fields { - static let user = "user" + static let userRecordID = "userRecordID" static let webFeed = "webFeed" static let subscriptionID = "subscriptionID" } @@ -45,18 +53,86 @@ final class CloudKitPublicZone: CloudKitZone { self.database = container.publicCloudDatabase } - func subscribe() {} + func subscribeToZoneChanges() {} func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { completion() } func createSubscription(_ webFeed: WebFeed, completion: @escaping (Result) -> Void) { - completion(.success(())) + let webFeedRecordID = CKRecord.ID(recordName: webFeed.url.md5String, zoneID: Self.zoneID) + let webFeedRecord = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: webFeedRecordID) + + save(webFeedRecord) { result in + switch result { + case .success: + + let webFeedRecordRef = CKRecord.Reference(recordID: webFeedRecordID, action: .none) + let predicate = NSPredicate(format: "webFeed = %@", webFeedRecordRef) + let subscription = CKQuerySubscription(recordType: CloudKitWebFeed.recordType, predicate: predicate, options: [.firesOnRecordUpdate]) + + let info = CKSubscription.NotificationInfo() + info.shouldSendContentAvailable = true + info.desiredKeys = [CloudKitWebFeed.Fields.httpLastModified, CloudKitWebFeed.Fields.httpEtag] + subscription.notificationInfo = info + + self.save(subscription) { result in + switch result { + case .success(let subscription): + + let userSubscriptionRecord = CKRecord(recordType: CloudKitUserSubscription.recordType, recordID: self.generateRecordID()) + userSubscriptionRecord[CloudKitUserSubscription.Fields.userRecordID] = CloudKitContainer.userRecordID + userSubscriptionRecord[CloudKitUserSubscription.Fields.webFeed] = webFeedRecordRef + userSubscriptionRecord[CloudKitUserSubscription.Fields.subscriptionID] = subscription.subscriptionID + + self.save(userSubscriptionRecord, completion: completion) + + case .failure(let error): + completion(.failure(error)) + } + } + + case .failure(let error): + completion(.failure(error)) + } + } } + /// Remove the subscription for the given feed along with its supporting record func removeSubscription(_ webFeed: WebFeed, completion: @escaping (Result) -> Void) { - completion(.success(())) + guard let userRecordID = CloudKitContainer.userRecordID else { + completion(.failure(CloudKitZoneError.invalidParameter)) + return + } + + let webFeedRecordID = CKRecord.ID(recordName: webFeed.url.md5String, zoneID: Self.zoneID) + let webFeedRecordRef = CKRecord.Reference(recordID: webFeedRecordID, action: .none) + let predicate = NSPredicate(format: "user = %@ AND webFeed = %@", userRecordID, webFeedRecordRef) + let ckQuery = CKQuery(recordType: CloudKitUserSubscription.recordType, predicate: predicate) + + query(ckQuery) { result in + switch result { + case .success(let records): + + if records.count > 0, let subscriptionID = records[0][CloudKitUserSubscription.Fields.subscriptionID] as? String { + self.delete(subscriptionID: subscriptionID) { result in + switch result { + case .success: + self.delete(recordID: records[0].recordID, completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + + } else { + completion(.failure(CloudKitZoneError.unknown)) + } + + case .failure(let error): + completion(.failure(error)) + } + } + } } diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 20ba10960..a461dd370 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -41,7 +41,7 @@ protocol CloudKitZone: class { func generateRecordID() -> CKRecord.ID /// Subscribe to changes at a zone level - func subscribe() + func subscribeToZoneChanges() /// Process a remove notification func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) @@ -59,27 +59,18 @@ extension CloudKitZone { return CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID) } - func subscribe() { - + func subscribeToZoneChanges() { let subscription = CKRecordZoneSubscription(zoneID: Self.zoneID) let info = CKSubscription.NotificationInfo() info.shouldSendContentAvailable = true subscription.notificationInfo = info - database?.save(subscription) { _, error in - switch CloudKitZoneResult.resolve(error) { - case .success: - break - case .retry(let timeToWait): - self.retryIfPossible(after: timeToWait) { - self.subscribe() - } - default: - os_log(.error, log: self.log, "%@ zone fetch changes error: %@.", Self.zoneID.zoneName, error?.localizedDescription ?? "Unknown") + save(subscription) { result in + if case .failure(let error) = result { + os_log(.error, log: self.log, "%@ zone subscribe to changes error: %@.", Self.zoneID.zoneName, error.localizedDescription) } } - } func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { @@ -200,6 +191,27 @@ extension CloudKitZone { modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion) } + /// Save the CKSubscription + func save(_ subscription: CKSubscription, completion: @escaping (Result) -> Void) { + database?.save(subscription) { savedSubscription, error in + switch CloudKitZoneResult.resolve(error) { + case .success: + completion(.success((savedSubscription!))) + case .retry(let timeToWait): + self.retryIfPossible(after: timeToWait) { + self.save(subscription, completion: completion) + } + default: + completion(.failure(CloudKitError(error!))) + } + } + } + + /// Delete a CKRecord using its recordID + func delete(recordID: CKRecord.ID, completion: @escaping (Result) -> Void) { + modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion) + } + /// Delete a CKRecord using its externalID func delete(externalID: String?, completion: @escaping (Result) -> Void) { guard let externalID = externalID else { @@ -211,6 +223,22 @@ extension CloudKitZone { modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion) } + /// Delete a CKSubscription + func delete(subscriptionID: String, completion: @escaping (Result) -> Void) { + database?.delete(withSubscriptionID: subscriptionID) { _, error in + switch CloudKitZoneResult.resolve(error) { + case .success: + completion(.success(())) + case .retry(let timeToWait): + self.retryIfPossible(after: timeToWait) { + self.delete(subscriptionID: subscriptionID, completion: completion) + } + default: + completion(.failure(CloudKitError(error!))) + } + } + } + /// Modify and delete the supplied CKRecords and CKRecord.IDs func modify(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result) -> Void) { let op = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete) From 231e3a12e2418285761ffbdf395b9a42198b9106 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 4 Apr 2020 13:36:54 -0500 Subject: [PATCH 83/98] Change web feed key to be an md5 has of the url. --- Frameworks/Account/CloudKit/CloudKitAccountZone.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index 132e99618..b688540c8 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -82,7 +82,8 @@ final class CloudKitAccountZone: CloudKitZone { /// Persist a web feed record to iCloud and return the external key func createWebFeed(url: String, editedName: String?, container: Container, completion: @escaping (Result) -> Void) { - let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: generateRecordID()) + let recordID = CKRecord.ID(recordName: url.md5String, zoneID: Self.zoneID) + let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: recordID) record[CloudKitWebFeed.Fields.url] = url if let editedName = editedName { record[CloudKitWebFeed.Fields.editedName] = editedName From 3a228be14296678ab4a1f5245a1ea807d9f0ce36 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 4 Apr 2020 15:04:38 -0500 Subject: [PATCH 84/98] Add user web feed subscription management. --- .../Account/Account.xcodeproj/project.pbxproj | 8 +- ...ner.swift => CKContainer+Extensions.swift} | 14 ++-- .../CloudKit/CloudKitAccountDelegate.swift | 4 +- .../CloudKit/CloudKitAccountZone.swift | 6 +- .../Account/CloudKit/CloudKitPublicZone.swift | 81 ++++++++++++------- .../Account/CloudKit/CloudKitZone.swift | 38 +++++++-- 6 files changed, 102 insertions(+), 49 deletions(-) rename Frameworks/Account/CloudKit/{CloudKitContainer.swift => CKContainer+Extensions.swift} (71%) diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index c030be8d3..e958c324f 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -60,7 +60,7 @@ 519E84A62433D49000D238B0 /* OPMLNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */; }; 519E84A82434C5EF00D238B0 /* CloudKitArticlesZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */; }; 519E84AC2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */; }; - 51B544672438F410003F03BF /* CloudKitContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B544662438F410003F03BF /* CloudKitContainer.swift */; }; + 51B544672438F410003F03BF /* CKContainer+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B544662438F410003F03BF /* CKContainer+Extensions.swift */; }; 51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */; }; 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC8FCB237EC055004F8B56 /* Feed.swift */; }; 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */; }; @@ -298,7 +298,7 @@ 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLNormalizer.swift; sourceTree = ""; }; 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZone.swift; sourceTree = ""; }; 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZoneDelegate.swift; sourceTree = ""; }; - 51B544662438F410003F03BF /* CloudKitContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitContainer.swift; sourceTree = ""; }; + 51B544662438F410003F03BF /* CKContainer+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKContainer+Extensions.swift"; sourceTree = ""; }; 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBehaviors.swift; sourceTree = ""; }; 51BC8FCB237EC055004F8B56 /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = ""; }; 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerIdentifier.swift; sourceTree = ""; }; @@ -525,13 +525,13 @@ 5103A9D7242253DC00410853 /* CloudKit */ = { isa = PBXGroup; children = ( + 51B544662438F410003F03BF /* CKContainer+Extensions.swift */, 512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */, 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */, 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */, 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */, 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */, 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */, - 51B544662438F410003F03BF /* CloudKitContainer.swift */, 5150FFFD243823B800C1A442 /* CloudKitError.swift */, 5150FFFF2438682300C1A442 /* CloudKitPublicZone.swift */, 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */, @@ -1159,7 +1159,7 @@ 84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */, 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */, 846E77501F6EF9C400A165E2 /* LocalAccountRefresher.swift in Sources */, - 51B544672438F410003F03BF /* CloudKitContainer.swift in Sources */, + 51B544672438F410003F03BF /* CKContainer+Extensions.swift in Sources */, 9EA643CF2391D3560018A28C /* FeedlyAddExistingFeedOperation.swift in Sources */, 55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */, 9E1D154F233371DD00F4944C /* FeedlyGetCollectionsOperation.swift in Sources */, diff --git a/Frameworks/Account/CloudKit/CloudKitContainer.swift b/Frameworks/Account/CloudKit/CKContainer+Extensions.swift similarity index 71% rename from Frameworks/Account/CloudKit/CloudKitContainer.swift rename to Frameworks/Account/CloudKit/CKContainer+Extensions.swift index a50390665..884b4b3ad 100644 --- a/Frameworks/Account/CloudKit/CloudKitContainer.swift +++ b/Frameworks/Account/CloudKit/CKContainer+Extensions.swift @@ -1,5 +1,5 @@ // -// CloudKitContainer.swift +// CKContainer+Extensions.swift // Account // // Created by Maurice Parker on 4/4/20. @@ -9,11 +9,11 @@ import Foundation import CloudKit -struct CloudKitContainer { +extension CKContainer { private static let userRecordIDKey = "cloudkit.server.userRecordID" - static var userRecordID: String? { + var userRecordID: String? { get { return UserDefaults.standard.string(forKey: Self.userRecordIDKey) } @@ -26,13 +26,13 @@ struct CloudKitContainer { } } - static func fetchUserRecordID() { - guard Self.userRecordID == nil else { return } - CKContainer.default().fetchUserRecordID { recordID, error in + func fetchUserRecordID() { + guard userRecordID == nil else { return } + fetchUserRecordID { recordID, error in guard let recordID = recordID, error == nil else { return } - Self.userRecordID = recordID.recordName + self.userRecordID = recordID.recordName } } diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index ec6456f7f..044dd6093 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -219,7 +219,7 @@ final class CloudKitAccountDelegate: AccountDelegate { self.publicZone.createSubscription(feed) { result in self.refreshProgress.completeTask() if case .failure(let error) = result { - os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription) + os_log(.error, log: self.log, "An error occurred while creating the subscription: %@.", error.localizedDescription) } } @@ -438,7 +438,7 @@ final class CloudKitAccountDelegate: AccountDelegate { // Check to see if this is a new account and initialize anything we need if account.externalID == nil { - CloudKitContainer.fetchUserRecordID() + container.fetchUserRecordID() accountZone.findOrCreateAccount() { result in switch result { case .success(let externalID): diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index b688540c8..d46e1e8ea 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -25,7 +25,7 @@ final class CloudKitAccountZone: CloudKitZone { var delegate: CloudKitZoneDelegate? struct CloudKitWebFeed { - static let recordType = "WebFeed" + static let recordType = "AccountWebFeed" struct Fields { static let url = "url" static let editedName = "editedName" @@ -34,7 +34,7 @@ final class CloudKitAccountZone: CloudKitZone { } struct CloudKitContainer { - static let recordType = "Container" + static let recordType = "AccountContainer" struct Fields { static let isAccount = "isAccount" static let name = "name" @@ -77,7 +77,7 @@ final class CloudKitAccountZone: CloudKitZone { } } - modify(recordsToSave: records, recordIDsToDelete: [], completion: completion) + save(records, completion: completion) } /// Persist a web feed record to iCloud and return the external key diff --git a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift index 7892d2146..7ede9b48e 100644 --- a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift @@ -31,7 +31,7 @@ final class CloudKitPublicZone: CloudKitZone { } } - struct CloudKitUserWebFeedCheck { + struct CloudKitWebFeedCheck { static let recordType = "WebFeedCheck" struct Fields { static let webFeed = "webFeed" @@ -59,55 +59,79 @@ final class CloudKitPublicZone: CloudKitZone { completion() } + /// Create a CloudKit subscription for the webfeed and any other supporting records that we need func createSubscription(_ webFeed: WebFeed, completion: @escaping (Result) -> Void) { - let webFeedRecordID = CKRecord.ID(recordName: webFeed.url.md5String, zoneID: Self.zoneID) - let webFeedRecord = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: webFeedRecordID) - save(webFeedRecord) { result in + func createSubscription(_ webFeedRecordRef: CKRecord.Reference) { + let predicate = NSPredicate(format: "webFeed = %@", webFeedRecordRef) + let subscription = CKQuerySubscription(recordType: CloudKitWebFeed.recordType, predicate: predicate, options: [.firesOnRecordUpdate]) + + let info = CKSubscription.NotificationInfo() + info.shouldSendContentAvailable = true + info.desiredKeys = [CloudKitWebFeed.Fields.httpLastModified, CloudKitWebFeed.Fields.httpEtag] + subscription.notificationInfo = info + + self.save(subscription) { result in + switch result { + case .success(let subscription): + + let userSubscriptionRecord = CKRecord(recordType: CloudKitUserSubscription.recordType, recordID: self.generateRecordID()) + userSubscriptionRecord[CloudKitUserSubscription.Fields.userRecordID] = self.container?.userRecordID + userSubscriptionRecord[CloudKitUserSubscription.Fields.webFeed] = webFeedRecordRef + userSubscriptionRecord[CloudKitUserSubscription.Fields.subscriptionID] = subscription.subscriptionID + + self.save(userSubscriptionRecord, completion: completion) + + case .failure(let error): + completion(.failure(error)) + } + } + } + + fetch(externalID: webFeed.url.md5String) { result in switch result { - case .success: + case .success(let record): + let webFeedRecordRef = CKRecord.Reference(record: record, action: .none) + createSubscription(webFeedRecordRef) + + case .failure: + + let webFeedRecordID = CKRecord.ID(recordName: webFeed.url.md5String, zoneID: Self.zoneID) let webFeedRecordRef = CKRecord.Reference(recordID: webFeedRecordID, action: .none) - let predicate = NSPredicate(format: "webFeed = %@", webFeedRecordRef) - let subscription = CKQuerySubscription(recordType: CloudKitWebFeed.recordType, predicate: predicate, options: [.firesOnRecordUpdate]) - - let info = CKSubscription.NotificationInfo() - info.shouldSendContentAvailable = true - info.desiredKeys = [CloudKitWebFeed.Fields.httpLastModified, CloudKitWebFeed.Fields.httpEtag] - subscription.notificationInfo = info - - self.save(subscription) { result in - switch result { - case .success(let subscription): - - let userSubscriptionRecord = CKRecord(recordType: CloudKitUserSubscription.recordType, recordID: self.generateRecordID()) - userSubscriptionRecord[CloudKitUserSubscription.Fields.userRecordID] = CloudKitContainer.userRecordID - userSubscriptionRecord[CloudKitUserSubscription.Fields.webFeed] = webFeedRecordRef - userSubscriptionRecord[CloudKitUserSubscription.Fields.subscriptionID] = subscription.subscriptionID + let webFeedRecord = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: webFeedRecordID) + webFeedRecord[CloudKitWebFeed.Fields.url] = webFeed.url + webFeedRecord[CloudKitWebFeed.Fields.httpLastModified] = "" + webFeedRecord[CloudKitWebFeed.Fields.httpEtag] = "" - self.save(userSubscriptionRecord, completion: completion) - + let webFeedCheckRecord = CKRecord(recordType: CloudKitWebFeedCheck.recordType, recordID: self.generateRecordID()) + webFeedRecord[CloudKitWebFeedCheck.Fields.webFeed] = webFeedRecordRef + webFeedRecord[CloudKitWebFeedCheck.Fields.lastCheck] = Date.distantPast + + self.save([webFeedRecord, webFeedCheckRecord]) { result in + switch result { + case .success: + createSubscription(webFeedRecordRef) case .failure(let error): completion(.failure(error)) } } - case .failure(let error): - completion(.failure(error)) } } + } /// Remove the subscription for the given feed along with its supporting record func removeSubscription(_ webFeed: WebFeed, completion: @escaping (Result) -> Void) { - guard let userRecordID = CloudKitContainer.userRecordID else { + guard let userRecordID = self.container?.userRecordID else { completion(.failure(CloudKitZoneError.invalidParameter)) return } let webFeedRecordID = CKRecord.ID(recordName: webFeed.url.md5String, zoneID: Self.zoneID) let webFeedRecordRef = CKRecord.Reference(recordID: webFeedRecordID, action: .none) - let predicate = NSPredicate(format: "user = %@ AND webFeed = %@", userRecordID, webFeedRecordRef) + let predicate = NSPredicate(format: "userRecordID = %@ AND webFeed = %@", userRecordID, webFeedRecordRef) let ckQuery = CKQuery(recordType: CloudKitUserSubscription.recordType, predicate: predicate) query(ckQuery) { result in @@ -125,7 +149,8 @@ final class CloudKitPublicZone: CloudKitZone { } } else { - completion(.failure(CloudKitZoneError.unknown)) + os_log(.error, log: self.log, "Remove subscription error. The subscription wasn't found.") + completion(.success(())) } case .failure(let error): diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index a461dd370..4a47d2baf 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -10,10 +10,14 @@ import CloudKit import os.log import RSWeb -enum CloudKitZoneError: Error { +enum CloudKitZoneError: LocalizedError { case userDeletedZone case invalidParameter case unknown + + var errorDescription: String? { + return NSLocalizedString("An unexpected CloudKit error occurred.", comment: "An unexpected CloudKit error occurred.") + } } protocol CloudKitZoneDelegate: class { @@ -191,18 +195,38 @@ extension CloudKitZone { modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion) } + /// Save the CKRecords + func save(_ records: [CKRecord], completion: @escaping (Result) -> Void) { + modify(recordsToSave: records, recordIDsToDelete: [], completion: completion) + } + /// Save the CKSubscription func save(_ subscription: CKSubscription, completion: @escaping (Result) -> Void) { database?.save(subscription) { savedSubscription, error in switch CloudKitZoneResult.resolve(error) { case .success: - completion(.success((savedSubscription!))) + DispatchQueue.main.async { + completion(.success((savedSubscription!))) + } + case .zoneNotFound: + self.createZoneRecord() { result in + switch result { + case .success: + self.save(subscription, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } case .retry(let timeToWait): self.retryIfPossible(after: timeToWait) { self.save(subscription, completion: completion) } default: - completion(.failure(CloudKitError(error!))) + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } } } } @@ -228,13 +252,17 @@ extension CloudKitZone { database?.delete(withSubscriptionID: subscriptionID) { _, error in switch CloudKitZoneResult.resolve(error) { case .success: - completion(.success(())) + DispatchQueue.main.async { + completion(.success(())) + } case .retry(let timeToWait): self.retryIfPossible(after: timeToWait) { self.delete(subscriptionID: subscriptionID, completion: completion) } default: - completion(.failure(CloudKitError(error!))) + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } } } } From 2a6e1078aa57711b306373b005735367ecb90bb9 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 4 Apr 2020 15:12:59 -0500 Subject: [PATCH 85/98] Add batch update so that feeds stop adding to the sidebar without their names populated. --- .../CloudKit/CloudKitAccountDelegate.swift | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 044dd6093..62b0839d6 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -182,11 +182,14 @@ final class CloudKitAccountDelegate: AccountDelegate { } func createWebFeed(for account: Account, url urlString: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { + let editedName = name == nil || name!.isEmpty ? nil : name + guard let url = URL(string: urlString) else { completion(.failure(LocalAccountDelegateError.invalidParameter)) return } + BatchUpdate.shared.start() refreshProgress.addToNumberOfTasksAndRemaining(4) FeedFinder.find(url: url) { result in @@ -194,25 +197,27 @@ final class CloudKitAccountDelegate: AccountDelegate { switch result { case .success(let feedSpecifiers): guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), let url = URL(string: bestFeedSpecifier.urlString) else { - self.refreshProgress.completeTask() + BatchUpdate.shared.end() + self.refreshProgress.clear() completion(.failure(AccountError.createErrorNotFound)) return } if account.hasWebFeed(withURL: bestFeedSpecifier.urlString) { - self.refreshProgress.completeTask() + BatchUpdate.shared.end() + self.refreshProgress.clear() completion(.failure(AccountError.createErrorAlreadySubscribed)) return } - self.accountZone.createWebFeed(url: bestFeedSpecifier.urlString, editedName: name, container: container) { result in + self.accountZone.createWebFeed(url: bestFeedSpecifier.urlString, editedName: editedName, container: container) { result in self.refreshProgress.completeTask() switch result { case .success(let externalID): let feed = account.createWebFeed(with: nil, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) - feed.editedName = name + feed.editedName = editedName feed.externalID = externalID container.addWebFeed(feed) @@ -228,6 +233,7 @@ final class CloudKitAccountDelegate: AccountDelegate { if let parsedFeed = parsedFeed { account.update(feed, with: parsedFeed, {_ in + BatchUpdate.shared.end() completion(.success(feed)) }) } @@ -235,11 +241,14 @@ final class CloudKitAccountDelegate: AccountDelegate { } case .failure(let error): + BatchUpdate.shared.end() + self.refreshProgress.clear() completion(.failure(error)) } } case .failure: + BatchUpdate.shared.end() self.refreshProgress.clear() completion(.failure(AccountError.createErrorNotFound)) } From 5273187033ffa1c99f415b1b56b92a598bf4a585 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 4 Apr 2020 16:05:52 -0500 Subject: [PATCH 86/98] Put update to UserDefaults on main thread. --- Frameworks/Account/CloudKit/CKContainer+Extensions.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Frameworks/Account/CloudKit/CKContainer+Extensions.swift b/Frameworks/Account/CloudKit/CKContainer+Extensions.swift index 884b4b3ad..afa27e410 100644 --- a/Frameworks/Account/CloudKit/CKContainer+Extensions.swift +++ b/Frameworks/Account/CloudKit/CKContainer+Extensions.swift @@ -32,7 +32,9 @@ extension CKContainer { guard let recordID = recordID, error == nil else { return } - self.userRecordID = recordID.recordName + DispatchQueue.main.async { + self.userRecordID = recordID.recordName + } } } From 0b87acec1e12eef485bd9db47e97750853593724 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 4 Apr 2020 17:35:09 -0500 Subject: [PATCH 87/98] Add subscriptions to OPML imports. --- .../CloudKit/CloudKitAccountDelegate.swift | 56 +++++++++++++++++-- .../Account/CloudKit/CloudKitPublicZone.swift | 11 ++-- .../Account/CloudKit/CloudKitZone.swift | 8 +-- 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 62b0839d6..9d456e461 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -178,7 +178,55 @@ final class CloudKitAccountDelegate: AccountDelegate { let normalizedItems = OPMLNormalizer.normalize(opmlItems) - accountZone.importOPML(rootExternalID: rootExternalID, items: normalizedItems, completion: completion) + var webFeedURLs = Set() + for opmlItem in normalizedItems { + if let webFeedURL = opmlItem.feedSpecifier?.feedURL { + webFeedURLs.insert(webFeedURL) + } else { + if let childItems = opmlItem.children { + for childItem in childItems { + if let webFeedURL = childItem.feedSpecifier?.feedURL { + webFeedURLs.insert(webFeedURL) + } + } + } + } + } + + refreshProgress.addToNumberOfTasksAndRemaining(webFeedURLs.count + 1) + var errorOccurred = false + + // You have to single thread these or CloudKit gets overwhelmed and freaks out + func takeOneAndPassItOn(_ webFeedURLs: [String], completion: @escaping () -> Void) { + var remainingWebFeedURLS = webFeedURLs + + if let webFeedURL = remainingWebFeedURLS.popLast() { + publicZone.createSubscription(webFeedURL) { result in + self.refreshProgress.completeTask() + if case .failure(let error) = result { + os_log(.error, log: self.log, "Error while subscribing to the feed: %@", error.localizedDescription) + errorOccurred = true + } + takeOneAndPassItOn(remainingWebFeedURLS, completion: completion) + } + } else { + completion() + } + + } + + takeOneAndPassItOn(Array(webFeedURLs)) { + if errorOccurred { + self.refreshProgress.completeTask() + completion(.failure(CloudKitZoneError.unknown)) + } else { + self.accountZone.importOPML(rootExternalID: rootExternalID, items: normalizedItems) { _ in + self.refreshProgress.completeTask() + completion(.success(())) + } + } + } + } func createWebFeed(for account: Account, url urlString: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { @@ -221,10 +269,10 @@ final class CloudKitAccountDelegate: AccountDelegate { feed.externalID = externalID container.addWebFeed(feed) - self.publicZone.createSubscription(feed) { result in + self.publicZone.createSubscription(feed.url) { result in self.refreshProgress.completeTask() if case .failure(let error) = result { - os_log(.error, log: self.log, "An error occurred while creating the subscription: %@.", error.localizedDescription) + os_log(.error, log: self.log, "An error occurred while creating the subscription: %@", error.localizedDescription) } } @@ -442,7 +490,7 @@ final class CloudKitAccountDelegate: AccountDelegate { } func accountDidInitialize(_ account: Account) { - accountZone.delegate = CloudKitAcountZoneDelegate(account: account, refreshProgress: refreshProgress) + accountZone.delegate = CloudKitAcountZoneDelegate(account: account, publicZone: publicZone, refreshProgress: refreshProgress) articlesZone.delegate = CloudKitArticlesZoneDelegate(account: account, database: database, articlesZone: articlesZone) // Check to see if this is a new account and initialize anything we need diff --git a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift index 7ede9b48e..ca6a76a3e 100644 --- a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift @@ -60,8 +60,9 @@ final class CloudKitPublicZone: CloudKitZone { } /// Create a CloudKit subscription for the webfeed and any other supporting records that we need - func createSubscription(_ webFeed: WebFeed, completion: @escaping (Result) -> Void) { - + func createSubscription(_ webFeedURL: String, completion: @escaping (Result) -> Void) { + let webFeedURLMD5String = webFeedURL.md5String + func createSubscription(_ webFeedRecordRef: CKRecord.Reference) { let predicate = NSPredicate(format: "webFeed = %@", webFeedRecordRef) let subscription = CKQuerySubscription(recordType: CloudKitWebFeed.recordType, predicate: predicate, options: [.firesOnRecordUpdate]) @@ -88,7 +89,7 @@ final class CloudKitPublicZone: CloudKitZone { } } - fetch(externalID: webFeed.url.md5String) { result in + fetch(externalID: webFeedURLMD5String) { result in switch result { case .success(let record): @@ -97,10 +98,10 @@ final class CloudKitPublicZone: CloudKitZone { case .failure: - let webFeedRecordID = CKRecord.ID(recordName: webFeed.url.md5String, zoneID: Self.zoneID) + let webFeedRecordID = CKRecord.ID(recordName: webFeedURLMD5String, zoneID: Self.zoneID) let webFeedRecordRef = CKRecord.Reference(recordID: webFeedRecordID, action: .none) let webFeedRecord = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: webFeedRecordID) - webFeedRecord[CloudKitWebFeed.Fields.url] = webFeed.url + webFeedRecord[CloudKitWebFeed.Fields.url] = webFeedURL webFeedRecord[CloudKitWebFeed.Fields.httpLastModified] = "" webFeedRecord[CloudKitWebFeed.Fields.httpEtag] = "" diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 4a47d2baf..dc528e655 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -72,7 +72,7 @@ extension CloudKitZone { save(subscription) { result in if case .failure(let error) = result { - os_log(.error, log: self.log, "%@ zone subscribe to changes error: %@.", Self.zoneID.zoneName, error.localizedDescription) + os_log(.error, log: self.log, "%@ zone subscribe to changes error: %@", Self.zoneID.zoneName, error.localizedDescription) } } } @@ -86,7 +86,7 @@ extension CloudKitZone { fetchChangesInZone() { result in if case .failure(let error) = result { - os_log(.error, log: self.log, "%@ zone remote notification fetch error: %@.", Self.zoneID.zoneName, error.localizedDescription) + os_log(.error, log: self.log, "%@ zone remote notification fetch error: %@", Self.zoneID.zoneName, error.localizedDescription) } completion() } @@ -319,7 +319,7 @@ extension CloudKitZone { group.enter() self.modify(recordsToSave: chunk, recordIDsToDelete: recordIDsToDelete) { result in if case .failure(let error) = result { - os_log(.error, log: self.log, "%@ zone modify records error: %@.", Self.zoneID.zoneName, error.localizedDescription) + os_log(.error, log: self.log, "%@ zone modify records error: %@", Self.zoneID.zoneName, error.localizedDescription) errorOccurred = true } group.leave() @@ -396,7 +396,7 @@ extension CloudKitZone { self.fetchChangesInZone(completion: completion) } default: - os_log(.error, log: self.log, "%@ zone fetch changes error: %@.", zoneID.zoneName, error?.localizedDescription ?? "Unknown") + os_log(.error, log: self.log, "%@ zone fetch changes error: %@", zoneID.zoneName, error?.localizedDescription ?? "Unknown") } } From f289735b50a3c04dc93b53f594b3f3c7c3d5e500 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 5 Apr 2020 10:49:15 -0500 Subject: [PATCH 88/98] Rework how feed subscriptions are managed. --- Frameworks/Account/Account.swift | 4 + .../CloudKit/CloudKitAccountDelegate.swift | 49 +++---- .../Account/CloudKit/CloudKitPublicZone.swift | 125 +++++------------ .../Account/CloudKit/CloudKitZone.swift | 126 ++++++++++++++++-- 4 files changed, 170 insertions(+), 134 deletions(-) diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 288943296..cc4fc37f4 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -166,6 +166,10 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } return _externalIDToWebFeedDictionary } + + var flattenedWebFeedURLs: Set { + return Set(flattenedWebFeeds().map({ $0.url })) + } var username: String? { get { diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 9d456e461..37bc811a9 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -178,7 +178,9 @@ final class CloudKitAccountDelegate: AccountDelegate { let normalizedItems = OPMLNormalizer.normalize(opmlItems) - var webFeedURLs = Set() + // Combine all existing web feed URLs with all the new ones + + var webFeedURLs = account.flattenedWebFeedURLs for opmlItem in normalizedItems { if let webFeedURL = opmlItem.feedSpecifier?.feedURL { webFeedURLs.insert(webFeedURL) @@ -193,37 +195,18 @@ final class CloudKitAccountDelegate: AccountDelegate { } } - refreshProgress.addToNumberOfTasksAndRemaining(webFeedURLs.count + 1) - var errorOccurred = false +// os_log(.error, log: self.log, "Error while subscribing to the feed: %@", error.localizedDescription) - // You have to single thread these or CloudKit gets overwhelmed and freaks out - func takeOneAndPassItOn(_ webFeedURLs: [String], completion: @escaping () -> Void) { - var remainingWebFeedURLS = webFeedURLs - - if let webFeedURL = remainingWebFeedURLS.popLast() { - publicZone.createSubscription(webFeedURL) { result in - self.refreshProgress.completeTask() - if case .failure(let error) = result { - os_log(.error, log: self.log, "Error while subscribing to the feed: %@", error.localizedDescription) - errorOccurred = true - } - takeOneAndPassItOn(remainingWebFeedURLS, completion: completion) - } - } else { - completion() - } - - } - - takeOneAndPassItOn(Array(webFeedURLs)) { - if errorOccurred { - self.refreshProgress.completeTask() - completion(.failure(CloudKitZoneError.unknown)) - } else { + refreshProgress.addToNumberOfTasksAndRemaining(2) + publicZone.manageSubscriptions(webFeedURLs) { result in + self.refreshProgress.completeTask() + switch result { + case .success: self.accountZone.importOPML(rootExternalID: rootExternalID, items: normalizedItems) { _ in - self.refreshProgress.completeTask() - completion(.success(())) + self.refreshAll(for: account, completion: completion) } + case .failure(let error): + completion(.failure(error)) } } @@ -269,13 +252,13 @@ final class CloudKitAccountDelegate: AccountDelegate { feed.externalID = externalID container.addWebFeed(feed) - self.publicZone.createSubscription(feed.url) { result in + self.publicZone.manageSubscriptions(account.flattenedWebFeedURLs) { result in self.refreshProgress.completeTask() if case .failure(let error) = result { os_log(.error, log: self.log, "An error occurred while creating the subscription: %@", error.localizedDescription) } } - + InitialFeedDownloader.download(url) { parsedFeed in self.refreshProgress.completeTask() @@ -328,7 +311,7 @@ final class CloudKitAccountDelegate: AccountDelegate { case .success(let deleted): container.removeWebFeed(feed) if deleted { - self.publicZone.removeSubscription(feed, completion: completion) + self.publicZone.manageSubscriptions(account.flattenedWebFeedURLs, completion: completion) } else { completion(.success(())) } @@ -490,7 +473,7 @@ final class CloudKitAccountDelegate: AccountDelegate { } func accountDidInitialize(_ account: Account) { - accountZone.delegate = CloudKitAcountZoneDelegate(account: account, publicZone: publicZone, refreshProgress: refreshProgress) + accountZone.delegate = CloudKitAcountZoneDelegate(account: account, refreshProgress: refreshProgress) articlesZone.delegate = CloudKitArticlesZoneDelegate(account: account, database: database, articlesZone: articlesZone) // Check to see if this is a new account and initialize anything we need diff --git a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift index ca6a76a3e..05ef0d7d7 100644 --- a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift @@ -59,106 +59,47 @@ final class CloudKitPublicZone: CloudKitZone { completion() } - /// Create a CloudKit subscription for the webfeed and any other supporting records that we need - func createSubscription(_ webFeedURL: String, completion: @escaping (Result) -> Void) { - let webFeedURLMD5String = webFeedURL.md5String + /// Create any new subscriptions and delete any old ones + func manageSubscriptions(_ webFeedURLs: Set, completion: @escaping (Result) -> Void) { - func createSubscription(_ webFeedRecordRef: CKRecord.Reference) { - let predicate = NSPredicate(format: "webFeed = %@", webFeedRecordRef) - let subscription = CKQuerySubscription(recordType: CloudKitWebFeed.recordType, predicate: predicate, options: [.firesOnRecordUpdate]) - - let info = CKSubscription.NotificationInfo() - info.shouldSendContentAvailable = true - info.desiredKeys = [CloudKitWebFeed.Fields.httpLastModified, CloudKitWebFeed.Fields.httpEtag] - subscription.notificationInfo = info - - self.save(subscription) { result in - switch result { - case .success(let subscription): - - let userSubscriptionRecord = CKRecord(recordType: CloudKitUserSubscription.recordType, recordID: self.generateRecordID()) - userSubscriptionRecord[CloudKitUserSubscription.Fields.userRecordID] = self.container?.userRecordID - userSubscriptionRecord[CloudKitUserSubscription.Fields.webFeed] = webFeedRecordRef - userSubscriptionRecord[CloudKitUserSubscription.Fields.subscriptionID] = subscription.subscriptionID + var webFeedRecords = [CKRecord]() + for webFeedURL in webFeedURLs { + let webFeedRecordID = CKRecord.ID(recordName: webFeedURL.md5String, zoneID: Self.zoneID) + let webFeedRecord = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: webFeedRecordID) + webFeedRecord[CloudKitWebFeed.Fields.url] = webFeedURL + webFeedRecord[CloudKitWebFeed.Fields.httpLastModified] = "" + webFeedRecord[CloudKitWebFeed.Fields.httpEtag] = "" + webFeedRecords.append(webFeedRecord) + } - self.save(userSubscriptionRecord, completion: completion) - + self.saveIfNew(webFeedRecords) { _ in + + var subscriptions = [CKSubscription]() + let webFeedURLChunks = Array(webFeedURLs).chunked(into: 20) + for webFeedURLChunk in webFeedURLChunks { + + let predicate = NSPredicate(format: "url in %@", webFeedURLChunk) + let subscription = CKQuerySubscription(recordType: CloudKitWebFeed.recordType, predicate: predicate, options: [.firesOnRecordUpdate]) + let info = CKSubscription.NotificationInfo() + info.shouldSendContentAvailable = true + info.desiredKeys = [CloudKitWebFeed.Fields.httpLastModified, CloudKitWebFeed.Fields.httpEtag] + subscription.notificationInfo = info + subscriptions.append(subscription) + + } + + self.fetchAllUserSubscriptions() { result in + switch result { + case .success(let subscriptionsToDelete): + let subscriptionToDeleteIDs = subscriptionsToDelete.map({ $0.subscriptionID }) + self.modify(subscriptionsToSave: subscriptions, subscriptionIDsToDelete: subscriptionToDeleteIDs, completion: completion) case .failure(let error): completion(.failure(error)) } } + } - fetch(externalID: webFeedURLMD5String) { result in - switch result { - case .success(let record): - - let webFeedRecordRef = CKRecord.Reference(record: record, action: .none) - createSubscription(webFeedRecordRef) - - case .failure: - - let webFeedRecordID = CKRecord.ID(recordName: webFeedURLMD5String, zoneID: Self.zoneID) - let webFeedRecordRef = CKRecord.Reference(recordID: webFeedRecordID, action: .none) - let webFeedRecord = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: webFeedRecordID) - webFeedRecord[CloudKitWebFeed.Fields.url] = webFeedURL - webFeedRecord[CloudKitWebFeed.Fields.httpLastModified] = "" - webFeedRecord[CloudKitWebFeed.Fields.httpEtag] = "" - - let webFeedCheckRecord = CKRecord(recordType: CloudKitWebFeedCheck.recordType, recordID: self.generateRecordID()) - webFeedRecord[CloudKitWebFeedCheck.Fields.webFeed] = webFeedRecordRef - webFeedRecord[CloudKitWebFeedCheck.Fields.lastCheck] = Date.distantPast - - self.save([webFeedRecord, webFeedCheckRecord]) { result in - switch result { - case .success: - createSubscription(webFeedRecordRef) - case .failure(let error): - completion(.failure(error)) - } - } - - } - } - - } - - /// Remove the subscription for the given feed along with its supporting record - func removeSubscription(_ webFeed: WebFeed, completion: @escaping (Result) -> Void) { - guard let userRecordID = self.container?.userRecordID else { - completion(.failure(CloudKitZoneError.invalidParameter)) - return - } - - let webFeedRecordID = CKRecord.ID(recordName: webFeed.url.md5String, zoneID: Self.zoneID) - let webFeedRecordRef = CKRecord.Reference(recordID: webFeedRecordID, action: .none) - let predicate = NSPredicate(format: "userRecordID = %@ AND webFeed = %@", userRecordID, webFeedRecordRef) - let ckQuery = CKQuery(recordType: CloudKitUserSubscription.recordType, predicate: predicate) - - query(ckQuery) { result in - switch result { - case .success(let records): - - if records.count > 0, let subscriptionID = records[0][CloudKitUserSubscription.Fields.subscriptionID] as? String { - self.delete(subscriptionID: subscriptionID) { result in - switch result { - case .success: - self.delete(recordID: records[0].recordID, completion: completion) - case .failure(let error): - completion(.failure(error)) - } - } - - } else { - os_log(.error, log: self.log, "Remove subscription error. The subscription wasn't found.") - completion(.success(())) - } - - case .failure(let error): - completion(.failure(error)) - } - } - } } diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index dc528e655..bf18652e3 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -200,6 +200,76 @@ extension CloudKitZone { modify(recordsToSave: records, recordIDsToDelete: [], completion: completion) } + /// Saves or modifies the records as long as they are unchanged relative to the local version + func saveIfNew(_ records: [CKRecord], completion: @escaping (Result) -> Void) { + let op = CKModifyRecordsOperation(recordsToSave: records, recordIDsToDelete: [CKRecord.ID]()) + op.savePolicy = .ifServerRecordUnchanged + op.isAtomic = false + + op.modifyRecordsCompletionBlock = { [weak self] (_, _, error) in + + guard let self = self else { return } + + switch CloudKitZoneResult.resolve(error) { + case .success: + DispatchQueue.main.async { + completion(.success(())) + } + case .zoneNotFound: + self.createZoneRecord() { result in + switch result { + case .success: + self.saveIfNew(records, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + case .userDeletedZone: + DispatchQueue.main.async { + completion(.failure(CloudKitZoneError.userDeletedZone)) + } + case .retry(let timeToWait): + self.retryIfPossible(after: timeToWait) { + self.saveIfNew(records, completion: completion) + } + case .limitExceeded: + + let chunkedRecords = records.chunked(into: 300) + + let group = DispatchGroup() + var errorOccurred = false + + for chunk in chunkedRecords { + group.enter() + self.saveIfNew(chunk) { result in + if case .failure(let error) = result { + os_log(.error, log: self.log, "%@ zone modify records error: %@", Self.zoneID.zoneName, error.localizedDescription) + errorOccurred = true + } + group.leave() + } + } + + group.notify(queue: DispatchQueue.main) { + if errorOccurred { + completion(.failure(CloudKitZoneError.unknown)) + } else { + completion(.success(())) + } + } + + default: + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } + } + } + + database?.add(op) + } + /// Save the CKSubscription func save(_ subscription: CKSubscription, completion: @escaping (Result) -> Void) { database?.save(subscription) { savedSubscription, error in @@ -266,18 +336,37 @@ extension CloudKitZone { } } } + + /// Bulk add (or modify I suppose) and delete of subscriptions + func modify(subscriptionsToSave: [CKSubscription], subscriptionIDsToDelete: [CKSubscription.ID], completion: @escaping (Result) -> Void) { + let op = CKModifySubscriptionsOperation(subscriptionsToSave: subscriptionsToSave, subscriptionIDsToDelete: subscriptionIDsToDelete) + + op.modifySubscriptionsCompletionBlock = { [weak self] (_, _, error) in + guard let self = self else { return } + + switch CloudKitZoneResult.resolve(error) { + case .success: + DispatchQueue.main.async { + completion(.success(())) + } + case .retry(let timeToWait): + self.retryIfPossible(after: timeToWait) { + self.modify(subscriptionsToSave: subscriptionsToSave, subscriptionIDsToDelete: subscriptionIDsToDelete, completion: completion) + } + default: + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } + } + } + + database?.add(op) + } /// Modify and delete the supplied CKRecords and CKRecord.IDs func modify(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result) -> Void) { let op = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete) - - // We use .changedKeys savePolicy to do unlocked changes here cause my app is contentious and off-line first - // Apple suggests using .ifServerRecordUnchanged save policy - // For more, see Advanced CloudKit(https://developer.apple.com/videos/play/wwdc2014/231/) op.savePolicy = .changedKeys - - // To avoid CKError.partialFailure, make the operation atomic (if one record fails to get modified, they all fail) - // If you want to handle partial failures, set .isAtomic to false and implement CKOperationResultType .fail(reason: .partialFailure) where appropriate op.isAtomic = true op.modifyRecordsCompletionBlock = { [weak self] (_, _, error) in @@ -309,7 +398,6 @@ extension CloudKitZone { self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion) } case .limitExceeded: - let chunkedRecords = recordsToSave.chunked(into: 300) let group = DispatchGroup() @@ -343,7 +431,27 @@ extension CloudKitZone { database?.add(op) } - + + /// Fetch all the subscriptions that a user has in the current database in all zones + func fetchAllUserSubscriptions(completion: @escaping (Result<[CKSubscription], Error>) -> Void) { + database?.fetchAllSubscriptions() { subscriptions, error in + switch CloudKitZoneResult.resolve(error) { + case .success: + DispatchQueue.main.async { + completion(.success((subscriptions!))) + } + case .retry(let timeToWait): + self.retryIfPossible(after: timeToWait) { + self.fetchAllUserSubscriptions(completion: completion) + } + default: + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } + } + } + } + /// Fetch all the changes in the CKZone since the last time we checked func fetchChangesInZone(completion: @escaping (Result) -> Void) { From 449085b84a7cf88f873301c6ca5cb57a6c93d9d0 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 5 Apr 2020 11:26:21 -0500 Subject: [PATCH 89/98] Fix bug that was causing duplicate downloads on OPML import. --- Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 37bc811a9..a923b1aa4 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -195,15 +195,13 @@ final class CloudKitAccountDelegate: AccountDelegate { } } -// os_log(.error, log: self.log, "Error while subscribing to the feed: %@", error.localizedDescription) - refreshProgress.addToNumberOfTasksAndRemaining(2) publicZone.manageSubscriptions(webFeedURLs) { result in self.refreshProgress.completeTask() switch result { case .success: self.accountZone.importOPML(rootExternalID: rootExternalID, items: normalizedItems) { _ in - self.refreshAll(for: account, completion: completion) + self.refreshAll(for: account, downloadFeeds: false, completion: completion) } case .failure(let error): completion(.failure(error)) From 116a346b872abf092471951a07d051662c5defe9 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 5 Apr 2020 11:49:29 -0500 Subject: [PATCH 90/98] Make the remove feed process manage the progress indicator better. --- .../Account/CloudKit/CloudKitAccountDelegate.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index a923b1aa4..6317434d5 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -302,14 +302,22 @@ final class CloudKitAccountDelegate: AccountDelegate { } func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result) -> Void) { - refreshProgress.addToNumberOfTasksAndRemaining(1) + refreshProgress.addToNumberOfTasksAndRemaining(2) accountZone.removeWebFeed(feed, from: container) { result in self.refreshProgress.completeTask() switch result { case .success(let deleted): container.removeWebFeed(feed) if deleted { - self.publicZone.manageSubscriptions(account.flattenedWebFeedURLs, completion: completion) + self.publicZone.manageSubscriptions(account.flattenedWebFeedURLs) { result in + self.refreshProgress.completeTask() + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } } else { completion(.success(())) } From bcfd75ff68b0ac941046862c1cde9c5d358dcec4 Mon Sep 17 00:00:00 2001 From: zgjie Date: Mon, 6 Apr 2020 02:06:24 +0800 Subject: [PATCH 91/98] Replace the `firstElementPassingTest` function come from RSCore with the native function `first(where:)`. --- .../Account/Feedly/OAuthAuthorizationCodeGranting.swift | 4 ++-- Mac/MainWindow/Timeline/Cell/SingleLineTextFieldSizer.swift | 2 +- iOS/Article/OpenInSafariActivity.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift b/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift index 735f33bff..836989e78 100644 --- a/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift +++ b/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift @@ -70,12 +70,12 @@ public extension OAuthAuthorizationResponse { guard let queryItems = components.queryItems, !queryItems.isEmpty else { throw URLError(.unsupportedURL) } - let code = queryItems.firstElementPassingTest { $0.name.lowercased() == "code" } + let code = queryItems.first { $0.name.lowercased() == "code" } guard let codeValue = code?.value, !codeValue.isEmpty else { throw URLError(.unsupportedURL) } - let state = queryItems.firstElementPassingTest { $0.name.lowercased() == "state" } + let state = queryItems.first { $0.name.lowercased() == "state" } let stateValue = state?.value self.init(code: codeValue, state: stateValue) diff --git a/Mac/MainWindow/Timeline/Cell/SingleLineTextFieldSizer.swift b/Mac/MainWindow/Timeline/Cell/SingleLineTextFieldSizer.swift index ed18581e9..e40db7235 100644 --- a/Mac/MainWindow/Timeline/Cell/SingleLineTextFieldSizer.swift +++ b/Mac/MainWindow/Timeline/Cell/SingleLineTextFieldSizer.swift @@ -53,7 +53,7 @@ final class SingleLineTextFieldSizer { // that members of such a dictionary were mutated after insertion. // We use just an array of sizers now — which is totally fine, // because there’s only going to be like three of them. - if let cachedSizer = sizers.firstElementPassingTest({ $0.font == font }) { + if let cachedSizer = sizers.first(where: { $0.font == font }) { return cachedSizer } diff --git a/iOS/Article/OpenInSafariActivity.swift b/iOS/Article/OpenInSafariActivity.swift index a157345fb..9b5b5ed8f 100644 --- a/iOS/Article/OpenInSafariActivity.swift +++ b/iOS/Article/OpenInSafariActivity.swift @@ -37,7 +37,7 @@ class OpenInSafariActivity: UIActivity { } override func perform() { - guard let url = activityItems?.firstElementPassingTest({ $0 is URL }) as? URL else { + guard let url = activityItems?.first(where: { $0 is URL }) as? URL else { activityDidFinish(false) return } From fb807809d7b628b6c3d33a907575eef690087529 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 5 Apr 2020 13:25:28 -0500 Subject: [PATCH 92/98] Update to latest RSCore --- submodules/RSCore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/RSCore b/submodules/RSCore index a175db500..a742db73c 160000 --- a/submodules/RSCore +++ b/submodules/RSCore @@ -1 +1 @@ -Subproject commit a175db5009f8222fcbaa825d9501305e8727da6f +Subproject commit a742db73c4f4007f0d7097746c88ce2074400045 From 390173dcb485fba72aa20f4e31abb294e5dfdff7 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 5 Apr 2020 15:05:08 -0500 Subject: [PATCH 93/98] Remove centralized CloudKit syncing code. --- .../Account/Account.xcodeproj/project.pbxproj | 8 -- .../CloudKit/CKContainer+Extensions.swift | 41 ------- .../CloudKit/CloudKitAccountDelegate.swift | 47 ++------ .../Account/CloudKit/CloudKitPublicZone.swift | 105 ------------------ .../Account/CloudKit/CloudKitZone.swift | 46 -------- 5 files changed, 9 insertions(+), 238 deletions(-) delete mode 100644 Frameworks/Account/CloudKit/CKContainer+Extensions.swift delete mode 100644 Frameworks/Account/CloudKit/CloudKitPublicZone.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index e958c324f..38efd6984 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -42,7 +42,6 @@ 5144EA49227B497600D19003 /* FeedbinAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA48227B497600D19003 /* FeedbinAPICaller.swift */; }; 5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA4D227B829A00D19003 /* FeedbinAccountDelegate.swift */; }; 514BF5202391B0DB00902FE8 /* SingleArticleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 514BF51F2391B0DB00902FE8 /* SingleArticleFetcher.swift */; }; - 515000002438682300C1A442 /* CloudKitPublicZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5150FFFF2438682300C1A442 /* CloudKitPublicZone.swift */; }; 5150FFFE243823B800C1A442 /* CloudKitError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5150FFFD243823B800C1A442 /* CloudKitError.swift */; }; 5154367B228EEB28005E1CDF /* FeedbinImportResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */; }; 515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515E4EB22324FF8C0057B0E7 /* CredentialsManager.swift */; }; @@ -60,7 +59,6 @@ 519E84A62433D49000D238B0 /* OPMLNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */; }; 519E84A82434C5EF00D238B0 /* CloudKitArticlesZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */; }; 519E84AC2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */; }; - 51B544672438F410003F03BF /* CKContainer+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B544662438F410003F03BF /* CKContainer+Extensions.swift */; }; 51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */; }; 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC8FCB237EC055004F8B56 /* Feed.swift */; }; 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */; }; @@ -280,7 +278,6 @@ 5144EA4D227B829A00D19003 /* FeedbinAccountDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinAccountDelegate.swift; sourceTree = ""; }; 514BF51F2391B0DB00902FE8 /* SingleArticleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleArticleFetcher.swift; sourceTree = ""; }; 5150FFFD243823B800C1A442 /* CloudKitError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitError.swift; sourceTree = ""; }; - 5150FFFF2438682300C1A442 /* CloudKitPublicZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitPublicZone.swift; sourceTree = ""; }; 5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinImportResult.swift; sourceTree = ""; }; 515E4EB22324FF8C0057B0E7 /* CredentialsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CredentialsManager.swift; sourceTree = ""; }; 515E4EB32324FF8C0057B0E7 /* URLRequest+RSWeb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URLRequest+RSWeb.swift"; sourceTree = ""; }; @@ -298,7 +295,6 @@ 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLNormalizer.swift; sourceTree = ""; }; 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZone.swift; sourceTree = ""; }; 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZoneDelegate.swift; sourceTree = ""; }; - 51B544662438F410003F03BF /* CKContainer+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKContainer+Extensions.swift"; sourceTree = ""; }; 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBehaviors.swift; sourceTree = ""; }; 51BC8FCB237EC055004F8B56 /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = ""; }; 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerIdentifier.swift; sourceTree = ""; }; @@ -525,7 +521,6 @@ 5103A9D7242253DC00410853 /* CloudKit */ = { isa = PBXGroup; children = ( - 51B544662438F410003F03BF /* CKContainer+Extensions.swift */, 512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */, 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */, 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */, @@ -533,7 +528,6 @@ 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */, 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */, 5150FFFD243823B800C1A442 /* CloudKitError.swift */, - 5150FFFF2438682300C1A442 /* CloudKitPublicZone.swift */, 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */, 51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */, ); @@ -1116,7 +1110,6 @@ 84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */, 841974251F6DDCE4006346C4 /* AccountDelegate.swift in Sources */, 510BD113232C3E9D002692E4 /* WebFeedMetadataFile.swift in Sources */, - 515000002438682300C1A442 /* CloudKitPublicZone.swift in Sources */, 5103A9D92422546800410853 /* CloudKitAccountDelegate.swift in Sources */, 5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */, 9E784EBE237E890600099B1B /* FeedlyLogoutOperation.swift in Sources */, @@ -1159,7 +1152,6 @@ 84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */, 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */, 846E77501F6EF9C400A165E2 /* LocalAccountRefresher.swift in Sources */, - 51B544672438F410003F03BF /* CKContainer+Extensions.swift in Sources */, 9EA643CF2391D3560018A28C /* FeedlyAddExistingFeedOperation.swift in Sources */, 55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */, 9E1D154F233371DD00F4944C /* FeedlyGetCollectionsOperation.swift in Sources */, diff --git a/Frameworks/Account/CloudKit/CKContainer+Extensions.swift b/Frameworks/Account/CloudKit/CKContainer+Extensions.swift deleted file mode 100644 index afa27e410..000000000 --- a/Frameworks/Account/CloudKit/CKContainer+Extensions.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// CKContainer+Extensions.swift -// Account -// -// Created by Maurice Parker on 4/4/20. -// Copyright © 2020 Ranchero Software, LLC. All rights reserved. -// - -import Foundation -import CloudKit - -extension CKContainer { - - private static let userRecordIDKey = "cloudkit.server.userRecordID" - - var userRecordID: String? { - get { - return UserDefaults.standard.string(forKey: Self.userRecordIDKey) - } - set { - guard let userRecordID = newValue else { - UserDefaults.standard.removeObject(forKey: Self.userRecordIDKey) - return - } - UserDefaults.standard.set(userRecordID, forKey: Self.userRecordIDKey) - } - } - - func fetchUserRecordID() { - guard userRecordID == nil else { return } - fetchUserRecordID { recordID, error in - guard let recordID = recordID, error == nil else { - return - } - DispatchQueue.main.async { - self.userRecordID = recordID.recordName - } - } - } - -} diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 6317434d5..407e4e31c 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -30,10 +30,9 @@ final class CloudKitAccountDelegate: AccountDelegate { return CKContainer(identifier: "iCloud.\(orgID).NetNewsWire") }() - private lazy var zones: [CloudKitZone] = [accountZone, articlesZone, publicZone] + private lazy var zones: [CloudKitZone] = [accountZone, articlesZone] private let accountZone: CloudKitAccountZone private let articlesZone: CloudKitArticlesZone - private let publicZone: CloudKitPublicZone private let refresher = LocalAccountRefresher() @@ -49,7 +48,6 @@ final class CloudKitAccountDelegate: AccountDelegate { init(dataFolder: String) { accountZone = CloudKitAccountZone(container: container) articlesZone = CloudKitArticlesZone(container: container) - publicZone = CloudKitPublicZone(container: container) let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3") database = SyncDatabase(databaseFilePath: databaseFilePath) @@ -195,17 +193,10 @@ final class CloudKitAccountDelegate: AccountDelegate { } } - refreshProgress.addToNumberOfTasksAndRemaining(2) - publicZone.manageSubscriptions(webFeedURLs) { result in - self.refreshProgress.completeTask() - switch result { - case .success: - self.accountZone.importOPML(rootExternalID: rootExternalID, items: normalizedItems) { _ in - self.refreshAll(for: account, downloadFeeds: false, completion: completion) - } - case .failure(let error): - completion(.failure(error)) - } + // Add one task here to show we started immediately. We don't need to complete is because refreshAll clears everything at the end. + refreshProgress.addToNumberOfTasksAndRemaining(1) + self.accountZone.importOPML(rootExternalID: rootExternalID, items: normalizedItems) { _ in + self.refreshAll(for: account, downloadFeeds: false, completion: completion) } } @@ -219,7 +210,7 @@ final class CloudKitAccountDelegate: AccountDelegate { } BatchUpdate.shared.start() - refreshProgress.addToNumberOfTasksAndRemaining(4) + refreshProgress.addToNumberOfTasksAndRemaining(3) FeedFinder.find(url: url) { result in self.refreshProgress.completeTask() @@ -250,13 +241,6 @@ final class CloudKitAccountDelegate: AccountDelegate { feed.externalID = externalID container.addWebFeed(feed) - self.publicZone.manageSubscriptions(account.flattenedWebFeedURLs) { result in - self.refreshProgress.completeTask() - if case .failure(let error) = result { - os_log(.error, log: self.log, "An error occurred while creating the subscription: %@", error.localizedDescription) - } - } - InitialFeedDownloader.download(url) { parsedFeed in self.refreshProgress.completeTask() @@ -302,25 +286,13 @@ final class CloudKitAccountDelegate: AccountDelegate { } func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result) -> Void) { - refreshProgress.addToNumberOfTasksAndRemaining(2) + refreshProgress.addToNumberOfTasksAndRemaining(1) accountZone.removeWebFeed(feed, from: container) { result in self.refreshProgress.completeTask() switch result { - case .success(let deleted): + case .success: container.removeWebFeed(feed) - if deleted { - self.publicZone.manageSubscriptions(account.flattenedWebFeedURLs) { result in - self.refreshProgress.completeTask() - switch result { - case .success: - completion(.success(())) - case .failure(let error): - completion(.failure(error)) - } - } - } else { - completion(.success(())) - } + completion(.success(())) case .failure(let error): completion(.failure(error)) } @@ -484,7 +456,6 @@ final class CloudKitAccountDelegate: AccountDelegate { // Check to see if this is a new account and initialize anything we need if account.externalID == nil { - container.fetchUserRecordID() accountZone.findOrCreateAccount() { result in switch result { case .success(let externalID): diff --git a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift b/Frameworks/Account/CloudKit/CloudKitPublicZone.swift deleted file mode 100644 index 05ef0d7d7..000000000 --- a/Frameworks/Account/CloudKit/CloudKitPublicZone.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// CloudKitPublicZone.swift -// Account -// -// Created by Maurice Parker on 4/4/20. -// Copyright © 2020 Ranchero Software, LLC. All rights reserved. -// - -import Foundation -import CloudKit -import os.log - -final class CloudKitPublicZone: CloudKitZone { - - static var zoneID: CKRecordZone.ID { - return CKRecordZone.default().zoneID - } - - var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") - - weak var container: CKContainer? - weak var database: CKDatabase? - var delegate: CloudKitZoneDelegate? - - struct CloudKitWebFeed { - static let recordType = "WebFeed" - struct Fields { - static let url = "url" - static let httpLastModified = "httpLastModified" - static let httpEtag = "httpEtag" - } - } - - struct CloudKitWebFeedCheck { - static let recordType = "WebFeedCheck" - struct Fields { - static let webFeed = "webFeed" - static let lastCheck = "lastCheck" - } - } - - struct CloudKitUserSubscription { - static let recordType = "UserSubscription" - struct Fields { - static let userRecordID = "userRecordID" - static let webFeed = "webFeed" - static let subscriptionID = "subscriptionID" - } - } - - init(container: CKContainer) { - self.container = container - self.database = container.publicCloudDatabase - } - - func subscribeToZoneChanges() {} - - func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { - completion() - } - - /// Create any new subscriptions and delete any old ones - func manageSubscriptions(_ webFeedURLs: Set, completion: @escaping (Result) -> Void) { - - var webFeedRecords = [CKRecord]() - for webFeedURL in webFeedURLs { - let webFeedRecordID = CKRecord.ID(recordName: webFeedURL.md5String, zoneID: Self.zoneID) - let webFeedRecord = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: webFeedRecordID) - webFeedRecord[CloudKitWebFeed.Fields.url] = webFeedURL - webFeedRecord[CloudKitWebFeed.Fields.httpLastModified] = "" - webFeedRecord[CloudKitWebFeed.Fields.httpEtag] = "" - webFeedRecords.append(webFeedRecord) - } - - self.saveIfNew(webFeedRecords) { _ in - - var subscriptions = [CKSubscription]() - let webFeedURLChunks = Array(webFeedURLs).chunked(into: 20) - for webFeedURLChunk in webFeedURLChunks { - - let predicate = NSPredicate(format: "url in %@", webFeedURLChunk) - let subscription = CKQuerySubscription(recordType: CloudKitWebFeed.recordType, predicate: predicate, options: [.firesOnRecordUpdate]) - let info = CKSubscription.NotificationInfo() - info.shouldSendContentAvailable = true - info.desiredKeys = [CloudKitWebFeed.Fields.httpLastModified, CloudKitWebFeed.Fields.httpEtag] - subscription.notificationInfo = info - subscriptions.append(subscription) - - } - - self.fetchAllUserSubscriptions() { result in - switch result { - case .success(let subscriptionsToDelete): - let subscriptionToDeleteIDs = subscriptionsToDelete.map({ $0.subscriptionID }) - self.modify(subscriptionsToSave: subscriptions, subscriptionIDsToDelete: subscriptionToDeleteIDs, completion: completion) - case .failure(let error): - completion(.failure(error)) - } - } - - } - - } - -} diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index bf18652e3..fb7a3269f 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -337,32 +337,6 @@ extension CloudKitZone { } } - /// Bulk add (or modify I suppose) and delete of subscriptions - func modify(subscriptionsToSave: [CKSubscription], subscriptionIDsToDelete: [CKSubscription.ID], completion: @escaping (Result) -> Void) { - let op = CKModifySubscriptionsOperation(subscriptionsToSave: subscriptionsToSave, subscriptionIDsToDelete: subscriptionIDsToDelete) - - op.modifySubscriptionsCompletionBlock = { [weak self] (_, _, error) in - guard let self = self else { return } - - switch CloudKitZoneResult.resolve(error) { - case .success: - DispatchQueue.main.async { - completion(.success(())) - } - case .retry(let timeToWait): - self.retryIfPossible(after: timeToWait) { - self.modify(subscriptionsToSave: subscriptionsToSave, subscriptionIDsToDelete: subscriptionIDsToDelete, completion: completion) - } - default: - DispatchQueue.main.async { - completion(.failure(CloudKitError(error!))) - } - } - } - - database?.add(op) - } - /// Modify and delete the supplied CKRecords and CKRecord.IDs func modify(recordsToSave: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result) -> Void) { let op = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete) @@ -432,26 +406,6 @@ extension CloudKitZone { database?.add(op) } - /// Fetch all the subscriptions that a user has in the current database in all zones - func fetchAllUserSubscriptions(completion: @escaping (Result<[CKSubscription], Error>) -> Void) { - database?.fetchAllSubscriptions() { subscriptions, error in - switch CloudKitZoneResult.resolve(error) { - case .success: - DispatchQueue.main.async { - completion(.success((subscriptions!))) - } - case .retry(let timeToWait): - self.retryIfPossible(after: timeToWait) { - self.fetchAllUserSubscriptions(completion: completion) - } - default: - DispatchQueue.main.async { - completion(.failure(CloudKitError(error!))) - } - } - } - } - /// Fetch all the changes in the CKZone since the last time we checked func fetchChangesInZone(completion: @escaping (Result) -> Void) { From bada18a41215d9de28dc0fd784c5772662913166 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 5 Apr 2020 15:23:11 -0500 Subject: [PATCH 94/98] Make sure a scheduled refresh can't stack on top on one happening right for CloudKit only. --- Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 407e4e31c..f01b94c93 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -72,6 +72,10 @@ final class CloudKitAccountDelegate: AccountDelegate { } func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { + guard refreshProgress.isComplete else { + completion(.success(())) + return + } refreshAll(for: account, downloadFeeds: true, completion: completion) } From 2ec56b52fd60cba7fc81a806ebf6f1df192c32d5 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 5 Apr 2020 19:30:25 -0500 Subject: [PATCH 95/98] Remove broken code that was slowing down application quitting. --- Mac/AppDelegate.swift | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index 96a278b52..09e6682ab 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -292,9 +292,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, } func applicationDidResignActive(_ notification: Notification) { - ArticleStringFormatter.emptyCaches() - saveState() } @@ -305,28 +303,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, func applicationWillTerminate(_ notification: Notification) { shuttingDown = true saveState() - - let group = DispatchGroup() - - group.enter() - AccountManager.shared.syncArticleStatusAll() { - group.leave() - } - - let timeout = DispatchTime.now() + .seconds(1) - _ = group.wait(timeout: timeout) } // MARK: Notifications @objc func unreadCountDidChange(_ note: Notification) { - if note.object is AccountManager { unreadCount = AccountManager.shared.unreadCount } } @objc func webFeedSettingDidChange(_ note: Notification) { - guard let feed = note.object as? WebFeed, let key = note.userInfo?[WebFeed.WebFeedSettingUserInfoKey] as? String else { return } @@ -336,7 +322,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, } @objc func inspectableObjectsDidChange(_ note: Notification) { - guard let inspectorWindowController = inspectorWindowController, inspectorWindowController.isOpen else { return } From 6364539608e594750c1805b5707447b61ac8f4e3 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Mon, 6 Apr 2020 02:15:28 -0500 Subject: [PATCH 96/98] Handle edge case where the user deletes the iCloud data. --- .../CloudKit/CloudKitAccountDelegate.swift | 26 +++- .../CloudKit/CloudKitAccountZone.swift | 34 +++++ .../CloudKit/CloudKitArticlesZone.swift | 48 ++++++- .../Account/CloudKit/CloudKitZone.swift | 121 ++++++++++++------ 4 files changed, 186 insertions(+), 43 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index f01b94c93..efd921396 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -103,6 +103,7 @@ final class CloudKitAccountDelegate: AccountDelegate { completion(.success(())) case .failure(let error): self.database.resetSelectedForProcessing(syncStatuses.map({ $0.articleID }) ) + self.processAccountError(account, error) completion(.failure(error)) } } @@ -133,7 +134,7 @@ final class CloudKitAccountDelegate: AccountDelegate { func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { os_log(.debug, log: log, "Refreshing article statuses...") - articlesZone.fetchChangesInZone() { result in + articlesZone.refreshArticleStatus() { result in os_log(.debug, log: self.log, "Done refreshing article statuses.") switch result { case .success: @@ -284,6 +285,7 @@ final class CloudKitAccountDelegate: AccountDelegate { feed.editedName = name completion(.success(())) case .failure(let error): + self.processAccountError(account, error) completion(.failure(error)) } } @@ -298,6 +300,7 @@ final class CloudKitAccountDelegate: AccountDelegate { container.removeWebFeed(feed) completion(.success(())) case .failure(let error): + self.processAccountError(account, error) completion(.failure(error)) } } @@ -313,6 +316,7 @@ final class CloudKitAccountDelegate: AccountDelegate { toContainer.addWebFeed(feed) completion(.success(())) case .failure(let error): + self.processAccountError(account, error) completion(.failure(error)) } } @@ -327,6 +331,7 @@ final class CloudKitAccountDelegate: AccountDelegate { container.addWebFeed(feed) completion(.success(())) case .failure(let error): + self.processAccountError(account, error) completion(.failure(error)) } } @@ -342,6 +347,7 @@ final class CloudKitAccountDelegate: AccountDelegate { container.addWebFeed(feed) completion(.success(())) case .failure(let error): + self.processAccountError(account, error) completion(.failure(error)) } } @@ -360,6 +366,7 @@ final class CloudKitAccountDelegate: AccountDelegate { completion(.failure(FeedbinAccountDelegateError.invalidParameter)) } case .failure(let error): + self.processAccountError(account, error) completion(.failure(error)) } } @@ -374,6 +381,7 @@ final class CloudKitAccountDelegate: AccountDelegate { folder.name = name completion(.success(())) case .failure(let error): + self.processAccountError(account, error) completion(.failure(error)) } } @@ -388,6 +396,7 @@ final class CloudKitAccountDelegate: AccountDelegate { account.removeFolder(folder) completion(.success(())) case .failure(let error): + self.processAccountError(account, error) completion(.failure(error)) } } @@ -434,6 +443,7 @@ final class CloudKitAccountDelegate: AccountDelegate { } case .failure(let error): + self.processAccountError(account, error) completion(.failure(error)) } } @@ -548,21 +558,35 @@ private extension CloudKitAccountDelegate { } case .failure(let error): + self.processAccountError(account, error) + self.refreshProgress.clear() completion(.failure(error)) } } case .failure(let error): + self.processAccountError(account, error) + self.refreshProgress.clear() completion(.failure(error)) } } case .failure(let error): + self.processAccountError(account, error) self.refreshProgress.clear() completion(.failure(error)) } } } + func processAccountError(_ account: Account, _ error: Error) { + if case CloudKitZoneError.userDeletedZone = error { + account.removeFeeds(account.topLevelWebFeeds) + for folder in account.folders ?? Set() { + account.removeFolder(folder) + } + } + } + } diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index d46e1e8ea..e9d73bcff 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -219,6 +219,40 @@ final class CloudKitAccountZone: CloudKitZone { let predicate = NSPredicate(format: "isAccount = \"1\"") let ckQuery = CKQuery(recordType: CloudKitContainer.recordType, predicate: predicate) + database?.perform(ckQuery, inZoneWith: Self.zoneID) { [weak self] records, error in + guard let self = self else { return } + + 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) + } + case .zoneNotFound, .userDeletedZone: + self.createZoneRecord() { result in + switch result { + case .success: + self.findOrCreateAccount(completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(CloudKitError(error))) + } + } + } + default: + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } + } + } + query(ckQuery) { result in switch result { case .success(let records): diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift index 411684452..080799ed8 100644 --- a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift @@ -59,19 +59,63 @@ final class CloudKitArticlesZone: CloudKitZone { self.database = container.privateCloudDatabase } + func refreshArticleStatus(completion: @escaping ((Result) -> Void)) { + fetchChangesInZone() { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + if case CloudKitZoneError.userDeletedZone = error { + self.createZoneRecord() { result in + switch result { + case .success: + self.refreshArticleStatus(completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + } else { + completion(.failure(error)) + } + } + } + } + func sendArticleStatus(_ syncStatuses: [SyncStatus], starredArticles: Set
, completion: @escaping ((Result) -> Void)) { var records = makeStatusRecords(syncStatuses) makeArticleRecordsIfNecessary(starredArticles) { result in switch result { case .success(let articleRecords): records.append(contentsOf: articleRecords) - self.modify(recordsToSave: records, recordIDsToDelete: [], completion: completion) + self.modify(recordsToSave: records, recordIDsToDelete: []) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + self.handleSendArticleStatusError(error, syncStatuses: syncStatuses, starredArticles: starredArticles, completion: completion) + } + } case .failure(let error): - completion(.failure(error)) + self.handleSendArticleStatusError(error, syncStatuses: syncStatuses, starredArticles: starredArticles, completion: completion) } } } + func handleSendArticleStatusError(_ error: Error, syncStatuses: [SyncStatus], starredArticles: Set
, completion: @escaping ((Result) -> Void)) { + if case CloudKitZoneError.userDeletedZone = error { + self.createZoneRecord() { result in + switch result { + case .success: + self.sendArticleStatus(syncStatuses, starredArticles: starredArticles, completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + } else { + completion(.failure(error)) + } + } + } private extension CloudKitArticlesZone { diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index fb7a3269f..ee3f45474 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -16,7 +16,11 @@ enum CloudKitZoneError: LocalizedError { case unknown var errorDescription: String? { - return NSLocalizedString("An unexpected CloudKit error occurred.", comment: "An unexpected CloudKit error occurred.") + if case .userDeletedZone = self { + return NSLocalizedString("The iCloud data was deleted. Please delete the NetNewsWire iCloud account and add it again to continue using NetNewsWire's iCloud support.", comment: "User deleted zone.") + } else { + return NSLocalizedString("An unexpected CloudKit error occurred.", comment: "An unexpected CloudKit error occurred.") + } } } @@ -63,19 +67,12 @@ extension CloudKitZone { return CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID) } - func subscribeToZoneChanges() { - let subscription = CKRecordZoneSubscription(zoneID: Self.zoneID) - - let info = CKSubscription.NotificationInfo() - info.shouldSendContentAvailable = true - subscription.notificationInfo = info - - save(subscription) { result in - if case .failure(let error) = result { - os_log(.error, log: self.log, "%@ zone subscribe to changes error: %@", Self.zoneID.zoneName, error.localizedDescription) - } - } - } + func retryIfPossible(after: Double, block: @escaping () -> ()) { + let delayTime = DispatchTime.now() + after + DispatchQueue.main.asyncAfter(deadline: delayTime, execute: { + block() + }) + } func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { let note = CKRecordZoneNotification(fromRemoteNotificationDictionary: userInfo) @@ -91,6 +88,39 @@ extension CloudKitZone { completion() } } + + func createZoneRecord(completion: @escaping (Result) -> Void) { + guard let database = database else { + completion(.failure(CloudKitZoneError.unknown)) + return + } + + database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in + if let error = error { + DispatchQueue.main.async { + completion(.failure(CloudKitError(error))) + } + } else { + DispatchQueue.main.async { + completion(.success(())) + } + } + } + } + + func subscribeToZoneChanges() { + let subscription = CKRecordZoneSubscription(zoneID: Self.zoneID) + + let info = CKSubscription.NotificationInfo() + info.shouldSendContentAvailable = true + subscription.notificationInfo = info + + save(subscription) { result in + if case .failure(let error) = result { + os_log(.error, log: self.log, "%@ zone subscribe to changes error: %@", Self.zoneID.zoneName, error.localizedDescription) + } + } + } /// Checks to see if the record described in the query exists by retrieving only the testField parameter field. func exists(_ query: CKQuery, completion: @escaping (Result) -> Void) { @@ -108,10 +138,25 @@ extension CloudKitZone { DispatchQueue.main.async { completion(.success(recordFound)) } + case .zoneNotFound: + self?.createZoneRecord() { result in + switch result { + case .success: + self?.exists(query, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } case .retry(let timeToWait): self?.retryIfPossible(after: timeToWait) { self?.exists(query, completion: completion) } + case .userDeletedZone: + DispatchQueue.main.async { + completion(.failure(CloudKitZoneError.userDeletedZone)) + } default: DispatchQueue.main.async { completion(.failure(CloudKitError(error!))) @@ -139,6 +184,17 @@ extension CloudKitZone { completion(.failure(CloudKitZoneError.unknown)) } } + case .zoneNotFound: + self?.createZoneRecord() { result in + switch result { + case .success: + self?.query(query, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } case .retry(let timeToWait): self?.retryIfPossible(after: timeToWait) { self?.query(query, completion: completion) @@ -174,6 +230,17 @@ extension CloudKitZone { completion(.failure(CloudKitZoneError.unknown)) } } + case .zoneNotFound: + self?.createZoneRecord() { result in + switch result { + case .success: + self?.fetch(externalID: externalID, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } case .retry(let timeToWait): self?.retryIfPossible(after: timeToWait) { self?.fetch(externalID: externalID, completion: completion) @@ -533,30 +600,4 @@ private extension CloudKitZone { return config } - func createZoneRecord(completion: @escaping (Result) -> Void) { - guard let database = database else { - completion(.failure(CloudKitZoneError.unknown)) - return - } - - database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in - if let error = error { - DispatchQueue.main.async { - completion(.failure(CloudKitError(error))) - } - } else { - DispatchQueue.main.async { - completion(.success(())) - } - } - } - } - - func retryIfPossible(after: Double, block: @escaping () -> ()) { - let delayTime = DispatchTime.now() + after - DispatchQueue.main.asyncAfter(deadline: delayTime, execute: { - block() - }) - } - } From a4c9a4b65f673735996f5e67404e720ff4491954 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Mon, 6 Apr 2020 02:18:42 -0500 Subject: [PATCH 97/98] Remove bad download progress indicator tick. --- Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index efd921396..d6f03a63e 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -198,8 +198,6 @@ final class CloudKitAccountDelegate: AccountDelegate { } } - // Add one task here to show we started immediately. We don't need to complete is because refreshAll clears everything at the end. - refreshProgress.addToNumberOfTasksAndRemaining(1) self.accountZone.importOPML(rootExternalID: rootExternalID, items: normalizedItems) { _ in self.refreshAll(for: account, downloadFeeds: false, completion: completion) } From f0ec7c5e196c60284f2162a3a38d51f72ab5a3c6 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Mon, 6 Apr 2020 08:47:01 -0500 Subject: [PATCH 98/98] Fix problem where back swiping wouldn't work anymore for full screen. Issue #1970 --- .../InteractiveNavigationController.swift | 3 +- .../PoppableGestureRecognizerDelegate.swift | 28 ++++--------------- 2 files changed, 7 insertions(+), 24 deletions(-) diff --git a/iOS/UIKit Extensions/InteractiveNavigationController.swift b/iOS/UIKit Extensions/InteractiveNavigationController.swift index 6c5da6628..eb939fc3d 100644 --- a/iOS/UIKit Extensions/InteractiveNavigationController.swift +++ b/iOS/UIKit Extensions/InteractiveNavigationController.swift @@ -26,7 +26,6 @@ class InteractiveNavigationController: UINavigationController { override func viewDidLoad() { super.viewDidLoad() - poppableDelegate.originalDelegate = interactivePopGestureRecognizer?.delegate poppableDelegate.navigationController = self interactivePopGestureRecognizer?.delegate = poppableDelegate } @@ -38,7 +37,7 @@ class InteractiveNavigationController: UINavigationController { } } - } +} // MARK: Private diff --git a/iOS/UIKit Extensions/PoppableGestureRecognizerDelegate.swift b/iOS/UIKit Extensions/PoppableGestureRecognizerDelegate.swift index 7d32ce2b5..fa9cda81e 100644 --- a/iOS/UIKit Extensions/PoppableGestureRecognizerDelegate.swift +++ b/iOS/UIKit Extensions/PoppableGestureRecognizerDelegate.swift @@ -5,36 +5,20 @@ // Created by Maurice Parker on 11/18/19. // Copyright © 2019 Ranchero Software. All rights reserved. // -// https://stackoverflow.com/a/38042863 +// https://stackoverflow.com/a/41248703 import UIKit final class PoppableGestureRecognizerDelegate: NSObject, UIGestureRecognizerDelegate { weak var navigationController: UINavigationController? - weak var originalDelegate: UIGestureRecognizerDelegate? - override func responds(to aSelector: Selector!) -> Bool { - if aSelector == #selector(gestureRecognizer(_:shouldReceive:)) { - return true - } else if let responds = originalDelegate?.responds(to: aSelector) { - return responds - } else { - return false - } + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + return navigationController?.viewControllers.count ?? 0 > 1 } - override func forwardingTarget(for aSelector: Selector!) -> Any? { - return originalDelegate - } - - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - if let nav = navigationController, nav.isNavigationBarHidden, nav.viewControllers.count > 1 { - return true - } else if let result = originalDelegate?.gestureRecognizer?(gestureRecognizer, shouldReceive: touch) { - return result - } else { - return false - } + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return true } + }