From 6ce82fc28bd705cfd7d80d700b02233388d83e2c Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sun, 29 Mar 2020 03:43:20 -0500 Subject: [PATCH] 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])