diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 64a897a00..a498c04ce 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -33,6 +33,7 @@ public enum AccountType: Int { case feedbin = 17 case feedWrangler = 18 case newsBlur = 19 + case googleReaderAPI = 20 // TODO: more } @@ -125,6 +126,17 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, } } + public var endpointURL: URL? { + get { + return metadata.endpointURL + } + set { + if newValue != metadata.endpointURL { + metadata.endpointURL = newValue + } + } + } + private var fetchingAllUnreadCounts = false var isUnreadCountsInitialized = false @@ -205,6 +217,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, self.delegate = LocalAccountDelegate() case .feedbin: self.delegate = FeedbinAccountDelegate(dataFolder: dataFolder, transport: transport) + case .googleReaderAPI: + self.delegate = ReaderAPIAccountDelegate(dataFolder: dataFolder, transport: transport) default: fatalError("Only Local and Feedbin accounts are supported") } @@ -232,6 +246,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, defaultName = "FeedWrangler" case .newsBlur: defaultName = "NewsBlur" + case .googleReaderAPI: + defaultName = "Reader" } NotificationCenter.default.addObserver(self, selector: #selector(downloadProgressDidChange(_:)), name: .DownloadProgressDidChange, object: nil) @@ -263,8 +279,10 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, switch credentials { case .basic(let username, _): self.username = username - default: - return + case .googleBasicLogin(let username, _): + self.username = username + case .googleAuthLogin(let username, _): + self.username = username } try CredentialsManager.storeCredentials(credentials, server: server) @@ -288,12 +306,29 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, self.username = nil } - public static func validateCredentials(transport: Transport = URLSession.webserviceTransport(), type: AccountType, credentials: Credentials, completion: @escaping (Result) -> Void) { + public func retrieveGoogleAuthCredentials() throws -> Credentials? { + guard let username = self.username, let server = delegate.server else { + return nil + } + return try CredentialsManager.retrieveGoogleAuthCredentials(server: server, username: username) + } + + public func removeGoogleAuthCredentials() throws { + guard let username = self.username, let server = delegate.server else { + return + } + try CredentialsManager.removeGoogleAuthCredentials(server: server, username: username) + self.username = nil + } + + public static func validateCredentials(transport: Transport = URLSession.webserviceTransport(), type: AccountType, credentials: Credentials, endpoint: URL? = nil, completion: @escaping (Result) -> Void) { switch type { case .onMyMac: LocalAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion) case .feedbin: FeedbinAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion) + case .googleReaderAPI: + ReaderAPIAccountDelegate.validateCredentials(transport: transport, credentials: credentials, endpoint: endpoint, completion: completion) default: break } diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 4e8d20f58..3a6f1fd8b 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -35,6 +35,13 @@ 51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E490352288C37100C791F0 /* FeedbinDate.swift */; }; 51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */; }; 51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */; }; + 552032F8229D5D5A009559E0 /* ReaderAPIEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032ED229D5D5A009559E0 /* ReaderAPIEntry.swift */; }; + 552032F9229D5D5A009559E0 /* ReaderAPISubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032EE229D5D5A009559E0 /* ReaderAPISubscription.swift */; }; + 552032FB229D5D5A009559E0 /* ReaderAPITag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F0229D5D5A009559E0 /* ReaderAPITag.swift */; }; + 552032FC229D5D5A009559E0 /* ReaderAPIUnreadEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F1229D5D5A009559E0 /* ReaderAPIUnreadEntry.swift */; }; + 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 */; }; 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 */; }; @@ -136,6 +143,13 @@ 51E490352288C37100C791F0 /* FeedbinDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinDate.swift; sourceTree = ""; }; 51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinUnreadEntry.swift; sourceTree = ""; }; 51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinStarredEntry.swift; sourceTree = ""; }; + 552032ED229D5D5A009559E0 /* ReaderAPIEntry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPIEntry.swift; sourceTree = ""; }; + 552032EE229D5D5A009559E0 /* ReaderAPISubscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPISubscription.swift; sourceTree = ""; }; + 552032F0229D5D5A009559E0 /* ReaderAPITag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPITag.swift; sourceTree = ""; }; + 552032F1229D5D5A009559E0 /* ReaderAPIUnreadEntry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPIUnreadEntry.swift; sourceTree = ""; }; + 552032F2229D5D5A009559E0 /* ReaderAPITagging.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPITagging.swift; sourceTree = ""; }; + 552032F3229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPIAccountDelegate.swift; sourceTree = ""; }; + 552032F5229D5D5A009559E0 /* ReaderAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReaderAPICaller.swift; sourceTree = ""; }; 841973E81F6DD19E006346C4 /* RSCore.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSCore.xcodeproj; path = ../RSCore/RSCore.xcodeproj; sourceTree = ""; }; 841973F41F6DD1AC006346C4 /* RSParser.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSParser.xcodeproj; path = ../RSParser/RSParser.xcodeproj; sourceTree = ""; }; 841974001F6DD1EC006346C4 /* Folder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Folder.swift; sourceTree = ""; }; @@ -222,6 +236,20 @@ path = JSON; sourceTree = ""; }; + 552032EA229D5D5A009559E0 /* ReaderAPI */ = { + isa = PBXGroup; + children = ( + 552032ED229D5D5A009559E0 /* ReaderAPIEntry.swift */, + 552032EE229D5D5A009559E0 /* ReaderAPISubscription.swift */, + 552032F0229D5D5A009559E0 /* ReaderAPITag.swift */, + 552032F1229D5D5A009559E0 /* ReaderAPIUnreadEntry.swift */, + 552032F2229D5D5A009559E0 /* ReaderAPITagging.swift */, + 552032F3229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift */, + 552032F5229D5D5A009559E0 /* ReaderAPICaller.swift */, + ); + path = ReaderAPI; + sourceTree = ""; + }; 841973E91F6DD19E006346C4 /* Products */ = { isa = PBXGroup; children = ( @@ -302,6 +330,7 @@ 5165D71F22835E9800D9D53D /* FeedFinder */, 8419742B1F6DDE84006346C4 /* LocalAccount */, 84245C7D1FDDD2580074AFBB /* Feedbin */, + 552032EA229D5D5A009559E0 /* ReaderAPI */, 848935031F62484F00CEBD24 /* AccountTests */, 848934F71F62484F00CEBD24 /* Products */, 8469F80F1F6DC3C10084783E /* Frameworks */, @@ -515,11 +544,13 @@ buildActionMask = 2147483647; files = ( 84C8B3F41F89DE430053CCA6 /* DataExtensions.swift in Sources */, + 552032F9229D5D5A009559E0 /* ReaderAPISubscription.swift in Sources */, 84C3654A1F899F3B001EC85C /* CombinedRefreshProgress.swift in Sources */, 8469F81C1F6DD15E0084783E /* Account.swift in Sources */, 5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */, 51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */, 846E77451F6EF9B900A165E2 /* Container.swift in Sources */, + 552032FD229D5D5A009559E0 /* ReaderAPITagging.swift in Sources */, 84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */, 841974251F6DDCE4006346C4 /* AccountDelegate.swift in Sources */, 5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */, @@ -533,10 +564,15 @@ 84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */, 5133231122810EB200C30F19 /* FeedbinIcon.swift in Sources */, 846E77501F6EF9C400A165E2 /* LocalAccountRefresher.swift in Sources */, + 55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */, 51E3EB41229AF61B00645299 /* AccountError.swift in Sources */, 51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */, + 552032F8229D5D5A009559E0 /* ReaderAPIEntry.swift in Sources */, + 552032FB229D5D5A009559E0 /* ReaderAPITag.swift in Sources */, 5165D72822835F7800D9D53D /* FeedFinder.swift in Sources */, 51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */, + 552032FE229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift in Sources */, + 552032FC229D5D5A009559E0 /* ReaderAPIUnreadEntry.swift in Sources */, 84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */, 84CAD7161FDF2E22000F0755 /* FeedbinEntry.swift in Sources */, 5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */, diff --git a/Frameworks/Account/AccountDelegate.swift b/Frameworks/Account/AccountDelegate.swift index 56005273a..1445e1f1f 100644 --- a/Frameworks/Account/AccountDelegate.swift +++ b/Frameworks/Account/AccountDelegate.swift @@ -47,6 +47,6 @@ protocol AccountDelegate { // Called at the end of account’s init method. func accountDidInitialize(_ account: Account) - static func validateCredentials(transport: Transport, credentials: Credentials, completion: @escaping (Result) -> Void) + static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, completion: @escaping (Result) -> Void) } diff --git a/Frameworks/Account/AccountMetadata.swift b/Frameworks/Account/AccountMetadata.swift index 3229a4689..a741bfb12 100644 --- a/Frameworks/Account/AccountMetadata.swift +++ b/Frameworks/Account/AccountMetadata.swift @@ -21,6 +21,7 @@ final class AccountMetadata: Codable { case username case conditionalGetInfo case lastArticleFetch + case endpointURL } var name: String? { @@ -62,6 +63,14 @@ final class AccountMetadata: Codable { } } } + + var endpointURL: URL? { + didSet { + if endpointURL != oldValue { + valueDidChange(.endpointURL) + } + } + } weak var delegate: AccountMetadataDelegate? diff --git a/Frameworks/Account/Feedbin/FeedbinAPICaller.swift b/Frameworks/Account/Feedbin/FeedbinAPICaller.swift index 27282de5b..a8d9757d1 100644 --- a/Frameworks/Account/Feedbin/FeedbinAPICaller.swift +++ b/Frameworks/Account/Feedbin/FeedbinAPICaller.swift @@ -42,7 +42,7 @@ final class FeedbinAPICaller: NSObject { self.transport = transport } - func validateCredentials(completion: @escaping (Result) -> Void) { + func validateCredentials(completion: @escaping (Result) -> Void) { let callURL = feedbinBaseURL.appendingPathComponent("authentication.json") let request = URLRequest(url: callURL, credentials: credentials) @@ -50,12 +50,12 @@ final class FeedbinAPICaller: NSObject { transport.send(request: request) { result in switch result { case .success: - completion(.success(true)) + completion(.success(self.credentials)) case .failure(let error): switch error { case TransportError.httpError(let status): if status == 401 { - completion(.success(false)) + completion(.success(self.credentials)) } else { completion(.failure(error)) } diff --git a/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift b/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift index b6c388f03..4ed6aa518 100644 --- a/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift +++ b/Frameworks/Account/Feedbin/FeedbinAccountDelegate.swift @@ -519,7 +519,7 @@ final class FeedbinAccountDelegate: AccountDelegate { accountMetadata = account.metadata } - static func validateCredentials(transport: Transport, credentials: Credentials, completion: @escaping (Result) -> Void) { + static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL? = nil, completion: @escaping (Result) -> Void) { let caller = FeedbinAPICaller(transport: transport) caller.credentials = credentials diff --git a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift index c3fa7b022..29d6e31a3 100644 --- a/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift +++ b/Frameworks/Account/LocalAccount/LocalAccountDelegate.swift @@ -195,8 +195,8 @@ final class LocalAccountDelegate: AccountDelegate { func accountDidInitialize(_ account: Account) { } - static func validateCredentials(transport: Transport, credentials: Credentials, completion: (Result) -> Void) { - return completion(.success(false)) + static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL? = nil, completion: (Result) -> Void) { + return completion(.success(nil)) } } diff --git a/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift b/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift new file mode 100644 index 000000000..6c2143f66 --- /dev/null +++ b/Frameworks/Account/ReaderAPI/ReaderAPIAccountDelegate.swift @@ -0,0 +1,1206 @@ +// +// ReaderAPIAccountDelegate.swift +// Account +// +// Created by Jeremy Beker on 5/28/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +#if os(macOS) +import AppKit +#else +import UIKit +import RSCore +#endif +import Articles +import RSCore +import RSParser +import RSWeb +import SyncDatabase +import os.log + +public enum ReaderAPIAccountDelegateError: String, Error { + case invalidParameter = "There was an invalid parameter passed." + case invalidResponse = "There was an invalid response from the server." +} + +final class ReaderAPIAccountDelegate: AccountDelegate { + + private let database: SyncDatabase + + private let caller: ReaderAPICaller + private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "ReaderAPI") + + let supportsSubFolders = false + let usesTags = true + + var server: String? { + get { + return caller.server + } + } + + var opmlImportInProgress = false + + var credentials: Credentials? { + didSet { + caller.credentials = credentials + } + } + + weak var accountMetadata: AccountMetadata? { + didSet { + caller.accountMetadata = accountMetadata + } + } + + init(dataFolder: String, transport: Transport?) { + + let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3") + database = SyncDatabase(databaseFilePath: databaseFilePath) + + if transport != nil { + + caller = ReaderAPICaller(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 + } + + caller = ReaderAPICaller(transport: URLSession(configuration: sessionConfiguration)) + + } + + } + + var refreshProgress = DownloadProgress(numberOfTasks: 0) + + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { + + refreshProgress.addToNumberOfTasksAndRemaining(6) + + refreshAccount(account) { result in + switch result { + case .success(): + self.refreshArticles(account) { + self.refreshArticleStatus(for: account) { + self.refreshMissingArticles(account) { + self.refreshProgress.clear() + 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)) + } + } + + } + + } + + func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) { + + os_log(.debug, log: log, "Sending article statuses...") + + let syncStatuses = database.selectForProcessing() + 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() + + group.enter() + sendArticleStatuses(createUnreadStatuses, apiCall: caller.createUnreadEntries) { + group.leave() + } + + group.enter() + sendArticleStatuses(deleteUnreadStatuses, apiCall: caller.deleteUnreadEntries) { + group.leave() + } + + group.enter() + sendArticleStatuses(createStarredStatuses, apiCall: caller.createStarredEntries) { + group.leave() + } + + group.enter() + sendArticleStatuses(deleteStarredStatuses, apiCall: caller.deleteStarredEntries) { + group.leave() + } + + group.notify(queue: DispatchQueue.main) { + os_log(.debug, log: self.log, "Done sending article statuses.") + completion() + } + + } + + func refreshArticleStatus(for account: Account, completion: @escaping (() -> Void)) { + + os_log(.debug, log: log, "Refreshing article statuses...") + + let group = DispatchGroup() + + group.enter() + caller.retrieveUnreadEntries() { result in + switch result { + case .success(let articleIDs): + self.syncArticleReadState(account: account, articleIDs: articleIDs) + group.leave() + case .failure(let error): + os_log(.info, log: self.log, "Retrieving unread entries failed: %@.", error.localizedDescription) + group.leave() + } + + } + + group.enter() + caller.retrieveStarredEntries() { result in + switch result { + case .success(let articleIDs): + self.syncArticleStarredState(account: account, articleIDs: articleIDs) + group.leave() + case .failure(let error): + os_log(.info, log: self.log, "Retrieving starred entries failed: %@.", error.localizedDescription) + group.leave() + } + + } + + group.notify(queue: DispatchQueue.main) { + os_log(.debug, log: self.log, "Done refreshing article statuses.") + completion() + } + + } + + func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result) -> Void) { + + var fileData: Data? + + do { + fileData = try Data(contentsOf: opmlFile) + } catch { + completion(.failure(error)) + return + } + + guard let opmlData = fileData else { + completion(.success(())) + return + } + + let parserData = ParserData(url: opmlFile.absoluteString, data: opmlData) + var opmlDocument: RSOPMLDocument? + + do { + opmlDocument = try RSOPMLParser.parseOPML(with: parserData) + } catch { + completion(.failure(error)) + return + } + + guard let loadDocument = opmlDocument else { + completion(.success(())) + return + } + + // We use the same mechanism to load local accounts as we do to load the subscription + // OPML all accounts. + BatchUpdate.shared.perform { + loadOPML(account: account, opmlDocument: loadDocument) + } + completion(.success(())) + + } + + func loadOPML(account: Account, opmlDocument: RSOPMLDocument) { + + guard let children = opmlDocument.children else { + return + } + loadOPMLItems(account: account, items: children, parentFolder: nil) + } + + func loadOPMLItems(account: Account, items: [RSOPMLItem], parentFolder: Folder?) { + + var feedsToAdd = Set() + + items.forEach { (item) in + + if let feedSpecifier = item.feedSpecifier { + feedsToAdd.insert(feedSpecifier.feedURL) + return + } + + guard let folderName = item.titleFromAttributes else { + // Folder doesn’t have a name, so it won’t be created, and its items will go one level up. + if let itemChildren = item.children { + loadOPMLItems(account: account, items: itemChildren, parentFolder: parentFolder) + } + return + } + + if let itemChildren = item.children, let folder = account.ensureFolder(with: folderName) { + loadOPMLItems(account: account, items: itemChildren, parentFolder: folder) + } + } + + let group = DispatchGroup() + + if let parentFolder = parentFolder { + for url in feedsToAdd { + group.enter() + caller.createSubscription(url: url) { result in + group.leave() + switch result { + case .success(let subResult): + switch subResult { + case .created(let subscription): + let feed = account.createFeed(with: subscription.name, url: subscription.url, feedID: String(subscription.feedID), homePageURL: subscription.homePageURL) + feed.subscriptionID = String(subscription.feedID) + account.addFeed(feed, to: parentFolder) { _ in } + default: + break + } + case .failure(_): + break + } + + } + } + } else { + for url in feedsToAdd { + group.enter() + caller.createSubscription(url: url) { result in + group.leave() + switch result { + case .success(let subResult): + switch subResult { + case .created(let subscription): + let feed = account.createFeed(with: subscription.name, url: subscription.url, feedID: String(subscription.feedID), homePageURL: subscription.homePageURL) + feed.subscriptionID = String(subscription.feedID) + account.addFeed(feed) + default: + break + } + case .failure(_): + break + } + } + } + } + + group.notify(queue: DispatchQueue.main) { + + DispatchQueue.main.async { + self.refreshAll(for: account) { (_) in } + } + } + } + + func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + if let folder = account.ensureFolder(with: name) { + completion(.success(folder)) + } else { + completion(.failure(FeedbinAccountDelegateError.invalidParameter)) + } + } + + func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> Void) { + + caller.renameTag(oldName: folder.name ?? "", newName: name) { result in + switch result { + case .success: + DispatchQueue.main.async { + folder.name = name + completion(.success(())) + } + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + + } + + func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) { + let group = DispatchGroup() + + for feed in folder.topLevelFeeds { + group.enter() + removeFeed(for: account, with: feed, from: folder) { result in + group.leave() + switch result { + case .success: + break + case .failure(let error): + os_log(.error, log: self.log, "Remove feed error: %@.", error.localizedDescription) + } + } + } + + group.notify(queue: DispatchQueue.main) { + self.caller.deleteTag(name: folder.name!) { (result) in + switch result { + case .success: + account.removeFolder(folder) + completion(.success(())) + case .failure(let error): + os_log(.error, log: self.log, "Remove feed error: %@.", error.localizedDescription) + } + + } + + } + + } + + func createFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { + + caller.createSubscription(url: url) { result in + switch result { + case .success(let subResult): + switch subResult { + case .created(let subscription): + self.createFeed(account: account, subscription: subscription, name: name, container: container, completion: completion) + case .alreadySubscribed: + DispatchQueue.main.async { + completion(.failure(AccountError.createErrorAlreadySubscribed)) + } + case .notFound: + DispatchQueue.main.async { + completion(.failure(AccountError.createErrorNotFound)) + } + } + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + + } + + } + + func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result) -> Void) { + + // This error should never happen + guard let subscriptionID = feed.subscriptionID else { + completion(.failure(FeedbinAccountDelegateError.invalidParameter)) + return + } + + caller.renameSubscription(subscriptionID: subscriptionID, newName: name) { result in + 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 removeFeed(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result) -> Void) { + if feed.folderRelationship?.count ?? 0 > 1 { + deleteTagging(for: account, with: feed, from: container, completion: completion) + } else { + account.clearFeedMetadata(feed) + deleteSubscription(for: account, with: feed, from: container, completion: completion) + } + } + + func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result) -> Void) { + if from is Account { + addFeed(for: account, with: feed, to: to, completion: completion) + } else { + deleteTagging(for: account, with: feed, from: from) { result in + switch result { + case .success: + self.addFeed(for: account, with: feed, to: to, completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + } + } + + func addFeed(for account: Account, with feed: Feed, to container: Container, completion: @escaping (Result) -> Void) { + + if let folder = container as? Folder, let feedName = feed.subscriptionID { + 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!) + account.removeFeed(feed) + folder.addFeed(feed) + completion(.success(())) + } + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + } else { + DispatchQueue.main.async { + if let account = container as? Account { + account.addFeedIfNotInAnyFolder(feed) + } + completion(.success(())) + } + } + + } + + func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result) -> Void) { + + createFeed(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) { + + account.addFolder(folder) + let group = DispatchGroup() + + for feed in folder.topLevelFeeds { + + group.enter() + addFeed(for: account, with: feed, to: folder) { result in + if account.topLevelFeeds.contains(feed) { + account.removeFeed(feed) + } + group.leave() + } + + } + + group.notify(queue: DispatchQueue.main) { + completion(.success(())) + } + + } + + func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { + + let syncStatuses = articles.map { article in + return SyncStatus(articleID: article.articleID, key: statusKey, flag: flag) + } + database.insertStatuses(syncStatuses) + + if database.selectPendingCount() > 100 { + sendArticleStatus(for: account) {} + } + + return account.update(articles, statusKey: statusKey, flag: flag) + + } + + func accountDidInitialize(_ account: Account) { + accountMetadata = account.metadata + credentials = try? account.retrieveGoogleAuthCredentials() + } + + static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, completion: @escaping (Result) -> Void) { + guard let endpoint = endpoint else { + completion(.failure(TransportError.noURL)) + return + } + + let caller = ReaderAPICaller(transport: transport) + caller.credentials = credentials + caller.validateCredentials(endpoint: endpoint) { result in + DispatchQueue.main.async { + completion(result) + } + } + + } + +} + +// MARK: Private + +private extension ReaderAPIAccountDelegate { + + func refreshAccount(_ account: Account, completion: @escaping (Result) -> Void) { + + caller.retrieveTags { result in + switch result { + case .success(let tags): + BatchUpdate.shared.perform { + self.syncFolders(account, tags) + } + self.refreshProgress.completeTask() + self.refreshFeeds(account, completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + + } + + func syncFolders(_ account: Account, _ tags: [ReaderAPITag]?) { + + guard let tags = tags else { return } + + os_log(.debug, log: log, "Syncing folders with %ld tags.", tags.count) + + let tagNames = tags.filter { $0.type == "folder" }.map { $0.tagID.replacingOccurrences(of: "user/-/label/", with: "") } + + // Delete any folders not at Reader + if let folders = account.folders { + folders.forEach { folder in + if !tagNames.contains(folder.name ?? "") { + DispatchQueue.main.sync { + for feed in folder.topLevelFeeds { + account.addFeed(feed) + clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") + } + account.removeFolder(folder) + } + } + } + } + + let folderNames: [String] = { + if let folders = account.folders { + return folders.map { $0.name ?? "" } + } else { + return [String]() + } + }() + + // Make any folders Reader has, but we don't + tagNames.forEach { tagName in + if !folderNames.contains(tagName) { + DispatchQueue.main.sync { + _ = account.ensureFolder(with: tagName) + } + } + } + + } + + func refreshFeeds(_ account: Account, completion: @escaping (Result) -> Void) { + + caller.retrieveSubscriptions { result in + switch result { + case .success(let subscriptions): + + self.refreshProgress.completeTask() + + BatchUpdate.shared.perform { + self.syncFeeds(account, subscriptions) + self.syncTaggings(account, subscriptions) + } + + self.refreshProgress.completeTask() + completion(.success(())) + + + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + func syncFeeds(_ account: Account, _ subscriptions: [ReaderAPISubscription]?) { + + guard let subscriptions = subscriptions else { return } + + os_log(.debug, log: log, "Syncing feeds with %ld subscriptions.", subscriptions.count) + + let subFeedIds = subscriptions.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.topLevelFeeds { + if !subFeedIds.contains(feed.feedID) { + DispatchQueue.main.sync { + folder.removeFeed(feed) + } + } + } + } + } + + for feed in account.topLevelFeeds { + if !subFeedIds.contains(feed.feedID) { + DispatchQueue.main.sync { + account.removeFeed(feed) + } + } + } + + // Add any feeds we don't have and update any we do + subscriptions.forEach { subscription in + + let subFeedId = String(subscription.feedID) + + DispatchQueue.main.sync { + if let feed = account.idToFeedDictionary[subFeedId] { + feed.name = subscription.name + feed.homePageURL = subscription.homePageURL + } else { + let feed = account.createFeed(with: subscription.name, url: subscription.url, feedID: subFeedId, homePageURL: subscription.homePageURL) + feed.iconURL = subscription.iconURL + feed.subscriptionID = String(subscription.feedID) + account.addFeed(feed) + } + } + + } + + } + + func syncTaggings(_ account: Account, _ subscriptions: [ReaderAPISubscription]?) { + + guard let subscriptions = subscriptions else { return } + + os_log(.debug, log: log, "Syncing taggings with %ld subscriptions.", subscriptions.count) + + // Set up some structures to make syncing easier + let folderDict: [String: Folder] = { + if let folders = account.folders { + return Dictionary(uniqueKeysWithValues: folders.map { ($0.name ?? "", $0) } ) + } else { + return [String: Folder]() + } + }() + + let taggingsDict = subscriptions.reduce([String: [ReaderAPISubscription]]()) { (dict, subscription) in + var taggedFeeds = dict + + // For each category that this feed belongs to, add the feed to that name in the dict + subscription.categories.forEach({ (category) in + let categoryName = category.categoryLabel.replacingOccurrences(of: "user/-/label/", with: "") + + if var taggedFeed = taggedFeeds[categoryName] { + taggedFeed.append(subscription) + taggedFeeds[categoryName] = taggedFeed + } else { + taggedFeeds[categoryName] = [subscription] + } + }) + + return taggedFeeds + } + + // Sync the folders + for (folderName, groupedTaggings) in taggingsDict { + + guard let folder = folderDict[folderName] else { return } + + let taggingFeedIDs = groupedTaggings.map { String($0.feedID) } + + // Move any feeds not in the folder to the account + for feed in folder.topLevelFeeds { + if !taggingFeedIDs.contains(feed.feedID) { + DispatchQueue.main.sync { + folder.removeFeed(feed) + clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") + account.addFeed(feed) + } + } + } + + // Add any feeds not in the folder + let folderFeedIds = folder.topLevelFeeds.map { $0.feedID } + + for subscription in groupedTaggings { + let taggingFeedID = String(subscription.feedID) + if !folderFeedIds.contains(taggingFeedID) { + let idDictionary = account.idToFeedDictionary + guard let feed = idDictionary[taggingFeedID] else { + continue + } + DispatchQueue.main.sync { + saveFolderRelationship(for: feed, withFolderName: folderName, id: String(subscription.feedID)) + folder.addFeed(feed) + } + } + } + + } + + let taggedFeedIDs = Set(subscriptions.map { String($0.feedID) }) + + // Remove all feeds from the account container that have a tag + DispatchQueue.main.sync { + for feed in account.topLevelFeeds { + if taggedFeedIDs.contains(feed.feedID) { + account.removeFeed(feed) + } + } + } + + } + + func sendArticleStatuses(_ statuses: [SyncStatus], + apiCall: ([Int], @escaping (Result) -> Void) -> Void, + completion: @escaping (() -> Void)) { + + guard !statuses.isEmpty else { + completion() + return + } + + let group = DispatchGroup() + + let articleIDs = statuses.compactMap { Int($0.articleID) } + let articleIDGroups = articleIDs.chunked(into: 1000) + for articleIDGroup in articleIDGroups { + + group.enter() + apiCall(articleIDGroup) { result in + switch result { + case .success: + self.database.deleteSelectedForProcessing(articleIDGroup.map { String($0) } ) + group.leave() + case .failure(let error): + os_log(.error, log: self.log, "Article status sync call failed: %@.", error.localizedDescription) + self.database.resetSelectedForProcessing(articleIDGroup.map { String($0) } ) + group.leave() + } + } + + } + + group.notify(queue: DispatchQueue.main) { + completion() + } + + } + + + + func clearFolderRelationship(for feed: Feed, withFolderName folderName: String) { + if var folderRelationship = feed.folderRelationship { + folderRelationship[folderName] = nil + feed.folderRelationship = folderRelationship + } + } + + func saveFolderRelationship(for feed: Feed, withFolderName folderName: String, id: String) { + if var folderRelationship = feed.folderRelationship { + folderRelationship[folderName] = id + feed.folderRelationship = folderRelationship + } else { + feed.folderRelationship = [folderName: id] + } + } + + func decideBestFeedChoice(account: Account, url: String, name: String?, container: Container, choices: [ReaderAPISubscriptionChoice], completion: @escaping (Result) -> Void) { + + let feedSpecifiers: [FeedSpecifier] = choices.map { choice in + let source = url == choice.url ? FeedSpecifier.Source.UserEntered : FeedSpecifier.Source.HTMLLink + let specifier = FeedSpecifier(title: choice.name, urlString: choice.url, source: source) + return specifier + } + + if let bestSpecifier = FeedSpecifier.bestFeed(in: Set(feedSpecifiers)) { + if let bestSubscription = choices.filter({ bestSpecifier.urlString == $0.url }).first { + createFeed(for: account, url: bestSubscription.url, name: name, container: container, completion: completion) + } else { + DispatchQueue.main.async { + completion(.failure(ReaderAPIAccountDelegateError.invalidParameter)) + } + } + } else { + DispatchQueue.main.async { + completion(.failure(ReaderAPIAccountDelegateError.invalidParameter)) + } + } + + } + + func createFeed( account: Account, subscription sub: ReaderAPISubscription, name: String?, container: Container, completion: @escaping (Result) -> Void) { + + DispatchQueue.main.async { + + let feed = account.createFeed(with: sub.name, url: sub.url, feedID: String(sub.feedID), homePageURL: sub.homePageURL) + feed.subscriptionID = String(sub.feedID) + + account.addFeed(feed, to: container) { result in + switch result { + case .success: + if let name = name { + account.renameFeed(feed, to: name) { result in + switch result { + case .success: + self.initialFeedDownload(account: account, feed: feed, completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + } else { + self.initialFeedDownload(account: account, feed: feed, completion: completion) + } + case .failure(let error): + completion(.failure(error)) + } + } + + } + + } + + func initialFeedDownload( account: Account, feed: Feed, completion: @escaping (Result) -> Void) { + + // Download the initial articles + self.caller.retrieveEntries(feedID: feed.feedID) { result in + + switch result { + case .success(let (entries, page)): + + self.processEntries(account: account, entries: entries) { + self.refreshArticles(account, page: page) { + self.refreshArticleStatus(for: account) { + self.refreshMissingArticles(account) { + DispatchQueue.main.async { + completion(.success(feed)) + } + } + } + } + } + + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + func refreshArticles(_ account: Account, completion: @escaping (() -> Void)) { + + os_log(.debug, log: log, "Refreshing articles...") + + caller.retrieveEntries() { result in + + switch result { + case .success(let (entries, page, lastPageNumber)): + + if let last = lastPageNumber { + self.refreshProgress.addToNumberOfTasksAndRemaining(last - 1) + } + + self.processEntries(account: account, entries: entries) { + + self.refreshProgress.completeTask() + self.refreshArticles(account, page: page) { + os_log(.debug, log: self.log, "Done refreshing articles.") + completion() + } + + } + + case .failure(let error): + os_log(.error, log: self.log, "Refresh articles failed: %@.", error.localizedDescription) + completion() + } + + } + + } + + func refreshMissingArticles(_ account: Account, completion: @escaping (() -> Void)) { + + os_log(.debug, log: log, "Refreshing missing articles...") + let articleIDs = Array(account.fetchArticleIDsForStatusesWithoutArticles()) + + let group = DispatchGroup() + + let chunkedArticleIDs = articleIDs.chunked(into: 100) + + for chunk in chunkedArticleIDs { + + group.enter() + caller.retrieveEntries(articleIDs: chunk) { result in + + switch result { + case .success(let entries): + + self.processEntries(account: account, entries: entries) { + group.leave() + } + + case .failure(let error): + os_log(.error, log: self.log, "Refresh missing articles failed: %@.", error.localizedDescription) + group.leave() + } + + } + + } + + group.notify(queue: DispatchQueue.main) { + self.refreshProgress.completeTask() + os_log(.debug, log: self.log, "Done refreshing missing articles.") + completion() + } + + } + + func refreshArticles(_ account: Account, page: String?, completion: @escaping (() -> Void)) { + + guard let page = page else { + completion() + return + } + + caller.retrieveEntries(page: page) { result in + + switch result { + case .success(let (entries, nextPage)): + + self.processEntries(account: account, entries: entries) { + self.refreshProgress.completeTask() + self.refreshArticles(account, page: nextPage, completion: completion) + } + + case .failure(let error): + os_log(.error, log: self.log, "Refresh articles for additional pages failed: %@.", error.localizedDescription) + completion() + } + + } + + } + + func processEntries(account: Account, entries: [ReaderAPIEntry]?, completion: @escaping (() -> Void)) { + + let parsedItems = mapEntriesToParsedItems(account: account, entries: entries) + let parsedMap = Dictionary(grouping: parsedItems, by: { item in item.feedURL } ) + + let group = DispatchGroup() + + for (feedID, mapItems) in parsedMap { + + group.enter() + + if let feed = account.idToFeedDictionary[feedID] { + DispatchQueue.main.async { + account.update(feed, parsedItems: Set(mapItems), defaultRead: true) { + group.leave() + } + } + } else { + group.leave() + } + + } + + group.notify(queue: DispatchQueue.main) { + completion() + } + + } + + func mapEntriesToParsedItems(account: Account, entries: [ReaderAPIEntry]?) -> Set { + + guard let entries = entries else { + return Set() + } + + let parsedItems: [ParsedItem] = entries.map { entry in + // let authors = Set([ParsedAuthor(name: entry.authorName, url: entry.jsonFeed?.jsonFeedAuthor?.url, avatarURL: entry.jsonFeed?.jsonFeedAuthor?.avatarURL, emailAddress: nil)]) + // let feed = account.idToFeedDictionary[entry.origin.streamId!]! // TODO clean this up + + return ParsedItem(syncServiceID: entry.uniqueID(), uniqueID: entry.uniqueID(), feedURL: entry.origin.streamId!, url: nil, externalURL: entry.alternates.first?.url, title: entry.title, contentHTML: entry.summary.content, contentText: nil, summary: entry.summary.content, imageURL: nil, bannerImageURL: nil, datePublished: entry.parseDatePublished(), dateModified: nil, authors: nil, tags: nil, attachments: nil) + } + + return Set(parsedItems) + + } + + func syncArticleReadState(account: Account, articleIDs: [Int]?) { + + guard let articleIDs = articleIDs else { + return + } + + let unreadArticleIDs = Set(articleIDs.map { String($0) } ) + let currentUnreadArticleIDs = account.fetchUnreadArticleIDs() + + // Mark articles as unread + let deltaUnreadArticleIDs = unreadArticleIDs.subtracting(currentUnreadArticleIDs) + let markUnreadArticles = account.fetchArticles(forArticleIDs: deltaUnreadArticleIDs) + DispatchQueue.main.async { + _ = account.update(markUnreadArticles, statusKey: .read, flag: false) + } + + // Save any unread statuses for articles we haven't yet received + let markUnreadArticleIDs = Set(markUnreadArticles.map { $0.articleID }) + let missingUnreadArticleIDs = deltaUnreadArticleIDs.subtracting(markUnreadArticleIDs) + if !missingUnreadArticleIDs.isEmpty { + DispatchQueue.main.async { + account.ensureStatuses(missingUnreadArticleIDs, .read, false) + } + } + + // Mark articles as read + let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(unreadArticleIDs) + let markReadArticles = account.fetchArticles(forArticleIDs: deltaReadArticleIDs) + DispatchQueue.main.async { + _ = account.update(markReadArticles, statusKey: .read, flag: true) + } + + // Save any read statuses for articles we haven't yet received + let markReadArticleIDs = Set(markReadArticles.map { $0.articleID }) + let missingReadArticleIDs = deltaReadArticleIDs.subtracting(markReadArticleIDs) + if !missingReadArticleIDs.isEmpty { + DispatchQueue.main.async { + account.ensureStatuses(missingReadArticleIDs, .read, true) + } + } + + } + + func syncArticleStarredState(account: Account, articleIDs: [Int]?) { + + guard let articleIDs = articleIDs else { + return + } + + let starredArticleIDs = Set(articleIDs.map { String($0) } ) + let currentStarredArticleIDs = account.fetchStarredArticleIDs() + + // Mark articles as starred + let deltaStarredArticleIDs = starredArticleIDs.subtracting(currentStarredArticleIDs) + let markStarredArticles = account.fetchArticles(forArticleIDs: deltaStarredArticleIDs) + DispatchQueue.main.async { + _ = account.update(markStarredArticles, statusKey: .starred, flag: true) + } + + // Save any starred statuses for articles we haven't yet received + let markStarredArticleIDs = Set(markStarredArticles.map { $0.articleID }) + let missingStarredArticleIDs = deltaStarredArticleIDs.subtracting(markStarredArticleIDs) + if !missingStarredArticleIDs.isEmpty { + DispatchQueue.main.async { + account.ensureStatuses(missingStarredArticleIDs, .starred, true) + } + } + + // Mark articles as unstarred + let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(starredArticleIDs) + let markUnstarredArticles = account.fetchArticles(forArticleIDs: deltaUnstarredArticleIDs) + DispatchQueue.main.async { + _ = account.update(markUnstarredArticles, statusKey: .starred, flag: false) + } + + // Save any unstarred statuses for articles we haven't yet received + let markUnstarredArticleIDs = Set(markUnstarredArticles.map { $0.articleID }) + let missingUnstarredArticleIDs = deltaUnstarredArticleIDs.subtracting(markUnstarredArticleIDs) + if !missingUnstarredArticleIDs.isEmpty { + DispatchQueue.main.async { + account.ensureStatuses(missingUnstarredArticleIDs, .starred, false) + } + } + + } + + func deleteTagging(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result) -> Void) { + + if let folder = container as? Folder, let feedName = feed.subscriptionID { + caller.deleteTagging(subscriptionID: feedName, tagName: folder.name ?? "") { result in + switch result { + case .success: + DispatchQueue.main.async { + self.clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") + folder.removeFeed(feed) + account.addFeedIfNotInAnyFolder(feed) + completion(.success(())) + } + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + } else { + if let account = container as? Account { + account.removeFeed(feed) + } + completion(.success(())) + } + + } + + func deleteSubscription(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result) -> Void) { + + // This error should never happen + guard let subscriptionID = feed.subscriptionID else { + completion(.failure(FeedbinAccountDelegateError.invalidParameter)) + return + } + + caller.deleteSubscription(subscriptionID: subscriptionID) { result in + switch result { + case .success: + DispatchQueue.main.async { + account.removeFeed(feed) + if let folders = account.folders { + for folder in folders { + folder.removeFeed(feed) + } + } + completion(.success(())) + } + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + + } + +} diff --git a/Frameworks/Account/ReaderAPI/ReaderAPICaller.swift b/Frameworks/Account/ReaderAPI/ReaderAPICaller.swift new file mode 100644 index 000000000..4384af936 --- /dev/null +++ b/Frameworks/Account/ReaderAPI/ReaderAPICaller.swift @@ -0,0 +1,947 @@ +// +// ReaderAPICaller.swift +// Account +// +// Created by Jeremy Beker on 5/28/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSWeb + +enum CreateReaderAPISubscriptionResult { + case created(ReaderAPISubscription) + case alreadySubscribed + case notFound +} + +final class ReaderAPICaller: NSObject { + + struct ConditionalGetKeys { + static let subscriptions = "subscriptions" + static let tags = "tags" + static let taggings = "taggings" + static let icons = "icons" + static let unreadEntries = "unreadEntries" + static let starredEntries = "starredEntries" + } + + enum ReaderState: String { + case read = "user/-/state/com.google/read" + case starred = "user/-/state/com.google/starred" + } + + enum ReaderStreams: String { + case readingList = "user/-/state/com.google/reading-list" + } + + enum ReaderAPIEndpoints: String { + case login = "/accounts/ClientLogin" + case token = "/reader/api/0/token" + case disableTag = "/reader/api/0/disable-tag" + case renameTag = "/reader/api/0/rename-tag" + case tagList = "/reader/api/0/tag/list" + case subscriptionList = "/reader/api/0/subscription/list" + case subscriptionEdit = "/reader/api/0/subscription/edit" + case subscriptionAdd = "/reader/api/0/subscription/quickadd" + case contents = "/reader/api/0/stream/items/contents" + case itemIds = "/reader/api/0/stream/items/ids" + case editTag = "/reader/api/0/edit-tag" + } + + private var transport: Transport! + + var credentials: Credentials? + private var accessToken: String? + + weak var accountMetadata: AccountMetadata? + + var server: String? { + get { + return APIBaseURL?.host + } + } + + private var APIBaseURL: URL? { + get { + guard let accountMetadata = accountMetadata else { + return nil + } + + return accountMetadata.endpointURL + } + } + + + init(transport: Transport) { + super.init() + self.transport = transport + } + + func validateCredentials(endpoint: URL, completion: @escaping (Result) -> Void) { + guard let credentials = credentials else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + guard case .googleBasicLogin(let username, _) = credentials else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + let request = URLRequest(url: endpoint.appendingPathComponent(ReaderAPIEndpoints.login.rawValue), credentials: credentials) + + transport.send(request: request) { result in + switch result { + case .success(let (_, data)): + guard let resultData = data else { + completion(.failure(TransportError.noData)) + break + } + + // Convert the return data to UTF8 and then parse out the Auth token + guard let rawData = String(data: resultData, encoding: .utf8) else { + completion(.failure(TransportError.noData)) + break + } + + var authData: [String: String] = [:] + rawData.split(separator: "\n").forEach({ (line: Substring) in + let items = line.split(separator: "=").map{String($0)} + authData[items[0]] = items[1] + }) + + guard let authString = authData["Auth"] else { + completion(.failure(CredentialsError.incompleteCredentials)) + break + } + + // Save Auth Token for later use + self.credentials = .googleAuthLogin(username: username, apiKey: authString) + + completion(.success(self.credentials)) + case .failure(let error): + completion(.failure(error)) + } + } + + } + + func requestAuthorizationToken(endpoint: URL, completion: @escaping (Result) -> Void) { + // If we have a token already, use it + if let accessToken = accessToken { + completion(.success(accessToken)) + return + } + + // Otherwise request one. + guard let credentials = credentials else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + let request = URLRequest(url: endpoint.appendingPathComponent(ReaderAPIEndpoints.token.rawValue), credentials: credentials) + + transport.send(request: request) { result in + switch result { + case .success(let (_, data)): + guard let resultData = data else { + completion(.failure(TransportError.noData)) + break + } + + // Convert the return data to UTF8 and then parse out the Auth token + guard let accessToken = String(data: resultData, encoding: .utf8) else { + completion(.failure(TransportError.noData)) + break + } + + self.accessToken = accessToken + completion(.success(accessToken)) + case .failure(let error): + completion(.failure(error)) + } + } + } + + + func retrieveTags(completion: @escaping (Result<[ReaderAPITag]?, Error>) -> Void) { + guard let baseURL = APIBaseURL else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + // Add query string for getting JSON (probably should break this out as I will be doing it a lot) + guard var components = URLComponents(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.tagList.rawValue), resolvingAgainstBaseURL: false) else { + completion(.failure(TransportError.noURL)) + return + } + + components.queryItems = [ + URLQueryItem(name: "output", value: "json") + ] + + guard let callURL = components.url else { + completion(.failure(TransportError.noURL)) + return + } + + let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.tags] + let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet) + + transport.send(request: request, resultType: ReaderAPITagContainer.self) { result in + + switch result { + case .success(let (response, wrapper)): + self.storeConditionalGet(key: ConditionalGetKeys.tags, headers: response.allHeaderFields) + completion(.success(wrapper?.tags)) + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + func renameTag(oldName: String, newName: String, completion: @escaping (Result) -> Void) { + guard let baseURL = APIBaseURL else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + self.requestAuthorizationToken(endpoint: baseURL) { (result) in + switch result { + case .success(let token): + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.renameTag.rawValue), credentials: self.credentials) + + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + + let oldTagName = "user/-/label/\(oldName)" + let newTagName = "user/-/label/\(newName)" + let postData = "T=\(token)&s=\(oldTagName)&dest=\(newTagName)".data(using: String.Encoding.utf8) + + self.transport.send(request: request, method: HTTPMethod.post, payload: postData!, completion: { (result) in + switch result { + case .success: + completion(.success(())) + break + case .failure(let error): + completion(.failure(error)) + break + } + }) + + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func deleteTag(name: String, completion: @escaping (Result) -> Void) { + + guard let baseURL = APIBaseURL else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + self.requestAuthorizationToken(endpoint: baseURL) { (result) in + switch result { + case .success(let token): + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.disableTag.rawValue), credentials: self.credentials) + + + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + + let tagName = "user/-/label/\(name)" + let postData = "T=\(token)&s=\(tagName)".data(using: String.Encoding.utf8) + + self.transport.send(request: request, method: HTTPMethod.post, payload: postData!, completion: { (result) in + switch result { + case .success: + completion(.success(())) + break + case .failure(let error): + completion(.failure(error)) + break + } + }) + + + case .failure(let error): + completion(.failure(error)) + } + } + + } + + func retrieveSubscriptions(completion: @escaping (Result<[ReaderAPISubscription]?, Error>) -> Void) { + guard let baseURL = APIBaseURL else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + // Add query string for getting JSON (probably should break this out as I will be doing it a lot) + guard var components = URLComponents(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionList.rawValue), resolvingAgainstBaseURL: false) else { + completion(.failure(TransportError.noURL)) + return + } + + components.queryItems = [ + URLQueryItem(name: "output", value: "json") + ] + + guard let callURL = components.url else { + completion(.failure(TransportError.noURL)) + return + } + + let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.subscriptions] + let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet) + + transport.send(request: request, resultType: ReaderAPISubscriptionContainer.self) { result in + + switch result { + case .success(let (response, container)): + self.storeConditionalGet(key: ConditionalGetKeys.subscriptions, headers: response.allHeaderFields) + completion(.success(container?.subscriptions)) + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + func createSubscription(url: String, completion: @escaping (Result) -> Void) { + guard let baseURL = APIBaseURL else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + self.requestAuthorizationToken(endpoint: baseURL) { (result) in + switch result { + case .success(let token): + guard var components = URLComponents(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionAdd.rawValue), resolvingAgainstBaseURL: false) else { + completion(.failure(TransportError.noURL)) + return + } + + components.queryItems = [ + URLQueryItem(name: "quickadd", value: url) + ] + + guard let callURL = components.url else { + completion(.failure(TransportError.noURL)) + return + } + + var request = URLRequest(url: callURL, credentials: self.credentials) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + + let postData = "T=\(token)".data(using: String.Encoding.utf8) + + self.transport.send(request: request, method: HTTPMethod.post, data: postData!, resultType: ReaderAPIQuickAddResult.self, completion: { (result) in + switch result { + case .success(let (_, subResult)): + + switch subResult?.numResults { + case 0: + completion(.success(.alreadySubscribed)) + default: + // We have a feed ID but need to get feed information + guard let streamId = subResult?.streamId else { + completion(.failure(AccountError.createErrorNotFound)) + return + } + + // There is no call to get a single subscription entry, so we get them all, + // look up the one we just subscribed to and return that + self.retrieveSubscriptions(completion: { (result) in + switch result { + case .success(let subscriptions): + guard let subscriptions = subscriptions else { + completion(.failure(AccountError.createErrorNotFound)) + return + } + + let newStreamId = "feed/\(streamId)" + + guard let subscription = subscriptions.first(where: { (sub) -> Bool in + sub.feedID == newStreamId + }) else { + completion(.failure(AccountError.createErrorNotFound)) + return + } + + completion(.success(.created(subscription))) + + case .failure(let error): + completion(.failure(error)) + } + }) + } + case .failure(let error): + completion(.failure(error)) + } + }) + + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func renameSubscription(subscriptionID: String, newName: String, completion: @escaping (Result) -> Void) { + guard let baseURL = APIBaseURL else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + self.requestAuthorizationToken(endpoint: baseURL) { (result) in + switch result { + case .success(let token): + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), credentials: self.credentials) + + + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + + let postData = "T=\(token)&s=\(subscriptionID)&ac=edit&t=\(newName)".data(using: String.Encoding.utf8) + + self.transport.send(request: request, method: HTTPMethod.post, payload: postData!, completion: { (result) in + switch result { + case .success: + completion(.success(())) + break + case .failure(let error): + completion(.failure(error)) + break + } + }) + + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func deleteSubscription(subscriptionID: String, completion: @escaping (Result) -> Void) { + guard let baseURL = APIBaseURL else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + self.requestAuthorizationToken(endpoint: baseURL) { (result) in + switch result { + case .success(let token): + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), credentials: self.credentials) + + + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + + let postData = "T=\(token)&s=\(subscriptionID)&ac=unsubscribe".data(using: String.Encoding.utf8) + + self.transport.send(request: request, method: HTTPMethod.post, payload: postData!, completion: { (result) in + switch result { + case .success: + completion(.success(())) + break + case .failure(let error): + completion(.failure(error)) + break + } + }) + + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func createTagging(subscriptionID: String, tagName: String, completion: @escaping (Result) -> Void) { + + guard let baseURL = APIBaseURL else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + self.requestAuthorizationToken(endpoint: baseURL) { (result) in + switch result { + case .success(let token): + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), credentials: self.credentials) + + + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + + let tagName = "user/-/label/\(tagName)" + let postData = "T=\(token)&s=\(subscriptionID)&ac=edit&a=\(tagName)".data(using: String.Encoding.utf8) + + self.transport.send(request: request, method: HTTPMethod.post, payload: postData!, completion: { (result) in + switch result { + case .success: + completion(.success(())) + break + case .failure(let error): + completion(.failure(error)) + break + } + }) + + + case .failure(let error): + completion(.failure(error)) + } + } + + } + + func deleteTagging(subscriptionID: String, tagName: String, completion: @escaping (Result) -> Void) { + guard let baseURL = APIBaseURL else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + self.requestAuthorizationToken(endpoint: baseURL) { (result) in + switch result { + case .success(let token): + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.subscriptionEdit.rawValue), credentials: self.credentials) + + + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + + let tagName = "user/-/label/\(tagName)" + let postData = "T=\(token)&s=\(subscriptionID)&ac=edit&r=\(tagName)".data(using: String.Encoding.utf8) + + self.transport.send(request: request, method: HTTPMethod.post, payload: postData!, completion: { (result) in + switch result { + case .success: + completion(.success(())) + break + case .failure(let error): + completion(.failure(error)) + break + } + }) + + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func retrieveEntries(articleIDs: [String], completion: @escaping (Result<([ReaderAPIEntry]?), Error>) -> Void) { + + guard !articleIDs.isEmpty else { + completion(.success(([ReaderAPIEntry]()))) + return + } + + guard let baseURL = APIBaseURL else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + self.requestAuthorizationToken(endpoint: baseURL) { (result) in + switch result { + case .success(let token): + // Do POST asking for data about all the new articles + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.contents.rawValue), credentials: self.credentials) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + + // Get ids from above into hex representation of value + let idsToFetch = articleIDs.map({ (reference) -> String in + return "i=\(reference)" + }).joined(separator:"&") + + let postData = "T=\(token)&output=json&\(idsToFetch)".data(using: String.Encoding.utf8) + + self.transport.send(request: request, method: HTTPMethod.post, data: postData!, resultType: ReaderAPIEntryWrapper.self, completion: { (result) in + switch result { + case .success(let (_, entryWrapper)): + guard let entryWrapper = entryWrapper else { + completion(.failure(ReaderAPIAccountDelegateError.invalidResponse)) + return + } + + completion(.success((entryWrapper.entries))) + case .failure(let error): + completion(.failure(error)) + } + }) + + + case .failure(let error): + completion(.failure(error)) + } + } + + } + + func retrieveEntries(feedID: String, completion: @escaping (Result<([ReaderAPIEntry]?, String?), Error>) -> Void) { + + let since = Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date() + + guard let baseURL = APIBaseURL else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + // Add query string for getting JSON (probably should break this out as I will be doing it a lot) + guard var components = URLComponents(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.itemIds.rawValue), resolvingAgainstBaseURL: false) else { + completion(.failure(TransportError.noURL)) + return + } + + components.queryItems = [ + URLQueryItem(name: "s", value: feedID), + URLQueryItem(name: "ot", value: String(since.timeIntervalSince1970)), + URLQueryItem(name: "output", value: "json") + ] + + guard let callURL = components.url else { + completion(.failure(TransportError.noURL)) + return + } + + let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: nil) + + transport.send(request: request, resultType: ReaderAPIReferenceWrapper.self) { result in + + switch result { + case .success(let (_, unreadEntries)): + + guard let itemRefs = unreadEntries?.itemRefs else { + completion(.success(([], nil))) + return + } + + let itemIds = itemRefs.map { (reference) -> String in + // Convert the IDs to the (stupid) Google Hex Format + let idValue = Int(reference.itemId)! + return String(idValue, radix: 16, uppercase: false) + } + + self.retrieveEntries(articleIDs: itemIds) { (results) in + switch results { + case .success(let entries): + completion(.success((entries,nil))) + case .failure(let error): + completion(.failure(error)) + } + } + + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + func retrieveEntries(completion: @escaping (Result<([ReaderAPIEntry]?, String?, Int?), Error>) -> Void) { + + guard let baseURL = APIBaseURL else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + let since: Date = { + if let lastArticleFetch = self.accountMetadata?.lastArticleFetch { + return lastArticleFetch + } else { + return Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date() + } + }() + + let sinceString = since.timeIntervalSince1970 + + // Add query string for getting JSON (probably should break this out as I will be doing it a lot) + guard var components = URLComponents(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.itemIds.rawValue), resolvingAgainstBaseURL: false) else { + completion(.failure(TransportError.noURL)) + return + } + + components.queryItems = [ + URLQueryItem(name: "o", value: String(sinceString)), + URLQueryItem(name: "n", value: "10000"), + URLQueryItem(name: "output", value: "json"), + URLQueryItem(name: "xt", value: ReaderState.read.rawValue), + URLQueryItem(name: "s", value: ReaderStreams.readingList.rawValue) + ] + + guard let callURL = components.url else { + completion(.failure(TransportError.noURL)) + return + } + + let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.unreadEntries] + let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet) + + self.transport.send(request: request, resultType: ReaderAPIReferenceWrapper.self) { result in + + switch result { + case .success(let (_, entries)): + + guard let entries = entries else { + completion(.failure(ReaderAPIAccountDelegateError.invalidResponse)) + return + } + + self.requestAuthorizationToken(endpoint: baseURL) { (result) in + switch result { + case .success(let token): + // Do POST asking for data about all the new articles + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.contents.rawValue), credentials: self.credentials) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + + // Get ids from above into hex representation of value + let idsToFetch = entries.itemRefs.map({ (reference) -> String in + let idValue = Int(reference.itemId)! + let idHexString = String(idValue, radix: 16, uppercase: false) + return "i=\(idHexString)" + }).joined(separator:"&") + + let postData = "T=\(token)&output=json&\(idsToFetch)".data(using: String.Encoding.utf8) + + self.transport.send(request: request, method: HTTPMethod.post, data: postData!, resultType: ReaderAPIEntryWrapper.self, completion: { (result) in + switch result { + case .success(let (response, entryWrapper)): + guard let entryWrapper = entryWrapper else { + completion(.failure(ReaderAPIAccountDelegateError.invalidResponse)) + return + } + + let dateInfo = HTTPDateInfo(urlResponse: response) + self.accountMetadata?.lastArticleFetch = dateInfo?.date + + + completion(.success((entryWrapper.entries, nil, nil))) + case .failure(let error): + completion(.failure(error)) + } + }) + + + case .failure(let error): + completion(.failure(error)) + } + } + + case .failure(let error): + self.accountMetadata?.lastArticleFetch = nil + completion(.failure(error)) + } + + } + } + + func retrieveEntries(page: String, completion: @escaping (Result<([ReaderAPIEntry]?, String?), Error>) -> Void) { + + guard let url = URL(string: page), var callComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + completion(.success((nil, nil))) + return + } + + callComponents.queryItems?.append(URLQueryItem(name: "mode", value: "extended")) + let request = URLRequest(url: callComponents.url!, credentials: credentials) + + transport.send(request: request, resultType: [ReaderAPIEntry].self) { result in + + switch result { + case .success(let (response, entries)): + + let pagingInfo = HTTPLinkPagingInfo(urlResponse: response) + completion(.success((entries, pagingInfo.nextPage))) + + case .failure(let error): + self.accountMetadata?.lastArticleFetch = nil + completion(.failure(error)) + } + + } + + } + + func retrieveUnreadEntries(completion: @escaping (Result<[Int]?, Error>) -> Void) { + + guard let baseURL = APIBaseURL else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + // Add query string for getting JSON (probably should break this out as I will be doing it a lot) + guard var components = URLComponents(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.itemIds.rawValue), resolvingAgainstBaseURL: false) else { + completion(.failure(TransportError.noURL)) + return + } + + components.queryItems = [ + URLQueryItem(name: "s", value: ReaderStreams.readingList.rawValue), + URLQueryItem(name: "n", value: "10000"), + URLQueryItem(name: "xt", value: ReaderState.read.rawValue), + URLQueryItem(name: "output", value: "json") + ] + + guard let callURL = components.url else { + completion(.failure(TransportError.noURL)) + return + } + + let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.unreadEntries] + let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet) + + transport.send(request: request, resultType: ReaderAPIReferenceWrapper.self) { result in + + switch result { + case .success(let (response, unreadEntries)): + + guard let itemRefs = unreadEntries?.itemRefs else { + completion(.success([])) + return + } + + let itemIds = itemRefs.map{ Int($0.itemId)! } + + self.storeConditionalGet(key: ConditionalGetKeys.unreadEntries, headers: response.allHeaderFields) + completion(.success(itemIds)) + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + func updateStateToEntries(entries: [Int], state: ReaderState, add: Bool, completion: @escaping (Result) -> Void) { + guard let baseURL = APIBaseURL else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + self.requestAuthorizationToken(endpoint: baseURL) { (result) in + switch result { + case .success(let token): + // Do POST asking for data about all the new articles + var request = URLRequest(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.editTag.rawValue), credentials: self.credentials) + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + + // Get ids from above into hex representation of value + let idsToFetch = entries.map({ (idValue) -> String in + let idHexString = String(format: "%.16llx", idValue) + return "i=\(idHexString)" + }).joined(separator:"&") + + let actionIndicator = add ? "a" : "r" + + let postData = "T=\(token)&\(idsToFetch)&\(actionIndicator)=\(state.rawValue)".data(using: String.Encoding.utf8) + + self.transport.send(request: request, method: HTTPMethod.post, payload: postData!, completion: { (result) in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } + }) + + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func createUnreadEntries(entries: [Int], completion: @escaping (Result) -> Void) { + updateStateToEntries(entries: entries, state: .read, add: false, completion: completion) + } + + func deleteUnreadEntries(entries: [Int], completion: @escaping (Result) -> Void) { + updateStateToEntries(entries: entries, state: .read, add: true, completion: completion) + + } + + func createStarredEntries(entries: [Int], completion: @escaping (Result) -> Void) { + updateStateToEntries(entries: entries, state: .starred, add: true, completion: completion) + + } + + func deleteStarredEntries(entries: [Int], completion: @escaping (Result) -> Void) { + updateStateToEntries(entries: entries, state: .starred, add: false, completion: completion) + } + + func retrieveStarredEntries(completion: @escaping (Result<[Int]?, Error>) -> Void) { + guard let baseURL = APIBaseURL else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + guard var components = URLComponents(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.itemIds.rawValue), resolvingAgainstBaseURL: false) else { + completion(.failure(TransportError.noURL)) + return + } + + components.queryItems = [ + URLQueryItem(name: "s", value: "user/-/state/com.google/starred"), + URLQueryItem(name: "n", value: "10000"), + URLQueryItem(name: "output", value: "json") + ] + + guard let callURL = components.url else { + completion(.failure(TransportError.noURL)) + return + } + + let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.starredEntries] + let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet) + + transport.send(request: request, resultType: ReaderAPIReferenceWrapper.self) { result in + + switch result { + case .success(let (response, unreadEntries)): + + guard let itemRefs = unreadEntries?.itemRefs else { + completion(.success([])) + return + } + + let itemIds = itemRefs.map{ Int($0.itemId)! } + + self.storeConditionalGet(key: ConditionalGetKeys.starredEntries, headers: response.allHeaderFields) + completion(.success(itemIds)) + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + + +} + +// MARK: Private + +extension ReaderAPICaller { + + func storeConditionalGet(key: String, headers: [AnyHashable : Any]) { + if var conditionalGet = accountMetadata?.conditionalGetInfo { + conditionalGet[key] = HTTPConditionalGetInfo(headers: headers) + accountMetadata?.conditionalGetInfo = conditionalGet + } + } +} diff --git a/Frameworks/Account/ReaderAPI/ReaderAPIEntry.swift b/Frameworks/Account/ReaderAPI/ReaderAPIEntry.swift new file mode 100644 index 000000000..d8f85ca6f --- /dev/null +++ b/Frameworks/Account/ReaderAPI/ReaderAPIEntry.swift @@ -0,0 +1,128 @@ +// +// ReaderAPIArticle.swift +// Account +// +// Created by Jeremy Beker on 5/28/19. +// Copyright © 2017 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSParser +import RSCore + +struct ReaderAPIEntryWrapper: Codable { + let id: String + let updated: Int + let entries: [ReaderAPIEntry] + + + enum CodingKeys: String, CodingKey { + case id = "id" + case updated = "updated" + case entries = "items" + } +} + +/* { +"id": "tag:google.com,2005:reader/item/00058a3b5197197b", +"crawlTimeMsec": "1559362260113", +"timestampUsec": "1559362260113787", +"published": 1554845280, +"title": "", +"summary": { +"content": "\n

Found an old screenshot of NetNewsWire 1.0 for iPhone!

\n\n

\"Netnewswire

\n" +}, +"alternate": [ +{ +"href": "https://nnw.ranchero.com/2019/04/09/found-an-old.html" +} +], +"categories": [ +"user/-/state/com.google/reading-list", +"user/-/label/Uncategorized" +], +"origin": { +"streamId": "feed/130", +"title": "NetNewsWire" +} +} +*/ +struct ReaderAPIEntry: Codable { + + let articleID: String + let title: String? + + let publishedTimestamp: Double? + let crawledTimestamp: String? + let timestampUsec: String? + + let summary: ReaderAPIArticleSummary + let alternates: [ReaderAPIAlternateLocation] + let categories: [String] + let origin: ReaderAPIEntryOrigin + + enum CodingKeys: String, CodingKey { + case articleID = "id" + case title = "title" + case summary = "summary" + case alternates = "alternate" + case categories = "categories" + case publishedTimestamp = "published" + case crawledTimestamp = "crawlTimeMsec" + case origin = "origin" + case timestampUsec = "timestampUsec" + } + + func parseDatePublished() -> Date? { + + guard let unixTime = publishedTimestamp else { + return nil + } + + return Date(timeIntervalSince1970: unixTime) + } + + func uniqueID() -> String { + // Should look something like "tag:google.com,2005:reader/item/00058b10ce338909" + // REGEX feels heavy, I should be able to just split on / and take the last element + + guard let idPart = articleID.components(separatedBy: "/").last else { + return articleID + } + + // Convert hex representation back to integer and then a string representation + guard let idNumber = Int(idPart, radix: 16) else { + return articleID + } + + return String(idNumber, radix: 10, uppercase: false) + } +} + +struct ReaderAPIArticleSummary: Codable { + let content: String? + + enum CodingKeys: String, CodingKey { + case content = "content" + } +} + +struct ReaderAPIAlternateLocation: Codable { + let url: String? + + enum CodingKeys: String, CodingKey { + case url = "href" + } +} + + +struct ReaderAPIEntryOrigin: Codable { + let streamId: String? + let title: String? + + enum CodingKeys: String, CodingKey { + case streamId = "streamId" + case title = "title" + } +} + diff --git a/Frameworks/Account/ReaderAPI/ReaderAPISubscription.swift b/Frameworks/Account/ReaderAPI/ReaderAPISubscription.swift new file mode 100644 index 000000000..3072656ec --- /dev/null +++ b/Frameworks/Account/ReaderAPI/ReaderAPISubscription.swift @@ -0,0 +1,104 @@ +// +// ReaderAPIFeed.swift +// Account +// +// Created by Jeremy Beker on 5/28/19. +// Copyright © 2017 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSCore +import RSParser + +/* + + { + "numResults":0, + "error": "Already subscribed! https://inessential.com/xml/rss.xml + } + +*/ + +struct ReaderAPIQuickAddResult: Codable { + let numResults: Int + let error: String? + let streamId: String? + + enum CodingKeys: String, CodingKey { + case numResults = "numResults" + case error = "error" + case streamId = "streamId" + } +} + +struct ReaderAPISubscriptionContainer: Codable { + let subscriptions: [ReaderAPISubscription] + + enum CodingKeys: String, CodingKey { + case subscriptions = "subscriptions" + } +} + +/* +{ + "id": "feed/1", + "title": "Questionable Content", + "categories": [ + { + "id": "user/-/label/Comics", + "label": "Comics" + } + ], + "url": "http://www.questionablecontent.net/QCRSS.xml", + "htmlUrl": "http://www.questionablecontent.net", + "iconUrl": "https://rss.confusticate.com/f.php?24decabc" +} + +*/ +struct ReaderAPISubscription: Codable { + let feedID: String + let name: String? + let categories: [ReaderAPICategory] + let url: String + let homePageURL: String? + let iconURL: String? + + enum CodingKeys: String, CodingKey { + case feedID = "id" + case name = "title" + case categories = "categories" + case url = "url" + case homePageURL = "htmlUrl" + case iconURL = "iconUrl" + } + +} + +struct ReaderAPICategory: Codable { + let categoryId: String + let categoryLabel: String + + enum CodingKeys: String, CodingKey { + case categoryId = "id" + case categoryLabel = "label" + } +} + +struct ReaderAPICreateSubscription: Codable { + let feedURL: String + enum CodingKeys: String, CodingKey { + case feedURL = "feed_url" + } +} + +struct ReaderAPISubscriptionChoice: Codable { + + let name: String? + let url: String + + enum CodingKeys: String, CodingKey { + case name = "title" + case url = "feed_url" + } + +} diff --git a/Frameworks/Account/ReaderAPI/ReaderAPITag.swift b/Frameworks/Account/ReaderAPI/ReaderAPITag.swift new file mode 100644 index 000000000..7f827e1a6 --- /dev/null +++ b/Frameworks/Account/ReaderAPI/ReaderAPITag.swift @@ -0,0 +1,29 @@ +// +// ReaderAPICompatibleTag.swift +// Account +// +// Created by Jeremy Beker on 5/28/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct ReaderAPITagContainer: Codable { + let tags: [ReaderAPITag] + + enum CodingKeys: String, CodingKey { + case tags = "tags" + } +} + +struct ReaderAPITag: Codable { + + let tagID: String + let type: String? + + enum CodingKeys: String, CodingKey { + case tagID = "id" + case type = "type" + } + +} diff --git a/Frameworks/Account/ReaderAPI/ReaderAPITagging.swift b/Frameworks/Account/ReaderAPI/ReaderAPITagging.swift new file mode 100644 index 000000000..d907ca445 --- /dev/null +++ b/Frameworks/Account/ReaderAPI/ReaderAPITagging.swift @@ -0,0 +1,35 @@ +// +// ReaderAPICompatibleTagging.swift +// Account +// +// Created by Jeremy Beker on 5/28/19. +// Copyright © 2018 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct ReaderAPITagging: Codable { + + let taggingID: Int + let feedID: Int + let name: String + + enum CodingKeys: String, CodingKey { + case taggingID = "id" + case feedID = "feed_id" + case name = "name" + } + +} + +struct ReaderAPICreateTagging: Codable { + + let feedID: Int + let name: String + + enum CodingKeys: String, CodingKey { + case feedID = "feed_id" + case name = "name" + } + +} diff --git a/Frameworks/Account/ReaderAPI/ReaderAPIUnreadEntry.swift b/Frameworks/Account/ReaderAPI/ReaderAPIUnreadEntry.swift new file mode 100644 index 000000000..a69909c21 --- /dev/null +++ b/Frameworks/Account/ReaderAPI/ReaderAPIUnreadEntry.swift @@ -0,0 +1,27 @@ +// +// ReaderAPIUnreadEntry.swift +// Account +// +// Created by Jeremy Beker on 5/28/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct ReaderAPIReferenceWrapper: Codable { + let itemRefs: [ReaderAPIReference] + + enum CodingKeys: String, CodingKey { + case itemRefs = "itemRefs" + } +} + +struct ReaderAPIReference: Codable { + + let itemId: String + + enum CodingKeys: String, CodingKey { + case itemId = "id" + } + +} diff --git a/Mac/Preferences/Accounts/AccountsAddViewController.swift b/Mac/Preferences/Accounts/AccountsAddViewController.swift index 6c3a4158f..64d94eafc 100644 --- a/Mac/Preferences/Accounts/AccountsAddViewController.swift +++ b/Mac/Preferences/Accounts/AccountsAddViewController.swift @@ -39,7 +39,7 @@ class AccountsAddViewController: NSViewController { extension AccountsAddViewController: NSTableViewDataSource { func numberOfRows(in tableView: NSTableView) -> Int { - return 2 + return 3 } func tableView(_ tableView: NSTableView, objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any? { @@ -63,6 +63,9 @@ extension AccountsAddViewController: NSTableViewDelegate { case 1: cell.accountNameLabel?.stringValue = NSLocalizedString("Feedbin", comment: "Feedbin") cell.accountImageView?.image = AppAssets.accountFeedbin + case 2: + cell.accountNameLabel?.stringValue = NSLocalizedString("Reader", comment: "Reader") + cell.accountImageView?.image = AppAssets.accountLocal default: break } @@ -87,6 +90,10 @@ extension AccountsAddViewController: NSTableViewDelegate { let accountsFeedbinWindowController = AccountsFeedbinWindowController() accountsFeedbinWindowController.runSheetOnWindow(self.view.window!) accountsAddWindowController = accountsFeedbinWindowController + case 2: + let accountsReaderAPIWindowController = AccountsReaderAPIWindowController() + accountsReaderAPIWindowController.runSheetOnWindow(self.view.window!) + accountsAddWindowController = accountsReaderAPIWindowController default: break } diff --git a/Mac/Preferences/Accounts/AccountsFeedbinWindowController.swift b/Mac/Preferences/Accounts/AccountsFeedbinWindowController.swift index 11cdcc109..191599c07 100644 --- a/Mac/Preferences/Accounts/AccountsFeedbinWindowController.swift +++ b/Mac/Preferences/Accounts/AccountsFeedbinWindowController.swift @@ -74,38 +74,36 @@ class AccountsFeedbinWindowController: NSWindowController { self.progressIndicator.stopAnimation(self) switch result { - case .success(let authenticated): - - if authenticated { - - var newAccount = false - if self.account == nil { - self.account = AccountManager.shared.createAccount(type: .feedbin) - newAccount = true - } - - do { - try self.account?.removeBasicCredentials() - try self.account?.storeCredentials(credentials) - if newAccount { - self.account?.refreshAll() { result in - switch result { - case .success: - break - case .failure(let error): - NSApplication.shared.presentError(error) - } + case .success(let validatedCredentials): + + guard let validatedCredentials = validatedCredentials else { + self.errorMessageLabel.stringValue = NSLocalizedString("Invalid email/password combination.", comment: "Credentials Error") + return + } + var newAccount = false + if self.account == nil { + self.account = AccountManager.shared.createAccount(type: .feedbin) + newAccount = true + } + + do { + try self.account?.removeBasicCredentials() + try self.account?.storeCredentials(validatedCredentials) + if newAccount { + self.account?.refreshAll() { result in + switch result { + case .success: + break + case .failure(let error): + NSApplication.shared.presentError(error) } } - self.hostWindow?.endSheet(self.window!, returnCode: NSApplication.ModalResponse.OK) - } catch { - self.errorMessageLabel.stringValue = NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error") } - - } else { - self.errorMessageLabel.stringValue = NSLocalizedString("Invalid email/password combination.", comment: "Credentials Error") + self.hostWindow?.endSheet(self.window!, returnCode: NSApplication.ModalResponse.OK) + } catch { + self.errorMessageLabel.stringValue = NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error") } - + case .failure: self.errorMessageLabel.stringValue = NSLocalizedString("Network error. Try again later.", comment: "Credentials Error") diff --git a/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift b/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift index 4de8f17af..84673da8b 100644 --- a/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift +++ b/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift @@ -107,6 +107,8 @@ extension AccountsPreferencesViewController: NSTableViewDelegate { cell.imageView?.image = AppAssets.accountLocal case .feedbin: cell.imageView?.image = NSImage(named: "accountFeedbin") + case .googleReaderAPI: + cell.imageView?.image = AppAssets.accountLocal default: break } diff --git a/Mac/Preferences/Accounts/AccountsReaderAPI.xib b/Mac/Preferences/Accounts/AccountsReaderAPI.xib new file mode 100644 index 000000000..1da180f56 --- /dev/null +++ b/Mac/Preferences/Accounts/AccountsReaderAPI.xib @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NSAllRomanInputSourcesLocaleIdentifier + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mac/Preferences/Accounts/AccountsReaderAPIWindowController.swift b/Mac/Preferences/Accounts/AccountsReaderAPIWindowController.swift new file mode 100644 index 000000000..ac74eef50 --- /dev/null +++ b/Mac/Preferences/Accounts/AccountsReaderAPIWindowController.swift @@ -0,0 +1,125 @@ +// +// AccountsAddFeedbinWindowController.swift +// NetNewsWire +// +// Created by Maurice Parker on 5/2/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import AppKit +import Account +import RSWeb + +class AccountsReaderAPIWindowController: NSWindowController { + + @IBOutlet weak var progressIndicator: NSProgressIndicator! + @IBOutlet weak var usernameTextField: NSTextField! + @IBOutlet weak var apiURLTextField: NSTextField! + @IBOutlet weak var passwordTextField: NSSecureTextField! + @IBOutlet weak var errorMessageLabel: NSTextField! + @IBOutlet weak var actionButton: NSButton! + + var account: Account? + + private weak var hostWindow: NSWindow? + + convenience init() { + self.init(windowNibName: NSNib.Name("AccountsReaderAPI")) + } + + override func windowDidLoad() { + if let account = account, let credentials = try? account.retrieveBasicCredentials() { + if case .basic(let username, let password) = credentials { + usernameTextField.stringValue = username + passwordTextField.stringValue = password + } + actionButton.title = NSLocalizedString("Update", comment: "Update") + } else { + actionButton.title = NSLocalizedString("Create", comment: "Create") + } + } + + // MARK: API + + func runSheetOnWindow(_ hostWindow: NSWindow, completionHandler handler: ((NSApplication.ModalResponse) -> Void)? = nil) { + self.hostWindow = hostWindow + hostWindow.beginSheet(window!, completionHandler: handler) + } + + // MARK: Actions + + @IBAction func cancel(_ sender: Any) { + hostWindow!.endSheet(window!, returnCode: NSApplication.ModalResponse.cancel) + } + + @IBAction func action(_ sender: Any) { + + self.errorMessageLabel.stringValue = "" + + guard !usernameTextField.stringValue.isEmpty && !passwordTextField.stringValue.isEmpty && !apiURLTextField.stringValue.isEmpty else { + self.errorMessageLabel.stringValue = NSLocalizedString("Username, password & API URL are required.", comment: "Credentials Error") + return + } + + actionButton.isEnabled = false + progressIndicator.isHidden = false + progressIndicator.startAnimation(self) + + guard let apiURL = URL(string: apiURLTextField.stringValue) else { + self.errorMessageLabel.stringValue = NSLocalizedString("Invalid API URL.", comment: "Credentials Error") + return + } + + let credentials = Credentials.googleBasicLogin(username: usernameTextField.stringValue, password: passwordTextField.stringValue) + Account.validateCredentials(type: .googleReaderAPI, credentials: credentials, endpoint: apiURL) { [weak self] result in + + guard let self = self else { return } + + self.actionButton.isEnabled = true + self.progressIndicator.isHidden = true + self.progressIndicator.stopAnimation(self) + + switch result { + case .success(let validatedCredentials): + guard let validatedCredentials = validatedCredentials else { + self.errorMessageLabel.stringValue = NSLocalizedString("Invalid email/password combination.", comment: "Credentials Error") + return + } + + + var newAccount = false + if self.account == nil { + self.account = AccountManager.shared.createAccount(type: .googleReaderAPI) + newAccount = true + } + + do { + self.account?.endpointURL = apiURL + + try self.account?.removeGoogleAuthCredentials() + try self.account?.storeCredentials(validatedCredentials) + + if newAccount { + self.account?.refreshAll() { result in + switch result { + case .success: + break + case .failure(let error): + NSApplication.shared.presentError(error) + } + } + } + self.hostWindow?.endSheet(self.window!, returnCode: NSApplication.ModalResponse.OK) + } catch { + self.errorMessageLabel.stringValue = NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error") + } + + case .failure: + self.errorMessageLabel.stringValue = NSLocalizedString("Network error. Try again later.", comment: "Credentials Error") + } + + } + + } + +} diff --git a/Mac/Scriptability/Account+Scriptability.swift b/Mac/Scriptability/Account+Scriptability.swift index 80637f6be..445b7bc16 100644 --- a/Mac/Scriptability/Account+Scriptability.swift +++ b/Mac/Scriptability/Account+Scriptability.swift @@ -142,6 +142,8 @@ class ScriptableAccount: NSObject, UniqueIdScriptingObject, ScriptingObjectConta osType = "FWrg" case .newsBlur: osType = "NBlr" + case .googleReaderAPI: + osType = "Grdr" } return osType.fourCharCode() } diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 08cbcb50d..52c120d44 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -155,6 +155,9 @@ 51F85BF92274AA7B00C787DC /* UIBarButtonItem-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F85BF82274AA7B00C787DC /* UIBarButtonItem-Extensions.swift */; }; 51F85BFB2275D85000C787DC /* Array-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F85BFA2275D85000C787DC /* Array-Extensions.swift */; }; 51F85BFD2275DCA800C787DC /* SingleLineUILabelSizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F85BFC2275DCA800C787DC /* SingleLineUILabelSizer.swift */; }; + 557EE1AE22B6F4E1004206FA /* SettingsReaderAPIAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 557EE1A522B6F4E1004206FA /* SettingsReaderAPIAccountView.swift */; }; + 55E15BCB229D65A900D6602A /* AccountsReaderAPI.xib in Resources */ = {isa = PBXBuildFile; fileRef = 55E15BC1229D65A900D6602A /* AccountsReaderAPI.xib */; }; + 55E15BCC229D65A900D6602A /* AccountsReaderAPIWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55E15BCA229D65A900D6602A /* AccountsReaderAPIWindowController.swift */; }; 6581C73820CED60100F4AD34 /* SafariExtensionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6581C73720CED60100F4AD34 /* SafariExtensionHandler.swift */; }; 6581C73A20CED60100F4AD34 /* SafariExtensionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6581C73920CED60100F4AD34 /* SafariExtensionViewController.swift */; }; 6581C73D20CED60100F4AD34 /* SafariExtensionViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 6581C73B20CED60100F4AD34 /* SafariExtensionViewController.xib */; }; @@ -750,6 +753,9 @@ 51F85BF82274AA7B00C787DC /* UIBarButtonItem-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBarButtonItem-Extensions.swift"; sourceTree = ""; }; 51F85BFA2275D85000C787DC /* Array-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array-Extensions.swift"; sourceTree = ""; }; 51F85BFC2275DCA800C787DC /* SingleLineUILabelSizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingleLineUILabelSizer.swift; sourceTree = ""; }; + 557EE1A522B6F4E1004206FA /* SettingsReaderAPIAccountView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsReaderAPIAccountView.swift; sourceTree = ""; }; + 55E15BC1229D65A900D6602A /* AccountsReaderAPI.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = AccountsReaderAPI.xib; sourceTree = ""; }; + 55E15BCA229D65A900D6602A /* AccountsReaderAPIWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountsReaderAPIWindowController.swift; sourceTree = ""; }; 6581C73320CED60000F4AD34 /* Subscribe to Feed.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Subscribe to Feed.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 6581C73420CED60100F4AD34 /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = System/Library/Frameworks/Cocoa.framework; sourceTree = SDKROOT; }; 6581C73720CED60100F4AD34 /* SafariExtensionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariExtensionHandler.swift; sourceTree = ""; }; @@ -1060,6 +1066,7 @@ 5183CCEB227117C70010922C /* Settings */ = { isa = PBXGroup; children = ( + 557EE1A522B6F4E1004206FA /* SettingsReaderAPIAccountView.swift */, 510D708122B041CC004E8F65 /* SettingsAccountLabelView.swift */, 510D707322B028E1004E8F65 /* SettingsAddAccountView.swift */, 51F772EC22B2789B0087D9D1 /* SettingsDetailAccountView.swift */, @@ -1662,6 +1669,8 @@ 84C9FC6F22629E1200D921D6 /* Accounts */ = { isa = PBXGroup; children = ( + 55E15BC1229D65A900D6602A /* AccountsReaderAPI.xib */, + 55E15BCA229D65A900D6602A /* AccountsReaderAPIWindowController.swift */, 84C9FC7022629E1200D921D6 /* AccountsTableViewBackgroundView.swift */, 84C9FC7122629E1200D921D6 /* AccountsControlsBackgroundView.swift */, 84C9FC7222629E1200D921D6 /* AccountsPreferencesViewController.swift */, @@ -1963,12 +1972,12 @@ ORGANIZATIONNAME = "Ranchero Software"; TargetAttributes = { 6581C73220CED60000F4AD34 = { - DevelopmentTeam = SHJK2V3AJG; - ProvisioningStyle = Manual; + DevelopmentTeam = 96VR936H35; + ProvisioningStyle = Automatic; }; 840D617B2029031C009BC708 = { CreatedOnToolsVersion = 9.3; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = 96VR936H35; ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.BackgroundModes = { @@ -1978,8 +1987,8 @@ }; 849C645F1ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = SHJK2V3AJG; - ProvisioningStyle = Manual; + DevelopmentTeam = 96VR936H35; + ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.HardenedRuntime = { enabled = 1; @@ -1988,7 +1997,7 @@ }; 849C64701ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = 96VR936H35; ProvisioningStyle = Automatic; TestTargetID = 849C645F1ED37A5D003D8FC0; }; @@ -2256,6 +2265,7 @@ 5144EA52227B8E4500D19003 /* AccountsFeedbin.xib in Resources */, 8405DDA222168920008CE1BF /* TimelineTableView.xib in Resources */, 8483630E2262A3FE00DA1D35 /* MainWindow.storyboard in Resources */, + 55E15BCB229D65A900D6602A /* AccountsReaderAPI.xib in Resources */, 84BAE64921CEDAF20046DB56 /* CrashReporterWindow.xib in Resources */, 84C9FC8E22629E8F00D921D6 /* Credits.rtf in Resources */, 84BBB12D20142A4700F054F5 /* Inspector.storyboard in Resources */, @@ -2380,6 +2390,7 @@ 51F85BF722749FA100C787DC /* UIFont-Extensions.swift in Sources */, 515436882291D75D005E1CDF /* AddLocalAccountViewController.swift in Sources */, 51C452AF2265108300C03939 /* ArticleArray.swift in Sources */, + 557EE1AE22B6F4E1004206FA /* SettingsReaderAPIAccountView.swift in Sources */, 51C4528E2265099C00C03939 /* SmartFeedsController.swift in Sources */, 51C4529C22650A1000C03939 /* SingleFaviconDownloader.swift in Sources */, 51E595A6228CC36500FCC42B /* ArticleStatusSyncTimer.swift in Sources */, @@ -2476,6 +2487,7 @@ 8477ACBE22238E9500DF7F37 /* SearchFeedDelegate.swift in Sources */, 51E3EB33229AB02C00645299 /* ErrorHandler.swift in Sources */, 8472058120142E8900AD578B /* FeedInspectorViewController.swift in Sources */, + 55E15BCC229D65A900D6602A /* AccountsReaderAPIWindowController.swift in Sources */, 5144EA382279FC6200D19003 /* AccountsAddLocalWindowController.swift in Sources */, 84AD1EAA2031617300BC20B7 /* PasteboardFolder.swift in Sources */, 5144EA51227B8E4500D19003 /* AccountsFeedbinWindowController.swift in Sources */, diff --git a/iOS/Settings/SettingsFeedbinAccountView.swift b/iOS/Settings/SettingsFeedbinAccountView.swift index 2412ff993..1b3f370fb 100644 --- a/iOS/Settings/SettingsFeedbinAccountView.swift +++ b/iOS/Settings/SettingsFeedbinAccountView.swift @@ -79,7 +79,7 @@ struct SettingsFeedbinAccountView : View { switch result { case .success(let authenticated): - if authenticated { + if (authenticated != nil) { var newAccount = false let workAccount: Account diff --git a/iOS/Settings/SettingsReaderAPIAccountView.swift b/iOS/Settings/SettingsReaderAPIAccountView.swift new file mode 100644 index 000000000..c21dd7891 --- /dev/null +++ b/iOS/Settings/SettingsReaderAPIAccountView.swift @@ -0,0 +1,187 @@ +// +// SettingsReaderAPIAccountView.swift +// NetNewsWire-iOS +// +// Created by Jeremy Beker on 5/28/2019. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import SwiftUI +import Combine +import Account +import RSWeb + +struct SettingsReaderAPIAccountView : View { + @Environment(\.isPresented) private var isPresented + @ObjectBinding var viewModel: ViewModel + @State var busy: Bool = false + @State var error: Text = Text("") + + var body: some View { + NavigationView { + List { + Section(header: + SettingsAccountLabelView(accountImage: "accountLocal", accountLabel: "Google Reader Compatible").padding() + ) { + HStack { + Text("Email:") + Divider() + TextField($viewModel.email) + .textContentType(.username) + } + HStack { + Text("Password:") + Divider() + SecureField($viewModel.password) + } + HStack { + Text("API URL:") + Divider() + TextField($viewModel.apiURL) + .textContentType(.URL) + } + } + Section(footer: + HStack { + Spacer() + error.color(.red) + Spacer() + } + ) { + HStack { + Spacer() + Button(action: { self.addAccount() }) { + if viewModel.isUpdate { + Text("Update Account") + } else { + Text("Add Account") + } + } + .disabled(!viewModel.isValid) + Spacer() + } + } + } + .disabled(busy) + .listStyle(.grouped) + .navigationBarTitle(Text(""), displayMode: .inline) + .navigationBarItems(leading: + Button(action: { self.dismiss() }) { Text("Cancel") } + ) + } + } + + private func addAccount() { + + busy = true + error = Text("") + + let emailAddress = viewModel.email.trimmingCharacters(in: .whitespaces) + let credentials = Credentials.googleBasicLogin(username: emailAddress, password: viewModel.password) + guard let apiURL = URL(string: viewModel.apiURL) else { + self.error = Text("Invalide API URL.") + return + } + + Account.validateCredentials(type: .googleReaderAPI, credentials: credentials, endpoint: apiURL) { result in + + self.busy = false + + switch result { + case .success(let authenticated): + + if (authenticated != nil) { + + var newAccount = false + let workAccount: Account + if self.viewModel.account == nil { + workAccount = AccountManager.shared.createAccount(type: .googleReaderAPI) + newAccount = true + } else { + workAccount = self.viewModel.account! + } + + do { + + do { + try workAccount.removeBasicCredentials() + } catch {} + + workAccount.endpointURL = apiURL + + try workAccount.storeCredentials(credentials) + + if newAccount { + workAccount.refreshAll() { result in } + } + + self.dismiss() + + } catch { + self.error = Text("Keychain error while storing credentials.") + } + + } else { + self.error = Text("Invalid email/password combination.") + } + + case .failure: + self.error = Text("Network error. Try again later.") + } + + } + + } + + private func dismiss() { + isPresented?.value = false + } + + class ViewModel: BindableObject { + let didChange = PassthroughSubject() + var account: Account? = nil + + init() { + } + + init(account: Account) { + self.account = account + if case .basic(let username, let password) = try? account.retrieveBasicCredentials() { + self.email = username + self.password = password + } + } + + var email: String = "" { + didSet { + didChange.send(self) + } + } + var password: String = "" { + didSet { + didChange.send(self) + } + } + var apiURL: String = "" { + didSet { + didChange.send(self) + } + } + var isUpdate: Bool { + return account != nil + } + + var isValid: Bool { + return !email.isEmpty && !password.isEmpty + } + } + +} + +#if DEBUG +struct SettingsReaderAPIAccountView_Previews : PreviewProvider { + static var previews: some View { + SettingsReaderAPIAccountView(viewModel: SettingsReaderAPIAccountView.ViewModel()) + } +} +#endif diff --git a/iOS/Settings/UIKit/FeedbinAccountViewController.swift b/iOS/Settings/UIKit/FeedbinAccountViewController.swift index c12d3d941..3cbf24bea 100644 --- a/iOS/Settings/UIKit/FeedbinAccountViewController.swift +++ b/iOS/Settings/UIKit/FeedbinAccountViewController.swift @@ -67,7 +67,7 @@ class FeedbinAccountViewController: UIViewController { switch result { case .success(let authenticated): - if authenticated { + if (authenticated != nil) { var newAccount = false if self.account == nil { self.account = AccountManager.shared.createAccount(type: .feedbin) diff --git a/submodules/RSParser b/submodules/RSParser index 52a23c95d..f2be15379 160000 --- a/submodules/RSParser +++ b/submodules/RSParser @@ -1 +1 @@ -Subproject commit 52a23c95d4cfd52b827c9f571a2271376ed070fd +Subproject commit f2be15379d64e2f660735219bcbd77f7a759b057