Implement CloudKit feed add.

This commit is contained in:
Maurice Parker 2020-03-29 03:43:20 -05:00
parent ecc20ad9e3
commit 6ce82fc28b
7 changed files with 138 additions and 44 deletions

View File

@ -545,7 +545,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
let feed = WebFeed(account: self, url: url, metadata: metadata) let feed = WebFeed(account: self, url: url, metadata: metadata)
feed.name = name feed.name = name
feed.homePageURL = homePageURL feed.homePageURL = homePageURL
return feed return feed
} }
@ -683,7 +682,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
} }
func update(_ webFeed: WebFeed, with parsedFeed: ParsedFeed, _ completion: @escaping DatabaseCompletionBlock) { 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) webFeed.takeSettings(from: parsedFeed)
let webFeedIDsAndItems = [webFeed.webFeedID: parsedFeed.items] let webFeedIDsAndItems = [webFeed.webFeedID: parsedFeed.items]
update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: false, completion: completion) update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: false, completion: completion)

View File

@ -56,7 +56,7 @@
51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */; }; 51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */; };
51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC8FCB237EC055004F8B56 /* Feed.swift */; }; 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC8FCB237EC055004F8B56 /* Feed.swift */; };
51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BFDECD238B508D00216323 /* ContainerIdentifier.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 */; }; 51C034E1242D660D0014DC71 /* CKError+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C034E0242D660D0014DC71 /* CKError+Extensions.swift */; };
51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D58754227F53BE00900287 /* FeedbinTag.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 */; }; 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 = "<group>"; }; 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBehaviors.swift; sourceTree = "<group>"; };
51BC8FCB237EC055004F8B56 /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = "<group>"; }; 51BC8FCB237EC055004F8B56 /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = "<group>"; };
51BFDECD238B508D00216323 /* ContainerIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerIdentifier.swift; sourceTree = "<group>"; }; 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerIdentifier.swift; sourceTree = "<group>"; };
51C034DE242D65D20014DC71 /* CloudKitResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitResult.swift; sourceTree = "<group>"; }; 51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitZoneResult.swift; sourceTree = "<group>"; };
51C034E0242D660D0014DC71 /* CKError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKError+Extensions.swift"; sourceTree = "<group>"; }; 51C034E0242D660D0014DC71 /* CKError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKError+Extensions.swift"; sourceTree = "<group>"; };
51D58754227F53BE00900287 /* FeedbinTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinTag.swift; sourceTree = "<group>"; }; 51D58754227F53BE00900287 /* FeedbinTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinTag.swift; sourceTree = "<group>"; };
51D58757227F630B00900287 /* tags_delete.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_delete.json; sourceTree = "<group>"; }; 51D58757227F630B00900287 /* tags_delete.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_delete.json; sourceTree = "<group>"; };
@ -514,8 +514,8 @@
51C034E0242D660D0014DC71 /* CKError+Extensions.swift */, 51C034E0242D660D0014DC71 /* CKError+Extensions.swift */,
5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */, 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */,
51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */, 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */,
51C034DE242D65D20014DC71 /* CloudKitResult.swift */,
51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */, 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */,
51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */,
); );
path = CloudKit; path = CloudKit;
sourceTree = "<group>"; sourceTree = "<group>";
@ -1184,7 +1184,7 @@
3B826DAC2385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift in Sources */, 3B826DAC2385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift in Sources */,
769F295938E5A30D03DFF88F /* NewsBlurAccountDelegate.swift in Sources */, 769F295938E5A30D03DFF88F /* NewsBlurAccountDelegate.swift in Sources */,
769F2BA02EF5F329CDE45F5A /* NewsBlurAPICaller.swift in Sources */, 769F2BA02EF5F329CDE45F5A /* NewsBlurAPICaller.swift in Sources */,
51C034DF242D65D20014DC71 /* CloudKitResult.swift in Sources */, 51C034DF242D65D20014DC71 /* CloudKitZoneResult.swift in Sources */,
179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */, 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */,
179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */, 179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */,
179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */, 179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */,

View File

@ -45,14 +45,6 @@ final class CloudKitAccountDelegate: AccountDelegate {
return refresher.progress 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) { init(dataFolder: String) {
accountZone = CloudKitAccountZone(container: container) accountZone = CloudKitAccountZone(container: container)
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3") let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3")
@ -140,22 +132,35 @@ final class CloudKitAccountDelegate: AccountDelegate {
return return
} }
let feed = account.createWebFeed(with: nil, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) self.accountZone.createFeed(url: urlString, editedName: name) { result in
switch result {
InitialFeedDownloader.download(url) { parsedFeed in case .success(let externalID):
self.refreshProgress.completeTask()
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 { if let parsedFeed = parsedFeed {
account.update(feed, with: parsedFeed, {_ in}) 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: case .failure:
self.refreshProgress.completeTask() self.refreshProgress.completeTask()
completion(.failure(AccountError.createErrorNotFound)) completion(.failure(AccountError.createErrorNotFound))

View File

@ -9,17 +9,17 @@
import Foundation import Foundation
import CloudKit import CloudKit
enum CloudKitResult { enum CloudKitZoneResult {
case success case success
case retry(afterSeconds: Double) case retry(afterSeconds: Double)
case chunk case limitExceeded
case changeTokenExpired case changeTokenExpired
case partialFailure case partialFailure(errors: [CKRecord.ID: CKError])
case serverRecordChanged case serverRecordChanged
case noZone case noZone
case failure(error: Error) case failure(error: Error)
static func resolve(_ error: Error?) -> CloudKitResult { static func resolve(_ error: Error?) -> CloudKitZoneResult {
guard error != nil else { return .success } guard error != nil else { return .success }
@ -39,11 +39,17 @@ enum CloudKitResult {
case .serverRecordChanged: case .serverRecordChanged:
return .serverRecordChanged return .serverRecordChanged
case .partialFailure: 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: case .limitExceeded:
return .chunk return .limitExceeded
case .zoneNotFound, .userDeletedZone:
return .noZone
default: default:
return .failure(error: error!) 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 } )
}
}

View File

@ -9,6 +9,7 @@
import CloudKit import CloudKit
public enum CloudKitZoneError: Error { public enum CloudKitZoneError: Error {
case userDeletedZone
case unknown case unknown
} }
@ -146,14 +147,13 @@ extension CloudKitZone {
guard let self = self else { return } guard let self = self else { return }
switch CloudKitResult.resolve(error) { switch CloudKitZoneResult.resolve(error) {
case .success: case .success:
DispatchQueue.main.async { DispatchQueue.main.async {
completion(.success(())) completion(.success(()))
} }
case .noZone: case .zoneNotFound:
self.createZoneRecord() { result in self.createZoneRecord() { result in
// TODO: Need to rebuild (push) zone data here...
switch result { switch result {
case .success: case .success:
self.modify(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete, completion: completion) self.modify(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete, completion: completion)
@ -161,11 +161,15 @@ extension CloudKitZone {
completion(.failure(error)) completion(.failure(error))
} }
} }
case .userDeletedZone:
DispatchQueue.main.async {
completion(.failure(CloudKitZoneError.userDeletedZone))
}
case .retry(let timeToWait): case .retry(let timeToWait):
self.retryOperationIfPossible(retryAfter: timeToWait) { self.retryOperationIfPossible(retryAfter: timeToWait) {
self.modify(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete, completion: completion) self.modify(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete, completion: completion)
} }
case .chunk: case .limitExceeded:
/// CloudKit says maximum number of items in a single request is 400. /// CloudKit says maximum number of items in a single request is 400.
/// So I think 300 should be fine by them. /// So I think 300 should be fine by them.
let chunkedRecords = recordsToStore.chunked(into: 300) let chunkedRecords = recordsToStore.chunked(into: 300)
@ -173,7 +177,9 @@ extension CloudKitZone {
self.modify(recordsToStore: chunk, recordIDsToDelete: recordIDsToDelete, completion: completion) self.modify(recordsToStore: chunk, recordIDsToDelete: recordIDsToDelete, completion: completion)
} }
default: default:
return DispatchQueue.main.async {
completion(.failure(error!))
}
} }
} }

View File

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

View File

@ -58,16 +58,12 @@ class AddFeedController: AddFeedWindowControllerDelegate {
return return
} }
BatchUpdate.shared.start()
account.createWebFeed(url: url.absoluteString, name: title, container: container) { result in account.createWebFeed(url: url.absoluteString, name: title, container: container) { result in
DispatchQueue.main.async { DispatchQueue.main.async {
self.endShowingProgress() self.endShowingProgress()
} }
BatchUpdate.shared.end()
switch result { switch result {
case .success(let feed): case .success(let feed):
NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.webFeed: feed]) NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.webFeed: feed])