Enable passing starred articles between devices.

This commit is contained in:
Maurice Parker
2020-04-03 11:25:01 -05:00
parent d6b094b37e
commit f143248e08
5 changed files with 220 additions and 37 deletions

View File

@@ -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<ParsedItem>, 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)

View File

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

View File

@@ -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<Article>, completion: @escaping ((Result<Void, Error>) -> 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<Article>) -> [CKRecord] {
func makeArticleRecordsIfNecessary(_ articles: Set<Article>, 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<ParsedAuthor>()
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
}
}

View File

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

View File

@@ -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<Bool, Error>) -> 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<CKRecord, Error>) -> 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, Error>) -> Void) {
modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion)
}
/// Delete a CKRecord using its externalID
func delete(externalID: String?, completion: @escaping (Result<Void, Error>) -> 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, Error>) -> 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, Error>) -> Void) {
var changedRecords = [CKRecord]()