Refactoring, fix folder changes not working, add new web feed

This commit is contained in:
Anh Do 2020-03-21 17:16:35 -04:00
parent ec855364bc
commit 70302a425c
No known key found for this signature in database
GPG Key ID: 451E3092F917B62D
9 changed files with 619 additions and 199 deletions

View File

@ -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 = "<group>"; };
179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurFeed.swift; sourceTree = "<group>"; };
179DB55DC2CAD332D4376416 /* NewsBlurAccountDelegate+Private.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NewsBlurAccountDelegate+Private.swift"; sourceTree = "<group>"; };
179DB3CBADAFCF5377DA3D02 /* NewsBlurFeedChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurFeedChange.swift; sourceTree = "<group>"; };
179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStoryStatusChange.swift; sourceTree = "<group>"; };
179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurGenericCodingKeys.swift; sourceTree = "<group>"; };
179DB7399814F6FB3247825C /* NewsBlurStory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStory.swift; sourceTree = "<group>"; };
179DB78C47256A122A281942 /* NewsBlurAccountDelegate+Internal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NewsBlurAccountDelegate+Internal.swift"; sourceTree = "<group>"; };
179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurStoryHash.swift; sourceTree = "<group>"; };
179DBBF346CF712AB2F0E9E6 /* NewsBlurAPICaller+Internal.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NewsBlurAPICaller+Internal.swift"; sourceTree = "<group>"; };
179DBDDC00B68411AA28941F /* NewsBlurFolderChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurFolderChange.swift; sourceTree = "<group>"; };
3B3A33E6238D3D6800314204 /* Secrets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Secrets.swift; path = ../../Shared/Secrets.swift; sourceTree = "<group>"; };
3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = "<group>"; };
@ -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 = "<group>";
};
179DBD810D353D9CED7C3BED /* Models */ = {
isa = PBXGroup;
children = (
@ -465,6 +478,7 @@
179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */,
179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */,
179DBDDC00B68411AA28941F /* NewsBlurFolderChange.swift */,
179DB3CBADAFCF5377DA3D02 /* NewsBlurFeedChange.swift */,
);
path = Models;
sourceTree = "<group>";
@ -563,7 +577,7 @@
769F2A8DF190549E24B5D110 /* NewsBlurAccountDelegate.swift */,
769F275FD5D942502C5B4716 /* NewsBlurAPICaller.swift */,
179DBD810D353D9CED7C3BED /* Models */,
179DB55DC2CAD332D4376416 /* NewsBlurAccountDelegate+Private.swift */,
179DB1571B95BAD0F833AF6D /* Internals */,
);
path = NewsBlur;
sourceTree = "<group>";
@ -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;
};

View File

@ -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 = [

View File

@ -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, Error>) -> Void
) {
let callURL = baseURL.appendingPathComponent(endpoint)
requestData(callURL: callURL, completion: completion)
}
// GET endpoint
func requestData<R: Decodable>(
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, Error>) -> Void
) {
let callURL = baseURL.appendingPathComponent(endpoint)
sendUpdates(callURL: callURL, payload: payload, completion: completion)
}
// POST to endpoint
func sendUpdates<R: Decodable>(
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, Error>) -> 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<R: Decodable>(
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, Error>) -> 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<R: Decodable>(
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))
}
}
}
}

View File

@ -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 dont 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<WebFeed, Error>) -> 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, Error>) -> 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<WebFeed, Error>) -> 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))
}
}
}
}
}

View File

@ -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) }

View File

@ -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)
}
}

View File

@ -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),

View File

@ -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<Credentials?, Error>) -> 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, Error>) -> 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, Error>) -> Void) {
sendUpdates(endpoint: "reader/mark_story_hash_as_unread", payload: NewsBlurStoryStatusChange(hashes: hashes), completion: completion)
}
func markAsRead(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) {
sendUpdates(endpoint: "reader/mark_story_hashes_as_read", payload: NewsBlurStoryStatusChange(hashes: hashes), completion: completion)
}
func star(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) {
sendUpdates(endpoint: "reader/mark_story_hash_as_starred", payload: NewsBlurStoryStatusChange(hashes: hashes), completion: completion)
}
func unstar(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) {
sendUpdates(endpoint: "reader/mark_story_hash_as_unstarred", payload: NewsBlurStoryStatusChange(hashes: hashes), completion: completion)
}
func addFolder(named name: String, completion: @escaping (Result<Void, Error>) -> Void) {
sendUpdates(endpoint: "reader/add_folder", payload: NewsBlurFolderChange.add(name), completion: completion)
}
func renameFolder(with folder: String, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
sendUpdates(endpoint: "reader/rename_folder", payload: NewsBlurFolderChange.rename(folder, name), completion: completion)
}
func removeFolder(named name: String, feedIDs: [String], completion: @escaping (Result<Void, Error>) -> 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, Error>) -> 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, Error>) -> Void) {
sendUpdates(
endpoint: "reader/mark_story_hash_as_unread",
payload: NewsBlurStoryStatusChange(hashes: hashes),
completion: completion
)
}
func markAsRead(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) {
sendUpdates(
endpoint: "reader/mark_story_hashes_as_read",
payload: NewsBlurStoryStatusChange(hashes: hashes),
completion: completion
)
}
func star(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) {
sendUpdates(
endpoint: "reader/mark_story_hash_as_starred",
payload: NewsBlurStoryStatusChange(hashes: hashes),
completion: completion
)
}
func unstar(hashes: [String], completion: @escaping (Result<Void, Error>) -> Void) {
sendUpdates(
endpoint: "reader/mark_story_hash_as_unstarred",
payload: NewsBlurStoryStatusChange(hashes: hashes),
completion: completion
)
}
func addFolder(named name: String, completion: @escaping (Result<Void, Error>) -> Void) {
sendUpdates(
endpoint: "reader/add_folder",
payload: NewsBlurFolderChange.add(name),
completion: completion
)
}
func renameFolder(with folder: String, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
sendUpdates(
endpoint: "reader/rename_folder",
payload: NewsBlurFolderChange.rename(folder, name),
completion: completion
)
}
func removeFolder(named name: String, feedIDs: [String], completion: @escaping (Result<Void, Error>) -> Void) {
sendUpdates(
endpoint: "reader/delete_folder",
payload: NewsBlurFolderChange.delete(name, feedIDs),
completion: completion
)
}
func addURL(_ url: String, completion: @escaping (Result<NewsBlurFeed?, Error>) -> 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))
}

View File

@ -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, Error>)-> Void) {
func refreshMissingStories(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
os_log(.debug, log: log, "Refreshing missing stories...")
account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate { result in
func process(_ fetchedHashes: Set<String>) {
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<Bool, DatabaseError>) -> 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<Void, Error>) -> ()) {
@ -383,13 +410,43 @@ final class NewsBlurAccountDelegate: AccountDelegate {
}
func createWebFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result<WebFeed, Error>) -> ()) {
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<Void, Error>) -> ()) {
completion(.success(()))
}
func addWebFeed(for account: Account, with: WebFeed, to container: Container, completion: @escaping (Result<Void, Error>) -> ()) {
func addWebFeed(for account: Account, with feed: WebFeed, to container: Container, completion: @escaping (Result<Void, Error>) -> ()) {
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(()))
}