Merge pull request #1908 from quanganhdo/newsblur-support

Newsblur support
This commit is contained in:
Maurice Parker 2020-03-22 10:28:07 -05:00 committed by GitHub
commit c41c551093
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 2540 additions and 3 deletions

View File

@ -243,6 +243,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
self.delegate = FeedlyAccountDelegate(dataFolder: dataFolder, transport: transport, api: FeedlyAccountDelegate.environment) self.delegate = FeedlyAccountDelegate(dataFolder: dataFolder, transport: transport, api: FeedlyAccountDelegate.environment)
case .feedWrangler: case .feedWrangler:
self.delegate = FeedWranglerAccountDelegate(dataFolder: dataFolder, transport: transport) self.delegate = FeedWranglerAccountDelegate(dataFolder: dataFolder, transport: transport)
case .newsBlur:
self.delegate = NewsBlurAccountDelegate(dataFolder: dataFolder, transport: transport)
default: default:
return nil return nil
} }
@ -325,6 +327,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
ReaderAPIAccountDelegate.validateCredentials(transport: transport, credentials: credentials, endpoint: endpoint, completion: completion) ReaderAPIAccountDelegate.validateCredentials(transport: transport, credentials: credentials, endpoint: endpoint, completion: completion)
case .feedWrangler: case .feedWrangler:
FeedWranglerAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion) FeedWranglerAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion)
case .newsBlur:
NewsBlurAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion)
default: default:
break break
} }

View File

@ -7,6 +7,16 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* 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 */; }; 3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A33E6238D3D6800314204 /* Secrets.swift */; };
3B826DA72385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */; }; 3B826DA72385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */; };
3B826DA82385C81C00FC1ADB /* FeedWranglerFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */; }; 3B826DA82385C81C00FC1ADB /* FeedWranglerFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */; };
@ -63,6 +73,8 @@
552032FD229D5D5A009559E0 /* ReaderAPITagging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F2229D5D5A009559E0 /* ReaderAPITagging.swift */; }; 552032FD229D5D5A009559E0 /* ReaderAPITagging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F2229D5D5A009559E0 /* ReaderAPITagging.swift */; };
552032FE229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F3229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift */; }; 552032FE229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F3229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift */; };
55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F5229D5D5A009559E0 /* ReaderAPICaller.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 */; }; 841973FE1F6DD1BC006346C4 /* RSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841973EF1F6DD19E006346C4 /* RSCore.framework */; };
841973FF1F6DD1C5006346C4 /* RSParser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841973FA1F6DD1AC006346C4 /* RSParser.framework */; }; 841973FF1F6DD1C5006346C4 /* RSParser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841973FA1F6DD1AC006346C4 /* RSParser.framework */; };
841974011F6DD1EC006346C4 /* Folder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841974001F6DD1EC006346C4 /* Folder.swift */; }; 841974011F6DD1EC006346C4 /* Folder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841974001F6DD1EC006346C4 /* Folder.swift */; };
@ -220,6 +232,16 @@
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */ /* 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>"; };
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>"; }; 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>"; }; 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = "<group>"; };
3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItem.swift; sourceTree = "<group>"; }; 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItem.swift; sourceTree = "<group>"; };
@ -278,6 +300,8 @@
552032F2229D5D5A009559E0 /* ReaderAPITagging.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPITagging.swift; sourceTree = "<group>"; }; 552032F2229D5D5A009559E0 /* ReaderAPITagging.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPITagging.swift; sourceTree = "<group>"; };
552032F3229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPIAccountDelegate.swift; sourceTree = "<group>"; }; 552032F3229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPIAccountDelegate.swift; sourceTree = "<group>"; };
552032F5229D5D5A009559E0 /* ReaderAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPICaller.swift; sourceTree = "<group>"; }; 552032F5229D5D5A009559E0 /* ReaderAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPICaller.swift; sourceTree = "<group>"; };
769F275FD5D942502C5B4716 /* NewsBlurAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurAPICaller.swift; sourceTree = "<group>"; };
769F2A8DF190549E24B5D110 /* NewsBlurAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurAccountDelegate.swift; sourceTree = "<group>"; };
841973E81F6DD19E006346C4 /* RSCore.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSCore.xcodeproj; path = ../RSCore/RSCore.xcodeproj; sourceTree = "<group>"; }; 841973E81F6DD19E006346C4 /* RSCore.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSCore.xcodeproj; path = ../RSCore/RSCore.xcodeproj; sourceTree = "<group>"; };
841973F41F6DD1AC006346C4 /* RSParser.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSParser.xcodeproj; path = ../RSParser/RSParser.xcodeproj; sourceTree = "<group>"; }; 841973F41F6DD1AC006346C4 /* RSParser.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSParser.xcodeproj; path = ../RSParser/RSParser.xcodeproj; sourceTree = "<group>"; };
841974001F6DD1EC006346C4 /* Folder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Folder.swift; sourceTree = "<group>"; }; 841974001F6DD1EC006346C4 /* Folder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Folder.swift; sourceTree = "<group>"; };
@ -435,6 +459,30 @@
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup 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 = (
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 = "<group>";
};
3B826D9D2385C81C00FC1ADB /* FeedWrangler */ = { 3B826D9D2385C81C00FC1ADB /* FeedWrangler */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -523,6 +571,17 @@
path = ReaderAPI; path = ReaderAPI;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
769F2630AF8DC873D4A73567 /* NewsBlur */ = {
isa = PBXGroup;
children = (
769F2A8DF190549E24B5D110 /* NewsBlurAccountDelegate.swift */,
769F275FD5D942502C5B4716 /* NewsBlurAPICaller.swift */,
179DBD810D353D9CED7C3BED /* Models */,
179DB1571B95BAD0F833AF6D /* Internals */,
);
path = NewsBlur;
sourceTree = "<group>";
};
841973E91F6DD19E006346C4 /* Products */ = { 841973E91F6DD19E006346C4 /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -622,6 +681,7 @@
8469F80F1F6DC3C10084783E /* Frameworks */, 8469F80F1F6DC3C10084783E /* Frameworks */,
D511EEB4202422BB00712EC3 /* xcconfig */, D511EEB4202422BB00712EC3 /* xcconfig */,
848934FA1F62484F00CEBD24 /* Info.plist */, 848934FA1F62484F00CEBD24 /* Info.plist */,
769F2630AF8DC873D4A73567 /* NewsBlur */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
usesTabs = 1; usesTabs = 1;
@ -1107,6 +1167,18 @@
9EF2602C23C91FFE006D160C /* FeedlyGetUpdatedArticleIdsOperation.swift in Sources */, 9EF2602C23C91FFE006D160C /* FeedlyGetUpdatedArticleIdsOperation.swift in Sources */,
3B826DAA2385C81C00FC1ADB /* FeedWranglerSubscription.swift in Sources */, 3B826DAA2385C81C00FC1ADB /* FeedWranglerSubscription.swift in Sources */,
3B826DAC2385C81C00FC1ADB /* FeedWranglerAccountDelegate.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; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@ -17,6 +17,8 @@ public enum CredentialsType: String {
case basic = "password" case basic = "password"
case feedWranglerBasic = "feedWranglerBasic" case feedWranglerBasic = "feedWranglerBasic"
case feedWranglerToken = "feedWranglerToken" case feedWranglerToken = "feedWranglerToken"
case newsBlurBasic = "newsBlurBasic"
case newsBlurSessionId = "newsBlurSessionId"
case readerBasic = "readerBasic" case readerBasic = "readerBasic"
case readerAPIKey = "readerAPIKey" case readerAPIKey = "readerAPIKey"
case oauthAccessToken = "oauthAccessToken" case oauthAccessToken = "oauthAccessToken"

View File

@ -33,6 +33,18 @@ public extension URLRequest {
]) ])
case .feedWranglerToken: case .feedWranglerToken:
self.url = url.appendingQueryItem(URLQueryItem(name: "access_token", value: credentials.secret)) self.url = url.appendingQueryItem(URLQueryItem(name: "access_token", value: credentials.secret))
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: case .readerBasic:
setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
httpMethod = "POST" httpMethod = "POST"

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

@ -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, Error>) -> 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<NewsBlurFeed>()
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 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)
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<Folder>?) -> [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, Error>) -> 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[..<numberOfStories])
caller.retrieveStories(hashes: hashesToFetch) { result in
switch result {
case .success((let stories, let date)):
self.processStories(account: account, stories: stories) { result in
self.refreshProgress.completeTask()
if case .failure(let error) = result {
completion(.failure(error))
return
}
self.refreshUnreadStories(for: account, hashes: Array(hashes[numberOfStories...]), updateFetchDate: date) { result in
os_log(.debug, log: self.log, "Done refreshing stories.")
switch result {
case .success:
completion(.success(()))
case .failure(let error):
completion(.failure(error))
}
}
}
case .failure(let error):
completion(.failure(error))
}
}
}
func mapStoriesToParsedItems(stories: [NewsBlurStory]?) -> Set<ParsedItem> {
guard let stories = stories else { return Set<ParsedItem>() }
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, Error>) -> Void) -> Void,
completion: @escaping (Result<Void, Error>) -> 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<String>) {
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<String>) {
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<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.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, 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: 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<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))
}
}
}
}
func deleteFeed(for account: Account, with feed: WebFeed, from container: Container?, completion: @escaping (Result<Void, Error>) -> 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))
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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__"
}
}

View File

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

View File

@ -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<Key>(container: KeyedDecodingContainer<Key>) 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
}
}

View File

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

View File

@ -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<Credentials?, Error>) -> 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, Error>) -> 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, 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, folder: String?, completion: @escaping (Result<NewsBlurFeed?, Error>) -> 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, Error>) -> 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, Error>) -> 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, Error>) -> 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))
}
}
}
}

View File

@ -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<Void, Error>) -> ()) {
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<Void, Error>) -> ()) {
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<Void, Error>) -> ()) {
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, Error>) -> 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, 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 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<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>) -> ()) {
completion(.success(()))
}
func addFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> ()) {
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<Void, Error>) -> ()) {
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<Void, Error>) -> ()) {
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<WebFeed, Error>) -> ()) {
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<Void, Error>) -> ()) {
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<Void, Error>) -> ()) {
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<Void, Error>) -> ()) {
deleteFeed(for: account, with: feed, from: container, completion: completion)
}
func moveWebFeed(for account: Account, with feed: WebFeed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> ()) {
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<Void, Error>) -> ()) {
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<Void, Error>) -> ()) {
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<Article>, statusKey: ArticleStatus.Key, flag: Bool) -> Set<Article>? {
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<Credentials?, Error>) -> ()) {
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()
}
}

View File

@ -466,7 +466,6 @@
65ED405D235DEF6C0081F399 /* SidebarKeyboardShortcuts.plist in Resources */ = {isa = PBXBuildFile; fileRef = 844B5B681FEA20DF00C7C76A /* SidebarKeyboardShortcuts.plist */; }; 65ED405D235DEF6C0081F399 /* SidebarKeyboardShortcuts.plist in Resources */ = {isa = PBXBuildFile; fileRef = 844B5B681FEA20DF00C7C76A /* SidebarKeyboardShortcuts.plist */; };
65ED405E235DEF6C0081F399 /* DefaultFeeds.opml in Resources */ = {isa = PBXBuildFile; fileRef = 84A3EE52223B667F00557320 /* DefaultFeeds.opml */; }; 65ED405E235DEF6C0081F399 /* DefaultFeeds.opml in Resources */ = {isa = PBXBuildFile; fileRef = 84A3EE52223B667F00557320 /* DefaultFeeds.opml */; };
65ED405F235DEF6C0081F399 /* Preferences.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 84C9FC8022629E4800D921D6 /* Preferences.storyboard */; }; 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 */; }; 65ED4061235DEF6C0081F399 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 849C64671ED37A5D003D8FC0 /* Assets.xcassets */; };
65ED4062235DEF6C0081F399 /* styleSheet.css in Resources */ = {isa = PBXBuildFile; fileRef = 848362FC2262A30800DA1D35 /* styleSheet.css */; }; 65ED4062235DEF6C0081F399 /* styleSheet.css in Resources */ = {isa = PBXBuildFile; fileRef = 848362FC2262A30800DA1D35 /* styleSheet.css */; };
65ED4063235DEF6C0081F399 /* RenameSheet.xib in Resources */ = {isa = PBXBuildFile; fileRef = 848363092262A3F000DA1D35 /* RenameSheet.xib */; }; 65ED4063235DEF6C0081F399 /* RenameSheet.xib in Resources */ = {isa = PBXBuildFile; fileRef = 848363092262A3F000DA1D35 /* RenameSheet.xib */; };
@ -505,6 +504,7 @@
65ED42DD235E74230081F399 /* org.sparkle-project.InstallerStatus.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 65ED42BA235E71B40081F399 /* org.sparkle-project.InstallerStatus.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 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 */; }; 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, ); }; }; 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 */; }; 8405DD8A2213E0E3008CE1BF /* DetailContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8405DD892213E0E3008CE1BF /* DetailContainerView.swift */; };
8405DD9922153B6B008CE1BF /* TimelineContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8405DD9822153B6B008CE1BF /* TimelineContainerView.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 */; }; 8405DD9C22153BD7008CE1BF /* NSView-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8405DD9B22153BD7008CE1BF /* NSView-Extensions.swift */; };
@ -1454,6 +1454,7 @@
65ED40F2235DF5E00081F399 /* NetNewsWire_macapp_target_macappstore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_macapp_target_macappstore.xcconfig; sourceTree = "<group>"; }; 65ED40F2235DF5E00081F399 /* NetNewsWire_macapp_target_macappstore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_macapp_target_macappstore.xcconfig; sourceTree = "<group>"; };
65ED4186235E045B0081F399 /* NetNewsWire_safariextension_target_macappstore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_safariextension_target_macappstore.xcconfig; sourceTree = "<group>"; }; 65ED4186235E045B0081F399 /* NetNewsWire_safariextension_target_macappstore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_safariextension_target_macappstore.xcconfig; sourceTree = "<group>"; };
65ED4299235E71B40081F399 /* Sparkle.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = Sparkle.xcodeproj; path = submodules/Sparkle/Sparkle.xcodeproj; sourceTree = SOURCE_ROOT; }; 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 = "<group>"; };
8405DD892213E0E3008CE1BF /* DetailContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailContainerView.swift; sourceTree = "<group>"; }; 8405DD892213E0E3008CE1BF /* DetailContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailContainerView.swift; sourceTree = "<group>"; };
8405DD9822153B6B008CE1BF /* TimelineContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineContainerView.swift; sourceTree = "<group>"; }; 8405DD9822153B6B008CE1BF /* TimelineContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineContainerView.swift; sourceTree = "<group>"; };
8405DD9B22153BD7008CE1BF /* NSView-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSView-Extensions.swift"; sourceTree = "<group>"; }; 8405DD9B22153BD7008CE1BF /* NSView-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSView-Extensions.swift"; sourceTree = "<group>"; };
@ -1874,6 +1875,7 @@
51A1698F235E10D600EB091F /* LocalAccountViewController.swift */, 51A1698F235E10D600EB091F /* LocalAccountViewController.swift */,
51A16996235E10D700EB091F /* FeedbinAccountViewController.swift */, 51A16996235E10D700EB091F /* FeedbinAccountViewController.swift */,
3B3A328B238B820900314204 /* FeedWranglerAccountViewController.swift */, 3B3A328B238B820900314204 /* FeedWranglerAccountViewController.swift */,
769F2D3643779DB02786278E /* NewsBlurAccountViewController.swift */,
); );
path = Account; path = Account;
sourceTree = "<group>"; sourceTree = "<group>";
@ -3487,7 +3489,6 @@
5103A9F5242258C600410853 /* AccountsAddCloudKit.xib in Resources */, 5103A9F5242258C600410853 /* AccountsAddCloudKit.xib in Resources */,
65ED405E235DEF6C0081F399 /* DefaultFeeds.opml in Resources */, 65ED405E235DEF6C0081F399 /* DefaultFeeds.opml in Resources */,
65ED405F235DEF6C0081F399 /* Preferences.storyboard in Resources */, 65ED405F235DEF6C0081F399 /* Preferences.storyboard in Resources */,
65ED4060235DEF6C0081F399 /* (null) in Resources */,
65ED4061235DEF6C0081F399 /* Assets.xcassets in Resources */, 65ED4061235DEF6C0081F399 /* Assets.xcassets in Resources */,
65ED4062235DEF6C0081F399 /* styleSheet.css in Resources */, 65ED4062235DEF6C0081F399 /* styleSheet.css in Resources */,
65ED4063235DEF6C0081F399 /* RenameSheet.xib in Resources */, 65ED4063235DEF6C0081F399 /* RenameSheet.xib in Resources */,
@ -4069,6 +4070,7 @@
51C45293226509C800C03939 /* StarredFeedDelegate.swift in Sources */, 51C45293226509C800C03939 /* StarredFeedDelegate.swift in Sources */,
51D6A5BC23199C85001C27D8 /* MasterTimelineDataSource.swift in Sources */, 51D6A5BC23199C85001C27D8 /* MasterTimelineDataSource.swift in Sources */,
51934CCB230F599B006127BE /* InteractiveNavigationController.swift in Sources */, 51934CCB230F599B006127BE /* InteractiveNavigationController.swift in Sources */,
769F2ED513DA03EE75B993A8 /* NewsBlurAccountViewController.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@ -406,6 +406,155 @@
</objects> </objects>
<point key="canvasLocation" x="3177" y="145"/> <point key="canvasLocation" x="3177" y="145"/>
</scene> </scene>
<!--Modal Navigation Controller-->
<scene sceneID="j4N-ax-exh">
<objects>
<navigationController storyboardIdentifier="NewsBlurAccountNavigationViewController" id="eE3-pu-HdL" customClass="ModalNavigationController" customModule="NetNewsWire" customModuleProvider="target" sceneMemberID="viewController">
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="Fsp-NG-hoR">
<rect key="frame" x="0.0" y="44" width="414" height="44"/>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<connections>
<segue destination="Cge-ND-NpD" kind="relationship" relationship="rootViewController" id="1D5-CN-liN"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="8t3-0U-5vL" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="4562" y="-528"/>
</scene>
<!--NewsBlur-->
<scene sceneID="tfA-kz-P6O">
<objects>
<tableViewController id="Cge-ND-NpD" customClass="NewsBlurAccountViewController" customModule="NetNewsWire" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="static" style="insetGrouped" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" id="fLL-7i-HdK">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<sections>
<tableViewSection id="I5T-12-2jC">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="gAY-Bo-c0L">
<rect key="frame" x="20" y="18" width="374" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="gAY-Bo-c0L" id="mqD-6S-DIl">
<rect key="frame" x="0.0" y="0.0" width="374" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" placeholder="Username or Email" textAlignment="natural" adjustsFontForContentSizeCategory="YES" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="S4v-fs-DIO">
<rect key="frame" x="20" y="11.5" width="334" height="21"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<textInputTraits key="textInputTraits" spellCheckingType="no" keyboardType="emailAddress" textContentType="username"/>
</textField>
</subviews>
<constraints>
<constraint firstAttribute="trailing" secondItem="S4v-fs-DIO" secondAttribute="trailing" constant="20" id="Upe-dm-4DP"/>
<constraint firstItem="S4v-fs-DIO" firstAttribute="leading" secondItem="mqD-6S-DIl" secondAttribute="leading" constant="20" id="pQc-Fh-6T3"/>
<constraint firstItem="S4v-fs-DIO" firstAttribute="centerY" secondItem="mqD-6S-DIl" secondAttribute="centerY" id="s9a-ew-C5W"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="iCK-kn-Au6">
<rect key="frame" x="20" y="61.5" width="374" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="iCK-kn-Au6" id="9Ej-wB-9Tr">
<rect key="frame" x="0.0" y="0.0" width="374" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<textField opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="left" contentVerticalAlignment="center" placeholder="Password" textAlignment="natural" adjustsFontForContentSizeCategory="YES" minimumFontSize="17" translatesAutoresizingMaskIntoConstraints="NO" id="fct-XR-fEa">
<rect key="frame" x="20" y="11.5" width="283" height="21"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<textInputTraits key="textInputTraits" secureTextEntry="YES" textContentType="password"/>
</textField>
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="GY9-nr-jFb">
<rect key="frame" x="311" y="5.5" width="43" height="33"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<state key="normal" title="Show"/>
<connections>
<action selector="showHidePassword:" destination="Cge-ND-NpD" eventType="touchUpInside" id="8JH-LX-URH"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstItem="GY9-nr-jFb" firstAttribute="centerY" secondItem="9Ej-wB-9Tr" secondAttribute="centerY" id="3jf-KC-nd8"/>
<constraint firstItem="GY9-nr-jFb" firstAttribute="leading" secondItem="fct-XR-fEa" secondAttribute="trailing" constant="8" symbolic="YES" id="Ibr-pt-eGr"/>
<constraint firstAttribute="trailing" secondItem="GY9-nr-jFb" secondAttribute="trailing" constant="20" symbolic="YES" id="mcZ-cl-knP"/>
<constraint firstItem="fct-XR-fEa" firstAttribute="leading" secondItem="9Ej-wB-9Tr" secondAttribute="leading" constant="20" id="u5f-tJ-8ce"/>
<constraint firstItem="fct-XR-fEa" firstAttribute="centerY" secondItem="9Ej-wB-9Tr" secondAttribute="centerY" id="z5e-jg-0nm"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection id="L37-iZ-GVj">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="fyQ-K8-byV">
<rect key="frame" x="20" y="141" width="374" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="fyQ-K8-byV" id="CtR-ZJ-FG5">
<rect key="frame" x="0.0" y="0.0" width="374" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" enabled="NO" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="E1I-C4-JdL" customClass="VibrantButton" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
<constraints>
<constraint firstAttribute="height" constant="44" id="yoo-36-msf"/>
</constraints>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<state key="normal" title="Action">
<color key="titleColor" name="secondaryAccentColor"/>
</state>
<connections>
<action selector="action:" destination="Cge-ND-NpD" eventType="touchUpInside" id="YQw-1k-e8G"/>
</connections>
</button>
</subviews>
<constraints>
<constraint firstItem="E1I-C4-JdL" firstAttribute="centerY" secondItem="CtR-ZJ-FG5" secondAttribute="centerY" id="2vc-Ys-4Cj"/>
<constraint firstAttribute="trailing" secondItem="E1I-C4-JdL" secondAttribute="trailing" id="SLX-wc-QR7"/>
<constraint firstItem="E1I-C4-JdL" firstAttribute="leading" secondItem="CtR-ZJ-FG5" secondAttribute="leading" id="Veu-Wo-GSZ"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
</cells>
</tableViewSection>
</sections>
<connections>
<outlet property="dataSource" destination="Cge-ND-NpD" id="u8B-p4-Vlv"/>
<outlet property="delegate" destination="Cge-ND-NpD" id="RIw-V2-EJC"/>
</connections>
</tableView>
<navigationItem key="navigationItem" title="NewsBlur" id="jCQ-pH-6AD">
<barButtonItem key="leftBarButtonItem" systemItem="cancel" id="bl6-Y1-wQ8">
<connections>
<action selector="cancel:" destination="Cge-ND-NpD" id="9zR-LJ-IWk"/>
</connections>
</barButtonItem>
<barButtonItem key="rightBarButtonItem" id="4yi-H0-B9J">
<view key="customView" contentMode="scaleToFill" id="8DU-L0-P6c">
<rect key="frame" x="374" y="12" width="20" height="20"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<subviews>
<activityIndicatorView opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" fixedFrame="YES" style="medium" translatesAutoresizingMaskIntoConstraints="NO" id="HfW-jV-MjK">
<rect key="frame" x="36" y="6" width="20" height="20"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
</activityIndicatorView>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</view>
</barButtonItem>
</navigationItem>
<connections>
<outlet property="actionButton" destination="E1I-C4-JdL" id="q2T-4o-c8i"/>
<outlet property="activityIndicator" destination="HfW-jV-MjK" id="AIV-uG-9uC"/>
<outlet property="cancelBarButtonItem" destination="bl6-Y1-wQ8" id="ohR-gW-5J2"/>
<outlet property="passwordTextField" destination="fct-XR-fEa" id="fGL-4k-gZ6"/>
<outlet property="showHideButton" destination="GY9-nr-jFb" id="1p9-9F-GMY"/>
<outlet property="usernameTextField" destination="S4v-fs-DIO" id="B7I-yz-M0T"/>
</connections>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="8Ku-6P-yPg" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="4562" y="145"/>
</scene>
</scenes> </scenes>
<resources> <resources>
<namedColor name="secondaryAccentColor"> <namedColor name="secondaryAccentColor">

View File

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

View File

@ -35,6 +35,10 @@ struct AppAssets {
return UIImage(named: "accountFreshRSS")! return UIImage(named: "accountFreshRSS")!
}() }()
static var accountNewsBlurImage: UIImage = {
return UIImage(named: "accountNewsBlur")!
}()
static var articleExtractorError: UIImage = { static var articleExtractorError: UIImage = {
return UIImage(named: "articleExtractorError")! return UIImage(named: "articleExtractorError")!
}() }()
@ -238,6 +242,8 @@ struct AppAssets {
return AppAssets.accountFeedWranglerImage return AppAssets.accountFeedWranglerImage
case .freshRSS: case .freshRSS:
return AppAssets.accountFreshRSSImage return AppAssets.accountFreshRSSImage
case .newsBlur:
return AppAssets.accountNewsBlurImage
default: default:
return nil return nil
} }

View File

@ -0,0 +1,15 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "newsblur-512.png"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@ -56,6 +56,12 @@ class AddAccountViewController: UITableViewController, AddAccountDismissDelegate
let addViewController = navController.topViewController as! FeedWranglerAccountViewController let addViewController = navController.topViewController as! FeedWranglerAccountViewController
addViewController.delegate = self addViewController.delegate = self
present(navController, animated: true) 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: default:
break break
} }

View File

@ -677,6 +677,43 @@
<outlet property="accountNameLabel" destination="Dur-Qf-YYi" id="DAF-c9-MJM"/> <outlet property="accountNameLabel" destination="Dur-Qf-YYi" id="DAF-c9-MJM"/>
</connections> </connections>
</tableViewCell> </tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="56" id="Btn-uu-2ks" customClass="SettingsAccountTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="241" width="374" height="56"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Btn-uu-2ks" id="rSE-Cm-Oom">
<rect key="frame" x="0.0" y="0.0" width="374" height="56"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" spacing="16" translatesAutoresizingMaskIntoConstraints="NO" id="PJ5-Pm-b2p">
<rect key="frame" x="20" y="12" width="164" height="32"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="accountNewsBlur" translatesAutoresizingMaskIntoConstraints="NO" id="6Tf-XJ-1e0">
<rect key="frame" x="0.0" y="0.0" width="32" height="32"/>
<color key="tintColor" systemColor="labelColor" cocoaTouchSystemColor="darkTextColor"/>
<constraints>
<constraint firstAttribute="width" constant="32" id="Bhm-KX-Sch"/>
<constraint firstAttribute="height" constant="32" id="sFc-DJ-NBg"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="NewsBlur" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="lKr-Le-Atw">
<rect key="frame" x="48" y="0.0" width="116" height="32"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle1"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
</stackView>
</subviews>
<constraints>
<constraint firstItem="PJ5-Pm-b2p" firstAttribute="centerY" secondItem="rSE-Cm-Oom" secondAttribute="centerY" id="4Zs-Lm-lmM"/>
<constraint firstItem="PJ5-Pm-b2p" firstAttribute="leading" secondItem="rSE-Cm-Oom" secondAttribute="leading" constant="20" symbolic="YES" id="tDb-Wo-OOG"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="accountImage" destination="6Tf-XJ-1e0" id="PGF-56-QEs"/>
<outlet property="accountNameLabel" destination="lKr-Le-Atw" id="g8z-Fb-JVk"/>
</connections>
</tableViewCell>
</cells> </cells>
</tableViewSection> </tableViewSection>
</sections> </sections>
@ -1050,6 +1087,7 @@
<image name="accountFeedbin" width="120" height="102"/> <image name="accountFeedbin" width="120" height="102"/>
<image name="accountFeedly" width="138" height="123"/> <image name="accountFeedly" width="138" height="123"/>
<image name="accountLocal" width="99" height="77"/> <image name="accountLocal" width="99" height="77"/>
<image name="accountNewsBlur" width="512" height="512"/>
<namedColor name="primaryAccentColor"> <namedColor name="primaryAccentColor">
<color red="0.031372549019607843" green="0.41568627450980394" blue="0.93333333333333335" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color red="0.031372549019607843" green="0.41568627450980394" blue="0.93333333333333335" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor> </namedColor>