From 9bb110a622e8851c344ddf11e8cee1ebc380c60a Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Fri, 20 Mar 2020 10:36:04 -0500 Subject: [PATCH 01/51] Don't filter feeds when a download occurs. --- Mac/MainWindow/Sidebar/SidebarViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Mac/MainWindow/Sidebar/SidebarViewController.swift b/Mac/MainWindow/Sidebar/SidebarViewController.swift index 971ebd595..17c2f5562 100644 --- a/Mac/MainWindow/Sidebar/SidebarViewController.swift +++ b/Mac/MainWindow/Sidebar/SidebarViewController.swift @@ -218,6 +218,7 @@ protocol SidebarDelegate: class { } @objc func downloadArticlesDidUpdateUnreadCounts(_ note: Notification) { + addTreeControllerToFilterExceptions() rebuildTreeAndRestoreSelection() } From 0d56226146a16f1d67d0d1dd9c0901cdd8600199 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Fri, 20 Mar 2020 17:48:57 -0500 Subject: [PATCH 02/51] Fix delegate name. --- Frameworks/Account/Account.xcodeproj/project.pbxproj | 8 ++++---- ...KitAppDelegate.swift => CloudKitAccountDelegate.swift} | 0 2 files changed, 4 insertions(+), 4 deletions(-) rename Frameworks/Account/CloudKit/{CloudKitAppDelegate.swift => CloudKitAccountDelegate.swift} (100%) diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index e96803641..0b927f0d0 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -18,7 +18,7 @@ 3B826DAE2385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA52385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift */; }; 3B826DAF2385C81C00FC1ADB /* FeedWranglerGenericResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA62385C81C00FC1ADB /* FeedWranglerGenericResult.swift */; }; 3BC23AB92385ECB100371CBA /* FeedWranglerSubscriptionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC23AB82385ECB100371CBA /* FeedWranglerSubscriptionResult.swift */; }; - 5103A9D92422546800410853 /* CloudKitAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5103A9D82422546800410853 /* CloudKitAppDelegate.swift */; }; + 5103A9D92422546800410853 /* CloudKitAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */; }; 5107A09B227DE49500C7C3C5 /* TestAccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */; }; 5107A09D227DE77700C7C3C5 /* TestTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09C227DE77700C7C3C5 /* TestTransport.swift */; }; 510BD111232C3801002692E4 /* AccountMetadataFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510BD110232C3801002692E4 /* AccountMetadataFile.swift */; }; @@ -231,7 +231,7 @@ 3B826DA52385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscriptionsRequest.swift; sourceTree = ""; }; 3B826DA62385C81C00FC1ADB /* FeedWranglerGenericResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerGenericResult.swift; sourceTree = ""; }; 3BC23AB82385ECB100371CBA /* FeedWranglerSubscriptionResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscriptionResult.swift; sourceTree = ""; }; - 5103A9D82422546800410853 /* CloudKitAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAppDelegate.swift; sourceTree = ""; }; + 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAccountDelegate.swift; sourceTree = ""; }; 5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCredentialsTest.swift; sourceTree = ""; }; 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAccountManager.swift; sourceTree = ""; }; 5107A09C227DE77700C7C3C5 /* TestTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTransport.swift; sourceTree = ""; }; @@ -455,7 +455,7 @@ 5103A9D7242253DC00410853 /* CloudKit */ = { isa = PBXGroup; children = ( - 5103A9D82422546800410853 /* CloudKitAppDelegate.swift */, + 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */, ); path = CloudKit; sourceTree = ""; @@ -1019,7 +1019,7 @@ 84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */, 841974251F6DDCE4006346C4 /* AccountDelegate.swift in Sources */, 510BD113232C3E9D002692E4 /* WebFeedMetadataFile.swift in Sources */, - 5103A9D92422546800410853 /* CloudKitAppDelegate.swift in Sources */, + 5103A9D92422546800410853 /* CloudKitAccountDelegate.swift in Sources */, 5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */, 9E784EBE237E890600099B1B /* FeedlyLogoutOperation.swift in Sources */, 9EEEF71F23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift in Sources */, diff --git a/Frameworks/Account/CloudKit/CloudKitAppDelegate.swift b/Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift similarity index 100% rename from Frameworks/Account/CloudKit/CloudKitAppDelegate.swift rename to Frameworks/Account/CloudKit/CloudKitAccountDelegate.swift From 9315a00d19a9b217814bdf05403027b7da64e5e9 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 21 Mar 2020 02:34:34 -0500 Subject: [PATCH 03/51] Add CloudKit container entitlements. --- Mac/Resources/NetNewsWire.entitlements | 12 ++++++++++-- NetNewsWire.xcodeproj/project.pbxproj | 8 ++++++++ iOS/Resources/NetNewsWire.entitlements | 10 ++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Mac/Resources/NetNewsWire.entitlements b/Mac/Resources/NetNewsWire.entitlements index ba32d8233..7b1d03115 100644 --- a/Mac/Resources/NetNewsWire.entitlements +++ b/Mac/Resources/NetNewsWire.entitlements @@ -2,10 +2,18 @@ + com.apple.developer.aps-environment + development + com.apple.developer.icloud-container-identifiers + + iCloud.$(ORGANIZATION_IDENTIFIER).NetNewsWire + + com.apple.developer.icloud-services + + CloudKit + com.apple.security.app-sandbox - com.apple.developer.icloud-container-identifiers - com.apple.security.automation.apple-events com.apple.security.files.user-selected.read-write diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index e6f57b6e2..ed02c1d85 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -260,6 +260,8 @@ 51E3EB3D229AB08300645299 /* ErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E3EB3C229AB08300645299 /* ErrorHandler.swift */; }; 51E43962238037C400015C31 /* AddWebFeedFolderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E43961238037C400015C31 /* AddWebFeedFolderViewController.swift */; }; 51E4398023805EBC00015C31 /* AddWebFeedFolderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4397F23805EBC00015C31 /* AddWebFeedFolderTableViewCell.swift */; }; + 51E4DAED2425F6940091EB5B /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51E4DAEC2425F6940091EB5B /* CloudKit.framework */; }; + 51E4DB082425F9EB0091EB5B /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51E4DB072425F9EB0091EB5B /* CloudKit.framework */; }; 51E595A5228CC36500FCC42B /* ArticleStatusSyncTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E595A4228CC36500FCC42B /* ArticleStatusSyncTimer.swift */; }; 51E595A6228CC36500FCC42B /* ArticleStatusSyncTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E595A4228CC36500FCC42B /* ArticleStatusSyncTimer.swift */; }; 51EAED96231363EF00A9EEE3 /* NonIntrinsicButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51EAED95231363EF00A9EEE3 /* NonIntrinsicButton.swift */; }; @@ -1402,6 +1404,8 @@ 51E3EB3C229AB08300645299 /* ErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorHandler.swift; sourceTree = ""; }; 51E43961238037C400015C31 /* AddWebFeedFolderViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddWebFeedFolderViewController.swift; sourceTree = ""; }; 51E4397F23805EBC00015C31 /* AddWebFeedFolderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddWebFeedFolderTableViewCell.swift; sourceTree = ""; }; + 51E4DAEC2425F6940091EB5B /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; }; + 51E4DB072425F9EB0091EB5B /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.2.sdk/System/Library/Frameworks/CloudKit.framework; sourceTree = DEVELOPER_DIR; }; 51E595A4228CC36500FCC42B /* ArticleStatusSyncTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleStatusSyncTimer.swift; sourceTree = ""; }; 51EAED95231363EF00A9EEE3 /* NonIntrinsicButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonIntrinsicButton.swift; sourceTree = ""; }; 51EC114B2149FE3300B296E3 /* FolderTreeMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FolderTreeMenu.swift; path = AddFeed/FolderTreeMenu.swift; sourceTree = ""; }; @@ -1725,6 +1729,7 @@ 51C451F02264C83100C03939 /* ArticlesDatabase.framework in Frameworks */, 51C451F42264C83900C03939 /* Articles.framework in Frameworks */, 51C451E82264C81000C03939 /* RSDatabase.framework in Frameworks */, + 51E4DB082425F9EB0091EB5B /* CloudKit.framework in Frameworks */, 51C451EC2264C81B00C03939 /* RSCore.framework in Frameworks */, 51554C30228B71A10055115A /* SyncDatabase.framework in Frameworks */, 51C451E42264C80600C03939 /* RSParser.framework in Frameworks */, @@ -1744,6 +1749,7 @@ 84C37FB520DD8DBB00CA8CF5 /* RSParser.framework in Frameworks */, 51C451BD226377D000C03939 /* Account.framework in Frameworks */, 51C451B9226377C900C03939 /* Articles.framework in Frameworks */, + 51E4DAED2425F6940091EB5B /* CloudKit.framework in Frameworks */, 84C37FA520DD8D8400CA8CF5 /* RSCore.framework in Frameworks */, 51554C24228B71910055115A /* SyncDatabase.framework in Frameworks */, ); @@ -2078,6 +2084,8 @@ 51C452B22265141B00C03939 /* Frameworks */ = { isa = PBXGroup; children = ( + 51E4DB072425F9EB0091EB5B /* CloudKit.framework */, + 51E4DAEC2425F6940091EB5B /* CloudKit.framework */, 51C452B32265141B00C03939 /* WebKit.framework */, ); name = Frameworks; diff --git a/iOS/Resources/NetNewsWire.entitlements b/iOS/Resources/NetNewsWire.entitlements index 05d04e805..028d33157 100644 --- a/iOS/Resources/NetNewsWire.entitlements +++ b/iOS/Resources/NetNewsWire.entitlements @@ -2,6 +2,16 @@ + aps-environment + development + com.apple.developer.icloud-container-identifiers + + iCloud.$(ORGANIZATION_IDENTIFIER).NetNewsWire + + com.apple.developer.icloud-services + + CloudKit + com.apple.security.application-groups group.$(ORGANIZATION_IDENTIFIER).NetNewsWire.iOS From f2d9552c8567be2ed0903b8f9e0dd02256107a15 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 21 Mar 2020 11:33:13 -0500 Subject: [PATCH 04/51] Renamed subscriptionID to externalD on WebFeed so that it matches what we did with Folder. --- .../FeedWrangler/FeedWranglerAccountDelegate.swift | 4 ++-- .../Account/Feedbin/FeedbinAccountDelegate.swift | 12 ++++++------ .../ReaderAPI/ReaderAPIAccountDelegate.swift | 14 +++++++------- Frameworks/Account/WebFeed.swift | 6 +++--- Frameworks/Account/WebFeedMetadata.swift | 8 ++++---- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index 8186e378b..9c760fd88 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -493,7 +493,7 @@ private extension FeedWranglerAccountDelegate { feed.name = subscription.title feed.editedName = nil feed.homePageURL = subscription.siteURL - feed.subscriptionID = nil // MARK: TODO What should this be? + feed.externalID = nil // MARK: TODO What should this be? } else { subscriptionsToAdd.insert(subscription) } @@ -502,7 +502,7 @@ private extension FeedWranglerAccountDelegate { subscriptionsToAdd.forEach { subscription in let feedId = String(subscription.feedID) let feed = account.createWebFeed(with: subscription.title, url: subscription.feedURL, webFeedID: feedId, homePageURL: subscription.siteURL) - feed.subscriptionID = nil + feed.externalID = nil account.addWebFeed(feed) } } diff --git a/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift b/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift index 35818a012..af2cda43a 100644 --- a/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift +++ b/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift @@ -334,7 +334,7 @@ final class FeedbinAccountDelegate: AccountDelegate { } else { - if let subscriptionID = feed.subscriptionID { + if let subscriptionID = feed.externalID { group.enter() refreshProgress.addToNumberOfTasksAndRemaining(1) caller.deleteSubscription(subscriptionID: subscriptionID) { result in @@ -398,7 +398,7 @@ final class FeedbinAccountDelegate: AccountDelegate { func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result) -> Void) { // This error should never happen - guard let subscriptionID = feed.subscriptionID else { + guard let subscriptionID = feed.externalID else { completion(.failure(FeedbinAccountDelegateError.invalidParameter)) return } @@ -812,7 +812,7 @@ private extension FeedbinAccountDelegate { // If the name has been changed on the server remove the locally edited name feed.editedName = nil feed.homePageURL = subscription.homePageURL - feed.subscriptionID = String(subscription.subscriptionID) + feed.externalID = String(subscription.subscriptionID) feed.faviconURL = subscription.jsonFeed?.favicon feed.iconURL = subscription.jsonFeed?.icon } @@ -824,7 +824,7 @@ private extension FeedbinAccountDelegate { // Actually add subscriptions all in one go, so we don’t trigger various rebuilding things that Account does. subscriptionsToAdd.forEach { subscription in let feed = account.createWebFeed(with: subscription.name, url: subscription.url, webFeedID: String(subscription.feedID), homePageURL: subscription.homePageURL) - feed.subscriptionID = String(subscription.subscriptionID) + feed.externalID = String(subscription.subscriptionID) account.addWebFeed(feed) } } @@ -1004,7 +1004,7 @@ private extension FeedbinAccountDelegate { DispatchQueue.main.async { let feed = account.createWebFeed(with: sub.name, url: sub.url, webFeedID: String(sub.feedID), homePageURL: sub.homePageURL) - feed.subscriptionID = String(sub.subscriptionID) + feed.externalID = String(sub.subscriptionID) feed.iconURL = sub.jsonFeed?.icon feed.faviconURL = sub.jsonFeed?.favicon @@ -1351,7 +1351,7 @@ private extension FeedbinAccountDelegate { func deleteSubscription(for account: Account, with feed: WebFeed, from container: Container?, completion: @escaping (Result) -> Void) { // This error should never happen - guard let subscriptionID = feed.subscriptionID else { + guard let subscriptionID = feed.externalID else { completion(.failure(FeedbinAccountDelegateError.invalidParameter)) return } diff --git a/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift b/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift index ad55d4e6c..ab36e98d6 100644 --- a/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift +++ b/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift @@ -292,7 +292,7 @@ final class ReaderAPIAccountDelegate: AccountDelegate { func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result) -> Void) { // This error should never happen - guard let subscriptionID = feed.subscriptionID else { + guard let subscriptionID = feed.externalID else { completion(.failure(FeedbinAccountDelegateError.invalidParameter)) return } @@ -340,12 +340,12 @@ final class ReaderAPIAccountDelegate: AccountDelegate { func addWebFeed(for account: Account, with feed: WebFeed, to container: Container, completion: @escaping (Result) -> Void) { - if let folder = container as? Folder, let feedName = feed.subscriptionID { + if let folder = container as? Folder, let feedName = feed.externalID { caller.createTagging(subscriptionID: feedName, tagName: folder.name ?? "") { result in switch result { case .success: DispatchQueue.main.async { - self.saveFolderRelationship(for: feed, withFolderName: folder.name ?? "", id: feed.subscriptionID!) + self.saveFolderRelationship(for: feed, withFolderName: folder.name ?? "", id: feed.externalID!) account.removeWebFeed(feed) folder.addWebFeed(feed) completion(.success(())) @@ -582,7 +582,7 @@ private extension ReaderAPIAccountDelegate { } else { let feed = account.createWebFeed(with: subscription.name, url: subscription.url, webFeedID: subFeedId, homePageURL: subscription.homePageURL) feed.iconURL = subscription.iconURL - feed.subscriptionID = String(subscription.feedID) + feed.externalID = String(subscription.feedID) account.addWebFeed(feed) } @@ -758,7 +758,7 @@ private extension ReaderAPIAccountDelegate { DispatchQueue.main.async { let feed = account.createWebFeed(with: sub.name, url: sub.url, webFeedID: String(sub.feedID), homePageURL: sub.homePageURL) - feed.subscriptionID = String(sub.feedID) + feed.externalID = String(sub.feedID) account.addWebFeed(feed, to: container) { result in switch result { @@ -985,7 +985,7 @@ private extension ReaderAPIAccountDelegate { func deleteTagging(for account: Account, with feed: WebFeed, from container: Container?, completion: @escaping (Result) -> Void) { - if let folder = container as? Folder, let feedName = feed.subscriptionID { + if let folder = container as? Folder, let feedName = feed.externalID { caller.deleteTagging(subscriptionID: feedName, tagName: folder.name ?? "") { result in switch result { case .success: @@ -1014,7 +1014,7 @@ private extension ReaderAPIAccountDelegate { func deleteSubscription(for account: Account, with feed: WebFeed, from container: Container?, completion: @escaping (Result) -> Void) { // This error should never happen - guard let subscriptionID = feed.subscriptionID else { + guard let subscriptionID = feed.externalID else { completion(.failure(FeedbinAccountDelegateError.invalidParameter)) return } diff --git a/Frameworks/Account/WebFeed.swift b/Frameworks/Account/WebFeed.swift index 179b7720d..a63d28fb0 100644 --- a/Frameworks/Account/WebFeed.swift +++ b/Frameworks/Account/WebFeed.swift @@ -153,12 +153,12 @@ public final class WebFeed: Feed, Renamable, Hashable { } } - public var subscriptionID: String? { + public var externalID: String? { get { - return metadata.subscriptionID + return metadata.externalID } set { - metadata.subscriptionID = newValue + metadata.externalID = newValue } } diff --git a/Frameworks/Account/WebFeedMetadata.swift b/Frameworks/Account/WebFeedMetadata.swift index 8af4eaa6a..424cdc638 100644 --- a/Frameworks/Account/WebFeedMetadata.swift +++ b/Frameworks/Account/WebFeedMetadata.swift @@ -27,7 +27,7 @@ final class WebFeedMetadata: Codable { case isNotifyAboutNewArticles case isArticleExtractorAlwaysOn case conditionalGetInfo - case subscriptionID + case externalID = "subscriptionID" case folderRelationship } @@ -111,10 +111,10 @@ final class WebFeedMetadata: Codable { } } - var subscriptionID: String? { + var externalID: String? { didSet { - if subscriptionID != oldValue { - valueDidChange(.subscriptionID) + if externalID != oldValue { + valueDidChange(.externalID) } } } From 2f3482d260d8e52b125d163015141dc3501b476d Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 21 Mar 2020 17:14:39 -0500 Subject: [PATCH 05/51] Remove border on nested tables. --- Mac/MainWindow/Detail/styleSheet.css | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Mac/MainWindow/Detail/styleSheet.css b/Mac/MainWindow/Detail/styleSheet.css index 2f64566e5..735e5abdb 100644 --- a/Mac/MainWindow/Detail/styleSheet.css +++ b/Mac/MainWindow/Detail/styleSheet.css @@ -153,7 +153,10 @@ code, pre { border: 1px solid var(--accent-color); font-size: inherit; } - +.nnw-overflow table table { + margin-bottom: 0; + border: none; +} .nnw-overflow td, .nnw-overflow th { -webkit-hyphens: none; word-break: normal; From 07cc89541cf975f7966e796d05c1756eb44c73e4 Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Sat, 21 Mar 2020 17:43:33 -0700 Subject: [PATCH 06/51] Add iCloud.com.ranchero.NetNewsWire entitlement to Mac and iOS targets. --- Mac/Resources/NetNewsWire.entitlements | 2 +- iOS/Resources/NetNewsWire.entitlements | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Mac/Resources/NetNewsWire.entitlements b/Mac/Resources/NetNewsWire.entitlements index 7b1d03115..c02ca9720 100644 --- a/Mac/Resources/NetNewsWire.entitlements +++ b/Mac/Resources/NetNewsWire.entitlements @@ -6,7 +6,7 @@ development com.apple.developer.icloud-container-identifiers - iCloud.$(ORGANIZATION_IDENTIFIER).NetNewsWire + iCloud.com.ranchero.NetNewsWire com.apple.developer.icloud-services diff --git a/iOS/Resources/NetNewsWire.entitlements b/iOS/Resources/NetNewsWire.entitlements index 028d33157..ad77f53e2 100644 --- a/iOS/Resources/NetNewsWire.entitlements +++ b/iOS/Resources/NetNewsWire.entitlements @@ -6,7 +6,7 @@ development com.apple.developer.icloud-container-identifiers - iCloud.$(ORGANIZATION_IDENTIFIER).NetNewsWire + iCloud.com.ranchero.NetNewsWire com.apple.developer.icloud-services From 33a1bb24eb25799c99b8c2e660ae11fb06883435 Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Sat, 21 Mar 2020 18:23:19 -0700 Subject: [PATCH 07/51] Add iCloud to entitlements. --- Mac/Resources/NetNewsWire.entitlements | 2 -- 1 file changed, 2 deletions(-) diff --git a/Mac/Resources/NetNewsWire.entitlements b/Mac/Resources/NetNewsWire.entitlements index c02ca9720..92833b16c 100644 --- a/Mac/Resources/NetNewsWire.entitlements +++ b/Mac/Resources/NetNewsWire.entitlements @@ -2,8 +2,6 @@ - com.apple.developer.aps-environment - development com.apple.developer.icloud-container-identifiers iCloud.com.ranchero.NetNewsWire From 6cbcf38aee1b191adb7021a1c55688a83757d15f Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Sat, 21 Mar 2020 18:41:17 -0700 Subject: [PATCH 08/51] =?UTF-8?q?Revise=20View=20menu=20=E2=80=94=20tweak?= =?UTF-8?q?=20commands=20for=20hiding/showing=20read=20items=20and=20feeds?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Mac/Base.lproj/Main.storyboard | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/Mac/Base.lproj/Main.storyboard b/Mac/Base.lproj/Main.storyboard index 64e9b3bdf..ce6a63050 100644 --- a/Mac/Base.lproj/Main.storyboard +++ b/Mac/Base.lproj/Main.storyboard @@ -1,7 +1,7 @@ - + - + @@ -343,18 +343,6 @@ - - - - - - - - - - - - @@ -381,12 +369,22 @@ - - + + + + + + + + + + + + From 960af2a8dff4310d15baa88c3907ce920588e717 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 21 Mar 2020 20:53:21 -0500 Subject: [PATCH 09/51] Parameterize the container so that each developer has their own --- Mac/Resources/NetNewsWire.entitlements | 4 +++- iOS/Resources/NetNewsWire.entitlements | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Mac/Resources/NetNewsWire.entitlements b/Mac/Resources/NetNewsWire.entitlements index 92833b16c..7b1d03115 100644 --- a/Mac/Resources/NetNewsWire.entitlements +++ b/Mac/Resources/NetNewsWire.entitlements @@ -2,9 +2,11 @@ + com.apple.developer.aps-environment + development com.apple.developer.icloud-container-identifiers - iCloud.com.ranchero.NetNewsWire + iCloud.$(ORGANIZATION_IDENTIFIER).NetNewsWire com.apple.developer.icloud-services diff --git a/iOS/Resources/NetNewsWire.entitlements b/iOS/Resources/NetNewsWire.entitlements index ad77f53e2..028d33157 100644 --- a/iOS/Resources/NetNewsWire.entitlements +++ b/iOS/Resources/NetNewsWire.entitlements @@ -6,7 +6,7 @@ development com.apple.developer.icloud-container-identifiers - iCloud.com.ranchero.NetNewsWire + iCloud.$(ORGANIZATION_IDENTIFIER).NetNewsWire com.apple.developer.icloud-services From 2da6ba55502669224e245eb040a76ad0bc989501 Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Sat, 21 Mar 2020 19:20:32 -0700 Subject: [PATCH 10/51] Switch to Automatically Manage Signing for Mac targets. --- NetNewsWire.xcodeproj/project.pbxproj | 43 +++++++++++++++++++-------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index ed02c1d85..eec05115b 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -3048,36 +3048,36 @@ TargetAttributes = { 51314636235A7BBE00387FDC = { CreatedOnToolsVersion = 11.2; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = M8L2WTLA8W; LastSwiftMigration = 1120; ProvisioningStyle = Automatic; }; 513C5CE5232571C2003D4054 = { CreatedOnToolsVersion = 11.0; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = M8L2WTLA8W; ProvisioningStyle = Automatic; }; 518B2ED12351B3DD00400001 = { CreatedOnToolsVersion = 11.2; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = M8L2WTLA8W; ProvisioningStyle = Automatic; TestTargetID = 840D617B2029031C009BC708; }; 6581C73220CED60000F4AD34 = { - DevelopmentTeam = SHJK2V3AJG; - ProvisioningStyle = Automatic; + DevelopmentTeam = M8L2WTLA8W; + ProvisioningStyle = Manual; }; 65ED3FA2235DEF6C0081F399 = { - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = M8L2WTLA8W; ProvisioningStyle = Automatic; }; 65ED4090235DEF770081F399 = { - DevelopmentTeam = SHJK2V3AJG; - ProvisioningStyle = Automatic; + DevelopmentTeam = M8L2WTLA8W; + ProvisioningStyle = Manual; }; 840D617B2029031C009BC708 = { CreatedOnToolsVersion = 9.3; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = M8L2WTLA8W; ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.BackgroundModes = { @@ -3087,7 +3087,7 @@ }; 849C645F1ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = M8L2WTLA8W; ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.HardenedRuntime = { @@ -3097,8 +3097,8 @@ }; 849C64701ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = SHJK2V3AJG; - ProvisioningStyle = Automatic; + DevelopmentTeam = M8L2WTLA8W; + ProvisioningStyle = Manual; TestTargetID = 849C645F1ED37A5D003D8FC0; }; }; @@ -4563,6 +4563,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = D5907CE02002F0FA005947E5 /* NetNewsWire_macapp_target.xcconfig */; buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = M8L2WTLA8W; + PROVISIONING_PROFILE_SPECIFIER = ""; }; name = Test; }; @@ -4613,7 +4617,11 @@ baseConfigurationReference = 65ED40F2235DF5E00081F399 /* NetNewsWire_macapp_target_macappstore.xcconfig */; buildSettings = { CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = M8L2WTLA8W; + PRODUCT_BUNDLE_IDENTIFIER = "com.ranchero.NetNewsWire-Evergreen.MAS"; PRODUCT_NAME = NetNewsWire; + PROVISIONING_PROFILE_SPECIFIER = ""; }; name = Debug; }; @@ -4622,7 +4630,10 @@ baseConfigurationReference = 65ED40F2235DF5E00081F399 /* NetNewsWire_macapp_target_macappstore.xcconfig */; buildSettings = { CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = ""; PRODUCT_NAME = NetNewsWire; + PROVISIONING_PROFILE_SPECIFIER = ""; }; name = Test; }; @@ -4691,6 +4702,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = D5907CE02002F0FA005947E5 /* NetNewsWire_macapp_target.xcconfig */; buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = M8L2WTLA8W; + PROVISIONING_PROFILE_SPECIFIER = ""; }; name = Debug; }; @@ -4698,6 +4713,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = D5907CE02002F0FA005947E5 /* NetNewsWire_macapp_target.xcconfig */; buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = M8L2WTLA8W; + PROVISIONING_PROFILE_SPECIFIER = ""; }; name = Release; }; From ec2a3012ee6ebfd62498008a3764ac875b41923d Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Sat, 21 Mar 2020 19:22:32 -0700 Subject: [PATCH 11/51] Fix bundle IDs in MAS target. --- NetNewsWire.xcodeproj/project.pbxproj | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index eec05115b..11216a5a9 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -4631,7 +4631,8 @@ buildSettings = { CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = M8L2WTLA8W; + PRODUCT_BUNDLE_IDENTIFIER = "com.ranchero.NetNewsWire-Evergreen.MAS"; PRODUCT_NAME = NetNewsWire; PROVISIONING_PROFILE_SPECIFIER = ""; }; @@ -4642,7 +4643,11 @@ baseConfigurationReference = 65ED40F2235DF5E00081F399 /* NetNewsWire_macapp_target_macappstore.xcconfig */; buildSettings = { CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = M8L2WTLA8W; + PRODUCT_BUNDLE_IDENTIFIER = "com.ranchero.NetNewsWire-Evergreen.MAS"; PRODUCT_NAME = NetNewsWire; + PROVISIONING_PROFILE_SPECIFIER = ""; }; name = Release; }; From 10c17649c0bcf13e231de7f3b7a892cb94d87485 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 21 Mar 2020 21:37:43 -0500 Subject: [PATCH 12/51] Rolled back some of Xcode less helpful changes --- NetNewsWire.xcodeproj/project.pbxproj | 48 +++++++-------------------- 1 file changed, 12 insertions(+), 36 deletions(-) diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 11216a5a9..ed02c1d85 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -3048,36 +3048,36 @@ TargetAttributes = { 51314636235A7BBE00387FDC = { CreatedOnToolsVersion = 11.2; - DevelopmentTeam = M8L2WTLA8W; + DevelopmentTeam = SHJK2V3AJG; LastSwiftMigration = 1120; ProvisioningStyle = Automatic; }; 513C5CE5232571C2003D4054 = { CreatedOnToolsVersion = 11.0; - DevelopmentTeam = M8L2WTLA8W; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; }; 518B2ED12351B3DD00400001 = { CreatedOnToolsVersion = 11.2; - DevelopmentTeam = M8L2WTLA8W; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; TestTargetID = 840D617B2029031C009BC708; }; 6581C73220CED60000F4AD34 = { - DevelopmentTeam = M8L2WTLA8W; - ProvisioningStyle = Manual; + DevelopmentTeam = SHJK2V3AJG; + ProvisioningStyle = Automatic; }; 65ED3FA2235DEF6C0081F399 = { - DevelopmentTeam = M8L2WTLA8W; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; }; 65ED4090235DEF770081F399 = { - DevelopmentTeam = M8L2WTLA8W; - ProvisioningStyle = Manual; + DevelopmentTeam = SHJK2V3AJG; + ProvisioningStyle = Automatic; }; 840D617B2029031C009BC708 = { CreatedOnToolsVersion = 9.3; - DevelopmentTeam = M8L2WTLA8W; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.BackgroundModes = { @@ -3087,7 +3087,7 @@ }; 849C645F1ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = M8L2WTLA8W; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.HardenedRuntime = { @@ -3097,8 +3097,8 @@ }; 849C64701ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = M8L2WTLA8W; - ProvisioningStyle = Manual; + DevelopmentTeam = SHJK2V3AJG; + ProvisioningStyle = Automatic; TestTargetID = 849C645F1ED37A5D003D8FC0; }; }; @@ -4563,10 +4563,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = D5907CE02002F0FA005947E5 /* NetNewsWire_macapp_target.xcconfig */; buildSettings = { - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = M8L2WTLA8W; - PROVISIONING_PROFILE_SPECIFIER = ""; }; name = Test; }; @@ -4617,11 +4613,7 @@ baseConfigurationReference = 65ED40F2235DF5E00081F399 /* NetNewsWire_macapp_target_macappstore.xcconfig */; buildSettings = { CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = M8L2WTLA8W; - PRODUCT_BUNDLE_IDENTIFIER = "com.ranchero.NetNewsWire-Evergreen.MAS"; PRODUCT_NAME = NetNewsWire; - PROVISIONING_PROFILE_SPECIFIER = ""; }; name = Debug; }; @@ -4630,11 +4622,7 @@ baseConfigurationReference = 65ED40F2235DF5E00081F399 /* NetNewsWire_macapp_target_macappstore.xcconfig */; buildSettings = { CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = M8L2WTLA8W; - PRODUCT_BUNDLE_IDENTIFIER = "com.ranchero.NetNewsWire-Evergreen.MAS"; PRODUCT_NAME = NetNewsWire; - PROVISIONING_PROFILE_SPECIFIER = ""; }; name = Test; }; @@ -4643,11 +4631,7 @@ baseConfigurationReference = 65ED40F2235DF5E00081F399 /* NetNewsWire_macapp_target_macappstore.xcconfig */; buildSettings = { CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = M8L2WTLA8W; - PRODUCT_BUNDLE_IDENTIFIER = "com.ranchero.NetNewsWire-Evergreen.MAS"; PRODUCT_NAME = NetNewsWire; - PROVISIONING_PROFILE_SPECIFIER = ""; }; name = Release; }; @@ -4707,10 +4691,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = D5907CE02002F0FA005947E5 /* NetNewsWire_macapp_target.xcconfig */; buildSettings = { - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = M8L2WTLA8W; - PROVISIONING_PROFILE_SPECIFIER = ""; }; name = Debug; }; @@ -4718,10 +4698,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = D5907CE02002F0FA005947E5 /* NetNewsWire_macapp_target.xcconfig */; buildSettings = { - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = M8L2WTLA8W; - PROVISIONING_PROFILE_SPECIFIER = ""; }; name = Release; }; From 70b8b82425a6f16c1f0efbd5c04d0ee2d95401bd Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Sat, 21 Mar 2020 21:42:08 -0500 Subject: [PATCH 13/51] Pass allow provisioning updates to xcodebuild --- buildscripts/ci-build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildscripts/ci-build.sh b/buildscripts/ci-build.sh index b3dab8640..1babfdfe5 100755 --- a/buildscripts/ci-build.sh +++ b/buildscripts/ci-build.sh @@ -31,7 +31,7 @@ rm -f buildscripts/certs/ios-dist.cer rm -f buildscripts/certs/mac-dist.p12 # Do the build -xcodebuild -scheme $SCHEME test -destination "$DESTINATION" -showBuildTimingSummary +xcodebuild -scheme $SCHEME test -destination "$DESTINATION" -showBuildTimingSummary -allowProvisioningUpdates # Delete the keychain and the provisioning profile security delete-keychain github-build.keychain From 8f5f856e4920db6c18f6a1d625806b981688cac6 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Mon, 9 Mar 2020 18:39:58 -0400 Subject: [PATCH 14/51] Add NewsBlur account prototype --- NetNewsWire.xcodeproj/project.pbxproj | 6 +- iOS/Account/Account.storyboard | 149 +++++++++++++++ .../NewsBlurAccountViewController.swift | 173 ++++++++++++++++++ iOS/AppAssets.swift | 6 + .../accountNewsBlur.imageset/Contents.json | 15 ++ .../accountNewsBlur.imageset/newsblur-512.png | Bin 0 -> 54138 bytes iOS/Settings/AddAccountViewController.swift | 6 + iOS/Settings/Settings.storyboard | 38 ++++ 8 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 iOS/Account/NewsBlurAccountViewController.swift create mode 100644 iOS/Resources/Assets.xcassets/accountNewsBlur.imageset/Contents.json create mode 100644 iOS/Resources/Assets.xcassets/accountNewsBlur.imageset/newsblur-512.png diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index ed02c1d85..7324d8578 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -466,7 +466,6 @@ 65ED405D235DEF6C0081F399 /* SidebarKeyboardShortcuts.plist in Resources */ = {isa = PBXBuildFile; fileRef = 844B5B681FEA20DF00C7C76A /* SidebarKeyboardShortcuts.plist */; }; 65ED405E235DEF6C0081F399 /* DefaultFeeds.opml in Resources */ = {isa = PBXBuildFile; fileRef = 84A3EE52223B667F00557320 /* DefaultFeeds.opml */; }; 65ED405F235DEF6C0081F399 /* Preferences.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 84C9FC8022629E4800D921D6 /* Preferences.storyboard */; }; - 65ED4060235DEF6C0081F399 /* (null) in Resources */ = {isa = PBXBuildFile; }; 65ED4061235DEF6C0081F399 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 849C64671ED37A5D003D8FC0 /* Assets.xcassets */; }; 65ED4062235DEF6C0081F399 /* styleSheet.css in Resources */ = {isa = PBXBuildFile; fileRef = 848362FC2262A30800DA1D35 /* styleSheet.css */; }; 65ED4063235DEF6C0081F399 /* RenameSheet.xib in Resources */ = {isa = PBXBuildFile; fileRef = 848363092262A3F000DA1D35 /* RenameSheet.xib */; }; @@ -505,6 +504,7 @@ 65ED42DD235E74230081F399 /* org.sparkle-project.InstallerStatus.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 65ED42BA235E71B40081F399 /* org.sparkle-project.InstallerStatus.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 65ED42DE235E74230081F399 /* Sparkle.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 65ED42B0235E71B40081F399 /* Sparkle.framework */; }; 65ED42DF235E74230081F399 /* Sparkle.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 65ED42B0235E71B40081F399 /* Sparkle.framework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 769F2ED513DA03EE75B993A8 /* NewsBlurAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 769F2D3643779DB02786278E /* NewsBlurAccountViewController.swift */; }; 8405DD8A2213E0E3008CE1BF /* DetailContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8405DD892213E0E3008CE1BF /* DetailContainerView.swift */; }; 8405DD9922153B6B008CE1BF /* TimelineContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8405DD9822153B6B008CE1BF /* TimelineContainerView.swift */; }; 8405DD9C22153BD7008CE1BF /* NSView-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8405DD9B22153BD7008CE1BF /* NSView-Extensions.swift */; }; @@ -1454,6 +1454,7 @@ 65ED40F2235DF5E00081F399 /* NetNewsWire_macapp_target_macappstore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_macapp_target_macappstore.xcconfig; sourceTree = ""; }; 65ED4186235E045B0081F399 /* NetNewsWire_safariextension_target_macappstore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_safariextension_target_macappstore.xcconfig; sourceTree = ""; }; 65ED4299235E71B40081F399 /* Sparkle.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = Sparkle.xcodeproj; path = submodules/Sparkle/Sparkle.xcodeproj; sourceTree = SOURCE_ROOT; }; + 769F2D3643779DB02786278E /* NewsBlurAccountViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurAccountViewController.swift; sourceTree = ""; }; 8405DD892213E0E3008CE1BF /* DetailContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailContainerView.swift; sourceTree = ""; }; 8405DD9822153B6B008CE1BF /* TimelineContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineContainerView.swift; sourceTree = ""; }; 8405DD9B22153BD7008CE1BF /* NSView-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSView-Extensions.swift"; sourceTree = ""; }; @@ -1874,6 +1875,7 @@ 51A1698F235E10D600EB091F /* LocalAccountViewController.swift */, 51A16996235E10D700EB091F /* FeedbinAccountViewController.swift */, 3B3A328B238B820900314204 /* FeedWranglerAccountViewController.swift */, + 769F2D3643779DB02786278E /* NewsBlurAccountViewController.swift */, ); path = Account; sourceTree = ""; @@ -3487,7 +3489,6 @@ 5103A9F5242258C600410853 /* AccountsAddCloudKit.xib in Resources */, 65ED405E235DEF6C0081F399 /* DefaultFeeds.opml in Resources */, 65ED405F235DEF6C0081F399 /* Preferences.storyboard in Resources */, - 65ED4060235DEF6C0081F399 /* (null) in Resources */, 65ED4061235DEF6C0081F399 /* Assets.xcassets in Resources */, 65ED4062235DEF6C0081F399 /* styleSheet.css in Resources */, 65ED4063235DEF6C0081F399 /* RenameSheet.xib in Resources */, @@ -4069,6 +4070,7 @@ 51C45293226509C800C03939 /* StarredFeedDelegate.swift in Sources */, 51D6A5BC23199C85001C27D8 /* MasterTimelineDataSource.swift in Sources */, 51934CCB230F599B006127BE /* InteractiveNavigationController.swift in Sources */, + 769F2ED513DA03EE75B993A8 /* NewsBlurAccountViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS/Account/Account.storyboard b/iOS/Account/Account.storyboard index 52982af01..62d36ff42 100644 --- a/iOS/Account/Account.storyboard +++ b/iOS/Account/Account.storyboard @@ -406,6 +406,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/Account/NewsBlurAccountViewController.swift b/iOS/Account/NewsBlurAccountViewController.swift new file mode 100644 index 000000000..3334f16c7 --- /dev/null +++ b/iOS/Account/NewsBlurAccountViewController.swift @@ -0,0 +1,173 @@ +// +// NewsBlurAccountViewController.swift +// NetNewsWire +// +// Created by Anh-Quang Do on 3/9/20. +// Copyright (c) 2020 Ranchero Software. All rights reserved. +// + +import UIKit +import Account +import RSWeb + +class NewsBlurAccountViewController: UITableViewController { + + @IBOutlet weak var activityIndicator: UIActivityIndicatorView! + @IBOutlet weak var cancelBarButtonItem: UIBarButtonItem! + @IBOutlet weak var emailTextField: UITextField! + @IBOutlet weak var passwordTextField: UITextField! + @IBOutlet weak var showHideButton: UIButton! + @IBOutlet weak var actionButton: UIButton! + + weak var account: Account? + weak var delegate: AddAccountDismissDelegate? + + override func viewDidLoad() { + super.viewDidLoad() + + activityIndicator.isHidden = true + emailTextField.delegate = self + passwordTextField.delegate = self + + if let account = account, let credentials = try? account.retrieveCredentials(type: .basic) { + actionButton.setTitle(NSLocalizedString("Update Credentials", comment: "Update Credentials"), for: .normal) + actionButton.isEnabled = true + emailTextField.text = credentials.username + passwordTextField.text = credentials.secret + } else { + actionButton.setTitle(NSLocalizedString("Add Account", comment: "Add Account"), for: .normal) + } + + NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: emailTextField) + NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: passwordTextField) + + tableView.register(ImageHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader") + } + + 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: .newsBlur) + return headerView + } else { + return super.tableView(tableView, viewForHeaderInSection: section) + } + } + + @IBAction func cancel(_ sender: Any) { + dismiss(animated: true, completion: nil) + delegate?.dismiss() + } + + @IBAction func showHidePassword(_ sender: Any) { + if passwordTextField.isSecureTextEntry { + passwordTextField.isSecureTextEntry = false + showHideButton.setTitle("Hide", for: .normal) + } else { + passwordTextField.isSecureTextEntry = true + showHideButton.setTitle("Show", for: .normal) + } + } + + @IBAction func action(_ sender: Any) { + + guard let email = emailTextField.text, let password = passwordTextField.text else { + showError(NSLocalizedString("Username & password required.", comment: "Credentials Error")) + return + } + + startAnimatingActivityIndicator() + disableNavigation() + + // When you fill in the email address via auto-complete it adds extra whitespace + let trimmedEmail = email.trimmingCharacters(in: .whitespaces) + let credentials = Credentials(type: .basic, username: trimmedEmail, secret: password) + Account.validateCredentials(type: .newsBlur, credentials: credentials) { result in + + self.stopAnimtatingActivityIndicator() + self.enableNavigation() + + switch result { + case .success(let credentials): + if let credentials = credentials { + var newAccount = false + if self.account == nil { + self.account = AccountManager.shared.createAccount(type: .newsBlur) + newAccount = true + } + + do { + + do { + try self.account?.removeCredentials(type: .basic) + } catch {} + try self.account?.storeCredentials(credentials) + + if newAccount { + self.account?.refreshAll() { result in + switch result { + case .success: + break + case .failure(let error): + self.presentError(error) + } + } + } + + self.dismiss(animated: true, completion: nil) + self.delegate?.dismiss() + } catch { + self.showError(NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error")) + } + } else { + self.showError(NSLocalizedString("Invalid email/password combination.", comment: "Credentials Error")) + } + case .failure: + self.showError(NSLocalizedString("Network error. Try again later.", comment: "Credentials Error")) + } + + } + } + + @objc func textDidChange(_ note: Notification) { + actionButton.isEnabled = !(emailTextField.text?.isEmpty ?? false) && !(passwordTextField.text?.isEmpty ?? false) + } + + private func showError(_ message: String) { + presentError(title: "Error", message: message) + } + + private func enableNavigation() { + self.cancelBarButtonItem.isEnabled = true + self.actionButton.isEnabled = true + } + + private func disableNavigation() { + cancelBarButtonItem.isEnabled = false + actionButton.isEnabled = false + } + + private func startAnimatingActivityIndicator() { + activityIndicator.isHidden = false + activityIndicator.startAnimating() + } + + private func stopAnimtatingActivityIndicator() { + self.activityIndicator.isHidden = true + self.activityIndicator.stopAnimating() + } + +} + +extension NewsBlurAccountViewController: UITextFieldDelegate { + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } + +} diff --git a/iOS/AppAssets.swift b/iOS/AppAssets.swift index 287e27b23..17473357b 100644 --- a/iOS/AppAssets.swift +++ b/iOS/AppAssets.swift @@ -35,6 +35,10 @@ struct AppAssets { return UIImage(named: "accountFreshRSS")! }() + static var accountNewsBlurImage: UIImage = { + return UIImage(named: "accountNewsBlur")! + }() + static var articleExtractorError: UIImage = { return UIImage(named: "articleExtractorError")! }() @@ -238,6 +242,8 @@ struct AppAssets { return AppAssets.accountFeedWranglerImage case .freshRSS: return AppAssets.accountFreshRSSImage + case .newsBlur: + return AppAssets.accountNewsBlurImage default: return nil } diff --git a/iOS/Resources/Assets.xcassets/accountNewsBlur.imageset/Contents.json b/iOS/Resources/Assets.xcassets/accountNewsBlur.imageset/Contents.json new file mode 100644 index 000000000..99f78349c --- /dev/null +++ b/iOS/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/iOS/Resources/Assets.xcassets/accountNewsBlur.imageset/newsblur-512.png b/iOS/Resources/Assets.xcassets/accountNewsBlur.imageset/newsblur-512.png new file mode 100644 index 0000000000000000000000000000000000000000..5fab67691f90e4ff35fd51ae1cc4d44ff821d230 GIT binary patch literal 54138 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 AYybcN literal 0 HcmV?d00001 diff --git a/iOS/Settings/AddAccountViewController.swift b/iOS/Settings/AddAccountViewController.swift index abe901c63..870b7e1f1 100644 --- a/iOS/Settings/AddAccountViewController.swift +++ b/iOS/Settings/AddAccountViewController.swift @@ -56,6 +56,12 @@ class AddAccountViewController: UITableViewController, AddAccountDismissDelegate let addViewController = navController.topViewController as! FeedWranglerAccountViewController addViewController.delegate = self present(navController, animated: true) + case 4: + let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "NewsBlurAccountNavigationViewController") as! UINavigationController + navController.modalPresentationStyle = .currentContext + let addViewController = navController.topViewController as! NewsBlurAccountViewController + addViewController.delegate = self + present(navController, animated: true) default: break } diff --git a/iOS/Settings/Settings.storyboard b/iOS/Settings/Settings.storyboard index 1ecb90a8e..2b2a986bc 100644 --- a/iOS/Settings/Settings.storyboard +++ b/iOS/Settings/Settings.storyboard @@ -677,6 +677,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1050,6 +1087,7 @@ + From 034aabbfffa083c8a49d62395bbf2c2c0bde8970 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Mon, 9 Mar 2020 20:19:24 -0400 Subject: [PATCH 15/51] Add login/logout support --- Frameworks/Account/Account.swift | 4 + .../Account/Account.xcodeproj/project.pbxproj | 28 ++++ .../Account/Credentials/Credentials.swift | 1 + .../Credentials/URLRequest+RSWeb.swift | 7 +- .../Models/NewsBlurLoginResponse.swift | 26 ++++ .../Account/NewsBlur/NewsBlurAPICaller.swift | 72 +++++++++ .../NewsBlur/NewsBlurAccountDelegate.swift | 146 ++++++++++++++++++ iOS/Account/Account.storyboard | 2 +- .../NewsBlurAccountViewController.swift | 26 ++-- 9 files changed, 298 insertions(+), 14 deletions(-) create mode 100644 Frameworks/Account/NewsBlur/Models/NewsBlurLoginResponse.swift create mode 100644 Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift create mode 100644 Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index f8bbaf8a8..891d1522e 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -243,6 +243,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, self.delegate = FeedlyAccountDelegate(dataFolder: dataFolder, transport: transport, api: FeedlyAccountDelegate.environment) case .feedWrangler: self.delegate = FeedWranglerAccountDelegate(dataFolder: dataFolder, transport: transport) + case .newsBlur: + self.delegate = NewsBlurAccountDelegate(dataFolder: dataFolder, transport: transport) default: return nil } @@ -325,6 +327,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, ReaderAPIAccountDelegate.validateCredentials(transport: transport, credentials: credentials, endpoint: endpoint, completion: completion) case .feedWrangler: FeedWranglerAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion) + case .newsBlur: + NewsBlurAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion) default: break } diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 0b927f0d0..abd406985 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */; }; 3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A33E6238D3D6800314204 /* Secrets.swift */; }; 3B826DA72385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */; }; 3B826DA82385C81C00FC1ADB /* FeedWranglerFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */; }; @@ -63,6 +64,8 @@ 552032FD229D5D5A009559E0 /* ReaderAPITagging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F2229D5D5A009559E0 /* ReaderAPITagging.swift */; }; 552032FE229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F3229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift */; }; 55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F5229D5D5A009559E0 /* ReaderAPICaller.swift */; }; + 769F295938E5A30D03DFF88F /* NewsBlurAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 769F2A8DF190549E24B5D110 /* NewsBlurAccountDelegate.swift */; }; + 769F2BA02EF5F329CDE45F5A /* NewsBlurAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 769F275FD5D942502C5B4716 /* NewsBlurAPICaller.swift */; }; 841973FE1F6DD1BC006346C4 /* RSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841973EF1F6DD19E006346C4 /* RSCore.framework */; }; 841973FF1F6DD1C5006346C4 /* RSParser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841973FA1F6DD1AC006346C4 /* RSParser.framework */; }; 841974011F6DD1EC006346C4 /* Folder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841974001F6DD1EC006346C4 /* Folder.swift */; }; @@ -220,6 +223,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurLoginResponse.swift; sourceTree = ""; }; 3B3A33E6238D3D6800314204 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Secrets.swift; path = ../../Shared/Secrets.swift; sourceTree = ""; }; 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = ""; }; 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItem.swift; sourceTree = ""; }; @@ -278,6 +282,8 @@ 552032F2229D5D5A009559E0 /* ReaderAPITagging.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPITagging.swift; sourceTree = ""; }; 552032F3229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPIAccountDelegate.swift; sourceTree = ""; }; 552032F5229D5D5A009559E0 /* ReaderAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPICaller.swift; sourceTree = ""; }; + 769F275FD5D942502C5B4716 /* NewsBlurAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurAPICaller.swift; sourceTree = ""; }; + 769F2A8DF190549E24B5D110 /* NewsBlurAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurAccountDelegate.swift; sourceTree = ""; }; 841973E81F6DD19E006346C4 /* RSCore.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSCore.xcodeproj; path = ../RSCore/RSCore.xcodeproj; sourceTree = ""; }; 841973F41F6DD1AC006346C4 /* RSParser.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSParser.xcodeproj; path = ../RSParser/RSParser.xcodeproj; sourceTree = ""; }; 841974001F6DD1EC006346C4 /* Folder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Folder.swift; sourceTree = ""; }; @@ -435,6 +441,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 179DBD810D353D9CED7C3BED /* Models */ = { + isa = PBXGroup; + children = ( + 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */, + ); + path = Models; + sourceTree = ""; + }; 3B826D9D2385C81C00FC1ADB /* FeedWrangler */ = { isa = PBXGroup; children = ( @@ -523,6 +537,16 @@ path = ReaderAPI; sourceTree = ""; }; + 769F2630AF8DC873D4A73567 /* NewsBlur */ = { + isa = PBXGroup; + children = ( + 769F2A8DF190549E24B5D110 /* NewsBlurAccountDelegate.swift */, + 769F275FD5D942502C5B4716 /* NewsBlurAPICaller.swift */, + 179DBD810D353D9CED7C3BED /* Models */, + ); + path = NewsBlur; + sourceTree = ""; + }; 841973E91F6DD19E006346C4 /* Products */ = { isa = PBXGroup; children = ( @@ -622,6 +646,7 @@ 8469F80F1F6DC3C10084783E /* Frameworks */, D511EEB4202422BB00712EC3 /* xcconfig */, 848934FA1F62484F00CEBD24 /* Info.plist */, + 769F2630AF8DC873D4A73567 /* NewsBlur */, ); sourceTree = ""; usesTabs = 1; @@ -1107,6 +1132,9 @@ 9EF2602C23C91FFE006D160C /* FeedlyGetUpdatedArticleIdsOperation.swift in Sources */, 3B826DAA2385C81C00FC1ADB /* FeedWranglerSubscription.swift in Sources */, 3B826DAC2385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift in Sources */, + 769F295938E5A30D03DFF88F /* NewsBlurAccountDelegate.swift in Sources */, + 769F2BA02EF5F329CDE45F5A /* NewsBlurAPICaller.swift in Sources */, + 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Frameworks/Account/Credentials/Credentials.swift b/Frameworks/Account/Credentials/Credentials.swift index bc5ac86ee..9cb9ae474 100644 --- a/Frameworks/Account/Credentials/Credentials.swift +++ b/Frameworks/Account/Credentials/Credentials.swift @@ -17,6 +17,7 @@ public enum CredentialsType: String { case basic = "password" case feedWranglerBasic = "feedWranglerBasic" case feedWranglerToken = "feedWranglerToken" + case newsBlur = "newsBlur" case readerBasic = "readerBasic" case readerAPIKey = "readerAPIKey" case oauthAccessToken = "oauthAccessToken" diff --git a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift index 1717dc10f..c74b73a68 100755 --- a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift +++ b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift @@ -33,7 +33,12 @@ public extension URLRequest { ]) case .feedWranglerToken: self.url = url.appendingQueryItem(URLQueryItem(name: "access_token", value: credentials.secret)) - case .readerBasic: + case .newsBlur: + setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + httpMethod = "POST" + let postData = "username=\(credentials.username)&password=\(credentials.secret)" + httpBody = postData.data(using: String.Encoding.utf8) + case .readerBasic: setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") httpMethod = "POST" var postData = URLComponents() diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurLoginResponse.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurLoginResponse.swift new file mode 100644 index 000000000..9529ea8e0 --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurLoginResponse.swift @@ -0,0 +1,26 @@ +// +// NewsBlurLoginResponse.swift +// Account +// +// Created by Anh Quang Do on 2020-03-09. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct NewsBlurLoginResponse: Decodable { + var code: Int + var errors: LoginError? + + struct LoginError: Decodable { + var username: [String]? + var others: [String]? + } +} + +extension NewsBlurLoginResponse.LoginError { + private enum CodingKeys: String, CodingKey { + case username = "username" + case others = "__all__" + } +} diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift new file mode 100644 index 000000000..0bd0d65d6 --- /dev/null +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -0,0 +1,72 @@ +// +// NewsBlurAPICaller.swift +// Account +// +// Created by Anh-Quang Do on 3/9/20. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSWeb + +enum NewsBlurError: LocalizedError { + case general(message: String) + + var errorDescription: String? { + switch self { + case .general(let message): + return message + } + } +} + +final class NewsBlurAPICaller: NSObject { + + private let baseURL = URL(string: "https://www.newsblur.com/")! + private var transport: Transport! + + var credentials: Credentials? + weak var accountMetadata: AccountMetadata? + + init(transport: Transport!) { + super.init() + self.transport = transport + } + + func validateCredentials(completion: @escaping (Result) -> Void) { + let url = baseURL.appendingPathComponent("api/login") + let request = URLRequest(url: url, credentials: credentials) + + transport.send(request: request, resultType: NewsBlurLoginResponse.self) { result in + switch result { + case .success(_, let payload): + guard payload?.code != -1 else { + let error = payload?.errors?.username ?? payload?.errors?.others + if let message = error?.first { + completion(.failure(NewsBlurError.general(message: message))) + } else { + completion(.failure(NewsBlurError.general(message: "Failed to log in"))) + } + return + } + completion(.success(self.credentials)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func logout(completion: @escaping (Result) -> Void) { + let url = baseURL.appendingPathComponent("api/logout") + let request = URLRequest(url: url, credentials: credentials) + + transport.send(request: request) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } +} diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift new file mode 100644 index 000000000..ef169af21 --- /dev/null +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -0,0 +1,146 @@ +// +// NewsBlurAccountDelegate.swift +// Account +// +// Created by Anh-Quang Do on 3/9/20. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Articles +import RSCore +import RSDatabase +import RSParser +import RSWeb +import SyncDatabase +import os.log + +final class NewsBlurAccountDelegate: AccountDelegate { + + var behaviors: AccountBehaviors = [] + + var isOPMLImportInProgress: Bool = false + var server: String? = "newsblur.com" + var credentials: Credentials? { + didSet { + caller.credentials = credentials + } + } + + var accountMetadata: AccountMetadata? = nil + var refreshProgress = DownloadProgress(numberOfTasks: 0) + + private let caller: NewsBlurAPICaller + private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "NewsBlur") + private let database: SyncDatabase + + init(dataFolder: String, transport: Transport?) { + if let transport = transport { + caller = NewsBlurAPICaller(transport: transport) + } else { + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData + sessionConfiguration.timeoutIntervalForRequest = 60.0 + sessionConfiguration.httpShouldSetCookies = false + sessionConfiguration.httpCookieAcceptPolicy = .never + sessionConfiguration.httpMaximumConnectionsPerHost = 1 + sessionConfiguration.httpCookieStorage = nil + sessionConfiguration.urlCache = nil + + if let userAgentHeaders = UserAgent.headers() { + sessionConfiguration.httpAdditionalHeaders = userAgentHeaders + } + + let session = URLSession(configuration: sessionConfiguration) + caller = NewsBlurAPICaller(transport: session) + } + + database = SyncDatabase(databaseFilePath: dataFolder.appending("/DB.sqlite3")) + } + + func refreshAll(for account: Account, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func sendArticleStatus(for account: Account, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func refreshArticleStatus(for account: Account, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func addFolder(for account: Account, name: String, completion: @escaping (Result) -> ()) { + } + + func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func createWebFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result) -> ()) { + } + + func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func addWebFeed(for account: Account, with: WebFeed, to container: Container, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func moveWebFeed(for account: Account, with feed: WebFeed, from: Container, to: Container, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func restoreWebFeed(for account: Account, feed: WebFeed, container: Container, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result) -> ()) { + completion(.success(())) + } + + func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { + fatalError("markArticles(for:articles:statusKey:flag:) has not been implemented") + } + + func accountDidInitialize(_ account: Account) { + credentials = try? account.retrieveCredentials(type: .newsBlur) + } + + func accountWillBeDeleted(_ account: Account) { + caller.logout() { _ in } + } + + class func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL? = nil, completion: @escaping (Result) -> ()) { + let caller = NewsBlurAPICaller(transport: transport) + caller.credentials = credentials + caller.validateCredentials() { result in + DispatchQueue.main.async { + completion(result) + } + } + } + + func suspendNetwork() { + } + + func suspendDatabase() { + database.suspend() + } + + func resume() { + database.resume() + } +} diff --git a/iOS/Account/Account.storyboard b/iOS/Account/Account.storyboard index 62d36ff42..dd9869dee 100644 --- a/iOS/Account/Account.storyboard +++ b/iOS/Account/Account.storyboard @@ -439,7 +439,7 @@ - + diff --git a/iOS/Account/NewsBlurAccountViewController.swift b/iOS/Account/NewsBlurAccountViewController.swift index 3334f16c7..7c190a604 100644 --- a/iOS/Account/NewsBlurAccountViewController.swift +++ b/iOS/Account/NewsBlurAccountViewController.swift @@ -14,7 +14,7 @@ class NewsBlurAccountViewController: UITableViewController { @IBOutlet weak var activityIndicator: UIActivityIndicatorView! @IBOutlet weak var cancelBarButtonItem: UIBarButtonItem! - @IBOutlet weak var emailTextField: UITextField! + @IBOutlet weak var usernameTextField: UITextField! @IBOutlet weak var passwordTextField: UITextField! @IBOutlet weak var showHideButton: UIButton! @IBOutlet weak var actionButton: UIButton! @@ -26,19 +26,19 @@ class NewsBlurAccountViewController: UITableViewController { super.viewDidLoad() activityIndicator.isHidden = true - emailTextField.delegate = self + usernameTextField.delegate = self passwordTextField.delegate = self if let account = account, let credentials = try? account.retrieveCredentials(type: .basic) { actionButton.setTitle(NSLocalizedString("Update Credentials", comment: "Update Credentials"), for: .normal) actionButton.isEnabled = true - emailTextField.text = credentials.username + usernameTextField.text = credentials.username passwordTextField.text = credentials.secret } else { actionButton.setTitle(NSLocalizedString("Add Account", comment: "Add Account"), for: .normal) } - NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: emailTextField) + NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: usernameTextField) NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: passwordTextField) tableView.register(ImageHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader") @@ -75,17 +75,19 @@ class NewsBlurAccountViewController: UITableViewController { @IBAction func action(_ sender: Any) { - guard let email = emailTextField.text, let password = passwordTextField.text else { - showError(NSLocalizedString("Username & password required.", comment: "Credentials Error")) + guard let username = usernameTextField.text else { + showError(NSLocalizedString("Username required.", comment: "Credentials Error")) return } + let password = passwordTextField.text ?? "" + startAnimatingActivityIndicator() disableNavigation() // When you fill in the email address via auto-complete it adds extra whitespace - let trimmedEmail = email.trimmingCharacters(in: .whitespaces) - let credentials = Credentials(type: .basic, username: trimmedEmail, secret: password) + let trimmedUsername = username.trimmingCharacters(in: .whitespaces) + let credentials = Credentials(type: .newsBlur, username: trimmedUsername, secret: password) Account.validateCredentials(type: .newsBlur, credentials: credentials) { result in self.stopAnimtatingActivityIndicator() @@ -124,17 +126,17 @@ class NewsBlurAccountViewController: UITableViewController { self.showError(NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error")) } } else { - self.showError(NSLocalizedString("Invalid email/password combination.", comment: "Credentials Error")) + self.showError(NSLocalizedString("Invalid username/password combination.", comment: "Credentials Error")) } - case .failure: - self.showError(NSLocalizedString("Network error. Try again later.", comment: "Credentials Error")) + case .failure(let error): + self.showError(error.localizedDescription) } } } @objc func textDidChange(_ note: Notification) { - actionButton.isEnabled = !(emailTextField.text?.isEmpty ?? false) && !(passwordTextField.text?.isEmpty ?? false) + actionButton.isEnabled = !(usernameTextField.text?.isEmpty ?? false) } private func showError(_ message: String) { From b7fe991b7b4d5a22cac2b000e62d94b70b5727a7 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Mon, 9 Mar 2020 23:26:11 -0400 Subject: [PATCH 16/51] Retrieve session id for later requests --- .../Account/Credentials/Credentials.swift | 3 ++- .../Credentials/URLRequest+RSWeb.swift | 5 ++++- .../Account/NewsBlur/NewsBlurAPICaller.swift | 20 ++++++++++++++++--- .../NewsBlur/NewsBlurAccountDelegate.swift | 2 +- .../NewsBlurAccountViewController.swift | 2 +- 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/Frameworks/Account/Credentials/Credentials.swift b/Frameworks/Account/Credentials/Credentials.swift index 9cb9ae474..ef9884f5b 100644 --- a/Frameworks/Account/Credentials/Credentials.swift +++ b/Frameworks/Account/Credentials/Credentials.swift @@ -17,7 +17,8 @@ public enum CredentialsType: String { case basic = "password" case feedWranglerBasic = "feedWranglerBasic" case feedWranglerToken = "feedWranglerToken" - case newsBlur = "newsBlur" + case newsBlurBasic = "newsBlurBasic" + case newsBlurSessionId = "newsBlurSessionId" case readerBasic = "readerBasic" case readerAPIKey = "readerAPIKey" case oauthAccessToken = "oauthAccessToken" diff --git a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift index c74b73a68..bde231592 100755 --- a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift +++ b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift @@ -33,11 +33,14 @@ public extension URLRequest { ]) case .feedWranglerToken: self.url = url.appendingQueryItem(URLQueryItem(name: "access_token", value: credentials.secret)) - case .newsBlur: + case .newsBlurBasic: setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") httpMethod = "POST" let postData = "username=\(credentials.username)&password=\(credentials.secret)" httpBody = postData.data(using: String.Encoding.utf8) + case .newsBlurSessionId: + setValue("\(NewsBlurAPICaller.SessionIdCookie)=\(credentials.secret)", forHTTPHeaderField: "Cookie") + httpShouldHandleCookies = true case .readerBasic: setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") httpMethod = "POST" diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index 0bd0d65d6..ca63b9404 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -21,6 +21,7 @@ enum NewsBlurError: LocalizedError { } final class NewsBlurAPICaller: NSObject { + static let SessionIdCookie = "newsblur_sessionid" private let baseURL = URL(string: "https://www.newsblur.com/")! private var transport: Transport! @@ -39,8 +40,8 @@ final class NewsBlurAPICaller: NSObject { transport.send(request: request, resultType: NewsBlurLoginResponse.self) { result in switch result { - case .success(_, let payload): - guard payload?.code != -1 else { + case .success(let response, let payload): + guard let url = response.url, let headerFields = response.allHeaderFields as? [String: String], payload?.code != -1 else { let error = payload?.errors?.username ?? payload?.errors?.others if let message = error?.first { completion(.failure(NewsBlurError.general(message: message))) @@ -49,7 +50,20 @@ final class NewsBlurAPICaller: NSObject { } return } - completion(.success(self.credentials)) + + guard let username = self.credentials?.username else { + completion(.failure(NewsBlurError.general(message: "Failed to log in"))) + return + } + + let cookies = HTTPCookie.cookies(withResponseHeaderFields: headerFields, for: url) + for cookie in cookies where cookie.name == Self.SessionIdCookie { + let credentials = Credentials(type: .newsBlurSessionId, username: username, secret: cookie.value) + completion(.success(credentials)) + return + } + + completion(.failure(NewsBlurError.general(message: "Failed to retrieve session"))) case .failure(let error): completion(.failure(error)) } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index ef169af21..7ddad1867 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -116,7 +116,7 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func accountDidInitialize(_ account: Account) { - credentials = try? account.retrieveCredentials(type: .newsBlur) + credentials = try? account.retrieveCredentials(type: .newsBlurSessionId) } func accountWillBeDeleted(_ account: Account) { diff --git a/iOS/Account/NewsBlurAccountViewController.swift b/iOS/Account/NewsBlurAccountViewController.swift index 7c190a604..8a5cb2ddf 100644 --- a/iOS/Account/NewsBlurAccountViewController.swift +++ b/iOS/Account/NewsBlurAccountViewController.swift @@ -87,7 +87,7 @@ class NewsBlurAccountViewController: UITableViewController { // When you fill in the email address via auto-complete it adds extra whitespace let trimmedUsername = username.trimmingCharacters(in: .whitespaces) - let credentials = Credentials(type: .newsBlur, username: trimmedUsername, secret: password) + let credentials = Credentials(type: .newsBlurBasic, username: trimmedUsername, secret: password) Account.validateCredentials(type: .newsBlur, credentials: credentials) { result in self.stopAnimtatingActivityIndicator() From 9727219b09b80059ee522b50d29e7f259ed57403 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Mon, 9 Mar 2020 23:26:48 -0400 Subject: [PATCH 17/51] Add feed list query --- .../Account/Account.xcodeproj/project.pbxproj | 4 + .../Models/NewsBlurSubscription.swift | 90 +++++++++++++++++++ .../Account/NewsBlur/NewsBlurAPICaller.swift | 24 +++++ .../NewsBlur/NewsBlurAccountDelegate.swift | 26 +++++- 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 Frameworks/Account/NewsBlur/Models/NewsBlurSubscription.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index abd406985..9b18a5cfa 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */; }; + 179DBF4DE2562D4C532F6008 /* NewsBlurSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB1B909672E0E807B5E8C /* NewsBlurSubscription.swift */; }; 3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A33E6238D3D6800314204 /* Secrets.swift */; }; 3B826DA72385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */; }; 3B826DA82385C81C00FC1ADB /* FeedWranglerFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */; }; @@ -224,6 +225,7 @@ /* Begin PBXFileReference section */ 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurLoginResponse.swift; sourceTree = ""; }; + 179DB1B909672E0E807B5E8C /* NewsBlurSubscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurSubscription.swift; sourceTree = ""; }; 3B3A33E6238D3D6800314204 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Secrets.swift; path = ../../Shared/Secrets.swift; sourceTree = ""; }; 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = ""; }; 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItem.swift; sourceTree = ""; }; @@ -445,6 +447,7 @@ isa = PBXGroup; children = ( 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */, + 179DB1B909672E0E807B5E8C /* NewsBlurSubscription.swift */, ); path = Models; sourceTree = ""; @@ -1135,6 +1138,7 @@ 769F295938E5A30D03DFF88F /* NewsBlurAccountDelegate.swift in Sources */, 769F2BA02EF5F329CDE45F5A /* NewsBlurAPICaller.swift in Sources */, 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */, + 179DBF4DE2562D4C532F6008 /* NewsBlurSubscription.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurSubscription.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurSubscription.swift new file mode 100644 index 000000000..e10ba0099 --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurSubscription.swift @@ -0,0 +1,90 @@ +// +// NewsBlurSubscription.swift +// Account +// +// Created by Anh Quang Do on 2020-03-09. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSCore +import RSParser + +typealias NewsBlurSubscription = NewsBlurFeedsResponse.Subscription + +struct NewsBlurFeedsResponse: Decodable { + let subscriptions: [Subscription] + let folders: [Folder] + + struct Subscription: Hashable, Codable { + let title: String + let feedId: Int + let feedURL: String + let siteURL: String? + let favicon: String? + } + + struct Folder: Hashable, Codable { + let name: String + let subscriptionIds: [Int] + } +} + +extension NewsBlurFeedsResponse { + private enum CodingKeys: String, CodingKey { + case feeds = "feeds" + case folders = "flat_folders" + // TODO: flat_folders_with_inactive + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Parse subscriptions + var subscriptions: [Subscription] = [] + let subscriptionContainer = try container.nestedContainer(keyedBy: GenericCodingKeys.self, forKey: .feeds) + try subscriptionContainer.allKeys.forEach { key in + let subscription = try subscriptionContainer.decode(Subscription.self, forKey: key) + subscriptions.append(subscription) + } + + // Parse folders + var folders: [Folder] = [] + let folderContainer = try container.nestedContainer(keyedBy: GenericCodingKeys.self, forKey: .folders) + try folderContainer.allKeys.forEach { key in + let subscriptionIds = try folderContainer.decode([Int].self, forKey: key) + let folder = Folder(name: key.stringValue, subscriptionIds: subscriptionIds) + + folders.append(folder) + } + + self.subscriptions = subscriptions + self.folders = folders + } +} + +extension NewsBlurFeedsResponse.Subscription { + private enum CodingKeys: String, CodingKey { + case title = "feed_title" + case feedId = "id" + case feedURL = "feed_address" + case siteURL = "feed_link" + case favicon = "favicon_url" + } +} + +fileprivate struct GenericCodingKeys: CodingKey { + var stringValue: String + + init?(stringValue: String) { + self.stringValue = stringValue + } + + var intValue: Int? { + return nil + } + + init?(intValue: Int) { + return nil + } +} diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index ca63b9404..5039e5f0e 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -83,4 +83,28 @@ final class NewsBlurAPICaller: NSObject { } } } + + func retrieveSubscriptions(completion: @escaping (Result<[NewsBlurSubscription]?, Error>) -> Void) { + let url = baseURL + .appendingPathComponent("reader/feeds") + .appendingQueryItems([ + URLQueryItem(name: "flat", value: "true"), + URLQueryItem(name: "update_counts", value: "false"), + ]) + + guard let callURL = url else { + completion(.failure(TransportError.noURL)) + return + } + + let request = URLRequest(url: callURL, credentials: credentials) + transport.send(request: request, resultType: NewsBlurFeedsResponse.self) { result in + switch result { + case .success(let (response, payload)): + print(payload) + case .failure(let error): + completion(.failure(error)) + } + } + } } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 7ddad1867..991f55ab6 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -58,7 +58,15 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func refreshAll(for account: Account, completion: @escaping (Result) -> ()) { - completion(.success(())) + self.refreshProgress.addToNumberOfTasks(1) + refreshSubscriptions(for: account) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } } func sendArticleStatus(for account: Account, completion: @escaping (Result) -> ()) { @@ -144,3 +152,19 @@ final class NewsBlurAccountDelegate: AccountDelegate { database.resume() } } + +extension NewsBlurAccountDelegate { + private func refreshSubscriptions(for account: Account, completion: @escaping (Result) -> Void) { + os_log(.debug, log: log, "Refreshing subscriptions...") + caller.retrieveSubscriptions { result in + switch result { + case .success(let subscriptions): + print(subscriptions) + self.refreshProgress.completeTask() + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } +} From d37f70d2ddef432d9c057a88844c4560ebe564cb Mon Sep 17 00:00:00 2001 From: Anh Do Date: Tue, 10 Mar 2020 21:08:56 -0400 Subject: [PATCH 18/51] Add unread story hashes query --- .../Account/Account.xcodeproj/project.pbxproj | 8 +++ .../NewsBlur/Models/NewsBlurArticle.swift | 50 ++++++++++++++ .../Models/NewsBlurGenericCodingKeys.swift | 25 +++++++ .../Models/NewsBlurSubscription.swift | 20 +----- .../Account/NewsBlur/NewsBlurAPICaller.swift | 33 ++++++++- .../NewsBlur/NewsBlurAccountDelegate.swift | 69 ++++++++++++++++++- 6 files changed, 181 insertions(+), 24 deletions(-) create mode 100644 Frameworks/Account/NewsBlur/Models/NewsBlurArticle.swift create mode 100644 Frameworks/Account/NewsBlur/Models/NewsBlurGenericCodingKeys.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 9b18a5cfa..2ae45d270 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -7,7 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 179DB02FFBC17AC9798F0EBC /* NewsBlurArticle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB7399814F6FB3247825C /* NewsBlurArticle.swift */; }; 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */; }; + 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */; }; 179DBF4DE2562D4C532F6008 /* NewsBlurSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB1B909672E0E807B5E8C /* NewsBlurSubscription.swift */; }; 3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A33E6238D3D6800314204 /* Secrets.swift */; }; 3B826DA72385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */; }; @@ -226,6 +228,8 @@ /* Begin PBXFileReference section */ 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurLoginResponse.swift; sourceTree = ""; }; 179DB1B909672E0E807B5E8C /* NewsBlurSubscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurSubscription.swift; sourceTree = ""; }; + 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurGenericCodingKeys.swift; sourceTree = ""; }; + 179DB7399814F6FB3247825C /* NewsBlurArticle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurArticle.swift; sourceTree = ""; }; 3B3A33E6238D3D6800314204 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Secrets.swift; path = ../../Shared/Secrets.swift; sourceTree = ""; }; 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = ""; }; 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItem.swift; sourceTree = ""; }; @@ -448,6 +452,8 @@ children = ( 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */, 179DB1B909672E0E807B5E8C /* NewsBlurSubscription.swift */, + 179DB7399814F6FB3247825C /* NewsBlurArticle.swift */, + 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */, ); path = Models; sourceTree = ""; @@ -1139,6 +1145,8 @@ 769F2BA02EF5F329CDE45F5A /* NewsBlurAPICaller.swift in Sources */, 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */, 179DBF4DE2562D4C532F6008 /* NewsBlurSubscription.swift in Sources */, + 179DB02FFBC17AC9798F0EBC /* NewsBlurArticle.swift in Sources */, + 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurArticle.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurArticle.swift new file mode 100644 index 000000000..8f4f88986 --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurArticle.swift @@ -0,0 +1,50 @@ +// +// NewsBlurArticle.swift +// Account +// +// Created by Anh Quang Do on 2020-03-10. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSCore +import RSParser + +typealias NewsBlurArticleHash = NewsBlurUnreadArticleHashesResponse.ArticleHash + +struct NewsBlurUnreadArticleHashesResponse: Decodable { + let subscriptions: [String: [ArticleHash]] + + struct ArticleHash: Hashable, Codable { + var hash: String + var timestamp: Date + } +} + +extension NewsBlurUnreadArticleHashesResponse { + private enum CodingKeys: String, CodingKey { + case feeds = "unread_feed_story_hashes" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Parse subscriptions + var subscriptions: [String: [ArticleHash]] = [:] + let subscriptionContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .feeds) + try subscriptionContainer.allKeys.forEach { key in + subscriptions[key.stringValue] = [] + var hashArrayContainer = try subscriptionContainer.nestedUnkeyedContainer(forKey: key) + while !hashArrayContainer.isAtEnd { + var hashContainer = try hashArrayContainer.nestedUnkeyedContainer() + let hash = try hashContainer.decode(String.self) + let timestamp = try hashContainer.decode(Date.self) + let articleHash = ArticleHash(hash: hash, timestamp: timestamp) + + subscriptions[key.stringValue]?.append(articleHash) + } + } + + self.subscriptions = subscriptions + } +} diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurGenericCodingKeys.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurGenericCodingKeys.swift new file mode 100644 index 000000000..99ecaa89f --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurGenericCodingKeys.swift @@ -0,0 +1,25 @@ +// +// NewsBlurGenericCodingKeys.swift +// Account +// +// Created by Anh Quang Do on 2020-03-10. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct NewsBlurGenericCodingKeys: CodingKey { + var stringValue: String + + init?(stringValue: String) { + self.stringValue = stringValue + } + + var intValue: Int? { + return nil + } + + init?(intValue: Int) { + return nil + } +} diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurSubscription.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurSubscription.swift index e10ba0099..35b9a8d6c 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurSubscription.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurSubscription.swift @@ -42,7 +42,7 @@ extension NewsBlurFeedsResponse { // Parse subscriptions var subscriptions: [Subscription] = [] - let subscriptionContainer = try container.nestedContainer(keyedBy: GenericCodingKeys.self, forKey: .feeds) + let subscriptionContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .feeds) try subscriptionContainer.allKeys.forEach { key in let subscription = try subscriptionContainer.decode(Subscription.self, forKey: key) subscriptions.append(subscription) @@ -50,7 +50,7 @@ extension NewsBlurFeedsResponse { // Parse folders var folders: [Folder] = [] - let folderContainer = try container.nestedContainer(keyedBy: GenericCodingKeys.self, forKey: .folders) + let folderContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .folders) try folderContainer.allKeys.forEach { key in let subscriptionIds = try folderContainer.decode([Int].self, forKey: key) let folder = Folder(name: key.stringValue, subscriptionIds: subscriptionIds) @@ -72,19 +72,3 @@ extension NewsBlurFeedsResponse.Subscription { case favicon = "favicon_url" } } - -fileprivate struct GenericCodingKeys: CodingKey { - var stringValue: String - - init?(stringValue: String) { - self.stringValue = stringValue - } - - var intValue: Int? { - return nil - } - - init?(intValue: Int) { - return nil - } -} diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index 5039e5f0e..a24cbc741 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -84,7 +84,7 @@ final class NewsBlurAPICaller: NSObject { } } - func retrieveSubscriptions(completion: @escaping (Result<[NewsBlurSubscription]?, Error>) -> Void) { + func retrieveSubscriptions(completion: @escaping (Result<[NewsBlurSubscription], Error>) -> Void) { let url = baseURL .appendingPathComponent("reader/feeds") .appendingQueryItems([ @@ -100,8 +100,35 @@ final class NewsBlurAPICaller: NSObject { let request = URLRequest(url: callURL, credentials: credentials) transport.send(request: request, resultType: NewsBlurFeedsResponse.self) { result in switch result { - case .success(let (response, payload)): - print(payload) + case .success((_, let payload)): + completion(.success(payload?.subscriptions ?? [])) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func retrieveUnreadArticleHashes(completion: @escaping (Result<[NewsBlurArticleHash], Error>) -> Void) { + let url = baseURL + .appendingPathComponent("reader/unread_story_hashes") + .appendingQueryItems([ + URLQueryItem(name: "include_timestamps", value: "true"), + ]) + + guard let callURL = url else { + completion(.failure(TransportError.noURL)) + return + } + + let request = URLRequest(url: callURL, credentials: credentials) + transport.send(request: request, resultType: NewsBlurUnreadArticleHashesResponse.self, dateDecoding: .secondsSince1970) { result in + switch result { + case .success((_, let payload)): + guard let subscriptions = payload?.subscriptions else { + completion(.success([])) + return + } + completion(.success(subscriptions.values.flatMap { $0 })) case .failure(let error): completion(.failure(error)) } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 991f55ab6..203400bbf 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -58,11 +58,57 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func refreshAll(for account: Account, completion: @escaping (Result) -> ()) { - self.refreshProgress.addToNumberOfTasks(1) + self.refreshProgress.addToNumberOfTasksAndRemaining(5) + refreshSubscriptions(for: account) { result in + self.refreshProgress.completeTask() + switch result { case .success: - completion(.success(())) + self.sendArticleStatus(for: account) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + self.refreshArticleStatus(for: account) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + self.refreshArticles(for: account) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + self.refreshMissingArticles(for: account) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + DispatchQueue.main.async { + completion(.success(())) + } + + case .failure(let error): + completion(.failure(error)) + } + } + + case .failure(let error): + completion(.failure(error)) + } + } + + case .failure(let error): + completion(.failure(error)) + } + } + + case .failure(let error): + completion(.failure(error)) + } + } + case .failure(let error): completion(.failure(error)) } @@ -77,6 +123,23 @@ final class NewsBlurAccountDelegate: AccountDelegate { completion(.success(())) } + func refreshArticles(for account: Account, completion: @escaping (Result<[NewsBlurArticleHash], Error>) -> Void) { + os_log(.debug, log: log, "Refreshing articles...") + + caller.retrieveUnreadArticleHashes { result in + switch result { + case .success(let articleHashes): + print(articleHashes) + case .failure(let error): + break + } + } + } + + func refreshMissingArticles(for account: Account, completion: @escaping (Result)-> Void) { + completion(.success(())) + } + func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result) -> ()) { completion(.success(())) } @@ -156,11 +219,11 @@ final class NewsBlurAccountDelegate: AccountDelegate { extension NewsBlurAccountDelegate { private func refreshSubscriptions(for account: Account, completion: @escaping (Result) -> Void) { os_log(.debug, log: log, "Refreshing subscriptions...") + caller.retrieveSubscriptions { result in switch result { case .success(let subscriptions): print(subscriptions) - self.refreshProgress.completeTask() completion(.success(())) case .failure(let error): completion(.failure(error)) From 175cd0e7987724a16962a3265ab3ad36fc7d5f16 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Fri, 13 Mar 2020 18:18:47 -0400 Subject: [PATCH 19/51] Parse articles from story river --- .../Account/Account.xcodeproj/project.pbxproj | 8 +++ .../NewsBlur/Models/NewsBlurArticle.swift | 55 ++++++++--------- .../NewsBlur/Models/NewsBlurDate.swift | 20 +++++++ .../Models/NewsBlurUnreadArticle.swift | 50 ++++++++++++++++ .../Account/NewsBlur/NewsBlurAPICaller.swift | 34 ++++++++--- .../NewsBlur/NewsBlurAccountDelegate.swift | 60 ++++++++++++++++++- 6 files changed, 186 insertions(+), 41 deletions(-) create mode 100644 Frameworks/Account/NewsBlur/Models/NewsBlurDate.swift create mode 100644 Frameworks/Account/NewsBlur/Models/NewsBlurUnreadArticle.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 2ae45d270..83dd24f7e 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 179DB02FFBC17AC9798F0EBC /* NewsBlurArticle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB7399814F6FB3247825C /* NewsBlurArticle.swift */; }; 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */; }; 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */; }; + 179DB61D33CD8DC94C90F7ED /* NewsBlurDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBB17C42E6E434EDC29FA /* NewsBlurDate.swift */; }; + 179DBED55C9B4D6A413486C1 /* NewsBlurUnreadArticle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB818180A51098A9816B2 /* NewsBlurUnreadArticle.swift */; }; 179DBF4DE2562D4C532F6008 /* NewsBlurSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB1B909672E0E807B5E8C /* NewsBlurSubscription.swift */; }; 3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A33E6238D3D6800314204 /* Secrets.swift */; }; 3B826DA72385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */; }; @@ -230,6 +232,8 @@ 179DB1B909672E0E807B5E8C /* NewsBlurSubscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurSubscription.swift; sourceTree = ""; }; 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurGenericCodingKeys.swift; sourceTree = ""; }; 179DB7399814F6FB3247825C /* NewsBlurArticle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurArticle.swift; sourceTree = ""; }; + 179DB818180A51098A9816B2 /* NewsBlurUnreadArticle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurUnreadArticle.swift; sourceTree = ""; }; + 179DBB17C42E6E434EDC29FA /* NewsBlurDate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurDate.swift; sourceTree = ""; }; 3B3A33E6238D3D6800314204 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Secrets.swift; path = ../../Shared/Secrets.swift; sourceTree = ""; }; 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = ""; }; 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItem.swift; sourceTree = ""; }; @@ -454,6 +458,8 @@ 179DB1B909672E0E807B5E8C /* NewsBlurSubscription.swift */, 179DB7399814F6FB3247825C /* NewsBlurArticle.swift */, 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */, + 179DB818180A51098A9816B2 /* NewsBlurUnreadArticle.swift */, + 179DBB17C42E6E434EDC29FA /* NewsBlurDate.swift */, ); path = Models; sourceTree = ""; @@ -1147,6 +1153,8 @@ 179DBF4DE2562D4C532F6008 /* NewsBlurSubscription.swift in Sources */, 179DB02FFBC17AC9798F0EBC /* NewsBlurArticle.swift in Sources */, 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */, + 179DBED55C9B4D6A413486C1 /* NewsBlurUnreadArticle.swift in Sources */, + 179DB61D33CD8DC94C90F7ED /* NewsBlurDate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurArticle.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurArticle.swift index 8f4f88986..39a1d54bf 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurArticle.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurArticle.swift @@ -10,41 +10,36 @@ import Foundation import RSCore import RSParser -typealias NewsBlurArticleHash = NewsBlurUnreadArticleHashesResponse.ArticleHash +typealias NewsBlurArticle = NewsBlurArticlesResponse.Article -struct NewsBlurUnreadArticleHashesResponse: Decodable { - let subscriptions: [String: [ArticleHash]] +struct NewsBlurArticlesResponse: Decodable { + let articles: [Article] - struct ArticleHash: Hashable, Codable { - var hash: String - var timestamp: Date + struct Article: Decodable { + let articleId: String + let feedId: Int + let title: String? + let url: String? + let authorName: String? + let contentHTML: String? + let datePublished: Date } } -extension NewsBlurUnreadArticleHashesResponse { +extension NewsBlurArticlesResponse { private enum CodingKeys: String, CodingKey { - case feeds = "unread_feed_story_hashes" - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - // Parse subscriptions - var subscriptions: [String: [ArticleHash]] = [:] - let subscriptionContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .feeds) - try subscriptionContainer.allKeys.forEach { key in - subscriptions[key.stringValue] = [] - var hashArrayContainer = try subscriptionContainer.nestedUnkeyedContainer(forKey: key) - while !hashArrayContainer.isAtEnd { - var hashContainer = try hashArrayContainer.nestedUnkeyedContainer() - let hash = try hashContainer.decode(String.self) - let timestamp = try hashContainer.decode(Date.self) - let articleHash = ArticleHash(hash: hash, timestamp: timestamp) - - subscriptions[key.stringValue]?.append(articleHash) - } - } - - self.subscriptions = subscriptions + case articles = "stories" + } +} + +extension NewsBlurArticlesResponse.Article { + private enum CodingKeys: String, CodingKey { + case articleId = "story_hash" + case feedId = "story_feed_id" + case title = "story_title" + case url = "story_permalink" + case authorName = "story_authors" + case contentHTML = "story_content" + case datePublished = "story_date" } } diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurDate.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurDate.swift new file mode 100644 index 000000000..d7731fbce --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurDate.swift @@ -0,0 +1,20 @@ +// +// NewsBlurDate.swift +// Account +// +// Created by Anh Quang Do on 2020-03-13. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct NewsBlurDate { + static let yyyyMMddHHmmss: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .iso8601) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(abbreviation: "GMT") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter + }() +} diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurUnreadArticle.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurUnreadArticle.swift new file mode 100644 index 000000000..ad316d0a8 --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurUnreadArticle.swift @@ -0,0 +1,50 @@ +// +// NewsBlurUnreadArticle.swift +// Account +// +// Created by Anh Quang Do on 2020-03-13. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSCore +import RSParser + +typealias NewsBlurArticleHash = NewsBlurUnreadArticleHashesResponse.ArticleHash + +struct NewsBlurUnreadArticleHashesResponse: Decodable { + let subscriptions: [String: [ArticleHash]] + + struct ArticleHash: Hashable, Codable { + var hash: String + var timestamp: Date + } +} + +extension NewsBlurUnreadArticleHashesResponse { + private enum CodingKeys: String, CodingKey { + case feeds = "unread_feed_story_hashes" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Parse subscriptions + var subscriptions: [String: [ArticleHash]] = [:] + let subscriptionContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .feeds) + try subscriptionContainer.allKeys.forEach { key in + subscriptions[key.stringValue] = [] + var hashArrayContainer = try subscriptionContainer.nestedUnkeyedContainer(forKey: key) + while !hashArrayContainer.isAtEnd { + var hashContainer = try hashArrayContainer.nestedUnkeyedContainer() + let hash = try hashContainer.decode(String.self) + let timestamp = try hashContainer.decode(Date.self) + let articleHash = ArticleHash(hash: hash, timestamp: timestamp) + + subscriptions[key.stringValue]?.append(articleHash) + } + } + + self.subscriptions = subscriptions + } +} diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index a24cbc741..c9b5e2c48 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -84,7 +84,7 @@ final class NewsBlurAPICaller: NSObject { } } - func retrieveSubscriptions(completion: @escaping (Result<[NewsBlurSubscription], Error>) -> Void) { + func retrieveSubscriptions(completion: @escaping (Result<[NewsBlurSubscription]?, Error>) -> Void) { let url = baseURL .appendingPathComponent("reader/feeds") .appendingQueryItems([ @@ -101,14 +101,14 @@ final class NewsBlurAPICaller: NSObject { transport.send(request: request, resultType: NewsBlurFeedsResponse.self) { result in switch result { case .success((_, let payload)): - completion(.success(payload?.subscriptions ?? [])) + completion(.success(payload?.subscriptions)) case .failure(let error): completion(.failure(error)) } } } - func retrieveUnreadArticleHashes(completion: @escaping (Result<[NewsBlurArticleHash], Error>) -> Void) { + func retrieveUnreadArticleHashes(completion: @escaping (Result<[NewsBlurArticleHash]?, Error>) -> Void) { let url = baseURL .appendingPathComponent("reader/unread_story_hashes") .appendingQueryItems([ @@ -124,11 +124,29 @@ final class NewsBlurAPICaller: NSObject { transport.send(request: request, resultType: NewsBlurUnreadArticleHashesResponse.self, dateDecoding: .secondsSince1970) { result in switch result { case .success((_, let payload)): - guard let subscriptions = payload?.subscriptions else { - completion(.success([])) - return - } - completion(.success(subscriptions.values.flatMap { $0 })) + completion(.success(payload?.subscriptions.values.flatMap { $0 })) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func retrieveArticles(hashes: [NewsBlurArticleHash], completion: @escaping (Result<[NewsBlurArticle]?, Error>) -> Void) { + let url = baseURL + .appendingPathComponent("reader/river_stories") + .appendingQueryItem(.init(name: "include_hidden", value: "true"))? + .appendingQueryItems(hashes.map { URLQueryItem(name: "h", value: $0.hash) }) + + guard let callURL = url else { + completion(.failure(TransportError.noURL)) + return + } + + let request = URLRequest(url: callURL, credentials: credentials) + transport.send(request: request, resultType: NewsBlurArticlesResponse.self, dateDecoding: .formatted(NewsBlurDate.yyyyMMddHHmmss)) { result in + switch result { + case .success((_, let payload)): + completion(.success(payload?.articles)) case .failure(let error): completion(.failure(error)) } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 203400bbf..2b08ee7b3 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -123,15 +123,50 @@ final class NewsBlurAccountDelegate: AccountDelegate { completion(.success(())) } - func refreshArticles(for account: Account, completion: @escaping (Result<[NewsBlurArticleHash], Error>) -> Void) { + func refreshArticles(for account: Account, completion: @escaping (Result) -> Void) { os_log(.debug, log: log, "Refreshing articles...") + os_log(.debug, log: log, "Refreshing unread articles...") caller.retrieveUnreadArticleHashes { result in switch result { case .success(let articleHashes): - print(articleHashes) + self.refreshProgress.completeTask() + + self.refreshUnreadArticles(for: account, hashes: articleHashes, updateFetchDate: nil, completion: completion) case .failure(let error): - break + completion(.failure(error)) + } + } + } + + func refreshUnreadArticles(for account: Account, hashes: [NewsBlurArticleHash]?, updateFetchDate: Date?, completion: @escaping (Result) -> Void) { + guard let hashes = hashes, !hashes.isEmpty else { + if let lastArticleFetch = updateFetchDate { + self.accountMetadata?.lastArticleFetchStartTime = lastArticleFetch + self.accountMetadata?.lastArticleFetchEndTime = Date() + } + completion(.success(())) + return + } + + let numberOfArticles = min(hashes.count, 100) // api limit + let hashesToFetch = Array(hashes[..)-> Void) { completion(.success(())) } + + func processArticles(account: Account, articles: [NewsBlurArticle]?, completion: @escaping DatabaseCompletionBlock) { + let parsedItems = mapArticlesToParsedItems(articles: articles) + let webFeedIDsAndItems = Dictionary(grouping: parsedItems, by: { item in item.feedURL } ).mapValues { Set($0) } + account.update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: true, completion: completion) + } + + func mapArticlesToParsedItems(articles: [NewsBlurArticle]?) -> Set { + guard let articles = articles else { + return Set() + } + + let parsedItems: [ParsedItem] = articles.map { article in + let author = Set([ParsedAuthor(name: article.authorName, url: nil, avatarURL: nil, emailAddress: nil)]) + return ParsedItem(syncServiceID: article.articleId, uniqueID: String(article.articleId), feedURL: String(article.feedId), url: article.url, externalURL: nil, title: article.title, contentHTML: article.contentHTML, contentText: nil, summary: nil, imageURL: nil, bannerImageURL: nil, datePublished: article.datePublished, dateModified: nil, authors: author, tags: nil, attachments: nil) + } + + return Set(parsedItems) + } func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result) -> ()) { completion(.success(())) From f4a0c56a540754ac339505026f105c59528fec39 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Fri, 13 Mar 2020 18:30:36 -0400 Subject: [PATCH 20/51] Use NewsBlur terminologies in models (Feed, Story) --- .../Account/Account.xcodeproj/project.pbxproj | 24 ++++---- ...rSubscription.swift => NewsBlurFeed.swift} | 28 ++++----- ...sBlurArticle.swift => NewsBlurStory.swift} | 20 +++---- .../Models/NewsBlurUnreadArticle.swift | 50 ---------------- .../NewsBlur/Models/NewsBlurUnreadStory.swift | 50 ++++++++++++++++ .../Account/NewsBlur/NewsBlurAPICaller.swift | 16 ++--- .../NewsBlur/NewsBlurAccountDelegate.swift | 58 +++++++++---------- 7 files changed, 123 insertions(+), 123 deletions(-) rename Frameworks/Account/NewsBlur/Models/{NewsBlurSubscription.swift => NewsBlurFeed.swift} (63%) rename Frameworks/Account/NewsBlur/Models/{NewsBlurArticle.swift => NewsBlurStory.swift} (64%) delete mode 100644 Frameworks/Account/NewsBlur/Models/NewsBlurUnreadArticle.swift create mode 100644 Frameworks/Account/NewsBlur/Models/NewsBlurUnreadStory.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 83dd24f7e..193e87830 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -7,12 +7,12 @@ objects = { /* Begin PBXBuildFile section */ - 179DB02FFBC17AC9798F0EBC /* NewsBlurArticle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB7399814F6FB3247825C /* NewsBlurArticle.swift */; }; + 179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB7399814F6FB3247825C /* NewsBlurStory.swift */; }; 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */; }; 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */; }; 179DB61D33CD8DC94C90F7ED /* NewsBlurDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBB17C42E6E434EDC29FA /* NewsBlurDate.swift */; }; - 179DBED55C9B4D6A413486C1 /* NewsBlurUnreadArticle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB818180A51098A9816B2 /* NewsBlurUnreadArticle.swift */; }; - 179DBF4DE2562D4C532F6008 /* NewsBlurSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB1B909672E0E807B5E8C /* NewsBlurSubscription.swift */; }; + 179DBED55C9B4D6A413486C1 /* NewsBlurUnreadStory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB818180A51098A9816B2 /* NewsBlurUnreadStory.swift */; }; + 179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */; }; 3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A33E6238D3D6800314204 /* Secrets.swift */; }; 3B826DA72385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */; }; 3B826DA82385C81C00FC1ADB /* FeedWranglerFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */; }; @@ -229,10 +229,10 @@ /* Begin PBXFileReference section */ 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurLoginResponse.swift; sourceTree = ""; }; - 179DB1B909672E0E807B5E8C /* NewsBlurSubscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurSubscription.swift; sourceTree = ""; }; + 179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurFeed.swift; sourceTree = ""; }; 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurGenericCodingKeys.swift; sourceTree = ""; }; - 179DB7399814F6FB3247825C /* NewsBlurArticle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurArticle.swift; sourceTree = ""; }; - 179DB818180A51098A9816B2 /* NewsBlurUnreadArticle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurUnreadArticle.swift; sourceTree = ""; }; + 179DB7399814F6FB3247825C /* NewsBlurStory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStory.swift; sourceTree = ""; }; + 179DB818180A51098A9816B2 /* NewsBlurUnreadStory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurUnreadStory.swift; sourceTree = ""; }; 179DBB17C42E6E434EDC29FA /* NewsBlurDate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurDate.swift; sourceTree = ""; }; 3B3A33E6238D3D6800314204 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Secrets.swift; path = ../../Shared/Secrets.swift; sourceTree = ""; }; 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = ""; }; @@ -455,10 +455,10 @@ isa = PBXGroup; children = ( 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */, - 179DB1B909672E0E807B5E8C /* NewsBlurSubscription.swift */, - 179DB7399814F6FB3247825C /* NewsBlurArticle.swift */, + 179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */, + 179DB7399814F6FB3247825C /* NewsBlurStory.swift */, 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */, - 179DB818180A51098A9816B2 /* NewsBlurUnreadArticle.swift */, + 179DB818180A51098A9816B2 /* NewsBlurUnreadStory.swift */, 179DBB17C42E6E434EDC29FA /* NewsBlurDate.swift */, ); path = Models; @@ -1150,10 +1150,10 @@ 769F295938E5A30D03DFF88F /* NewsBlurAccountDelegate.swift in Sources */, 769F2BA02EF5F329CDE45F5A /* NewsBlurAPICaller.swift in Sources */, 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */, - 179DBF4DE2562D4C532F6008 /* NewsBlurSubscription.swift in Sources */, - 179DB02FFBC17AC9798F0EBC /* NewsBlurArticle.swift in Sources */, + 179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */, + 179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */, 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */, - 179DBED55C9B4D6A413486C1 /* NewsBlurUnreadArticle.swift in Sources */, + 179DBED55C9B4D6A413486C1 /* NewsBlurUnreadStory.swift in Sources */, 179DB61D33CD8DC94C90F7ED /* NewsBlurDate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurSubscription.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift similarity index 63% rename from Frameworks/Account/NewsBlur/Models/NewsBlurSubscription.swift rename to Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift index 35b9a8d6c..b4f24d1d0 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurSubscription.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift @@ -1,5 +1,5 @@ // -// NewsBlurSubscription.swift +// NewsBlurFeed.swift // Account // // Created by Anh Quang Do on 2020-03-09. @@ -10,13 +10,13 @@ import Foundation import RSCore import RSParser -typealias NewsBlurSubscription = NewsBlurFeedsResponse.Subscription +typealias NewsBlurFeed = NewsBlurFeedsResponse.Feed struct NewsBlurFeedsResponse: Decodable { - let subscriptions: [Subscription] + let feeds: [Feed] let folders: [Folder] - struct Subscription: Hashable, Codable { + struct Feed: Hashable, Codable { let title: String let feedId: Int let feedURL: String @@ -26,7 +26,7 @@ struct NewsBlurFeedsResponse: Decodable { struct Folder: Hashable, Codable { let name: String - let subscriptionIds: [Int] + let feedIds: [Int] } } @@ -40,12 +40,12 @@ extension NewsBlurFeedsResponse { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - // Parse subscriptions - var subscriptions: [Subscription] = [] - let subscriptionContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .feeds) - try subscriptionContainer.allKeys.forEach { key in - let subscription = try subscriptionContainer.decode(Subscription.self, forKey: key) - subscriptions.append(subscription) + // Parse feeds + var feeds: [Feed] = [] + let feedContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .feeds) + try feedContainer.allKeys.forEach { key in + let subscription = try feedContainer.decode(Feed.self, forKey: key) + feeds.append(subscription) } // Parse folders @@ -53,17 +53,17 @@ extension NewsBlurFeedsResponse { let folderContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .folders) try folderContainer.allKeys.forEach { key in let subscriptionIds = try folderContainer.decode([Int].self, forKey: key) - let folder = Folder(name: key.stringValue, subscriptionIds: subscriptionIds) + let folder = Folder(name: key.stringValue, feedIds: subscriptionIds) folders.append(folder) } - self.subscriptions = subscriptions + self.feeds = feeds self.folders = folders } } -extension NewsBlurFeedsResponse.Subscription { +extension NewsBlurFeedsResponse.Feed { private enum CodingKeys: String, CodingKey { case title = "feed_title" case feedId = "id" diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurArticle.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift similarity index 64% rename from Frameworks/Account/NewsBlur/Models/NewsBlurArticle.swift rename to Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift index 39a1d54bf..f37ee421c 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurArticle.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift @@ -1,5 +1,5 @@ // -// NewsBlurArticle.swift +// NewsBlurStory.swift // Account // // Created by Anh Quang Do on 2020-03-10. @@ -10,13 +10,13 @@ import Foundation import RSCore import RSParser -typealias NewsBlurArticle = NewsBlurArticlesResponse.Article +typealias NewsBlurStory = NewsBlurStoriesResponse.Story -struct NewsBlurArticlesResponse: Decodable { - let articles: [Article] +struct NewsBlurStoriesResponse: Decodable { + let stories: [Story] - struct Article: Decodable { - let articleId: String + struct Story: Decodable { + let storyId: String let feedId: Int let title: String? let url: String? @@ -26,15 +26,15 @@ struct NewsBlurArticlesResponse: Decodable { } } -extension NewsBlurArticlesResponse { +extension NewsBlurStoriesResponse { private enum CodingKeys: String, CodingKey { - case articles = "stories" + case stories = "stories" } } -extension NewsBlurArticlesResponse.Article { +extension NewsBlurStoriesResponse.Story { private enum CodingKeys: String, CodingKey { - case articleId = "story_hash" + case storyId = "story_hash" case feedId = "story_feed_id" case title = "story_title" case url = "story_permalink" diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurUnreadArticle.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurUnreadArticle.swift deleted file mode 100644 index ad316d0a8..000000000 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurUnreadArticle.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// NewsBlurUnreadArticle.swift -// Account -// -// Created by Anh Quang Do on 2020-03-13. -// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. -// - -import Foundation -import RSCore -import RSParser - -typealias NewsBlurArticleHash = NewsBlurUnreadArticleHashesResponse.ArticleHash - -struct NewsBlurUnreadArticleHashesResponse: Decodable { - let subscriptions: [String: [ArticleHash]] - - struct ArticleHash: Hashable, Codable { - var hash: String - var timestamp: Date - } -} - -extension NewsBlurUnreadArticleHashesResponse { - private enum CodingKeys: String, CodingKey { - case feeds = "unread_feed_story_hashes" - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - // Parse subscriptions - var subscriptions: [String: [ArticleHash]] = [:] - let subscriptionContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .feeds) - try subscriptionContainer.allKeys.forEach { key in - subscriptions[key.stringValue] = [] - var hashArrayContainer = try subscriptionContainer.nestedUnkeyedContainer(forKey: key) - while !hashArrayContainer.isAtEnd { - var hashContainer = try hashArrayContainer.nestedUnkeyedContainer() - let hash = try hashContainer.decode(String.self) - let timestamp = try hashContainer.decode(Date.self) - let articleHash = ArticleHash(hash: hash, timestamp: timestamp) - - subscriptions[key.stringValue]?.append(articleHash) - } - } - - self.subscriptions = subscriptions - } -} diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurUnreadStory.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurUnreadStory.swift new file mode 100644 index 000000000..25a123770 --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurUnreadStory.swift @@ -0,0 +1,50 @@ +// +// NewsBlurUnreadStory.swift +// Account +// +// Created by Anh Quang Do on 2020-03-13. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSCore +import RSParser + +typealias NewsBlurStoryHash = NewsBlurUnreadStoryHashesResponse.StoryHash + +struct NewsBlurUnreadStoryHashesResponse: Decodable { + let feeds: [String: [StoryHash]] + + struct StoryHash: Hashable, Codable { + var hash: String + var timestamp: Date + } +} + +extension NewsBlurUnreadStoryHashesResponse { + private enum CodingKeys: String, CodingKey { + case feeds = "unread_feed_story_hashes" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Parse feeds + var feeds: [String: [StoryHash]] = [:] + let feedContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .feeds) + try feedContainer.allKeys.forEach { key in + feeds[key.stringValue] = [] + var hashArrayContainer = try feedContainer.nestedUnkeyedContainer(forKey: key) + while !hashArrayContainer.isAtEnd { + var hashContainer = try hashArrayContainer.nestedUnkeyedContainer() + let hash = try hashContainer.decode(String.self) + let timestamp = try hashContainer.decode(Date.self) + let storyHash = StoryHash(hash: hash, timestamp: timestamp) + + feeds[key.stringValue]?.append(storyHash) + } + } + + self.feeds = feeds + } +} diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index c9b5e2c48..4dbe44ddf 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -84,7 +84,7 @@ final class NewsBlurAPICaller: NSObject { } } - func retrieveSubscriptions(completion: @escaping (Result<[NewsBlurSubscription]?, Error>) -> Void) { + func retrieveFeeds(completion: @escaping (Result<[NewsBlurFeed]?, Error>) -> Void) { let url = baseURL .appendingPathComponent("reader/feeds") .appendingQueryItems([ @@ -101,14 +101,14 @@ final class NewsBlurAPICaller: NSObject { transport.send(request: request, resultType: NewsBlurFeedsResponse.self) { result in switch result { case .success((_, let payload)): - completion(.success(payload?.subscriptions)) + completion(.success(payload?.feeds)) case .failure(let error): completion(.failure(error)) } } } - func retrieveUnreadArticleHashes(completion: @escaping (Result<[NewsBlurArticleHash]?, Error>) -> Void) { + func retrieveUnreadStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { let url = baseURL .appendingPathComponent("reader/unread_story_hashes") .appendingQueryItems([ @@ -121,17 +121,17 @@ final class NewsBlurAPICaller: NSObject { } let request = URLRequest(url: callURL, credentials: credentials) - transport.send(request: request, resultType: NewsBlurUnreadArticleHashesResponse.self, dateDecoding: .secondsSince1970) { result in + transport.send(request: request, resultType: NewsBlurUnreadStoryHashesResponse.self, dateDecoding: .secondsSince1970) { result in switch result { case .success((_, let payload)): - completion(.success(payload?.subscriptions.values.flatMap { $0 })) + completion(.success(payload?.feeds.values.flatMap { $0 })) case .failure(let error): completion(.failure(error)) } } } - func retrieveArticles(hashes: [NewsBlurArticleHash], completion: @escaping (Result<[NewsBlurArticle]?, Error>) -> Void) { + func retrieveStories(hashes: [NewsBlurStoryHash], completion: @escaping (Result<[NewsBlurStory]?, Error>) -> Void) { let url = baseURL .appendingPathComponent("reader/river_stories") .appendingQueryItem(.init(name: "include_hidden", value: "true"))? @@ -143,10 +143,10 @@ final class NewsBlurAPICaller: NSObject { } let request = URLRequest(url: callURL, credentials: credentials) - transport.send(request: request, resultType: NewsBlurArticlesResponse.self, dateDecoding: .formatted(NewsBlurDate.yyyyMMddHHmmss)) { result in + transport.send(request: request, resultType: NewsBlurStoriesResponse.self, dateDecoding: .formatted(NewsBlurDate.yyyyMMddHHmmss)) { result in switch result { case .success((_, let payload)): - completion(.success(payload?.articles)) + completion(.success(payload?.stories)) case .failure(let error): completion(.failure(error)) } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 2b08ee7b3..cf608504e 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -60,7 +60,7 @@ final class NewsBlurAccountDelegate: AccountDelegate { func refreshAll(for account: Account, completion: @escaping (Result) -> ()) { self.refreshProgress.addToNumberOfTasksAndRemaining(5) - refreshSubscriptions(for: account) { result in + refreshFeeds(for: account) { result in self.refreshProgress.completeTask() switch result { @@ -75,12 +75,12 @@ final class NewsBlurAccountDelegate: AccountDelegate { switch result { case .success: - self.refreshArticles(for: account) { result in + self.refreshStories(for: account) { result in self.refreshProgress.completeTask() switch result { case .success: - self.refreshMissingArticles(for: account) { result in + self.refreshMissingStories(for: account) { result in self.refreshProgress.completeTask() switch result { @@ -123,23 +123,23 @@ final class NewsBlurAccountDelegate: AccountDelegate { completion(.success(())) } - func refreshArticles(for account: Account, completion: @escaping (Result) -> Void) { - os_log(.debug, log: log, "Refreshing articles...") - os_log(.debug, log: log, "Refreshing unread articles...") + func refreshStories(for account: Account, completion: @escaping (Result) -> Void) { + os_log(.debug, log: log, "Refreshing stories...") + os_log(.debug, log: log, "Refreshing unread stories...") - caller.retrieveUnreadArticleHashes { result in + caller.retrieveUnreadStoryHashes { result in switch result { - case .success(let articleHashes): + case .success(let storyHashes): self.refreshProgress.completeTask() - self.refreshUnreadArticles(for: account, hashes: articleHashes, updateFetchDate: nil, completion: completion) + self.refreshUnreadStories(for: account, hashes: storyHashes, updateFetchDate: nil, completion: completion) case .failure(let error): completion(.failure(error)) } } } - func refreshUnreadArticles(for account: Account, hashes: [NewsBlurArticleHash]?, updateFetchDate: Date?, completion: @escaping (Result) -> Void) { + func refreshUnreadStories(for account: Account, hashes: [NewsBlurStoryHash]?, updateFetchDate: Date?, completion: @escaping (Result) -> Void) { guard let hashes = hashes, !hashes.isEmpty else { if let lastArticleFetch = updateFetchDate { self.accountMetadata?.lastArticleFetchStartTime = lastArticleFetch @@ -149,13 +149,13 @@ final class NewsBlurAccountDelegate: AccountDelegate { return } - let numberOfArticles = min(hashes.count, 100) // api limit - let hashesToFetch = Array(hashes[..)-> Void) { + func refreshMissingStories(for account: Account, completion: @escaping (Result)-> Void) { completion(.success(())) } - func processArticles(account: Account, articles: [NewsBlurArticle]?, completion: @escaping DatabaseCompletionBlock) { - let parsedItems = mapArticlesToParsedItems(articles: articles) + func processStories(account: Account, stories: [NewsBlurStory]?, completion: @escaping DatabaseCompletionBlock) { + let parsedItems = mapStoriesToParsedItems(stories: stories) let webFeedIDsAndItems = Dictionary(grouping: parsedItems, by: { item in item.feedURL } ).mapValues { Set($0) } account.update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: true, completion: completion) } - func mapArticlesToParsedItems(articles: [NewsBlurArticle]?) -> Set { - guard let articles = articles else { + func mapStoriesToParsedItems(stories: [NewsBlurStory]?) -> Set { + guard let stories = stories else { return Set() } - let parsedItems: [ParsedItem] = articles.map { article in - let author = Set([ParsedAuthor(name: article.authorName, url: nil, avatarURL: nil, emailAddress: nil)]) - return ParsedItem(syncServiceID: article.articleId, uniqueID: String(article.articleId), feedURL: String(article.feedId), url: article.url, externalURL: nil, title: article.title, contentHTML: article.contentHTML, contentText: nil, summary: nil, imageURL: nil, bannerImageURL: nil, datePublished: article.datePublished, dateModified: nil, authors: author, tags: nil, attachments: nil) + 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: nil, bannerImageURL: nil, datePublished: story.datePublished, dateModified: nil, authors: author, tags: nil, attachments: nil) } return Set(parsedItems) @@ -271,13 +271,13 @@ final class NewsBlurAccountDelegate: AccountDelegate { } extension NewsBlurAccountDelegate { - private func refreshSubscriptions(for account: Account, completion: @escaping (Result) -> Void) { - os_log(.debug, log: log, "Refreshing subscriptions...") + private func refreshFeeds(for account: Account, completion: @escaping (Result) -> Void) { + os_log(.debug, log: log, "Refreshing feeds...") - caller.retrieveSubscriptions { result in + caller.retrieveFeeds { result in switch result { - case .success(let subscriptions): - print(subscriptions) + case .success(let feeds): + print(feeds) completion(.success(())) case .failure(let error): completion(.failure(error)) From 8e99f8deea461d5b92a86fc66ad053c5411854ea Mon Sep 17 00:00:00 2001 From: Anh Do Date: Fri, 13 Mar 2020 18:57:38 -0400 Subject: [PATCH 21/51] Display flat feed list --- .../NewsBlur/Models/NewsBlurFeed.swift | 8 +- .../NewsBlur/Models/NewsBlurStory.swift | 8 +- .../NewsBlur/NewsBlurAccountDelegate.swift | 145 ++++++++++++------ 3 files changed, 107 insertions(+), 54 deletions(-) diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift index b4f24d1d0..598e775ac 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift @@ -18,7 +18,7 @@ struct NewsBlurFeedsResponse: Decodable { struct Feed: Hashable, Codable { let title: String - let feedId: Int + let feedID: Int let feedURL: String let siteURL: String? let favicon: String? @@ -26,7 +26,7 @@ struct NewsBlurFeedsResponse: Decodable { struct Folder: Hashable, Codable { let name: String - let feedIds: [Int] + let feedIDs: [Int] } } @@ -53,7 +53,7 @@ extension NewsBlurFeedsResponse { let folderContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .folders) try folderContainer.allKeys.forEach { key in let subscriptionIds = try folderContainer.decode([Int].self, forKey: key) - let folder = Folder(name: key.stringValue, feedIds: subscriptionIds) + let folder = Folder(name: key.stringValue, feedIDs: subscriptionIds) folders.append(folder) } @@ -66,7 +66,7 @@ extension NewsBlurFeedsResponse { extension NewsBlurFeedsResponse.Feed { private enum CodingKeys: String, CodingKey { case title = "feed_title" - case feedId = "id" + case feedID = "id" case feedURL = "feed_address" case siteURL = "feed_link" case favicon = "favicon_url" diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift index f37ee421c..edfb81c62 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift @@ -16,8 +16,8 @@ struct NewsBlurStoriesResponse: Decodable { let stories: [Story] struct Story: Decodable { - let storyId: String - let feedId: Int + let storyID: String + let feedID: Int let title: String? let url: String? let authorName: String? @@ -34,8 +34,8 @@ extension NewsBlurStoriesResponse { extension NewsBlurStoriesResponse.Story { private enum CodingKeys: String, CodingKey { - case storyId = "story_hash" - case feedId = "story_feed_id" + case storyID = "story_hash" + case feedID = "story_feed_id" case title = "story_title" case url = "story_permalink" case authorName = "story_authors" diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index cf608504e..ca522853e 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -139,38 +139,6 @@ final class NewsBlurAccountDelegate: AccountDelegate { } } - func refreshUnreadStories(for account: Account, hashes: [NewsBlurStoryHash]?, updateFetchDate: Date?, completion: @escaping (Result) -> Void) { - guard let hashes = hashes, !hashes.isEmpty else { - if let lastArticleFetch = updateFetchDate { - self.accountMetadata?.lastArticleFetchStartTime = lastArticleFetch - self.accountMetadata?.lastArticleFetchEndTime = Date() - } - completion(.success(())) - return - } - - let numberOfStories = min(hashes.count, 100) // api limit - let hashesToFetch = Array(hashes[..)-> Void) { completion(.success(())) } @@ -181,19 +149,6 @@ final class NewsBlurAccountDelegate: AccountDelegate { account.update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: true, completion: completion) } - func mapStoriesToParsedItems(stories: [NewsBlurStory]?) -> Set { - guard let stories = stories else { - return Set() - } - - 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: nil, bannerImageURL: nil, datePublished: story.datePublished, dateModified: nil, authors: author, tags: nil, attachments: nil) - } - - return Set(parsedItems) - } - func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result) -> ()) { completion(.success(())) } @@ -277,11 +232,109 @@ extension NewsBlurAccountDelegate { caller.retrieveFeeds { result in switch result { case .success(let feeds): - print(feeds) + self.refreshProgress.completeTask() + + self.syncFeeds(account, feeds) completion(.success(())) case .failure(let error): completion(.failure(error)) } } } + + private func syncFeeds(_ account: Account, _ feeds: [NewsBlurFeed]?) { + guard let feeds = feeds else { return } + + os_log(.debug, log: log, "Syncing feeds with %ld feeds.", feeds.count) + + let subFeedIds = feeds.map { String($0.feedID) } + + // Remove any feeds that are no longer in the subscriptions + if let folders = account.folders { + for folder in folders { + for feed in folder.topLevelWebFeeds { + if !subFeedIds.contains(feed.webFeedID) { + folder.removeWebFeed(feed) + } + } + } + } + + for feed in account.topLevelWebFeeds { + if !subFeedIds.contains(feed.webFeedID) { + account.removeWebFeed(feed) + } + } + + // Add any feeds we don't have and update any we do + var feedsToAdd = Set() + feeds.forEach { feed in + let subFeedId = String(feed.feedID) + + if let webFeed = account.existingWebFeed(withWebFeedID: subFeedId) { + webFeed.name = feed.title + // If the name has been changed on the server remove the locally edited name + webFeed.editedName = nil + webFeed.homePageURL = feed.siteURL + webFeed.subscriptionID = String(feed.feedID) + webFeed.faviconURL = feed.favicon + webFeed.iconURL = feed.favicon + } + else { + feedsToAdd.insert(feed) + } + } + + // Actually add feeds all in one go, so we don’t trigger various rebuilding things that Account does. + feedsToAdd.forEach { feed in + let webFeed = account.createWebFeed(with: feed.title, url: feed.feedURL, webFeedID: String(feed.feedID), homePageURL: feed.siteURL) + webFeed.subscriptionID = String(feed.feedID) + account.addWebFeed(webFeed) + } + } + + private func refreshUnreadStories(for account: Account, hashes: [NewsBlurStoryHash]?, updateFetchDate: Date?, completion: @escaping (Result) -> Void) { + guard let hashes = hashes, !hashes.isEmpty else { + if let lastArticleFetch = updateFetchDate { + self.accountMetadata?.lastArticleFetchStartTime = lastArticleFetch + self.accountMetadata?.lastArticleFetchEndTime = Date() + } + completion(.success(())) + return + } + + let numberOfStories = min(hashes.count, 100) // api limit + let hashesToFetch = Array(hashes[.. Set { + guard let stories = stories else { + return Set() + } + + 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: nil, bannerImageURL: nil, datePublished: story.datePublished, dateModified: nil, authors: author, tags: nil, attachments: nil) + } + + return Set(parsedItems) + } } From 6b38c0765467c75f899353ae304031bd17325bf5 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Fri, 13 Mar 2020 20:03:51 -0400 Subject: [PATCH 22/51] Put feeds in folders (code taken from Feedbin) --- .../NewsBlur/Models/NewsBlurFeed.swift | 24 ++- .../Account/NewsBlur/NewsBlurAPICaller.swift | 4 +- .../NewsBlur/NewsBlurAccountDelegate.swift | 148 +++++++++++++++++- 3 files changed, 161 insertions(+), 15 deletions(-) diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift index 598e775ac..7d7be968b 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift @@ -11,17 +11,18 @@ import RSCore import RSParser typealias NewsBlurFeed = NewsBlurFeedsResponse.Feed +typealias NewsBlurFolder = NewsBlurFeedsResponse.Folder struct NewsBlurFeedsResponse: Decodable { let feeds: [Feed] let folders: [Folder] struct Feed: Hashable, Codable { - let title: String + let name: String let feedID: Int let feedURL: String - let siteURL: String? - let favicon: String? + let homepageURL: String? + let faviconURL: String? } struct Folder: Hashable, Codable { @@ -30,6 +31,11 @@ struct NewsBlurFeedsResponse: Decodable { } } +struct NewsBlurFolderRelationship: Codable { + let folderName: String + let feedID: Int +} + extension NewsBlurFeedsResponse { private enum CodingKeys: String, CodingKey { case feeds = "feeds" @@ -65,10 +71,16 @@ extension NewsBlurFeedsResponse { extension NewsBlurFeedsResponse.Feed { private enum CodingKeys: String, CodingKey { - case title = "feed_title" + case name = "feed_title" case feedID = "id" case feedURL = "feed_address" - case siteURL = "feed_link" - case favicon = "favicon_url" + case homepageURL = "feed_link" + case faviconURL = "favicon_url" + } +} + +extension NewsBlurFeedsResponse.Folder { + var asRelationships: [NewsBlurFolderRelationship] { + return feedIDs.map { NewsBlurFolderRelationship(folderName: name, feedID: $0) } } } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index 4dbe44ddf..3be8319ef 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -84,7 +84,7 @@ final class NewsBlurAPICaller: NSObject { } } - func retrieveFeeds(completion: @escaping (Result<[NewsBlurFeed]?, Error>) -> Void) { + func retrieveFeeds(completion: @escaping (Result<([NewsBlurFeed]?, [NewsBlurFolder]?), Error>) -> Void) { let url = baseURL .appendingPathComponent("reader/feeds") .appendingQueryItems([ @@ -101,7 +101,7 @@ final class NewsBlurAPICaller: NSObject { transport.send(request: request, resultType: NewsBlurFeedsResponse.self) { result in switch result { case .success((_, let payload)): - completion(.success(payload?.feeds)) + completion(.success((payload?.feeds, payload?.folders))) case .failure(let error): completion(.failure(error)) } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index ca522853e..4c7bc2595 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -231,10 +231,15 @@ extension NewsBlurAccountDelegate { caller.retrieveFeeds { result in switch result { - case .success(let feeds): + case .success((let feeds, let folders)): self.refreshProgress.completeTask() - self.syncFeeds(account, feeds) + BatchUpdate.shared.perform { + self.syncFolders(account, folders) + self.syncFeeds(account, feeds) + self.syncFeedFolderRelationship(account, folders) + } + completion(.success(())) case .failure(let error): completion(.failure(error)) @@ -242,8 +247,46 @@ extension NewsBlurAccountDelegate { } } + private func syncFolders(_ account: Account, _ folders: [NewsBlurFolder]?) { + guard let folders = folders else { return } + assert(Thread.isMainThread) + + os_log(.debug, log: log, "Syncing folders with %ld folders.", folders.count) + + let folderNames = folders.map { $0.name } + + // Delete any folders not at NewsBlur + if let folders = account.folders { + folders.forEach { folder in + if !folderNames.contains(folder.name ?? "") { + for feed in folder.topLevelWebFeeds { + account.addWebFeed(feed) + clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") + } + account.removeFolder(folder) + } + } + } + + let accountFolderNames: [String] = { + if let folders = account.folders { + return folders.map { $0.name ?? "" } + } else { + return [String]() + } + }() + + // Make any folders NewsBlur has, but we don't + folderNames.forEach { folderName in + if !accountFolderNames.contains(folderName) { + _ = account.ensureFolder(with: folderName) + } + } + } + private func syncFeeds(_ account: Account, _ feeds: [NewsBlurFeed]?) { guard let feeds = feeds else { return } + assert(Thread.isMainThread) os_log(.debug, log: log, "Syncing feeds with %ld feeds.", feeds.count) @@ -272,13 +315,12 @@ extension NewsBlurAccountDelegate { let subFeedId = String(feed.feedID) if let webFeed = account.existingWebFeed(withWebFeedID: subFeedId) { - webFeed.name = feed.title + webFeed.name = feed.name // If the name has been changed on the server remove the locally edited name webFeed.editedName = nil - webFeed.homePageURL = feed.siteURL + webFeed.homePageURL = feed.homepageURL webFeed.subscriptionID = String(feed.feedID) - webFeed.faviconURL = feed.favicon - webFeed.iconURL = feed.favicon + webFeed.faviconURL = feed.faviconURL } else { feedsToAdd.insert(feed) @@ -287,12 +329,104 @@ extension NewsBlurAccountDelegate { // Actually add feeds all in one go, so we don’t trigger various rebuilding things that Account does. feedsToAdd.forEach { feed in - let webFeed = account.createWebFeed(with: feed.title, url: feed.feedURL, webFeedID: String(feed.feedID), homePageURL: feed.siteURL) + let webFeed = account.createWebFeed(with: feed.name, url: feed.feedURL, webFeedID: String(feed.feedID), homePageURL: feed.homepageURL) webFeed.subscriptionID = String(feed.feedID) account.addWebFeed(webFeed) } } + private func syncFeedFolderRelationship(_ account: Account, _ folders: [NewsBlurFolder]?) { + guard let folders = folders else { return } + assert(Thread.isMainThread) + + os_log(.debug, log: log, "Syncing folders with %ld folders.", folders.count) + + // Set up some structures to make syncing easier + let relationships = folders.map({ $0.asRelationships }).flatMap { $0 } + let folderDict = nameToFolderDictionary(with: account.folders) + let foldersDict = relationships.reduce([String: [NewsBlurFolderRelationship]]()) { (dict, relationship) in + var feedInFolders = dict + if var feedInFolder = feedInFolders[relationship.folderName] { + feedInFolder.append(relationship) + feedInFolders[relationship.folderName] = feedInFolder + } else { + feedInFolders[relationship.folderName] = [relationship] + } + return feedInFolders + } + + // Sync the folders + for (folderName, folderRelationships) in foldersDict { + guard let folder = folderDict[folderName] else { return } + + let folderFeedIDs = folderRelationships.map { String($0.feedID) } + + // Move any feeds not in the folder to the account + for feed in folder.topLevelWebFeeds { + if !folderFeedIDs.contains(feed.webFeedID) { + folder.removeWebFeed(feed) + clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") + account.addWebFeed(feed) + } + } + + // Add any feeds not in the folder + let folderFeedIds = folder.topLevelWebFeeds.map { $0.webFeedID } + + for relationship in folderRelationships { + let folderFeedID = String(relationship.feedID) + if !folderFeedIds.contains(folderFeedID) { + guard let feed = account.existingWebFeed(withWebFeedID: folderFeedID) else { + continue + } + saveFolderRelationship(for: feed, withFolderName: folderName, id: relationship.folderName) + folder.addWebFeed(feed) + } + } + + } + + let folderFeedIDs = Set(relationships.map { String($0.feedID) }) + + // Remove all feeds from the account container that have a tag + for feed in account.topLevelWebFeeds { + if folderFeedIDs.contains(feed.webFeedID) { + account.removeWebFeed(feed) + } + } + } + + private func clearFolderRelationship(for feed: WebFeed, withFolderName folderName: String) { + if var folderRelationship = feed.folderRelationship { + folderRelationship[folderName] = nil + feed.folderRelationship = folderRelationship + } + } + + private func saveFolderRelationship(for feed: WebFeed, withFolderName folderName: String, id: String) { + if var folderRelationship = feed.folderRelationship { + folderRelationship[folderName] = id + feed.folderRelationship = folderRelationship + } else { + feed.folderRelationship = [folderName: id] + } + } + + private func nameToFolderDictionary(with folders: Set?) -> [String: Folder] { + guard let folders = folders else { + return [String: Folder]() + } + + var d = [String: Folder]() + for folder in folders { + let name = folder.name ?? "" + if d[name] == nil { + d[name] = folder + } + } + return d + } + private func refreshUnreadStories(for account: Account, hashes: [NewsBlurStoryHash]?, updateFetchDate: Date?, completion: @escaping (Result) -> Void) { guard let hashes = hashes, !hashes.isEmpty else { if let lastArticleFetch = updateFetchDate { From 7453e397d5a9c60c4da8df097427b8e4919aeb2c Mon Sep 17 00:00:00 2001 From: Anh Do Date: Fri, 13 Mar 2020 20:36:26 -0400 Subject: [PATCH 23/51] Read published date from timestamp directly --- .../Account/Account.xcodeproj/project.pbxproj | 4 ---- .../NewsBlur/Models/NewsBlurDate.swift | 20 ------------------- .../NewsBlur/Models/NewsBlurStory.swift | 9 +++++++-- .../Account/NewsBlur/NewsBlurAPICaller.swift | 2 +- 4 files changed, 8 insertions(+), 27 deletions(-) delete mode 100644 Frameworks/Account/NewsBlur/Models/NewsBlurDate.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 193e87830..2c2a0b35b 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -10,7 +10,6 @@ 179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB7399814F6FB3247825C /* NewsBlurStory.swift */; }; 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */; }; 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */; }; - 179DB61D33CD8DC94C90F7ED /* NewsBlurDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBB17C42E6E434EDC29FA /* NewsBlurDate.swift */; }; 179DBED55C9B4D6A413486C1 /* NewsBlurUnreadStory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB818180A51098A9816B2 /* NewsBlurUnreadStory.swift */; }; 179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */; }; 3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A33E6238D3D6800314204 /* Secrets.swift */; }; @@ -233,7 +232,6 @@ 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurGenericCodingKeys.swift; sourceTree = ""; }; 179DB7399814F6FB3247825C /* NewsBlurStory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStory.swift; sourceTree = ""; }; 179DB818180A51098A9816B2 /* NewsBlurUnreadStory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurUnreadStory.swift; sourceTree = ""; }; - 179DBB17C42E6E434EDC29FA /* NewsBlurDate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurDate.swift; sourceTree = ""; }; 3B3A33E6238D3D6800314204 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Secrets.swift; path = ../../Shared/Secrets.swift; sourceTree = ""; }; 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = ""; }; 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItem.swift; sourceTree = ""; }; @@ -459,7 +457,6 @@ 179DB7399814F6FB3247825C /* NewsBlurStory.swift */, 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */, 179DB818180A51098A9816B2 /* NewsBlurUnreadStory.swift */, - 179DBB17C42E6E434EDC29FA /* NewsBlurDate.swift */, ); path = Models; sourceTree = ""; @@ -1154,7 +1151,6 @@ 179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */, 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */, 179DBED55C9B4D6A413486C1 /* NewsBlurUnreadStory.swift in Sources */, - 179DB61D33CD8DC94C90F7ED /* NewsBlurDate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurDate.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurDate.swift deleted file mode 100644 index d7731fbce..000000000 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurDate.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// NewsBlurDate.swift -// Account -// -// Created by Anh Quang Do on 2020-03-13. -// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. -// - -import Foundation - -struct NewsBlurDate { - static let yyyyMMddHHmmss: DateFormatter = { - let formatter = DateFormatter() - formatter.calendar = Calendar(identifier: .iso8601) - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(abbreviation: "GMT") - formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - return formatter - }() -} diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift index edfb81c62..a8ccf5881 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift @@ -22,7 +22,12 @@ struct NewsBlurStoriesResponse: Decodable { let url: String? let authorName: String? let contentHTML: String? - let datePublished: Date + var datePublished: Date? { + let interval = (publishedTimestamp as NSString).doubleValue + return Date(timeIntervalSince1970: interval) + } + + private var publishedTimestamp: String } } @@ -40,6 +45,6 @@ extension NewsBlurStoriesResponse.Story { case url = "story_permalink" case authorName = "story_authors" case contentHTML = "story_content" - case datePublished = "story_date" + case publishedTimestamp = "story_timestamp" } } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index 3be8319ef..9f51552bd 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -143,7 +143,7 @@ final class NewsBlurAPICaller: NSObject { } let request = URLRequest(url: callURL, credentials: credentials) - transport.send(request: request, resultType: NewsBlurStoriesResponse.self, dateDecoding: .formatted(NewsBlurDate.yyyyMMddHHmmss)) { result in + transport.send(request: request, resultType: NewsBlurStoriesResponse.self) { result in switch result { case .success((_, let payload)): completion(.success(payload?.stories)) From a913b65700837523a429d1e7c9cb1ad5367fa93d Mon Sep 17 00:00:00 2001 From: Anh Do Date: Fri, 13 Mar 2020 20:59:36 -0400 Subject: [PATCH 24/51] Fix failing login when credentials include &= --- Frameworks/Account/Credentials/URLRequest+RSWeb.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift index bde231592..b0cca5a23 100755 --- a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift +++ b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift @@ -36,8 +36,12 @@ public extension URLRequest { case .newsBlurBasic: setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") httpMethod = "POST" - let postData = "username=\(credentials.username)&password=\(credentials.secret)" - httpBody = postData.data(using: String.Encoding.utf8) + var postData = URLComponents() + postData.queryItems = [ + URLQueryItem(name: "username", value: credentials.username), + URLQueryItem(name: "password", value: credentials.secret), + ] + httpBody = postData.percentEncodedQuery?.data(using: .utf8) case .newsBlurSessionId: setValue("\(NewsBlurAPICaller.SessionIdCookie)=\(credentials.secret)", forHTTPHeaderField: "Cookie") httpShouldHandleCookies = true From 299619703c0952cb82a4b25b1f7044a060f1e076 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Fri, 13 Mar 2020 21:18:29 -0400 Subject: [PATCH 25/51] Ignore "everything" folder --- Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift index 7d7be968b..a2641136a 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift @@ -57,7 +57,9 @@ extension NewsBlurFeedsResponse { // Parse folders var folders: [Folder] = [] let folderContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .folders) - try folderContainer.allKeys.forEach { key in + + // Skip "everything" folder + for key in folderContainer.allKeys where key.stringValue != " " { let subscriptionIds = try folderContainer.decode([Int].self, forKey: key) let folder = Folder(name: key.stringValue, feedIDs: subscriptionIds) From bf1a732e8fc8311666c055fe073a558c0de1a528 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Fri, 13 Mar 2020 21:28:59 -0400 Subject: [PATCH 26/51] Add missing story fetching handler --- .../Account/NewsBlur/NewsBlurAccountDelegate.swift | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 4c7bc2595..37a2c252d 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -232,14 +232,13 @@ extension NewsBlurAccountDelegate { caller.retrieveFeeds { result in switch result { case .success((let feeds, let folders)): - self.refreshProgress.completeTask() - BatchUpdate.shared.perform { self.syncFolders(account, folders) self.syncFeeds(account, feeds) self.syncFeedFolderRelationship(account, folders) } + self.refreshProgress.completeTask() completion(.success(())) case .failure(let error): completion(.failure(error)) @@ -451,7 +450,15 @@ extension NewsBlurAccountDelegate { return } - self.refreshUnreadStories(for: account, hashes: Array(hashes[numberOfStories...]), updateFetchDate: updateFetchDate, completion: completion) + self.refreshUnreadStories(for: account, hashes: Array(hashes[numberOfStories...]), updateFetchDate: updateFetchDate) { result in + os_log(.debug, log: self.log, "Done refreshing stories.") + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } } case .failure(let error): completion(.failure(error)) From 8f64f7230db9b69490123193fd046213cfba0be9 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Fri, 13 Mar 2020 22:23:54 -0400 Subject: [PATCH 27/51] Add network suspension support --- .../Account/NewsBlur/NewsBlurAPICaller.swift | 36 +++++++++++++++++++ .../NewsBlur/NewsBlurAccountDelegate.swift | 7 ++++ 2 files changed, 43 insertions(+) diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index 9f51552bd..95c50f144 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -25,6 +25,7 @@ final class NewsBlurAPICaller: NSObject { private let baseURL = URL(string: "https://www.newsblur.com/")! private var transport: Transport! + private var suspended = false var credentials: Credentials? weak var accountMetadata: AccountMetadata? @@ -34,11 +35,26 @@ final class NewsBlurAPICaller: NSObject { self.transport = transport } + /// Cancels all pending requests rejects any that come in later + func suspend() { + transport.cancelAll() + suspended = true + } + + func resume() { + suspended = false + } + func validateCredentials(completion: @escaping (Result) -> Void) { let url = baseURL.appendingPathComponent("api/login") let request = URLRequest(url: url, credentials: credentials) transport.send(request: request, resultType: NewsBlurLoginResponse.self) { result in + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } + switch result { case .success(let response, let payload): guard let url = response.url, let headerFields = response.allHeaderFields as? [String: String], payload?.code != -1 else { @@ -75,6 +91,11 @@ final class NewsBlurAPICaller: NSObject { let request = URLRequest(url: url, credentials: credentials) transport.send(request: request) { result in + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } + switch result { case .success: completion(.success(())) @@ -99,6 +120,11 @@ final class NewsBlurAPICaller: NSObject { let request = URLRequest(url: callURL, credentials: credentials) transport.send(request: request, resultType: NewsBlurFeedsResponse.self) { result in + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } + switch result { case .success((_, let payload)): completion(.success((payload?.feeds, payload?.folders))) @@ -122,6 +148,11 @@ final class NewsBlurAPICaller: NSObject { let request = URLRequest(url: callURL, credentials: credentials) transport.send(request: request, resultType: NewsBlurUnreadStoryHashesResponse.self, dateDecoding: .secondsSince1970) { result in + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } + switch result { case .success((_, let payload)): completion(.success(payload?.feeds.values.flatMap { $0 })) @@ -144,6 +175,11 @@ final class NewsBlurAPICaller: NSObject { let request = URLRequest(url: callURL, credentials: credentials) transport.send(request: request, resultType: NewsBlurStoriesResponse.self) { result in + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } + switch result { case .success((_, let payload)): completion(.success(payload?.stories)) diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 37a2c252d..8c565209e 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -213,14 +213,21 @@ final class NewsBlurAccountDelegate: AccountDelegate { } } + // MARK: Suspend and Resume (for iOS) + + /// Suspend all network activity func suspendNetwork() { + caller.suspend() } + /// Suspend the SQLLite databases func suspendDatabase() { database.suspend() } + /// Make sure no SQLite databases are open and we are ready to issue network requests. func resume() { + caller.resume() database.resume() } } From e1d5288d3da9562eb57504beaa7b9d568a1551c6 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Fri, 13 Mar 2020 23:16:52 -0400 Subject: [PATCH 28/51] Implement status change --- .../Account/Account.xcodeproj/project.pbxproj | 4 + .../Models/NewsBlurStoryStatusChange.swift | 22 ++++ .../Account/NewsBlur/NewsBlurAPICaller.swift | 39 ++++++ .../NewsBlur/NewsBlurAccountDelegate.swift | 119 +++++++++++++++++- 4 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 Frameworks/Account/NewsBlur/Models/NewsBlurStoryStatusChange.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 2c2a0b35b..fe9810bfc 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB7399814F6FB3247825C /* NewsBlurStory.swift */; }; + 179DB0B17A6C51B95ABC1741 /* NewsBlurStoryStatusChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */; }; 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */; }; 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */; }; 179DBED55C9B4D6A413486C1 /* NewsBlurUnreadStory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB818180A51098A9816B2 /* NewsBlurUnreadStory.swift */; }; @@ -229,6 +230,7 @@ /* Begin PBXFileReference section */ 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurLoginResponse.swift; sourceTree = ""; }; 179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurFeed.swift; sourceTree = ""; }; + 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStoryStatusChange.swift; sourceTree = ""; }; 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurGenericCodingKeys.swift; sourceTree = ""; }; 179DB7399814F6FB3247825C /* NewsBlurStory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStory.swift; sourceTree = ""; }; 179DB818180A51098A9816B2 /* NewsBlurUnreadStory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurUnreadStory.swift; sourceTree = ""; }; @@ -457,6 +459,7 @@ 179DB7399814F6FB3247825C /* NewsBlurStory.swift */, 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */, 179DB818180A51098A9816B2 /* NewsBlurUnreadStory.swift */, + 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */, ); path = Models; sourceTree = ""; @@ -1151,6 +1154,7 @@ 179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */, 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */, 179DBED55C9B4D6A413486C1 /* NewsBlurUnreadStory.swift in Sources */, + 179DB0B17A6C51B95ABC1741 /* NewsBlurStoryStatusChange.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurStoryStatusChange.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurStoryStatusChange.swift new file mode 100644 index 000000000..464fe1fdb --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurStoryStatusChange.swift @@ -0,0 +1,22 @@ +// +// NewsBlurStoryStatusChange.swift +// Account +// +// Created by Anh Quang Do on 2020-03-13. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct NewsBlurStoryStatusChange { + let hashes: [String] +} + +extension NewsBlurStoryStatusChange { + var asData: Data? { + var postData = URLComponents() + postData.queryItems = hashes.map { URLQueryItem(name: "story_hash", value: $0) } + + return postData.percentEncodedQuery?.data(using: .utf8) + } +} diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index 95c50f144..c139c76fa 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -11,11 +11,14 @@ import RSWeb enum NewsBlurError: LocalizedError { case general(message: String) + case unknown var errorDescription: String? { switch self { case .general(let message): return message + case .unknown: + return "An unknown error occurred" } } } @@ -188,4 +191,40 @@ final class NewsBlurAPICaller: NSObject { } } } + + func markAsUnread(hashes: [String], completion: @escaping (Result) -> Void) { + sendStoryStatus(endpoint: "reader/mark_story_hash_as_unread", hashes: hashes, completion: completion) + } + + func markAsRead(hashes: [String], completion: @escaping (Result) -> Void) { + sendStoryStatus(endpoint: "reader/mark_story_hashes_as_read", hashes: hashes, completion: completion) + } + + func star(hashes: [String], completion: @escaping (Result) -> Void) { + sendStoryStatus(endpoint: "reader/mark_story_hash_as_starred", hashes: hashes, completion: completion) + } + + func unstar(hashes: [String], completion: @escaping (Result) -> Void) { + sendStoryStatus(endpoint: "reader/mark_story_hash_as_unstarred", hashes: hashes, completion: completion) + } + + private func sendStoryStatus(endpoint: String, hashes: [String], completion: @escaping (Result) -> Void) { + let callURL = baseURL.appendingPathComponent(endpoint) + + var request = URLRequest(url: callURL, credentials: credentials) + request.httpBody = NewsBlurStoryStatusChange(hashes: hashes).asData + transport.send(request: request, method: HTTPMethod.post) { result in + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } + + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 8c565209e..84bf1befa 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -116,11 +116,74 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func sendArticleStatus(for account: Account, completion: @escaping (Result) -> ()) { - completion(.success(())) + os_log(.debug, log: log, "Sending story statuses...") + + database.selectForProcessing { result in + + func processStatuses(_ syncStatuses: [SyncStatus]) { + let createUnreadStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.read && $0.flag == false } + let deleteUnreadStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.read && $0.flag == true } + let createStarredStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.starred && $0.flag == true } + let deleteStarredStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.starred && $0.flag == false } + + let group = DispatchGroup() + var errorOccurred = false + + group.enter() + self.sendStoryStatuses(createUnreadStatuses, throttle: true, apiCall: self.caller.markAsUnread) { result in + group.leave() + if case .failure = result { + errorOccurred = true + } + } + + group.enter() + self.sendStoryStatuses(deleteStarredStatuses, throttle: false, apiCall: self.caller.markAsRead) { result in + group.leave() + if case .failure = result { + errorOccurred = true + } + } + + group.enter() + self.sendStoryStatuses(createStarredStatuses, throttle: true, apiCall: self.caller.star) { result in + group.leave() + if case .failure = result { + errorOccurred = true + } + } + + group.enter() + self.sendStoryStatuses(deleteStarredStatuses, throttle: true, apiCall: self.caller.unstar) { result in + group.leave() + if case .failure = result { + errorOccurred = true + } + } + + group.notify(queue: DispatchQueue.main) { + os_log(.debug, log: self.log, "Done sending article statuses.") + if errorOccurred { + completion(.failure(NewsBlurError.unknown)) + } else { + completion(.success(())) + } + } + } + + switch result { + case .success(let syncStatuses): + processStatuses(syncStatuses) + case .failure(let databaseError): + completion(.failure(databaseError)) + } + } } func refreshArticleStatus(for account: Account, completion: @escaping (Result) -> ()) { - completion(.success(())) + os_log(.debug, log: log, "Refreshing article statuses...") + + // TODO: Fill this in } func refreshStories(for account: Account, completion: @escaping (Result) -> Void) { @@ -192,7 +255,18 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { - fatalError("markArticles(for:articles:statusKey:flag:) has not been implemented") + 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) { @@ -485,4 +559,43 @@ extension NewsBlurAccountDelegate { return Set(parsedItems) } + + private func sendStoryStatuses(_ statuses: [SyncStatus], + throttle: Bool, + apiCall: ([String], @escaping (Result) -> Void) -> Void, + completion: @escaping (Result) -> Void) { + guard !statuses.isEmpty else { + completion(.success(())) + return + } + + let group = DispatchGroup() + var errorOccurred = false + + let storyHashes = statuses.compactMap { $0.articleID } + let storyHashGroups = storyHashes.chunked(into: throttle ? 1 : 5) // api limit + for storyHashGroup in storyHashGroups { + group.enter() + apiCall(storyHashGroup) { result in + switch result { + case .success: + self.database.deleteSelectedForProcessing(storyHashGroup.map { String($0) } ) + group.leave() + case .failure(let error): + errorOccurred = true + os_log(.error, log: self.log, "Story status sync call failed: %@.", error.localizedDescription) + self.database.resetSelectedForProcessing(storyHashGroup.map { String($0) } ) + group.leave() + } + } + } + + group.notify(queue: DispatchQueue.main) { + if errorOccurred { + completion(.failure(NewsBlurError.unknown)) + } else { + completion(.success(())) + } + } + } } From b8c7e8c1c6436939bb17951dd8b2d778e4198c1c Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 14 Mar 2020 16:09:19 -0400 Subject: [PATCH 29/51] Fix typo --- Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 84bf1befa..1b2b7e87f 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -138,7 +138,7 @@ final class NewsBlurAccountDelegate: AccountDelegate { } group.enter() - self.sendStoryStatuses(deleteStarredStatuses, throttle: false, apiCall: self.caller.markAsRead) { result in + self.sendStoryStatuses(deleteUnreadStatuses, throttle: false, apiCall: self.caller.markAsRead) { result in group.leave() if case .failure = result { errorOccurred = true From b7e7e176568e09bd45c8d4c6397f87ab5c536e50 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 14 Mar 2020 16:10:05 -0400 Subject: [PATCH 30/51] Update unread/starred status --- .../Account/NewsBlur/NewsBlurAPICaller.swift | 64 +++++----- .../NewsBlur/NewsBlurAccountDelegate.swift | 110 +++++++++++++++++- 2 files changed, 143 insertions(+), 31 deletions(-) diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index c139c76fa..d13418b8a 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -138,38 +138,20 @@ final class NewsBlurAPICaller: NSObject { } func retrieveUnreadStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { - let url = baseURL - .appendingPathComponent("reader/unread_story_hashes") - .appendingQueryItems([ - URLQueryItem(name: "include_timestamps", value: "true"), - ]) + retrieveStoryHashes(endpoint: "reader/unread_story_hashes", completion: completion) + } - guard let callURL = url else { - completion(.failure(TransportError.noURL)) - return - } - - let request = URLRequest(url: callURL, credentials: credentials) - transport.send(request: request, resultType: NewsBlurUnreadStoryHashesResponse.self, dateDecoding: .secondsSince1970) { result in - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } - - switch result { - case .success((_, let payload)): - completion(.success(payload?.feeds.values.flatMap { $0 })) - case .failure(let error): - completion(.failure(error)) - } - } + func retrieveStarredStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { + retrieveStoryHashes(endpoint: "reader/starred_story_hashes", completion: completion) } func retrieveStories(hashes: [NewsBlurStoryHash], completion: @escaping (Result<[NewsBlurStory]?, Error>) -> Void) { let url = baseURL .appendingPathComponent("reader/river_stories") .appendingQueryItem(.init(name: "include_hidden", value: "true"))? - .appendingQueryItems(hashes.map { URLQueryItem(name: "h", value: $0.hash) }) + .appendingQueryItems(hashes.map { + URLQueryItem(name: "h", value: $0.hash) + }) guard let callURL = url else { completion(.failure(TransportError.noURL)) @@ -207,7 +189,37 @@ final class NewsBlurAPICaller: NSObject { func unstar(hashes: [String], completion: @escaping (Result) -> Void) { sendStoryStatus(endpoint: "reader/mark_story_hash_as_unstarred", hashes: hashes, completion: completion) } - +} + +extension NewsBlurAPICaller { + private func retrieveStoryHashes(endpoint: String, completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { + let url = baseURL + .appendingPathComponent(endpoint) + .appendingQueryItems([ + URLQueryItem(name: "include_timestamps", value: "true"), + ]) + + guard let callURL = url else { + completion(.failure(TransportError.noURL)) + return + } + + let request = URLRequest(url: callURL, credentials: credentials) + transport.send(request: request, resultType: NewsBlurUnreadStoryHashesResponse.self, dateDecoding: .secondsSince1970) { result in + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } + + switch result { + case .success((_, let payload)): + completion(.success(payload?.feeds.values.flatMap { $0 })) + case .failure(let error): + completion(.failure(error)) + } + } + } + private func sendStoryStatus(endpoint: String, hashes: [String], completion: @escaping (Result) -> Void) { let callURL = baseURL.appendingPathComponent(endpoint) diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 1b2b7e87f..d72ea2007 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -181,9 +181,45 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func refreshArticleStatus(for account: Account, completion: @escaping (Result) -> ()) { - os_log(.debug, log: log, "Refreshing article statuses...") + os_log(.debug, log: log, "Refreshing story statuses...") - // TODO: Fill this in + let group = DispatchGroup() + var errorOccurred = false + + group.enter() + caller.retrieveUnreadStoryHashes { result in + switch result { + case .success(let storyHashes): + self.syncStoryReadState(account: account, hashes: storyHashes) + group.leave() + case .failure(let error): + errorOccurred = true + os_log(.info, log: self.log, "Retrieving unread stories failed: %@.", error.localizedDescription) + group.leave() + } + } + + group.enter() + caller.retrieveStarredStoryHashes { result in + switch result { + case .success(let storyHashes): + self.syncStoryStarredState(account: account, hashes: storyHashes) + group.leave() + case .failure(let error): + errorOccurred = true + os_log(.info, log: self.log, "Retrieving starred stories failed: %@.", error.localizedDescription) + group.leave() + } + } + + group.notify(queue: DispatchQueue.main) { + os_log(.debug, log: self.log, "Done refreshing article statuses.") + if errorOccurred { + completion(.failure(NewsBlurError.unknown)) + } else { + completion(.success(())) + } + } } func refreshStories(for account: Account, completion: @escaping (Result) -> Void) { @@ -548,9 +584,7 @@ extension NewsBlurAccountDelegate { } private func mapStoriesToParsedItems(stories: [NewsBlurStory]?) -> Set { - guard let stories = stories else { - return Set() - } + guard let stories = stories else { return Set() } let parsedItems: [ParsedItem] = stories.map { story in let author = Set([ParsedAuthor(name: story.authorName, url: nil, avatarURL: nil, emailAddress: nil)]) @@ -598,4 +632,70 @@ extension NewsBlurAccountDelegate { } } } + + private func syncStoryReadState(account: Account, hashes: [NewsBlurStoryHash]?) { + guard let hashes = hashes else { return } + + database.selectPendingReadStatusArticleIDs() { result in + func process(_ pendingStoryHashes: Set) { + + let newsBlurUnreadStoryHashes = Set(hashes.map { $0.hash } ) + let updatableNewsBlurUnreadStoryHashes = newsBlurUnreadStoryHashes.subtracting(pendingStoryHashes) + + account.fetchUnreadArticleIDs { articleIDsResult in + guard let currentUnreadArticleIDs = try? articleIDsResult.get() else { + return + } + + // Mark articles as unread + let deltaUnreadArticleIDs = updatableNewsBlurUnreadStoryHashes.subtracting(currentUnreadArticleIDs) + account.markAsUnread(deltaUnreadArticleIDs) + + // Mark articles as read + let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(updatableNewsBlurUnreadStoryHashes) + account.markAsRead(deltaReadArticleIDs) + } + } + + switch result { + case .success(let pendingArticleIDs): + process(pendingArticleIDs) + case .failure(let error): + os_log(.error, log: self.log, "Sync Story Read Status failed: %@.", error.localizedDescription) + } + } + } + + private func syncStoryStarredState(account: Account, hashes: [NewsBlurStoryHash]?) { + guard let hashes = hashes else { return } + + database.selectPendingStarredStatusArticleIDs() { result in + func process(_ pendingStoryHashes: Set) { + + let newsBlurStarredStoryHashes = Set(hashes.map { $0.hash } ) + let updatableNewsBlurUnreadStoryHashes = newsBlurStarredStoryHashes.subtracting(pendingStoryHashes) + + account.fetchStarredArticleIDs { articleIDsResult in + guard let currentStarredArticleIDs = try? articleIDsResult.get() else { + return + } + + // Mark articles as starred + let deltaStarredArticleIDs = updatableNewsBlurUnreadStoryHashes.subtracting(currentStarredArticleIDs) + account.markAsStarred(deltaStarredArticleIDs) + + // Mark articles as unstarred + let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(updatableNewsBlurUnreadStoryHashes) + account.markAsUnstarred(deltaUnstarredArticleIDs) + } + } + + switch result { + case .success(let pendingArticleIDs): + process(pendingArticleIDs) + case .failure(let error): + os_log(.error, log: self.log, "Sync Story Starred Status failed: %@.", error.localizedDescription) + } + } + } } From bde6a607ba77325ec649f423afb78d22e5ee1d6d Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 14 Mar 2020 16:44:23 -0400 Subject: [PATCH 31/51] Fix starred story parsing using incorrect JSON field --- .../Account/Account.xcodeproj/project.pbxproj | 12 +- .../NewsBlur/Models/NewsBlurStoryHash.swift | 64 +++ .../NewsBlur/Models/NewsBlurUnreadStory.swift | 50 --- .../Account/NewsBlur/NewsBlurAPICaller.swift | 5 +- .../NewsBlurAccountDelegate+Private.swift | 374 ++++++++++++++++++ .../NewsBlur/NewsBlurAccountDelegate.swift | 364 +---------------- 6 files changed, 452 insertions(+), 417 deletions(-) create mode 100644 Frameworks/Account/NewsBlur/Models/NewsBlurStoryHash.swift delete mode 100644 Frameworks/Account/NewsBlur/Models/NewsBlurUnreadStory.swift create mode 100644 Frameworks/Account/NewsBlur/NewsBlurAccountDelegate+Private.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index fe9810bfc..cdfe3e4c6 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -11,7 +11,8 @@ 179DB0B17A6C51B95ABC1741 /* NewsBlurStoryStatusChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */; }; 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */; }; 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */; }; - 179DBED55C9B4D6A413486C1 /* NewsBlurUnreadStory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB818180A51098A9816B2 /* NewsBlurUnreadStory.swift */; }; + 179DB96B984E67DC101E470D /* NewsBlurAccountDelegate+Private.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB55DC2CAD332D4376416 /* NewsBlurAccountDelegate+Private.swift */; }; + 179DBED55C9B4D6A413486C1 /* NewsBlurStoryHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */; }; 179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */; }; 3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A33E6238D3D6800314204 /* Secrets.swift */; }; 3B826DA72385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */; }; @@ -230,10 +231,11 @@ /* Begin PBXFileReference section */ 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurLoginResponse.swift; sourceTree = ""; }; 179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurFeed.swift; sourceTree = ""; }; + 179DB55DC2CAD332D4376416 /* NewsBlurAccountDelegate+Private.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NewsBlurAccountDelegate+Private.swift"; sourceTree = ""; }; 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStoryStatusChange.swift; sourceTree = ""; }; 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurGenericCodingKeys.swift; sourceTree = ""; }; 179DB7399814F6FB3247825C /* NewsBlurStory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStory.swift; sourceTree = ""; }; - 179DB818180A51098A9816B2 /* NewsBlurUnreadStory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurUnreadStory.swift; sourceTree = ""; }; + 179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStoryHash.swift; sourceTree = ""; }; 3B3A33E6238D3D6800314204 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Secrets.swift; path = ../../Shared/Secrets.swift; sourceTree = ""; }; 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = ""; }; 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItem.swift; sourceTree = ""; }; @@ -458,7 +460,7 @@ 179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */, 179DB7399814F6FB3247825C /* NewsBlurStory.swift */, 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */, - 179DB818180A51098A9816B2 /* NewsBlurUnreadStory.swift */, + 179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */, 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */, ); path = Models; @@ -558,6 +560,7 @@ 769F2A8DF190549E24B5D110 /* NewsBlurAccountDelegate.swift */, 769F275FD5D942502C5B4716 /* NewsBlurAPICaller.swift */, 179DBD810D353D9CED7C3BED /* Models */, + 179DB55DC2CAD332D4376416 /* NewsBlurAccountDelegate+Private.swift */, ); path = NewsBlur; sourceTree = ""; @@ -1153,8 +1156,9 @@ 179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */, 179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */, 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */, - 179DBED55C9B4D6A413486C1 /* NewsBlurUnreadStory.swift in Sources */, + 179DBED55C9B4D6A413486C1 /* NewsBlurStoryHash.swift in Sources */, 179DB0B17A6C51B95ABC1741 /* NewsBlurStoryStatusChange.swift in Sources */, + 179DB96B984E67DC101E470D /* NewsBlurAccountDelegate+Private.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurStoryHash.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurStoryHash.swift new file mode 100644 index 000000000..fd76f7915 --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurStoryHash.swift @@ -0,0 +1,64 @@ +// +// NewsBlurStoryHash.swift +// Account +// +// Created by Anh Quang Do on 2020-03-13. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSCore +import RSParser + +typealias NewsBlurStoryHash = NewsBlurStoryHashesResponse.StoryHash + +struct NewsBlurStoryHashesResponse: Decodable { + typealias StoryHashDictionary = [String: [StoryHash]] + + var unread: StoryHashDictionary? + var starred: StoryHashDictionary? + + struct StoryHash: Hashable, Codable { + var hash: String + var timestamp: Date + } +} + +extension NewsBlurStoryHashesResponse { + private enum CodingKeys: String, CodingKey { + case unread = "unread_feed_story_hashes" + case starred = "starred_story_hashes" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Parse unread + if let unreadContainer = try? container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .unread) { + self.unread = try NewsBlurStoryHashesResponse.extractHashes(container: unreadContainer) + } + + // Parse starred + if let starredContainer = try? container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .starred) { + self.starred = try NewsBlurStoryHashesResponse.extractHashes(container: starredContainer) + } + } + + static func extractHashes(container: KeyedDecodingContainer) throws -> StoryHashDictionary where Key: CodingKey { + var dict: StoryHashDictionary = [:] + for key in container.allKeys { + dict[key.stringValue] = [] + var hashArrayContainer = try container.nestedUnkeyedContainer(forKey: key) + while !hashArrayContainer.isAtEnd { + var hashContainer = try hashArrayContainer.nestedUnkeyedContainer() + let hash = try hashContainer.decode(String.self) + let timestamp = try hashContainer.decode(Date.self) + let storyHash = StoryHash(hash: hash, timestamp: timestamp) + + dict[key.stringValue]?.append(storyHash) + } + } + + return dict + } +} diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurUnreadStory.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurUnreadStory.swift deleted file mode 100644 index 25a123770..000000000 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurUnreadStory.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// NewsBlurUnreadStory.swift -// Account -// -// Created by Anh Quang Do on 2020-03-13. -// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. -// - -import Foundation -import RSCore -import RSParser - -typealias NewsBlurStoryHash = NewsBlurUnreadStoryHashesResponse.StoryHash - -struct NewsBlurUnreadStoryHashesResponse: Decodable { - let feeds: [String: [StoryHash]] - - struct StoryHash: Hashable, Codable { - var hash: String - var timestamp: Date - } -} - -extension NewsBlurUnreadStoryHashesResponse { - private enum CodingKeys: String, CodingKey { - case feeds = "unread_feed_story_hashes" - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - // Parse feeds - var feeds: [String: [StoryHash]] = [:] - let feedContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .feeds) - try feedContainer.allKeys.forEach { key in - feeds[key.stringValue] = [] - var hashArrayContainer = try feedContainer.nestedUnkeyedContainer(forKey: key) - while !hashArrayContainer.isAtEnd { - var hashContainer = try hashArrayContainer.nestedUnkeyedContainer() - let hash = try hashContainer.decode(String.self) - let timestamp = try hashContainer.decode(Date.self) - let storyHash = StoryHash(hash: hash, timestamp: timestamp) - - feeds[key.stringValue]?.append(storyHash) - } - } - - self.feeds = feeds - } -} diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index d13418b8a..2d8d43cc9 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -205,7 +205,7 @@ extension NewsBlurAPICaller { } let request = URLRequest(url: callURL, credentials: credentials) - transport.send(request: request, resultType: NewsBlurUnreadStoryHashesResponse.self, dateDecoding: .secondsSince1970) { result in + transport.send(request: request, resultType: NewsBlurStoryHashesResponse.self, dateDecoding: .secondsSince1970) { result in if self.suspended { completion(.failure(TransportError.suspended)) return @@ -213,7 +213,8 @@ extension NewsBlurAPICaller { switch result { case .success((_, let payload)): - completion(.success(payload?.feeds.values.flatMap { $0 })) + let hashes = payload?.unread ?? payload?.starred + completion(.success(hashes?.values.flatMap { $0 })) case .failure(let error): completion(.failure(error)) } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate+Private.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate+Private.swift new file mode 100644 index 000000000..1eff39d50 --- /dev/null +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate+Private.swift @@ -0,0 +1,374 @@ +// +// NewsBlurAccountDelegate+Private.swift +// Mostly adapted from FeedbinAccountDelegate.swift +// Account +// +// Created by Anh Quang Do on 2020-03-14. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Articles +import RSCore +import RSDatabase +import RSParser +import RSWeb +import SyncDatabase +import os.log + +extension NewsBlurAccountDelegate { + func refreshFeeds(for account: Account, completion: @escaping (Result) -> Void) { + os_log(.debug, log: log, "Refreshing feeds...") + + caller.retrieveFeeds { result in + switch result { + case .success((let feeds, let folders)): + BatchUpdate.shared.perform { + self.syncFolders(account, folders) + self.syncFeeds(account, feeds) + self.syncFeedFolderRelationship(account, folders) + } + + self.refreshProgress.completeTask() + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func syncFolders(_ account: Account, _ folders: [NewsBlurFolder]?) { + guard let folders = folders else { return } + assert(Thread.isMainThread) + + os_log(.debug, log: log, "Syncing folders with %ld folders.", folders.count) + + let folderNames = folders.map { $0.name } + + // Delete any folders not at NewsBlur + if let folders = account.folders { + folders.forEach { folder in + if !folderNames.contains(folder.name ?? "") { + for feed in folder.topLevelWebFeeds { + account.addWebFeed(feed) + clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") + } + account.removeFolder(folder) + } + } + } + + let accountFolderNames: [String] = { + if let folders = account.folders { + return folders.map { $0.name ?? "" } + } else { + return [String]() + } + }() + + // Make any folders NewsBlur has, but we don't + folderNames.forEach { folderName in + if !accountFolderNames.contains(folderName) { + _ = account.ensureFolder(with: folderName) + } + } + } + + func syncFeeds(_ account: Account, _ feeds: [NewsBlurFeed]?) { + guard let feeds = feeds else { return } + assert(Thread.isMainThread) + + os_log(.debug, log: log, "Syncing feeds with %ld feeds.", feeds.count) + + let subFeedIds = feeds.map { String($0.feedID) } + + // Remove any feeds that are no longer in the subscriptions + if let folders = account.folders { + for folder in folders { + for feed in folder.topLevelWebFeeds { + if !subFeedIds.contains(feed.webFeedID) { + folder.removeWebFeed(feed) + } + } + } + } + + for feed in account.topLevelWebFeeds { + if !subFeedIds.contains(feed.webFeedID) { + account.removeWebFeed(feed) + } + } + + // Add any feeds we don't have and update any we do + var feedsToAdd = Set() + feeds.forEach { feed in + let subFeedId = String(feed.feedID) + + if let webFeed = account.existingWebFeed(withWebFeedID: subFeedId) { + webFeed.name = feed.name + // If the name has been changed on the server remove the locally edited name + webFeed.editedName = nil + webFeed.homePageURL = feed.homepageURL + webFeed.subscriptionID = String(feed.feedID) + webFeed.faviconURL = feed.faviconURL + } + else { + feedsToAdd.insert(feed) + } + } + + // Actually add feeds all in one go, so we don’t trigger various rebuilding things that Account does. + feedsToAdd.forEach { feed in + let webFeed = account.createWebFeed(with: feed.name, url: feed.feedURL, webFeedID: String(feed.feedID), homePageURL: feed.homepageURL) + webFeed.subscriptionID = String(feed.feedID) + account.addWebFeed(webFeed) + } + } + + func syncFeedFolderRelationship(_ account: Account, _ folders: [NewsBlurFolder]?) { + guard let folders = folders else { return } + assert(Thread.isMainThread) + + os_log(.debug, log: log, "Syncing folders with %ld folders.", folders.count) + + // Set up some structures to make syncing easier + let relationships = folders.map({ $0.asRelationships }).flatMap { $0 } + let folderDict = nameToFolderDictionary(with: account.folders) + let foldersDict = relationships.reduce([String: [NewsBlurFolderRelationship]]()) { (dict, relationship) in + var feedInFolders = dict + if var feedInFolder = feedInFolders[relationship.folderName] { + feedInFolder.append(relationship) + feedInFolders[relationship.folderName] = feedInFolder + } else { + feedInFolders[relationship.folderName] = [relationship] + } + return feedInFolders + } + + // Sync the folders + for (folderName, folderRelationships) in foldersDict { + guard let folder = folderDict[folderName] else { return } + + let folderFeedIDs = folderRelationships.map { String($0.feedID) } + + // Move any feeds not in the folder to the account + for feed in folder.topLevelWebFeeds { + if !folderFeedIDs.contains(feed.webFeedID) { + folder.removeWebFeed(feed) + clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") + account.addWebFeed(feed) + } + } + + // Add any feeds not in the folder + let folderFeedIds = folder.topLevelWebFeeds.map { $0.webFeedID } + + for relationship in folderRelationships { + let folderFeedID = String(relationship.feedID) + if !folderFeedIds.contains(folderFeedID) { + guard let feed = account.existingWebFeed(withWebFeedID: folderFeedID) else { + continue + } + saveFolderRelationship(for: feed, withFolderName: folderName, id: relationship.folderName) + folder.addWebFeed(feed) + } + } + + } + + let folderFeedIDs = Set(relationships.map { String($0.feedID) }) + + // Remove all feeds from the account container that have a tag + for feed in account.topLevelWebFeeds { + if folderFeedIDs.contains(feed.webFeedID) { + account.removeWebFeed(feed) + } + } + } + + func clearFolderRelationship(for feed: WebFeed, withFolderName folderName: String) { + if var folderRelationship = feed.folderRelationship { + folderRelationship[folderName] = nil + feed.folderRelationship = folderRelationship + } + } + + func saveFolderRelationship(for feed: WebFeed, withFolderName folderName: String, id: String) { + if var folderRelationship = feed.folderRelationship { + folderRelationship[folderName] = id + feed.folderRelationship = folderRelationship + } else { + feed.folderRelationship = [folderName: id] + } + } + + func nameToFolderDictionary(with folders: Set?) -> [String: Folder] { + guard let folders = folders else { + return [String: Folder]() + } + + var d = [String: Folder]() + for folder in folders { + let name = folder.name ?? "" + if d[name] == nil { + d[name] = folder + } + } + return d + } + + func refreshUnreadStories(for account: Account, hashes: [NewsBlurStoryHash]?, updateFetchDate: Date?, completion: @escaping (Result) -> Void) { + guard let hashes = hashes, !hashes.isEmpty else { + if let lastArticleFetch = updateFetchDate { + self.accountMetadata?.lastArticleFetchStartTime = lastArticleFetch + self.accountMetadata?.lastArticleFetchEndTime = Date() + } + completion(.success(())) + return + } + + let numberOfStories = min(hashes.count, 100) // api limit + let hashesToFetch = Array(hashes[.. Set { + guard let stories = stories else { return Set() } + + 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: nil, bannerImageURL: nil, datePublished: story.datePublished, dateModified: nil, authors: author, tags: nil, attachments: nil) + } + + return Set(parsedItems) + } + + func sendStoryStatuses(_ statuses: [SyncStatus], + throttle: Bool, + apiCall: ([String], @escaping (Result) -> Void) -> Void, + completion: @escaping (Result) -> Void) { + guard !statuses.isEmpty else { + completion(.success(())) + return + } + + let group = DispatchGroup() + var errorOccurred = false + + let storyHashes = statuses.compactMap { $0.articleID } + let storyHashGroups = storyHashes.chunked(into: throttle ? 1 : 5) // api limit + for storyHashGroup in storyHashGroups { + group.enter() + apiCall(storyHashGroup) { result in + switch result { + case .success: + self.database.deleteSelectedForProcessing(storyHashGroup.map { String($0) } ) + group.leave() + case .failure(let error): + errorOccurred = true + os_log(.error, log: self.log, "Story status sync call failed: %@.", error.localizedDescription) + self.database.resetSelectedForProcessing(storyHashGroup.map { String($0) } ) + group.leave() + } + } + } + + group.notify(queue: DispatchQueue.main) { + if errorOccurred { + completion(.failure(NewsBlurError.unknown)) + } else { + completion(.success(())) + } + } + } + + func syncStoryReadState(account: Account, hashes: [NewsBlurStoryHash]?) { + guard let hashes = hashes else { return } + + database.selectPendingReadStatusArticleIDs() { result in + func process(_ pendingStoryHashes: Set) { + + let newsBlurUnreadStoryHashes = Set(hashes.map { $0.hash } ) + let updatableNewsBlurUnreadStoryHashes = newsBlurUnreadStoryHashes.subtracting(pendingStoryHashes) + + account.fetchUnreadArticleIDs { articleIDsResult in + guard let currentUnreadArticleIDs = try? articleIDsResult.get() else { + return + } + + // Mark articles as unread + let deltaUnreadArticleIDs = updatableNewsBlurUnreadStoryHashes.subtracting(currentUnreadArticleIDs) + account.markAsUnread(deltaUnreadArticleIDs) + + // Mark articles as read + let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(updatableNewsBlurUnreadStoryHashes) + account.markAsRead(deltaReadArticleIDs) + } + } + + switch result { + case .success(let pendingArticleIDs): + process(pendingArticleIDs) + case .failure(let error): + os_log(.error, log: self.log, "Sync Story Read Status failed: %@.", error.localizedDescription) + } + } + } + + func syncStoryStarredState(account: Account, hashes: [NewsBlurStoryHash]?) { + guard let hashes = hashes else { return } + + database.selectPendingStarredStatusArticleIDs() { result in + func process(_ pendingStoryHashes: Set) { + + let newsBlurStarredStoryHashes = Set(hashes.map { $0.hash } ) + let updatableNewsBlurUnreadStoryHashes = newsBlurStarredStoryHashes.subtracting(pendingStoryHashes) + + account.fetchStarredArticleIDs { articleIDsResult in + guard let currentStarredArticleIDs = try? articleIDsResult.get() else { + return + } + + // Mark articles as starred + let deltaStarredArticleIDs = updatableNewsBlurUnreadStoryHashes.subtracting(currentStarredArticleIDs) + account.markAsStarred(deltaStarredArticleIDs) + + // Mark articles as unstarred + let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(updatableNewsBlurUnreadStoryHashes) + account.markAsUnstarred(deltaUnstarredArticleIDs) + } + } + + switch result { + case .success(let pendingArticleIDs): + process(pendingArticleIDs) + case .failure(let error): + os_log(.error, log: self.log, "Sync Story Starred Status failed: %@.", error.localizedDescription) + } + } + } +} diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index d72ea2007..f6e781cb8 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -29,9 +29,9 @@ final class NewsBlurAccountDelegate: AccountDelegate { var accountMetadata: AccountMetadata? = nil var refreshProgress = DownloadProgress(numberOfTasks: 0) - private let caller: NewsBlurAPICaller - private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "NewsBlur") - private let database: SyncDatabase + let caller: NewsBlurAPICaller + let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "NewsBlur") + let database: SyncDatabase init(dataFolder: String, transport: Transport?) { if let transport = transport { @@ -341,361 +341,3 @@ final class NewsBlurAccountDelegate: AccountDelegate { database.resume() } } - -extension NewsBlurAccountDelegate { - private func refreshFeeds(for account: Account, completion: @escaping (Result) -> Void) { - os_log(.debug, log: log, "Refreshing feeds...") - - caller.retrieveFeeds { result in - switch result { - case .success((let feeds, let folders)): - BatchUpdate.shared.perform { - self.syncFolders(account, folders) - self.syncFeeds(account, feeds) - self.syncFeedFolderRelationship(account, folders) - } - - self.refreshProgress.completeTask() - completion(.success(())) - case .failure(let error): - completion(.failure(error)) - } - } - } - - private func syncFolders(_ account: Account, _ folders: [NewsBlurFolder]?) { - guard let folders = folders else { return } - assert(Thread.isMainThread) - - os_log(.debug, log: log, "Syncing folders with %ld folders.", folders.count) - - let folderNames = folders.map { $0.name } - - // Delete any folders not at NewsBlur - if let folders = account.folders { - folders.forEach { folder in - if !folderNames.contains(folder.name ?? "") { - for feed in folder.topLevelWebFeeds { - account.addWebFeed(feed) - clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") - } - account.removeFolder(folder) - } - } - } - - let accountFolderNames: [String] = { - if let folders = account.folders { - return folders.map { $0.name ?? "" } - } else { - return [String]() - } - }() - - // Make any folders NewsBlur has, but we don't - folderNames.forEach { folderName in - if !accountFolderNames.contains(folderName) { - _ = account.ensureFolder(with: folderName) - } - } - } - - private func syncFeeds(_ account: Account, _ feeds: [NewsBlurFeed]?) { - guard let feeds = feeds else { return } - assert(Thread.isMainThread) - - os_log(.debug, log: log, "Syncing feeds with %ld feeds.", feeds.count) - - let subFeedIds = feeds.map { String($0.feedID) } - - // Remove any feeds that are no longer in the subscriptions - if let folders = account.folders { - for folder in folders { - for feed in folder.topLevelWebFeeds { - if !subFeedIds.contains(feed.webFeedID) { - folder.removeWebFeed(feed) - } - } - } - } - - for feed in account.topLevelWebFeeds { - if !subFeedIds.contains(feed.webFeedID) { - account.removeWebFeed(feed) - } - } - - // Add any feeds we don't have and update any we do - var feedsToAdd = Set() - feeds.forEach { feed in - let subFeedId = String(feed.feedID) - - if let webFeed = account.existingWebFeed(withWebFeedID: subFeedId) { - webFeed.name = feed.name - // If the name has been changed on the server remove the locally edited name - webFeed.editedName = nil - webFeed.homePageURL = feed.homepageURL - webFeed.subscriptionID = String(feed.feedID) - webFeed.faviconURL = feed.faviconURL - } - else { - feedsToAdd.insert(feed) - } - } - - // Actually add feeds all in one go, so we don’t trigger various rebuilding things that Account does. - feedsToAdd.forEach { feed in - let webFeed = account.createWebFeed(with: feed.name, url: feed.feedURL, webFeedID: String(feed.feedID), homePageURL: feed.homepageURL) - webFeed.subscriptionID = String(feed.feedID) - account.addWebFeed(webFeed) - } - } - - private func syncFeedFolderRelationship(_ account: Account, _ folders: [NewsBlurFolder]?) { - guard let folders = folders else { return } - assert(Thread.isMainThread) - - os_log(.debug, log: log, "Syncing folders with %ld folders.", folders.count) - - // Set up some structures to make syncing easier - let relationships = folders.map({ $0.asRelationships }).flatMap { $0 } - let folderDict = nameToFolderDictionary(with: account.folders) - let foldersDict = relationships.reduce([String: [NewsBlurFolderRelationship]]()) { (dict, relationship) in - var feedInFolders = dict - if var feedInFolder = feedInFolders[relationship.folderName] { - feedInFolder.append(relationship) - feedInFolders[relationship.folderName] = feedInFolder - } else { - feedInFolders[relationship.folderName] = [relationship] - } - return feedInFolders - } - - // Sync the folders - for (folderName, folderRelationships) in foldersDict { - guard let folder = folderDict[folderName] else { return } - - let folderFeedIDs = folderRelationships.map { String($0.feedID) } - - // Move any feeds not in the folder to the account - for feed in folder.topLevelWebFeeds { - if !folderFeedIDs.contains(feed.webFeedID) { - folder.removeWebFeed(feed) - clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") - account.addWebFeed(feed) - } - } - - // Add any feeds not in the folder - let folderFeedIds = folder.topLevelWebFeeds.map { $0.webFeedID } - - for relationship in folderRelationships { - let folderFeedID = String(relationship.feedID) - if !folderFeedIds.contains(folderFeedID) { - guard let feed = account.existingWebFeed(withWebFeedID: folderFeedID) else { - continue - } - saveFolderRelationship(for: feed, withFolderName: folderName, id: relationship.folderName) - folder.addWebFeed(feed) - } - } - - } - - let folderFeedIDs = Set(relationships.map { String($0.feedID) }) - - // Remove all feeds from the account container that have a tag - for feed in account.topLevelWebFeeds { - if folderFeedIDs.contains(feed.webFeedID) { - account.removeWebFeed(feed) - } - } - } - - private func clearFolderRelationship(for feed: WebFeed, withFolderName folderName: String) { - if var folderRelationship = feed.folderRelationship { - folderRelationship[folderName] = nil - feed.folderRelationship = folderRelationship - } - } - - private func saveFolderRelationship(for feed: WebFeed, withFolderName folderName: String, id: String) { - if var folderRelationship = feed.folderRelationship { - folderRelationship[folderName] = id - feed.folderRelationship = folderRelationship - } else { - feed.folderRelationship = [folderName: id] - } - } - - private func nameToFolderDictionary(with folders: Set?) -> [String: Folder] { - guard let folders = folders else { - return [String: Folder]() - } - - var d = [String: Folder]() - for folder in folders { - let name = folder.name ?? "" - if d[name] == nil { - d[name] = folder - } - } - return d - } - - private func refreshUnreadStories(for account: Account, hashes: [NewsBlurStoryHash]?, updateFetchDate: Date?, completion: @escaping (Result) -> Void) { - guard let hashes = hashes, !hashes.isEmpty else { - if let lastArticleFetch = updateFetchDate { - self.accountMetadata?.lastArticleFetchStartTime = lastArticleFetch - self.accountMetadata?.lastArticleFetchEndTime = Date() - } - completion(.success(())) - return - } - - let numberOfStories = min(hashes.count, 100) // api limit - let hashesToFetch = Array(hashes[.. Set { - guard let stories = stories else { return Set() } - - 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: nil, bannerImageURL: nil, datePublished: story.datePublished, dateModified: nil, authors: author, tags: nil, attachments: nil) - } - - return Set(parsedItems) - } - - private func sendStoryStatuses(_ statuses: [SyncStatus], - throttle: Bool, - apiCall: ([String], @escaping (Result) -> Void) -> Void, - completion: @escaping (Result) -> Void) { - guard !statuses.isEmpty else { - completion(.success(())) - return - } - - let group = DispatchGroup() - var errorOccurred = false - - let storyHashes = statuses.compactMap { $0.articleID } - let storyHashGroups = storyHashes.chunked(into: throttle ? 1 : 5) // api limit - for storyHashGroup in storyHashGroups { - group.enter() - apiCall(storyHashGroup) { result in - switch result { - case .success: - self.database.deleteSelectedForProcessing(storyHashGroup.map { String($0) } ) - group.leave() - case .failure(let error): - errorOccurred = true - os_log(.error, log: self.log, "Story status sync call failed: %@.", error.localizedDescription) - self.database.resetSelectedForProcessing(storyHashGroup.map { String($0) } ) - group.leave() - } - } - } - - group.notify(queue: DispatchQueue.main) { - if errorOccurred { - completion(.failure(NewsBlurError.unknown)) - } else { - completion(.success(())) - } - } - } - - private func syncStoryReadState(account: Account, hashes: [NewsBlurStoryHash]?) { - guard let hashes = hashes else { return } - - database.selectPendingReadStatusArticleIDs() { result in - func process(_ pendingStoryHashes: Set) { - - let newsBlurUnreadStoryHashes = Set(hashes.map { $0.hash } ) - let updatableNewsBlurUnreadStoryHashes = newsBlurUnreadStoryHashes.subtracting(pendingStoryHashes) - - account.fetchUnreadArticleIDs { articleIDsResult in - guard let currentUnreadArticleIDs = try? articleIDsResult.get() else { - return - } - - // Mark articles as unread - let deltaUnreadArticleIDs = updatableNewsBlurUnreadStoryHashes.subtracting(currentUnreadArticleIDs) - account.markAsUnread(deltaUnreadArticleIDs) - - // Mark articles as read - let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(updatableNewsBlurUnreadStoryHashes) - account.markAsRead(deltaReadArticleIDs) - } - } - - switch result { - case .success(let pendingArticleIDs): - process(pendingArticleIDs) - case .failure(let error): - os_log(.error, log: self.log, "Sync Story Read Status failed: %@.", error.localizedDescription) - } - } - } - - private func syncStoryStarredState(account: Account, hashes: [NewsBlurStoryHash]?) { - guard let hashes = hashes else { return } - - database.selectPendingStarredStatusArticleIDs() { result in - func process(_ pendingStoryHashes: Set) { - - let newsBlurStarredStoryHashes = Set(hashes.map { $0.hash } ) - let updatableNewsBlurUnreadStoryHashes = newsBlurStarredStoryHashes.subtracting(pendingStoryHashes) - - account.fetchStarredArticleIDs { articleIDsResult in - guard let currentStarredArticleIDs = try? articleIDsResult.get() else { - return - } - - // Mark articles as starred - let deltaStarredArticleIDs = updatableNewsBlurUnreadStoryHashes.subtracting(currentStarredArticleIDs) - account.markAsStarred(deltaStarredArticleIDs) - - // Mark articles as unstarred - let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(updatableNewsBlurUnreadStoryHashes) - account.markAsUnstarred(deltaUnstarredArticleIDs) - } - } - - switch result { - case .success(let pendingArticleIDs): - process(pendingArticleIDs) - case .failure(let error): - os_log(.error, log: self.log, "Sync Story Starred Status failed: %@.", error.localizedDescription) - } - } - } -} From 1d3073c593593ec580d9e1efabc203e31420618a Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 14 Mar 2020 17:51:21 -0400 Subject: [PATCH 32/51] Add missing story fetching --- .../NewsBlur/NewsBlurAccountDelegate.swift | 54 ++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index f6e781cb8..74f277b2b 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -239,9 +239,59 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func refreshMissingStories(for account: Account, completion: @escaping (Result)-> Void) { - completion(.success(())) + os_log(.debug, log: log, "Refreshing missing stories...") + + account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate { result in + func process(_ fetchedHashes: Set) { + let group = DispatchGroup() + var errorOccurred = false + + let storyHashes = Array(fetchedHashes).map { NewsBlurStoryHash(hash: $0, timestamp: Date()) } + let chunkedStoryHashes = storyHashes.chunked(into: 100) + + for chunk in chunkedStoryHashes { + group.enter() + self.caller.retrieveStories(hashes: chunk) { result in + + switch result { + case .success(let stories): + + self.processStories(account: account, stories: stories) { error in + group.leave() + if error != nil { + errorOccurred = true + } + } + + case .failure(let error): + errorOccurred = true + os_log(.error, log: self.log, "Refresh missing stories failed: %@.", error.localizedDescription) + group.leave() + } + } + } + + group.notify(queue: DispatchQueue.main) { + self.refreshProgress.completeTask() + os_log(.debug, log: self.log, "Done refreshing missing stories.") + if errorOccurred { + completion(.failure(NewsBlurError.unknown)) + } else { + completion(.success(())) + } + } + } + + switch result { + case .success(let fetchedArticleIDs): + process(fetchedArticleIDs) + case .failure(let error): + self.refreshProgress.completeTask() + completion(.failure(error)) + } + } } - + func processStories(account: Account, stories: [NewsBlurStory]?, completion: @escaping DatabaseCompletionBlock) { let parsedItems = mapStoriesToParsedItems(stories: stories) let webFeedIDsAndItems = Dictionary(grouping: parsedItems, by: { item in item.feedURL } ).mapValues { Set($0) } From 0479bbd9f5e65e53f783c254fff5b9c3319fe654 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 14 Mar 2020 18:19:50 -0400 Subject: [PATCH 33/51] Parse story image and tags --- Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift | 7 +++++++ .../Account/NewsBlur/NewsBlurAccountDelegate+Private.swift | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift index a8ccf5881..8ac098ef6 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift @@ -22,11 +22,16 @@ struct NewsBlurStoriesResponse: Decodable { let url: String? let authorName: String? let contentHTML: String? + var imageURL: String? { + return imageURLs?.first + } + var tags: [String]? var datePublished: Date? { let interval = (publishedTimestamp as NSString).doubleValue return Date(timeIntervalSince1970: interval) } + private var imageURLs: [String]? private var publishedTimestamp: String } } @@ -45,6 +50,8 @@ extension NewsBlurStoriesResponse.Story { case url = "story_permalink" case authorName = "story_authors" case contentHTML = "story_content" + case imageURLs = "image_urls" + case tags = "story_tags" case publishedTimestamp = "story_timestamp" } } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate+Private.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate+Private.swift index 1eff39d50..51a8e6fc7 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate+Private.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate+Private.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: nil, bannerImageURL: nil, datePublished: story.datePublished, dateModified: nil, authors: author, tags: nil, attachments: 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 Set(parsedItems) From fb07cfe18759abbd7f7399f6e6e852d55d612474 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 14 Mar 2020 18:33:38 -0400 Subject: [PATCH 34/51] Handle refresh progress --- .../Account/NewsBlur/NewsBlurAccountDelegate.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 74f277b2b..3e957e965 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -90,7 +90,11 @@ final class NewsBlurAccountDelegate: AccountDelegate { } case .failure(let error): - completion(.failure(error)) + DispatchQueue.main.async { + self.refreshProgress.clear() + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } } } @@ -231,6 +235,10 @@ final class NewsBlurAccountDelegate: AccountDelegate { case .success(let storyHashes): self.refreshProgress.completeTask() + if let count = storyHashes?.count, count > 0 { + self.refreshProgress.addToNumberOfTasksAndRemaining((count - 1) / 100 + 1) + } + self.refreshUnreadStories(for: account, hashes: storyHashes, updateFetchDate: nil, completion: completion) case .failure(let error): completion(.failure(error)) @@ -255,14 +263,12 @@ final class NewsBlurAccountDelegate: AccountDelegate { switch result { case .success(let stories): - self.processStories(account: account, stories: stories) { error in group.leave() if error != nil { errorOccurred = true } } - case .failure(let error): errorOccurred = true os_log(.error, log: self.log, "Refresh missing stories failed: %@.", error.localizedDescription) From 08339606e9413da3670de2d56cb558a0b7c5bf9b Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 14 Mar 2020 18:45:11 -0400 Subject: [PATCH 35/51] Use secure version of story images --- Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift index 8ac098ef6..e4878b287 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift @@ -23,7 +23,7 @@ struct NewsBlurStoriesResponse: Decodable { let authorName: String? let contentHTML: String? var imageURL: String? { - return imageURLs?.first + return imageURLs?.first?.value } var tags: [String]? var datePublished: Date? { @@ -31,7 +31,7 @@ struct NewsBlurStoriesResponse: Decodable { return Date(timeIntervalSince1970: interval) } - private var imageURLs: [String]? + private var imageURLs: [String: String]? private var publishedTimestamp: String } } @@ -50,7 +50,7 @@ extension NewsBlurStoriesResponse.Story { case url = "story_permalink" case authorName = "story_authors" case contentHTML = "story_content" - case imageURLs = "image_urls" + case imageURLs = "secure_image_urls" case tags = "story_tags" case publishedTimestamp = "story_timestamp" } From 494ce5bab9a4aa259340bf0c735621cf9df303a4 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 14 Mar 2020 20:00:29 -0400 Subject: [PATCH 36/51] Add folder --- .../Account/Account.xcodeproj/project.pbxproj | 4 +++ .../Models/NewsBlurFolderChange.swift | 27 +++++++++++++++++++ .../Account/NewsBlur/NewsBlurAPICaller.swift | 27 +++++++++++++++++-- .../NewsBlur/NewsBlurAccountDelegate.swift | 16 +++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index cdfe3e4c6..7f96e2abd 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */; }; 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */; }; 179DB96B984E67DC101E470D /* NewsBlurAccountDelegate+Private.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB55DC2CAD332D4376416 /* NewsBlurAccountDelegate+Private.swift */; }; + 179DBD4ECC1C9712DF51DB8C /* NewsBlurFolderChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBDDC00B68411AA28941F /* NewsBlurFolderChange.swift */; }; 179DBED55C9B4D6A413486C1 /* NewsBlurStoryHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */; }; 179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */; }; 3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A33E6238D3D6800314204 /* Secrets.swift */; }; @@ -236,6 +237,7 @@ 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurGenericCodingKeys.swift; sourceTree = ""; }; 179DB7399814F6FB3247825C /* NewsBlurStory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStory.swift; sourceTree = ""; }; 179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStoryHash.swift; sourceTree = ""; }; + 179DBDDC00B68411AA28941F /* NewsBlurFolderChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurFolderChange.swift; sourceTree = ""; }; 3B3A33E6238D3D6800314204 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Secrets.swift; path = ../../Shared/Secrets.swift; sourceTree = ""; }; 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = ""; }; 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItem.swift; sourceTree = ""; }; @@ -462,6 +464,7 @@ 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */, 179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */, 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */, + 179DBDDC00B68411AA28941F /* NewsBlurFolderChange.swift */, ); path = Models; sourceTree = ""; @@ -1159,6 +1162,7 @@ 179DBED55C9B4D6A413486C1 /* NewsBlurStoryHash.swift in Sources */, 179DB0B17A6C51B95ABC1741 /* NewsBlurStoryStatusChange.swift in Sources */, 179DB96B984E67DC101E470D /* NewsBlurAccountDelegate+Private.swift in Sources */, + 179DBD4ECC1C9712DF51DB8C /* NewsBlurFolderChange.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift new file mode 100644 index 000000000..f894fc4b9 --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift @@ -0,0 +1,27 @@ +// +// NewsBlurFolderChange.swift +// Account +// +// Created by Anh Quang Do on 2020-03-14. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +enum NewsBlurFolderChange { + case add(String) +} + +extension NewsBlurFolderChange { + var asData: Data? { + var postData = URLComponents() + postData.queryItems = { + switch self { + case .add(let name): + return [URLQueryItem(name: "folder", value: name)] + } + }() + + return postData.percentEncodedQuery?.data(using: .utf8) + } +} diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index 2d8d43cc9..4e752e804 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -11,12 +11,15 @@ import RSWeb enum NewsBlurError: LocalizedError { case general(message: String) + case invalidParameter case unknown var errorDescription: String? { switch self { case .general(let message): return message + case .invalidParameter: + return "There was an invalid parameter passed" case .unknown: return "An unknown error occurred" } @@ -65,13 +68,13 @@ final class NewsBlurAPICaller: NSObject { if let message = error?.first { completion(.failure(NewsBlurError.general(message: message))) } else { - completion(.failure(NewsBlurError.general(message: "Failed to log in"))) + completion(.failure(NewsBlurError.unknown)) } return } guard let username = self.credentials?.username else { - completion(.failure(NewsBlurError.general(message: "Failed to log in"))) + completion(.failure(NewsBlurError.unknown)) return } @@ -189,6 +192,26 @@ final class NewsBlurAPICaller: NSObject { func unstar(hashes: [String], completion: @escaping (Result) -> Void) { sendStoryStatus(endpoint: "reader/mark_story_hash_as_unstarred", hashes: hashes, completion: completion) } + + func addFolder(named name: String, completion: @escaping (Result) -> Void) { + let callURL = baseURL.appendingPathComponent("reader/add_folder") + + var request = URLRequest(url: callURL, credentials: credentials) + request.httpBody = NewsBlurFolderChange.add(name).asData + transport.send(request: request, method: HTTPMethod.post) { result in + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } + + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } } extension NewsBlurAPICaller { diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 3e957e965..7cb1bb741 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -309,6 +309,22 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func addFolder(for account: Account, name: String, completion: @escaping (Result) -> ()) { + self.refreshProgress.addToNumberOfTasksAndRemaining(1) + + caller.addFolder(named: name) { result in + self.refreshProgress.completeTask() + + switch result { + case .success(): + if let folder = account.ensureFolder(with: name) { + completion(.success(folder)) + } else { + completion(.failure(NewsBlurError.invalidParameter)) + } + case .failure(let error): + completion(.failure(error)) + } + } } func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> ()) { From e6659eea10aaf5719c550ef1ac7bebdb531408f0 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 14 Mar 2020 20:42:45 -0400 Subject: [PATCH 37/51] Rename and remove folder --- .../Models/NewsBlurFolderChange.swift | 15 ++++++- .../Models/NewsBlurStoryStatusChange.swift | 2 +- .../Account/NewsBlur/NewsBlurAPICaller.swift | 38 ++++++++--------- .../NewsBlur/NewsBlurAccountDelegate.swift | 42 ++++++++++++++++++- 4 files changed, 72 insertions(+), 25 deletions(-) diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift index f894fc4b9..cc38615a7 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift @@ -10,15 +10,28 @@ import Foundation enum NewsBlurFolderChange { case add(String) + case rename(String, String) + case delete(String) } -extension NewsBlurFolderChange { +extension NewsBlurFolderChange: NewsBlurDataConvertible { var asData: Data? { var postData = URLComponents() postData.queryItems = { switch self { case .add(let name): return [URLQueryItem(name: "folder", value: name)] + case .rename(let from, let to): + return [ + URLQueryItem(name: "folder_to_rename", value: from), + URLQueryItem(name: "new_folder_name", value: to), + URLQueryItem(name: "in_folder", value: ""), // root folder + ] + case .delete(let name): + return [ + URLQueryItem(name: "folder_to_delete", value: name), + URLQueryItem(name: "in_folder", value: ""), // root folder + ] } }() diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurStoryStatusChange.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurStoryStatusChange.swift index 464fe1fdb..c07fd9220 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurStoryStatusChange.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurStoryStatusChange.swift @@ -12,7 +12,7 @@ struct NewsBlurStoryStatusChange { let hashes: [String] } -extension NewsBlurStoryStatusChange { +extension NewsBlurStoryStatusChange: NewsBlurDataConvertible { var asData: Data? { var postData = URLComponents() postData.queryItems = hashes.map { URLQueryItem(name: "story_hash", value: $0) } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index 4e752e804..e42f4ce0e 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -9,6 +9,10 @@ import Foundation import RSWeb +protocol NewsBlurDataConvertible { + var asData: Data? { get } +} + enum NewsBlurError: LocalizedError { case general(message: String) case invalidParameter @@ -178,39 +182,31 @@ final class NewsBlurAPICaller: NSObject { } func markAsUnread(hashes: [String], completion: @escaping (Result) -> Void) { - sendStoryStatus(endpoint: "reader/mark_story_hash_as_unread", hashes: hashes, completion: completion) + sendUpdates(endpoint: "reader/mark_story_hash_as_unread", payload: NewsBlurStoryStatusChange(hashes: hashes), completion: completion) } func markAsRead(hashes: [String], completion: @escaping (Result) -> Void) { - sendStoryStatus(endpoint: "reader/mark_story_hashes_as_read", hashes: hashes, completion: completion) + sendUpdates(endpoint: "reader/mark_story_hashes_as_read", payload: NewsBlurStoryStatusChange(hashes: hashes), completion: completion) } func star(hashes: [String], completion: @escaping (Result) -> Void) { - sendStoryStatus(endpoint: "reader/mark_story_hash_as_starred", hashes: hashes, completion: completion) + sendUpdates(endpoint: "reader/mark_story_hash_as_starred", payload: NewsBlurStoryStatusChange(hashes: hashes), completion: completion) } func unstar(hashes: [String], completion: @escaping (Result) -> Void) { - sendStoryStatus(endpoint: "reader/mark_story_hash_as_unstarred", hashes: hashes, completion: completion) + sendUpdates(endpoint: "reader/mark_story_hash_as_unstarred", payload: NewsBlurStoryStatusChange(hashes: hashes), completion: completion) } func addFolder(named name: String, completion: @escaping (Result) -> Void) { - let callURL = baseURL.appendingPathComponent("reader/add_folder") + sendUpdates(endpoint: "reader/add_folder", payload: NewsBlurFolderChange.add(name), completion: completion) + } - var request = URLRequest(url: callURL, credentials: credentials) - request.httpBody = NewsBlurFolderChange.add(name).asData - transport.send(request: request, method: HTTPMethod.post) { result in - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } + func renameFolder(with folder: String, to name: String, completion: @escaping (Result) -> Void) { + sendUpdates(endpoint: "reader/rename_folder", payload: NewsBlurFolderChange.rename(folder, name), completion: completion) + } - switch result { - case .success: - completion(.success(())) - case .failure(let error): - completion(.failure(error)) - } - } + func removeFolder(named name: String, completion: @escaping (Result) -> Void) { + sendUpdates(endpoint: "reader/delete_folder", payload: NewsBlurFolderChange.delete(name), completion: completion) } } @@ -244,11 +240,11 @@ extension NewsBlurAPICaller { } } - private func sendStoryStatus(endpoint: String, hashes: [String], completion: @escaping (Result) -> Void) { + private func sendUpdates(endpoint: String, payload: NewsBlurDataConvertible, completion: @escaping (Result) -> Void) { let callURL = baseURL.appendingPathComponent(endpoint) var request = URLRequest(url: callURL, credentials: credentials) - request.httpBody = NewsBlurStoryStatusChange(hashes: hashes).asData + request.httpBody = payload.asData transport.send(request: request, method: HTTPMethod.post) { result in if self.suspended { completion(.failure(TransportError.suspended)) diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 7cb1bb741..c0de947af 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -328,11 +328,49 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> ()) { - completion(.success(())) + guard let folderToRename = folder.name else { + completion(.failure(NewsBlurError.invalidParameter)) + return + } + + refreshProgress.addToNumberOfTasksAndRemaining(1) + + let nameBefore = folder.name + + caller.renameFolder(with: folderToRename, to: name) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + completion(.success(())) + case .failure(let error): + folder.name = nameBefore + completion(.failure(error)) + } + } + + folder.name = name } func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> ()) { - completion(.success(())) + guard let folderToRemove = folder.name else { + completion(.failure(NewsBlurError.invalidParameter)) + return + } + + refreshProgress.addToNumberOfTasksAndRemaining(1) + + caller.removeFolder(named: folderToRemove) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + account.removeFolder(folder) + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } } func createWebFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result) -> ()) { From ec855364bc7b16e049b32625aefb75997e5fda4e Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 14 Mar 2020 21:09:42 -0400 Subject: [PATCH 38/51] Remove feeds in folder --- .../NewsBlur/Models/NewsBlurFolderChange.swift | 10 +++++++--- Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift | 4 ++-- .../Account/NewsBlur/NewsBlurAccountDelegate.swift | 11 ++++++++++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift index cc38615a7..e122cce07 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift @@ -11,7 +11,7 @@ import Foundation enum NewsBlurFolderChange { case add(String) case rename(String, String) - case delete(String) + case delete(String, [String]) } extension NewsBlurFolderChange: NewsBlurDataConvertible { @@ -27,11 +27,15 @@ extension NewsBlurFolderChange: NewsBlurDataConvertible { URLQueryItem(name: "new_folder_name", value: to), URLQueryItem(name: "in_folder", value: ""), // root folder ] - case .delete(let name): - return [ + case .delete(let name, let feedIDs): + var queryItems = [ URLQueryItem(name: "folder_to_delete", value: name), URLQueryItem(name: "in_folder", value: ""), // root folder ] + queryItems.append(contentsOf: feedIDs.map { id in + URLQueryItem(name: "feed_id", value: id) + }) + return queryItems } }() diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index e42f4ce0e..1eb938f85 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -205,8 +205,8 @@ final class NewsBlurAPICaller: NSObject { sendUpdates(endpoint: "reader/rename_folder", payload: NewsBlurFolderChange.rename(folder, name), completion: completion) } - func removeFolder(named name: String, completion: @escaping (Result) -> Void) { - sendUpdates(endpoint: "reader/delete_folder", payload: NewsBlurFolderChange.delete(name), completion: completion) + func removeFolder(named name: String, feedIDs: [String], completion: @escaping (Result) -> Void) { + sendUpdates(endpoint: "reader/delete_folder", payload: NewsBlurFolderChange.delete(name, feedIDs), completion: completion) } } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index c0de947af..6dd3d9f28 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -358,9 +358,18 @@ final class NewsBlurAccountDelegate: AccountDelegate { return } + var feedIDs: [String] = [] + for feed in folder.topLevelWebFeeds { + if feed.folderRelationship?.count ?? 0 > 1 { + clearFolderRelationship(for: feed, withFolderName: folderToRemove) + } else if let subscriptionID = feed.subscriptionID { + feedIDs.append(subscriptionID) + } + } + refreshProgress.addToNumberOfTasksAndRemaining(1) - caller.removeFolder(named: folderToRemove) { result in + caller.removeFolder(named: folderToRemove, feedIDs: feedIDs) { result in self.refreshProgress.completeTask() switch result { From 70302a425c3486842b21c438d00b5dc94a4b7829 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 21 Mar 2020 17:16:35 -0400 Subject: [PATCH 39/51] Refactoring, fix folder changes not working, add new web feed --- .../Account/Account.xcodeproj/project.pbxproj | 24 +- .../Credentials/URLRequest+RSWeb.swift | 2 +- .../NewsBlurAPICaller+Internal.swift | 236 +++++++++++++++ .../NewsBlurAccountDelegate+Internal.swift} | 110 ++++++- .../NewsBlur/Models/NewsBlurFeed.swift | 49 +-- .../NewsBlur/Models/NewsBlurFeedChange.swift | 30 ++ .../Models/NewsBlurFolderChange.swift | 5 +- .../Account/NewsBlur/NewsBlurAPICaller.swift | 279 ++++++++---------- .../NewsBlur/NewsBlurAccountDelegate.swift | 83 +++++- 9 files changed, 619 insertions(+), 199 deletions(-) create mode 100644 Frameworks/Account/NewsBlur/Internals/NewsBlurAPICaller+Internal.swift rename Frameworks/Account/NewsBlur/{NewsBlurAccountDelegate+Private.swift => Internals/NewsBlurAccountDelegate+Internal.swift} (78%) create mode 100644 Frameworks/Account/NewsBlur/Models/NewsBlurFeedChange.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 7f96e2abd..5a546215d 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -10,9 +10,11 @@ 179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB7399814F6FB3247825C /* NewsBlurStory.swift */; }; 179DB0B17A6C51B95ABC1741 /* NewsBlurStoryStatusChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */; }; 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */; }; + 179DB3A93E3205EF29C2AF62 /* NewsBlurAPICaller+Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBBF346CF712AB2F0E9E6 /* NewsBlurAPICaller+Internal.swift */; }; 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */; }; - 179DB96B984E67DC101E470D /* NewsBlurAccountDelegate+Private.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB55DC2CAD332D4376416 /* NewsBlurAccountDelegate+Private.swift */; }; + 179DBCB4B11C88EBE852A015 /* NewsBlurFeedChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB3CBADAFCF5377DA3D02 /* NewsBlurFeedChange.swift */; }; 179DBD4ECC1C9712DF51DB8C /* NewsBlurFolderChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBDDC00B68411AA28941F /* NewsBlurFolderChange.swift */; }; + 179DBE829FDF48E102F73244 /* NewsBlurAccountDelegate+Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB78C47256A122A281942 /* NewsBlurAccountDelegate+Internal.swift */; }; 179DBED55C9B4D6A413486C1 /* NewsBlurStoryHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */; }; 179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */; }; 3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A33E6238D3D6800314204 /* Secrets.swift */; }; @@ -232,11 +234,13 @@ /* Begin PBXFileReference section */ 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurLoginResponse.swift; sourceTree = ""; }; 179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurFeed.swift; sourceTree = ""; }; - 179DB55DC2CAD332D4376416 /* NewsBlurAccountDelegate+Private.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NewsBlurAccountDelegate+Private.swift"; sourceTree = ""; }; + 179DB3CBADAFCF5377DA3D02 /* NewsBlurFeedChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurFeedChange.swift; sourceTree = ""; }; 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStoryStatusChange.swift; sourceTree = ""; }; 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurGenericCodingKeys.swift; sourceTree = ""; }; 179DB7399814F6FB3247825C /* NewsBlurStory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStory.swift; sourceTree = ""; }; + 179DB78C47256A122A281942 /* NewsBlurAccountDelegate+Internal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NewsBlurAccountDelegate+Internal.swift"; sourceTree = ""; }; 179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStoryHash.swift; sourceTree = ""; }; + 179DBBF346CF712AB2F0E9E6 /* NewsBlurAPICaller+Internal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NewsBlurAPICaller+Internal.swift"; sourceTree = ""; }; 179DBDDC00B68411AA28941F /* NewsBlurFolderChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurFolderChange.swift; sourceTree = ""; }; 3B3A33E6238D3D6800314204 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Secrets.swift; path = ../../Shared/Secrets.swift; sourceTree = ""; }; 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = ""; }; @@ -455,6 +459,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 179DB1571B95BAD0F833AF6D /* Internals */ = { + isa = PBXGroup; + children = ( + 179DB78C47256A122A281942 /* NewsBlurAccountDelegate+Internal.swift */, + 179DBBF346CF712AB2F0E9E6 /* NewsBlurAPICaller+Internal.swift */, + ); + path = Internals; + sourceTree = ""; + }; 179DBD810D353D9CED7C3BED /* Models */ = { isa = PBXGroup; children = ( @@ -465,6 +478,7 @@ 179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */, 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */, 179DBDDC00B68411AA28941F /* NewsBlurFolderChange.swift */, + 179DB3CBADAFCF5377DA3D02 /* NewsBlurFeedChange.swift */, ); path = Models; sourceTree = ""; @@ -563,7 +577,7 @@ 769F2A8DF190549E24B5D110 /* NewsBlurAccountDelegate.swift */, 769F275FD5D942502C5B4716 /* NewsBlurAPICaller.swift */, 179DBD810D353D9CED7C3BED /* Models */, - 179DB55DC2CAD332D4376416 /* NewsBlurAccountDelegate+Private.swift */, + 179DB1571B95BAD0F833AF6D /* Internals */, ); path = NewsBlur; sourceTree = ""; @@ -1161,8 +1175,10 @@ 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */, 179DBED55C9B4D6A413486C1 /* NewsBlurStoryHash.swift in Sources */, 179DB0B17A6C51B95ABC1741 /* NewsBlurStoryStatusChange.swift in Sources */, - 179DB96B984E67DC101E470D /* NewsBlurAccountDelegate+Private.swift in Sources */, 179DBD4ECC1C9712DF51DB8C /* NewsBlurFolderChange.swift in Sources */, + 179DBCB4B11C88EBE852A015 /* NewsBlurFeedChange.swift in Sources */, + 179DBE829FDF48E102F73244 /* NewsBlurAccountDelegate+Internal.swift in Sources */, + 179DB3A93E3205EF29C2AF62 /* NewsBlurAPICaller+Internal.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift index b0cca5a23..f82f3de02 100755 --- a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift +++ b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift @@ -34,7 +34,7 @@ public extension URLRequest { case .feedWranglerToken: self.url = url.appendingQueryItem(URLQueryItem(name: "access_token", value: credentials.secret)) case .newsBlurBasic: - setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType) httpMethod = "POST" var postData = URLComponents() postData.queryItems = [ diff --git a/Frameworks/Account/NewsBlur/Internals/NewsBlurAPICaller+Internal.swift b/Frameworks/Account/NewsBlur/Internals/NewsBlurAPICaller+Internal.swift new file mode 100644 index 000000000..078bb1d36 --- /dev/null +++ b/Frameworks/Account/NewsBlur/Internals/NewsBlurAPICaller+Internal.swift @@ -0,0 +1,236 @@ +// +// NewsBlurAPICaller+Internal.swift +// Account +// +// Created by Anh Quang Do on 2020-03-21. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSWeb + +protocol NewsBlurDataConvertible { + var asData: Data? { get } +} + +enum NewsBlurError: LocalizedError { + case general(message: String) + case invalidParameter + case unknown + + var errorDescription: String? { + switch self { + case .general(let message): + return message + case .invalidParameter: + return "There was an invalid parameter passed" + case .unknown: + return "An unknown error occurred" + } + } +} + +// MARK: - Interact with endpoints + +extension NewsBlurAPICaller { + // GET endpoint, discard response + func requestData( + endpoint: String, + completion: @escaping (Result) -> Void + ) { + let callURL = baseURL.appendingPathComponent(endpoint) + + requestData(callURL: callURL, completion: completion) + } + + // GET endpoint + func requestData( + endpoint: String, + resultType: R.Type, + dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, + keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, + completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void + ) { + let callURL = baseURL.appendingPathComponent(endpoint) + + requestData( + callURL: callURL, + resultType: resultType, + dateDecoding: dateDecoding, + keyDecoding: keyDecoding, + completion: completion + ) + } + + // POST to endpoint, discard response + func sendUpdates( + endpoint: String, + payload: NewsBlurDataConvertible, + completion: @escaping (Result) -> Void + ) { + let callURL = baseURL.appendingPathComponent(endpoint) + + sendUpdates(callURL: callURL, payload: payload, completion: completion) + } + + // POST to endpoint + func sendUpdates( + endpoint: String, + payload: NewsBlurDataConvertible, + resultType: R.Type, + dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, + keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, + completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void + ) { + let callURL = baseURL.appendingPathComponent(endpoint) + + sendUpdates( + callURL: callURL, + payload: payload, + resultType: resultType, + dateDecoding: dateDecoding, + keyDecoding: keyDecoding, + completion: completion + ) + } +} + +// MARK: - Interact with URLs + +extension NewsBlurAPICaller { + // GET URL with params, discard response + func requestData( + callURL: URL?, + completion: @escaping (Result) -> Void + ) { + guard let callURL = callURL else { + completion(.failure(TransportError.noURL)) + return + } + + let request = URLRequest(url: callURL, credentials: credentials) + + transport.send(request: request) { result in + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } + + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } + + // GET URL with params + func requestData( + callURL: URL?, + resultType: R.Type, + dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, + keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, + completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void + ) { + guard let callURL = callURL else { + completion(.failure(TransportError.noURL)) + return + } + + let request = URLRequest(url: callURL, credentials: credentials) + + transport.send( + request: request, + resultType: resultType, + dateDecoding: dateDecoding, + keyDecoding: keyDecoding + ) { result in + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } + + switch result { + case .success(let response): + completion(.success(response)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + // POST to URL with params, discard response + func sendUpdates( + callURL: URL?, + payload: NewsBlurDataConvertible, + completion: @escaping (Result) -> Void + ) { + guard let callURL = callURL else { + completion(.failure(TransportError.noURL)) + return + } + + var request = URLRequest(url: callURL, credentials: credentials) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType) + request.httpBody = payload.asData + + transport.send(request: request, method: HTTPMethod.post) { result in + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } + + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } + + // POST to URL with params + func sendUpdates( + callURL: URL?, + payload: NewsBlurDataConvertible, + resultType: R.Type, + dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, + keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, + completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void + ) { + guard let callURL = callURL else { + completion(.failure(TransportError.noURL)) + return + } + + guard let data = payload.asData else { + completion(.failure(NewsBlurError.invalidParameter)) + return + } + + var request = URLRequest(url: callURL, credentials: credentials) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType) + + transport.send( + request: request, + method: HTTPMethod.post, + data: data, + resultType: resultType, + dateDecoding: dateDecoding, + keyDecoding: keyDecoding + ) { result in + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } + + switch result { + case .success(let response): + completion(.success(response)) + case .failure(let error): + completion(.failure(error)) + } + } + } +} diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate+Private.swift b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift similarity index 78% rename from Frameworks/Account/NewsBlur/NewsBlurAccountDelegate+Private.swift rename to Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift index 51a8e6fc7..698d1d3c2 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate+Private.swift +++ b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift @@ -1,5 +1,5 @@ // -// NewsBlurAccountDelegate+Private.swift +// NewsBlurAccountDelegate+Internal.swift // Mostly adapted from FeedbinAccountDelegate.swift // Account // @@ -107,7 +107,7 @@ extension NewsBlurAccountDelegate { webFeed.name = feed.name // If the name has been changed on the server remove the locally edited name webFeed.editedName = nil - webFeed.homePageURL = feed.homepageURL + webFeed.homePageURL = feed.homePageURL webFeed.subscriptionID = String(feed.feedID) webFeed.faviconURL = feed.faviconURL } @@ -118,7 +118,7 @@ extension NewsBlurAccountDelegate { // Actually add feeds all in one go, so we don’t trigger various rebuilding things that Account does. feedsToAdd.forEach { feed in - let webFeed = account.createWebFeed(with: feed.name, url: feed.feedURL, webFeedID: String(feed.feedID), homePageURL: feed.homepageURL) + let webFeed = account.createWebFeed(with: feed.name, url: feed.feedURL, webFeedID: String(feed.feedID), homePageURL: feed.homePageURL) webFeed.subscriptionID = String(feed.feedID) account.addWebFeed(webFeed) } @@ -232,10 +232,10 @@ extension NewsBlurAccountDelegate { caller.retrieveStories(hashes: hashesToFetch) { result in switch result { case .success(let stories): - self.processStories(account: account, stories: stories) { error in + self.processStories(account: account, stories: stories) { result in self.refreshProgress.completeTask() - if let error = error { + if case .failure(let error) = result { completion(.failure(error)) return } @@ -371,4 +371,104 @@ extension NewsBlurAccountDelegate { } } } + + func createFeed(account: Account, feed: NewsBlurFeed?, name: String?, container: Container, completion: @escaping (Result) -> Void) { + guard let feed = feed else { + completion(.failure(NewsBlurError.invalidParameter)) + return + } + + DispatchQueue.main.async { + let webFeed = account.createWebFeed(with: feed.name, url: feed.feedURL, webFeedID: String(feed.feedID), homePageURL: feed.homePageURL) + webFeed.subscriptionID = String(feed.feedID) + webFeed.faviconURL = feed.faviconURL + + account.addWebFeed(webFeed, to: container) { result in + switch result { + case .success: + if let name = name { + account.renameWebFeed(webFeed, to: name) { result in + switch result { + case .success: + self.initialFeedDownload(account: account, feed: webFeed, completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + } else { + self.initialFeedDownload(account: account, feed: webFeed, completion: completion) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + } + + func downloadFeed(account: Account, feed: WebFeed, page: Int, completion: @escaping (Result) -> Void) { + refreshProgress.addToNumberOfTasksAndRemaining(1) + + caller.retrieveStories(feedID: feed.webFeedID, page: page) { result in + switch result { + case .success(let stories): + // No more stories + guard let stories = stories, stories.count > 0 else { + self.refreshProgress.completeTask() + + completion(.success(())) + return + } + + let since = Calendar.current.date(byAdding: .month, value: -3, to: Date()) + self.processStories(account: account, stories: stories, since: since) { result in + self.refreshProgress.completeTask() + + if case .failure(let error) = result { + completion(.failure(error)) + return + } + + // No more recent stories + if case .success(let hasStories) = result, !hasStories { + completion(.success(())) + return + } + + self.downloadFeed(account: account, feed: feed, page: page + 1, completion: completion) + } + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func initialFeedDownload(account: Account, feed: WebFeed, completion: @escaping (Result) -> Void) { + refreshProgress.addToNumberOfTasksAndRemaining(1) + + // Download the initial articles + downloadFeed(account: account, feed: feed, page: 1) { result in + self.refreshArticleStatus(for: account) { result in + switch result { + case .success: + self.refreshMissingStories(for: account) { result in + switch result { + case .success: + self.refreshProgress.completeTask() + + DispatchQueue.main.async { + completion(.success(feed)) + } + + case .failure(let error): + completion(.failure(error)) + } + } + + case .failure(let error): + completion(.failure(error)) + } + } + } + } } diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift index a2641136a..ad3fb5497 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift @@ -10,20 +10,19 @@ import Foundation import RSCore import RSParser -typealias NewsBlurFeed = NewsBlurFeedsResponse.Feed typealias NewsBlurFolder = NewsBlurFeedsResponse.Folder -struct NewsBlurFeedsResponse: Decodable { - let feeds: [Feed] - let folders: [Folder] +struct NewsBlurFeed: Hashable, Codable { + let name: String + let feedID: Int + let feedURL: String + let homePageURL: String? + let faviconURL: String? +} - struct Feed: Hashable, Codable { - let name: String - let feedID: Int - let feedURL: String - let homepageURL: String? - let faviconURL: String? - } +struct NewsBlurFeedsResponse: Decodable { + let feeds: [NewsBlurFeed] + let folders: [Folder] struct Folder: Hashable, Codable { let name: String @@ -31,11 +30,25 @@ struct NewsBlurFeedsResponse: Decodable { } } +struct NewsBlurAddURLResponse: Decodable { + let feed: NewsBlurFeed? +} + struct NewsBlurFolderRelationship: Codable { let folderName: String let feedID: Int } +extension NewsBlurFeed { + private enum CodingKeys: String, CodingKey { + case name = "feed_title" + case feedID = "id" + case feedURL = "feed_address" + case homePageURL = "feed_link" + case faviconURL = "favicon_url" + } +} + extension NewsBlurFeedsResponse { private enum CodingKeys: String, CodingKey { case feeds = "feeds" @@ -47,10 +60,10 @@ extension NewsBlurFeedsResponse { let container = try decoder.container(keyedBy: CodingKeys.self) // Parse feeds - var feeds: [Feed] = [] + var feeds: [NewsBlurFeed] = [] let feedContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .feeds) try feedContainer.allKeys.forEach { key in - let subscription = try feedContainer.decode(Feed.self, forKey: key) + let subscription = try feedContainer.decode(NewsBlurFeed.self, forKey: key) feeds.append(subscription) } @@ -71,16 +84,6 @@ extension NewsBlurFeedsResponse { } } -extension NewsBlurFeedsResponse.Feed { - private enum CodingKeys: String, CodingKey { - case name = "feed_title" - case feedID = "id" - case feedURL = "feed_address" - case homepageURL = "feed_link" - case faviconURL = "favicon_url" - } -} - extension NewsBlurFeedsResponse.Folder { var asRelationships: [NewsBlurFolderRelationship] { return feedIDs.map { NewsBlurFolderRelationship(folderName: name, feedID: $0) } diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurFeedChange.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFeedChange.swift new file mode 100644 index 000000000..e3082fe3d --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFeedChange.swift @@ -0,0 +1,30 @@ +// +// NewsBlurFeedChange.swift +// Account +// +// Created by Anh Quang Do on 2020-03-14. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +enum NewsBlurFeedChange { + case add(String) +} + +extension NewsBlurFeedChange: NewsBlurDataConvertible { + var asData: Data? { + var postData = URLComponents() + postData.queryItems = { + switch self { + case .add(let url): + return [ + URLQueryItem(name: "url", value: url), + URLQueryItem(name: "folder", value: ""), // root folder + ] + } + }() + + return postData.percentEncodedQuery?.data(using: .utf8) + } +} diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift index e122cce07..ed2df52cd 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift @@ -20,7 +20,10 @@ extension NewsBlurFolderChange: NewsBlurDataConvertible { postData.queryItems = { switch self { case .add(let name): - return [URLQueryItem(name: "folder", value: name)] + return [ + URLQueryItem(name: "folder", value: name), + URLQueryItem(name: "parent_folder", value: ""), // root folder + ] case .rename(let from, let to): return [ URLQueryItem(name: "folder_to_rename", value: from), diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index 1eb938f85..fc58042e3 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -9,33 +9,12 @@ import Foundation import RSWeb -protocol NewsBlurDataConvertible { - var asData: Data? { get } -} - -enum NewsBlurError: LocalizedError { - case general(message: String) - case invalidParameter - case unknown - - var errorDescription: String? { - switch self { - case .general(let message): - return message - case .invalidParameter: - return "There was an invalid parameter passed" - case .unknown: - return "An unknown error occurred" - } - } -} - final class NewsBlurAPICaller: NSObject { static let SessionIdCookie = "newsblur_sessionid" - private let baseURL = URL(string: "https://www.newsblur.com/")! - private var transport: Transport! - private var suspended = false + let baseURL = URL(string: "https://www.newsblur.com/")! + var transport: Transport! + var suspended = false var credentials: Credentials? weak var accountMetadata: AccountMetadata? @@ -56,15 +35,7 @@ final class NewsBlurAPICaller: NSObject { } func validateCredentials(completion: @escaping (Result) -> Void) { - let url = baseURL.appendingPathComponent("api/login") - let request = URLRequest(url: url, credentials: credentials) - - transport.send(request: request, resultType: NewsBlurLoginResponse.self) { result in - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } - + requestData(endpoint: "api/login", resultType: NewsBlurLoginResponse.self) { result in switch result { case .success(let response, let payload): guard let url = response.url, let headerFields = response.allHeaderFields as? [String: String], payload?.code != -1 else { @@ -97,22 +68,7 @@ final class NewsBlurAPICaller: NSObject { } func logout(completion: @escaping (Result) -> Void) { - let url = baseURL.appendingPathComponent("api/logout") - let request = URLRequest(url: url, credentials: credentials) - - transport.send(request: request) { result in - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } - - switch result { - case .success: - completion(.success(())) - case .failure(let error): - completion(.failure(error)) - } - } + requestData(endpoint: "api/logout", completion: completion) } func retrieveFeeds(completion: @escaping (Result<([NewsBlurFeed]?, [NewsBlurFolder]?), Error>) -> Void) { @@ -123,18 +79,7 @@ final class NewsBlurAPICaller: NSObject { URLQueryItem(name: "update_counts", value: "false"), ]) - guard let callURL = url else { - completion(.failure(TransportError.noURL)) - return - } - - let request = URLRequest(url: callURL, credentials: credentials) - transport.send(request: request, resultType: NewsBlurFeedsResponse.self) { result in - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } - + requestData(callURL: url, resultType: NewsBlurFeedsResponse.self) { result in switch result { case .success((_, let payload)): completion(.success((payload?.feeds, payload?.folders))) @@ -144,92 +89,18 @@ final class NewsBlurAPICaller: NSObject { } } - func retrieveUnreadStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { - retrieveStoryHashes(endpoint: "reader/unread_story_hashes", completion: completion) - } - - func retrieveStarredStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { - retrieveStoryHashes(endpoint: "reader/starred_story_hashes", completion: completion) - } - - func retrieveStories(hashes: [NewsBlurStoryHash], completion: @escaping (Result<[NewsBlurStory]?, Error>) -> Void) { - let url = baseURL - .appendingPathComponent("reader/river_stories") - .appendingQueryItem(.init(name: "include_hidden", value: "true"))? - .appendingQueryItems(hashes.map { - URLQueryItem(name: "h", value: $0.hash) - }) - - guard let callURL = url else { - completion(.failure(TransportError.noURL)) - return - } - - let request = URLRequest(url: callURL, credentials: credentials) - transport.send(request: request, resultType: NewsBlurStoriesResponse.self) { result in - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } - - switch result { - case .success((_, let payload)): - completion(.success(payload?.stories)) - case .failure(let error): - completion(.failure(error)) - } - } - } - - func markAsUnread(hashes: [String], completion: @escaping (Result) -> Void) { - sendUpdates(endpoint: "reader/mark_story_hash_as_unread", payload: NewsBlurStoryStatusChange(hashes: hashes), completion: completion) - } - - func markAsRead(hashes: [String], completion: @escaping (Result) -> Void) { - sendUpdates(endpoint: "reader/mark_story_hashes_as_read", payload: NewsBlurStoryStatusChange(hashes: hashes), completion: completion) - } - - func star(hashes: [String], completion: @escaping (Result) -> Void) { - sendUpdates(endpoint: "reader/mark_story_hash_as_starred", payload: NewsBlurStoryStatusChange(hashes: hashes), completion: completion) - } - - func unstar(hashes: [String], completion: @escaping (Result) -> Void) { - sendUpdates(endpoint: "reader/mark_story_hash_as_unstarred", payload: NewsBlurStoryStatusChange(hashes: hashes), completion: completion) - } - - func addFolder(named name: String, completion: @escaping (Result) -> Void) { - sendUpdates(endpoint: "reader/add_folder", payload: NewsBlurFolderChange.add(name), completion: completion) - } - - func renameFolder(with folder: String, to name: String, completion: @escaping (Result) -> Void) { - sendUpdates(endpoint: "reader/rename_folder", payload: NewsBlurFolderChange.rename(folder, name), completion: completion) - } - - func removeFolder(named name: String, feedIDs: [String], completion: @escaping (Result) -> Void) { - sendUpdates(endpoint: "reader/delete_folder", payload: NewsBlurFolderChange.delete(name, feedIDs), completion: completion) - } -} - -extension NewsBlurAPICaller { - private func retrieveStoryHashes(endpoint: String, completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { + func retrieveStoryHashes(endpoint: String, completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { let url = baseURL .appendingPathComponent(endpoint) .appendingQueryItems([ URLQueryItem(name: "include_timestamps", value: "true"), ]) - guard let callURL = url else { - completion(.failure(TransportError.noURL)) - return - } - - let request = URLRequest(url: callURL, credentials: credentials) - transport.send(request: request, resultType: NewsBlurStoryHashesResponse.self, dateDecoding: .secondsSince1970) { result in - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } - + requestData( + callURL: url, + resultType: NewsBlurStoryHashesResponse.self, + dateDecoding: .secondsSince1970 + ) { result in switch result { case .success((_, let payload)): let hashes = payload?.unread ?? payload?.starred @@ -240,20 +111,124 @@ extension NewsBlurAPICaller { } } - private func sendUpdates(endpoint: String, payload: NewsBlurDataConvertible, completion: @escaping (Result) -> Void) { - let callURL = baseURL.appendingPathComponent(endpoint) + func retrieveUnreadStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { + retrieveStoryHashes( + endpoint: "reader/unread_story_hashes", + completion: completion + ) + } - var request = URLRequest(url: callURL, credentials: credentials) - request.httpBody = payload.asData - transport.send(request: request, method: HTTPMethod.post) { result in - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } + func retrieveStarredStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { + retrieveStoryHashes( + endpoint: "reader/starred_story_hashes", + completion: completion + ) + } + func retrieveStories(feedID: String, page: Int, completion: @escaping (Result<[NewsBlurStory]?, Error>) -> Void) { + let url = baseURL + .appendingPathComponent("reader/feed/\(feedID)") + .appendingQueryItems([ + URLQueryItem(name: "page", value: String(page)), + URLQueryItem(name: "order", value: "newest"), + URLQueryItem(name: "read_filter", value: "all"), + URLQueryItem(name: "include_hidden", value: "true"), + URLQueryItem(name: "include_story_content", value: "true"), + ]) + + requestData(callURL: url, resultType: NewsBlurStoriesResponse.self) { result in switch result { - case .success: - completion(.success(())) + case .success((_, let payload)): + completion(.success(payload?.stories)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func retrieveStories(hashes: [NewsBlurStoryHash], completion: @escaping (Result<[NewsBlurStory]?, Error>) -> Void) { + let url = baseURL + .appendingPathComponent("reader/river_stories") + .appendingQueryItem(.init(name: "include_hidden", value: "true"))? + .appendingQueryItems(hashes.map { + URLQueryItem(name: "h", value: $0.hash) + }) + + requestData(callURL: url, resultType: NewsBlurStoriesResponse.self) { result in + switch result { + case .success((_, let payload)): + completion(.success(payload?.stories)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func markAsUnread(hashes: [String], completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/mark_story_hash_as_unread", + payload: NewsBlurStoryStatusChange(hashes: hashes), + completion: completion + ) + } + + func markAsRead(hashes: [String], completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/mark_story_hashes_as_read", + payload: NewsBlurStoryStatusChange(hashes: hashes), + completion: completion + ) + } + + func star(hashes: [String], completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/mark_story_hash_as_starred", + payload: NewsBlurStoryStatusChange(hashes: hashes), + completion: completion + ) + } + + func unstar(hashes: [String], completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/mark_story_hash_as_unstarred", + payload: NewsBlurStoryStatusChange(hashes: hashes), + completion: completion + ) + } + + func addFolder(named name: String, completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/add_folder", + payload: NewsBlurFolderChange.add(name), + completion: completion + ) + } + + func renameFolder(with folder: String, to name: String, completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/rename_folder", + payload: NewsBlurFolderChange.rename(folder, name), + completion: completion + ) + } + + func removeFolder(named name: String, feedIDs: [String], completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/delete_folder", + payload: NewsBlurFolderChange.delete(name, feedIDs), + completion: completion + ) + } + + func addURL(_ url: String, completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/add_url", + payload: NewsBlurFeedChange.add(url), + resultType: NewsBlurAddURLResponse.self + ) { result in + switch result { + case .success(_, let payload): + completion(.success(payload?.feed)) case .failure(let error): completion(.failure(error)) } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 6dd3d9f28..96bfebd4e 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -125,10 +125,18 @@ final class NewsBlurAccountDelegate: AccountDelegate { database.selectForProcessing { result in func processStatuses(_ syncStatuses: [SyncStatus]) { - let createUnreadStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.read && $0.flag == false } - let deleteUnreadStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.read && $0.flag == true } - let createStarredStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.starred && $0.flag == true } - let deleteStarredStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.starred && $0.flag == false } + let createUnreadStatuses = syncStatuses.filter { + $0.key == ArticleStatus.Key.read && $0.flag == false + } + let deleteUnreadStatuses = syncStatuses.filter { + $0.key == ArticleStatus.Key.read && $0.flag == true + } + let createStarredStatuses = syncStatuses.filter { + $0.key == ArticleStatus.Key.starred && $0.flag == true + } + let deleteStarredStatuses = syncStatuses.filter { + $0.key == ArticleStatus.Key.starred && $0.flag == false + } let group = DispatchGroup() var errorOccurred = false @@ -246,15 +254,18 @@ final class NewsBlurAccountDelegate: AccountDelegate { } } - func refreshMissingStories(for account: Account, completion: @escaping (Result)-> Void) { + func refreshMissingStories(for account: Account, completion: @escaping (Result) -> Void) { os_log(.debug, log: log, "Refreshing missing stories...") account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate { result in + func process(_ fetchedHashes: Set) { let group = DispatchGroup() var errorOccurred = false - let storyHashes = Array(fetchedHashes).map { NewsBlurStoryHash(hash: $0, timestamp: Date()) } + let storyHashes = Array(fetchedHashes).map { + NewsBlurStoryHash(hash: $0, timestamp: Date()) + } let chunkedStoryHashes = storyHashes.chunked(into: 100) for chunk in chunkedStoryHashes { @@ -263,9 +274,9 @@ final class NewsBlurAccountDelegate: AccountDelegate { switch result { case .success(let stories): - self.processStories(account: account, stories: stories) { error in + self.processStories(account: account, stories: stories) { result in group.leave() - if error != nil { + if case .failure = result { errorOccurred = true } } @@ -298,10 +309,26 @@ final class NewsBlurAccountDelegate: AccountDelegate { } } - func processStories(account: Account, stories: [NewsBlurStory]?, completion: @escaping DatabaseCompletionBlock) { - let parsedItems = mapStoriesToParsedItems(stories: stories) - let webFeedIDsAndItems = Dictionary(grouping: parsedItems, by: { item in item.feedURL } ).mapValues { Set($0) } - account.update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: true, completion: completion) + func processStories(account: Account, stories: [NewsBlurStory]?, since: Date? = nil, completion: @escaping (Result) -> Void) { + let parsedItems = mapStoriesToParsedItems(stories: stories).filter { + guard let datePublished = $0.datePublished, let since = since else { + return true + } + + return datePublished >= since + } + let webFeedIDsAndItems = Dictionary(grouping: parsedItems, by: { item in item.feedURL }).mapValues { + Set($0) + } + + account.update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: true) { error in + if let error = error { + completion(.failure(error)) + return + } + + completion(.success(!webFeedIDsAndItems.isEmpty)) + } } func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result) -> ()) { @@ -383,13 +410,43 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func createWebFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result) -> ()) { + refreshProgress.addToNumberOfTasksAndRemaining(1) + + caller.addURL(url) { result in + self.refreshProgress.completeTask() + + switch result { + case .success(let feed): + self.createFeed(account: account, feed: feed, name: name, container: container, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } } func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result) -> ()) { completion(.success(())) } - func addWebFeed(for account: Account, with: WebFeed, to container: Container, completion: @escaping (Result) -> ()) { + func addWebFeed(for account: Account, with feed: WebFeed, to container: Container, completion: @escaping (Result) -> ()) { + guard let folder = container as? Folder else { + DispatchQueue.main.async { + if let account = container as? Account { + account.addFeedIfNotInAnyFolder(feed) + } + completion(.success(())) + } + + return + } + + let folderName = folder.name ?? "" + saveFolderRelationship(for: feed, withFolderName: folderName, id: folderName) + folder.addWebFeed(feed) + completion(.success(())) } From 20ff0533359b34506b8536c2cf63483d8a2f9249 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 21 Mar 2020 18:29:20 -0400 Subject: [PATCH 40/51] Rename, delete web feed, fix adding feed directly under account not working --- .../NewsBlurAccountDelegate+Internal.swift | 35 +++++++++++++++++++ .../NewsBlur/Models/NewsBlurFeedChange.swift | 18 ++++++++-- .../Account/NewsBlur/NewsBlurAPICaller.swift | 32 +++++++++++++++-- .../NewsBlur/NewsBlurAccountDelegate.swift | 35 ++++++++++++++++--- 4 files changed, 110 insertions(+), 10 deletions(-) diff --git a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift index 698d1d3c2..113e3b763 100644 --- a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift +++ b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift @@ -471,4 +471,39 @@ extension NewsBlurAccountDelegate { } } } + + func deleteFeed(for account: Account, with feed: WebFeed, from container: Container?, completion: @escaping (Result) -> Void) { + // This error should never happen + guard let feedID = feed.subscriptionID else { + completion(.failure(NewsBlurError.invalidParameter)) + return + } + + refreshProgress.addToNumberOfTasksAndRemaining(1) + + let folderName = (container as? Folder)?.name + caller.deleteFeed(feedID: feedID, folder: folderName) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + DispatchQueue.main.async { + account.clearWebFeedMetadata(feed) + account.removeWebFeed(feed) + if let folders = account.folders { + for folder in folders where folderName != nil && folder.name == folderName { + folder.removeWebFeed(feed) + } + } + completion(.success(())) + } + + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + } } diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurFeedChange.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFeedChange.swift index e3082fe3d..e0d9528a9 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurFeedChange.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFeedChange.swift @@ -9,7 +9,9 @@ import Foundation enum NewsBlurFeedChange { - case add(String) + case add(String, String?) + case rename(String, String) + case delete(String, String?) } extension NewsBlurFeedChange: NewsBlurDataConvertible { @@ -17,11 +19,21 @@ extension NewsBlurFeedChange: NewsBlurDataConvertible { var postData = URLComponents() postData.queryItems = { switch self { - case .add(let url): + case .add(let url, let folder): return [ URLQueryItem(name: "url", value: url), - URLQueryItem(name: "folder", value: ""), // root folder + folder != nil ? URLQueryItem(name: "folder", value: folder) : nil + ].compactMap { $0 } + case .rename(let feedID, let newName): + return [ + URLQueryItem(name: "feed_id", value: feedID), + URLQueryItem(name: "feed_title", value: newName), ] + case .delete(let feedID, let folder): + return [ + URLQueryItem(name: "feed_id", value: feedID), + folder != nil ? URLQueryItem(name: "in_folder", value: folder) : nil, + ].compactMap { $0 } } }() diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index fc58042e3..b0c07735a 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -220,10 +220,10 @@ final class NewsBlurAPICaller: NSObject { ) } - func addURL(_ url: String, completion: @escaping (Result) -> Void) { + func addURL(_ url: String, folder: String?, completion: @escaping (Result) -> Void) { sendUpdates( endpoint: "reader/add_url", - payload: NewsBlurFeedChange.add(url), + payload: NewsBlurFeedChange.add(url, folder), resultType: NewsBlurAddURLResponse.self ) { result in switch result { @@ -234,4 +234,32 @@ final class NewsBlurAPICaller: NSObject { } } } + + func renameFeed(feedID: String, newName: String, completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/rename_feed", + payload: NewsBlurFeedChange.rename(feedID, newName) + ) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func deleteFeed(feedID: String, folder: String? = nil, completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/delete_feed", + payload: NewsBlurFeedChange.delete(feedID, folder) + ) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 96bfebd4e..f7a69454e 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -387,7 +387,7 @@ final class NewsBlurAccountDelegate: AccountDelegate { var feedIDs: [String] = [] for feed in folder.topLevelWebFeeds { - if feed.folderRelationship?.count ?? 0 > 1 { + if (feed.folderRelationship?.count ?? 0) > 1 { clearFolderRelationship(for: feed, withFolderName: folderToRemove) } else if let subscriptionID = feed.subscriptionID { feedIDs.append(subscriptionID) @@ -412,7 +412,8 @@ final class NewsBlurAccountDelegate: AccountDelegate { func createWebFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result) -> ()) { refreshProgress.addToNumberOfTasksAndRemaining(1) - caller.addURL(url) { result in + let folderName = (container as? Folder)?.name + caller.addURL(url, folder: folderName) { result in self.refreshProgress.completeTask() switch result { @@ -428,14 +429,38 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result) -> ()) { - completion(.success(())) + // This error should never happen + guard let feedID = feed.subscriptionID else { + completion(.failure(NewsBlurError.invalidParameter)) + return + } + + refreshProgress.addToNumberOfTasksAndRemaining(1) + + caller.renameFeed(feedID: feedID, newName: name) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + DispatchQueue.main.async { + feed.editedName = name + completion(.success(())) + } + + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } } func addWebFeed(for account: Account, with feed: WebFeed, to container: Container, completion: @escaping (Result) -> ()) { guard let folder = container as? Folder else { DispatchQueue.main.async { if let account = container as? Account { - account.addFeedIfNotInAnyFolder(feed) + account.addWebFeed(feed) } completion(.success(())) } @@ -451,7 +476,7 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result) -> ()) { - completion(.success(())) + deleteFeed(for: account, with: feed, from: container, completion: completion) } func moveWebFeed(for account: Account, with feed: WebFeed, from: Container, to: Container, completion: @escaping (Result) -> ()) { From 51a4a2b3c8a6af6e77848eb832a5a05c035c3899 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 21 Mar 2020 18:33:31 -0400 Subject: [PATCH 41/51] Rename subscriptionID to externalID --- .../Internals/NewsBlurAccountDelegate+Internal.swift | 8 ++++---- Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift index 113e3b763..0cadff712 100644 --- a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift +++ b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift @@ -108,7 +108,7 @@ extension NewsBlurAccountDelegate { // If the name has been changed on the server remove the locally edited name webFeed.editedName = nil webFeed.homePageURL = feed.homePageURL - webFeed.subscriptionID = String(feed.feedID) + webFeed.externalID = String(feed.feedID) webFeed.faviconURL = feed.faviconURL } else { @@ -119,7 +119,7 @@ extension NewsBlurAccountDelegate { // Actually add feeds all in one go, so we don’t trigger various rebuilding things that Account does. feedsToAdd.forEach { feed in let webFeed = account.createWebFeed(with: feed.name, url: feed.feedURL, webFeedID: String(feed.feedID), homePageURL: feed.homePageURL) - webFeed.subscriptionID = String(feed.feedID) + webFeed.externalID = String(feed.feedID) account.addWebFeed(webFeed) } } @@ -380,7 +380,7 @@ extension NewsBlurAccountDelegate { DispatchQueue.main.async { let webFeed = account.createWebFeed(with: feed.name, url: feed.feedURL, webFeedID: String(feed.feedID), homePageURL: feed.homePageURL) - webFeed.subscriptionID = String(feed.feedID) + webFeed.externalID = String(feed.feedID) webFeed.faviconURL = feed.faviconURL account.addWebFeed(webFeed, to: container) { result in @@ -474,7 +474,7 @@ extension NewsBlurAccountDelegate { func deleteFeed(for account: Account, with feed: WebFeed, from container: Container?, completion: @escaping (Result) -> Void) { // This error should never happen - guard let feedID = feed.subscriptionID else { + guard let feedID = feed.externalID else { completion(.failure(NewsBlurError.invalidParameter)) return } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index f7a69454e..68a77a74d 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -389,8 +389,8 @@ final class NewsBlurAccountDelegate: AccountDelegate { for feed in folder.topLevelWebFeeds { if (feed.folderRelationship?.count ?? 0) > 1 { clearFolderRelationship(for: feed, withFolderName: folderToRemove) - } else if let subscriptionID = feed.subscriptionID { - feedIDs.append(subscriptionID) + } else if let feedID = feed.externalID { + feedIDs.append(feedID) } } @@ -430,7 +430,7 @@ final class NewsBlurAccountDelegate: AccountDelegate { func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result) -> ()) { // This error should never happen - guard let feedID = feed.subscriptionID else { + guard let feedID = feed.externalID else { completion(.failure(NewsBlurError.invalidParameter)) return } From a9615560ae4234e015260177837561558761aafd Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 21 Mar 2020 20:54:16 -0400 Subject: [PATCH 42/51] Fix feed not showing up at account-level if it's also in another folder --- .../NewsBlurAccountDelegate+Internal.swift | 21 ++++++------------- .../NewsBlur/Models/NewsBlurFeed.swift | 5 ++--- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift index 0cadff712..034d59f7e 100644 --- a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift +++ b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift @@ -66,8 +66,9 @@ extension NewsBlurAccountDelegate { }() // Make any folders NewsBlur has, but we don't + // Ignore account-level folder folderNames.forEach { folderName in - if !accountFolderNames.contains(folderName) { + if !accountFolderNames.contains(folderName) && folderName != " " { _ = account.ensureFolder(with: folderName) } } @@ -133,7 +134,7 @@ extension NewsBlurAccountDelegate { // Set up some structures to make syncing easier let relationships = folders.map({ $0.asRelationships }).flatMap { $0 } let folderDict = nameToFolderDictionary(with: account.folders) - let foldersDict = relationships.reduce([String: [NewsBlurFolderRelationship]]()) { (dict, relationship) in + let newsBlurFolderDict = relationships.reduce([String: [NewsBlurFolderRelationship]]()) { (dict, relationship) in var feedInFolders = dict if var feedInFolder = feedInFolders[relationship.folderName] { feedInFolder.append(relationship) @@ -145,14 +146,14 @@ extension NewsBlurAccountDelegate { } // Sync the folders - for (folderName, folderRelationships) in foldersDict { + for (folderName, folderRelationships) in newsBlurFolderDict { guard let folder = folderDict[folderName] else { return } - let folderFeedIDs = folderRelationships.map { String($0.feedID) } + let newsBlurFolderFeedIDs = folderRelationships.map { String($0.feedID) } // Move any feeds not in the folder to the account for feed in folder.topLevelWebFeeds { - if !folderFeedIDs.contains(feed.webFeedID) { + if !newsBlurFolderFeedIDs.contains(feed.webFeedID) { folder.removeWebFeed(feed) clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") account.addWebFeed(feed) @@ -172,16 +173,6 @@ extension NewsBlurAccountDelegate { folder.addWebFeed(feed) } } - - } - - let folderFeedIDs = Set(relationships.map { String($0.feedID) }) - - // Remove all feeds from the account container that have a tag - for feed in account.topLevelWebFeeds { - if folderFeedIDs.contains(feed.webFeedID) { - account.removeWebFeed(feed) - } } } diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift index ad3fb5497..d4a0500a9 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift @@ -34,7 +34,7 @@ struct NewsBlurAddURLResponse: Decodable { let feed: NewsBlurFeed? } -struct NewsBlurFolderRelationship: Codable { +struct NewsBlurFolderRelationship { let folderName: String let feedID: Int } @@ -71,8 +71,7 @@ extension NewsBlurFeedsResponse { var folders: [Folder] = [] let folderContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .folders) - // Skip "everything" folder - for key in folderContainer.allKeys where key.stringValue != " " { + for key in folderContainer.allKeys { let subscriptionIds = try folderContainer.decode([Int].self, forKey: key) let folder = Folder(name: key.stringValue, feedIDs: subscriptionIds) From fe7cbe3bd308760b5c398a4d600998ae3dd4cf47 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 21 Mar 2020 21:25:39 -0400 Subject: [PATCH 43/51] Fix feed deletion in folder triggering unexpected deletes --- .../NewsBlurAccountDelegate+Internal.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift index 034d59f7e..880ecf5e6 100644 --- a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift +++ b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift @@ -479,13 +479,22 @@ extension NewsBlurAccountDelegate { switch result { case .success: DispatchQueue.main.async { - account.clearWebFeedMetadata(feed) - account.removeWebFeed(feed) + let feedID = feed.webFeedID + + if folderName == nil { + account.removeWebFeed(feed) + } + if let folders = account.folders { for folder in folders where folderName != nil && folder.name == folderName { folder.removeWebFeed(feed) } } + + if account.flattenedWebFeeds().first(where: { $0.webFeedID == feedID }) == nil { + account.clearWebFeedMetadata(feed) + } + completion(.success(())) } From 1e66860f346f953d90ebf436a2b30670b8954d57 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 21 Mar 2020 22:17:01 -0400 Subject: [PATCH 44/51] Move feed --- .../NewsBlur/Models/NewsBlurFeedChange.swift | 7 +++++ .../Account/NewsBlur/NewsBlurAPICaller.swift | 14 ++++++++++ .../NewsBlur/NewsBlurAccountDelegate.swift | 26 +++++++++++++++++-- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurFeedChange.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFeedChange.swift index e0d9528a9..56f09a95c 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurFeedChange.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFeedChange.swift @@ -12,6 +12,7 @@ enum NewsBlurFeedChange { case add(String, String?) case rename(String, String) case delete(String, String?) + case move(String, String?, String?) } extension NewsBlurFeedChange: NewsBlurDataConvertible { @@ -34,6 +35,12 @@ extension NewsBlurFeedChange: NewsBlurDataConvertible { URLQueryItem(name: "feed_id", value: feedID), folder != nil ? URLQueryItem(name: "in_folder", value: folder) : nil, ].compactMap { $0 } + case .move(let feedID, let from, let to): + return [ + URLQueryItem(name: "feed_id", value: feedID), + URLQueryItem(name: "in_folder", value: from ?? ""), + URLQueryItem(name: "to_folder", value: to ?? ""), + ] } }() diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index b0c07735a..0cd8bf977 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -262,4 +262,18 @@ final class NewsBlurAPICaller: NSObject { } } } + + func moveFeed(feedID: String, from: String?, to: String?, completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/move_feed_to_folder", + payload: NewsBlurFeedChange.move(feedID, from, to) + ) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 68a77a74d..de8c14f1f 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -429,7 +429,6 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result) -> ()) { - // This error should never happen guard let feedID = feed.externalID else { completion(.failure(NewsBlurError.invalidParameter)) return @@ -480,7 +479,30 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func moveWebFeed(for account: Account, with feed: WebFeed, from: Container, to: Container, completion: @escaping (Result) -> ()) { - completion(.success(())) + guard let feedID = feed.externalID else { + completion(.failure(NewsBlurError.invalidParameter)) + return + } + + refreshProgress.addToNumberOfTasksAndRemaining(1) + + caller.moveFeed( + feedID: feedID, + from: (from as? Folder)?.name, + to: (to as? Folder)?.name + ) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + from.removeWebFeed(feed) + to.addWebFeed(feed) + + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } } func restoreWebFeed(for account: Account, feed: WebFeed, container: Container, completion: @escaping (Result) -> ()) { From 1e5033febeee2f527bb3e99191385d4905f03fd0 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 21 Mar 2020 22:56:12 -0400 Subject: [PATCH 45/51] Restore feed and folder --- .../NewsBlur/NewsBlurAccountDelegate.swift | 59 ++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index de8c14f1f..9a1006bd6 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -506,11 +506,66 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func restoreWebFeed(for account: Account, feed: WebFeed, container: Container, completion: @escaping (Result) -> ()) { - completion(.success(())) + if let existingFeed = account.existingWebFeed(withURL: feed.url) { + account.addWebFeed(existingFeed, to: container) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } else { + createWebFeed(for: account, url: feed.url, name: feed.editedName, container: container) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } } func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result) -> ()) { - completion(.success(())) + guard let folderName = folder.name else { + completion(.failure(NewsBlurError.invalidParameter)) + return + } + + var feedsToRestore: [WebFeed] = [] + for feed in folder.topLevelWebFeeds { + feedsToRestore.append(feed) + folder.topLevelWebFeeds.remove(feed) + } + + let group = DispatchGroup() + + group.enter() + addFolder(for: account, name: folderName) { result in + group.leave() + switch result { + case .success(let folder): + for feed in feedsToRestore { + group.enter() + self.restoreWebFeed(for: account, feed: feed, container: folder) { result in + group.leave() + switch result { + case .success: + break + case .failure(let error): + os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription) + } + } + } + case .failure(let error): + os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription) + } + } + + group.notify(queue: DispatchQueue.main) { + completion(.success(())) + } } func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { From a784b11d1cfd773f55304f329d573002f2d2616d Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 21 Mar 2020 22:56:41 -0400 Subject: [PATCH 46/51] Fix feeds being returned without corresponding folder --- .../NewsBlurAccountDelegate+Internal.swift | 8 +++--- .../NewsBlur/Models/NewsBlurFeed.swift | 25 +++++++++++-------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift index 880ecf5e6..20083b9c1 100644 --- a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift +++ b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift @@ -80,13 +80,13 @@ extension NewsBlurAccountDelegate { os_log(.debug, log: log, "Syncing feeds with %ld feeds.", feeds.count) - let subFeedIds = feeds.map { String($0.feedID) } + let newsBlurFeedIds = feeds.map { String($0.feedID) } // Remove any feeds that are no longer in the subscriptions if let folders = account.folders { for folder in folders { for feed in folder.topLevelWebFeeds { - if !subFeedIds.contains(feed.webFeedID) { + if !newsBlurFeedIds.contains(feed.webFeedID) { folder.removeWebFeed(feed) } } @@ -94,7 +94,7 @@ extension NewsBlurAccountDelegate { } for feed in account.topLevelWebFeeds { - if !subFeedIds.contains(feed.webFeedID) { + if !newsBlurFeedIds.contains(feed.webFeedID) { account.removeWebFeed(feed) } } @@ -491,7 +491,7 @@ extension NewsBlurAccountDelegate { } } - if account.flattenedWebFeeds().first(where: { $0.webFeedID == feedID }) == nil { + if account.existingWebFeed(withWebFeedID: feedID) != nil { account.clearWebFeedMetadata(feed) } diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift index d4a0500a9..f0b0300bb 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift @@ -59,26 +59,31 @@ extension NewsBlurFeedsResponse { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - // Parse feeds - var feeds: [NewsBlurFeed] = [] - let feedContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .feeds) - try feedContainer.allKeys.forEach { key in - let subscription = try feedContainer.decode(NewsBlurFeed.self, forKey: key) - feeds.append(subscription) - } + // Tricky part: Some feeds listed in `feeds` don't exist in `folders` for some reason + // They don't show up on both mobile/web app, so let's filter them out + var visibleFeedIDs: [Int] = [] // Parse folders var folders: [Folder] = [] let folderContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .folders) for key in folderContainer.allKeys { - let subscriptionIds = try folderContainer.decode([Int].self, forKey: key) - let folder = Folder(name: key.stringValue, feedIDs: subscriptionIds) + let feedIDs = try folderContainer.decode([Int].self, forKey: key) + let folder = Folder(name: key.stringValue, feedIDs: feedIDs) folders.append(folder) + visibleFeedIDs.append(contentsOf: feedIDs) } - self.feeds = feeds + // Parse feeds + var feeds: [NewsBlurFeed] = [] + let feedContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .feeds) + try feedContainer.allKeys.forEach { key in + let feed = try feedContainer.decode(NewsBlurFeed.self, forKey: key) + feeds.append(feed) + } + + self.feeds = feeds.filter { visibleFeedIDs.contains($0.feedID) } self.folders = folders } } From f0fe308c7ba4651531ad65097ccb174465824658 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 21 Mar 2020 23:28:29 -0400 Subject: [PATCH 47/51] Update last article fetch time --- .../NewsBlurAccountDelegate+Internal.swift | 15 +++++++++++---- .../Account/NewsBlur/NewsBlurAPICaller.swift | 12 ++++++------ .../NewsBlur/NewsBlurAccountDelegate.swift | 2 +- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift index 20083b9c1..295cb9967 100644 --- a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift +++ b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift @@ -222,7 +222,7 @@ extension NewsBlurAccountDelegate { caller.retrieveStories(hashes: hashesToFetch) { result in switch result { - case .success(let stories): + case .success((let stories, let date)): self.processStories(account: account, stories: stories) { result in self.refreshProgress.completeTask() @@ -231,7 +231,7 @@ extension NewsBlurAccountDelegate { return } - self.refreshUnreadStories(for: account, hashes: Array(hashes[numberOfStories...]), updateFetchDate: updateFetchDate) { result in + self.refreshUnreadStories(for: account, hashes: Array(hashes[numberOfStories...]), updateFetchDate: date) { result in os_log(.debug, log: self.log, "Done refreshing stories.") switch result { case .success: @@ -401,7 +401,7 @@ extension NewsBlurAccountDelegate { caller.retrieveStories(feedID: feed.webFeedID, page: page) { result in switch result { - case .success(let stories): + case .success((let stories, _)): // No more stories guard let stories = stories, stories.count > 0 else { self.refreshProgress.completeTask() @@ -410,7 +410,14 @@ extension NewsBlurAccountDelegate { return } - let since = Calendar.current.date(byAdding: .month, value: -3, to: Date()) + let since: Date? = { + if let lastArticleFetch = self.accountMetadata?.lastArticleFetchStartTime { + return lastArticleFetch + } else { + return Calendar.current.date(byAdding: .month, value: -3, to: Date()) + } + }() + self.processStories(account: account, stories: stories, since: since) { result in self.refreshProgress.completeTask() diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index 0cd8bf977..b87a82bdb 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -125,7 +125,7 @@ final class NewsBlurAPICaller: NSObject { ) } - func retrieveStories(feedID: String, page: Int, completion: @escaping (Result<[NewsBlurStory]?, Error>) -> Void) { + func retrieveStories(feedID: String, page: Int, completion: @escaping (Result<([NewsBlurStory]?, Date?), Error>) -> Void) { let url = baseURL .appendingPathComponent("reader/feed/\(feedID)") .appendingQueryItems([ @@ -138,15 +138,15 @@ final class NewsBlurAPICaller: NSObject { requestData(callURL: url, resultType: NewsBlurStoriesResponse.self) { result in switch result { - case .success((_, let payload)): - completion(.success(payload?.stories)) + case .success(let (response, payload)): + completion(.success((payload?.stories, HTTPDateInfo(urlResponse: response)?.date))) case .failure(let error): completion(.failure(error)) } } } - func retrieveStories(hashes: [NewsBlurStoryHash], completion: @escaping (Result<[NewsBlurStory]?, Error>) -> Void) { + func retrieveStories(hashes: [NewsBlurStoryHash], completion: @escaping (Result<([NewsBlurStory]?, Date?), Error>) -> Void) { let url = baseURL .appendingPathComponent("reader/river_stories") .appendingQueryItem(.init(name: "include_hidden", value: "true"))? @@ -156,8 +156,8 @@ final class NewsBlurAPICaller: NSObject { requestData(callURL: url, resultType: NewsBlurStoriesResponse.self) { result in switch result { - case .success((_, let payload)): - completion(.success(payload?.stories)) + case .success(let (response, payload)): + completion(.success((payload?.stories, HTTPDateInfo(urlResponse: response)?.date))) case .failure(let error): completion(.failure(error)) } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 9a1006bd6..716ee1870 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -273,7 +273,7 @@ final class NewsBlurAccountDelegate: AccountDelegate { self.caller.retrieveStories(hashes: chunk) { result in switch result { - case .success(let stories): + case .success((let stories, _)): self.processStories(account: account, stories: stories) { result in group.leave() if case .failure = result { From 59f3fb4b3d92af3c14ada55d354d2667d39f5a9b Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sat, 21 Mar 2020 23:58:27 -0400 Subject: [PATCH 48/51] Handle account-level folder when syncing --- .../NewsBlurAccountDelegate+Internal.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift index 295cb9967..54579c361 100644 --- a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift +++ b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift @@ -147,10 +147,19 @@ extension NewsBlurAccountDelegate { // Sync the folders for (folderName, folderRelationships) in newsBlurFolderDict { - guard let folder = folderDict[folderName] else { return } - let newsBlurFolderFeedIDs = folderRelationships.map { String($0.feedID) } + // Handle account-level folder + if folderName == " " { + for feed in account.topLevelWebFeeds { + if !newsBlurFolderFeedIDs.contains(feed.webFeedID) { + account.removeWebFeed(feed) + } + } + } + + guard let folder = folderDict[folderName] else { return } + // Move any feeds not in the folder to the account for feed in folder.topLevelWebFeeds { if !newsBlurFolderFeedIDs.contains(feed.webFeedID) { From cfaacd0ada43f8dd1478a1c5f5dbdfd885a8e330 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sun, 22 Mar 2020 00:46:42 -0400 Subject: [PATCH 49/51] Fix starred story hashes being parsed incorrectly --- .../NewsBlur/Models/NewsBlurStoryHash.swift | 26 +++++++++++++++---- .../Account/NewsBlur/NewsBlurAPICaller.swift | 2 +- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurStoryHash.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurStoryHash.swift index fd76f7915..48286df00 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurStoryHash.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurStoryHash.swift @@ -15,8 +15,8 @@ typealias NewsBlurStoryHash = NewsBlurStoryHashesResponse.StoryHash struct NewsBlurStoryHashesResponse: Decodable { typealias StoryHashDictionary = [String: [StoryHash]] - var unread: StoryHashDictionary? - var starred: StoryHashDictionary? + var unread: [StoryHash]? + var starred: [StoryHash]? struct StoryHash: Hashable, Codable { var hash: String @@ -35,12 +35,13 @@ extension NewsBlurStoryHashesResponse { // Parse unread if let unreadContainer = try? container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .unread) { - self.unread = try NewsBlurStoryHashesResponse.extractHashes(container: unreadContainer) + let storyHashes = try NewsBlurStoryHashesResponse.extractHashes(container: unreadContainer) + self.unread = storyHashes.values.flatMap { $0 } } // Parse starred - if let starredContainer = try? container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .starred) { - self.starred = try NewsBlurStoryHashesResponse.extractHashes(container: starredContainer) + if let starredContainer = try? container.nestedUnkeyedContainer(forKey: .starred) { + self.starred = try NewsBlurStoryHashesResponse.extractArray(container: starredContainer) } } @@ -61,4 +62,19 @@ extension NewsBlurStoryHashesResponse { return dict } + + static func extractArray(container: UnkeyedDecodingContainer) throws -> [StoryHash] { + var hashes: [StoryHash] = [] + var hashArrayContainer = container + while !hashArrayContainer.isAtEnd { + var hashContainer = try hashArrayContainer.nestedUnkeyedContainer() + let hash = try hashContainer.decode(String.self) + let timestamp = try (hashContainer.decode(String.self) as NSString).doubleValue + let storyHash = StoryHash(hash: hash, timestamp: Date(timeIntervalSince1970: timestamp)) + + hashes.append(storyHash) + } + + return hashes + } } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index b87a82bdb..9c974b2cd 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -104,7 +104,7 @@ final class NewsBlurAPICaller: NSObject { switch result { case .success((_, let payload)): let hashes = payload?.unread ?? payload?.starred - completion(.success(hashes?.values.flatMap { $0 })) + completion(.success(hashes)) case .failure(let error): completion(.failure(error)) } From bdbeced5d24e511220307132121b96032aed7026 Mon Sep 17 00:00:00 2001 From: Anh Do Date: Sun, 22 Mar 2020 01:11:00 -0400 Subject: [PATCH 50/51] Initial feed download now retrieves entries in the last 90 days --- .../Internals/NewsBlurAccountDelegate+Internal.swift | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift index 54579c361..406d2ec70 100644 --- a/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift +++ b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift @@ -419,13 +419,7 @@ extension NewsBlurAccountDelegate { return } - let since: Date? = { - if let lastArticleFetch = self.accountMetadata?.lastArticleFetchStartTime { - return lastArticleFetch - } else { - return Calendar.current.date(byAdding: .month, value: -3, to: Date()) - } - }() + let since: Date? = Calendar.current.date(byAdding: .month, value: -3, to: Date()) self.processStories(account: account, stories: stories, since: since) { result in self.refreshProgress.completeTask() From 1ea8cc0dda39b4ccd520517c8c30e209b01621ac Mon Sep 17 00:00:00 2001 From: Brent Simmons Date: Sat, 21 Mar 2020 22:13:56 -0700 Subject: [PATCH 51/51] Create new encrypted provisioning profile. --- .../profile/NetNewsWire.provisionprofile.enc | 340 +++++++++--------- 1 file changed, 173 insertions(+), 167 deletions(-) diff --git a/buildscripts/profile/NetNewsWire.provisionprofile.enc b/buildscripts/profile/NetNewsWire.provisionprofile.enc index 2d9138c73..0ef3c6f85 100644 --- a/buildscripts/profile/NetNewsWire.provisionprofile.enc +++ b/buildscripts/profile/NetNewsWire.provisionprofile.enc @@ -1,167 +1,173 @@ -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