diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index c3ad99300..8e802625c 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -511,6 +511,13 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, return delegate.markArticles(for: self, articles: articles, statusKey: statusKey, flag: flag) } + func existingContainer(withExternalID externalID: String) -> Container? { + guard self.externalID != externalID else { + return self + } + return existingFolder(withExternalID: externalID) + } + @discardableResult func ensureFolder(with name: String) -> Folder? { // TODO: support subfolders, maybe, some day @@ -561,10 +568,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, return feed } - public func existingWebFeed(withExternalID externalID: String) -> WebFeed? { - return externalIDToWebFeedDictionary[externalID] - } - public func addWebFeed(_ feed: WebFeed, to container: Container, completion: @escaping (Result) -> Void) { delegate.addWebFeed(for: self, with: feed, to: container, completion: completion) } @@ -1315,6 +1318,11 @@ extension Account { public func existingWebFeed(withWebFeedID webFeedID: String) -> WebFeed? { return idToWebFeedDictionary[webFeedID] } + + public func existingWebFeed(withExternalID externalID: String) -> WebFeed? { + return externalIDToWebFeedDictionary[externalID] + } + } // MARK: - OPMLRepresentable diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 64b98b0e4..4c9959808 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -155,9 +155,9 @@ final class CloudKitAccountDelegate: AccountDelegate { return } - self.accountZone.createWebFeed(url: bestFeedSpecifier.urlString, editedName: name) { result in + self.accountZone.createWebFeed(url: bestFeedSpecifier.urlString, editedName: name, container: container) { result in switch result { - case .success(let externalID): + case .success(let containerWebFeed): let feed = account.createWebFeed(with: nil, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) @@ -168,7 +168,8 @@ final class CloudKitAccountDelegate: AccountDelegate { account.update(feed, with: parsedFeed, {_ in feed.editedName = name - feed.externalID = externalID + feed.externalID = containerWebFeed.webFeedExternalID + feed.folderRelationship?[containerWebFeed.containerWebFeedExternalID] = containerWebFeed.containerExternalID container.addWebFeed(feed) completion(.success(feed)) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index 71c9e558c..d45ab3833 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -13,6 +13,8 @@ import CloudKit final class CloudKitAccountZone: CloudKitZone { + typealias ContainerWebFeed = (webFeedExternalID: String, containerWebFeedExternalID: String, containerExternalID: String) + static var zoneID: CKRecordZone.ID { return CKRecordZone.ID(zoneName: "Account", ownerName: CKCurrentUserDefaultName) } @@ -40,29 +42,51 @@ final class CloudKitAccountZone: CloudKitZone { } } + struct CloudKitContainerWebFeed { + static let recordType = "ContainerWebFeed" + struct Fields { + static let container = "container" + static let webFeed = "webFeed" + } + } + init(container: CKContainer) { self.container = container self.database = container.privateCloudDatabase } /// Persist a web feed record to iCloud and return the external key - func createWebFeed(url: String, editedName: String?, completion: @escaping (Result) -> Void) { - let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: generateRecordID()) - record[CloudKitWebFeed.Fields.url] = url + func createWebFeed(url: String, editedName: String?, container: Container, completion: @escaping (Result) -> Void) { + let webFeedRecord = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: generateRecordID()) + webFeedRecord[CloudKitWebFeed.Fields.url] = url if let editedName = editedName { - record[CloudKitWebFeed.Fields.editedName] = editedName + webFeedRecord[CloudKitWebFeed.Fields.editedName] = editedName } - save(record: record) { result in + guard let containerExternalID = container.externalID else { + completion(.failure(CloudKitZoneError.invalidParameter)) + return + } + + let containerRecordID = CKRecord.ID(recordName: containerExternalID, zoneID: Self.zoneID) + let containerWebFeedRecord = CKRecord(recordType: CloudKitContainerWebFeed.recordType, recordID: generateRecordID()) + containerWebFeedRecord[CloudKitContainerWebFeed.Fields.container] = CKRecord.Reference(recordID: containerRecordID, action: .deleteSelf) + containerWebFeedRecord[CloudKitContainerWebFeed.Fields.webFeed] = CKRecord.Reference(record: webFeedRecord, action: .deleteSelf) + + save([webFeedRecord, containerWebFeedRecord]) { result in switch result { case .success: - completion(.success(record.externalID)) + let cwf = ContainerWebFeed(webFeedExternalID: webFeedRecord.externalID, + containerWebFeedExternalID: containerWebFeedRecord.externalID, + containerExternalID: containerExternalID) + completion(.success(cwf)) case .failure(let error): completion(.failure(error)) } } } + /// Rename the given web feed func renameWebFeed(_ webFeed: WebFeed, editedName: String?, completion: @escaping (Result) -> Void) { guard let externalID = webFeed.externalID else { completion(.failure(CloudKitZoneError.invalidParameter)) @@ -73,7 +97,7 @@ final class CloudKitAccountZone: CloudKitZone { let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: recordID) record[CloudKitWebFeed.Fields.editedName] = editedName - save(record: record) { result in + save([record]) { result in switch result { case .success: completion(.success(())) @@ -116,7 +140,7 @@ final class CloudKitAccountZone: CloudKitZone { let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: recordID) record[CloudKitContainer.Fields.name] = name - save(record: record) { result in + save([record]) { result in switch result { case .success: completion(.success(())) @@ -139,7 +163,7 @@ private extension CloudKitAccountZone { record[CloudKitContainer.Fields.name] = name record[CloudKitContainer.Fields.isAccount] = isAccount ? "true" : "false" - save(record: record) { result in + save([record]) { result in switch result { case .success: completion(.success(record.externalID)) diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift index 391380fde..f100f30c6 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -13,6 +13,9 @@ import CloudKit class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { + private typealias UnclaimedWebFeed = (url: String, editedName: String?) + private var unclaimedWebFeeds = [String: UnclaimedWebFeed]() + private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") weak var account: Account? @@ -29,6 +32,8 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { addOrUpdateWebFeed(record) case CloudKitAccountZone.CloudKitContainer.recordType: addOrUpdateContainer(record) + case CloudKitAccountZone.CloudKitContainerWebFeed.recordType: + addOrUpdateContainerWebFeed(record) default: assertionFailure("Unknown record type: \(record.recordType)") } @@ -37,9 +42,11 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { func cloudKitDidDelete(recordType: CKRecord.RecordType, recordID: CKRecord.ID) { switch recordType { case CloudKitAccountZone.CloudKitWebFeed.recordType: - removeWebFeed(recordID.externalID) + break case CloudKitAccountZone.CloudKitContainer.recordType: removeContainer(recordID.externalID) + case CloudKitAccountZone.CloudKitContainerWebFeed.recordType: + removeContainerWebFeed(recordID.externalID) default: assertionFailure("Unknown record type: \(recordID.externalID)") } @@ -53,20 +60,12 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { if let webFeed = account.existingWebFeed(withExternalID: record.externalID) { webFeed.editedName = editedName } else { - if let urlString = record[CloudKitAccountZone.CloudKitWebFeed.Fields.url] as? String, let url = URL(string: urlString) { - downloadAndAddWebFeed(url: url, editedName: editedName, externalID: record.externalID) - } else { - os_log(.error, log: self.log, "Failed to add or update web feed.") + if let urlString = record[CloudKitAccountZone.CloudKitWebFeed.Fields.url] as? String { + unclaimedWebFeeds[record.externalID] = UnclaimedWebFeed(url: urlString, editedName: editedName) } } } - func removeWebFeed(_ externalID: String) { - if let webFeed = account?.existingWebFeed(withExternalID: externalID) { - account?.removeWebFeed(webFeed) - } - } - func addOrUpdateContainer(_ record: CKRecord) { guard let account = account, let name = record[CloudKitAccountZone.CloudKitContainer.Fields.name] as? String, @@ -87,17 +86,34 @@ class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { } } -} - -private extension CloudKitAcountZoneDelegate { - - func downloadAndAddWebFeed(url: URL, editedName: String?, externalID: String) { - guard let account = account else { return } + func addOrUpdateContainerWebFeed(_ record: CKRecord) { + guard let account = account, + let containerReference = record[CloudKitAccountZone.CloudKitContainerWebFeed.Fields.container] as? CKRecord.Reference, + let webFeedReference = record[CloudKitAccountZone.CloudKitContainerWebFeed.Fields.webFeed] as? CKRecord.Reference else { return } - let webFeed = account.createWebFeed(with: editedName, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) - webFeed.editedName = editedName - webFeed.externalID = externalID - account.addWebFeed(webFeed) + let containerWebFeedExternalID = record.externalID + let containerExternalID = containerReference.recordID.externalID + let webFeedExternalID = webFeedReference.recordID.externalID + + guard let container = account.existingContainer(withExternalID: containerExternalID) else { return } + + if let webFeed = account.existingWebFeed(withExternalID: webFeedExternalID) { + webFeed.folderRelationship?[containerWebFeedExternalID] = containerExternalID + container.addWebFeed(webFeed) + return + } + + guard let unclaimedWebFeed = unclaimedWebFeeds[webFeedExternalID] else { return } + unclaimedWebFeeds.removeValue(forKey: webFeedExternalID) + + let webFeed = account.createWebFeed(with: nil, url: unclaimedWebFeed.url, webFeedID: unclaimedWebFeed.url, homePageURL: nil) + webFeed.editedName = unclaimedWebFeed.editedName + webFeed.externalID = webFeedExternalID + webFeed.folderRelationship = [String: String]() + webFeed.folderRelationship![containerWebFeedExternalID] = containerExternalID + container.addWebFeed(webFeed) + + guard let url = URL(string: unclaimedWebFeed.url) else { return } refreshProgress?.addToNumberOfTasksAndRemaining(1) InitialFeedDownloader.download(url) { parsedFeed in @@ -106,7 +122,22 @@ private extension CloudKitAcountZoneDelegate { account.update(webFeed, with: parsedFeed, {_ in }) } } + } + + func removeContainerWebFeed(_ containerWebFeedExternalID: String) { + guard let account = account, + let webFeed = account.flattenedWebFeeds().first(where: { $0.folderRelationship?.keys.contains(containerWebFeedExternalID) ?? false }), + let containerExternalId = webFeed.folderRelationship?[containerWebFeedExternalID] else { return } + + webFeed.folderRelationship?.removeValue(forKey: containerWebFeedExternalID) + guard account.externalID != containerExternalId else { + account.removeWebFeed(webFeed) + return + } + + guard let folder = account.existingFolder(withExternalID: containerExternalId) else { return } + folder.removeWebFeed(webFeed) } } diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 5c53edbb2..1f0fa7cd9 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -96,8 +96,8 @@ extension CloudKitZone { } } - func save(record: CKRecord, completion: @escaping (Result) -> Void) { - modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion) + func save(_ records: [CKRecord], completion: @escaping (Result) -> Void) { + modify(recordsToSave: records, recordIDsToDelete: [], completion: completion) } func delete(externalID: String?, completion: @escaping (Result) -> Void) { diff --git a/Frameworks/Account/Container.swift b/Frameworks/Account/Container.swift index 4405b0439..7c4eda9c8 100644 --- a/Frameworks/Account/Container.swift +++ b/Frameworks/Account/Container.swift @@ -39,6 +39,7 @@ public protocol Container: class, ContainerIdentifiable { func hasWebFeed(withURL url: String) -> Bool func existingWebFeed(withWebFeedID: String) -> WebFeed? func existingWebFeed(withURL url: String) -> WebFeed? + func existingWebFeed(withExternalID externalID: String) -> WebFeed? func existingFolder(with name: String) -> Folder? func existingFolder(withID: Int) -> Folder? @@ -117,6 +118,15 @@ public extension Container { } return nil } + + func existingWebFeed(withExternalID externalID: String) -> WebFeed? { + for feed in flattenedWebFeeds() { + if feed.externalID == externalID { + return feed + } + } + return nil + } func existingFolder(with name: String) -> Folder? { guard let folders = folders else {