Merge branch 'master' into accent-color-experimental

This commit is contained in:
Maurice Parker 2020-03-22 10:39:46 -05:00
commit a8d3872490
37 changed files with 2787 additions and 216 deletions

View File

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

View File

@ -7,6 +7,16 @@
objects = {
/* Begin PBXBuildFile section */
179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB7399814F6FB3247825C /* NewsBlurStory.swift */; };
179DB0B17A6C51B95ABC1741 /* NewsBlurStoryStatusChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB5B421C5433B45C5F13E /* NewsBlurStoryStatusChange.swift */; };
179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */; };
179DB3A93E3205EF29C2AF62 /* NewsBlurAPICaller+Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBBF346CF712AB2F0E9E6 /* NewsBlurAPICaller+Internal.swift */; };
179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB66D933E976C29159DEE /* NewsBlurGenericCodingKeys.swift */; };
179DBCB4B11C88EBE852A015 /* NewsBlurFeedChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB3CBADAFCF5377DA3D02 /* NewsBlurFeedChange.swift */; };
179DBD4ECC1C9712DF51DB8C /* NewsBlurFolderChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DBDDC00B68411AA28941F /* NewsBlurFolderChange.swift */; };
179DBE829FDF48E102F73244 /* NewsBlurAccountDelegate+Internal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB78C47256A122A281942 /* NewsBlurAccountDelegate+Internal.swift */; };
179DBED55C9B4D6A413486C1 /* NewsBlurStoryHash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB818180A51098A9816B2 /* NewsBlurStoryHash.swift */; };
179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 179DB1B909672E0E807B5E8C /* NewsBlurFeed.swift */; };
3B3A33E7238D3D6800314204 /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B3A33E6238D3D6800314204 /* Secrets.swift */; };
3B826DA72385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */; };
3B826DA82385C81C00FC1ADB /* FeedWranglerFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */; };
@ -18,7 +28,7 @@
3B826DAE2385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA52385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift */; };
3B826DAF2385C81C00FC1ADB /* FeedWranglerGenericResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA62385C81C00FC1ADB /* FeedWranglerGenericResult.swift */; };
3BC23AB92385ECB100371CBA /* FeedWranglerSubscriptionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC23AB82385ECB100371CBA /* FeedWranglerSubscriptionResult.swift */; };
5103A9D92422546800410853 /* CloudKitAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5103A9D82422546800410853 /* CloudKitAppDelegate.swift */; };
5103A9D92422546800410853 /* CloudKitAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */; };
5107A09B227DE49500C7C3C5 /* TestAccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */; };
5107A09D227DE77700C7C3C5 /* TestTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09C227DE77700C7C3C5 /* TestTransport.swift */; };
510BD111232C3801002692E4 /* AccountMetadataFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 510BD110232C3801002692E4 /* AccountMetadataFile.swift */; };
@ -63,6 +73,8 @@
552032FD229D5D5A009559E0 /* ReaderAPITagging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F2229D5D5A009559E0 /* ReaderAPITagging.swift */; };
552032FE229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F3229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift */; };
55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F5229D5D5A009559E0 /* ReaderAPICaller.swift */; };
769F295938E5A30D03DFF88F /* NewsBlurAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 769F2A8DF190549E24B5D110 /* NewsBlurAccountDelegate.swift */; };
769F2BA02EF5F329CDE45F5A /* NewsBlurAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 769F275FD5D942502C5B4716 /* NewsBlurAPICaller.swift */; };
841973FE1F6DD1BC006346C4 /* RSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841973EF1F6DD19E006346C4 /* RSCore.framework */; };
841973FF1F6DD1C5006346C4 /* RSParser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841973FA1F6DD1AC006346C4 /* RSParser.framework */; };
841974011F6DD1EC006346C4 /* Folder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841974001F6DD1EC006346C4 /* Folder.swift */; };
@ -220,6 +232,16 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
179DB088236E3236010462E8 /* NewsBlurLoginResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewsBlurLoginResponse.swift; sourceTree = "<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>"; };
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>"; };
@ -231,7 +253,7 @@
3B826DA52385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscriptionsRequest.swift; sourceTree = "<group>"; };
3B826DA62385C81C00FC1ADB /* FeedWranglerGenericResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerGenericResult.swift; sourceTree = "<group>"; };
3BC23AB82385ECB100371CBA /* FeedWranglerSubscriptionResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscriptionResult.swift; sourceTree = "<group>"; };
5103A9D82422546800410853 /* CloudKitAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAppDelegate.swift; sourceTree = "<group>"; };
5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudKitAccountDelegate.swift; sourceTree = "<group>"; };
5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCredentialsTest.swift; sourceTree = "<group>"; };
5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAccountManager.swift; sourceTree = "<group>"; };
5107A09C227DE77700C7C3C5 /* TestTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTransport.swift; sourceTree = "<group>"; };
@ -278,6 +300,8 @@
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>"; };
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>"; };
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>"; };
@ -435,6 +459,30 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
179DB1571B95BAD0F833AF6D /* Internals */ = {
isa = PBXGroup;
children = (
179DB78C47256A122A281942 /* NewsBlurAccountDelegate+Internal.swift */,
179DBBF346CF712AB2F0E9E6 /* NewsBlurAPICaller+Internal.swift */,
);
path = Internals;
sourceTree = "<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 */ = {
isa = PBXGroup;
children = (
@ -455,7 +503,7 @@
5103A9D7242253DC00410853 /* CloudKit */ = {
isa = PBXGroup;
children = (
5103A9D82422546800410853 /* CloudKitAppDelegate.swift */,
5103A9D82422546800410853 /* CloudKitAccountDelegate.swift */,
);
path = CloudKit;
sourceTree = "<group>";
@ -523,6 +571,17 @@
path = ReaderAPI;
sourceTree = "<group>";
};
769F2630AF8DC873D4A73567 /* NewsBlur */ = {
isa = PBXGroup;
children = (
769F2A8DF190549E24B5D110 /* NewsBlurAccountDelegate.swift */,
769F275FD5D942502C5B4716 /* NewsBlurAPICaller.swift */,
179DBD810D353D9CED7C3BED /* Models */,
179DB1571B95BAD0F833AF6D /* Internals */,
);
path = NewsBlur;
sourceTree = "<group>";
};
841973E91F6DD19E006346C4 /* Products */ = {
isa = PBXGroup;
children = (
@ -622,6 +681,7 @@
8469F80F1F6DC3C10084783E /* Frameworks */,
D511EEB4202422BB00712EC3 /* xcconfig */,
848934FA1F62484F00CEBD24 /* Info.plist */,
769F2630AF8DC873D4A73567 /* NewsBlur */,
);
sourceTree = "<group>";
usesTabs = 1;
@ -1019,7 +1079,7 @@
84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */,
841974251F6DDCE4006346C4 /* AccountDelegate.swift in Sources */,
510BD113232C3E9D002692E4 /* WebFeedMetadataFile.swift in Sources */,
5103A9D92422546800410853 /* CloudKitAppDelegate.swift in Sources */,
5103A9D92422546800410853 /* CloudKitAccountDelegate.swift in Sources */,
5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */,
9E784EBE237E890600099B1B /* FeedlyLogoutOperation.swift in Sources */,
9EEEF71F23545CB4009E9D80 /* FeedlySendArticleStatusesOperation.swift in Sources */,
@ -1107,6 +1167,18 @@
9EF2602C23C91FFE006D160C /* FeedlyGetUpdatedArticleIdsOperation.swift in Sources */,
3B826DAA2385C81C00FC1ADB /* FeedWranglerSubscription.swift in Sources */,
3B826DAC2385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift in Sources */,
769F295938E5A30D03DFF88F /* NewsBlurAccountDelegate.swift in Sources */,
769F2BA02EF5F329CDE45F5A /* NewsBlurAPICaller.swift in Sources */,
179DB28CF49F73A945EBF5DB /* NewsBlurLoginResponse.swift in Sources */,
179DBF4DE2562D4C532F6008 /* NewsBlurFeed.swift in Sources */,
179DB02FFBC17AC9798F0EBC /* NewsBlurStory.swift in Sources */,
179DB49A960F8B78C4924458 /* NewsBlurGenericCodingKeys.swift in Sources */,
179DBED55C9B4D6A413486C1 /* NewsBlurStoryHash.swift in Sources */,
179DB0B17A6C51B95ABC1741 /* NewsBlurStoryStatusChange.swift in Sources */,
179DBD4ECC1C9712DF51DB8C /* NewsBlurFolderChange.swift in Sources */,
179DBCB4B11C88EBE852A015 /* NewsBlurFeedChange.swift in Sources */,
179DBE829FDF48E102F73244 /* NewsBlurAccountDelegate+Internal.swift in Sources */,
179DB3A93E3205EF29C2AF62 /* NewsBlurAPICaller+Internal.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

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

View File

@ -33,7 +33,19 @@ public extension URLRequest {
])
case .feedWranglerToken:
self.url = url.appendingQueryItem(URLQueryItem(name: "access_token", value: credentials.secret))
case .readerBasic:
case .newsBlurBasic:
setValue("application/x-www-form-urlencoded", forHTTPHeaderField: HTTPRequestHeader.contentType)
httpMethod = "POST"
var postData = URLComponents()
postData.queryItems = [
URLQueryItem(name: "username", value: credentials.username),
URLQueryItem(name: "password", value: credentials.secret),
]
httpBody = postData.percentEncodedQuery?.data(using: .utf8)
case .newsBlurSessionId:
setValue("\(NewsBlurAPICaller.SessionIdCookie)=\(credentials.secret)", forHTTPHeaderField: "Cookie")
httpShouldHandleCookies = true
case .readerBasic:
setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
httpMethod = "POST"
var postData = URLComponents()

View File

@ -493,7 +493,7 @@ private extension FeedWranglerAccountDelegate {
feed.name = subscription.title
feed.editedName = nil
feed.homePageURL = subscription.siteURL
feed.subscriptionID = nil // MARK: TODO What should this be?
feed.externalID = nil // MARK: TODO What should this be?
} else {
subscriptionsToAdd.insert(subscription)
}
@ -502,7 +502,7 @@ private extension FeedWranglerAccountDelegate {
subscriptionsToAdd.forEach { subscription in
let feedId = String(subscription.feedID)
let feed = account.createWebFeed(with: subscription.title, url: subscription.feedURL, webFeedID: feedId, homePageURL: subscription.siteURL)
feed.subscriptionID = nil
feed.externalID = nil
account.addWebFeed(feed)
}
}

View File

@ -334,7 +334,7 @@ final class FeedbinAccountDelegate: AccountDelegate {
} else {
if let subscriptionID = feed.subscriptionID {
if let subscriptionID = feed.externalID {
group.enter()
refreshProgress.addToNumberOfTasksAndRemaining(1)
caller.deleteSubscription(subscriptionID: subscriptionID) { result in
@ -398,7 +398,7 @@ final class FeedbinAccountDelegate: AccountDelegate {
func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
// This error should never happen
guard let subscriptionID = feed.subscriptionID else {
guard let subscriptionID = feed.externalID else {
completion(.failure(FeedbinAccountDelegateError.invalidParameter))
return
}
@ -812,7 +812,7 @@ private extension FeedbinAccountDelegate {
// If the name has been changed on the server remove the locally edited name
feed.editedName = nil
feed.homePageURL = subscription.homePageURL
feed.subscriptionID = String(subscription.subscriptionID)
feed.externalID = String(subscription.subscriptionID)
feed.faviconURL = subscription.jsonFeed?.favicon
feed.iconURL = subscription.jsonFeed?.icon
}
@ -824,7 +824,7 @@ private extension FeedbinAccountDelegate {
// Actually add subscriptions all in one go, so we dont trigger various rebuilding things that Account does.
subscriptionsToAdd.forEach { subscription in
let feed = account.createWebFeed(with: subscription.name, url: subscription.url, webFeedID: String(subscription.feedID), homePageURL: subscription.homePageURL)
feed.subscriptionID = String(subscription.subscriptionID)
feed.externalID = String(subscription.subscriptionID)
account.addWebFeed(feed)
}
}
@ -1004,7 +1004,7 @@ private extension FeedbinAccountDelegate {
DispatchQueue.main.async {
let feed = account.createWebFeed(with: sub.name, url: sub.url, webFeedID: String(sub.feedID), homePageURL: sub.homePageURL)
feed.subscriptionID = String(sub.subscriptionID)
feed.externalID = String(sub.subscriptionID)
feed.iconURL = sub.jsonFeed?.icon
feed.faviconURL = sub.jsonFeed?.favicon
@ -1351,7 +1351,7 @@ private extension FeedbinAccountDelegate {
func deleteSubscription(for account: Account, with feed: WebFeed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void) {
// This error should never happen
guard let subscriptionID = feed.subscriptionID else {
guard let subscriptionID = feed.externalID else {
completion(.failure(FeedbinAccountDelegateError.invalidParameter))
return
}

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

@ -292,7 +292,7 @@ final class ReaderAPIAccountDelegate: AccountDelegate {
func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
// This error should never happen
guard let subscriptionID = feed.subscriptionID else {
guard let subscriptionID = feed.externalID else {
completion(.failure(FeedbinAccountDelegateError.invalidParameter))
return
}
@ -340,12 +340,12 @@ final class ReaderAPIAccountDelegate: AccountDelegate {
func addWebFeed(for account: Account, with feed: WebFeed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
if let folder = container as? Folder, let feedName = feed.subscriptionID {
if let folder = container as? Folder, let feedName = feed.externalID {
caller.createTagging(subscriptionID: feedName, tagName: folder.name ?? "") { result in
switch result {
case .success:
DispatchQueue.main.async {
self.saveFolderRelationship(for: feed, withFolderName: folder.name ?? "", id: feed.subscriptionID!)
self.saveFolderRelationship(for: feed, withFolderName: folder.name ?? "", id: feed.externalID!)
account.removeWebFeed(feed)
folder.addWebFeed(feed)
completion(.success(()))
@ -582,7 +582,7 @@ private extension ReaderAPIAccountDelegate {
} else {
let feed = account.createWebFeed(with: subscription.name, url: subscription.url, webFeedID: subFeedId, homePageURL: subscription.homePageURL)
feed.iconURL = subscription.iconURL
feed.subscriptionID = String(subscription.feedID)
feed.externalID = String(subscription.feedID)
account.addWebFeed(feed)
}
@ -758,7 +758,7 @@ private extension ReaderAPIAccountDelegate {
DispatchQueue.main.async {
let feed = account.createWebFeed(with: sub.name, url: sub.url, webFeedID: String(sub.feedID), homePageURL: sub.homePageURL)
feed.subscriptionID = String(sub.feedID)
feed.externalID = String(sub.feedID)
account.addWebFeed(feed, to: container) { result in
switch result {
@ -985,7 +985,7 @@ private extension ReaderAPIAccountDelegate {
func deleteTagging(for account: Account, with feed: WebFeed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void) {
if let folder = container as? Folder, let feedName = feed.subscriptionID {
if let folder = container as? Folder, let feedName = feed.externalID {
caller.deleteTagging(subscriptionID: feedName, tagName: folder.name ?? "") { result in
switch result {
case .success:
@ -1014,7 +1014,7 @@ private extension ReaderAPIAccountDelegate {
func deleteSubscription(for account: Account, with feed: WebFeed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void) {
// This error should never happen
guard let subscriptionID = feed.subscriptionID else {
guard let subscriptionID = feed.externalID else {
completion(.failure(FeedbinAccountDelegateError.invalidParameter))
return
}

View File

@ -153,12 +153,12 @@ public final class WebFeed: Feed, Renamable, Hashable {
}
}
public var subscriptionID: String? {
public var externalID: String? {
get {
return metadata.subscriptionID
return metadata.externalID
}
set {
metadata.subscriptionID = newValue
metadata.externalID = newValue
}
}

View File

@ -27,7 +27,7 @@ final class WebFeedMetadata: Codable {
case isNotifyAboutNewArticles
case isArticleExtractorAlwaysOn
case conditionalGetInfo
case subscriptionID
case externalID = "subscriptionID"
case folderRelationship
}
@ -111,10 +111,10 @@ final class WebFeedMetadata: Codable {
}
}
var subscriptionID: String? {
var externalID: String? {
didSet {
if subscriptionID != oldValue {
valueDidChange(.subscriptionID)
if externalID != oldValue {
valueDidChange(.externalID)
}
}
}

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="15505" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<document type="com.apple.InterfaceBuilder3.Cocoa.Storyboard.XIB" version="3.0" toolsVersion="15705" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="15505"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="15705"/>
</dependencies>
<scenes>
<!--Application-->
@ -343,18 +343,6 @@
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="View" id="HyV-fh-RgO">
<items>
<menuItem title="Clean Up" keyEquivalent="H" id="J5h-uQ-57w">
<connections>
<action selector="cleanUp:" target="Ady-hI-5gd" id="eNB-UA-e3a"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="rcJ-r3-Y2U"/>
<menuItem title="Hide Read Articles" id="b10-sA-Yzi">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleReadArticlesFilter:" target="Ady-hI-5gd" id="YhV-0F-jrM"/>
</connections>
</menuItem>
<menuItem title="Sort Articles By" id="nLP-fa-KUi">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Sort Articles By" id="OlJ-93-6OP">
@ -381,12 +369,22 @@
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="dZt-2W-gxf"/>
<menuItem title="Hide Read Feeds" id="E9K-zV-nLv">
<modifierMask key="keyEquivalentModifierMask"/>
<menuItem title="Clean Up Articles" keyEquivalent="'" id="J5h-uQ-57w">
<connections>
<action selector="cleanUp:" target="Ady-hI-5gd" id="eNB-UA-e3a"/>
</connections>
</menuItem>
<menuItem title="Hide Read Articles" keyEquivalent="H" id="b10-sA-Yzi">
<connections>
<action selector="toggleReadArticlesFilter:" target="Ady-hI-5gd" id="YhV-0F-jrM"/>
</connections>
</menuItem>
<menuItem title="Hide Read Feeds" keyEquivalent="F" id="E9K-zV-nLv">
<connections>
<action selector="toggleReadFeedsFilter:" target="Ady-hI-5gd" id="5pI-YT-xai"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="Ocl-6H-m3k"/>
<menuItem title="Show Sidebar" keyEquivalent="s" id="kIP-vf-haE">
<modifierMask key="keyEquivalentModifierMask" control="YES" command="YES"/>
<connections>

View File

@ -154,7 +154,10 @@ code, pre {
border: 1px solid var(--accent-color);
font-size: inherit;
}
.nnw-overflow table table {
margin-bottom: 0;
border: none;
}
.nnw-overflow td, .nnw-overflow th {
-webkit-hyphens: none;
word-break: normal;

View File

@ -218,6 +218,7 @@ protocol SidebarDelegate: class {
}
@objc func downloadArticlesDidUpdateUnreadCounts(_ note: Notification) {
addTreeControllerToFilterExceptions()
rebuildTreeAndRestoreSelection()
}

View File

@ -2,10 +2,18 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.$(ORGANIZATION_IDENTIFIER).NetNewsWire</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.developer.icloud-container-identifiers</key>
<array/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>

View File

@ -260,6 +260,8 @@
51E3EB3D229AB08300645299 /* ErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E3EB3C229AB08300645299 /* ErrorHandler.swift */; };
51E43962238037C400015C31 /* AddWebFeedFolderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E43961238037C400015C31 /* AddWebFeedFolderViewController.swift */; };
51E4398023805EBC00015C31 /* AddWebFeedFolderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E4397F23805EBC00015C31 /* AddWebFeedFolderTableViewCell.swift */; };
51E4DAED2425F6940091EB5B /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51E4DAEC2425F6940091EB5B /* CloudKit.framework */; };
51E4DB082425F9EB0091EB5B /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51E4DB072425F9EB0091EB5B /* CloudKit.framework */; };
51E595A5228CC36500FCC42B /* ArticleStatusSyncTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E595A4228CC36500FCC42B /* ArticleStatusSyncTimer.swift */; };
51E595A6228CC36500FCC42B /* ArticleStatusSyncTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E595A4228CC36500FCC42B /* ArticleStatusSyncTimer.swift */; };
51EAED96231363EF00A9EEE3 /* NonIntrinsicButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51EAED95231363EF00A9EEE3 /* NonIntrinsicButton.swift */; };
@ -464,7 +466,6 @@
65ED405D235DEF6C0081F399 /* SidebarKeyboardShortcuts.plist in Resources */ = {isa = PBXBuildFile; fileRef = 844B5B681FEA20DF00C7C76A /* SidebarKeyboardShortcuts.plist */; };
65ED405E235DEF6C0081F399 /* DefaultFeeds.opml in Resources */ = {isa = PBXBuildFile; fileRef = 84A3EE52223B667F00557320 /* DefaultFeeds.opml */; };
65ED405F235DEF6C0081F399 /* Preferences.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 84C9FC8022629E4800D921D6 /* Preferences.storyboard */; };
65ED4060235DEF6C0081F399 /* (null) in Resources */ = {isa = PBXBuildFile; };
65ED4061235DEF6C0081F399 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 849C64671ED37A5D003D8FC0 /* Assets.xcassets */; };
65ED4062235DEF6C0081F399 /* styleSheet.css in Resources */ = {isa = PBXBuildFile; fileRef = 848362FC2262A30800DA1D35 /* styleSheet.css */; };
65ED4063235DEF6C0081F399 /* RenameSheet.xib in Resources */ = {isa = PBXBuildFile; fileRef = 848363092262A3F000DA1D35 /* RenameSheet.xib */; };
@ -503,6 +504,7 @@
65ED42DD235E74230081F399 /* org.sparkle-project.InstallerStatus.xpc in Embed XPC Services */ = {isa = PBXBuildFile; fileRef = 65ED42BA235E71B40081F399 /* org.sparkle-project.InstallerStatus.xpc */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
65ED42DE235E74230081F399 /* Sparkle.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 65ED42B0235E71B40081F399 /* Sparkle.framework */; };
65ED42DF235E74230081F399 /* Sparkle.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 65ED42B0235E71B40081F399 /* Sparkle.framework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
769F2ED513DA03EE75B993A8 /* NewsBlurAccountViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 769F2D3643779DB02786278E /* NewsBlurAccountViewController.swift */; };
8405DD8A2213E0E3008CE1BF /* DetailContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8405DD892213E0E3008CE1BF /* DetailContainerView.swift */; };
8405DD9922153B6B008CE1BF /* TimelineContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8405DD9822153B6B008CE1BF /* TimelineContainerView.swift */; };
8405DD9C22153BD7008CE1BF /* NSView-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8405DD9B22153BD7008CE1BF /* NSView-Extensions.swift */; };
@ -1402,6 +1404,8 @@
51E3EB3C229AB08300645299 /* ErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorHandler.swift; sourceTree = "<group>"; };
51E43961238037C400015C31 /* AddWebFeedFolderViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddWebFeedFolderViewController.swift; sourceTree = "<group>"; };
51E4397F23805EBC00015C31 /* AddWebFeedFolderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddWebFeedFolderTableViewCell.swift; sourceTree = "<group>"; };
51E4DAEC2425F6940091EB5B /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; };
51E4DB072425F9EB0091EB5B /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS13.2.sdk/System/Library/Frameworks/CloudKit.framework; sourceTree = DEVELOPER_DIR; };
51E595A4228CC36500FCC42B /* ArticleStatusSyncTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleStatusSyncTimer.swift; sourceTree = "<group>"; };
51EAED95231363EF00A9EEE3 /* NonIntrinsicButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NonIntrinsicButton.swift; sourceTree = "<group>"; };
51EC114B2149FE3300B296E3 /* FolderTreeMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FolderTreeMenu.swift; path = AddFeed/FolderTreeMenu.swift; sourceTree = "<group>"; };
@ -1450,6 +1454,7 @@
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>"; };
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>"; };
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>"; };
@ -1725,6 +1730,7 @@
51C451F02264C83100C03939 /* ArticlesDatabase.framework in Frameworks */,
51C451F42264C83900C03939 /* Articles.framework in Frameworks */,
51C451E82264C81000C03939 /* RSDatabase.framework in Frameworks */,
51E4DB082425F9EB0091EB5B /* CloudKit.framework in Frameworks */,
51C451EC2264C81B00C03939 /* RSCore.framework in Frameworks */,
51554C30228B71A10055115A /* SyncDatabase.framework in Frameworks */,
51C451E42264C80600C03939 /* RSParser.framework in Frameworks */,
@ -1744,6 +1750,7 @@
84C37FB520DD8DBB00CA8CF5 /* RSParser.framework in Frameworks */,
51C451BD226377D000C03939 /* Account.framework in Frameworks */,
51C451B9226377C900C03939 /* Articles.framework in Frameworks */,
51E4DAED2425F6940091EB5B /* CloudKit.framework in Frameworks */,
84C37FA520DD8D8400CA8CF5 /* RSCore.framework in Frameworks */,
51554C24228B71910055115A /* SyncDatabase.framework in Frameworks */,
);
@ -1868,6 +1875,7 @@
51A1698F235E10D600EB091F /* LocalAccountViewController.swift */,
51A16996235E10D700EB091F /* FeedbinAccountViewController.swift */,
3B3A328B238B820900314204 /* FeedWranglerAccountViewController.swift */,
769F2D3643779DB02786278E /* NewsBlurAccountViewController.swift */,
);
path = Account;
sourceTree = "<group>";
@ -2078,6 +2086,8 @@
51C452B22265141B00C03939 /* Frameworks */ = {
isa = PBXGroup;
children = (
51E4DB072425F9EB0091EB5B /* CloudKit.framework */,
51E4DAEC2425F6940091EB5B /* CloudKit.framework */,
51C452B32265141B00C03939 /* WebKit.framework */,
);
name = Frameworks;
@ -3479,7 +3489,6 @@
5103A9F5242258C600410853 /* AccountsAddCloudKit.xib in Resources */,
65ED405E235DEF6C0081F399 /* DefaultFeeds.opml in Resources */,
65ED405F235DEF6C0081F399 /* Preferences.storyboard in Resources */,
65ED4060235DEF6C0081F399 /* (null) in Resources */,
65ED4061235DEF6C0081F399 /* Assets.xcassets in Resources */,
65ED4062235DEF6C0081F399 /* styleSheet.css in Resources */,
65ED4063235DEF6C0081F399 /* RenameSheet.xib in Resources */,
@ -4061,6 +4070,7 @@
51C45293226509C800C03939 /* StarredFeedDelegate.swift in Sources */,
51D6A5BC23199C85001C27D8 /* MasterTimelineDataSource.swift in Sources */,
51934CCB230F599B006127BE /* InteractiveNavigationController.swift in Sources */,
769F2ED513DA03EE75B993A8 /* NewsBlurAccountViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -31,7 +31,7 @@ rm -f buildscripts/certs/ios-dist.cer
rm -f buildscripts/certs/mac-dist.p12
# Do the build
xcodebuild -scheme $SCHEME test -destination "$DESTINATION" -showBuildTimingSummary
xcodebuild -scheme $SCHEME test -destination "$DESTINATION" -showBuildTimingSummary -allowProvisioningUpdates
# Delete the keychain and the provisioning profile
security delete-keychain github-build.keychain

View File

@ -1,167 +1,173 @@
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

View File

@ -406,6 +406,155 @@
</objects>
<point key="canvasLocation" x="3177" y="145"/>
</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>
<resources>
<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")!
}()
static var accountNewsBlurImage: UIImage = {
return UIImage(named: "accountNewsBlur")!
}()
static var articleExtractorError: UIImage = {
return UIImage(named: "articleExtractorError")!
}()
@ -238,6 +242,8 @@ struct AppAssets {
return AppAssets.accountFeedWranglerImage
case .freshRSS:
return AppAssets.accountFreshRSSImage
case .newsBlur:
return AppAssets.accountNewsBlurImage
default:
return nil
}

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

@ -2,6 +2,16 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.$(ORGANIZATION_IDENTIFIER).NetNewsWire</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.$(ORGANIZATION_IDENTIFIER).NetNewsWire.iOS</string>

View File

@ -56,6 +56,12 @@ class AddAccountViewController: UITableViewController, AddAccountDismissDelegate
let addViewController = navController.topViewController as! FeedWranglerAccountViewController
addViewController.delegate = self
present(navController, animated: true)
case 4:
let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "NewsBlurAccountNavigationViewController") as! UINavigationController
navController.modalPresentationStyle = .currentContext
let addViewController = navController.topViewController as! NewsBlurAccountViewController
addViewController.delegate = self
present(navController, animated: true)
default:
break
}

View File

@ -677,6 +677,43 @@
<outlet property="accountNameLabel" destination="Dur-Qf-YYi" id="DAF-c9-MJM"/>
</connections>
</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>
</tableViewSection>
</sections>
@ -1050,6 +1087,7 @@
<image name="accountFeedbin" width="120" height="102"/>
<image name="accountFeedly" width="138" height="123"/>
<image name="accountLocal" width="99" height="77"/>
<image name="accountNewsBlur" width="512" height="512"/>
<namedColor name="primaryAccentColor">
<color red="0.031372549019607843" green="0.41568627450980394" blue="0.93333333333333335" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>