mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
Enable passing starred articles between devices.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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(()))
|
||||
|
||||
@@ -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]()
|
||||
|
||||
Reference in New Issue
Block a user