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]()