diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 7f96e2abd..5a546215d 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -10,9 +10,11 @@ 179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB7399814F6FB3247825C /* NewsBlurStory.swift */; }; 179DB0B17A6C51B95ABC1741 /* NewsBlurStoryStatusChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */; }; 179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */; }; + 179DB3A93E3205EF29C2AF62 /* NewsBlurAPICaller+Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBBF346CF712AB2F0E9E6 /* NewsBlurAPICaller+Internal.swift */; }; 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */; }; - 179DB96B984E67DC101E470D /* NewsBlurAccountDelegate+Private.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB55DC2CAD332D4376416 /* NewsBlurAccountDelegate+Private.swift */; }; + 179DBCB4B11C88EBE852A015 /* NewsBlurFeedChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB3CBADAFCF5377DA3D02 /* NewsBlurFeedChange.swift */; }; 179DBD4ECC1C9712DF51DB8C /* NewsBlurFolderChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBDDC00B68411AA28941F /* NewsBlurFolderChange.swift */; }; + 179DBE829FDF48E102F73244 /* NewsBlurAccountDelegate+Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB78C47256A122A281942 /* NewsBlurAccountDelegate+Internal.swift */; }; 179DBED55C9B4D6A413486C1 /* NewsBlurStoryHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */; }; 179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */; }; 3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A33E6238D3D6800314204 /* Secrets.swift */; }; @@ -232,11 +234,13 @@ /* Begin PBXFileReference section */ 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurLoginResponse.swift; sourceTree = ""; }; 179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurFeed.swift; sourceTree = ""; }; - 179DB55DC2CAD332D4376416 /* NewsBlurAccountDelegate+Private.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NewsBlurAccountDelegate+Private.swift"; sourceTree = ""; }; + 179DB3CBADAFCF5377DA3D02 /* NewsBlurFeedChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurFeedChange.swift; sourceTree = ""; }; 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStoryStatusChange.swift; sourceTree = ""; }; 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurGenericCodingKeys.swift; sourceTree = ""; }; 179DB7399814F6FB3247825C /* NewsBlurStory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStory.swift; sourceTree = ""; }; + 179DB78C47256A122A281942 /* NewsBlurAccountDelegate+Internal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NewsBlurAccountDelegate+Internal.swift"; sourceTree = ""; }; 179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStoryHash.swift; sourceTree = ""; }; + 179DBBF346CF712AB2F0E9E6 /* NewsBlurAPICaller+Internal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NewsBlurAPICaller+Internal.swift"; sourceTree = ""; }; 179DBDDC00B68411AA28941F /* NewsBlurFolderChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurFolderChange.swift; sourceTree = ""; }; 3B3A33E6238D3D6800314204 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Secrets.swift; path = ../../Shared/Secrets.swift; sourceTree = ""; }; 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = ""; }; @@ -455,6 +459,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 179DB1571B95BAD0F833AF6D /* Internals */ = { + isa = PBXGroup; + children = ( + 179DB78C47256A122A281942 /* NewsBlurAccountDelegate+Internal.swift */, + 179DBBF346CF712AB2F0E9E6 /* NewsBlurAPICaller+Internal.swift */, + ); + path = Internals; + sourceTree = ""; + }; 179DBD810D353D9CED7C3BED /* Models */ = { isa = PBXGroup; children = ( @@ -465,6 +478,7 @@ 179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */, 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */, 179DBDDC00B68411AA28941F /* NewsBlurFolderChange.swift */, + 179DB3CBADAFCF5377DA3D02 /* NewsBlurFeedChange.swift */, ); path = Models; sourceTree = ""; @@ -563,7 +577,7 @@ 769F2A8DF190549E24B5D110 /* NewsBlurAccountDelegate.swift */, 769F275FD5D942502C5B4716 /* NewsBlurAPICaller.swift */, 179DBD810D353D9CED7C3BED /* Models */, - 179DB55DC2CAD332D4376416 /* NewsBlurAccountDelegate+Private.swift */, + 179DB1571B95BAD0F833AF6D /* Internals */, ); path = NewsBlur; sourceTree = ""; @@ -1161,8 +1175,10 @@ 179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */, 179DBED55C9B4D6A413486C1 /* NewsBlurStoryHash.swift in Sources */, 179DB0B17A6C51B95ABC1741 /* NewsBlurStoryStatusChange.swift in Sources */, - 179DB96B984E67DC101E470D /* NewsBlurAccountDelegate+Private.swift in Sources */, 179DBD4ECC1C9712DF51DB8C /* NewsBlurFolderChange.swift in Sources */, + 179DBCB4B11C88EBE852A015 /* NewsBlurFeedChange.swift in Sources */, + 179DBE829FDF48E102F73244 /* NewsBlurAccountDelegate+Internal.swift in Sources */, + 179DB3A93E3205EF29C2AF62 /* NewsBlurAPICaller+Internal.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift index b0cca5a23..f82f3de02 100755 --- a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift +++ b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift @@ -34,7 +34,7 @@ public extension URLRequest { case .feedWranglerToken: self.url = url.appendingQueryItem(URLQueryItem(name: "access_token", value: credentials.secret)) case .newsBlurBasic: - setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType) httpMethod = "POST" var postData = URLComponents() postData.queryItems = [ diff --git a/Frameworks/Account/NewsBlur/Internals/NewsBlurAPICaller+Internal.swift b/Frameworks/Account/NewsBlur/Internals/NewsBlurAPICaller+Internal.swift new file mode 100644 index 000000000..078bb1d36 --- /dev/null +++ b/Frameworks/Account/NewsBlur/Internals/NewsBlurAPICaller+Internal.swift @@ -0,0 +1,236 @@ +// +// NewsBlurAPICaller+Internal.swift +// Account +// +// Created by Anh Quang Do on 2020-03-21. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSWeb + +protocol NewsBlurDataConvertible { + var asData: Data? { get } +} + +enum NewsBlurError: LocalizedError { + case general(message: String) + case invalidParameter + case unknown + + var errorDescription: String? { + switch self { + case .general(let message): + return message + case .invalidParameter: + return "There was an invalid parameter passed" + case .unknown: + return "An unknown error occurred" + } + } +} + +// MARK: - Interact with endpoints + +extension NewsBlurAPICaller { + // GET endpoint, discard response + func requestData( + endpoint: String, + completion: @escaping (Result) -> Void + ) { + let callURL = baseURL.appendingPathComponent(endpoint) + + requestData(callURL: callURL, completion: completion) + } + + // GET endpoint + func requestData( + endpoint: String, + resultType: R.Type, + dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, + keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, + completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void + ) { + let callURL = baseURL.appendingPathComponent(endpoint) + + requestData( + callURL: callURL, + resultType: resultType, + dateDecoding: dateDecoding, + keyDecoding: keyDecoding, + completion: completion + ) + } + + // POST to endpoint, discard response + func sendUpdates( + endpoint: String, + payload: NewsBlurDataConvertible, + completion: @escaping (Result) -> Void + ) { + let callURL = baseURL.appendingPathComponent(endpoint) + + sendUpdates(callURL: callURL, payload: payload, completion: completion) + } + + // POST to endpoint + func sendUpdates( + endpoint: String, + payload: NewsBlurDataConvertible, + resultType: R.Type, + dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, + keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, + completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void + ) { + let callURL = baseURL.appendingPathComponent(endpoint) + + sendUpdates( + callURL: callURL, + payload: payload, + resultType: resultType, + dateDecoding: dateDecoding, + keyDecoding: keyDecoding, + completion: completion + ) + } +} + +// MARK: - Interact with URLs + +extension NewsBlurAPICaller { + // GET URL with params, discard response + func requestData( + callURL: URL?, + completion: @escaping (Result) -> Void + ) { + guard let callURL = callURL else { + completion(.failure(TransportError.noURL)) + return + } + + let request = URLRequest(url: callURL, credentials: credentials) + + transport.send(request: request) { result in + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } + + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } + + // GET URL with params + func requestData( + callURL: URL?, + resultType: R.Type, + dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, + keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, + completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void + ) { + guard let callURL = callURL else { + completion(.failure(TransportError.noURL)) + return + } + + let request = URLRequest(url: callURL, credentials: credentials) + + transport.send( + request: request, + resultType: resultType, + dateDecoding: dateDecoding, + keyDecoding: keyDecoding + ) { result in + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } + + switch result { + case .success(let response): + completion(.success(response)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + // POST to URL with params, discard response + func sendUpdates( + callURL: URL?, + payload: NewsBlurDataConvertible, + completion: @escaping (Result) -> Void + ) { + guard let callURL = callURL else { + completion(.failure(TransportError.noURL)) + return + } + + var request = URLRequest(url: callURL, credentials: credentials) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType) + request.httpBody = payload.asData + + transport.send(request: request, method: HTTPMethod.post) { result in + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } + + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + } + } + + // POST to URL with params + func sendUpdates( + callURL: URL?, + payload: NewsBlurDataConvertible, + resultType: R.Type, + dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, + keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, + completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void + ) { + guard let callURL = callURL else { + completion(.failure(TransportError.noURL)) + return + } + + guard let data = payload.asData else { + completion(.failure(NewsBlurError.invalidParameter)) + return + } + + var request = URLRequest(url: callURL, credentials: credentials) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType) + + transport.send( + request: request, + method: HTTPMethod.post, + data: data, + resultType: resultType, + dateDecoding: dateDecoding, + keyDecoding: keyDecoding + ) { result in + if self.suspended { + completion(.failure(TransportError.suspended)) + return + } + + switch result { + case .success(let response): + completion(.success(response)) + case .failure(let error): + completion(.failure(error)) + } + } + } +} diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate+Private.swift b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift similarity index 78% rename from Frameworks/Account/NewsBlur/NewsBlurAccountDelegate+Private.swift rename to Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift index 51a8e6fc7..698d1d3c2 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate+Private.swift +++ b/Frameworks/Account/NewsBlur/Internals/NewsBlurAccountDelegate+Internal.swift @@ -1,5 +1,5 @@ // -// NewsBlurAccountDelegate+Private.swift +// NewsBlurAccountDelegate+Internal.swift // Mostly adapted from FeedbinAccountDelegate.swift // Account // @@ -107,7 +107,7 @@ extension NewsBlurAccountDelegate { webFeed.name = feed.name // If the name has been changed on the server remove the locally edited name webFeed.editedName = nil - webFeed.homePageURL = feed.homepageURL + webFeed.homePageURL = feed.homePageURL webFeed.subscriptionID = String(feed.feedID) webFeed.faviconURL = feed.faviconURL } @@ -118,7 +118,7 @@ extension NewsBlurAccountDelegate { // Actually add feeds all in one go, so we don’t trigger various rebuilding things that Account does. feedsToAdd.forEach { feed in - let webFeed = account.createWebFeed(with: feed.name, url: feed.feedURL, webFeedID: String(feed.feedID), homePageURL: feed.homepageURL) + let webFeed = account.createWebFeed(with: feed.name, url: feed.feedURL, webFeedID: String(feed.feedID), homePageURL: feed.homePageURL) webFeed.subscriptionID = String(feed.feedID) account.addWebFeed(webFeed) } @@ -232,10 +232,10 @@ extension NewsBlurAccountDelegate { caller.retrieveStories(hashes: hashesToFetch) { result in switch result { case .success(let stories): - self.processStories(account: account, stories: stories) { error in + self.processStories(account: account, stories: stories) { result in self.refreshProgress.completeTask() - if let error = error { + if case .failure(let error) = result { completion(.failure(error)) return } @@ -371,4 +371,104 @@ extension NewsBlurAccountDelegate { } } } + + func createFeed(account: Account, feed: NewsBlurFeed?, name: String?, container: Container, completion: @escaping (Result) -> Void) { + guard let feed = feed else { + completion(.failure(NewsBlurError.invalidParameter)) + return + } + + DispatchQueue.main.async { + let webFeed = account.createWebFeed(with: feed.name, url: feed.feedURL, webFeedID: String(feed.feedID), homePageURL: feed.homePageURL) + webFeed.subscriptionID = String(feed.feedID) + webFeed.faviconURL = feed.faviconURL + + account.addWebFeed(webFeed, to: container) { result in + switch result { + case .success: + if let name = name { + account.renameWebFeed(webFeed, to: name) { result in + switch result { + case .success: + self.initialFeedDownload(account: account, feed: webFeed, completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + } else { + self.initialFeedDownload(account: account, feed: webFeed, completion: completion) + } + case .failure(let error): + completion(.failure(error)) + } + } + } + } + + func downloadFeed(account: Account, feed: WebFeed, page: Int, completion: @escaping (Result) -> Void) { + refreshProgress.addToNumberOfTasksAndRemaining(1) + + caller.retrieveStories(feedID: feed.webFeedID, page: page) { result in + switch result { + case .success(let stories): + // No more stories + guard let stories = stories, stories.count > 0 else { + self.refreshProgress.completeTask() + + completion(.success(())) + return + } + + let since = Calendar.current.date(byAdding: .month, value: -3, to: Date()) + self.processStories(account: account, stories: stories, since: since) { result in + self.refreshProgress.completeTask() + + if case .failure(let error) = result { + completion(.failure(error)) + return + } + + // No more recent stories + if case .success(let hasStories) = result, !hasStories { + completion(.success(())) + return + } + + self.downloadFeed(account: account, feed: feed, page: page + 1, completion: completion) + } + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func initialFeedDownload(account: Account, feed: WebFeed, completion: @escaping (Result) -> Void) { + refreshProgress.addToNumberOfTasksAndRemaining(1) + + // Download the initial articles + downloadFeed(account: account, feed: feed, page: 1) { result in + self.refreshArticleStatus(for: account) { result in + switch result { + case .success: + self.refreshMissingStories(for: account) { result in + switch result { + case .success: + self.refreshProgress.completeTask() + + DispatchQueue.main.async { + completion(.success(feed)) + } + + case .failure(let error): + completion(.failure(error)) + } + } + + case .failure(let error): + completion(.failure(error)) + } + } + } + } } diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift index a2641136a..ad3fb5497 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFeed.swift @@ -10,20 +10,19 @@ import Foundation import RSCore import RSParser -typealias NewsBlurFeed = NewsBlurFeedsResponse.Feed typealias NewsBlurFolder = NewsBlurFeedsResponse.Folder -struct NewsBlurFeedsResponse: Decodable { - let feeds: [Feed] - let folders: [Folder] +struct NewsBlurFeed: Hashable, Codable { + let name: String + let feedID: Int + let feedURL: String + let homePageURL: String? + let faviconURL: String? +} - struct Feed: Hashable, Codable { - let name: String - let feedID: Int - let feedURL: String - let homepageURL: String? - let faviconURL: String? - } +struct NewsBlurFeedsResponse: Decodable { + let feeds: [NewsBlurFeed] + let folders: [Folder] struct Folder: Hashable, Codable { let name: String @@ -31,11 +30,25 @@ struct NewsBlurFeedsResponse: Decodable { } } +struct NewsBlurAddURLResponse: Decodable { + let feed: NewsBlurFeed? +} + struct NewsBlurFolderRelationship: Codable { let folderName: String let feedID: Int } +extension NewsBlurFeed { + private enum CodingKeys: String, CodingKey { + case name = "feed_title" + case feedID = "id" + case feedURL = "feed_address" + case homePageURL = "feed_link" + case faviconURL = "favicon_url" + } +} + extension NewsBlurFeedsResponse { private enum CodingKeys: String, CodingKey { case feeds = "feeds" @@ -47,10 +60,10 @@ extension NewsBlurFeedsResponse { let container = try decoder.container(keyedBy: CodingKeys.self) // Parse feeds - var feeds: [Feed] = [] + var feeds: [NewsBlurFeed] = [] let feedContainer = try container.nestedContainer(keyedBy: NewsBlurGenericCodingKeys.self, forKey: .feeds) try feedContainer.allKeys.forEach { key in - let subscription = try feedContainer.decode(Feed.self, forKey: key) + let subscription = try feedContainer.decode(NewsBlurFeed.self, forKey: key) feeds.append(subscription) } @@ -71,16 +84,6 @@ extension NewsBlurFeedsResponse { } } -extension NewsBlurFeedsResponse.Feed { - private enum CodingKeys: String, CodingKey { - case name = "feed_title" - case feedID = "id" - case feedURL = "feed_address" - case homepageURL = "feed_link" - case faviconURL = "favicon_url" - } -} - extension NewsBlurFeedsResponse.Folder { var asRelationships: [NewsBlurFolderRelationship] { return feedIDs.map { NewsBlurFolderRelationship(folderName: name, feedID: $0) } diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurFeedChange.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFeedChange.swift new file mode 100644 index 000000000..e3082fe3d --- /dev/null +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFeedChange.swift @@ -0,0 +1,30 @@ +// +// NewsBlurFeedChange.swift +// Account +// +// Created by Anh Quang Do on 2020-03-14. +// Copyright (c) 2020 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +enum NewsBlurFeedChange { + case add(String) +} + +extension NewsBlurFeedChange: NewsBlurDataConvertible { + var asData: Data? { + var postData = URLComponents() + postData.queryItems = { + switch self { + case .add(let url): + return [ + URLQueryItem(name: "url", value: url), + URLQueryItem(name: "folder", value: ""), // root folder + ] + } + }() + + return postData.percentEncodedQuery?.data(using: .utf8) + } +} diff --git a/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift b/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift index e122cce07..ed2df52cd 100644 --- a/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift +++ b/Frameworks/Account/NewsBlur/Models/NewsBlurFolderChange.swift @@ -20,7 +20,10 @@ extension NewsBlurFolderChange: NewsBlurDataConvertible { postData.queryItems = { switch self { case .add(let name): - return [URLQueryItem(name: "folder", value: name)] + return [ + URLQueryItem(name: "folder", value: name), + URLQueryItem(name: "parent_folder", value: ""), // root folder + ] case .rename(let from, let to): return [ URLQueryItem(name: "folder_to_rename", value: from), diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift index 1eb938f85..fc58042e3 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift @@ -9,33 +9,12 @@ import Foundation import RSWeb -protocol NewsBlurDataConvertible { - var asData: Data? { get } -} - -enum NewsBlurError: LocalizedError { - case general(message: String) - case invalidParameter - case unknown - - var errorDescription: String? { - switch self { - case .general(let message): - return message - case .invalidParameter: - return "There was an invalid parameter passed" - case .unknown: - return "An unknown error occurred" - } - } -} - final class NewsBlurAPICaller: NSObject { static let SessionIdCookie = "newsblur_sessionid" - private let baseURL = URL(string: "https://www.newsblur.com/")! - private var transport: Transport! - private var suspended = false + let baseURL = URL(string: "https://www.newsblur.com/")! + var transport: Transport! + var suspended = false var credentials: Credentials? weak var accountMetadata: AccountMetadata? @@ -56,15 +35,7 @@ final class NewsBlurAPICaller: NSObject { } func validateCredentials(completion: @escaping (Result) -> Void) { - let url = baseURL.appendingPathComponent("api/login") - let request = URLRequest(url: url, credentials: credentials) - - transport.send(request: request, resultType: NewsBlurLoginResponse.self) { result in - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } - + requestData(endpoint: "api/login", resultType: NewsBlurLoginResponse.self) { result in switch result { case .success(let response, let payload): guard let url = response.url, let headerFields = response.allHeaderFields as? [String: String], payload?.code != -1 else { @@ -97,22 +68,7 @@ final class NewsBlurAPICaller: NSObject { } func logout(completion: @escaping (Result) -> Void) { - let url = baseURL.appendingPathComponent("api/logout") - let request = URLRequest(url: url, credentials: credentials) - - transport.send(request: request) { result in - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } - - switch result { - case .success: - completion(.success(())) - case .failure(let error): - completion(.failure(error)) - } - } + requestData(endpoint: "api/logout", completion: completion) } func retrieveFeeds(completion: @escaping (Result<([NewsBlurFeed]?, [NewsBlurFolder]?), Error>) -> Void) { @@ -123,18 +79,7 @@ final class NewsBlurAPICaller: NSObject { URLQueryItem(name: "update_counts", value: "false"), ]) - guard let callURL = url else { - completion(.failure(TransportError.noURL)) - return - } - - let request = URLRequest(url: callURL, credentials: credentials) - transport.send(request: request, resultType: NewsBlurFeedsResponse.self) { result in - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } - + requestData(callURL: url, resultType: NewsBlurFeedsResponse.self) { result in switch result { case .success((_, let payload)): completion(.success((payload?.feeds, payload?.folders))) @@ -144,92 +89,18 @@ final class NewsBlurAPICaller: NSObject { } } - func retrieveUnreadStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { - retrieveStoryHashes(endpoint: "reader/unread_story_hashes", completion: completion) - } - - func retrieveStarredStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { - retrieveStoryHashes(endpoint: "reader/starred_story_hashes", completion: completion) - } - - func retrieveStories(hashes: [NewsBlurStoryHash], completion: @escaping (Result<[NewsBlurStory]?, Error>) -> Void) { - let url = baseURL - .appendingPathComponent("reader/river_stories") - .appendingQueryItem(.init(name: "include_hidden", value: "true"))? - .appendingQueryItems(hashes.map { - URLQueryItem(name: "h", value: $0.hash) - }) - - guard let callURL = url else { - completion(.failure(TransportError.noURL)) - return - } - - let request = URLRequest(url: callURL, credentials: credentials) - transport.send(request: request, resultType: NewsBlurStoriesResponse.self) { result in - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } - - switch result { - case .success((_, let payload)): - completion(.success(payload?.stories)) - case .failure(let error): - completion(.failure(error)) - } - } - } - - func markAsUnread(hashes: [String], completion: @escaping (Result) -> Void) { - sendUpdates(endpoint: "reader/mark_story_hash_as_unread", payload: NewsBlurStoryStatusChange(hashes: hashes), completion: completion) - } - - func markAsRead(hashes: [String], completion: @escaping (Result) -> Void) { - sendUpdates(endpoint: "reader/mark_story_hashes_as_read", payload: NewsBlurStoryStatusChange(hashes: hashes), completion: completion) - } - - func star(hashes: [String], completion: @escaping (Result) -> Void) { - sendUpdates(endpoint: "reader/mark_story_hash_as_starred", payload: NewsBlurStoryStatusChange(hashes: hashes), completion: completion) - } - - func unstar(hashes: [String], completion: @escaping (Result) -> Void) { - sendUpdates(endpoint: "reader/mark_story_hash_as_unstarred", payload: NewsBlurStoryStatusChange(hashes: hashes), completion: completion) - } - - func addFolder(named name: String, completion: @escaping (Result) -> Void) { - sendUpdates(endpoint: "reader/add_folder", payload: NewsBlurFolderChange.add(name), completion: completion) - } - - func renameFolder(with folder: String, to name: String, completion: @escaping (Result) -> Void) { - sendUpdates(endpoint: "reader/rename_folder", payload: NewsBlurFolderChange.rename(folder, name), completion: completion) - } - - func removeFolder(named name: String, feedIDs: [String], completion: @escaping (Result) -> Void) { - sendUpdates(endpoint: "reader/delete_folder", payload: NewsBlurFolderChange.delete(name, feedIDs), completion: completion) - } -} - -extension NewsBlurAPICaller { - private func retrieveStoryHashes(endpoint: String, completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { + func retrieveStoryHashes(endpoint: String, completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { let url = baseURL .appendingPathComponent(endpoint) .appendingQueryItems([ URLQueryItem(name: "include_timestamps", value: "true"), ]) - guard let callURL = url else { - completion(.failure(TransportError.noURL)) - return - } - - let request = URLRequest(url: callURL, credentials: credentials) - transport.send(request: request, resultType: NewsBlurStoryHashesResponse.self, dateDecoding: .secondsSince1970) { result in - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } - + requestData( + callURL: url, + resultType: NewsBlurStoryHashesResponse.self, + dateDecoding: .secondsSince1970 + ) { result in switch result { case .success((_, let payload)): let hashes = payload?.unread ?? payload?.starred @@ -240,20 +111,124 @@ extension NewsBlurAPICaller { } } - private func sendUpdates(endpoint: String, payload: NewsBlurDataConvertible, completion: @escaping (Result) -> Void) { - let callURL = baseURL.appendingPathComponent(endpoint) + func retrieveUnreadStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { + retrieveStoryHashes( + endpoint: "reader/unread_story_hashes", + completion: completion + ) + } - var request = URLRequest(url: callURL, credentials: credentials) - request.httpBody = payload.asData - transport.send(request: request, method: HTTPMethod.post) { result in - if self.suspended { - completion(.failure(TransportError.suspended)) - return - } + func retrieveStarredStoryHashes(completion: @escaping (Result<[NewsBlurStoryHash]?, Error>) -> Void) { + retrieveStoryHashes( + endpoint: "reader/starred_story_hashes", + completion: completion + ) + } + func retrieveStories(feedID: String, page: Int, completion: @escaping (Result<[NewsBlurStory]?, Error>) -> Void) { + let url = baseURL + .appendingPathComponent("reader/feed/\(feedID)") + .appendingQueryItems([ + URLQueryItem(name: "page", value: String(page)), + URLQueryItem(name: "order", value: "newest"), + URLQueryItem(name: "read_filter", value: "all"), + URLQueryItem(name: "include_hidden", value: "true"), + URLQueryItem(name: "include_story_content", value: "true"), + ]) + + requestData(callURL: url, resultType: NewsBlurStoriesResponse.self) { result in switch result { - case .success: - completion(.success(())) + case .success((_, let payload)): + completion(.success(payload?.stories)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func retrieveStories(hashes: [NewsBlurStoryHash], completion: @escaping (Result<[NewsBlurStory]?, Error>) -> Void) { + let url = baseURL + .appendingPathComponent("reader/river_stories") + .appendingQueryItem(.init(name: "include_hidden", value: "true"))? + .appendingQueryItems(hashes.map { + URLQueryItem(name: "h", value: $0.hash) + }) + + requestData(callURL: url, resultType: NewsBlurStoriesResponse.self) { result in + switch result { + case .success((_, let payload)): + completion(.success(payload?.stories)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + func markAsUnread(hashes: [String], completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/mark_story_hash_as_unread", + payload: NewsBlurStoryStatusChange(hashes: hashes), + completion: completion + ) + } + + func markAsRead(hashes: [String], completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/mark_story_hashes_as_read", + payload: NewsBlurStoryStatusChange(hashes: hashes), + completion: completion + ) + } + + func star(hashes: [String], completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/mark_story_hash_as_starred", + payload: NewsBlurStoryStatusChange(hashes: hashes), + completion: completion + ) + } + + func unstar(hashes: [String], completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/mark_story_hash_as_unstarred", + payload: NewsBlurStoryStatusChange(hashes: hashes), + completion: completion + ) + } + + func addFolder(named name: String, completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/add_folder", + payload: NewsBlurFolderChange.add(name), + completion: completion + ) + } + + func renameFolder(with folder: String, to name: String, completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/rename_folder", + payload: NewsBlurFolderChange.rename(folder, name), + completion: completion + ) + } + + func removeFolder(named name: String, feedIDs: [String], completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/delete_folder", + payload: NewsBlurFolderChange.delete(name, feedIDs), + completion: completion + ) + } + + func addURL(_ url: String, completion: @escaping (Result) -> Void) { + sendUpdates( + endpoint: "reader/add_url", + payload: NewsBlurFeedChange.add(url), + resultType: NewsBlurAddURLResponse.self + ) { result in + switch result { + case .success(_, let payload): + completion(.success(payload?.feed)) case .failure(let error): completion(.failure(error)) } diff --git a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift index 6dd3d9f28..96bfebd4e 100644 --- a/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift +++ b/Frameworks/Account/NewsBlur/NewsBlurAccountDelegate.swift @@ -125,10 +125,18 @@ final class NewsBlurAccountDelegate: AccountDelegate { database.selectForProcessing { result in func processStatuses(_ syncStatuses: [SyncStatus]) { - let createUnreadStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.read && $0.flag == false } - let deleteUnreadStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.read && $0.flag == true } - let createStarredStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.starred && $0.flag == true } - let deleteStarredStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.starred && $0.flag == false } + let createUnreadStatuses = syncStatuses.filter { + $0.key == ArticleStatus.Key.read && $0.flag == false + } + let deleteUnreadStatuses = syncStatuses.filter { + $0.key == ArticleStatus.Key.read && $0.flag == true + } + let createStarredStatuses = syncStatuses.filter { + $0.key == ArticleStatus.Key.starred && $0.flag == true + } + let deleteStarredStatuses = syncStatuses.filter { + $0.key == ArticleStatus.Key.starred && $0.flag == false + } let group = DispatchGroup() var errorOccurred = false @@ -246,15 +254,18 @@ final class NewsBlurAccountDelegate: AccountDelegate { } } - func refreshMissingStories(for account: Account, completion: @escaping (Result)-> Void) { + func refreshMissingStories(for account: Account, completion: @escaping (Result) -> Void) { os_log(.debug, log: log, "Refreshing missing stories...") account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate { result in + func process(_ fetchedHashes: Set) { let group = DispatchGroup() var errorOccurred = false - let storyHashes = Array(fetchedHashes).map { NewsBlurStoryHash(hash: $0, timestamp: Date()) } + let storyHashes = Array(fetchedHashes).map { + NewsBlurStoryHash(hash: $0, timestamp: Date()) + } let chunkedStoryHashes = storyHashes.chunked(into: 100) for chunk in chunkedStoryHashes { @@ -263,9 +274,9 @@ final class NewsBlurAccountDelegate: AccountDelegate { switch result { case .success(let stories): - self.processStories(account: account, stories: stories) { error in + self.processStories(account: account, stories: stories) { result in group.leave() - if error != nil { + if case .failure = result { errorOccurred = true } } @@ -298,10 +309,26 @@ final class NewsBlurAccountDelegate: AccountDelegate { } } - func processStories(account: Account, stories: [NewsBlurStory]?, completion: @escaping DatabaseCompletionBlock) { - let parsedItems = mapStoriesToParsedItems(stories: stories) - let webFeedIDsAndItems = Dictionary(grouping: parsedItems, by: { item in item.feedURL } ).mapValues { Set($0) } - account.update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: true, completion: completion) + func processStories(account: Account, stories: [NewsBlurStory]?, since: Date? = nil, completion: @escaping (Result) -> Void) { + let parsedItems = mapStoriesToParsedItems(stories: stories).filter { + guard let datePublished = $0.datePublished, let since = since else { + return true + } + + return datePublished >= since + } + let webFeedIDsAndItems = Dictionary(grouping: parsedItems, by: { item in item.feedURL }).mapValues { + Set($0) + } + + account.update(webFeedIDsAndItems: webFeedIDsAndItems, defaultRead: true) { error in + if let error = error { + completion(.failure(error)) + return + } + + completion(.success(!webFeedIDsAndItems.isEmpty)) + } } func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result) -> ()) { @@ -383,13 +410,43 @@ final class NewsBlurAccountDelegate: AccountDelegate { } func createWebFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result) -> ()) { + refreshProgress.addToNumberOfTasksAndRemaining(1) + + caller.addURL(url) { result in + self.refreshProgress.completeTask() + + switch result { + case .success(let feed): + self.createFeed(account: account, feed: feed, name: name, container: container, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } } func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result) -> ()) { completion(.success(())) } - func addWebFeed(for account: Account, with: WebFeed, to container: Container, completion: @escaping (Result) -> ()) { + func addWebFeed(for account: Account, with feed: WebFeed, to container: Container, completion: @escaping (Result) -> ()) { + guard let folder = container as? Folder else { + DispatchQueue.main.async { + if let account = container as? Account { + account.addFeedIfNotInAnyFolder(feed) + } + completion(.success(())) + } + + return + } + + let folderName = folder.name ?? "" + saveFolderRelationship(for: feed, withFolderName: folderName, id: folderName) + folder.addWebFeed(feed) + completion(.success(())) }