diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4c8984b2d..c0e58f182 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ jobs: matrix: run-config: - { scheme: 'NetNewsWire', destination: 'platform=macOS'} - - { scheme: 'NetNewsWire-iOS', destination: 'platform=iOS Simulator,OS=13.0,name=iPhone 11' } + - { scheme: 'NetNewsWire-iOS', destination: 'platform=iOS Simulator,OS=13.4,name=iPhone 11' } steps: - name: Checkout Project @@ -25,8 +25,11 @@ jobs: with: submodules: recursive + - name: List Available Applications + run: ls /Applications + - name: Switch to Xcode 11 - run: sudo xcode-select -s /Applications/Xcode_11.app + run: sudo xcode-select -s /Applications/Xcode_11.4.app - name: Show Build Version run: xcodebuild -version diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 2291fee56..cc4fc37f4 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -27,7 +27,6 @@ public extension Notification.Name { static let AccountRefreshDidBegin = Notification.Name(rawValue: "AccountRefreshDidBegin") static let AccountRefreshDidFinish = Notification.Name(rawValue: "AccountRefreshDidFinish") static let AccountRefreshProgressDidChange = Notification.Name(rawValue: "AccountRefreshProgressDidChange") - static let DownloadArticlesDidUpdateUnreadCounts = Notification.Name(rawValue: "DownloadArticlesDidUpdateUnreadCounts") static let AccountDidDownloadArticles = Notification.Name(rawValue: "AccountDidDownloadArticles") static let AccountStateDidChange = Notification.Name(rawValue: "AccountStateDidChange") static let StatusesDidChange = Notification.Name(rawValue: "StatusesDidChange") @@ -136,6 +135,15 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, public var topLevelWebFeeds = Set() public var folders: Set? = Set() + public var externalID: String? { + get { + return metadata.externalID + } + set { + metadata.externalID = newValue + } + } + public var sortedFolders: [Folder]? { if let folders = folders { return Array(folders).sorted(by: { $0.nameForDisplay < $1.nameForDisplay }) @@ -143,14 +151,25 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, return nil } - private var webFeedDictionaryNeedsUpdate = true + private var webFeedDictionariesNeedUpdate = true private var _idToWebFeedDictionary = [String: WebFeed]() var idToWebFeedDictionary: [String: WebFeed] { - if webFeedDictionaryNeedsUpdate { + if webFeedDictionariesNeedUpdate { rebuildWebFeedDictionaries() } return _idToWebFeedDictionary } + private var _externalIDToWebFeedDictionary = [String: WebFeed]() + var externalIDToWebFeedDictionary: [String: WebFeed] { + if webFeedDictionariesNeedUpdate { + rebuildWebFeedDictionaries() + } + return _externalIDToWebFeedDictionary + } + + var flattenedWebFeedURLs: Set { + return Set(flattenedWebFeeds().map({ $0.url })) + } var username: String? { get { @@ -254,7 +273,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, self.dataFolder = dataFolder let databaseFilePath = (dataFolder as NSString).appendingPathComponent("DB.sqlite3") - self.database = ArticlesDatabase(databaseFilePath: databaseFilePath, accountID: accountID) + let retentionStyle: ArticlesDatabase.RetentionStyle = (type == .onMyMac || type == .cloudKit) ? .feedBased : .syncSystem + self.database = ArticlesDatabase(databaseFilePath: databaseFilePath, accountID: accountID, retentionStyle: retentionStyle) switch type { case .onMyMac: @@ -370,8 +390,12 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, grantingType.requestOAuthAccessToken(with: response, transport: transport, completion: completion) } + public func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + delegate.receiveRemoteNotification(for: self, userInfo: userInfo, completion: completion) + } + public func refreshAll(completion: @escaping (Result) -> Void) { - self.delegate.refreshAll(for: self, completion: completion) + delegate.refreshAll(for: self, completion: completion) } public func syncArticleStatus(completion: ((Result) -> Void)? = nil) { @@ -417,14 +441,18 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } public func suspendDatabase() { + #if os(iOS) database.cancelAndSuspend() + #endif save() } /// Re-open the SQLite database and allow database calls. /// Call this *before* calling resume. public func resumeDatabaseAndDelegate() { + #if os(iOS) database.resume() + #endif delegate.resume() } @@ -443,49 +471,51 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, delegate.accountWillBeDeleted(self) } - func loadOPMLItems(_ items: [RSOPMLItem], parentFolder: Folder?) { - var feedsToAdd = Set() - - items.forEach { (item) in - + func addOPMLItems(_ items: [RSOPMLItem]) { + for item in items { if let feedSpecifier = item.feedSpecifier { - let feed = newWebFeed(with: feedSpecifier) - feedsToAdd.insert(feed) - return - } - - guard let folderName = item.titleFromAttributes else { - // Folder doesn’t have a name, so it won’t be created, and its items will go one level up. - if let itemChildren = item.children { - loadOPMLItems(itemChildren, parentFolder: parentFolder) - } - return - } - - if let folder = ensureFolder(with: folderName) { - folder.externalID = item.attributes?["nnw_externalID"] as? String - if let itemChildren = item.children { - loadOPMLItems(itemChildren, parentFolder: folder) + addWebFeed(newWebFeed(with: feedSpecifier)) + } else { + if let title = item.titleFromAttributes, let folder = ensureFolder(with: title) { + folder.externalID = item.attributes?["nnw_externalID"] as? String + item.children?.forEach { itemChild in + if let feedSpecifier = itemChild.feedSpecifier { + folder.addWebFeed(newWebFeed(with: feedSpecifier)) + } + } } } } - - if let parentFolder = parentFolder { - for feed in feedsToAdd { - parentFolder.addWebFeed(feed) - } - } else { - for feed in feedsToAdd { - addWebFeed(feed) - } - } - + } + + func loadOPMLItems(_ items: [RSOPMLItem]) { + addOPMLItems(OPMLNormalizer.normalize(items)) } public func markArticles(_ articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { 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) + } + + func existingContainers(withWebFeed webFeed: WebFeed) -> [Container] { + var containers = [Container]() + if topLevelWebFeeds.contains(webFeed) { + containers.append(self) + } + folders?.forEach { folder in + if folder.topLevelWebFeeds.contains(webFeed) { + containers.append(folder) + } + } + return containers + } + @discardableResult func ensureFolder(with name: String) -> Folder? { // TODO: support subfolders, maybe, some day @@ -516,10 +546,14 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, return ensureFolder(with: folderName) } - public func findFolder(withDisplayName displayName: String) -> Folder? { + public func existingFolder(withDisplayName displayName: String) -> Folder? { return folders?.first(where: { $0.nameForDisplay == displayName }) } + public func existingFolder(withExternalID externalID: String) -> Folder? { + return folders?.first(where: { $0.externalID == externalID }) + } + func newWebFeed(with opmlFeedSpecifier: RSOPMLFeedSpecifier) -> WebFeed { let feedURL = opmlFeedSpecifier.feedURL let metadata = webFeedMetadata(feedURL: feedURL, webFeedID: feedURL) @@ -545,7 +579,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, let feed = WebFeed(account: self, url: url, metadata: metadata) feed.name = name feed.homePageURL = homePageURL - return feed } @@ -566,7 +599,7 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } public func addFolder(_ name: String, completion: @escaping (Result) -> Void) { - delegate.addFolder(for: self, name: name, completion: completion) + delegate.createFolder(for: self, name: name, completion: completion) } public func removeFolder(_ folder: Folder, completion: @escaping (Result) -> Void) { @@ -679,60 +712,53 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, // Or feeds inside folders were added or deleted. opmlFile.markAsDirty() flattenedWebFeedsNeedUpdate = true - webFeedDictionaryNeedsUpdate = true + webFeedDictionariesNeedUpdate = true } 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 or iCloud account. + precondition(Thread.isMainThread) + precondition(type == .onMyMac || type == .cloudKit) + webFeed.takeSettings(from: parsedFeed) - let webFeedIDsAndItems = [webFeed.webFeedID: parsedFeed.items] - update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: false, completion: completion) + let parsedItems = parsedFeed.items + guard !parsedItems.isEmpty else { + completion(nil) + return + } + + 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) + completion(nil) + case .failure(let databaseError): + completion(databaseError) + } + } } func update(webFeedIDsAndItems: [String: Set], defaultRead: Bool, completion: @escaping DatabaseCompletionBlock) { + // Used only by syncing systems. precondition(Thread.isMainThread) + precondition(type != .onMyMac && type != .cloudKit) guard !webFeedIDsAndItems.isEmpty else { completion(nil) return } database.update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: defaultRead) { updateArticlesResult in - - func sendNotificationAbout(newArticles: Set
?, updatedArticles: Set
?) { - var webFeeds = Set() - - if let newArticles = newArticles { - webFeeds.formUnion(Set(newArticles.compactMap { $0.webFeed })) - } - if let updatedArticles = updatedArticles { - webFeeds.formUnion(Set(updatedArticles.compactMap { $0.webFeed })) - } - - var shouldSendNotification = false - var userInfo = [String: Any]() - - if let newArticles = newArticles, !newArticles.isEmpty { - shouldSendNotification = true - userInfo[UserInfoKey.newArticles] = newArticles - self.updateUnreadCounts(for: webFeeds) { - NotificationCenter.default.post(name: .DownloadArticlesDidUpdateUnreadCounts, object: self, userInfo: nil) - } - } - - if let updatedArticles = updatedArticles, !updatedArticles.isEmpty { - shouldSendNotification = true - userInfo[UserInfoKey.updatedArticles] = updatedArticles - } - - if shouldSendNotification { - userInfo[UserInfoKey.webFeeds] = webFeeds - NotificationCenter.default.post(name: .AccountDidDownloadArticles, object: self, userInfo: userInfo) - } - } - switch updateArticlesResult { case .success(let newAndUpdatedArticles): - sendNotificationAbout(newArticles: newAndUpdatedArticles.newArticles, updatedArticles: newAndUpdatedArticles.updatedArticles) + self.sendNotificationAbout(newAndUpdatedArticles) completion(nil) case .failure(let databaseError): completion(databaseError) @@ -1150,13 +1176,18 @@ private extension Account { func rebuildWebFeedDictionaries() { var idDictionary = [String: WebFeed]() - + var externalIDDictionary = [String: WebFeed]() + flattenedWebFeeds().forEach { (feed) in idDictionary[feed.webFeedID] = feed + if let externalID = feed.externalID { + externalIDDictionary[externalID] = feed + } } _idToWebFeedDictionary = idDictionary - webFeedDictionaryNeedsUpdate = false + _externalIDToWebFeedDictionary = externalIDDictionary + webFeedDictionariesNeedUpdate = false } func updateUnreadCount() { @@ -1252,6 +1283,36 @@ private extension Account { feed.unreadCount = unreadCount } } + + func sendNotificationAbout(_ newAndUpdatedArticles: NewAndUpdatedArticles) { + var webFeeds = Set() + + if let newArticles = newAndUpdatedArticles.newArticles { + webFeeds.formUnion(Set(newArticles.compactMap { $0.webFeed })) + } + if let updatedArticles = newAndUpdatedArticles.updatedArticles { + webFeeds.formUnion(Set(updatedArticles.compactMap { $0.webFeed })) + } + + var shouldSendNotification = false + var userInfo = [String: Any]() + + if let newArticles = newAndUpdatedArticles.newArticles, !newArticles.isEmpty { + shouldSendNotification = true + userInfo[UserInfoKey.newArticles] = newArticles + self.updateUnreadCounts(for: webFeeds) + } + + if let updatedArticles = newAndUpdatedArticles.updatedArticles, !updatedArticles.isEmpty { + shouldSendNotification = true + userInfo[UserInfoKey.updatedArticles] = updatedArticles + } + + if shouldSendNotification { + userInfo[UserInfoKey.webFeeds] = webFeeds + NotificationCenter.default.post(name: .AccountDidDownloadArticles, object: self, userInfo: userInfo) + } + } } // MARK: - Container Overrides @@ -1261,6 +1322,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/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 755ee05fe..38efd6984 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -34,12 +34,15 @@ 510BD111232C3801002692E4 /* AccountMetadataFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510BD110232C3801002692E4 /* AccountMetadataFile.swift */; }; 510BD113232C3E9D002692E4 /* WebFeedMetadataFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510BD112232C3E9D002692E4 /* WebFeedMetadataFile.swift */; }; 511B9804237CD4270028BCAA /* FeedIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 511B9803237CD4270028BCAA /* FeedIdentifier.swift */; }; + 512DD4CB2431000600C17B1F /* CKRecord+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */; }; + 512DD4CD2431098700C17B1F /* CloudKitAccountZoneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */; }; 513323082281070D00C30F19 /* AccountFeedbinSyncTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 513323072281070C00C30F19 /* AccountFeedbinSyncTest.swift */; }; 5133230A2281082F00C30F19 /* subscriptions_initial.json in Resources */ = {isa = PBXBuildFile; fileRef = 513323092281082F00C30F19 /* subscriptions_initial.json */; }; 5133230C2281088A00C30F19 /* subscriptions_add.json in Resources */ = {isa = PBXBuildFile; fileRef = 5133230B2281088A00C30F19 /* subscriptions_add.json */; }; 5144EA49227B497600D19003 /* FeedbinAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA48227B497600D19003 /* FeedbinAPICaller.swift */; }; 5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5144EA4D227B829A00D19003 /* FeedbinAccountDelegate.swift */; }; 514BF5202391B0DB00902FE8 /* SingleArticleFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 514BF51F2391B0DB00902FE8 /* SingleArticleFetcher.swift */; }; + 5150FFFE243823B800C1A442 /* CloudKitError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5150FFFD243823B800C1A442 /* CloudKitError.swift */; }; 5154367B228EEB28005E1CDF /* FeedbinImportResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */; }; 515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515E4EB22324FF8C0057B0E7 /* CredentialsManager.swift */; }; 515E4EB62324FF8C0057B0E7 /* URLRequest+RSWeb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 515E4EB32324FF8C0057B0E7 /* URLRequest+RSWeb.swift */; }; @@ -53,11 +56,13 @@ 5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5165D71E22835E9800D9D53D /* HTMLFeedFinder.swift */; }; 5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5165D73022837F3400D9D53D /* InitialFeedDownloader.swift */; }; 5170743C232AEDB500A461A3 /* OPMLFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5170743B232AEDB500A461A3 /* OPMLFile.swift */; }; + 519E84A62433D49000D238B0 /* OPMLNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */; }; + 519E84A82434C5EF00D238B0 /* CloudKitArticlesZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */; }; + 519E84AC2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */; }; 51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */; }; 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC8FCB237EC055004F8B56 /* Feed.swift */; }; 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */; }; - 51C034DF242D65D20014DC71 /* CloudKitResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C034DE242D65D20014DC71 /* CloudKitResult.swift */; }; - 51C034E1242D660D0014DC71 /* CKError+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C034E0242D660D0014DC71 /* CKError+Extensions.swift */; }; + 51C034DF242D65D20014DC71 /* CloudKitZoneResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C034DE242D65D20014DC71 /* CloudKitZoneResult.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 */; }; 51D5875B227F630B00900287 /* tags_add.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58758227F630B00900287 /* tags_add.json */; }; @@ -68,9 +73,6 @@ 51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E490352288C37100C791F0 /* FeedbinDate.swift */; }; 51E4DB2E242633ED0091EB5B /* CloudKitZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */; }; 51E4DB302426353D0091EB5B /* CloudKitAccountZone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */; }; - 51E4DB3224264B470091EB5B /* WebFeed+CloudKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB3124264B470091EB5B /* WebFeed+CloudKit.swift */; }; - 51E4DB3424264CD50091EB5B /* Folder+CloudKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB3324264CD50091EB5B /* Folder+CloudKit.swift */; }; - 51E4DB362426693F0091EB5B /* CloudKitRecordConvertable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4DB352426693F0091EB5B /* CloudKitRecordConvertable.swift */; }; 51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */; }; 51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */; }; 552032F8229D5D5A009559E0 /* ReaderAPIEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032ED229D5D5A009559E0 /* ReaderAPIEntry.swift */; }; @@ -267,12 +269,15 @@ 510BD110232C3801002692E4 /* AccountMetadataFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountMetadataFile.swift; sourceTree = ""; }; 510BD112232C3E9D002692E4 /* WebFeedMetadataFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebFeedMetadataFile.swift; sourceTree = ""; }; 511B9803237CD4270028BCAA /* FeedIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedIdentifier.swift; sourceTree = ""; }; + 512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKRecord+Extensions.swift"; sourceTree = ""; }; + 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAccountZoneDelegate.swift; sourceTree = ""; }; 513323072281070C00C30F19 /* AccountFeedbinSyncTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFeedbinSyncTest.swift; sourceTree = ""; }; 513323092281082F00C30F19 /* subscriptions_initial.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = subscriptions_initial.json; sourceTree = ""; }; 5133230B2281088A00C30F19 /* subscriptions_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = subscriptions_add.json; sourceTree = ""; }; 5144EA48227B497600D19003 /* FeedbinAPICaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinAPICaller.swift; sourceTree = ""; }; 5144EA4D227B829A00D19003 /* FeedbinAccountDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinAccountDelegate.swift; sourceTree = ""; }; 514BF51F2391B0DB00902FE8 /* SingleArticleFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleArticleFetcher.swift; sourceTree = ""; }; + 5150FFFD243823B800C1A442 /* CloudKitError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitError.swift; sourceTree = ""; }; 5154367A228EEB28005E1CDF /* FeedbinImportResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinImportResult.swift; sourceTree = ""; }; 515E4EB22324FF8C0057B0E7 /* CredentialsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CredentialsManager.swift; sourceTree = ""; }; 515E4EB32324FF8C0057B0E7 /* URLRequest+RSWeb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URLRequest+RSWeb.swift"; sourceTree = ""; }; @@ -287,11 +292,13 @@ 5165D73022837F3400D9D53D /* InitialFeedDownloader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InitialFeedDownloader.swift; sourceTree = ""; }; 5170743B232AEDB500A461A3 /* OPMLFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLFile.swift; sourceTree = ""; }; 518B2EA52351306200400001 /* Account_project_test.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_test.xcconfig; sourceTree = ""; }; + 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLNormalizer.swift; sourceTree = ""; }; + 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZone.swift; sourceTree = ""; }; + 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CloudKitArticlesZoneDelegate.swift; sourceTree = ""; }; 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBehaviors.swift; sourceTree = ""; }; 51BC8FCB237EC055004F8B56 /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = ""; }; 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerIdentifier.swift; sourceTree = ""; }; - 51C034DE242D65D20014DC71 /* CloudKitResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitResult.swift; sourceTree = ""; }; - 51C034E0242D660D0014DC71 /* CKError+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CKError+Extensions.swift"; sourceTree = ""; }; + 51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitZoneResult.swift; sourceTree = ""; }; 51D58754227F53BE00900287 /* FeedbinTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinTag.swift; sourceTree = ""; }; 51D58757227F630B00900287 /* tags_delete.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_delete.json; sourceTree = ""; }; 51D58758227F630B00900287 /* tags_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_add.json; sourceTree = ""; }; @@ -302,9 +309,6 @@ 51E490352288C37100C791F0 /* FeedbinDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinDate.swift; sourceTree = ""; }; 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitZone.swift; sourceTree = ""; }; 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAccountZone.swift; sourceTree = ""; }; - 51E4DB3124264B470091EB5B /* WebFeed+CloudKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebFeed+CloudKit.swift"; sourceTree = ""; }; - 51E4DB3324264CD50091EB5B /* Folder+CloudKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Folder+CloudKit.swift"; sourceTree = ""; }; - 51E4DB352426693F0091EB5B /* CloudKitRecordConvertable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitRecordConvertable.swift; sourceTree = ""; }; 51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinUnreadEntry.swift; sourceTree = ""; }; 51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinStarredEntry.swift; sourceTree = ""; }; 552032ED229D5D5A009559E0 /* ReaderAPIEntry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPIEntry.swift; sourceTree = ""; }; @@ -517,14 +521,15 @@ 5103A9D7242253DC00410853 /* CloudKit */ = { isa = PBXGroup; children = ( - 51C034E0242D660D0014DC71 /* CKError+Extensions.swift */, + 512DD4CA2431000600C17B1F /* CKRecord+Extensions.swift */, 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */, 51E4DB2F2426353D0091EB5B /* CloudKitAccountZone.swift */, - 51E4DB352426693F0091EB5B /* CloudKitRecordConvertable.swift */, - 51C034DE242D65D20014DC71 /* CloudKitResult.swift */, + 512DD4CC2431098700C17B1F /* CloudKitAccountZoneDelegate.swift */, + 519E84A72434C5EF00D238B0 /* CloudKitArticlesZone.swift */, + 519E84AB2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift */, + 5150FFFD243823B800C1A442 /* CloudKitError.swift */, 51E4DB2D242633ED0091EB5B /* CloudKitZone.swift */, - 51E4DB3324264CD50091EB5B /* Folder+CloudKit.swift */, - 51E4DB3124264B470091EB5B /* WebFeed+CloudKit.swift */, + 51C034DE242D65D20014DC71 /* CloudKitZoneResult.swift */, ); path = CloudKit; sourceTree = ""; @@ -673,6 +678,7 @@ 51E3EB40229AF61B00645299 /* AccountError.swift */, 846E77531F6F00E300A165E2 /* AccountManager.swift */, 5170743B232AEDB500A461A3 /* OPMLFile.swift */, + 519E84A52433D49000D238B0 /* OPMLNormalizer.swift */, 84AF4EA3222CFDD100F6A800 /* AccountMetadata.swift */, 510BD110232C3801002692E4 /* AccountMetadataFile.swift */, 84F73CF0202788D80000BCEF /* ArticleFetcher.swift */, @@ -1085,12 +1091,15 @@ 9EA643D5239306AC0018A28C /* FeedlyFeedsSearchResponse.swift in Sources */, 9EAEC60E2332FEC20085D7C9 /* FeedlyFeed.swift in Sources */, 5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */, + 519E84AC2435019100D238B0 /* CloudKitArticlesZoneDelegate.swift in Sources */, + 512DD4CB2431000600C17B1F /* CKRecord+Extensions.swift in Sources */, 3B826DAF2385C81C00FC1ADB /* FeedWranglerGenericResult.swift in Sources */, 9ECC9A85234DC16E009B5144 /* FeedlyAccountDelegateError.swift in Sources */, 9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */, 51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */, 846E77451F6EF9B900A165E2 /* Container.swift in Sources */, 9EA643D3239305680018A28C /* FeedlySearchOperation.swift in Sources */, + 5150FFFE243823B800C1A442 /* CloudKitError.swift in Sources */, 9E5EC15D23E0D58500A4E503 /* FeedlyFeedParser.swift in Sources */, 9E1D15532334304B00F4944C /* FeedlyGetStreamContentsOperation.swift in Sources */, 9E12B0202334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift in Sources */, @@ -1104,6 +1113,7 @@ 5103A9D92422546800410853 /* CloudKitAccountDelegate.swift in Sources */, 5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */, 9E784EBE237E890600099B1B /* FeedlyLogoutOperation.swift in Sources */, + 519E84A62433D49000D238B0 /* OPMLNormalizer.swift in Sources */, 9EEEF71F23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift in Sources */, 9EBD49C223C67784005AD5CD /* FeedlyEntryIdentifierProviding.swift in Sources */, 846E77541F6F00E300A165E2 /* AccountManager.swift in Sources */, @@ -1111,11 +1121,9 @@ 51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */, 9EEAE06E235D002D00E3FEE4 /* FeedlyGetCollectionsService.swift in Sources */, 5165D72922835F7A00D9D53D /* FeedSpecifier.swift in Sources */, - 51E4DB3424264CD50091EB5B /* Folder+CloudKit.swift in Sources */, 9E85C8ED2367020700D0F1F7 /* FeedlyGetEntriesService.swift in Sources */, 9E5EC15923E01D8A00A4E503 /* FeedlyCollectionParser.swift in Sources */, 9E84DC492359A73600D6E809 /* FeedlyCheckpointOperation.swift in Sources */, - 51E4DB3224264B470091EB5B /* WebFeed+CloudKit.swift in Sources */, 9E85C8EB236700E600D0F1F7 /* FeedlyGetEntriesOperation.swift in Sources */, 9E1D154D233370D800F4944C /* FeedlySyncAllOperation.swift in Sources */, 9E44C90F23C6FF3600CCC286 /* FeedlyIngestStreamArticleIdsOperation.swift in Sources */, @@ -1156,7 +1164,6 @@ 51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */, 552032F8229D5D5A009559E0 /* ReaderAPIEntry.swift in Sources */, 552032FB229D5D5A009559E0 /* ReaderAPITag.swift in Sources */, - 51E4DB362426693F0091EB5B /* CloudKitRecordConvertable.swift in Sources */, 5165D72822835F7800D9D53D /* FeedFinder.swift in Sources */, 9EBD49C023C67602005AD5CD /* FeedlyDownloadArticlesOperation.swift in Sources */, 51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */, @@ -1166,6 +1173,7 @@ 51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */, 9E1773D923458D590056A5A8 /* FeedlyResourceId.swift in Sources */, 9EE4CCFA234F106600FBAE4B /* FeedlyFeedContainerValidator.swift in Sources */, + 519E84A82434C5EF00D238B0 /* CloudKitArticlesZone.swift in Sources */, 552032FC229D5D5A009559E0 /* ReaderAPIUnreadEntry.swift in Sources */, 9EC688EA232B973C00A8D0A2 /* FeedlyAPICaller.swift in Sources */, 9E1773D32345700F0056A5A8 /* FeedlyLink.swift in Sources */, @@ -1174,7 +1182,6 @@ 9E964EB823754AC400A7AF2E /* OAuthAuthorizationClient+Feedly.swift in Sources */, 3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */, 9EF1B10923590E93000A486A /* FeedlyStreamIds.swift in Sources */, - 51C034E1242D660D0014DC71 /* CKError+Extensions.swift in Sources */, 84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */, 84CAD7161FDF2E22000F0755 /* FeedbinEntry.swift in Sources */, 5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */, @@ -1186,6 +1193,7 @@ 515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */, 844B297F210CE37E004020B3 /* UnreadCountProvider.swift in Sources */, 9E1773D5234570E30056A5A8 /* FeedlyEntryParser.swift in Sources */, + 512DD4CD2431098700C17B1F /* CloudKitAccountZoneDelegate.swift in Sources */, 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */, 9E1D1555233431A600F4944C /* FeedlyOperation.swift in Sources */, 84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */, @@ -1196,7 +1204,7 @@ 3B826DAC2385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift in Sources */, 769F295938E5A30D03DFF88F /* NewsBlurAccountDelegate.swift in Sources */, 769F2BA02EF5F329CDE45F5A /* NewsBlurAPICaller.swift in Sources */, - 51C034DF242D65D20014DC71 /* CloudKitResult.swift in Sources */, + 51C034DF242D65D20014DC71 /* CloudKitZoneResult.swift in Sources */, 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */, 179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */, 179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */, diff --git a/Frameworks/Account/AccountDelegate.swift b/Frameworks/Account/AccountDelegate.swift index edef2fb1c..a7c201cab 100644 --- a/Frameworks/Account/AccountDelegate.swift +++ b/Frameworks/Account/AccountDelegate.swift @@ -22,13 +22,15 @@ protocol AccountDelegate { var refreshProgress: DownloadProgress { get } + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result) -> Void) - func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) + func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> Void) func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) diff --git a/Frameworks/Account/AccountManager.swift b/Frameworks/Account/AccountManager.swift index 321e8fb5f..c2b1a5da0 100644 --- a/Frameworks/Account/AccountManager.swift +++ b/Frameworks/Account/AccountManager.swift @@ -72,7 +72,7 @@ public final class AccountManager: UnreadCountProvider { return lastArticleFetchEndTime } - public func findActiveAccount(forDisplayName displayName: String) -> Account? { + public func existingActiveAccount(forDisplayName displayName: String) -> Account? { return AccountManager.shared.activeAccounts.first(where: { $0.nameForDisplay == displayName }) } @@ -184,7 +184,22 @@ public final class AccountManager: UnreadCountProvider { accounts.forEach { $0.resume() } } - public func refreshAll(errorHandler: @escaping (Error) -> Void, completion: (() ->Void)? = nil) { + public func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: (() -> Void)? = nil) { + let group = DispatchGroup() + + activeAccounts.forEach { account in + group.enter() + account.receiveRemoteNotification(userInfo: userInfo) { + group.leave() + } + } + + group.notify(queue: DispatchQueue.main) { + completion?() + } + } + + public func refreshAll(errorHandler: @escaping (Error) -> Void, completion: (() -> Void)? = nil) { let group = DispatchGroup() activeAccounts.forEach { account in @@ -203,7 +218,6 @@ public final class AccountManager: UnreadCountProvider { group.notify(queue: DispatchQueue.main) { completion?() } - } public func syncArticleStatusAll(completion: (() -> Void)? = nil) { diff --git a/Frameworks/Account/AccountMetadata.swift b/Frameworks/Account/AccountMetadata.swift index 7c5f378f9..705424ca4 100644 --- a/Frameworks/Account/AccountMetadata.swift +++ b/Frameworks/Account/AccountMetadata.swift @@ -23,6 +23,7 @@ final class AccountMetadata: Codable { case lastArticleFetchStartTime = "lastArticleFetch" case lastArticleFetchEndTime case endpointURL + case externalID } var name: String? { @@ -81,6 +82,14 @@ final class AccountMetadata: Codable { } } + var externalID: String? { + didSet { + if externalID != oldValue { + valueDidChange(.externalID) + } + } + } + weak var delegate: AccountMetadataDelegate? func valueDidChange(_ key: CodingKeys) { diff --git a/Frameworks/Account/AccountTests/Feedly/FeedlyTestSupport.swift b/Frameworks/Account/AccountTests/Feedly/FeedlyTestSupport.swift index 46117de80..ea677dbe4 100644 --- a/Frameworks/Account/AccountTests/Feedly/FeedlyTestSupport.swift +++ b/Frameworks/Account/AccountTests/Feedly/FeedlyTestSupport.swift @@ -287,6 +287,7 @@ class FeedlyTestSupport { url: "http://localhost/", externalURL: "http://localhost/\(pair.0)/articles/\(index).html", title: "Title\(index)", + language: nil, contentHTML: "Content \(index) HTML.", contentText: "Content \(index) Text", summary: nil, diff --git a/Frameworks/Account/CloudKit/CKRecord+Extensions.swift b/Frameworks/Account/CloudKit/CKRecord+Extensions.swift new file mode 100644 index 000000000..fc97d2dd7 --- /dev/null +++ b/Frameworks/Account/CloudKit/CKRecord+Extensions.swift @@ -0,0 +1,26 @@ +// +// CKRecord+Extensions.swift +// Account +// +// Created by Maurice Parker on 3/29/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import CloudKit + +extension CKRecord { + + var externalID: String { + return recordID.externalID + } + +} + +extension CKRecord.ID { + + var externalID: String { + return recordName + } + +} diff --git a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift index 2f49f8234..d6f03a63e 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift @@ -30,7 +30,9 @@ final class CloudKitAccountDelegate: AccountDelegate { return CKContainer(identifier: "iCloud.\(orgID).NetNewsWire") }() + private lazy var zones: [CloudKitZone] = [accountZone, articlesZone] private let accountZone: CloudKitAccountZone + private let articlesZone: CloudKitArticlesZone private let refresher = LocalAccountRefresher() @@ -41,37 +43,106 @@ final class CloudKitAccountDelegate: AccountDelegate { var credentials: Credentials? var accountMetadata: AccountMetadata? - var refreshProgress: DownloadProgress { - 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) -// } -// } -// } + var refreshProgress = DownloadProgress(numberOfTasks: 0) init(dataFolder: String) { accountZone = CloudKitAccountZone(container: container) + articlesZone = CloudKitArticlesZone(container: container) + let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3") database = SyncDatabase(databaseFilePath: databaseFilePath) } - func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { - refresher.refreshFeeds(account.flattenedWebFeeds()) { - account.metadata.lastArticleFetchEndTime = Date() - completion(.success(())) + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + os_log(.debug, log: log, "Processing remote notification...") + + let group = DispatchGroup() + + zones.forEach { zone in + group.enter() + zone.receiveRemoteNotification(userInfo: userInfo) { + group.leave() + } } + + group.notify(queue: DispatchQueue.main) { + os_log(.debug, log: self.log, "Done processing remote notification...") + completion() + } + } + + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { + guard refreshProgress.isComplete else { + completion(.success(())) + return + } + refreshAll(for: account, downloadFeeds: true, completion: completion) } func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { - completion(.success(())) + os_log(.debug, log: log, "Sending article statuses...") + + database.selectForProcessing { result in + + func processStatuses(_ syncStatuses: [SyncStatus]) { + guard syncStatuses.count > 0 else { + completion(.success(())) + return + } + + let starredArticleIDs = syncStatuses.filter({ $0.key == .starred && $0.flag == true }).map({ $0.articleID }) + account.fetchArticlesAsync(.articleIDs(Set(starredArticleIDs))) { result in + + func processWithArticles(_ starredArticles: Set
) { + + self.articlesZone.sendArticleStatus(syncStatuses, starredArticles: starredArticles) { result in + switch result { + case .success: + self.database.deleteSelectedForProcessing(syncStatuses.map({ $0.articleID }) ) + os_log(.debug, log: self.log, "Done sending article statuses.") + completion(.success(())) + case .failure(let error): + self.database.resetSelectedForProcessing(syncStatuses.map({ $0.articleID }) ) + self.processAccountError(account, error) + completion(.failure(error)) + } + } + + } + + switch result { + case .success(let starredArticles): + processWithArticles(starredArticles) + case .failure(let databaseError): + completion(.failure(databaseError)) + } + + } + + } + + switch result { + case .success(let syncStatuses): + processStatuses(syncStatuses) + case .failure(let databaseError): + completion(.failure(databaseError)) + } + } } + func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { - completion(.success(())) + os_log(.debug, log: log, "Refreshing article statuses...") + + articlesZone.refreshArticleStatus() { result in + os_log(.debug, log: self.log, "Done refreshing article statuses.") + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } } func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result) -> Void) { @@ -104,60 +175,97 @@ final class CloudKitAccountDelegate: AccountDelegate { return } - guard let children = loadDocument.children else { + guard let opmlItems = loadDocument.children, let rootExternalID = account.externalID else { return } - BatchUpdate.shared.perform { - account.loadOPMLItems(children, parentFolder: nil) + let normalizedItems = OPMLNormalizer.normalize(opmlItems) + + // Combine all existing web feed URLs with all the new ones + + var webFeedURLs = account.flattenedWebFeedURLs + for opmlItem in normalizedItems { + if let webFeedURL = opmlItem.feedSpecifier?.feedURL { + webFeedURLs.insert(webFeedURL) + } else { + if let childItems = opmlItem.children { + for childItem in childItems { + if let webFeedURL = childItem.feedSpecifier?.feedURL { + webFeedURLs.insert(webFeedURL) + } + } + } + } + } + + self.accountZone.importOPML(rootExternalID: rootExternalID, items: normalizedItems) { _ in + self.refreshAll(for: account, downloadFeeds: false, completion: completion) } - completion(.success(())) - } func createWebFeed(for account: Account, url urlString: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { + let editedName = name == nil || name!.isEmpty ? nil : name + guard let url = URL(string: urlString) else { completion(.failure(LocalAccountDelegateError.invalidParameter)) return } - refreshProgress.addToNumberOfTasksAndRemaining(1) + BatchUpdate.shared.start() + refreshProgress.addToNumberOfTasksAndRemaining(3) FeedFinder.find(url: url) { result in + self.refreshProgress.completeTask() switch result { case .success(let feedSpecifiers): - guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), - let url = URL(string: bestFeedSpecifier.urlString) else { - self.refreshProgress.completeTask() - completion(.failure(AccountError.createErrorNotFound)) - return + guard let bestFeedSpecifier = FeedSpecifier.bestFeed(in: feedSpecifiers), let url = URL(string: bestFeedSpecifier.urlString) else { + BatchUpdate.shared.end() + self.refreshProgress.clear() + completion(.failure(AccountError.createErrorNotFound)) + return } if account.hasWebFeed(withURL: bestFeedSpecifier.urlString) { - self.refreshProgress.completeTask() + BatchUpdate.shared.end() + self.refreshProgress.clear() completion(.failure(AccountError.createErrorAlreadySubscribed)) return } - let feed = account.createWebFeed(with: nil, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) - - InitialFeedDownloader.download(url) { parsedFeed in - self.refreshProgress.completeTask() + self.accountZone.createWebFeed(url: bestFeedSpecifier.urlString, editedName: editedName, container: container) { result in - if let parsedFeed = parsedFeed { - account.update(feed, with: parsedFeed, {_ in}) + self.refreshProgress.completeTask() + switch result { + case .success(let externalID): + + let feed = account.createWebFeed(with: nil, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) + feed.editedName = editedName + feed.externalID = externalID + container.addWebFeed(feed) + + InitialFeedDownloader.download(url) { parsedFeed in + self.refreshProgress.completeTask() + + if let parsedFeed = parsedFeed { + account.update(feed, with: parsedFeed, {_ in + BatchUpdate.shared.end() + completion(.success(feed)) + }) + } + + } + + case .failure(let error): + BatchUpdate.shared.end() + self.refreshProgress.clear() + completion(.failure(error)) } - - feed.editedName = name - - container.addWebFeed(feed) - completion(.success(feed)) - } - + case .failure: - self.refreshProgress.completeTask() + BatchUpdate.shared.end() + self.refreshProgress.clear() completion(.failure(AccountError.createErrorNotFound)) } @@ -166,62 +274,224 @@ final class CloudKitAccountDelegate: AccountDelegate { } func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result) -> Void) { - feed.editedName = name - completion(.success(())) + let editedName = name.isEmpty ? nil : name + refreshProgress.addToNumberOfTasksAndRemaining(1) + accountZone.renameWebFeed(feed, editedName: editedName) { result in + self.refreshProgress.completeTask() + switch result { + case .success: + feed.editedName = name + completion(.success(())) + case .failure(let error): + self.processAccountError(account, error) + completion(.failure(error)) + } + } } func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result) -> Void) { - container.removeWebFeed(feed) - completion(.success(())) + refreshProgress.addToNumberOfTasksAndRemaining(1) + accountZone.removeWebFeed(feed, from: container) { result in + self.refreshProgress.completeTask() + switch result { + case .success: + container.removeWebFeed(feed) + completion(.success(())) + case .failure(let error): + self.processAccountError(account, error) + completion(.failure(error)) + } + } } - func moveWebFeed(for account: Account, with feed: WebFeed, from: Container, to: Container, completion: @escaping (Result) -> Void) { - from.removeWebFeed(feed) - to.addWebFeed(feed) - completion(.success(())) + func moveWebFeed(for account: Account, with feed: WebFeed, from fromContainer: Container, to toContainer: Container, completion: @escaping (Result) -> Void) { + refreshProgress.addToNumberOfTasksAndRemaining(1) + accountZone.moveWebFeed(feed, from: fromContainer, to: toContainer) { result in + self.refreshProgress.completeTask() + switch result { + case .success: + fromContainer.removeWebFeed(feed) + toContainer.addWebFeed(feed) + completion(.success(())) + case .failure(let error): + self.processAccountError(account, error) + completion(.failure(error)) + } + } } func addWebFeed(for account: Account, with feed: WebFeed, to container: Container, completion: @escaping (Result) -> Void) { - container.addWebFeed(feed) - completion(.success(())) + refreshProgress.addToNumberOfTasksAndRemaining(1) + accountZone.addWebFeed(feed, to: container) { result in + self.refreshProgress.completeTask() + switch result { + case .success: + container.addWebFeed(feed) + completion(.success(())) + case .failure(let error): + self.processAccountError(account, error) + completion(.failure(error)) + } + } } func restoreWebFeed(for account: Account, feed: WebFeed, container: Container, completion: @escaping (Result) -> Void) { - container.addWebFeed(feed) - completion(.success(())) + refreshProgress.addToNumberOfTasksAndRemaining(1) + accountZone.createWebFeed(url: feed.url, editedName: feed.editedName, container: container) { result in + self.refreshProgress.completeTask() + switch result { + case .success(let externalID): + feed.externalID = externalID + container.addWebFeed(feed) + completion(.success(())) + case .failure(let error): + self.processAccountError(account, error) + completion(.failure(error)) + } + } } - func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { - if let folder = account.ensureFolder(with: name) { - completion(.success(folder)) - } else { - completion(.failure(FeedbinAccountDelegateError.invalidParameter)) + func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + refreshProgress.addToNumberOfTasksAndRemaining(1) + accountZone.createFolder(name: name) { result in + self.refreshProgress.completeTask() + switch result { + case .success(let externalID): + if let folder = account.ensureFolder(with: name) { + folder.externalID = externalID + completion(.success(folder)) + } else { + completion(.failure(FeedbinAccountDelegateError.invalidParameter)) + } + case .failure(let error): + self.processAccountError(account, error) + completion(.failure(error)) + } } } func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> Void) { - folder.name = name - completion(.success(())) + refreshProgress.addToNumberOfTasksAndRemaining(1) + accountZone.renameFolder(folder, to: name) { result in + self.refreshProgress.completeTask() + switch result { + case .success: + folder.name = name + completion(.success(())) + case .failure(let error): + self.processAccountError(account, error) + completion(.failure(error)) + } + } } func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) { - account.removeFolder(folder) - completion(.success(())) + refreshProgress.addToNumberOfTasksAndRemaining(1) + accountZone.removeFolder(folder) { result in + self.refreshProgress.completeTask() + switch result { + case .success: + account.removeFolder(folder) + completion(.success(())) + case .failure(let error): + self.processAccountError(account, error) + completion(.failure(error)) + } + } } func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result) -> Void) { - account.addFolder(folder) - completion(.success(())) + guard let name = folder.name else { + completion(.failure(LocalAccountDelegateError.invalidParameter)) + return + } + + let feedsToRestore = folder.topLevelWebFeeds + refreshProgress.addToNumberOfTasksAndRemaining(1 + feedsToRestore.count) + + accountZone.createFolder(name: name) { result in + self.refreshProgress.completeTask() + switch result { + case .success(let externalID): + folder.externalID = externalID + account.addFolder(folder) + + let group = DispatchGroup() + for feed in feedsToRestore { + + folder.topLevelWebFeeds.remove(feed) + + group.enter() + self.restoreWebFeed(for: account, feed: feed, container: folder) { result in + self.refreshProgress.completeTask() + group.leave() + switch result { + case .success: + break + case .failure(let error): + os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription) + } + } + + } + + group.notify(queue: DispatchQueue.main) { + account.addFolder(folder) + completion(.success(())) + } + + case .failure(let error): + self.processAccountError(account, error) + completion(.failure(error)) + } + } } func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { + let syncStatuses = articles.map { article in + return SyncStatus(articleID: article.articleID, key: statusKey, flag: flag) + } + database.insertStatuses(syncStatuses) + + database.selectPendingCount { result in + if let count = try? result.get(), count > 100 { + self.sendArticleStatus(for: account) { _ in } + } + } + return try? account.update(articles, statusKey: statusKey, flag: flag) } func accountDidInitialize(_ account: Account) { + accountZone.delegate = CloudKitAcountZoneDelegate(account: account, refreshProgress: refreshProgress) + articlesZone.delegate = CloudKitArticlesZoneDelegate(account: account, database: database, articlesZone: articlesZone) + + // Check to see if this is a new account and initialize anything we need + if account.externalID == nil { + accountZone.findOrCreateAccount() { result in + switch result { + case .success(let externalID): + account.externalID = externalID + self.refreshAll(for: account, downloadFeeds: false) { result in + if case .failure(let error) = result { + os_log(.error, log: self.log, "Error while doing intial refresh: %@", error.localizedDescription) + } + } + case .failure(let error): + os_log(.error, log: self.log, "Error adding account container: %@", error.localizedDescription) + } + } + zones.forEach { zone in + zone.subscribeToZoneChanges() + } + } + } func accountWillBeDeleted(_ account: Account) { + zones.forEach { zone in + zone.resetChangeToken() + } } static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL? = nil, completion: (Result) -> Void) { @@ -235,10 +505,86 @@ final class CloudKitAccountDelegate: AccountDelegate { } func suspendDatabase() { - // Nothing to do + database.suspend() } func resume() { refresher.resume() + database.resume() } } + +private extension CloudKitAccountDelegate { + + func refreshAll(for account: Account, downloadFeeds: Bool, completion: @escaping (Result) -> Void) { + + let intialWebFeedsCount = downloadFeeds ? account.flattenedWebFeeds().count : 0 + refreshProgress.addToNumberOfTasksAndRemaining(3 + intialWebFeedsCount) + + BatchUpdate.shared.start() + accountZone.fetchChangesInZone() { result in + BatchUpdate.shared.end() + switch result { + case .success: + + let webFeeds = account.flattenedWebFeeds() + if downloadFeeds { + self.refreshProgress.addToNumberOfTasksAndRemaining(webFeeds.count - intialWebFeedsCount) + } + + self.refreshProgress.completeTask() + self.sendArticleStatus(for: account) { result in + switch result { + case .success: + + self.refreshProgress.completeTask() + self.refreshArticleStatus(for: account) { result in + switch result { + case .success: + + self.refreshProgress.completeTask() + + guard downloadFeeds else { + completion(.success(())) + return + } + + self.refresher.refreshFeeds(webFeeds, feedCompletionBlock: { _ in self.refreshProgress.completeTask() }) { + account.metadata.lastArticleFetchEndTime = Date() + self.refreshProgress.clear() + completion(.success(())) + } + + case .failure(let error): + self.processAccountError(account, error) + self.refreshProgress.clear() + completion(.failure(error)) + } + } + + case .failure(let error): + self.processAccountError(account, error) + self.refreshProgress.clear() + completion(.failure(error)) + } + + } + + case .failure(let error): + self.processAccountError(account, error) + self.refreshProgress.clear() + completion(.failure(error)) + } + } + } + + func processAccountError(_ account: Account, _ error: Error) { + if case CloudKitZoneError.userDeletedZone = error { + account.removeFeeds(account.topLevelWebFeeds) + for folder in account.folders ?? Set() { + account.removeFolder(folder) + } + } + } + +} diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift index 0d9072bae..e9d73bcff 100644 --- a/Frameworks/Account/CloudKit/CloudKitAccountZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitAccountZone.swift @@ -7,6 +7,9 @@ // import Foundation +import os.log +import RSWeb +import RSParser import CloudKit final class CloudKitAccountZone: CloudKitZone { @@ -15,79 +18,317 @@ final class CloudKitAccountZone: CloudKitZone { return CKRecordZone.ID(zoneName: "Account", ownerName: CKCurrentUserDefaultName) } - let container: CKContainer - let database: CKDatabase + var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") + + weak var container: CKContainer? + weak var database: CKDatabase? + var delegate: CloudKitZoneDelegate? - init(container: CKContainer) { + struct CloudKitWebFeed { + static let recordType = "AccountWebFeed" + struct Fields { + static let url = "url" + static let editedName = "editedName" + static let containerExternalIDs = "containerExternalIDs" + } + } + + struct CloudKitContainer { + static let recordType = "AccountContainer" + struct Fields { + static let isAccount = "isAccount" + static let name = "name" + } + } + + init(container: CKContainer) { self.container = container self.database = container.privateCloudDatabase } - - /// Persist a feed record to iCloud and return the external key - func createFeed(url: String, editedName: String?, completion: @escaping (Result) -> Void) { - let record = CKRecord(recordType: "Feed", recordID: generateRecordID()) - record["url"] = url - if let editedName = editedName { - record["editedName"] = editedName + + func importOPML(rootExternalID: String, items: [RSOPMLItem], completion: @escaping (Result) -> Void) { + var records = [CKRecord]() + var feedRecords = [String: CKRecord]() + + func processFeed(feedSpecifier: RSOPMLFeedSpecifier, containerExternalID: String) { + if let webFeedRecord = feedRecords[feedSpecifier.feedURL], var containerExternalIDs = webFeedRecord[CloudKitWebFeed.Fields.containerExternalIDs] as? [String] { + containerExternalIDs.append(containerExternalID) + webFeedRecord[CloudKitWebFeed.Fields.containerExternalIDs] = containerExternalIDs + } else { + let webFeedRecord = newWebFeedCKRecord(feedSpecifier: feedSpecifier, containerExternalID: containerExternalID) + records.append(webFeedRecord) + feedRecords[feedSpecifier.feedURL] = webFeedRecord + } } - save(record: record, completion: completion) + for item in items { + if let feedSpecifier = item.feedSpecifier { + processFeed(feedSpecifier: feedSpecifier, containerExternalID: rootExternalID) + } else { + if let title = item.titleFromAttributes { + let containerRecord = newContainerCKRecord(name: title) + records.append(containerRecord) + item.children?.forEach { itemChild in + if let feedSpecifier = itemChild.feedSpecifier { + processFeed(feedSpecifier: feedSpecifier, containerExternalID: containerRecord.externalID) + } + } + } + } + } + + save(records, completion: completion) + } + + /// Persist a web feed record to iCloud and return the external key + func createWebFeed(url: String, editedName: String?, container: Container, completion: @escaping (Result) -> Void) { + let recordID = CKRecord.ID(recordName: url.md5String, zoneID: Self.zoneID) + let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: recordID) + record[CloudKitWebFeed.Fields.url] = url + if let editedName = editedName { + record[CloudKitWebFeed.Fields.editedName] = editedName + } + + guard let containerExternalID = container.externalID else { + completion(.failure(CloudKitZoneError.invalidParameter)) + return + } + record[CloudKitWebFeed.Fields.containerExternalIDs] = [containerExternalID] + + save(record) { result in + switch result { + case .success: + completion(.success(record.externalID)) + case .failure(let error): + completion(.failure(error)) + } + } } -// private func fetchChangesInZones(_ callback: ((Error?) -> Void)? = nil) { -// let changesOp = CKFetchRecordZoneChangesOperation(recordZoneIDs: zoneIds, optionsByRecordZoneID: zoneIdOptions) -// changesOp.fetchAllChanges = true -// -// changesOp.recordZoneChangeTokensUpdatedBlock = { [weak self] zoneId, token, _ in -// guard let self = self else { return } -// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == zoneId }) else { return } -// syncObject.zoneChangesToken = token -// } -// -// changesOp.recordChangedBlock = { [weak self] record in -// /// The Cloud will return the modified record since the last zoneChangesToken, we need to do local cache here. -// /// Handle the record: -// guard let self = self else { return } -// guard let syncObject = self.syncObjects.first(where: { $0.recordType == record.recordType }) else { return } -// syncObject.add(record: record) -// } -// -// changesOp.recordWithIDWasDeletedBlock = { [weak self] recordId, _ in -// guard let self = self else { return } -// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == recordId.zoneID }) else { return } -// syncObject.delete(recordID: recordId) -// } -// -// changesOp.recordZoneFetchCompletionBlock = { [weak self](zoneId ,token, _, _, error) in -// guard let self = self else { return } -// switch ErrorHandler.shared.resultType(with: error) { -// case .success: -// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == zoneId }) else { return } -// syncObject.zoneChangesToken = token -// case .retry(let timeToWait, _): -// ErrorHandler.shared.retryOperationIfPossible(retryAfter: timeToWait, block: { -// self.fetchChangesInZones(callback) -// }) -// case .recoverableError(let reason, _): -// switch reason { -// case .changeTokenExpired: -// /// The previousServerChangeToken value is too old and the client must re-sync from scratch -// guard let syncObject = self.syncObjects.first(where: { $0.zoneID == zoneId }) else { return } -// syncObject.zoneChangesToken = nil -// self.fetchChangesInZones(callback) -// default: -// return -// } -// default: -// return -// } -// } -// -// changesOp.fetchRecordZoneChangesCompletionBlock = { error in -// callback?(error) -// } -// -// database.add(changesOp) -// } + /// 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)) + return + } + + let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID) + let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: recordID) + record[CloudKitWebFeed.Fields.editedName] = editedName + + save(record) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } + + /// Removes a web feed from a container and optionally deletes it, calling the completion with true if deleted + func removeWebFeed(_ webFeed: WebFeed, from: Container, completion: @escaping (Result) -> Void) { + guard let fromContainerExternalID = from.externalID else { + completion(.failure(CloudKitZoneError.invalidParameter)) + return + } + + fetch(externalID: webFeed.externalID) { result in + switch result { + case .success(let record): + + if let containerExternalIDs = record[CloudKitWebFeed.Fields.containerExternalIDs] as? [String] { + var containerExternalIDSet = Set(containerExternalIDs) + containerExternalIDSet.remove(fromContainerExternalID) + + if containerExternalIDSet.isEmpty { + self.delete(externalID: webFeed.externalID) { result in + switch result { + case .success: + completion(.success(true)) + case .failure(let error): + completion(.failure(error)) + } + } + + } else { + + record[CloudKitWebFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet) + self.save(record) { result in + switch result { + case .success: + completion(.success(false)) + case .failure(let error): + completion(.failure(error)) + } + } + + } + } + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func moveWebFeed(_ webFeed: WebFeed, from: Container, to: Container, completion: @escaping (Result) -> Void) { + guard let fromContainerExternalID = from.externalID, let toContainerExternalID = to.externalID else { + completion(.failure(CloudKitZoneError.invalidParameter)) + return + } + + fetch(externalID: webFeed.externalID) { result in + switch result { + case .success(let record): + if let containerExternalIDs = record[CloudKitWebFeed.Fields.containerExternalIDs] as? [String] { + var containerExternalIDSet = Set(containerExternalIDs) + containerExternalIDSet.remove(fromContainerExternalID) + containerExternalIDSet.insert(toContainerExternalID) + record[CloudKitWebFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet) + self.save(record, completion: completion) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + func addWebFeed(_ webFeed: WebFeed, to: Container, completion: @escaping (Result) -> Void) { + guard let toContainerExternalID = to.externalID else { + completion(.failure(CloudKitZoneError.invalidParameter)) + return + } + + fetch(externalID: webFeed.externalID) { result in + switch result { + case .success(let record): + if let containerExternalIDs = record[CloudKitWebFeed.Fields.containerExternalIDs] as? [String] { + var containerExternalIDSet = Set(containerExternalIDs) + containerExternalIDSet.insert(toContainerExternalID) + record[CloudKitWebFeed.Fields.containerExternalIDs] = Array(containerExternalIDSet) + self.save(record, completion: completion) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + + func findOrCreateAccount(completion: @escaping (Result) -> Void) { + let predicate = NSPredicate(format: "isAccount = \"1\"") + let ckQuery = CKQuery(recordType: CloudKitContainer.recordType, predicate: predicate) + + database?.perform(ckQuery, inZoneWith: Self.zoneID) { [weak self] records, error in + guard let self = self else { return } + + switch CloudKitZoneResult.resolve(error) { + case .success: + DispatchQueue.main.async { + if records!.count > 0 { + completion(.success(records![0].externalID)) + } else { + self.createContainer(name: "Account", isAccount: true, completion: completion) + } + } + case .retry(let timeToWait): + self.retryIfPossible(after: timeToWait) { + self.findOrCreateAccount(completion: completion) + } + case .zoneNotFound, .userDeletedZone: + self.createZoneRecord() { result in + switch result { + case .success: + self.findOrCreateAccount(completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(CloudKitError(error))) + } + } + } + default: + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } + } + } + + query(ckQuery) { result in + switch result { + case .success(let records): + if records.count > 0 { + completion(.success(records[0].externalID)) + } else { + self.createContainer(name: "Account", isAccount: true, completion: completion) + } + case .failure: + self.createContainer(name: "Account", isAccount: true, completion: completion) + } + } + } + + func createFolder(name: String, completion: @escaping (Result) -> Void) { + createContainer(name: name, isAccount: false, completion: completion) + } + + func renameFolder(_ folder: Folder, to name: String, completion: @escaping (Result) -> Void) { + guard let externalID = folder.externalID else { + completion(.failure(CloudKitZoneError.invalidParameter)) + return + } + + let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID) + let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: recordID) + record[CloudKitContainer.Fields.name] = name + + save(record) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func removeFolder(_ folder: Folder, completion: @escaping (Result) -> Void) { + delete(externalID: folder.externalID, completion: completion) + } + +} + +private extension CloudKitAccountZone { + + func newWebFeedCKRecord(feedSpecifier: RSOPMLFeedSpecifier, containerExternalID: String) -> CKRecord { + let record = CKRecord(recordType: CloudKitWebFeed.recordType, recordID: generateRecordID()) + record[CloudKitWebFeed.Fields.url] = feedSpecifier.feedURL + if let editedName = feedSpecifier.title { + record[CloudKitWebFeed.Fields.editedName] = editedName + } + record[CloudKitWebFeed.Fields.containerExternalIDs] = [containerExternalID] + return record + } + + func newContainerCKRecord(name: String) -> CKRecord { + let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: generateRecordID()) + record[CloudKitContainer.Fields.name] = name + record[CloudKitContainer.Fields.isAccount] = "0" + return record + } + + func createContainer(name: String, isAccount: Bool, completion: @escaping (Result) -> Void) { + let record = CKRecord(recordType: CloudKitContainer.recordType, recordID: generateRecordID()) + record[CloudKitContainer.Fields.name] = name + record[CloudKitContainer.Fields.isAccount] = isAccount ? "1" : "0" + + save(record) { result in + switch result { + case .success: + completion(.success(record.externalID)) + case .failure(let error): + completion(.failure(error)) + } + } + } } diff --git a/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift new file mode 100644 index 000000000..6ab4004fa --- /dev/null +++ b/Frameworks/Account/CloudKit/CloudKitAccountZoneDelegate.swift @@ -0,0 +1,176 @@ +// +// CloudKitAccountZoneDelegate.swift +// Account +// +// Created by Maurice Parker on 3/29/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import os.log +import RSWeb +import CloudKit + +class CloudKitAcountZoneDelegate: CloudKitZoneDelegate { + + private typealias UnclaimedWebFeed = (url: URL, editedName: String?, webFeedExternalID: String) + private var unclaimedWebFeeds = [String: [UnclaimedWebFeed]]() + + private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") + + weak var account: Account? + weak var refreshProgress: DownloadProgress? + + init(account: Account, refreshProgress: DownloadProgress) { + self.account = account + self.refreshProgress = refreshProgress + } + + func cloudKitDidChange(record: CKRecord) { + switch record.recordType { + case CloudKitAccountZone.CloudKitWebFeed.recordType: + addOrUpdateWebFeed(record) + case CloudKitAccountZone.CloudKitContainer.recordType: + addOrUpdateContainer(record) + default: + assertionFailure("Unknown record type: \(record.recordType)") + } + } + + func cloudKitDidDelete(recordKey: CloudKitRecordKey) { + switch recordKey.recordType { + case CloudKitAccountZone.CloudKitWebFeed.recordType: + removeWebFeed(recordKey.recordID.externalID) + case CloudKitAccountZone.CloudKitContainer.recordType: + removeContainer(recordKey.recordID.externalID) + default: + assertionFailure("Unknown record type: \(recordKey.recordType)") + } + } + + func cloudKitDidModify(changed: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result) -> Void) { + completion(.success(())) + } + + func addOrUpdateWebFeed(_ record: CKRecord) { + guard let account = account, + let urlString = record[CloudKitAccountZone.CloudKitWebFeed.Fields.url] as? String, + let containerExternalIDs = record[CloudKitAccountZone.CloudKitWebFeed.Fields.containerExternalIDs] as? [String], + let url = URL(string: urlString) else { return } + + let editedName = record[CloudKitAccountZone.CloudKitWebFeed.Fields.editedName] as? String + + if let webFeed = account.existingWebFeed(withExternalID: record.externalID) { + updateWebFeed(webFeed, editedName: editedName, containerExternalIDs: containerExternalIDs) + } else { + var webFeed: WebFeed? = nil + for containerExternalID in containerExternalIDs { + if let container = account.existingContainer(withExternalID: containerExternalID) { + if webFeed == nil { + webFeed = createWebFeed(url: url, editedName: editedName, webFeedExternalID: record.externalID) + } + container.addWebFeed(webFeed!) + } else { + addUnclaimedWebFeed(url: url, editedName: editedName, webFeedExternalID: record.externalID, containerExternalID: containerExternalID) + } + } + } + } + + func removeWebFeed(_ externalID: String) { + if let webFeed = account?.existingWebFeed(withExternalID: externalID), let containers = account?.existingContainers(withWebFeed: webFeed) { + containers.forEach { $0.removeWebFeed(webFeed) } + } + } + + func addOrUpdateContainer(_ record: CKRecord) { + guard let account = account, + let name = record[CloudKitAccountZone.CloudKitContainer.Fields.name] as? String, + let isAccount = record[CloudKitAccountZone.CloudKitContainer.Fields.isAccount] as? String, + isAccount != "1" else { return } + + var folder = account.existingFolder(withExternalID: record.externalID) + folder?.name = name + + if folder == nil { + folder = account.ensureFolder(with: name) + folder?.externalID = record.externalID + } + + if let folder = folder, let containerExternalID = folder.externalID, let unclaimedWebFeeds = unclaimedWebFeeds[containerExternalID] { + for unclaimedWebFeed in unclaimedWebFeeds { + var webFeed = account.existingWebFeed(withExternalID: unclaimedWebFeed.webFeedExternalID) + if webFeed == nil { + webFeed = createWebFeed(url: unclaimedWebFeed.url, editedName: unclaimedWebFeed.editedName, webFeedExternalID: unclaimedWebFeed.webFeedExternalID) + } + if let webFeed = webFeed { + folder.addWebFeed(webFeed) + } + } + self.unclaimedWebFeeds.removeValue(forKey: containerExternalID) + } + } + + func removeContainer(_ externalID: String) { + if let folder = account?.existingFolder(withExternalID: externalID) { + account?.removeFolder(folder) + } + } + +} + +private extension CloudKitAcountZoneDelegate { + + func updateWebFeed(_ webFeed: WebFeed, editedName: String?, containerExternalIDs: [String]) { + guard let account = account else { return } + webFeed.editedName = editedName + + let existingContainers = account.existingContainers(withWebFeed: webFeed) + let existingContainerExternalIds = existingContainers.compactMap { $0.externalID } + + let diff = containerExternalIDs.difference(from: existingContainerExternalIds) + + for change in diff { + switch change { + case .remove(_, let externalID, _): + if let container = existingContainers.first(where: { $0.externalID == externalID }) { + container.removeWebFeed(webFeed) + } + case .insert(_, let externalID, _): + if let container = account.existingContainer(withExternalID: externalID) { + container.addWebFeed(webFeed) + } + } + } + } + + func createWebFeed(url: URL, editedName: String?, webFeedExternalID: String) -> WebFeed? { + guard let account = account else { return nil } + + let webFeed = account.createWebFeed(with: editedName, url: url.absoluteString, webFeedID: url.absoluteString, homePageURL: nil) + webFeed.editedName = editedName + webFeed.externalID = webFeedExternalID + + refreshProgress?.addToNumberOfTasksAndRemaining(1) + InitialFeedDownloader.download(url) { parsedFeed in + self.refreshProgress?.completeTask() + if let parsedFeed = parsedFeed { + account.update(webFeed, with: parsedFeed, {_ in }) + } + } + + return webFeed + } + + func addUnclaimedWebFeed(url: URL, editedName: String?, webFeedExternalID: String, containerExternalID: String) { + if var unclaimedWebFeeds = self.unclaimedWebFeeds[containerExternalID] { + unclaimedWebFeeds.append(UnclaimedWebFeed(url: url, editedName: editedName, webFeedExternalID: webFeedExternalID)) + self.unclaimedWebFeeds[containerExternalID] = unclaimedWebFeeds + } else { + var unclaimedWebFeeds = [UnclaimedWebFeed]() + unclaimedWebFeeds.append(UnclaimedWebFeed(url: url, editedName: editedName, webFeedExternalID: webFeedExternalID)) + self.unclaimedWebFeeds[containerExternalID] = unclaimedWebFeeds + } + } + +} diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift new file mode 100644 index 000000000..080799ed8 --- /dev/null +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift @@ -0,0 +1,226 @@ +// +// CloudKitArticlesZone.swift +// Account +// +// Created by Maurice Parker on 4/1/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import os.log +import RSParser +import RSWeb +import CloudKit +import Articles +import SyncDatabase + +final class CloudKitArticlesZone: CloudKitZone { + + static var zoneID: CKRecordZone.ID { + return CKRecordZone.ID(zoneName: "Articles", ownerName: CKCurrentUserDefaultName) + } + + var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") + + weak var container: CKContainer? + weak var database: CKDatabase? + var delegate: CloudKitZoneDelegate? = nil + + struct CloudKitArticle { + static let recordType = "Article" + struct Fields { + static let articleStatus = "articleStatus" + static let webFeedURL = "webFeedURL" + static let uniqueID = "uniqueID" + static let title = "title" + static let contentHTML = "contentHTML" + static let contentText = "contentText" + static let url = "url" + static let externalURL = "externalURL" + static let summary = "summary" + static let imageURL = "imageURL" + static let datePublished = "datePublished" + static let dateModified = "dateModified" + static let parsedAuthors = "parsedAuthors" + } + } + + struct CloudKitArticleStatus { + static let recordType = "ArticleStatus" + struct Fields { + static let read = "read" + static let starred = "starred" + static let userDeleted = "userDeleted" + } + } + + init(container: CKContainer) { + self.container = container + self.database = container.privateCloudDatabase + } + + func refreshArticleStatus(completion: @escaping ((Result) -> Void)) { + fetchChangesInZone() { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + if case CloudKitZoneError.userDeletedZone = error { + self.createZoneRecord() { result in + switch result { + case .success: + self.refreshArticleStatus(completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + } else { + completion(.failure(error)) + } + } + } + } + + func sendArticleStatus(_ syncStatuses: [SyncStatus], starredArticles: Set
, completion: @escaping ((Result) -> Void)) { + var records = makeStatusRecords(syncStatuses) + makeArticleRecordsIfNecessary(starredArticles) { result in + switch result { + case .success(let articleRecords): + records.append(contentsOf: articleRecords) + self.modify(recordsToSave: records, recordIDsToDelete: []) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + self.handleSendArticleStatusError(error, syncStatuses: syncStatuses, starredArticles: starredArticles, completion: completion) + } + } + case .failure(let error): + self.handleSendArticleStatusError(error, syncStatuses: syncStatuses, starredArticles: starredArticles, completion: completion) + } + } + } + + func handleSendArticleStatusError(_ error: Error, syncStatuses: [SyncStatus], starredArticles: Set
, completion: @escaping ((Result) -> Void)) { + if case CloudKitZoneError.userDeletedZone = error { + self.createZoneRecord() { result in + switch result { + case .success: + self.sendArticleStatus(syncStatuses, starredArticles: starredArticles, completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + } else { + completion(.failure(error)) + } + } + +} + +private extension CloudKitArticlesZone { + + func makeStatusRecords(_ syncStatuses: [SyncStatus]) -> [CKRecord] { + var records = [String: CKRecord]() + + for status in syncStatuses { + + var record = records[status.articleID] + if record == nil { + let recordID = CKRecord.ID(recordName: status.articleID, zoneID: Self.zoneID) + record = CKRecord(recordType: CloudKitArticleStatus.recordType, recordID: recordID) + records[status.articleID] = record + } + + switch status.key { + case .read: + record![CloudKitArticleStatus.Fields.read] = status.flag ? "1" : "0" + case .starred: + record![CloudKitArticleStatus.Fields.starred] = status.flag ? "1" : "0" + case .userDeleted: + record![CloudKitArticleStatus.Fields.userDeleted] = status.flag ? "1" : "0" + } + } + + return Array(records.values) + } + + func makeArticleRecordsIfNecessary(_ articles: Set
, completion: @escaping ((Result<[CKRecord], Error>) -> Void)) { + let group = DispatchGroup() + var errorOccurred = false + var records = [CKRecord]() + + for article in articles { + + 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) + + 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.webFeedURL] = article.webFeed?.url + 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 + + let encoder = JSONEncoder() + var parsedAuthors = [String]() + + if let authors = article.authors { + for author in authors { + let parsedAuthor = ParsedAuthor(name: author.name, + url: author.url, + avatarURL: author.avatarURL, + emailAddress: author.emailAddress) + if let data = try? encoder.encode(parsedAuthor), let encodedParsedAuthor = String(data: data, encoding: .utf8) { + parsedAuthors.append(encodedParsedAuthor) + } + } + } + + articleRecord[CloudKitArticle.Fields.parsedAuthors] = parsedAuthors + + records.append(articleRecord) + return records + } + + +} diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift new file mode 100644 index 000000000..6b712b932 --- /dev/null +++ b/Frameworks/Account/CloudKit/CloudKitArticlesZoneDelegate.swift @@ -0,0 +1,162 @@ +// +// CloudKitArticlesZoneDelegate.swift +// Account +// +// Created by Maurice Parker on 4/1/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import os.log +import RSParser +import CloudKit +import SyncDatabase + +class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate { + + private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") + + weak var account: Account? + var database: SyncDatabase + weak var articlesZone: CloudKitArticlesZone? + + init(account: Account, database: SyncDatabase, articlesZone: CloudKitArticlesZone) { + self.account = account + self.database = database + self.articlesZone = articlesZone + } + + func cloudKitDidChange(record: CKRecord) { + + } + + func cloudKitDidDelete(recordKey: CloudKitRecordKey) { + // Article downloads clean up old articles and statuses + } + + func cloudKitDidModify(changed: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result) -> Void) { + + database.selectPendingReadStatusArticleIDs() { result in + switch result { + case .success(let pendingReadStatusArticleIDs): + + self.database.selectPendingStarredStatusArticleIDs() { result in + switch result { + case .success(let pendingStarredStatusArticleIDs): + + self.process(records: changed, + pendingReadStatusArticleIDs: pendingReadStatusArticleIDs, + pendingStarredStatusArticleIDs: pendingStarredStatusArticleIDs, + completion: completion) + + case .failure(let error): + os_log(.error, log: self.log, "Error occurred geting pending starred records: %@", error.localizedDescription) + } + } + case .failure(let error): + os_log(.error, log: self.log, "Error occurred getting pending read status records: %@", error.localizedDescription) + } + + } + + } + +} + +private extension CloudKitArticlesZoneDelegate { + + func process(records: [CKRecord], pendingReadStatusArticleIDs: Set, pendingStarredStatusArticleIDs: Set, completion: @escaping (Result) -> Void) { + + let receivedUnreadArticleIDs = Set(records.filter({ $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.read] == "0" }).map({ $0.externalID })) + let receivedReadArticleIDs = Set(records.filter({ $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.read] == "1" }).map({ $0.externalID })) + let receivedUnstarredArticleIDs = Set(records.filter({ $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.starred] == "0" }).map({ $0.externalID })) + let receivedStarredArticleIDs = Set(records.filter({ $0[CloudKitArticlesZone.CloudKitArticleStatus.Fields.starred] == "1" }).map({ $0.externalID })) + + let receivedStarredArticles = records.filter({ $0.recordType == CloudKitArticlesZone.CloudKitArticle.recordType }) + + let updateableUnreadArticleIDs = receivedUnreadArticleIDs.subtracting(pendingReadStatusArticleIDs) + let updateableReadArticleIDs = receivedReadArticleIDs.subtracting(pendingReadStatusArticleIDs) + let updateableUnstarredArticleIDs = receivedUnstarredArticleIDs.subtracting(pendingStarredStatusArticleIDs) + let updateableStarredArticleIDs = receivedStarredArticleIDs.subtracting(pendingStarredStatusArticleIDs) + + let group = DispatchGroup() + + group.enter() + account?.markAsUnread(updateableUnreadArticleIDs) { _ in + group.leave() + } + + group.enter() + account?.markAsRead(updateableReadArticleIDs) { _ in + group.leave() + } + + group.enter() + account?.markAsUnstarred(updateableUnstarredArticleIDs) { _ in + group.leave() + } + + group.enter() + account?.markAsStarred(updateableStarredArticleIDs) { _ in + group.leave() + } + + for receivedStarredArticle in receivedStarredArticles { + if let parsedItem = makeParsedItem(receivedStarredArticle) { + group.enter() + self.account?.update(parsedItem.feedURL, 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) + } + } + } + } + + group.notify(queue: DispatchQueue.main) { + completion(.success(())) + } + + } + + + func makeParsedItem(_ articleRecord: CKRecord) -> ParsedItem? { + var parsedAuthors = Set() + + let decoder = JSONDecoder() + + if let encodedParsedAuthors = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.parsedAuthors] as? [String] { + for encodedParsedAuthor in encodedParsedAuthors { + if let data = encodedParsedAuthor.data(using: .utf8), let parsedAuthor = try? decoder.decode(ParsedAuthor.self, from: data) { + parsedAuthors.insert(parsedAuthor) + } + } + } + + guard let uniqueID = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.uniqueID] as? String, + let webFeedURL = articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.webFeedURL] as? String else { + return nil + } + + let parsedItem = ParsedItem(syncServiceID: nil, + uniqueID: uniqueID, + feedURL: webFeedURL, + url: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.url] as? String, + externalURL: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.externalURL] as? String, + title: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.title] as? String, + language: nil, + contentHTML: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.contentHTML] as? String, + contentText: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.contentText] as? String, + summary: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.summary] as? String, + imageURL: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.imageURL] as? String, + bannerImageURL: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.imageURL] as? String, + datePublished: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.datePublished] as? Date, + dateModified: articleRecord[CloudKitArticlesZone.CloudKitArticle.Fields.dateModified] as? Date, + authors: parsedAuthors, + tags: nil, + attachments: nil) + + return parsedItem + } + +} diff --git a/Frameworks/Account/CloudKit/CKError+Extensions.swift b/Frameworks/Account/CloudKit/CloudKitError.swift similarity index 95% rename from Frameworks/Account/CloudKit/CKError+Extensions.swift rename to Frameworks/Account/CloudKit/CloudKitError.swift index cdce8cdfb..a92a4d167 100644 --- a/Frameworks/Account/CloudKit/CKError+Extensions.swift +++ b/Frameworks/Account/CloudKit/CloudKitError.swift @@ -1,18 +1,29 @@ // -// CKError+Extensions.swift +// CloudKitError.swift // Account // // Created by Maurice Parker on 3/26/20. // Copyright © 2020 Ranchero Software, LLC. All rights reserved. // +// Derived from https://github.com/caiyue1993/IceCream import Foundation import CloudKit -extension CKError: LocalizedError { +class CloudKitError: LocalizedError { + let error: Error + + init(_ error: Error) { + self.error = error + } + public var errorDescription: String? { - switch code { + guard let ckError = error as? CKError else { + return error.localizedDescription + } + + switch ckError.code { case .alreadyShared: return NSLocalizedString("Already Shared: a record or share cannot be saved because doing so would cause the same hierarchy of records to exist in multiple shares.", comment: "Known iCloud Error") case .assetFileModified: diff --git a/Frameworks/Account/CloudKit/CloudKitRecordConvertable.swift b/Frameworks/Account/CloudKit/CloudKitRecordConvertable.swift deleted file mode 100644 index b4463f63a..000000000 --- a/Frameworks/Account/CloudKit/CloudKitRecordConvertable.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// CloudKitRecordConvertable.swift -// Account -// -// Created by Maurice Parker on 3/21/20. -// Copyright © 2020 Ranchero Software, LLC. All rights reserved. -// - -import Foundation -import CloudKit - -protocol CloudKitRecordConvertible { - static var cloudKitRecordType: String { get } - static var cloudKitZoneID: CKRecordZone.ID { get } - - var cloudKitPrimaryKey: String { get } - var recordID: CKRecord.ID { get } - var cloudKitRecord: CKRecord { get } - - func assignCloudKitPrimaryKeyIfNecessary() -} - -extension CloudKitRecordConvertible { - - public static var cloudKitRecordType: String { - return String(describing: self) - } - - public var recordID: CKRecord.ID { - return CKRecord.ID(recordName: cloudKitPrimaryKey, zoneID: Self.cloudKitZoneID) - } - -} diff --git a/Frameworks/Account/CloudKit/CloudKitResult.swift b/Frameworks/Account/CloudKit/CloudKitResult.swift index 7f6592f63..c1ce67f86 100644 --- a/Frameworks/Account/CloudKit/CloudKitResult.swift +++ b/Frameworks/Account/CloudKit/CloudKitResult.swift @@ -9,17 +9,17 @@ import Foundation import CloudKit -enum CloudKitResult { +enum CloudKitZoneResult { case success case retry(afterSeconds: Double) - case chunk + case limitExceeded case changeTokenExpired - case partialFailure + case partialFailure(errors: [CKRecord.ID: CKError]) case serverRecordChanged case noZone case failure(error: Error) - static func resolve(_ error: Error?) -> CloudKitResult { + static func resolve(_ error: Error?) -> CloudKitZoneResult { guard error != nil else { return .success } @@ -39,11 +39,17 @@ enum CloudKitResult { case .serverRecordChanged: return .serverRecordChanged 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: - return .chunk - case .zoneNotFound, .userDeletedZone: - return .noZone + return .limitExceeded default: 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 } ) + } + +} diff --git a/Frameworks/Account/CloudKit/CloudKitZone.swift b/Frameworks/Account/CloudKit/CloudKitZone.swift index 44a686f69..ee3f45474 100644 --- a/Frameworks/Account/CloudKit/CloudKitZone.swift +++ b/Frameworks/Account/CloudKit/CloudKitZone.swift @@ -7,35 +7,575 @@ // import CloudKit +import os.log +import RSWeb -public enum CloudKitZoneError: Error { +enum CloudKitZoneError: LocalizedError { + case userDeletedZone + case invalidParameter case unknown + + var errorDescription: String? { + if case .userDeletedZone = self { + return NSLocalizedString("The iCloud data was deleted. Please delete the NetNewsWire iCloud account and add it again to continue using NetNewsWire's iCloud support.", comment: "User deleted zone.") + } else { + return NSLocalizedString("An unexpected CloudKit error occurred.", comment: "An unexpected CloudKit error occurred.") + } + } } -public protocol CloudKitZone: class { +protocol CloudKitZoneDelegate: class { + func cloudKitDidChange(record: CKRecord); + func cloudKitDidDelete(recordKey: CloudKitRecordKey) + func cloudKitDidModify(changed: [CKRecord], deleted: [CloudKitRecordKey], completion: @escaping (Result) -> Void); +} + +typealias CloudKitRecordKey = (recordType: CKRecord.RecordType, recordID: CKRecord.ID) + +protocol CloudKitZone: class { static var zoneID: CKRecordZone.ID { get } - var container: CKContainer { get } - var database: CKDatabase { get } + var log: OSLog { get } + + var container: CKContainer? { get } + var database: CKDatabase? { get } + var delegate: CloudKitZoneDelegate? { get set } + + /// Reset the change token used to determine what point in time we are doing changes fetches + func resetChangeToken() + + /// Generates a new CKRecord.ID using a UUID for the record's name + func generateRecordID() -> CKRecord.ID - // func prepare() + /// Subscribe to changes at a zone level + func subscribeToZoneChanges() - // func fetchChangesInDatabase(_ callback: ((Error?) -> Void)?) - - /// The CloudKit Best Practice is out of date, now use this: - /// https://developer.apple.com/documentation/cloudkit/ckoperation - /// Which problem does this func solve? E.g.: - /// 1.(Offline) You make a local change, involve a operation - /// 2. App exits or ejected by user - /// 3. Back to app again - /// The operation resumes! All works like a magic! - func resumeLongLivedOperationIfPossible() + /// Process a remove notification + func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) } extension CloudKitZone { + /// Reset the change token used to determine what point in time we are doing changes fetches + func resetChangeToken() { + changeToken = nil + } + + func generateRecordID() -> CKRecord.ID { + return CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID) + } + + func retryIfPossible(after: Double, block: @escaping () -> ()) { + let delayTime = DispatchTime.now() + after + DispatchQueue.main.asyncAfter(deadline: delayTime, execute: { + block() + }) + } + + func receiveRemoteNotification(userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + let note = CKRecordZoneNotification(fromRemoteNotificationDictionary: userInfo) + guard note?.recordZoneID?.zoneName == Self.zoneID.zoneName else { + completion() + return + } + + fetchChangesInZone() { result in + if case .failure(let error) = result { + os_log(.error, log: self.log, "%@ zone remote notification fetch error: %@", Self.zoneID.zoneName, error.localizedDescription) + } + completion() + } + } + + func createZoneRecord(completion: @escaping (Result) -> Void) { + guard let database = database else { + completion(.failure(CloudKitZoneError.unknown)) + return + } + + database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in + if let error = error { + DispatchQueue.main.async { + completion(.failure(CloudKitError(error))) + } + } else { + DispatchQueue.main.async { + completion(.success(())) + } + } + } + } + + func subscribeToZoneChanges() { + let subscription = CKRecordZoneSubscription(zoneID: Self.zoneID) + + let info = CKSubscription.NotificationInfo() + info.shouldSendContentAvailable = true + subscription.notificationInfo = info + + save(subscription) { result in + if case .failure(let error) = result { + os_log(.error, log: self.log, "%@ zone subscribe to changes error: %@", Self.zoneID.zoneName, error.localizedDescription) + } + } + } + + /// 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 .zoneNotFound: + self?.createZoneRecord() { result in + switch result { + case .success: + self?.exists(query, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + case .retry(let timeToWait): + self?.retryIfPossible(after: timeToWait) { + self?.exists(query, completion: completion) + } + case .userDeletedZone: + DispatchQueue.main.async { + completion(.failure(CloudKitZoneError.userDeletedZone)) + } + default: + DispatchQueue.main.async { + completion(.failure(CloudKitError(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)) + return + } + + database.perform(query, inZoneWith: Self.zoneID) { [weak self] records, error in + switch CloudKitZoneResult.resolve(error) { + case .success: + DispatchQueue.main.async { + if let records = records { + completion(.success(records)) + } else { + completion(.failure(CloudKitZoneError.unknown)) + } + } + case .zoneNotFound: + self?.createZoneRecord() { result in + switch result { + case .success: + self?.query(query, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + case .retry(let timeToWait): + self?.retryIfPossible(after: timeToWait) { + self?.query(query, completion: completion) + } + case .userDeletedZone: + DispatchQueue.main.async { + completion(.failure(CloudKitZoneError.userDeletedZone)) + } + default: + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } + } + } + } + + /// Fetch a CKRecord by using its externalID + func fetch(externalID: String?, completion: @escaping (Result) -> Void) { + guard let externalID = externalID else { + completion(.failure(CloudKitZoneError.invalidParameter)) + return + } + + let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID) + + database?.fetch(withRecordID: recordID) { [weak self] record, error in + switch CloudKitZoneResult.resolve(error) { + case .success: + DispatchQueue.main.async { + if let record = record { + completion(.success(record)) + } else { + completion(.failure(CloudKitZoneError.unknown)) + } + } + case .zoneNotFound: + self?.createZoneRecord() { result in + switch result { + case .success: + self?.fetch(externalID: externalID, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + case .retry(let timeToWait): + self?.retryIfPossible(after: timeToWait) { + self?.fetch(externalID: externalID, completion: completion) + } + case .userDeletedZone: + DispatchQueue.main.async { + completion(.failure(CloudKitZoneError.userDeletedZone)) + } + default: + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } + } + } + } + + /// Save the CKRecord + func save(_ record: CKRecord, completion: @escaping (Result) -> Void) { + modify(recordsToSave: [record], recordIDsToDelete: [], completion: completion) + } + + /// Save the CKRecords + func save(_ records: [CKRecord], completion: @escaping (Result) -> Void) { + modify(recordsToSave: records, recordIDsToDelete: [], completion: completion) + } + + /// Saves or modifies the records as long as they are unchanged relative to the local version + func saveIfNew(_ records: [CKRecord], completion: @escaping (Result) -> Void) { + let op = CKModifyRecordsOperation(recordsToSave: records, recordIDsToDelete: [CKRecord.ID]()) + op.savePolicy = .ifServerRecordUnchanged + op.isAtomic = false + + op.modifyRecordsCompletionBlock = { [weak self] (_, _, error) in + + guard let self = self else { return } + + switch CloudKitZoneResult.resolve(error) { + case .success: + DispatchQueue.main.async { + completion(.success(())) + } + case .zoneNotFound: + self.createZoneRecord() { result in + switch result { + case .success: + self.saveIfNew(records, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + case .userDeletedZone: + DispatchQueue.main.async { + completion(.failure(CloudKitZoneError.userDeletedZone)) + } + case .retry(let timeToWait): + self.retryIfPossible(after: timeToWait) { + self.saveIfNew(records, completion: completion) + } + case .limitExceeded: + + let chunkedRecords = records.chunked(into: 300) + + let group = DispatchGroup() + var errorOccurred = false + + for chunk in chunkedRecords { + group.enter() + self.saveIfNew(chunk) { result in + if case .failure(let error) = result { + os_log(.error, log: self.log, "%@ zone modify records error: %@", Self.zoneID.zoneName, error.localizedDescription) + errorOccurred = true + } + group.leave() + } + } + + group.notify(queue: DispatchQueue.main) { + if errorOccurred { + completion(.failure(CloudKitZoneError.unknown)) + } else { + completion(.success(())) + } + } + + default: + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } + } + } + + database?.add(op) + } + + /// Save the CKSubscription + func save(_ subscription: CKSubscription, completion: @escaping (Result) -> Void) { + database?.save(subscription) { savedSubscription, error in + switch CloudKitZoneResult.resolve(error) { + case .success: + DispatchQueue.main.async { + completion(.success((savedSubscription!))) + } + case .zoneNotFound: + self.createZoneRecord() { result in + switch result { + case .success: + self.save(subscription, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + case .retry(let timeToWait): + self.retryIfPossible(after: timeToWait) { + self.save(subscription, completion: completion) + } + default: + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } + } + } + } + + /// Delete a CKRecord using its recordID + func delete(recordID: CKRecord.ID, completion: @escaping (Result) -> Void) { + modify(recordsToSave: [], recordIDsToDelete: [recordID], 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)) + return + } + + let recordID = CKRecord.ID(recordName: externalID, zoneID: Self.zoneID) + modify(recordsToSave: [], recordIDsToDelete: [recordID], completion: completion) + } + + /// Delete a CKSubscription + func delete(subscriptionID: String, completion: @escaping (Result) -> Void) { + database?.delete(withSubscriptionID: subscriptionID) { _, error in + switch CloudKitZoneResult.resolve(error) { + case .success: + DispatchQueue.main.async { + completion(.success(())) + } + case .retry(let timeToWait): + self.retryIfPossible(after: timeToWait) { + self.delete(subscriptionID: subscriptionID, completion: completion) + } + default: + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } + } + } + } + + /// 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) + op.savePolicy = .changedKeys + op.isAtomic = true + + op.modifyRecordsCompletionBlock = { [weak self] (_, _, error) in + + guard let self = self else { return } + + switch CloudKitZoneResult.resolve(error) { + case .success: + DispatchQueue.main.async { + completion(.success(())) + } + case .zoneNotFound: + self.createZoneRecord() { result in + switch result { + case .success: + self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + case .userDeletedZone: + DispatchQueue.main.async { + completion(.failure(CloudKitZoneError.userDeletedZone)) + } + case .retry(let timeToWait): + self.retryIfPossible(after: timeToWait) { + self.modify(recordsToSave: recordsToSave, recordIDsToDelete: recordIDsToDelete, completion: completion) + } + case .limitExceeded: + let chunkedRecords = recordsToSave.chunked(into: 300) + + let group = DispatchGroup() + var errorOccurred = false + + for chunk in chunkedRecords { + group.enter() + self.modify(recordsToSave: chunk, recordIDsToDelete: recordIDsToDelete) { result in + if case .failure(let error) = result { + os_log(.error, log: self.log, "%@ zone modify records error: %@", Self.zoneID.zoneName, error.localizedDescription) + errorOccurred = true + } + group.leave() + } + } + + group.notify(queue: DispatchQueue.main) { + if errorOccurred { + completion(.failure(CloudKitZoneError.unknown)) + } else { + completion(.success(())) + } + } + + default: + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } + } + } + + 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]() + var deletedRecordKeys = [CloudKitRecordKey]() + + let zoneConfig = CKFetchRecordZoneChangesOperation.ZoneConfiguration() + zoneConfig.previousServerChangeToken = changeToken + let op = CKFetchRecordZoneChangesOperation(recordZoneIDs: [Self.zoneID], configurationsByRecordZoneID: [Self.zoneID: zoneConfig]) + op.fetchAllChanges = true + + op.recordZoneChangeTokensUpdatedBlock = { [weak self] zoneID, token, _ in + guard let self = self else { return } + + DispatchQueue.main.async { + self.changeToken = token + } + } + + op.recordChangedBlock = { [weak self] record in + guard let self = self else { return } + + changedRecords.append(record) + DispatchQueue.main.async { + self.delegate?.cloudKitDidChange(record: record) + } + } + + op.recordWithIDWasDeletedBlock = { [weak self] recordID, recordType in + guard let self = self else { return } + + let recordKey = CloudKitRecordKey(recordType: recordType, recordID: recordID) + deletedRecordKeys.append(recordKey) + + DispatchQueue.main.async { + self.delegate?.cloudKitDidDelete(recordKey: recordKey) + } + } + + op.recordZoneFetchCompletionBlock = { [weak self] zoneID ,token, _, _, error in + guard let self = self else { return } + + switch CloudKitZoneResult.resolve(error) { + case .success: + DispatchQueue.main.async { + self.changeToken = token + } + case .retry(let timeToWait): + self.retryIfPossible(after: timeToWait) { + self.fetchChangesInZone(completion: completion) + } + default: + os_log(.error, log: self.log, "%@ zone fetch changes error: %@", zoneID.zoneName, error?.localizedDescription ?? "Unknown") + } + } + + op.fetchRecordZoneChangesCompletionBlock = { [weak self] error in + guard let self = self else { return } + + switch CloudKitZoneResult.resolve(error) { + case .success: + DispatchQueue.main.async { + self.delegate?.cloudKitDidModify(changed: changedRecords, deleted: deletedRecordKeys, completion: completion) + } + case .zoneNotFound: + self.createZoneRecord() { result in + switch result { + case .success: + self.fetchChangesInZone(completion: completion) + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + case .userDeletedZone: + DispatchQueue.main.async { + completion(.failure(CloudKitZoneError.userDeletedZone)) + } + case .retry(let timeToWait): + self.retryIfPossible(after: timeToWait) { + self.fetchChangesInZone(completion: completion) + } + case .changeTokenExpired: + DispatchQueue.main.async { + self.changeToken = nil + self.fetchChangesInZone(completion: completion) + } + default: + DispatchQueue.main.async { + completion(.failure(CloudKitError(error!))) + } + } + + } + + database?.add(op) + } + +} + +private extension CloudKitZone { + var changeTokenKey: String { return "cloudkit.server.token.\(Self.zoneID.zoneName)" } @@ -60,138 +600,4 @@ extension CloudKitZone { return config } - func generateRecordID() -> CKRecord.ID { - return CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID) - } - - func createZoneRecord(completion: @escaping (Result) -> Void) { - database.save(CKRecordZone(zoneID: Self.zoneID)) { (recordZone, error) in - if let error = error { - DispatchQueue.main.async { - completion(.failure(error)) - } - } else { - DispatchQueue.main.async { - completion(.success(())) - } - } - } - } - - // func prepare() { - // syncObjects.forEach { - // $0.pipeToEngine = { [weak self] recordsToStore, recordIDsToDelete in - // guard let self = self else { return } - // self.syncRecordsToCloudKit(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete) - // } - // } - // } - - func resumeLongLivedOperationIfPossible() { - container.fetchAllLongLivedOperationIDs { [weak self]( opeIDs, error) in - guard let self = self, error == nil, let ids = opeIDs else { return } - for id in ids { - self.container.fetchLongLivedOperation(withID: id, completionHandler: { [weak self](ope, error) in - guard let self = self, error == nil else { return } - if let modifyOp = ope as? CKModifyRecordsOperation { - modifyOp.modifyRecordsCompletionBlock = { (_,_,_) in - print("Resume modify records success!") - } - self.container.add(modifyOp) - } - }) - } - } - } - - // func startObservingRemoteChanges() { - // NotificationCenter.default.addObserver(forName: Notifications.cloudKitDataDidChangeRemotely.name, object: nil, queue: nil, using: { [weak self](_) in - // guard let self = self else { return } - // DispatchQueue.global(qos: .utility).async { - // self.fetchChangesInDatabase(nil) - // } - // }) - // } - - public func save(record: CKRecord, completion: @escaping (Result) -> Void) { - database.save(record) {(savedRecord, error) in - - switch CloudKitResult.resolve(error) { - - case .success: - DispatchQueue.main.async { - if let savedRecord = savedRecord { - completion(.success(savedRecord.recordID.recordName)) - } else { - completion(.failure(CloudKitZoneError.unknown)) - } - } - - case .retry(let timeToWait): - self.retryOperationIfPossible(retryAfter: timeToWait) { - self.save(record: record, completion: completion) - } - - default: - return - } - - } - - } - - /// Sync local data to CloudKit - /// For more about the savePolicy: https://developer.apple.com/documentation/cloudkit/ckrecordsavepolicy - public func syncRecordsToCloudKit(recordsToStore: [CKRecord], recordIDsToDelete: [CKRecord.ID], completion: @escaping (Result) -> Void) { - let op = CKModifyRecordsOperation(recordsToSave: recordsToStore, recordIDsToDelete: recordIDsToDelete) - - let config = CKOperation.Configuration() - config.isLongLived = true - op.configuration = config - - // We use .changedKeys savePolicy to do unlocked changes here cause my app is contentious and off-line first - // Apple suggests using .ifServerRecordUnchanged save policy - // For more, see Advanced CloudKit(https://developer.apple.com/videos/play/wwdc2014/231/) - op.savePolicy = .changedKeys - - // To avoid CKError.partialFailure, make the operation atomic (if one record fails to get modified, they all fail) - // If you want to handle partial failures, set .isAtomic to false and implement CKOperationResultType .fail(reason: .partialFailure) where appropriate - op.isAtomic = true - - op.modifyRecordsCompletionBlock = { [weak self] (_, _, error) in - - guard let self = self else { return } - - switch CloudKitResult.resolve(error) { - case .success: - DispatchQueue.main.async { - completion(.success(())) - } - case .retry(let timeToWait): - self.retryOperationIfPossible(retryAfter: timeToWait) { - self.syncRecordsToCloudKit(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete, completion: completion) - } - case .chunk: - /// CloudKit says maximum number of items in a single request is 400. - /// So I think 300 should be fine by them. - let chunkedRecords = recordsToStore.chunked(into: 300) - for chunk in chunkedRecords { - self.syncRecordsToCloudKit(recordsToStore: chunk, recordIDsToDelete: recordIDsToDelete, completion: completion) - } - default: - return - } - } - - database.add(op) - } - - func retryOperationIfPossible(retryAfter: Double, block: @escaping () -> ()) { - let delayTime = DispatchTime.now() + retryAfter - DispatchQueue.main.asyncAfter(deadline: delayTime, execute: { - block() - }) - } - } - diff --git a/Frameworks/Account/CloudKit/CloudKitZoneResult.swift b/Frameworks/Account/CloudKit/CloudKitZoneResult.swift new file mode 100644 index 000000000..ca1af57c1 --- /dev/null +++ b/Frameworks/Account/CloudKit/CloudKitZoneResult.swift @@ -0,0 +1,81 @@ +// +// 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: [AnyHashable: 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: CloudKitError(ckError)) + } + case .zoneNotFound: + return .zoneNotFound + case .userDeletedZone: + return .userDeletedZone + case .changeTokenExpired: + return .changeTokenExpired + case .serverRecordChanged: + return .serverRecordChanged + case .partialFailure: + if let partialErrors = ckError.userInfo[CKPartialErrorsByItemIDKey] as? [AnyHashable: CKError] { + if let zoneResult = anyRequestErrors(partialErrors) { + return zoneResult + } else { + return .partialFailure(errors: partialErrors) + } + } else { + return .failure(error: CloudKitError(ckError)) + } + case .limitExceeded: + return .limitExceeded + default: + return .failure(error: CloudKitError(ckError)) + } + + } + +} + +private extension CloudKitZoneResult { + + static func anyRequestErrors(_ errors: [AnyHashable: CKError]) -> CloudKitZoneResult? { + if errors.values.contains(where: { $0.code == .changeTokenExpired } ) { + return .changeTokenExpired + } + if errors.values.contains(where: { $0.code == .zoneNotFound } ) { + return .zoneNotFound + } + if errors.values.contains(where: { $0.code == .userDeletedZone } ) { + return .userDeletedZone + } + return nil + } + +} diff --git a/Frameworks/Account/CloudKit/Folder+CloudKit.swift b/Frameworks/Account/CloudKit/Folder+CloudKit.swift deleted file mode 100644 index cc9271b20..000000000 --- a/Frameworks/Account/CloudKit/Folder+CloudKit.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// Folder+CloudKit.swift -// Account -// -// Created by Maurice Parker on 3/21/20. -// Copyright © 2020 Ranchero Software, LLC. All rights reserved. -// - -import Foundation -import CloudKit - -extension Folder: CloudKitRecordConvertible { - - enum CloudKitKey: String { - case name - } - - static var cloudKitZoneID: CKRecordZone.ID { - return CloudKitAccountZone.zoneID - } - - var cloudKitPrimaryKey: String { - return externalID! - } - - var cloudKitRecord: CKRecord { - let record = CKRecord(recordType: Self.cloudKitRecordType) - record[.name] = name - return record - } - - func assignCloudKitPrimaryKeyIfNecessary() { - if externalID == nil { - externalID = UUID().uuidString - } - } - -} - -extension CKRecord { - subscript(key: Folder.CloudKitKey) -> Any? { - get { - return self[key.rawValue] - } - set { - self[key.rawValue] = newValue as? CKRecordValue - } - } -} diff --git a/Frameworks/Account/CloudKit/WebFeed+CloudKit.swift b/Frameworks/Account/CloudKit/WebFeed+CloudKit.swift deleted file mode 100644 index 8c6fe43c8..000000000 --- a/Frameworks/Account/CloudKit/WebFeed+CloudKit.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// WebFeed+CloudKit.swift -// Account -// -// Created by Maurice Parker on 3/21/20. -// Copyright © 2020 Ranchero Software, LLC. All rights reserved. -// - -import Foundation -import CloudKit - -extension WebFeed: CloudKitRecordConvertible { - - enum CloudKitKey: String { - case url - case editedName - } - - static var cloudKitZoneID: CKRecordZone.ID { - return CloudKitAccountZone.zoneID - } - - var cloudKitPrimaryKey: String { - return externalID! - } - - var cloudKitRecord: CKRecord { - let record = CKRecord(recordType: Self.cloudKitRecordType) - record[.url] = url - record[.editedName] = editedName - return record - } - - func assignCloudKitPrimaryKeyIfNecessary() { - if externalID == nil { - externalID = UUID().uuidString - } - } - -} - -extension CKRecord { - subscript(key: WebFeed.CloudKitKey) -> Any? { - get { - return self[key.rawValue] - } - set { - self[key.rawValue] = newValue as? CKRecordValue - } - } -} diff --git a/Frameworks/Account/Container.swift b/Frameworks/Account/Container.swift index f9dbaacb1..7c4eda9c8 100644 --- a/Frameworks/Account/Container.swift +++ b/Frameworks/Account/Container.swift @@ -21,7 +21,8 @@ public protocol Container: class, ContainerIdentifiable { var account: Account? { get } var topLevelWebFeeds: Set { get set } var folders: Set? { get set } - + var externalID: String? { get set } + func hasAtLeastOneWebFeed() -> Bool func objectIsChild(_ object: AnyObject) -> Bool @@ -38,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? @@ -116,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 { diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index 9c760fd88..95910f410 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -60,6 +60,10 @@ final class FeedWranglerAccountDelegate: AccountDelegate { caller.logout() { _ in } } + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + completion() + } + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { refreshProgress.addToNumberOfTasksAndRemaining(6) @@ -279,7 +283,7 @@ final class FeedWranglerAccountDelegate: AccountDelegate { fatalError() } - func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { fatalError() } @@ -511,7 +515,7 @@ private extension FeedWranglerAccountDelegate { let parsedItems = feedItems.map { (item: FeedWranglerFeedItem) -> ParsedItem in let itemID = String(item.feedItemID) // let authors = ... - let parsedItem = ParsedItem(syncServiceID: itemID, uniqueID: itemID, feedURL: String(item.feedID), url: nil, externalURL: item.url, title: item.title, contentHTML: item.body, contentText: nil, summary: nil, imageURL: nil, bannerImageURL: nil, datePublished: item.publishedDate, dateModified: item.updatedDate, authors: nil, tags: nil, attachments: nil) + let parsedItem = ParsedItem(syncServiceID: itemID, uniqueID: itemID, feedURL: String(item.feedID), url: nil, externalURL: item.url, title: item.title, language: nil, contentHTML: item.body, contentText: nil, summary: nil, imageURL: nil, bannerImageURL: nil, datePublished: item.publishedDate, dateModified: item.updatedDate, authors: nil, tags: nil, attachments: nil) return parsedItem } diff --git a/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift b/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift index af2cda43a..1e03828dc 100644 --- a/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift +++ b/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift @@ -41,6 +41,8 @@ final class FeedbinAccountDelegate: AccountDelegate { caller.accountMetadata = accountMetadata } } + + var refreshProgress = DownloadProgress(numberOfTasks: 0) init(dataFolder: String, transport: Transport?) { @@ -71,9 +73,11 @@ final class FeedbinAccountDelegate: AccountDelegate { } } - - var refreshProgress = DownloadProgress(numberOfTasks: 0) - + + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + completion() + } + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { refreshProgress.addToNumberOfTasksAndRemaining(5) @@ -265,7 +269,7 @@ final class FeedbinAccountDelegate: AccountDelegate { } - func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { if let folder = account.ensureFolder(with: name) { completion(.success(folder)) } else { @@ -1233,7 +1237,7 @@ private extension FeedbinAccountDelegate { let parsedItems: [ParsedItem] = entries.map { entry in let authors = Set([ParsedAuthor(name: entry.authorName, url: entry.jsonFeed?.jsonFeedAuthor?.url, avatarURL: entry.jsonFeed?.jsonFeedAuthor?.avatarURL, emailAddress: nil)]) - return ParsedItem(syncServiceID: String(entry.articleID), uniqueID: String(entry.articleID), feedURL: String(entry.feedID), url: entry.url, externalURL: nil, title: entry.title, contentHTML: entry.contentHTML, contentText: nil, summary: entry.summary, imageURL: nil, bannerImageURL: nil, datePublished: entry.parsedDatePublished, dateModified: nil, authors: authors, tags: nil, attachments: nil) + return ParsedItem(syncServiceID: String(entry.articleID), uniqueID: String(entry.articleID), feedURL: String(entry.feedID), url: entry.url, externalURL: nil, title: entry.title, language: nil, contentHTML: entry.contentHTML, contentText: nil, summary: entry.summary, imageURL: nil, bannerImageURL: nil, datePublished: entry.parsedDatePublished, dateModified: nil, authors: authors, tags: nil, attachments: nil) } return Set(parsedItems) diff --git a/Frameworks/Account/Feedly/FeedlyAPICaller.swift b/Frameworks/Account/Feedly/FeedlyAPICaller.swift index dfd101325..d52c83bb8 100644 --- a/Frameworks/Account/Feedly/FeedlyAPICaller.swift +++ b/Frameworks/Account/Feedly/FeedlyAPICaller.swift @@ -302,7 +302,7 @@ final class FeedlyAPICaller { transport.send(request: request, resultType: [FeedlyFeed].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in switch result { - case .success(let httpResponse, _): + case .success((let httpResponse, _)): if httpResponse.statusCode == 200 { completion(.success(())) } else { @@ -364,7 +364,7 @@ extension FeedlyAPICaller: FeedlyAddFeedToCollectionService { transport.send(request: request, resultType: [FeedlyFeed].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in switch result { - case .success(_, let collectionFeeds): + case .success((_, let collectionFeeds)): if let feeds = collectionFeeds { completion(.success(feeds)) } else { diff --git a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift index 369e237f8..743706414 100644 --- a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift +++ b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift @@ -95,6 +95,10 @@ final class FeedlyAccountDelegate: AccountDelegate { // MARK: Account API + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + completion() + } + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { assert(Thread.isMainThread) @@ -212,7 +216,7 @@ final class FeedlyAccountDelegate: AccountDelegate { } } - func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { let progress = refreshProgress progress.addToNumberOfTasksAndRemaining(1) diff --git a/Frameworks/Account/Feedly/Models/FeedlyEntryParser.swift b/Frameworks/Account/Feedly/Models/FeedlyEntryParser.swift index 8af6714c7..2da2edc84 100644 --- a/Frameworks/Account/Feedly/Models/FeedlyEntryParser.swift +++ b/Frameworks/Account/Feedly/Models/FeedlyEntryParser.swift @@ -98,6 +98,7 @@ struct FeedlyEntryParser { url: nil, externalURL: externalUrl, title: title, + language: nil, contentHTML: contentHMTL, contentText: contentText, summary: summary, diff --git a/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift b/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift index 735f33bff..836989e78 100644 --- a/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift +++ b/Frameworks/Account/Feedly/OAuthAuthorizationCodeGranting.swift @@ -70,12 +70,12 @@ public extension OAuthAuthorizationResponse { guard let queryItems = components.queryItems, !queryItems.isEmpty else { throw URLError(.unsupportedURL) } - let code = queryItems.firstElementPassingTest { $0.name.lowercased() == "code" } + let code = queryItems.first { $0.name.lowercased() == "code" } guard let codeValue = code?.value, !codeValue.isEmpty else { throw URLError(.unsupportedURL) } - let state = queryItems.firstElementPassingTest { $0.name.lowercased() == "state" } + let state = queryItems.first { $0.name.lowercased() == "state" } let stateValue = state?.value self.init(code: codeValue, state: stateValue) diff --git a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift index 4fcec7b45..e99388d81 100644 --- a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift +++ b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift @@ -18,6 +18,8 @@ public enum LocalAccountDelegateError: String, Error { final class LocalAccountDelegate: AccountDelegate { + private let refresher = LocalAccountRefresher() + let behaviors: AccountBehaviors = [] let isOPMLImportInProgress = false @@ -25,14 +27,17 @@ final class LocalAccountDelegate: AccountDelegate { var credentials: Credentials? var accountMetadata: AccountMetadata? - private let refresher = LocalAccountRefresher() - - var refreshProgress: DownloadProgress { - return refresher.progress + let refreshProgress = DownloadProgress(numberOfTasks: 0) + + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + completion() } func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { - refresher.refreshFeeds(account.flattenedWebFeeds()) { + let webFeeds = account.flattenedWebFeeds() + refreshProgress.addToNumberOfTasksAndRemaining(webFeeds.count) + refresher.refreshFeeds(webFeeds, feedCompletionBlock: { _ in self.refreshProgress.completeTask() }) { + self.refreshProgress.clear() account.metadata.lastArticleFetchEndTime = Date() completion(.success(())) } @@ -81,7 +86,7 @@ final class LocalAccountDelegate: AccountDelegate { } BatchUpdate.shared.perform { - account.loadOPMLItems(children, parentFolder: nil) + account.loadOPMLItems(children) } completion(.success(())) @@ -163,7 +168,7 @@ final class LocalAccountDelegate: AccountDelegate { completion(.success(())) } - func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { if let folder = account.ensureFolder(with: name) { completion(.success(folder)) } else { diff --git a/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift b/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift index 870113d3d..28c8fa47e 100644 --- a/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift +++ b/Frameworks/Account/LocalAccount/LocalAccountRefresher.swift @@ -14,6 +14,7 @@ import Articles final class LocalAccountRefresher { + private var feedCompletionBlock: ((WebFeed) -> Void)? private var completion: (() -> Void)? private var isSuspended = false @@ -21,11 +22,8 @@ final class LocalAccountRefresher { return DownloadSession(delegate: self) }() - var progress: DownloadProgress { - return downloadSession.progress - } - - public func refreshFeeds(_ feeds: Set, completion: @escaping () -> Void) { + public func refreshFeeds(_ feeds: Set, feedCompletionBlock: @escaping (WebFeed) -> Void, completion: @escaping () -> Void) { + self.feedCompletionBlock = feedCompletionBlock self.completion = completion downloadSession.downloadObjects(feeds as NSSet) } @@ -62,28 +60,37 @@ extension LocalAccountRefresher: DownloadSessionDelegate { } func downloadSession(_ downloadSession: DownloadSession, downloadDidCompleteForRepresentedObject representedObject: AnyObject, response: URLResponse?, data: Data, error: NSError?, completion: @escaping () -> Void) { - guard let feed = representedObject as? WebFeed, !data.isEmpty, !isSuspended else { + let feed = representedObject as! WebFeed + + guard !data.isEmpty, !isSuspended else { completion() + feedCompletionBlock?(feed) return } if let error = error { print("Error downloading \(feed.url) - \(error)") completion() + feedCompletionBlock?(feed) return } let dataHash = data.md5String if dataHash == feed.contentHash { completion() + feedCompletionBlock?(feed) return } let parserData = ParserData(url: feed.url, data: data) FeedParser.parse(parserData) { (parsedFeed, error) in + guard let account = feed.account, let parsedFeed = parsedFeed, error == nil else { + completion() + self.feedCompletionBlock?(feed) return } + account.update(feed, with: parsedFeed) { error in if error == nil { if let httpResponse = response as? HTTPURLResponse { @@ -93,12 +100,16 @@ extension LocalAccountRefresher: DownloadSessionDelegate { feed.contentHash = dataHash } completion() + self.feedCompletionBlock?(feed) } + } } func downloadSession(_ downloadSession: DownloadSession, shouldContinueAfterReceivingData data: Data, representedObject: AnyObject) -> Bool { - guard !isSuspended, let feed = representedObject as? WebFeed else { + let feed = representedObject as! WebFeed + guard !isSuspended else { + feedCompletionBlock?(feed) return false } @@ -107,21 +118,31 @@ extension LocalAccountRefresher: DownloadSessionDelegate { } if data.isDefinitelyNotFeed() { + feedCompletionBlock?(feed) return false } if data.count > 4096 { let parserData = ParserData(url: feed.url, data: data) - return FeedParser.mightBeAbleToParseBasedOnPartialData(parserData) + if FeedParser.mightBeAbleToParseBasedOnPartialData(parserData) { + return true + } else { + feedCompletionBlock?(feed) + return false + } } return true } func downloadSession(_ downloadSession: DownloadSession, didReceiveUnexpectedResponse response: URLResponse, representedObject: AnyObject) { + let feed = representedObject as! WebFeed + feedCompletionBlock?(feed) } func downloadSession(_ downloadSession: DownloadSession, didReceiveNotModifiedResponse: URLResponse, representedObject: AnyObject) { + let feed = representedObject as! WebFeed + feedCompletionBlock?(feed) } func downloadSessionDidCompleteDownloadObjects(_ downloadSession: DownloadSession) { diff --git a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift index 406d2ec70..1548d7c67 100644 --- a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift +++ b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift @@ -261,7 +261,7 @@ extension NewsBlurAccountDelegate { let parsedItems: [ParsedItem] = stories.map { story in let author = Set([ParsedAuthor(name: story.authorName, url: nil, avatarURL: nil, emailAddress: nil)]) - return ParsedItem(syncServiceID: story.storyID, uniqueID: String(story.storyID), feedURL: String(story.feedID), url: story.url, externalURL: nil, title: story.title, contentHTML: story.contentHTML, contentText: nil, summary: nil, imageURL: story.imageURL, bannerImageURL: nil, datePublished: story.datePublished, dateModified: nil, authors: author, tags: Set(story.tags ?? []), attachments: nil) + return ParsedItem(syncServiceID: story.storyID, uniqueID: String(story.storyID), feedURL: String(story.feedID), url: story.url, externalURL: nil, title: story.title, language: nil, contentHTML: story.contentHTML, contentText: nil, summary: nil, imageURL: story.imageURL, bannerImageURL: nil, datePublished: story.datePublished, dateModified: nil, authors: author, tags: Set(story.tags ?? []), attachments: nil) } return Set(parsedItems) diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 716ee1870..609dd3085 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -57,6 +57,10 @@ final class NewsBlurAccountDelegate: AccountDelegate { database = SyncDatabase(databaseFilePath: dataFolder.appending("/DB.sqlite3")) } + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + completion() + } + func refreshAll(for account: Account, completion: @escaping (Result) -> ()) { self.refreshProgress.addToNumberOfTasksAndRemaining(5) @@ -335,7 +339,7 @@ final class NewsBlurAccountDelegate: AccountDelegate { completion(.success(())) } - func addFolder(for account: Account, name: String, completion: @escaping (Result) -> ()) { + func createFolder(for account: Account, name: String, completion: @escaping (Result) -> ()) { self.refreshProgress.addToNumberOfTasksAndRemaining(1) caller.addFolder(named: name) { result in @@ -542,7 +546,7 @@ final class NewsBlurAccountDelegate: AccountDelegate { let group = DispatchGroup() group.enter() - addFolder(for: account, name: folderName) { result in + createFolder(for: account, name: folderName) { result in group.leave() switch result { case .success(let folder): diff --git a/Frameworks/Account/OPMLFile.swift b/Frameworks/Account/OPMLFile.swift index eb17aa033..134358c35 100644 --- a/Frameworks/Account/OPMLFile.swift +++ b/Frameworks/Account/OPMLFile.swift @@ -40,7 +40,7 @@ final class OPMLFile { } BatchUpdate.shared.perform { - account.loadOPMLItems(opmlItems, parentFolder: nil) + account.loadOPMLItems(opmlItems) } } diff --git a/Frameworks/Account/OPMLNormalizer.swift b/Frameworks/Account/OPMLNormalizer.swift new file mode 100644 index 000000000..dee350ee3 --- /dev/null +++ b/Frameworks/Account/OPMLNormalizer.swift @@ -0,0 +1,60 @@ +// +// OPMLNormalizer.swift +// Account +// +// Created by Maurice Parker on 3/31/20. +// Copyright © 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSParser + +final class OPMLNormalizer { + + var normalizedOPMLItems = [RSOPMLItem]() + + static func normalize(_ items: [RSOPMLItem]) -> [RSOPMLItem] { + let opmlNormalizer = OPMLNormalizer() + opmlNormalizer.normalize(items) + return opmlNormalizer.normalizedOPMLItems + } + + private func normalize(_ items: [RSOPMLItem], parentFolder: RSOPMLItem? = nil) { + var feedsToAdd = [RSOPMLItem]() + + items.forEach { (item) in + + if let _ = item.feedSpecifier { + if !feedsToAdd.contains(where: { $0.feedSpecifier?.feedURL == item.feedSpecifier?.feedURL } ) { + feedsToAdd.append(item) + } + return + } + + guard let _ = item.titleFromAttributes else { + // Folder doesn’t have a name, so it won’t be created, and its items will go one level up. + if let itemChildren = item.children { + normalize(itemChildren, parentFolder: parentFolder) + } + return + } + + normalizedOPMLItems.append(item) + if let itemChildren = item.children { + normalize(itemChildren, parentFolder: item) + } + } + + if let parentFolder = parentFolder { + for feed in feedsToAdd { + parentFolder.addChild(feed) + } + } else { + for feed in feedsToAdd { + normalizedOPMLItems.append(feed) + } + } + + } + +} diff --git a/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift b/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift index ab36e98d6..722df7842 100644 --- a/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift +++ b/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift @@ -47,6 +47,8 @@ final class ReaderAPIAccountDelegate: AccountDelegate { } } + var refreshProgress = DownloadProgress(numberOfTasks: 0) + init(dataFolder: String, transport: Transport?) { let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3") @@ -77,7 +79,9 @@ final class ReaderAPIAccountDelegate: AccountDelegate { } - var refreshProgress = DownloadProgress(numberOfTasks: 0) + func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) { + completion() + } func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { @@ -202,7 +206,7 @@ final class ReaderAPIAccountDelegate: AccountDelegate { func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result) -> Void) { } - func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + func createFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { if let folder = account.ensureFolder(with: name) { completion(.success(folder)) } else { @@ -932,7 +936,7 @@ private extension ReaderAPIAccountDelegate { // let authors = Set([ParsedAuthor(name: entry.authorName, url: entry.jsonFeed?.jsonFeedAuthor?.url, avatarURL: entry.jsonFeed?.jsonFeedAuthor?.avatarURL, emailAddress: nil)]) // let feed = account.idToFeedDictionary[entry.origin.streamId!]! // TODO clean this up - return ParsedItem(syncServiceID: entry.uniqueID(), uniqueID: entry.uniqueID(), feedURL: entry.origin.streamId!, url: nil, externalURL: entry.alternates.first?.url, title: entry.title, contentHTML: entry.summary.content, contentText: nil, summary: entry.summary.content, imageURL: nil, bannerImageURL: nil, datePublished: entry.parseDatePublished(), dateModified: nil, authors: nil, tags: nil, attachments: nil) + return ParsedItem(syncServiceID: entry.uniqueID(), uniqueID: entry.uniqueID(), feedURL: entry.origin.streamId!, url: nil, externalURL: entry.alternates.first?.url, title: entry.title, language: nil, contentHTML: entry.summary.content, contentText: nil, summary: entry.summary.content, imageURL: nil, bannerImageURL: nil, datePublished: entry.parseDatePublished(), dateModified: nil, authors: nil, tags: nil, attachments: nil) } return Set(parsedItems) diff --git a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift index ec94b2e35..4ca73e177 100644 --- a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift +++ b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift @@ -43,14 +43,21 @@ public typealias ArticleStatusesResultBlock = (ArticleStatusesResult) -> Void public final class ArticlesDatabase { + public enum RetentionStyle { + case feedBased // Local and iCloud: article retention is defined by contents of feed + case syncSystem // Feedbin, Feedly, etc.: article retention is defined by external system + } + private let articlesTable: ArticlesTable private let queue: DatabaseQueue private let operationQueue = MainThreadOperationQueue() + private let retentionStyle: RetentionStyle - public init(databaseFilePath: String, accountID: String) { + public init(databaseFilePath: String, accountID: String, retentionStyle: RetentionStyle) { let queue = DatabaseQueue(databasePath: databaseFilePath) self.queue = queue - self.articlesTable = ArticlesTable(name: DatabaseTableName.articles, accountID: accountID, queue: queue) + self.articlesTable = ArticlesTable(name: DatabaseTableName.articles, accountID: accountID, queue: queue, retentionStyle: retentionStyle) + self.retentionStyle = retentionStyle try! queue.runCreateStatements(ArticlesDatabase.tableCreationStatements) queue.runInDatabase { databaseResult in @@ -62,7 +69,6 @@ public final class ArticlesDatabase { database.executeStatements("DROP TABLE if EXISTS tags;DROP INDEX if EXISTS tags_tagName_index;DROP INDEX if EXISTS articles_feedID_index;DROP INDEX if EXISTS statuses_read_index;DROP TABLE if EXISTS attachments;DROP TABLE if EXISTS attachmentsLookup;") } -// queue.vacuumIfNeeded(daysBetweenVacuums: 9) // TODO: restore this after we do database cleanups. DispatchQueue.main.async { self.articlesTable.indexUnindexedArticles() } @@ -183,8 +189,15 @@ public final class ArticlesDatabase { // MARK: - Saving and Updating Articles - /// Update articles and save new ones. + /// Update articles and save new ones — for feed-based systems (local and iCloud). + public func update(with parsedItems: Set, webFeedID: String, completion: @escaping UpdateArticlesCompletionBlock) { + precondition(retentionStyle == .feedBased) + articlesTable.update(parsedItems, webFeedID, completion) + } + + /// Update articles and save new ones — for sync systems (Feedbin, Feedly, etc.). public func update(webFeedIDsAndItems: [String: Set], defaultRead: Bool, completion: @escaping UpdateArticlesCompletionBlock) { + precondition(retentionStyle == .syncSystem) articlesTable.update(webFeedIDsAndItems, defaultRead, completion) } @@ -219,6 +232,7 @@ public final class ArticlesDatabase { articlesTable.createStatusesIfNeeded(articleIDs, completion) } +#if os(iOS) // MARK: - Suspend and Resume (for iOS) /// Cancel current operations and close the database. @@ -239,7 +253,8 @@ public final class ArticlesDatabase { queue.resume() operationQueue.resume() } - +#endif + // MARK: - Caches /// Call to free up some memory. Should be done when the app is backgrounded, for instance. @@ -254,7 +269,9 @@ public final class ArticlesDatabase { /// Calls the various clean-up functions. public func cleanupDatabaseAtStartup(subscribedToWebFeedIDs: Set) { - articlesTable.deleteOldArticles() + if retentionStyle == .syncSystem { + articlesTable.deleteOldArticles() + } articlesTable.deleteArticlesNotInSubscribedToFeedIDs(subscribedToWebFeedIDs) } } diff --git a/Frameworks/ArticlesDatabase/ArticlesTable.swift b/Frameworks/ArticlesDatabase/ArticlesTable.swift index 35b52d583..9aa7bc06b 100644 --- a/Frameworks/ArticlesDatabase/ArticlesTable.swift +++ b/Frameworks/ArticlesDatabase/ArticlesTable.swift @@ -19,6 +19,8 @@ final class ArticlesTable: DatabaseTable { private let queue: DatabaseQueue private let statusesTable: StatusesTable private let authorsLookupTable: DatabaseLookupTable + private let retentionStyle: ArticlesDatabase.RetentionStyle + private var articlesCache = [String: Article]() private lazy var searchTable: SearchTable = { @@ -30,13 +32,14 @@ final class ArticlesTable: DatabaseTable { private typealias ArticlesFetchMethod = (FMDatabase) -> Set
- init(name: String, accountID: String, queue: DatabaseQueue) { + init(name: String, accountID: String, queue: DatabaseQueue, retentionStyle: ArticlesDatabase.RetentionStyle) { self.name = name self.accountID = accountID self.queue = queue self.statusesTable = StatusesTable(queue: queue) - + self.retentionStyle = retentionStyle + let authorsTable = AuthorsTable(name: DatabaseTableName.authors) self.authorsLookupTable = DatabaseLookupTable(name: DatabaseTableName.authorsLookup, objectIDKey: DatabaseKey.articleID, relatedObjectIDKey: DatabaseKey.authorID, relatedTable: authorsTable, relationshipName: RelationshipName.authors) } @@ -169,7 +172,78 @@ final class ArticlesTable: DatabaseTable { // MARK: - Updating + func update(_ parsedItems: Set, _ webFeedID: String, _ completion: @escaping UpdateArticlesCompletionBlock) { + precondition(retentionStyle == .feedBased) + if parsedItems.isEmpty { + callUpdateArticlesCompletionBlock(nil, nil, completion) + return + } + + // 1. Ensure statuses for all the incoming articles. + // 2. Create incoming articles with parsedItems. + // 3. Ignore incoming articles that are userDeleted + // 4. Fetch all articles for the feed. + // 5. Create array of Articles not in database and save them. + // 6. Create array of updated Articles and save what’s changed. + // 7. Call back with new and updated Articles. + // 8. Delete Articles in database no longer present in the feed. + // 9. Update search index. + + self.queue.runInTransaction { (databaseResult) in + + func makeDatabaseCalls(_ database: FMDatabase) { + let articleIDs = parsedItems.articleIDs() + + let statusesDictionary = self.statusesTable.ensureStatusesForArticleIDs(articleIDs, false, database) //1 + assert(statusesDictionary.count == articleIDs.count) + + let allIncomingArticles = Article.articlesWithParsedItems(parsedItems, webFeedID, self.accountID, statusesDictionary) //2 + let incomingArticles = Set(allIncomingArticles.filter { !($0.status.userDeleted) }) //3 + if incomingArticles.isEmpty { + self.callUpdateArticlesCompletionBlock(nil, nil, completion) + return + } + + let fetchedArticles = self.fetchArticlesForFeedID(webFeedID, withLimits: false, database) //4 + let fetchedArticlesDictionary = fetchedArticles.dictionary() + + let newArticles = self.findAndSaveNewArticles(incomingArticles, fetchedArticlesDictionary, database) //5 + let updatedArticles = self.findAndSaveUpdatedArticles(incomingArticles, fetchedArticlesDictionary, database) //6 + + self.callUpdateArticlesCompletionBlock(newArticles, updatedArticles, completion) //7 + + self.addArticlesToCache(newArticles) + self.addArticlesToCache(updatedArticles) + + // 8. Delete articles no longer in feed. + let articleIDsToDelete = fetchedArticles.articleIDs().filter { !(articleIDs.contains($0)) } + if !articleIDsToDelete.isEmpty { + self.removeArticles(articleIDsToDelete, database) + self.removeArticleIDsFromCache(articleIDsToDelete) + } + + // 9. Update search index. + if let newArticles = newArticles { + self.searchTable.indexNewArticles(newArticles, database) + } + if let updatedArticles = updatedArticles { + self.searchTable.indexUpdatedArticles(updatedArticles, database) + } + } + + switch databaseResult { + case .success(let database): + makeDatabaseCalls(database) + case .failure(let databaseError): + DispatchQueue.main.async { + completion(.failure(databaseError)) + } + } + } + } + func update(_ webFeedIDsAndItems: [String: Set], _ read: Bool, _ completion: @escaping UpdateArticlesCompletionBlock) { + precondition(retentionStyle == .syncSystem) if webFeedIDsAndItems.isEmpty { callUpdateArticlesCompletionBlock(nil, nil, completion) return @@ -850,6 +924,12 @@ private extension ArticlesTable { } } + func removeArticleIDsFromCache(_ articleIDs: Set) { + for articleID in articleIDs { + articlesCache[articleID] = nil + } + } + func articleIsIgnorable(_ article: Article) -> Bool { // Ignorable articles: either userDeleted==1 or (not starred and arrival date > 4 months). if article.status.userDeleted { @@ -863,6 +943,7 @@ private extension ArticlesTable { func filterIncomingArticles(_ articles: Set
) -> Set
{ // Drop Articles that we can ignore. + precondition(retentionStyle == .syncSystem) return Set(articles.filter{ !articleIsIgnorable($0) }) } diff --git a/Frameworks/ArticlesDatabase/Extensions/Article+Database.swift b/Frameworks/ArticlesDatabase/Extensions/Article+Database.swift index 47b88caf7..d3e78c687 100644 --- a/Frameworks/ArticlesDatabase/Extensions/Article+Database.swift +++ b/Frameworks/ArticlesDatabase/Extensions/Article+Database.swift @@ -112,8 +112,12 @@ extension Article { // return Set(parsedItems.map{ Article(parsedItem: $0, maximumDateAllowed: maximumDateAllowed, accountID: accountID, feedID: feedID, status: statusesDictionary[$0.articleID]!) }) // } + private static func _maximumDateAllowed() -> Date { + return Date().addingTimeInterval(60 * 60 * 24) // Allow dates up to about 24 hours ahead of now + } + static func articlesWithWebFeedIDsAndItems(_ webFeedIDsAndItems: [String: Set], _ accountID: String, _ statusesDictionary: [String: ArticleStatus]) -> Set
{ - let maximumDateAllowed = Date().addingTimeInterval(60 * 60 * 24) // Allow dates up to about 24 hours ahead of now + let maximumDateAllowed = _maximumDateAllowed() var feedArticles = Set
() for (webFeedID, parsedItems) in webFeedIDsAndItems { for parsedItem in parsedItems { @@ -124,6 +128,11 @@ extension Article { } return feedArticles } + + static func articlesWithParsedItems(_ parsedItems: Set, _ webFeedID: String, _ accountID: String, _ statusesDictionary: [String: ArticleStatus]) -> Set
{ + let maximumDateAllowed = _maximumDateAllowed() + return Set(parsedItems.map{ Article(parsedItem: $0, maximumDateAllowed: maximumDateAllowed, accountID: accountID, webFeedID: webFeedID, status: statusesDictionary[$0.articleID]!) }) + } } extension Article: DatabaseObject { diff --git a/Mac/AppAssets.swift b/Mac/AppAssets.swift index c537ee21b..777438f2c 100644 --- a/Mac/AppAssets.swift +++ b/Mac/AppAssets.swift @@ -40,6 +40,10 @@ struct AppAssets { static var accountFreshRSS: RSImage! = { return RSImage(named: "accountFreshRSS") }() + + static var accountNewsBlur: RSImage! = { + return RSImage(named: "accountNewsBlur") + }() static var articleExtractor: RSImage! = { return RSImage(named: "articleExtractor") @@ -151,8 +155,8 @@ struct AppAssets { return AppAssets.accountFeedWrangler case .freshRSS: return AppAssets.accountFreshRSS - default: - return nil + case .newsBlur: + return AppAssets.accountNewsBlur } } diff --git a/Mac/AppDefaults.swift b/Mac/AppDefaults.swift index a61ca84d0..89d42b928 100644 --- a/Mac/AppDefaults.swift +++ b/Mac/AppDefaults.swift @@ -48,6 +48,13 @@ struct AppDefaults { private static let smallestFontSizeRawValue = FontSize.small.rawValue private static let largestFontSizeRawValue = FontSize.veryLarge.rawValue + static let isDeveloperBuild: Bool = { + if let dev = Bundle.main.object(forInfoDictionaryKey: "DeveloperEntitlements") as? String, dev == "-dev" { + return true + } + return false + }() + static let isFirstRun: Bool = { if let _ = UserDefaults.standard.object(forKey: Key.firstRunDate) as? Date { return false diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index b771a5530..09e6682ab 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -231,13 +231,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, refreshTimer = AccountRefreshTimer() syncTimer = ArticleStatusSyncTimer() - UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .sound, .alert]) { (granted, error) in - if granted { - DispatchQueue.main.async { - NSApplication.shared.registerForRemoteNotifications() - } - } - } + UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .sound, .alert]) { (granted, error) in } + NSApplication.shared.registerForRemoteNotifications() UNUserNotificationCenter.current().delegate = self userNotificationManager = UserNotificationManager() @@ -267,6 +262,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, CrashReporter.check(appName: "NetNewsWire") } #endif + } func application(_ application: NSApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([NSUserActivityRestoring]) -> Void) -> Bool { @@ -296,37 +292,27 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, } func applicationDidResignActive(_ notification: Notification) { - ArticleStringFormatter.emptyCaches() - saveState() } + func application(_ application: NSApplication, didReceiveRemoteNotification userInfo: [String : Any]) { + AccountManager.shared.receiveRemoteNotification(userInfo: userInfo) + } + func applicationWillTerminate(_ notification: Notification) { shuttingDown = true saveState() - - let group = DispatchGroup() - - group.enter() - AccountManager.shared.syncArticleStatusAll() { - group.leave() - } - - let timeout = DispatchTime.now() + .seconds(1) - _ = group.wait(timeout: timeout) } // MARK: Notifications @objc func unreadCountDidChange(_ note: Notification) { - if note.object is AccountManager { unreadCount = AccountManager.shared.unreadCount } } @objc func webFeedSettingDidChange(_ note: Notification) { - guard let feed = note.object as? WebFeed, let key = note.userInfo?[WebFeed.WebFeedSettingUserInfoKey] as? String else { return } @@ -336,7 +322,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, } @objc func inspectableObjectsDidChange(_ note: Notification) { - guard let inspectorWindowController = inspectorWindowController, inspectorWindowController.isOpen else { return } diff --git a/Mac/MainWindow/AddFeed/AddFeedController.swift b/Mac/MainWindow/AddFeed/AddFeedController.swift index 77071e7cd..8282126ac 100644 --- a/Mac/MainWindow/AddFeed/AddFeedController.swift +++ b/Mac/MainWindow/AddFeed/AddFeedController.swift @@ -58,16 +58,12 @@ class AddFeedController: AddFeedWindowControllerDelegate { return } - BatchUpdate.shared.start() - account.createWebFeed(url: url.absoluteString, name: title, container: container) { result in DispatchQueue.main.async { self.endShowingProgress() } - BatchUpdate.shared.end() - switch result { case .success(let feed): NotificationCenter.default.post(name: .UserDidAddFeed, object: self, userInfo: [UserInfoKey.webFeed: feed]) diff --git a/Mac/MainWindow/MainWindowController.swift b/Mac/MainWindow/MainWindowController.swift index c4f5c2a44..8e24bf155 100644 --- a/Mac/MainWindow/MainWindowController.swift +++ b/Mac/MainWindow/MainWindowController.swift @@ -799,6 +799,10 @@ private extension MainWindowController { } func validateToggleArticleExtractor(_ item: NSValidatedUserInterfaceItem) -> Bool { + guard !AppDefaults.isDeveloperBuild else { + return false + } + guard let toolbarItem = item as? NSToolbarItem, let toolbarButton = toolbarItem.view as? ArticleExtractorButton else { if let menuItem = item as? NSMenuItem { menuItem.state = isShowingExtractedArticle ? .on : .off diff --git a/Mac/MainWindow/Sidebar/SidebarViewController.swift b/Mac/MainWindow/Sidebar/SidebarViewController.swift index 5ffe5ae31..e7cde17b1 100644 --- a/Mac/MainWindow/Sidebar/SidebarViewController.swift +++ b/Mac/MainWindow/Sidebar/SidebarViewController.swift @@ -75,7 +75,6 @@ protocol SidebarDelegate: class { NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(webFeedSettingDidChange(_:)), name: .WebFeedSettingDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(downloadArticlesDidUpdateUnreadCounts(_:)), name: .DownloadArticlesDidUpdateUnreadCounts, object: nil) outlineView.reloadData() @@ -218,10 +217,6 @@ protocol SidebarDelegate: class { revealAndSelectRepresentedObject(feed as AnyObject) } - @objc func downloadArticlesDidUpdateUnreadCounts(_ note: Notification) { - rebuildTreeAndRestoreSelection() - } - // MARK: - Actions @IBAction func delete(_ sender: AnyObject?) { diff --git a/Mac/MainWindow/Timeline/Cell/SingleLineTextFieldSizer.swift b/Mac/MainWindow/Timeline/Cell/SingleLineTextFieldSizer.swift index ed18581e9..e40db7235 100644 --- a/Mac/MainWindow/Timeline/Cell/SingleLineTextFieldSizer.swift +++ b/Mac/MainWindow/Timeline/Cell/SingleLineTextFieldSizer.swift @@ -53,7 +53,7 @@ final class SingleLineTextFieldSizer { // that members of such a dictionary were mutated after insertion. // We use just an array of sizers now — which is totally fine, // because there’s only going to be like three of them. - if let cachedSizer = sizers.firstElementPassingTest({ $0.font == font }) { + if let cachedSizer = sizers.first(where: { $0.font == font }) { return cachedSizer } diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift index 2400d555c..7b2ca80b4 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController.swift @@ -476,7 +476,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr if isReadFiltered ?? false { if let accountName = userInfo[ArticlePathKey.accountName] as? String, - let account = AccountManager.shared.findActiveAccount(forDisplayName: accountName) { + let account = AccountManager.shared.existingActiveAccount(forDisplayName: accountName) { exceptionArticleFetcher = SingleArticleFetcher(account: account, articleID: articleID) fetchAndReplaceArticlesSync() } diff --git a/Mac/Preferences/Accounts/AccountsAddCloudKitWindowController.swift b/Mac/Preferences/Accounts/AccountsAddCloudKitWindowController.swift index 5bd0e8916..5d0e3202f 100644 --- a/Mac/Preferences/Accounts/AccountsAddCloudKitWindowController.swift +++ b/Mac/Preferences/Accounts/AccountsAddCloudKitWindowController.swift @@ -35,7 +35,7 @@ class AccountsAddCloudKitWindowController: NSWindowController { } @IBAction func create(_ sender: Any) { - _ = AccountManager.shared.createAccount(type: .cloudKit) + let _ = AccountManager.shared.createAccount(type: .cloudKit) hostWindow!.endSheet(window!, returnCode: NSApplication.ModalResponse.OK) } diff --git a/Mac/Preferences/Accounts/AccountsAddViewController.swift b/Mac/Preferences/Accounts/AccountsAddViewController.swift index 4923272be..4699e64e1 100644 --- a/Mac/Preferences/Accounts/AccountsAddViewController.swift +++ b/Mac/Preferences/Accounts/AccountsAddViewController.swift @@ -17,7 +17,7 @@ class AccountsAddViewController: NSViewController { private var accountsAddWindowController: NSWindowController? #if DEBUG - private var addableAccountTypes: [AccountType] = [.onMyMac, .cloudKit, .feedbin, .feedly, .feedWrangler, .freshRSS] + private var addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly, .feedWrangler, .freshRSS, .cloudKit, .newsBlur] #else private var addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly] #endif @@ -34,7 +34,7 @@ class AccountsAddViewController: NSViewController { super.viewDidLoad() tableView.dataSource = self tableView.delegate = self - removeCloudKitIfNecessary() + restrictAccounts() } } @@ -80,8 +80,9 @@ extension AccountsAddViewController: NSTableViewDelegate { case .feedly: cell.accountNameLabel?.stringValue = NSLocalizedString("Feedly", comment: "Feedly") cell.accountImageView?.image = AppAssets.accountFeedly - default: - break + case .newsBlur: + cell.accountNameLabel?.stringValue = NSLocalizedString("NewsBlur", comment: "NewsBlur") + cell.accountImageView?.image = AppAssets.accountNewsBlur } return cell } @@ -104,7 +105,7 @@ extension AccountsAddViewController: NSTableViewDelegate { let accountsAddCloudKitWindowController = AccountsAddCloudKitWindowController() accountsAddCloudKitWindowController.runSheetOnWindow(self.view.window!) { response in if response == NSApplication.ModalResponse.OK { - self.removeCloudKitIfNecessary() + self.restrictAccounts() self.tableView.reloadData() } } @@ -127,8 +128,10 @@ extension AccountsAddViewController: NSTableViewDelegate { addAccount.delegate = self addAccount.presentationAnchor = self.view.window! MainThreadOperationQueue.shared.add(addAccount) - default: - break + case .newsBlur: + let accountsNewsBlurWindowController = AccountsNewsBlurWindowController() + accountsNewsBlurWindowController.runSheetOnWindow(self.view.window!) + accountsAddWindowController = accountsNewsBlurWindowController } tableView.selectRowIndexes([], byExtendingSelection: false) @@ -161,21 +164,22 @@ extension AccountsAddViewController: OAuthAccountAuthorizationOperationDelegate private extension AccountsAddViewController { - func removeCloudKitIfNecessary() { - func removeCloudKit() { - if let cloudKitIndex = addableAccountTypes.firstIndex(of: .cloudKit) { - addableAccountTypes.remove(at: cloudKitIndex) + func restrictAccounts() { + func removeAccountType(_ accountType: AccountType) { + if let index = addableAccountTypes.firstIndex(of: accountType) { + addableAccountTypes.remove(at: index) } } - if AccountManager.shared.activeAccounts.firstIndex(where: { $0.type == .cloudKit }) != nil { - removeCloudKit() + if AppDefaults.isDeveloperBuild { + removeAccountType(.cloudKit) + removeAccountType(.feedly) + removeAccountType(.feedWrangler) return } - - // We don't want developers without entitlements to be trying to add the CloudKit account - if let dev = Bundle.main.object(forInfoDictionaryKey: "DeveloperEntitlements") as? String, dev == "-dev" { - removeCloudKit() + + if AccountManager.shared.activeAccounts.firstIndex(where: { $0.type == .cloudKit }) != nil { + removeAccountType(.cloudKit) } } diff --git a/Mac/Preferences/Accounts/AccountsNewsBlur.xib b/Mac/Preferences/Accounts/AccountsNewsBlur.xib new file mode 100644 index 000000000..3b6027ed8 --- /dev/null +++ b/Mac/Preferences/Accounts/AccountsNewsBlur.xib @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NSAllRomanInputSourcesLocaleIdentifier + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mac/Preferences/Accounts/AccountsNewsBlurWindowController.swift b/Mac/Preferences/Accounts/AccountsNewsBlurWindowController.swift new file mode 100644 index 000000000..cae19a31a --- /dev/null +++ b/Mac/Preferences/Accounts/AccountsNewsBlurWindowController.swift @@ -0,0 +1,110 @@ +// +// AccountsNewsBlurWindowController.swift +// NetNewsWire +// +// Created by Anh Quang Do on 2020-03-22. +// Copyright (c) 2020 Ranchero Software. All rights reserved. +// + +import AppKit +import Account +import RSWeb + +class AccountsNewsBlurWindowController: NSWindowController { + @IBOutlet weak var progressIndicator: NSProgressIndicator! + @IBOutlet weak var usernameTextField: NSTextField! + @IBOutlet weak var passwordTextField: NSSecureTextField! + @IBOutlet weak var errorMessageLabel: NSTextField! + @IBOutlet weak var actionButton: NSButton! + + var account: Account? + + private weak var hostWindow: NSWindow? + + convenience init() { + self.init(windowNibName: NSNib.Name("AccountsNewsBlur")) + } + + override func windowDidLoad() { + if let account = account, let credentials = try? account.retrieveCredentials(type: .newsBlurBasic) { + usernameTextField.stringValue = credentials.username + actionButton.title = NSLocalizedString("Update", comment: "Update") + } else { + actionButton.title = NSLocalizedString("Create", comment: "Create") + } + } + + // MARK: API + + func runSheetOnWindow(_ hostWindow: NSWindow, completion: ((NSApplication.ModalResponse) -> Void)? = nil) { + self.hostWindow = hostWindow + hostWindow.beginSheet(window!, completionHandler: completion) + } + + // MARK: Actions + + @IBAction func cancel(_ sender: Any) { + hostWindow!.endSheet(window!, returnCode: NSApplication.ModalResponse.cancel) + } + + @IBAction func action(_ sender: Any) { + self.errorMessageLabel.stringValue = "" + + guard !usernameTextField.stringValue.isEmpty else { + self.errorMessageLabel.stringValue = NSLocalizedString("Username required.", comment: "Credentials Error") + return + } + + actionButton.isEnabled = false + progressIndicator.isHidden = false + progressIndicator.startAnimation(self) + + let credentials = Credentials(type: .newsBlurBasic, username: usernameTextField.stringValue, secret: passwordTextField.stringValue) + Account.validateCredentials(type: .newsBlur, credentials: credentials) { [weak self] result in + + guard let self = self else { return } + + self.actionButton.isEnabled = true + self.progressIndicator.isHidden = true + self.progressIndicator.stopAnimation(self) + + switch result { + case .success(let validatedCredentials): + guard let validatedCredentials = validatedCredentials else { + self.errorMessageLabel.stringValue = NSLocalizedString("Invalid email/password combination.", comment: "Credentials Error") + return + } + var newAccount = false + if self.account == nil { + self.account = AccountManager.shared.createAccount(type: .newsBlur) + newAccount = true + } + + do { + try self.account?.removeCredentials(type: .newsBlurBasic) + try self.account?.removeCredentials(type: .newsBlurSessionId) + try self.account?.storeCredentials(credentials) + try self.account?.storeCredentials(validatedCredentials) + if newAccount { + self.account?.refreshAll() { result in + switch result { + case .success: + break + case .failure(let error): + NSApplication.shared.presentError(error) + } + } + } + self.hostWindow?.endSheet(self.window!, returnCode: NSApplication.ModalResponse.OK) + } catch { + self.errorMessageLabel.stringValue = NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error") + } + + case .failure: + + self.errorMessageLabel.stringValue = NSLocalizedString("Network error. Try again later.", comment: "Credentials Error") + + } + } + } +} diff --git a/Mac/Resources/Assets.xcassets/accountNewsBlur.imageset/Contents.json b/Mac/Resources/Assets.xcassets/accountNewsBlur.imageset/Contents.json new file mode 100644 index 000000000..99f78349c --- /dev/null +++ b/Mac/Resources/Assets.xcassets/accountNewsBlur.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "newsblur-512.png" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template" + } +} \ No newline at end of file diff --git a/Mac/Resources/Assets.xcassets/accountNewsBlur.imageset/newsblur-512.png b/Mac/Resources/Assets.xcassets/accountNewsBlur.imageset/newsblur-512.png new file mode 100644 index 000000000..5fab67691 Binary files /dev/null and b/Mac/Resources/Assets.xcassets/accountNewsBlur.imageset/newsblur-512.png differ diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 4cdb13a9d..cfe77b8a8 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 179DB1DFBCF9177104B12E0F /* AccountsNewsBlurWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBBA2B22A659F81EED6F9 /* AccountsNewsBlurWindowController.swift */; }; + 179DB3CE822BFCC2D774D9F4 /* AccountsNewsBlurWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBBA2B22A659F81EED6F9 /* AccountsNewsBlurWindowController.swift */; }; 3B3A32A5238B820900314204 /* FeedWranglerAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A328B238B820900314204 /* FeedWranglerAccountViewController.swift */; }; 3B826DCB2385C84800FC1ADB /* AccountsFeedWrangler.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3B826DB02385C84800FC1ADB /* AccountsFeedWrangler.xib */; }; 3B826DCC2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DCA2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift */; }; @@ -45,6 +47,7 @@ 512AF9C2236ED52C0066F8BE /* ImageHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512AF9C1236ED52C0066F8BE /* ImageHeaderView.swift */; }; 512AF9DD236F05230066F8BE /* InteractiveLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512AF9DC236F05230066F8BE /* InteractiveLabel.swift */; }; 512D554423C804DE0023FFFA /* OpenInSafariActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512D554323C804DE0023FFFA /* OpenInSafariActivity.swift */; }; + 512DD4C92430086400C17B1F /* CloudKitAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512DD4C82430086400C17B1F /* CloudKitAccountViewController.swift */; }; 512E08E62268800D00BDCFDD /* FolderTreeControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97A11ED9F180007D329B /* FolderTreeControllerDelegate.swift */; }; 512E08E72268801200BDCFDD /* WebFeedTreeControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97611ED9EB96007D329B /* WebFeedTreeControllerDelegate.swift */; }; 512E09012268907400BDCFDD /* MasterFeedTableViewSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512E08F722688F7C00BDCFDD /* MasterFeedTableViewSectionHeader.swift */; }; @@ -665,6 +668,8 @@ B2B80778239C4C7000F191E0 /* RSImage-AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */; }; B2B80779239C4C7300F191E0 /* RSImage-AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */; }; B528F81E23333C7E00E735DD /* page.html in Resources */ = {isa = PBXBuildFile; fileRef = B528F81D23333C7E00E735DD /* page.html */; }; + BDCB516724282C8A00102A80 /* AccountsNewsBlur.xib in Resources */ = {isa = PBXBuildFile; fileRef = BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */; }; + BDCB516824282C8A00102A80 /* AccountsNewsBlur.xib in Resources */ = {isa = PBXBuildFile; fileRef = BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */; }; C5A6ED5223C9AF4300AB6BE2 /* TitleActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A6ED5123C9AF4300AB6BE2 /* TitleActivityItemSource.swift */; }; C5A6ED6D23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5A6ED6C23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift */; }; D553738B20186C20006D8857 /* Article+Scriptability.swift in Sources */ = {isa = PBXBuildFile; fileRef = D553737C20186C1F006D8857 /* Article+Scriptability.swift */; }; @@ -1255,6 +1260,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 179DBBA2B22A659F81EED6F9 /* AccountsNewsBlurWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountsNewsBlurWindowController.swift; sourceTree = ""; }; 3B3A328B238B820900314204 /* FeedWranglerAccountViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAccountViewController.swift; sourceTree = ""; }; 3B826DB02385C84800FC1ADB /* AccountsFeedWrangler.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = AccountsFeedWrangler.xib; sourceTree = ""; }; 3B826DCA2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountsFeedWranglerWindowController.swift; sourceTree = ""; }; @@ -1282,6 +1288,7 @@ 512AF9C1236ED52C0066F8BE /* ImageHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageHeaderView.swift; sourceTree = ""; }; 512AF9DC236F05230066F8BE /* InteractiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractiveLabel.swift; sourceTree = ""; }; 512D554323C804DE0023FFFA /* OpenInSafariActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenInSafariActivity.swift; sourceTree = ""; }; + 512DD4C82430086400C17B1F /* CloudKitAccountViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAccountViewController.swift; sourceTree = ""; }; 512E08F722688F7C00BDCFDD /* MasterFeedTableViewSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterFeedTableViewSectionHeader.swift; sourceTree = ""; }; 51314617235A797400387FDC /* NetNewsWire_iOSintentextension_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_iOSintentextension_target.xcconfig; sourceTree = ""; }; 51314637235A7BBE00387FDC /* NetNewsWire iOS Intents Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "NetNewsWire iOS Intents Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1626,6 +1633,7 @@ B24EFD5923310109006C6242 /* WKPreferencesPrivate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WKPreferencesPrivate.h; sourceTree = ""; }; B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RSImage-AppIcons.swift"; sourceTree = ""; }; B528F81D23333C7E00E735DD /* page.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = page.html; sourceTree = ""; }; + BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = AccountsNewsBlur.xib; sourceTree = ""; }; C5A6ED5123C9AF4300AB6BE2 /* TitleActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleActivityItemSource.swift; sourceTree = ""; }; C5A6ED6C23C9B0C800AB6BE2 /* UIActivityViewController-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIActivityViewController-Extensions.swift"; sourceTree = ""; }; D519E74722EE553300923F27 /* NetNewsWire_safariextension_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_safariextension_target.xcconfig; sourceTree = ""; }; @@ -1875,6 +1883,7 @@ children = ( 516A093F2361240900EAE89B /* Account.storyboard */, 51A1698F235E10D600EB091F /* LocalAccountViewController.swift */, + 512DD4C82430086400C17B1F /* CloudKitAccountViewController.swift */, 51A16996235E10D700EB091F /* FeedbinAccountViewController.swift */, 3B3A328B238B820900314204 /* FeedWranglerAccountViewController.swift */, 769F2D3643779DB02786278E /* NewsBlurAccountViewController.swift */, @@ -2630,6 +2639,8 @@ 5144EA4F227B8E4500D19003 /* AccountsFeedbinWindowController.swift */, 3B826DB02385C84800FC1ADB /* AccountsFeedWrangler.xib */, 3B826DCA2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift */, + BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */, + 179DBBA2B22A659F81EED6F9 /* AccountsNewsBlurWindowController.swift */, 55E15BC1229D65A900D6602A /* AccountsReaderAPI.xib */, 55E15BCA229D65A900D6602A /* AccountsReaderAPIWindowController.swift */, 5144EA352279FC3D00D19003 /* AccountsAddLocal.xib */, @@ -3501,6 +3512,7 @@ 65ED4066235DEF6C0081F399 /* TimelineTableView.xib in Resources */, 65ED4067235DEF6C0081F399 /* page.html in Resources */, 65ED4068235DEF6C0081F399 /* MainWindow.storyboard in Resources */, + BDCB516824282C8A00102A80 /* AccountsNewsBlur.xib in Resources */, 3B826DCD2385C89600FC1ADB /* AccountsFeedWrangler.xib in Resources */, 65ED4069235DEF6C0081F399 /* AccountsReaderAPI.xib in Resources */, 65ED406A235DEF6C0081F399 /* newsfoot.js in Resources */, @@ -3593,6 +3605,7 @@ 3B826DCB2385C84800FC1ADB /* AccountsFeedWrangler.xib in Resources */, 55E15BCB229D65A900D6602A /* AccountsReaderAPI.xib in Resources */, 49F40DF82335B71000552BF4 /* newsfoot.js in Resources */, + BDCB516724282C8A00102A80 /* AccountsNewsBlur.xib in Resources */, 5103A9982421643300410853 /* blank.html in Resources */, 84BAE64921CEDAF20046DB56 /* CrashReporterWindow.xib in Resources */, 84C9FC8E22629E8F00D921D6 /* Credits.rtf in Resources */, @@ -3627,7 +3640,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n"; + shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n\n\nif [ $? -ne 0 ]\nthen\n echo \"error: Build Setting were found in the project.pbxproj file. Most likely you didn't intend to change this file and should revert it.\"\n exit 1\nfi\n"; }; 65ED406F235DEF6C0081F399 /* Run Script: Automated build numbers */ = { isa = PBXShellScriptBuildPhase; @@ -3659,7 +3672,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n"; + shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n\nif [ $? -ne 0 ]\nthen\n echo \"error: Build Setting were found in the project.pbxproj file. Most likely you didn't intend to change this file and should revert it.\"\n exit 1\nfi\n"; }; 8423E3E3220158E700C3795B /* Run Script: Code Sign Sparkle */ = { isa = PBXShellScriptBuildPhase; @@ -3709,7 +3722,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n"; + shellScript = "xcrun -sdk macosx swiftc -target x86_64-macosx10.11 buildscripts/VerifyNoBuildSettings.swift -o $CONFIGURATION_TEMP_DIR/VerifyNoBS\n$CONFIGURATION_TEMP_DIR/VerifyNoBS ${PROJECT_NAME}.xcodeproj/project.pbxproj\n\n\nif [ $? -ne 0 ]\nthen\n echo \"error: Build Setting were found in the project.pbxproj file. Most likely you didn't intend to change this file and should revert it.\"\n exit 1\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -3909,6 +3922,7 @@ 65ED403E235DEF6C0081F399 /* TimelineCellAppearance.swift in Sources */, 65ED403F235DEF6C0081F399 /* ArticleRenderer.swift in Sources */, 65ED4040235DEF6C0081F399 /* GeneralPrefencesViewController.swift in Sources */, + 179DB1DFBCF9177104B12E0F /* AccountsNewsBlurWindowController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3926,6 +3940,7 @@ buildActionMask = 2147483647; files = ( 51E36E71239D6610006F47A5 /* AddWebFeedSelectFolderTableViewCell.swift in Sources */, + 512DD4C92430086400C17B1F /* CloudKitAccountViewController.swift in Sources */, 840D617F2029031C009BC708 /* AppDelegate.swift in Sources */, 51236339236915B100951F16 /* RoundedProgressView.swift in Sources */, 512E08E72268801200BDCFDD /* WebFeedTreeControllerDelegate.swift in Sources */, @@ -4226,6 +4241,7 @@ 849A97761ED9EC04007D329B /* TimelineCellAppearance.swift in Sources */, 849A977F1ED9EC42007D329B /* ArticleRenderer.swift in Sources */, 84C9FC7822629E1200D921D6 /* GeneralPrefencesViewController.swift in Sources */, + 179DB3CE822BFCC2D774D9F4 /* AccountsNewsBlurWindowController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/README.md b/README.md index 62c290283..f53c59995 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,9 @@ This allows for a pristine project with code signing set up with the appropriate developer ID and certificates, and for dev to be able to have local settings without needing to check in anything into source control. -As an example, make a directory SharedXcodeSettings next to where you have this repository. -An example of the structure is: +Make a directory SharedXcodeSettings next to where you have this repository. + +The directory structure is: ``` aDirectory/ @@ -50,6 +51,13 @@ aDirectory/ NetNewsWire NetNewsWire.xcworkspace ``` +Example: + +If your NetNewsWire Xcode project file is at: +`/Users/Shared/git/NetNewsWire/NetNewsWire.xcodeproj` + +Create your `DeveloperSettings.xcconfig` file at +`/Users/Shared/git/SharedXcodeSettings/DeveloperSettings.xcconfig` Then create a plain text file in it: `SharedXcodeSettings/DeveloperSettings.xcconfig` and give it the contents: @@ -63,13 +71,17 @@ DEVELOPER_ENTITLEMENTS = -dev PROVISIONING_PROFILE_SPECIFIER = ``` +Set `DEVELOPMENT_TEAM` to your Apple supplied development team. You can use Keychain +Acceass to [find your development team ID](/Technotes/FindingYourDevelopmentTeamID.md). +Set `ORGANIZATION_IDENTIFIER` to a reversed domain name that you control or have made up. +Note that `PROVISIONING_PROFILE_SPECIFIER` should not have a value associated with it. + +You can now open the `NetNewsWire.xcworkspace` in Xcode. + Now you should be able to build without code signing errors and without modifying -the NetNewsWire Xcode project. +the NetNewsWire Xcode project. This is a special build of NetNewsWire with some +functionality disabled. This is because we have API keys that can't be stored in the +repository or shared between developers. Certain account types, like Feedly, aren't +enabled and the Reader View isn't enabled because of this. -Example: - -If your NetNewsWire Xcode project file is at: -`/Users/Shared/git/NetNewsWire/NetNewsWire.xcodeproj` - -Create your `DeveloperSettings.xcconfig` file at -`/Users/Shared/git/SharedXcodeSettings/DeveloperSettings.xcconfig` +If you have any problems, we will help you out in Slack (see above). diff --git a/Shared/Data/AddWebFeedDefaultContainer.swift b/Shared/Data/AddWebFeedDefaultContainer.swift index 41e158ce9..0275f6159 100644 --- a/Shared/Data/AddWebFeedDefaultContainer.swift +++ b/Shared/Data/AddWebFeedDefaultContainer.swift @@ -14,7 +14,7 @@ struct AddWebFeedDefaultContainer { static var defaultContainer: Container? { if let accountID = AppDefaults.addWebFeedAccountID, let account = AccountManager.shared.activeAccounts.first(where: { $0.accountID == accountID }) { - if let folderName = AppDefaults.addWebFeedFolderName, let folder = account.findFolder(withDisplayName: folderName) { + if let folderName = AppDefaults.addWebFeedFolderName, let folder = account.existingFolder(withDisplayName: folderName) { return folder } else { return substituteContainerIfNeeded(account: account) diff --git a/Shared/Extensions/RSImage-AppIcons.swift b/Shared/Extensions/RSImage-AppIcons.swift index b0a778e7b..df5670732 100644 --- a/Shared/Extensions/RSImage-AppIcons.swift +++ b/Shared/Extensions/RSImage-AppIcons.swift @@ -27,11 +27,10 @@ extension RSImage { } extension IconImage { - static var appIcon: IconImage? { + static var appIcon: IconImage? = { if let image = RSImage.appIconImage { return IconImage(image) } - return nil - } + }() } diff --git a/Shared/Images/WebFeedIconDownloader.swift b/Shared/Images/WebFeedIconDownloader.swift index d62f5201c..bb67e2706 100644 --- a/Shared/Images/WebFeedIconDownloader.swift +++ b/Shared/Images/WebFeedIconDownloader.swift @@ -67,6 +67,10 @@ public final class WebFeedIconDownloader { return cachedImage } + if let hpURLString = feed.homePageURL, let hpURL = URL(string: hpURLString), hpURL.host == "nnw.ranchero.com" { + return IconImage.appIcon + } + func checkHomePageURL() { guard let homePageURL = feed.homePageURL else { return @@ -124,11 +128,6 @@ private extension WebFeedIconDownloader { func icon(forHomePageURL homePageURL: String, feed: WebFeed, _ imageResultBlock: @escaping (RSImage?) -> Void) { - if let url = URL(string: homePageURL), url.host == "nnw.ranchero.com" { - imageResultBlock(RSImage.appIconImage) - return - } - if homePagesWithNoIconURLCache.contains(homePageURL) || homePagesWithUglyIcons.contains(homePageURL) { imageResultBlock(nil) return diff --git a/Technotes/FindingYourDevelopmentTeamID.md b/Technotes/FindingYourDevelopmentTeamID.md new file mode 100644 index 000000000..0e619ed55 --- /dev/null +++ b/Technotes/FindingYourDevelopmentTeamID.md @@ -0,0 +1,13 @@ +# Finding Your Development Team ID + +* Open Keychain Access on your development machine. +* On the left hand side, make sure "My Certificates" is selected. +* Find the certificate that reads `Apple Development: ` + +![DevelopmentTeamID](Images/DevelopmentTeamID.png) + +* Right click on the certificate and select "Get Info". + +![DevelopmentTeamIDInfo](Images/DevelopmentTeamIDInfo.png) + +Your **Development Team ID** is the value next to **Organizational Unit**. diff --git a/Technotes/Images/DevelopmentTeamID.png b/Technotes/Images/DevelopmentTeamID.png new file mode 100644 index 000000000..0aff9f377 Binary files /dev/null and b/Technotes/Images/DevelopmentTeamID.png differ diff --git a/Technotes/Images/DevelopmentTeamIDInfo.png b/Technotes/Images/DevelopmentTeamIDInfo.png new file mode 100644 index 000000000..eade5322c Binary files /dev/null and b/Technotes/Images/DevelopmentTeamIDInfo.png differ diff --git a/iOS/Account/Account.storyboard b/iOS/Account/Account.storyboard index dd9869dee..9de54a8dd 100644 --- a/iOS/Account/Account.storyboard +++ b/iOS/Account/Account.storyboard @@ -1,8 +1,8 @@ - + - + @@ -10,7 +10,7 @@ - + @@ -21,7 +21,7 @@ - + @@ -57,7 +57,7 @@ - + @@ -206,7 +206,7 @@ - + @@ -271,7 +271,7 @@ - + @@ -291,7 +291,7 @@ - + @@ -315,7 +315,7 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/Account/CloudKitAccountViewController.swift b/iOS/Account/CloudKitAccountViewController.swift new file mode 100644 index 000000000..a72f8203f --- /dev/null +++ b/iOS/Account/CloudKitAccountViewController.swift @@ -0,0 +1,47 @@ +// +// CloudKitAccountViewController.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 3/28/20. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import UIKit +import Account + +class CloudKitAccountViewController: UITableViewController { + + weak var delegate: AddAccountDismissDelegate? + + override func viewDidLoad() { + super.viewDidLoad() + + tableView.register(ImageHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader") + } + + @IBAction func cancel(_ sender: Any) { + dismiss(animated: true, completion: nil) + delegate?.dismiss() + } + + @IBAction func add(_ sender: Any) { + let _ = AccountManager.shared.createAccount(type: .cloudKit) + dismiss(animated: true, completion: nil) + delegate?.dismiss() + } + + override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: section) + } + + override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + if section == 0 { + let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "SectionHeader") as! ImageHeaderView + headerView.imageView.image = AppAssets.image(for: .cloudKit) + return headerView + } else { + return super.tableView(tableView, viewForHeaderInSection: section) + } + } + +} diff --git a/iOS/Account/NewsBlurAccountViewController.swift b/iOS/Account/NewsBlurAccountViewController.swift index 8a5cb2ddf..9810f8196 100644 --- a/iOS/Account/NewsBlurAccountViewController.swift +++ b/iOS/Account/NewsBlurAccountViewController.swift @@ -29,7 +29,7 @@ class NewsBlurAccountViewController: UITableViewController { usernameTextField.delegate = self passwordTextField.delegate = self - if let account = account, let credentials = try? account.retrieveCredentials(type: .basic) { + if let account = account, let credentials = try? account.retrieveCredentials(type: .newsBlurBasic) { actionButton.setTitle(NSLocalizedString("Update Credentials", comment: "Update Credentials"), for: .normal) actionButton.isEnabled = true usernameTextField.text = credentials.username @@ -90,7 +90,7 @@ class NewsBlurAccountViewController: UITableViewController { let credentials = Credentials(type: .newsBlurBasic, username: trimmedUsername, secret: password) Account.validateCredentials(type: .newsBlur, credentials: credentials) { result in - self.stopAnimtatingActivityIndicator() + self.stopAnimatingActivityIndicator() self.enableNavigation() switch result { @@ -105,7 +105,7 @@ class NewsBlurAccountViewController: UITableViewController { do { do { - try self.account?.removeCredentials(type: .basic) + try self.account?.removeCredentials(type: .newsBlurBasic) } catch {} try self.account?.storeCredentials(credentials) @@ -158,7 +158,7 @@ class NewsBlurAccountViewController: UITableViewController { activityIndicator.startAnimating() } - private func stopAnimtatingActivityIndicator() { + private func stopAnimatingActivityIndicator() { self.activityIndicator.isHidden = true self.activityIndicator.stopAnimating() } diff --git a/iOS/AppAssets.swift b/iOS/AppAssets.swift index 17473357b..58d600aef 100644 --- a/iOS/AppAssets.swift +++ b/iOS/AppAssets.swift @@ -19,6 +19,10 @@ struct AppAssets { return UIImage(named: "accountLocalPhone")! }() + static var accountCloudKitImage: UIImage = { + return UIImage(named: "accountCloudKit")! + }() + static var accountFeedbinImage: UIImage = { return UIImage(named: "accountFeedbin")! }() @@ -234,6 +238,8 @@ struct AppAssets { } else { return AppAssets.accountLocalPhoneImage } + case .cloudKit: + return AppAssets.accountCloudKitImage case .feedbin: return AppAssets.accountFeedbinImage case .feedly: @@ -244,8 +250,6 @@ struct AppAssets { return AppAssets.accountFreshRSSImage case .newsBlur: return AppAssets.accountNewsBlurImage - default: - return nil } } diff --git a/iOS/AppDefaults.swift b/iOS/AppDefaults.swift index f790db2a6..986378b00 100644 --- a/iOS/AppDefaults.swift +++ b/iOS/AppDefaults.swift @@ -52,6 +52,13 @@ struct AppDefaults { static let addFolderAccountID = "addFolderAccountID" } + static let isDeveloperBuild: Bool = { + if let dev = Bundle.main.object(forInfoDictionaryKey: "DeveloperEntitlements") as? String, dev == "-dev" { + return true + } + return false + }() + static let isFirstRun: Bool = { if let _ = AppDefaults.shared.object(forKey: Key.firstRunDate) as? Date { return false diff --git a/iOS/AppDelegate.swift b/iOS/AppDelegate.swift index e6f25a9ce..a16e92ab2 100644 --- a/iOS/AppDelegate.swift +++ b/iOS/AppDelegate.swift @@ -91,15 +91,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD self.unreadCount = AccountManager.shared.unreadCount } - UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .sound, .alert]) { (granted, error) in - if granted { - DispatchQueue.main.async { - UIApplication.shared.registerForRemoteNotifications() - } - } - } - UNUserNotificationCenter.current().delegate = self + UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .sound, .alert]) { _, _ in } + UIApplication.shared.registerForRemoteNotifications() + userNotificationManager = UserNotificationManager() extensionContainersFile = ExtensionContainersFile() @@ -115,6 +110,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } + func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + DispatchQueue.main.async { + self.resumeDatabaseProcessingIfNecessary() + AccountManager.shared.receiveRemoteNotification(userInfo: userInfo) { + self.suspendApplication() + completionHandler(.newData) + } + } + } + func applicationWillTerminate(_ application: UIApplication) { shuttingDown = true } diff --git a/iOS/Article/ArticleViewController.swift b/iOS/Article/ArticleViewController.swift index 3768d3441..d6227ccab 100644 --- a/iOS/Article/ArticleViewController.swift +++ b/iOS/Article/ArticleViewController.swift @@ -153,7 +153,7 @@ class ArticleViewController: UIViewController { starBarButtonItem.isEnabled = true let permalinkPresent = article.preferredLink != nil - articleExtractorButton.isEnabled = permalinkPresent + articleExtractorButton.isEnabled = permalinkPresent && !AppDefaults.isDeveloperBuild actionBarButtonItem.isEnabled = permalinkPresent if article.status.read { diff --git a/iOS/Article/OpenInSafariActivity.swift b/iOS/Article/OpenInSafariActivity.swift index a157345fb..9b5b5ed8f 100644 --- a/iOS/Article/OpenInSafariActivity.swift +++ b/iOS/Article/OpenInSafariActivity.swift @@ -37,7 +37,7 @@ class OpenInSafariActivity: UIActivity { } override func perform() { - guard let url = activityItems?.firstElementPassingTest({ $0 is URL }) as? URL else { + guard let url = activityItems?.first(where: { $0 is URL }) as? URL else { activityDidFinish(false) return } diff --git a/iOS/Inspector/AccountInspectorViewController.swift b/iOS/Inspector/AccountInspectorViewController.swift index 70022f211..cc7c5be5b 100644 --- a/iOS/Inspector/AccountInspectorViewController.swift +++ b/iOS/Inspector/AccountInspectorViewController.swift @@ -107,8 +107,7 @@ extension AccountInspectorViewController { return true } switch account.type { - case .onMyMac, - .feedly: + case .onMyMac, .cloudKit, .feedly: return true default: return false diff --git a/iOS/MasterFeed/Cell/MasterFeedTableViewCell.swift b/iOS/MasterFeed/Cell/MasterFeedTableViewCell.swift index 1bed941f6..bf9d4652b 100644 --- a/iOS/MasterFeed/Cell/MasterFeedTableViewCell.swift +++ b/iOS/MasterFeed/Cell/MasterFeedTableViewCell.swift @@ -12,7 +12,7 @@ import Account import RSTree protocol MasterFeedTableViewCellDelegate: class { - func disclosureSelected(_ sender: MasterFeedTableViewCell, expanding: Bool) + func masterFeedTableViewCellDisclosureDidToggle(_ sender: MasterFeedTableViewCell, expanding: Bool) } class MasterFeedTableViewCell : VibrantTableViewCell { @@ -149,7 +149,7 @@ class MasterFeedTableViewCell : VibrantTableViewCell { @objc func buttonPressed(_ sender: UIButton) { if isDisclosureAvailable { setDisclosure(isExpanded: !isDisclosureExpanded, animated: true) - delegate?.disclosureSelected(self, expanding: isDisclosureExpanded) + delegate?.masterFeedTableViewCellDisclosureDidToggle(self, expanding: isDisclosureExpanded) } } @@ -181,6 +181,9 @@ private extension MasterFeedTableViewCell { disclosureButton?.tintColor = AppAssets.controlBackgroundColor disclosureButton?.imageView?.contentMode = .center disclosureButton?.imageView?.clipsToBounds = false + if #available(iOS 13.4, *) { + disclosureButton?.addInteraction(UIPointerInteraction()) + } addSubviewAtInit(disclosureButton!) } diff --git a/iOS/MasterFeed/Cell/MasterFeedTableViewSectionHeader.swift b/iOS/MasterFeed/Cell/MasterFeedTableViewSectionHeader.swift index 0227184bf..9c081688c 100644 --- a/iOS/MasterFeed/Cell/MasterFeedTableViewSectionHeader.swift +++ b/iOS/MasterFeed/Cell/MasterFeedTableViewSectionHeader.swift @@ -8,8 +8,14 @@ import UIKit +protocol MasterFeedTableViewSectionHeaderDelegate { + func masterFeedTableViewSectionHeaderDisclosureDidToggle(_ sender: MasterFeedTableViewSectionHeader) +} + class MasterFeedTableViewSectionHeader: UITableViewHeaderFooterView { + var delegate: MasterFeedTableViewSectionHeaderDelegate? + override var accessibilityLabel: String? { set {} get { @@ -66,12 +72,16 @@ class MasterFeedTableViewSectionHeader: UITableViewHeaderFooterView { }() private let unreadCountView = MasterFeedUnreadCountView(frame: CGRect.zero) - private var disclosureView: UIImageView = { - let iView = NonIntrinsicImageView() - iView.tintColor = UIColor.tertiaryLabel - iView.image = AppAssets.disclosureImage - iView.contentMode = .center - return iView + private lazy var disclosureButton: UIButton = { + let button = NonIntrinsicButton() + button.tintColor = UIColor.tertiaryLabel + button.setImage(AppAssets.disclosureImage, for: .normal) + button.contentMode = .center + if #available(iOS 13.4, *) { + button.addInteraction(UIPointerInteraction()) + } + button.addTarget(self, action: #selector(toggleDisclosure), for: .touchUpInside) + return button }() private let topSeparatorView: UIView = { @@ -115,10 +125,14 @@ class MasterFeedTableViewSectionHeader: UITableViewHeaderFooterView { private extension MasterFeedTableViewSectionHeader { + @objc func toggleDisclosure() { + delegate?.masterFeedTableViewSectionHeaderDisclosureDidToggle(self) + } + func commonInit() { addSubviewAtInit(unreadCountView) addSubviewAtInit(titleView) - addSubviewAtInit(disclosureView) + addSubviewAtInit(disclosureButton) updateExpandedState(animate: false) addBackgroundView() addSubviewAtInit(topSeparatorView) @@ -136,9 +150,9 @@ private extension MasterFeedTableViewSectionHeader { withDuration: duration, animations: { if self.disclosureExpanded { - self.disclosureView.transform = CGAffineTransform(rotationAngle: 1.570796) + self.disclosureButton.transform = CGAffineTransform(rotationAngle: 1.570796) } else { - self.disclosureView.transform = CGAffineTransform(rotationAngle: 0) + self.disclosureButton.transform = CGAffineTransform(rotationAngle: 0) } }, completion: { _ in if !self.isLastSection && !self.disclosureExpanded { @@ -167,7 +181,7 @@ private extension MasterFeedTableViewSectionHeader { func layoutWith(_ layout: MasterFeedTableViewSectionHeaderLayout) { titleView.setFrameIfNotEqual(layout.titleRect) unreadCountView.setFrameIfNotEqual(layout.unreadCountRect) - disclosureView.setFrameIfNotEqual(layout.disclosureButtonRect) + disclosureButton.setFrameIfNotEqual(layout.disclosureButtonRect) let top = CGRect(x: safeAreaInsets.left, y: 0, width: frame.width - safeAreaInsets.right - safeAreaInsets.left, height: 0.33) topSeparatorView.setFrameIfNotEqual(top) diff --git a/iOS/MasterFeed/MasterFeedViewController.swift b/iOS/MasterFeed/MasterFeedViewController.swift index 5432c72a0..d8f99402f 100644 --- a/iOS/MasterFeed/MasterFeedViewController.swift +++ b/iOS/MasterFeed/MasterFeedViewController.swift @@ -158,6 +158,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { } let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "SectionHeader") as! MasterFeedTableViewSectionHeader + headerView.delegate = self headerView.name = nameProvider.nameForDisplay guard let sectionNode = coordinator.rootNode.childAtIndex(section) else { @@ -386,24 +387,10 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { } @objc func toggleSectionHeader(_ sender: UITapGestureRecognizer) { - - guard let sectionIndex = sender.view?.tag, - let sectionNode = coordinator.rootNode.childAtIndex(sectionIndex), - let headerView = sender.view as? MasterFeedTableViewSectionHeader - else { - return + guard let headerView = sender.view as? MasterFeedTableViewSectionHeader else { + return } - - if coordinator.isExpanded(sectionNode) { - headerView.disclosureExpanded = false - coordinator.collapse(sectionNode) - self.applyChanges(animated: true) - } else { - headerView.disclosureExpanded = true - coordinator.expand(sectionNode) - self.applyChanges(animated: true) - } - + toggle(headerView) } @objc func refreshAccounts(_ sender: Any) { @@ -556,11 +543,21 @@ extension MasterFeedViewController: UIContextMenuInteractionDelegate { } } +// MARK: MasterFeedTableViewSectionHeaderDelegate + +extension MasterFeedViewController: MasterFeedTableViewSectionHeaderDelegate { + + func masterFeedTableViewSectionHeaderDisclosureDidToggle(_ sender: MasterFeedTableViewSectionHeader) { + toggle(sender) + } + +} + // MARK: MasterTableViewCellDelegate extension MasterFeedViewController: MasterFeedTableViewCellDelegate { - func disclosureSelected(_ sender: MasterFeedTableViewCell, expanding: Bool) { + func masterFeedTableViewCellDisclosureDidToggle(_ sender: MasterFeedTableViewCell, expanding: Bool) { if expanding { expand(sender) } else { @@ -753,6 +750,22 @@ private extension MasterFeedViewController { return nil } + func toggle(_ headerView: MasterFeedTableViewSectionHeader) { + guard let sectionNode = coordinator.rootNode.childAtIndex(headerView.tag) else { + return + } + + if coordinator.isExpanded(sectionNode) { + headerView.disclosureExpanded = false + coordinator.collapse(sectionNode) + self.applyChanges(animated: true) + } else { + headerView.disclosureExpanded = true + coordinator.expand(sectionNode) + self.applyChanges(animated: true) + } + } + func expand(_ cell: MasterFeedTableViewCell) { guard let indexPath = tableView.indexPath(for: cell), let node = dataSource.itemIdentifier(for: indexPath) else { return diff --git a/iOS/MasterTimeline/MasterTimelineTitleView.swift b/iOS/MasterTimeline/MasterTimelineTitleView.swift index 3046f079a..76e4fbbaa 100644 --- a/iOS/MasterTimeline/MasterTimelineTitleView.swift +++ b/iOS/MasterTimeline/MasterTimelineTitleView.swift @@ -13,5 +13,38 @@ class MasterTimelineTitleView: UIView { @IBOutlet weak var iconView: IconView! @IBOutlet weak var label: UILabel! @IBOutlet weak var unreadCountView: MasterTimelineUnreadCountView! + + @available(iOS 13.4, *) + private lazy var pointerInteraction: UIPointerInteraction = { + UIPointerInteraction(delegate: self) + }() + + func buttonize() { + heightAnchor.constraint(equalToConstant: 40.0).isActive = true + accessibilityTraits = .button + if #available(iOS 13.4, *) { + addInteraction(pointerInteraction) + } + } + + func debuttonize() { + accessibilityTraits.remove(.button) + if #available(iOS 13.4, *) { + removeInteraction(pointerInteraction) + } + } + +} + +extension MasterTimelineTitleView: UIPointerInteractionDelegate { + + @available(iOS 13.4, *) + func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? { + var rect = self.frame + rect.origin.x = rect.origin.x - 10 + rect.size.width = rect.width + 20 + + return UIPointerStyle(effect: .automatic(UITargetedPreview(view: self)), shape: .roundedRect(rect)) + } } diff --git a/iOS/MasterTimeline/MasterTimelineViewController.swift b/iOS/MasterTimeline/MasterTimelineViewController.swift index 4cb77da86..8f7b41c24 100644 --- a/iOS/MasterTimeline/MasterTimelineViewController.swift +++ b/iOS/MasterTimeline/MasterTimelineViewController.swift @@ -564,12 +564,11 @@ private extension MasterTimelineViewController { updateTitleUnreadCount() if coordinator.timelineFeed is WebFeed { - titleView.heightAnchor.constraint(equalToConstant: 44.0).isActive = true + titleView.buttonize() titleView.addGestureRecognizer(feedTapGestureRecognizer) - titleView.accessibilityTraits = .button } else { + titleView.debuttonize() titleView.removeGestureRecognizer(feedTapGestureRecognizer) - titleView.accessibilityTraits.remove(.button) } navigationItem.titleView = titleView diff --git a/iOS/Resources/Assets.xcassets/accountCloudKit.imageset/Contents.json b/iOS/Resources/Assets.xcassets/accountCloudKit.imageset/Contents.json new file mode 100644 index 000000000..fc07d2975 --- /dev/null +++ b/iOS/Resources/Assets.xcassets/accountCloudKit.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "icloud.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/iOS/Resources/Assets.xcassets/accountCloudKit.imageset/icloud.pdf b/iOS/Resources/Assets.xcassets/accountCloudKit.imageset/icloud.pdf new file mode 100644 index 000000000..74406f4cb Binary files /dev/null and b/iOS/Resources/Assets.xcassets/accountCloudKit.imageset/icloud.pdf differ diff --git a/iOS/Resources/Info.plist b/iOS/Resources/Info.plist index 9251a4909..2baef99bc 100644 --- a/iOS/Resources/Info.plist +++ b/iOS/Resources/Info.plist @@ -92,6 +92,8 @@ UIBackgroundModes fetch + processing + remote-notification UILaunchStoryboardName LaunchScreenPhone diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index 4c3c0b108..aac383db3 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -61,7 +61,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { private var wasRootSplitViewControllerCollapsed = false private let fetchAndMergeArticlesQueue = CoalescingQueue(name: "Fetch and Merge Articles", interval: 0.5) - private let rebuildBackingStoresWithMergeQueue = CoalescingQueue(name: "Rebuild The Backing Stores by Merging", interval: 1.0) + private let rebuildBackingStoresQueue = CoalescingQueue(name: "Rebuild The Backing Stores", interval: 1.0) private var fetchSerialNumber = 0 private let fetchRequestQueue = FetchRequestQueue() @@ -301,7 +301,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { NotificationCenter.default.addObserver(self, selector: #selector(userDidDeleteAccount(_:)), name: .UserDidDeleteAccount, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(userDidAddFeed(_:)), name: .UserDidAddFeed, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(downloadArticlesDidUpdateUnreadCounts(_:)), name: .DownloadArticlesDidUpdateUnreadCounts, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(accountDidDownloadArticles(_:)), name: .AccountDidDownloadArticles, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil) @@ -441,12 +440,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { return } - // If we are filtering reads, the new unread count is greater than 1, and the feed isn't shown then continue - guard let feed = note.object as? Feed, isReadFeedsFiltered, feed.unreadCount > 0, !shadowTableContains(feed) else { - return - } - - rebuildBackingStoresWithMergeQueue.add(self, #selector(rebuildBackingStoresWithMerge)) + queueRebuildBackingStores() } @objc func statusesDidChange(_ note: Notification) { @@ -465,7 +459,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { } @objc func batchUpdateDidPerform(_ notification: Notification) { - rebuildBackingStoresWithMerge() + rebuildBackingStores() } @objc func displayNameDidChange(_ note: Notification) { @@ -540,10 +534,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { self.groupByFeed = AppDefaults.timelineGroupByFeed } - @objc func downloadArticlesDidUpdateUnreadCounts(_ note: Notification) { - rebuildBackingStoresWithMerge() - } - @objc func accountDidDownloadArticles(_ note: Notification) { guard let feeds = note.userInfo?[Account.UserInfoKey.webFeeds] as? Set else { return @@ -568,7 +558,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { func suspend() { fetchAndMergeArticlesQueue.performCallsImmediately() - rebuildBackingStoresWithMergeQueue.performCallsImmediately() + rebuildBackingStoresQueue.performCallsImmediately() fetchRequestQueue.cancelAllRequests() } @@ -745,6 +735,9 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { } else { setTimelineFeed(nil, animated: false) { + if self.isReadFeedsFiltered { + self.queueRebuildBackingStores() + } self.activityManager.invalidateSelecting() if self.rootSplitViewController.isCollapsed && self.navControllerForTimeline().viewControllers.last is MasterTimelineViewController { self.navControllerForTimeline().popViewController(animated: animations.contains(.navigation)) @@ -929,7 +922,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { } if selectNextUnreadArticleInTimeline() { - activityManager.selectingNextUnread() return } @@ -938,9 +930,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { } selectNextUnreadFeed() { - if self.selectNextUnreadArticleInTimeline() { - self.activityManager.selectingNextUnread() - } + self.selectNextUnreadArticleInTimeline() } } @@ -1360,7 +1350,15 @@ private extension SceneCoordinator { } } } + + func queueRebuildBackingStores() { + rebuildBackingStoresQueue.add(self, #selector(rebuildBackingStoresWithDefaults)) + } + @objc func rebuildBackingStoresWithDefaults() { + rebuildBackingStores() + } + func rebuildBackingStores(initialLoad: Bool = false, updateExpandedNodes: (() -> Void)? = nil, completion: (() -> Void)? = nil) { if !animatingChanges && !BatchUpdate.shared.isPerforming { @@ -1375,11 +1373,6 @@ private extension SceneCoordinator { } } - @objc func rebuildBackingStoresWithMerge() { - addShadowTableToFilterExceptions() - rebuildBackingStores() - } - func rebuildShadowTable() { shadowTable = [[Node]]() diff --git a/iOS/Settings/AddAccountViewController.swift b/iOS/Settings/AddAccountViewController.swift index 870b7e1f1..1a2cf52f3 100644 --- a/iOS/Settings/AddAccountViewController.swift +++ b/iOS/Settings/AddAccountViewController.swift @@ -16,47 +16,90 @@ protocol AddAccountDismissDelegate: UIViewController { class AddAccountViewController: UITableViewController, AddAccountDismissDelegate { - @IBOutlet private weak var localAccountImageView: UIImageView! - @IBOutlet private weak var localAccountNameLabel: UILabel! - + #if DEBUG + private var addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly, .feedWrangler, .cloudKit, .newsBlur] + #else + private var addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly] + #endif + override func viewDidLoad() { super.viewDidLoad() - localAccountImageView.image = AppAssets.image(for: .onMyMac) - localAccountNameLabel.text = Account.defaultLocalAccountName + restrictAccounts() + } + + override func numberOfSections(in tableView: UITableView) -> Int { + 1 } - #if !DEBUG override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return 3 + return addableAccountTypes.count + } + + override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return 52.0 + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "SettingsAccountTableViewCell", for: indexPath) as! SettingsAccountTableViewCell + + switch addableAccountTypes[indexPath.row] { + case .onMyMac: + cell.accountNameLabel?.text = Account.defaultLocalAccountName + cell.accountImage?.image = AppAssets.image(for: .onMyMac) + case .cloudKit: + cell.accountNameLabel?.text = NSLocalizedString("iCloud", comment: "iCloud") + cell.accountImage?.image = AppAssets.accountCloudKitImage + case .feedbin: + cell.accountNameLabel?.text = NSLocalizedString("Feedbin", comment: "Feedbin") + cell.accountImage?.image = AppAssets.accountFeedbinImage + case .feedWrangler: + cell.accountNameLabel?.text = NSLocalizedString("Feed Wrangler", comment: "Feed Wrangler") + cell.accountImage?.image = AppAssets.accountFeedWranglerImage + case .feedly: + cell.accountNameLabel?.text = NSLocalizedString("Feedly", comment: "Feedly") + cell.accountImage?.image = AppAssets.accountFeedlyImage + case .newsBlur: + cell.accountNameLabel?.text = NSLocalizedString("NewsBlur", comment: "NewsBlur") + cell.accountImage?.image = AppAssets.accountNewsBlurImage + default: + break + } + + return cell } - #endif override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - switch indexPath.row { - case 0: - let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "AddLocalAccountNavigationViewController") as! UINavigationController + switch addableAccountTypes[indexPath.row] { + case .onMyMac: + let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "LocalAccountNavigationViewController") as! UINavigationController navController.modalPresentationStyle = .currentContext let addViewController = navController.topViewController as! LocalAccountViewController addViewController.delegate = self present(navController, animated: true) - case 1: + case .cloudKit: + let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "CloudKitAccountNavigationViewController") as! UINavigationController + navController.modalPresentationStyle = .currentContext + let addViewController = navController.topViewController as! CloudKitAccountViewController + addViewController.delegate = self + present(navController, animated: true) + case .feedbin: let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "FeedbinAccountNavigationViewController") as! UINavigationController navController.modalPresentationStyle = .currentContext let addViewController = navController.topViewController as! FeedbinAccountViewController addViewController.delegate = self present(navController, animated: true) - case 2: + case .feedly: let addAccount = OAuthAccountAuthorizationOperation(accountType: .feedly) addAccount.delegate = self addAccount.presentationAnchor = self.view.window! MainThreadOperationQueue.shared.add(addAccount) - case 3: + case .feedWrangler: let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "FeedWranglerAccountNavigationViewController") as! UINavigationController navController.modalPresentationStyle = .currentContext let addViewController = navController.topViewController as! FeedWranglerAccountViewController addViewController.delegate = self present(navController, animated: true) - case 4: + case .newsBlur: let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "NewsBlurAccountNavigationViewController") as! UINavigationController navController.modalPresentationStyle = .currentContext let addViewController = navController.topViewController as! NewsBlurAccountViewController @@ -97,3 +140,28 @@ extension AddAccountViewController: OAuthAccountAuthorizationOperationDelegate { presentError(error) } } + +// MARK: Private + +private extension AddAccountViewController { + + func restrictAccounts() { + func removeAccountType(_ accountType: AccountType) { + if let index = addableAccountTypes.firstIndex(of: accountType) { + addableAccountTypes.remove(at: index) + } + } + + if AppDefaults.isDeveloperBuild { + removeAccountType(.cloudKit) + removeAccountType(.feedly) + removeAccountType(.feedWrangler) + return + } + + if AccountManager.shared.activeAccounts.firstIndex(where: { $0.type == .cloudKit }) != nil { + removeAccountType(.cloudKit) + } + } + +} diff --git a/iOS/Settings/Settings.storyboard b/iOS/Settings/Settings.storyboard index 2b2a986bc..7d7c0f0f6 100644 --- a/iOS/Settings/Settings.storyboard +++ b/iOS/Settings/Settings.storyboard @@ -1,8 +1,8 @@ - + - + @@ -320,7 +320,7 @@ - + @@ -356,7 +356,7 @@ - + @@ -373,7 +373,7 @@ - + @@ -390,7 +390,7 @@ - + @@ -407,7 +407,7 @@ - + @@ -424,7 +424,7 @@ - + @@ -441,7 +441,7 @@ - + @@ -458,7 +458,7 @@ - + @@ -475,7 +475,7 @@ - + @@ -523,209 +523,54 @@ - + - - - - - - - - - + + + + + + + + + + - - - - - - - - - - - - - - + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + - - - - @@ -1083,11 +928,7 @@ - - - - diff --git a/iOS/UIKit Extensions/InteractiveNavigationController.swift b/iOS/UIKit Extensions/InteractiveNavigationController.swift index 6c5da6628..eb939fc3d 100644 --- a/iOS/UIKit Extensions/InteractiveNavigationController.swift +++ b/iOS/UIKit Extensions/InteractiveNavigationController.swift @@ -26,7 +26,6 @@ class InteractiveNavigationController: UINavigationController { override func viewDidLoad() { super.viewDidLoad() - poppableDelegate.originalDelegate = interactivePopGestureRecognizer?.delegate poppableDelegate.navigationController = self interactivePopGestureRecognizer?.delegate = poppableDelegate } @@ -38,7 +37,7 @@ class InteractiveNavigationController: UINavigationController { } } - } +} // MARK: Private diff --git a/iOS/UIKit Extensions/PoppableGestureRecognizerDelegate.swift b/iOS/UIKit Extensions/PoppableGestureRecognizerDelegate.swift index 7d32ce2b5..fa9cda81e 100644 --- a/iOS/UIKit Extensions/PoppableGestureRecognizerDelegate.swift +++ b/iOS/UIKit Extensions/PoppableGestureRecognizerDelegate.swift @@ -5,36 +5,20 @@ // Created by Maurice Parker on 11/18/19. // Copyright © 2019 Ranchero Software. All rights reserved. // -// https://stackoverflow.com/a/38042863 +// https://stackoverflow.com/a/41248703 import UIKit final class PoppableGestureRecognizerDelegate: NSObject, UIGestureRecognizerDelegate { weak var navigationController: UINavigationController? - weak var originalDelegate: UIGestureRecognizerDelegate? - override func responds(to aSelector: Selector!) -> Bool { - if aSelector == #selector(gestureRecognizer(_:shouldReceive:)) { - return true - } else if let responds = originalDelegate?.responds(to: aSelector) { - return responds - } else { - return false - } + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + return navigationController?.viewControllers.count ?? 0 > 1 } - override func forwardingTarget(for aSelector: Selector!) -> Any? { - return originalDelegate - } - - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { - if let nav = navigationController, nav.isNavigationBarHidden, nav.viewControllers.count > 1 { - return true - } else if let result = originalDelegate?.gestureRecognizer?(gestureRecognizer, shouldReceive: touch) { - return result - } else { - return false - } + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return true } + } diff --git a/submodules/RSCore b/submodules/RSCore index a175db500..a742db73c 160000 --- a/submodules/RSCore +++ b/submodules/RSCore @@ -1 +1 @@ -Subproject commit a175db5009f8222fcbaa825d9501305e8727da6f +Subproject commit a742db73c4f4007f0d7097746c88ce2074400045 diff --git a/submodules/RSParser b/submodules/RSParser index 47ba87875..a977d8e84 160000 --- a/submodules/RSParser +++ b/submodules/RSParser @@ -1 +1 @@ -Subproject commit 47ba87875fbd026dccc2c4d4382a98cb4a1f1fbc +Subproject commit a977d8e84af8645fc8268ac843e8a79b3644b133 diff --git a/submodules/RSWeb b/submodules/RSWeb index aa4fda94f..88d634f5f 160000 --- a/submodules/RSWeb +++ b/submodules/RSWeb @@ -1 +1 @@ -Subproject commit aa4fda94f0e81809ac23de4040512378132f9e5d +Subproject commit 88d634f5fd42aab203b6e53c7b551a92b03ffc97