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 e96803641..5a546215d 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -7,6 +7,16 @@ objects = { /* 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 */; }; + 179DB3A93E3205EF29C2AF62 /* NewsBlurAPICaller+Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBBF346CF712AB2F0E9E6 /* NewsBlurAPICaller+Internal.swift */; }; + 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.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 */; }; 3B826DA72385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */; }; 3B826DA82385C81C00FC1ADB /* FeedWranglerFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */; }; @@ -18,7 +28,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 */; }; @@ -63,6 +73,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 +232,16 @@ /* End PBXContainerItemProxy section */ /* 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 = ""; }; + 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 = ""; }; 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItem.swift; sourceTree = ""; }; @@ -231,7 +253,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 = ""; }; @@ -278,6 +300,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 +459,30 @@ /* 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 = ( + 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */, + 179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */, + 179DB7399814F6FB3247825C /* NewsBlurStory.swift */, + 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */, + 179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */, + 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */, + 179DBDDC00B68411AA28941F /* NewsBlurFolderChange.swift */, + 179DB3CBADAFCF5377DA3D02 /* NewsBlurFeedChange.swift */, + ); + path = Models; + sourceTree = ""; + }; 3B826D9D2385C81C00FC1ADB /* FeedWrangler */ = { isa = PBXGroup; children = ( @@ -455,7 +503,7 @@ 5103A9D7242253DC00410853 /* CloudKit */ = { isa = PBXGroup; children = ( - 5103A9D82422546800410853 /* CloudKitAppDelegate.swift */, + 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */, ); path = CloudKit; sourceTree = ""; @@ -523,6 +571,17 @@ path = ReaderAPI; sourceTree = ""; }; + 769F2630AF8DC873D4A73567 /* NewsBlur */ = { + isa = PBXGroup; + children = ( + 769F2A8DF190549E24B5D110 /* NewsBlurAccountDelegate.swift */, + 769F275FD5D942502C5B4716 /* NewsBlurAPICaller.swift */, + 179DBD810D353D9CED7C3BED /* Models */, + 179DB1571B95BAD0F833AF6D /* Internals */, + ); + path = NewsBlur; + sourceTree = ""; + }; 841973E91F6DD19E006346C4 /* Products */ = { isa = PBXGroup; children = ( @@ -622,6 +681,7 @@ 8469F80F1F6DC3C10084783E /* Frameworks */, D511EEB4202422BB00712EC3 /* xcconfig */, 848934FA1F62484F00CEBD24 /* Info.plist */, + 769F2630AF8DC873D4A73567 /* NewsBlur */, ); sourceTree = ""; usesTabs = 1; @@ -1019,7 +1079,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 */, @@ -1107,6 +1167,18 @@ 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 */, + 179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */, + 179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */, + 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */, + 179DBED55C9B4D6A413486C1 /* NewsBlurStoryHash.swift in Sources */, + 179DB0B17A6C51B95ABC1741 /* NewsBlurStoryStatusChange.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/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 diff --git a/Frameworks/Account/Credentials/Credentials.swift b/Frameworks/Account/Credentials/Credentials.swift index bc5ac86ee..ef9884f5b 100644 --- a/Frameworks/Account/Credentials/Credentials.swift +++ b/Frameworks/Account/Credentials/Credentials.swift @@ -17,6 +17,8 @@ public enum CredentialsType: String { case basic = "password" case feedWranglerBasic = "feedWranglerBasic" case feedWranglerToken = "feedWranglerToken" + 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 1717dc10f..f82f3de02 100755 --- a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift +++ b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift @@ -33,7 +33,19 @@ public extension URLRequest { ]) case .feedWranglerToken: self.url = url.appendingQueryItem(URLQueryItem(name: "access_token", value: credentials.secret)) - case .readerBasic: + case .newsBlurBasic: + setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType) + httpMethod = "POST" + 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 + case .readerBasic: setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") httpMethod = "POST" var postData = URLComponents() 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/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/Internals/NewsBlurAccountDelegate+Internal.swift b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift new file mode 100644 index 000000000..406d2ec70 --- /dev/null +++ b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift @@ -0,0 +1,519 @@ +// +// NewsBlurAccountDelegate+Internal.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 + // Ignore account-level folder + folderNames.forEach { folderName in + if !accountFolderNames.contains(folderName) && 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 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 !newsBlurFeedIds.contains(feed.webFeedID) { + folder.removeWebFeed(feed) + } + } + } + } + + for feed in account.topLevelWebFeeds { + if !newsBlurFeedIds.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.externalID = 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.externalID = 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 newsBlurFolderDict = 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 newsBlurFolderDict { + 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) { + 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) + } + } + } + } + + 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: story.imageURL, bannerImageURL: nil, datePublished: story.datePublished, dateModified: nil, authors: author, tags: Set(story.tags ?? []), 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) + } + } + } + + 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.externalID = 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: Date? = 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)) + } + } + } + } + + func deleteFeed(for account: Account, with feed: WebFeed, from container: Container?, completion: @escaping (Result) -> Void) { + // This error should never happen + guard let feedID = feed.externalID 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 { + 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.existingWebFeed(withWebFeedID: feedID) != nil { + account.clearWebFeedMetadata(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/NewsBlurFeed.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift new file mode 100644 index 000000000..f0b0300bb --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift @@ -0,0 +1,95 @@ +// +// NewsBlurFeed.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 NewsBlurFolder = NewsBlurFeedsResponse.Folder + +struct NewsBlurFeed: 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 + let feedIDs: [Int] + } +} + +struct NewsBlurAddURLResponse: Decodable { + let feed: NewsBlurFeed? +} + +struct NewsBlurFolderRelationship { + 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" + case folders = "flat_folders" + // TODO: flat_folders_with_inactive + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // 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 feedIDs = try folderContainer.decode([Int].self, forKey: key) + let folder = Folder(name: key.stringValue, feedIDs: feedIDs) + + folders.append(folder) + visibleFeedIDs.append(contentsOf: feedIDs) + } + + // 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 + } +} + +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..56f09a95c --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFeedChange.swift @@ -0,0 +1,49 @@ +// +// 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, String?) + case rename(String, String) + case delete(String, String?) + case move(String, String?, String?) +} + +extension NewsBlurFeedChange: NewsBlurDataConvertible { + var asData: Data? { + var postData = URLComponents() + postData.queryItems = { + switch self { + case .add(let url, let folder): + return [ + URLQueryItem(name: "url", value: url), + 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 } + 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 ?? ""), + ] + } + }() + + return postData.percentEncodedQuery?.data(using: .utf8) + } +} diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift new file mode 100644 index 000000000..ed2df52cd --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift @@ -0,0 +1,47 @@ +// +// 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) + case rename(String, String) + case delete(String, [String]) +} + +extension NewsBlurFolderChange: NewsBlurDataConvertible { + var asData: Data? { + var postData = URLComponents() + postData.queryItems = { + switch self { + case .add(let 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), + URLQueryItem(name: "new_folder_name", value: to), + URLQueryItem(name: "in_folder", value: ""), // root folder + ] + 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 + } + }() + + return postData.percentEncodedQuery?.data(using: .utf8) + } +} 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/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/Models/NewsBlurStory.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift new file mode 100644 index 000000000..e4878b287 --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurStory.swift @@ -0,0 +1,57 @@ +// +// NewsBlurStory.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 NewsBlurStory = NewsBlurStoriesResponse.Story + +struct NewsBlurStoriesResponse: Decodable { + let stories: [Story] + + struct Story: Decodable { + let storyID: String + let feedID: Int + let title: String? + let url: String? + let authorName: String? + let contentHTML: String? + var imageURL: String? { + return imageURLs?.first?.value + } + var tags: [String]? + var datePublished: Date? { + let interval = (publishedTimestamp as NSString).doubleValue + return Date(timeIntervalSince1970: interval) + } + + private var imageURLs: [String: String]? + private var publishedTimestamp: String + } +} + +extension NewsBlurStoriesResponse { + private enum CodingKeys: String, CodingKey { + case stories = "stories" + } +} + +extension NewsBlurStoriesResponse.Story { + private enum CodingKeys: String, CodingKey { + case storyID = "story_hash" + case feedID = "story_feed_id" + case title = "story_title" + case url = "story_permalink" + case authorName = "story_authors" + case contentHTML = "story_content" + case imageURLs = "secure_image_urls" + case tags = "story_tags" + case publishedTimestamp = "story_timestamp" + } +} diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurStoryHash.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurStoryHash.swift new file mode 100644 index 000000000..48286df00 --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurStoryHash.swift @@ -0,0 +1,80 @@ +// +// 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: [StoryHash]? + var starred: [StoryHash]? + + 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) { + let storyHashes = try NewsBlurStoryHashesResponse.extractHashes(container: unreadContainer) + self.unread = storyHashes.values.flatMap { $0 } + } + + // Parse starred + if let starredContainer = try? container.nestedUnkeyedContainer(forKey: .starred) { + self.starred = try NewsBlurStoryHashesResponse.extractArray(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 + } + + 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/Models/NewsBlurStoryStatusChange.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurStoryStatusChange.swift new file mode 100644 index 000000000..c07fd9220 --- /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: NewsBlurDataConvertible { + 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 new file mode 100644 index 000000000..9c974b2cd --- /dev/null +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -0,0 +1,279 @@ +// +// 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 + +final class NewsBlurAPICaller: NSObject { + static let SessionIdCookie = "newsblur_sessionid" + + let baseURL = URL(string: "https://www.newsblur.com/")! + var transport: Transport! + var suspended = false + + var credentials: Credentials? + weak var accountMetadata: AccountMetadata? + + init(transport: Transport!) { + super.init() + 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) { + 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 { + let error = payload?.errors?.username ?? payload?.errors?.others + if let message = error?.first { + completion(.failure(NewsBlurError.general(message: message))) + } else { + completion(.failure(NewsBlurError.unknown)) + } + return + } + + guard let username = self.credentials?.username else { + completion(.failure(NewsBlurError.unknown)) + 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)) + } + } + } + + func logout(completion: @escaping (Result) -> Void) { + requestData(endpoint: "api/logout", completion: completion) + } + + func retrieveFeeds(completion: @escaping (Result<([NewsBlurFeed]?, [NewsBlurFolder]?), Error>) -> Void) { + let url = baseURL + .appendingPathComponent("reader/feeds") + .appendingQueryItems([ + URLQueryItem(name: "flat", value: "true"), + URLQueryItem(name: "update_counts", value: "false"), + ]) + + requestData(callURL: url, resultType: NewsBlurFeedsResponse.self) { result in + switch result { + case .success((_, let payload)): + completion(.success((payload?.feeds, payload?.folders))) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func retrieveStoryHashes(endpoint: String, completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { + let url = baseURL + .appendingPathComponent(endpoint) + .appendingQueryItems([ + URLQueryItem(name: "include_timestamps", value: "true"), + ]) + + requestData( + callURL: url, + resultType: NewsBlurStoryHashesResponse.self, + dateDecoding: .secondsSince1970 + ) { result in + switch result { + case .success((_, let payload)): + let hashes = payload?.unread ?? payload?.starred + completion(.success(hashes)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + 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(feedID: String, page: Int, completion: @escaping (Result<([NewsBlurStory]?, Date?), 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(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]?, Date?), 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 (response, payload)): + completion(.success((payload?.stories, HTTPDateInfo(urlResponse: response)?.date))) + 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, folder: String?, completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/add_url", + payload: NewsBlurFeedChange.add(url, folder), + resultType: NewsBlurAddURLResponse.self + ) { result in + switch result { + case .success(_, let payload): + completion(.success(payload?.feed)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + 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)) + } + } + } + + 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 new file mode 100644 index 000000000..716ee1870 --- /dev/null +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -0,0 +1,621 @@ +// +// 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) + + let caller: NewsBlurAPICaller + let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "NewsBlur") + 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) -> ()) { + self.refreshProgress.addToNumberOfTasksAndRemaining(5) + + refreshFeeds(for: account) { result in + self.refreshProgress.completeTask() + + switch result { + case .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.refreshStories(for: account) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + self.refreshMissingStories(for: account) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + DispatchQueue.main.async { + completion(.success(())) + } + + case .failure(let error): + DispatchQueue.main.async { + self.refreshProgress.clear() + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + + 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)) + } + } + } + + func sendArticleStatus(for account: Account, completion: @escaping (Result) -> ()) { + 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(deleteUnreadStatuses, 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) -> ()) { + os_log(.debug, log: log, "Refreshing story statuses...") + + 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) { + os_log(.debug, log: log, "Refreshing stories...") + os_log(.debug, log: log, "Refreshing unread stories...") + + caller.retrieveUnreadStoryHashes { result in + switch result { + 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)) + } + } + } + + 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 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) { result in + group.leave() + if case .failure = result { + 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]?, 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) -> ()) { + completion(.success(())) + } + + 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) -> ()) { + 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) -> ()) { + guard let folderToRemove = folder.name else { + completion(.failure(NewsBlurError.invalidParameter)) + return + } + + var feedIDs: [String] = [] + for feed in folder.topLevelWebFeeds { + if (feed.folderRelationship?.count ?? 0) > 1 { + clearFolderRelationship(for: feed, withFolderName: folderToRemove) + } else if let feedID = feed.externalID { + feedIDs.append(feedID) + } + } + + refreshProgress.addToNumberOfTasksAndRemaining(1) + + caller.removeFolder(named: folderToRemove, feedIDs: feedIDs) { 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) -> ()) { + refreshProgress.addToNumberOfTasksAndRemaining(1) + + let folderName = (container as? Folder)?.name + caller.addURL(url, folder: folderName) { 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) -> ()) { + guard let feedID = feed.externalID 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.addWebFeed(feed) + } + completion(.success(())) + } + + return + } + + let folderName = folder.name ?? "" + saveFolderRelationship(for: feed, withFolderName: folderName, id: folderName) + folder.addWebFeed(feed) + + completion(.success(())) + } + + func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result) -> ()) { + deleteFeed(for: account, with: feed, from: container, completion: completion) + } + + func moveWebFeed(for account: Account, with feed: WebFeed, from: Container, to: Container, completion: @escaping (Result) -> ()) { + 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) -> ()) { + 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) -> ()) { + 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
? { + 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) { + credentials = try? account.retrieveCredentials(type: .newsBlurSessionId) + } + + 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) + } + } + } + + // 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() + } +} 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) } } } 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 @@ - - + + + + + + + + + + + + diff --git a/Mac/MainWindow/Detail/styleSheet.css b/Mac/MainWindow/Detail/styleSheet.css index 872a639ac..ea9723bb9 100644 --- a/Mac/MainWindow/Detail/styleSheet.css +++ b/Mac/MainWindow/Detail/styleSheet.css @@ -154,7 +154,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; 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() } 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..7324d8578 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 */; }; @@ -464,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 */; }; @@ -503,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 */; }; @@ -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 = ""; }; @@ -1450,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 = ""; }; @@ -1725,6 +1730,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 +1750,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 */, ); @@ -1868,6 +1875,7 @@ 51A1698F235E10D600EB091F /* LocalAccountViewController.swift */, 51A16996235E10D700EB091F /* FeedbinAccountViewController.swift */, 3B3A328B238B820900314204 /* FeedWranglerAccountViewController.swift */, + 769F2D3643779DB02786278E /* NewsBlurAccountViewController.swift */, ); path = Account; sourceTree = ""; @@ -2078,6 +2086,8 @@ 51C452B22265141B00C03939 /* Frameworks */ = { isa = PBXGroup; children = ( + 51E4DB072425F9EB0091EB5B /* CloudKit.framework */, + 51E4DAEC2425F6940091EB5B /* CloudKit.framework */, 51C452B32265141B00C03939 /* WebKit.framework */, ); name = Frameworks; @@ -3479,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 */, @@ -4061,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/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 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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 diff --git a/iOS/Account/Account.storyboard b/iOS/Account/Account.storyboard index 52982af01..dd9869dee 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..8a5cb2ddf --- /dev/null +++ b/iOS/Account/NewsBlurAccountViewController.swift @@ -0,0 +1,175 @@ +// +// 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 usernameTextField: 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 + 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 + 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: usernameTextField) + 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 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 trimmedUsername = username.trimmingCharacters(in: .whitespaces) + let credentials = Credentials(type: .newsBlurBasic, username: trimmedUsername, 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 username/password combination.", comment: "Credentials Error")) + } + case .failure(let error): + self.showError(error.localizedDescription) + } + + } + } + + @objc func textDidChange(_ note: Notification) { + actionButton.isEnabled = !(usernameTextField.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 000000000..5fab67691 Binary files /dev/null and b/iOS/Resources/Assets.xcassets/accountNewsBlur.imageset/newsblur-512.png differ 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 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 @@ +