From 50428f3179fbc7b2c36466613ec6b5af11181f18 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Sat, 28 Sep 2019 00:44:58 -0400 Subject: [PATCH 01/28] Allow adding Feed Wrangler accounts --- Frameworks/Account/Account.swift | 4 + .../Account/Account.xcodeproj/project.pbxproj | 20 ++ .../Account/Credentials/Credentials.swift | 2 + .../Credentials/URLRequest+RSWeb.swift | 13 ++ .../FeedWrangler/FeedWranglerAPICaller.swift | 59 ++++++ .../FeedWranglerAccountDelegate.swift | 156 +++++++++++++++ .../FeedWrangler/FeedWranglerConfig.swift | 17 ++ Mac/AppAssets.swift | 4 + .../Accounts/AccountsAddViewController.swift | 9 +- .../Accounts/AccountsFeedWrangler.xib | 185 ++++++++++++++++++ ...AccountsFeedWranglerWindowController.swift | 110 +++++++++++ .../Contents.json | 15 ++ .../accountFreshRSS.pdf | Bin 0 -> 4206 bytes NetNewsWire.xcodeproj/project.pbxproj | 18 +- 14 files changed, 606 insertions(+), 6 deletions(-) create mode 100644 Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift create mode 100644 Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift create mode 100644 Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift create mode 100644 Mac/Preferences/Accounts/AccountsFeedWrangler.xib create mode 100644 Mac/Preferences/Accounts/AccountsFeedWranglerWindowController.swift create mode 100644 Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/Contents.json create mode 100644 Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/accountFreshRSS.pdf diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index d0f787da3..c2a3b76b9 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -226,6 +226,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, self.delegate = ReaderAPIAccountDelegate(dataFolder: dataFolder, transport: transport) case .feedly: self.delegate = FeedlyAccountDelegate(dataFolder: dataFolder, transport: transport) + case .feedWrangler: + self.delegate = FeedWranglerAccountDelegate(dataFolder: dataFolder, transport: transport) default: return nil } @@ -302,6 +304,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, FeedbinAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion) case .freshRSS: ReaderAPIAccountDelegate.validateCredentials(transport: transport, credentials: credentials, endpoint: endpoint, completion: completion) + case .feedWrangler: + FeedWranglerAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion) default: break } diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index a27cac954..12b3b737f 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 3B29BC6E233EA83C002A346D /* FeedWranglerAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B29BC6C233EA83C002A346D /* FeedWranglerAPICaller.swift */; }; + 3B29BC6F233EA83C002A346D /* FeedWranglerAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B29BC6D233EA83C002A346D /* FeedWranglerAccountDelegate.swift */; }; + 3B90F51F233F0EF800D481CC /* FeedWranglerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B90F51E233F0EF800D481CC /* FeedWranglerConfig.swift */; }; 5107A099227DE42E00C7C3C5 /* AccountCredentialsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */; }; 5107A09B227DE49500C7C3C5 /* TestAccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */; }; 5107A09D227DE77700C7C3C5 /* TestTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09C227DE77700C7C3C5 /* TestTransport.swift */; }; @@ -144,6 +147,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 3B29BC6C233EA83C002A346D /* FeedWranglerAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAPICaller.swift; sourceTree = ""; }; + 3B29BC6D233EA83C002A346D /* FeedWranglerAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAccountDelegate.swift; sourceTree = ""; }; + 3B90F51E233F0EF800D481CC /* FeedWranglerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerConfig.swift; sourceTree = ""; }; 5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCredentialsTest.swift; sourceTree = ""; }; 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAccountManager.swift; sourceTree = ""; }; 5107A09C227DE77700C7C3C5 /* TestTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTransport.swift; sourceTree = ""; }; @@ -268,6 +274,16 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 3B29BC6B233EA83C002A346D /* FeedWrangler */ = { + isa = PBXGroup; + children = ( + 3B29BC6C233EA83C002A346D /* FeedWranglerAPICaller.swift */, + 3B29BC6D233EA83C002A346D /* FeedWranglerAccountDelegate.swift */, + 3B90F51E233F0EF800D481CC /* FeedWranglerConfig.swift */, + ); + path = FeedWrangler; + sourceTree = ""; + }; 515E4EB12324FF7D0057B0E7 /* Credentials */ = { isa = PBXGroup; children = ( @@ -405,6 +421,7 @@ 84245C7D1FDDD2580074AFBB /* Feedbin */, 552032EA229D5D5A009559E0 /* ReaderAPI */, 9EA31339231E368100268BA0 /* Feedly */, + 3B29BC6B233EA83C002A346D /* FeedWrangler */, 848935031F62484F00CEBD24 /* AccountTests */, 848934F71F62484F00CEBD24 /* Products */, 8469F80F1F6DC3C10084783E /* Frameworks */, @@ -665,14 +682,17 @@ 84C8B3F41F89DE430053CCA6 /* DataExtensions.swift in Sources */, 552032F9229D5D5A009559E0 /* ReaderAPISubscription.swift in Sources */, 84C3654A1F899F3B001EC85C /* CombinedRefreshProgress.swift in Sources */, + 3B29BC6F233EA83C002A346D /* FeedWranglerAccountDelegate.swift in Sources */, 9EC688EE232C58E800A8D0A2 /* OAuthAuthorizationCodeGranting.swift in Sources */, 9EC688EC232C583300A8D0A2 /* FeedlyAccountDelegate+OAuth.swift in Sources */, 8469F81C1F6DD15E0084783E /* Account.swift in Sources */, 9EAEC60E2332FEC20085D7C9 /* FeedlyFeed.swift in Sources */, 5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */, 9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */, + 3B29BC6E233EA83C002A346D /* FeedWranglerAPICaller.swift in Sources */, 51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */, 9E1D155923343B2A00F4944C /* FeedlyGetStreamParsedItemsOperation.swift in Sources */, + 3B90F51F233F0EF800D481CC /* FeedWranglerConfig.swift in Sources */, 846E77451F6EF9B900A165E2 /* Container.swift in Sources */, 9E1D15532334304B00F4944C /* FeedlyGetCollectionStreamOperation.swift in Sources */, 9E12B0202334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift in Sources */, diff --git a/Frameworks/Account/Credentials/Credentials.swift b/Frameworks/Account/Credentials/Credentials.swift index 22a99e8a3..3a6cf06bd 100644 --- a/Frameworks/Account/Credentials/Credentials.swift +++ b/Frameworks/Account/Credentials/Credentials.swift @@ -15,6 +15,8 @@ public enum CredentialsError: Error { public enum CredentialsType: String { case basic = "password" + case feedWranglerBasic = "feedWranglerBasic" + case feedWranglerToken = "feedWranglerToken" case readerBasic = "readerBasic" case readerAPIKey = "readerAPIKey" case oauthAccessToken = "oauthAccessToken" diff --git a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift index 4374f285b..da7b04a49 100755 --- a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift +++ b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift @@ -25,6 +25,19 @@ public extension URLRequest { let base64 = data?.base64EncodedString() let auth = "Basic \(base64 ?? "")" setValue(auth, forHTTPHeaderField: HTTPRequestHeader.authorization) + case .feedWranglerBasic: + + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return + } + components.queryItems = [ + URLQueryItem(name: "email", value: credentials.username), + URLQueryItem(name: "password", value: credentials.secret), + URLQueryItem(name: "client_key", value: FeedWranglerConfig.clientKey) + ] + self.url = components.url + case .feedWranglerToken: + fatalError() // TODO: implement case .readerBasic: setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") httpMethod = "POST" diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift new file mode 100644 index 000000000..110ec5d8b --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift @@ -0,0 +1,59 @@ +// +// FeedWranglerAPICaller.swift +// Account +// +// Created by Jonathan Bennett on 2019-08-29. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +import Foundation +import RSWeb + +final class FeedWranglerAPICaller: NSObject { + + private var transport: Transport! + + var credentials: Credentials? + weak var accountMetadata: AccountMetadata? + + init(transport: Transport) { + super.init() + self.transport = transport + } + + func validateCredentials(completion: @escaping (Result) -> Void) { + let callURL = FeedWranglerConfig.clientURL.appendingPathComponent("users/authorize") + let request = URLRequest(url: callURL, credentials: credentials) + let username = self.credentials?.username ?? "" + + transport.send(request: request) { result in + switch result { + case .success(let (_, data)): + guard let data = data else { + completion(.success(nil)) + return + } + + do { + if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String:Any] { + if let accessToken = json["access_token"] as? String { + let authCredentials = Credentials(type: .feedWranglerToken, username: username, secret: accessToken) + completion(.success(authCredentials)) + return + } + } + + completion(.success(nil)) + } catch let error { + completion(.failure(error)) + } + case .failure(let error): + completion(.failure(error)) + } + + } + } + +} diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift new file mode 100644 index 000000000..5829fcc3f --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -0,0 +1,156 @@ +// +// FeedWranglerAccountDelegate.swift +// Account +// +// Created by Jonathan Bennett on 2019-08-29. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Articles +import RSCore +import RSParser +import RSWeb +import SyncDatabase +import os.log + +final class FeedWranglerAccountDelegate: AccountDelegate { + + var behaviors: AccountBehaviors = [] + + var isOPMLImportInProgress = false + var server: String? = FeedWranglerConfig.clientPath + var credentials: Credentials? + var accountMetadata: AccountMetadata? + var refreshProgress = DownloadProgress(numberOfTasks: 0) + + private let caller: FeedWranglerAPICaller + private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Feed Wrangler") + private let database: SyncDatabase + + init(dataFolder: String, transport: Transport?) { + if let transport = transport { + caller = FeedWranglerAPICaller(transport: transport) + } else { + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData + sessionConfiguration.timeoutIntervalForRequest = 60.0 + sessionConfiguration.httpShouldSetCookies = false + sessionConfiguration.httpCookieAcceptPolicy = .never + sessionConfiguration.httpMaximumConnectionsPerHost = 1 + sessionConfiguration.httpCookieStorage = nil + sessionConfiguration.urlCache = nil + + if let userAgentHeaders = UserAgent.headers() { + sessionConfiguration.httpAdditionalHeaders = userAgentHeaders + } + + let session = URLSession(configuration: sessionConfiguration) + caller = FeedWranglerAPICaller(transport: session) + } + + database = SyncDatabase(databaseFilePath: dataFolder.appending("/Sync.sqlite")) + } + + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { + refreshProgress.addToNumberOfTasksAndRemaining(6) + + self.sendArticleStatus(for: account) { + self.refreshArticleStatus(for: account) { + self.refreshArticles(for: account) { + self.refreshMissingArticles(for: account) { + self.refreshProgress.clear() + DispatchQueue.main.async { + completion(.success(())) + } + } + } + } + } + } + + func refreshArticles(for account: Account, completion: @escaping (() -> Void)) { + os_log(.debug, log: log, "Refreshing articles...") + } + + func refreshMissingArticles(for account: Account, completion: @escaping (() -> Void)) { + os_log(.debug, log: log, "Refreshing missing articles...") + completion() + } + + func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) { + os_log(.debug, log: log, "Sending article status...") + completion() + } + + func refreshArticleStatus(for account: Account, completion: @escaping (() -> Void)) { + os_log(.debug, log: log, "Refreshing article status...") + completion() + } + + func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result) -> Void) { + fatalError() + } + + func addFolder(for account: Account, name: String, completion: @escaping (Result) -> Void) { + fatalError() + } + + func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> Void) { + fatalError() + } + + func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) { + fatalError() + } + + func createFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { + fatalError() + } + + func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result) -> Void) { + fatalError() + } + + func addFeed(for account: Account, with: Feed, to container: Container, completion: @escaping (Result) -> Void) { + fatalError() + } + + func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result) -> Void) { + fatalError() + } + + func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result) -> Void) { + fatalError() + } + + func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result) -> Void) { + fatalError() + } + + func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result) -> Void) { + fatalError() + } + + func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { + fatalError() + } + + func accountDidInitialize(_ account: Account) { + credentials = try? account.retrieveCredentials(type: .feedWranglerToken) + } + + static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL? = nil, completion: @escaping (Result) -> Void) { + let caller = FeedWranglerAPICaller(transport: transport) + caller.credentials = credentials + caller.validateCredentials() { result in + DispatchQueue.main.async { + completion(result) + } + } + } +} + +// MARK: Private +private extension FeedWranglerAccountDelegate { + +} diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift b/Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift new file mode 100644 index 000000000..6e594b80f --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift @@ -0,0 +1,17 @@ +// +// FeedWranglerConfig.swift +// NetNewsWire +// +// Created by Jonathan Bennett on 9/27/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +enum FeedWranglerConfig { + static let clientKey = "{FEEDWRANGLERKEY}" + static let clientPath = "https://feedwrangler.net/api/v2/" + static let clientURL = { + URL(string: FeedWranglerConfig.clientPath)! + }() +} diff --git a/Mac/AppAssets.swift b/Mac/AppAssets.swift index b67f10559..3eec27951 100644 --- a/Mac/AppAssets.swift +++ b/Mac/AppAssets.swift @@ -38,6 +38,10 @@ struct AppAssets { return RSImage(named: "accountFeedly") }() + static var accountFeedWrangler: RSImage! = { + return RSImage(named: "accountFeedWrangler") + }() + static var accountFreshRSS: RSImage! = { return RSImage(named: "accountFreshRSS") }() diff --git a/Mac/Preferences/Accounts/AccountsAddViewController.swift b/Mac/Preferences/Accounts/AccountsAddViewController.swift index 02b87bd38..dababea22 100644 --- a/Mac/Preferences/Accounts/AccountsAddViewController.swift +++ b/Mac/Preferences/Accounts/AccountsAddViewController.swift @@ -15,7 +15,7 @@ class AccountsAddViewController: NSViewController { private var accountsAddWindowController: NSWindowController? - private let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly, .freshRSS] + private let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly, .feedWrangler, .freshRSS] init() { super.init(nibName: "AccountsAdd", bundle: nil) @@ -65,6 +65,9 @@ extension AccountsAddViewController: NSTableViewDelegate { case .feedbin: cell.accountNameLabel?.stringValue = NSLocalizedString("Feedbin", comment: "Feedbin") cell.accountImageView?.image = AppAssets.accountFeedbin + case .feedWrangler: + cell.accountNameLabel?.stringValue = NSLocalizedString("Feed Wrangler", comment: "Feed Wrangler") + cell.accountImageView?.image = AppAssets.accountFeedWrangler case .freshRSS: cell.accountNameLabel?.stringValue = NSLocalizedString("FreshRSS", comment: "FreshRSS") cell.accountImageView?.image = AppAssets.accountFreshRSS @@ -95,6 +98,10 @@ extension AccountsAddViewController: NSTableViewDelegate { let accountsFeedbinWindowController = AccountsFeedbinWindowController() accountsFeedbinWindowController.runSheetOnWindow(self.view.window!) accountsAddWindowController = accountsFeedbinWindowController + case .feedWrangler: + let accountsFeedWranglerWindowController = AccountsFeedWranglerWindowController() + accountsFeedWranglerWindowController.runSheetOnWindow(self.view.window!) + accountsAddWindowController = accountsFeedWranglerWindowController case .freshRSS: let accountsReaderAPIWindowController = AccountsReaderAPIWindowController() accountsReaderAPIWindowController.accountType = .freshRSS diff --git a/Mac/Preferences/Accounts/AccountsFeedWrangler.xib b/Mac/Preferences/Accounts/AccountsFeedWrangler.xib new file mode 100644 index 000000000..684cdb225 --- /dev/null +++ b/Mac/Preferences/Accounts/AccountsFeedWrangler.xib @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NSAllRomanInputSourcesLocaleIdentifier + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mac/Preferences/Accounts/AccountsFeedWranglerWindowController.swift b/Mac/Preferences/Accounts/AccountsFeedWranglerWindowController.swift new file mode 100644 index 000000000..c4b4f6e1e --- /dev/null +++ b/Mac/Preferences/Accounts/AccountsFeedWranglerWindowController.swift @@ -0,0 +1,110 @@ +// +// AccountsFeedWranglerWindowController.swift +// NetNewsWire +// +// Created by Jonathan Bennett on 2019-08-29. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import AppKit +import Account +import RSWeb + +class AccountsFeedWranglerWindowController: NSWindowController { + @IBOutlet weak var progressIndicator: NSProgressIndicator! + @IBOutlet weak var usernameTextField: 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("AccountsFeedWrangler")) + } + + override func windowDidLoad() { + if let account = account, let credentials = try? account.retrieveCredentials(type: .basic) { + usernameTextField.stringValue = credentials.username + 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 else { + self.errorMessageLabel.stringValue = NSLocalizedString("Username & password required.", comment: "Credentials Error") + return + } + + actionButton.isEnabled = false + progressIndicator.isHidden = false + progressIndicator.startAnimation(self) + + let credentials = Credentials(type: .feedWranglerBasic, username: usernameTextField.stringValue, secret: passwordTextField.stringValue) + Account.validateCredentials(type: .feedWrangler, credentials: credentials) { [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: .feedWrangler) + newAccount = true + } + + do { + try self.account?.removeCredentials(type: .feedWranglerBasic) + try self.account?.removeCredentials(type: .feedWranglerToken) + try self.account?.storeCredentials(credentials) + 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/Resources/Assets.xcassets/accountFeedWrangler.imageset/Contents.json b/Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/Contents.json new file mode 100644 index 000000000..b2c62a136 --- /dev/null +++ b/Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "accountFreshRSS.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template" + } +} \ No newline at end of file diff --git a/Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/accountFreshRSS.pdf b/Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/accountFreshRSS.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1ff98e115ed9096d447af7849c78bbd5906f8b00 GIT binary patch literal 4206 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 Date: Sat, 28 Sep 2019 01:38:17 -0400 Subject: [PATCH 02/28] externalize keys --- .../Account/Account.xcodeproj/project.pbxproj | 41 +++++++++++++++++++ .../FeedWrangler/FeedWranglerConfig.swift | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 12b3b737f..4270c5970 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -529,7 +529,9 @@ isa = PBXNativeTarget; buildConfigurationList = 8489350A1F62485000CEBD24 /* Build configuration list for PBXNativeTarget "Account" */; buildPhases = ( + 3B90F52F233F266A00D481CC /* Run Script: Update FeedWranglerConfig.swift */, 848934F11F62484F00CEBD24 /* Sources */, + 3B90F530233F267400D481CC /* Run Script: Reset FeedWranglerConfig.swift */, 848934F21F62484F00CEBD24 /* Frameworks */, 848934F31F62484F00CEBD24 /* Headers */, 848934F41F62484F00CEBD24 /* Resources */, @@ -674,6 +676,45 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 3B90F52F233F266A00D481CC /* Run Script: Update FeedWranglerConfig.swift */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run Script: Update FeedWranglerConfig.swift"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "FAILED=false\n\nif [ -z \"${FEED_WRANGLER_KEY}\" ]; then\nFAILED=true\nfi\n\nif [ \"$FAILED\" = true ]; then\necho \"Missing Feed Wrangler Key. FeedWranglerConfig.swift not changed.\"\nexit 0\nfi\n\nsed -i .tmp \"s|{FEEDWRANGLERKEY}|${FEED_WRANGLER_KEY}|g; s|{FEEDWRANGLERKEY}|${FEED_WRANGLER_KEY}|g\" \"${SRCROOT}/FeedWrangler/FeedWranglerConfig.swift\"\n\nrm -f \"${SRCROOT}/FeedWrangler/FeedWranglerConfig.swift.tmp\"\n\necho \"All Feed Wrangler env values found!\"\n\n"; + }; + 3B90F530233F267400D481CC /* Run Script: Reset FeedWranglerConfig.swift */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run Script: Reset FeedWranglerConfig.swift"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "git checkout \"${SRCROOT}/FeedWrangler/FeedWranglerConfig.swift\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 848934F11F62484F00CEBD24 /* Sources */ = { isa = PBXSourcesBuildPhase; diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift b/Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift index 6e594b80f..ba301f08e 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift @@ -9,7 +9,7 @@ import Foundation enum FeedWranglerConfig { - static let clientKey = "{FEEDWRANGLERKEY}" + static let clientKey = "{FEEDWRANGLERKEY}" // Add FEED_WRANGLER_KEY = XYZ to SharedXcodeSettings/DeveloperSettings.xcconfig static let clientPath = "https://feedwrangler.net/api/v2/" static let clientURL = { URL(string: FeedWranglerConfig.clientPath)! From 0c2185ae258e07f6dac0c18036154996f3795b04 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Mon, 7 Oct 2019 10:22:10 -0400 Subject: [PATCH 03/28] fix db file extension --- .../Account/FeedWrangler/FeedWranglerAccountDelegate.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index 5829fcc3f..0c125feaa 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -48,7 +48,7 @@ final class FeedWranglerAccountDelegate: AccountDelegate { caller = FeedWranglerAPICaller(transport: session) } - database = SyncDatabase(databaseFilePath: dataFolder.appending("/Sync.sqlite")) + database = SyncDatabase(databaseFilePath: dataFolder.appending("/Sync.sqlite3")) } func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { From d80aeefdb3046bacbec0082f3d862f8364cd5839 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 16 Oct 2019 08:30:11 -0400 Subject: [PATCH 04/28] Allow refreshing of Feed Wrangler subscriptions --- .../Account/Account.xcodeproj/project.pbxproj | 8 ++ .../Credentials/URLRequest+RSWeb.swift | 8 +- .../FeedWrangler/FeedWranglerAPICaller.swift | 15 ++++ .../FeedWranglerAccountDelegate.swift | 77 ++++++++++++++++--- .../FeedWranglerSubscription.swift | 18 +++++ .../FeedWranglerSubscriptionsRequest.swift | 17 ++++ 6 files changed, 133 insertions(+), 10 deletions(-) create mode 100644 Frameworks/Account/FeedWrangler/FeedWranglerSubscription.swift create mode 100644 Frameworks/Account/FeedWrangler/FeedWranglerSubscriptionsRequest.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 5608e6ed1..e0c3cc872 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 3BF610C723571CD4000EF978 /* FeedWranglerAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF610C423571CD4000EF978 /* FeedWranglerAPICaller.swift */; }; 3BF610C823571CD4000EF978 /* FeedWranglerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF610C523571CD4000EF978 /* FeedWranglerConfig.swift */; }; 3BF610C923571CD4000EF978 /* FeedWranglerAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF610C623571CD4000EF978 /* FeedWranglerAccountDelegate.swift */; }; + 3BF6112423572A62000EF978 /* FeedWranglerSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF6112323572A62000EF978 /* FeedWranglerSubscription.swift */; }; + 3BF6112623572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF6112523572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift */; }; 5107A099227DE42E00C7C3C5 /* AccountCredentialsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */; }; 5107A09B227DE49500C7C3C5 /* TestAccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */; }; 5107A09D227DE77700C7C3C5 /* TestTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09C227DE77700C7C3C5 /* TestTransport.swift */; }; @@ -191,6 +193,8 @@ 3BF610C423571CD4000EF978 /* FeedWranglerAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAPICaller.swift; sourceTree = ""; }; 3BF610C523571CD4000EF978 /* FeedWranglerConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerConfig.swift; sourceTree = ""; }; 3BF610C623571CD4000EF978 /* FeedWranglerAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAccountDelegate.swift; sourceTree = ""; }; + 3BF6112323572A62000EF978 /* FeedWranglerSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscription.swift; sourceTree = ""; }; + 3BF6112523572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscriptionsRequest.swift; sourceTree = ""; }; 5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCredentialsTest.swift; sourceTree = ""; }; 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAccountManager.swift; sourceTree = ""; }; 5107A09C227DE77700C7C3C5 /* TestTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTransport.swift; sourceTree = ""; }; @@ -349,6 +353,8 @@ 3BF610C423571CD4000EF978 /* FeedWranglerAPICaller.swift */, 3BF610C523571CD4000EF978 /* FeedWranglerConfig.swift */, 3BF610C623571CD4000EF978 /* FeedWranglerAccountDelegate.swift */, + 3BF6112323572A62000EF978 /* FeedWranglerSubscription.swift */, + 3BF6112523572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift */, ); path = FeedWrangler; sourceTree = ""; @@ -892,6 +898,7 @@ 9ECC9A85234DC16E009B5144 /* FeedlyAccountDelegateError.swift in Sources */, 9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */, 51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */, + 3BF6112423572A62000EF978 /* FeedWranglerSubscription.swift in Sources */, 846E77451F6EF9B900A165E2 /* Container.swift in Sources */, 9E1D15532334304B00F4944C /* FeedlyGetStreamOperation.swift in Sources */, 9E12B0202334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift in Sources */, @@ -950,6 +957,7 @@ 84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */, 84CAD7161FDF2E22000F0755 /* FeedbinEntry.swift in Sources */, 5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */, + 3BF6112623572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift in Sources */, 841974011F6DD1EC006346C4 /* Folder.swift in Sources */, 510BD111232C3801002692E4 /* AccountMetadataFile.swift in Sources */, 846E774F1F6EF9C000A165E2 /* LocalAccountDelegate.swift in Sources */, diff --git a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift index da7b04a49..90d215a72 100755 --- a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift +++ b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift @@ -37,7 +37,13 @@ public extension URLRequest { ] self.url = components.url case .feedWranglerToken: - fatalError() // TODO: implement + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return + } + components.queryItems = [ + URLQueryItem(name: "access_token", value: credentials.secret), + ] + self.url = components.url case .readerBasic: setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") httpMethod = "POST" diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift index 110ec5d8b..1aeabe525 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift @@ -56,4 +56,19 @@ final class FeedWranglerAPICaller: NSObject { } } + func retrieveSubscriptions(completion: @escaping (Result<[FeedWranglerSubscription], Error>) -> Void) { + let url = FeedWranglerConfig.clientURL.appendingPathComponent("subscriptions/list") + let request = URLRequest(url: url, credentials: credentials) + + transport.send(request: request, resultType: FeedWranglerSubscriptionsRequest.self) { result in + switch result { + case .success(let (_, results)): + completion(.success(results?.feeds ?? [])) + + case .failure(let error): + completion(.failure(error)) + } + } + } + } diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index 0c125feaa..8eba043ef 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -19,7 +19,12 @@ final class FeedWranglerAccountDelegate: AccountDelegate { var isOPMLImportInProgress = false var server: String? = FeedWranglerConfig.clientPath - var credentials: Credentials? + var credentials: Credentials? { + didSet { + caller.credentials = credentials + } + } + var accountMetadata: AccountMetadata? var refreshProgress = DownloadProgress(numberOfTasks: 0) @@ -54,13 +59,17 @@ final class FeedWranglerAccountDelegate: AccountDelegate { func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { refreshProgress.addToNumberOfTasksAndRemaining(6) - self.sendArticleStatus(for: account) { - self.refreshArticleStatus(for: account) { - self.refreshArticles(for: account) { - self.refreshMissingArticles(for: account) { - self.refreshProgress.clear() - DispatchQueue.main.async { - completion(.success(())) + self.refreshCredentials(for: account) { + self.refreshSubscriptions(for: account) { _ in + self.sendArticleStatus(for: account) { + self.refreshArticleStatus(for: account) { + self.refreshArticles(for: account) { + self.refreshMissingArticles(for: account) { + self.refreshProgress.clear() + DispatchQueue.main.async { + completion(.success(())) + } + } } } } @@ -68,8 +77,31 @@ final class FeedWranglerAccountDelegate: AccountDelegate { } } + func refreshCredentials(for account: Account, completion: @escaping (() -> Void)) { + os_log(.debug, log: log, "Refreshing credentials...") + // MARK: TODO + credentials = try? account.retrieveCredentials(type: .feedWranglerToken) + completion() + } + + func refreshSubscriptions(for account: Account, completion: @escaping ((Result) -> Void)) { + os_log(.debug, log: log, "Refreshing subscriptions...") + caller.retrieveSubscriptions { result in + switch result { + case .success(let subscriptions): + self.syncFeeds(account, subscriptions) + completion(.success(())) + + case .failure(let error): + completion(.failure(error)) + } + + } + } + func refreshArticles(for account: Account, completion: @escaping (() -> Void)) { - os_log(.debug, log: log, "Refreshing articles...") + os_log(.debug, log: log, "Refreshing articles...") + completion() } func refreshMissingArticles(for account: Account, completion: @escaping (() -> Void)) { @@ -152,5 +184,32 @@ final class FeedWranglerAccountDelegate: AccountDelegate { // MARK: Private private extension FeedWranglerAccountDelegate { + + func syncFeeds(_ account: Account, _ subscriptions: [FeedWranglerSubscription]) { + assert(Thread.isMainThread) + let feedIds = subscriptions.map { String($0.feed_id) } + + let feedsToRemove = account.topLevelFeeds.filter { !feedIds.contains($0.feedID) } + account.removeFeeds(feedsToRemove) + var subscriptionsToAdd = Set() + subscriptions.forEach { subscription in + let subscriptionId = String(subscription.feed_id) + + if let feed = account.existingFeed(withFeedID: subscriptionId) { + feed.name = subscription.title + feed.homePageURL = subscription.site_url + feed.subscriptionID = nil // MARK: TODO What should this be? + } else { + subscriptionsToAdd.insert(subscription) + } + } + + subscriptionsToAdd.forEach { subscription in + let feedId = String(subscription.feed_id) + let feed = account.createFeed(with: subscription.title, url: subscription.feed_url, feedID: feedId, homePageURL: subscription.site_url) + feed.subscriptionID = nil + account.addFeed(feed) + } + } } diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerSubscription.swift b/Frameworks/Account/FeedWrangler/FeedWranglerSubscription.swift new file mode 100644 index 000000000..3a69961dd --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerSubscription.swift @@ -0,0 +1,18 @@ +// +// FeedWranglerSubscription.swift +// Account +// +// Created by Jonathan Bennett on 2019-10-16. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// +import Foundation +import RSCore +import RSParser + +struct FeedWranglerSubscription: Hashable, Codable { + let title: String + let feed_id: Int + let feed_url: String + let site_url: String? + +} diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerSubscriptionsRequest.swift b/Frameworks/Account/FeedWrangler/FeedWranglerSubscriptionsRequest.swift new file mode 100644 index 000000000..66fb6a20b --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerSubscriptionsRequest.swift @@ -0,0 +1,17 @@ +// +// FeedWranglerSubscriptionsRequest.swift +// Account +// +// Created by Jonathan Bennett on 2019-10-16. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct FeedWranglerSubscriptionsRequest: Hashable, Codable { + + let feeds: [FeedWranglerSubscription] + let error: String? + let result: String + +} From 51dc82ffef619060e32a4318e4dd3d8cfe22f9d1 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 16 Oct 2019 09:04:24 -0400 Subject: [PATCH 05/28] use swiftier property names --- .../FeedWrangler/FeedWranglerAccountDelegate.swift | 11 ++++++----- .../FeedWrangler/FeedWranglerSubscription.swift | 14 +++++++++++--- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index 8eba043ef..1f5a056da 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -93,6 +93,7 @@ final class FeedWranglerAccountDelegate: AccountDelegate { completion(.success(())) case .failure(let error): + os_log(.debug, log: self.log, "Failed to refresh subscriptions: %@", error.localizedDescription) completion(.failure(error)) } @@ -187,18 +188,18 @@ private extension FeedWranglerAccountDelegate { func syncFeeds(_ account: Account, _ subscriptions: [FeedWranglerSubscription]) { assert(Thread.isMainThread) - let feedIds = subscriptions.map { String($0.feed_id) } + let feedIds = subscriptions.map { String($0.feedID) } let feedsToRemove = account.topLevelFeeds.filter { !feedIds.contains($0.feedID) } account.removeFeeds(feedsToRemove) var subscriptionsToAdd = Set() subscriptions.forEach { subscription in - let subscriptionId = String(subscription.feed_id) + let subscriptionId = String(subscription.feedID) if let feed = account.existingFeed(withFeedID: subscriptionId) { feed.name = subscription.title - feed.homePageURL = subscription.site_url + feed.homePageURL = subscription.siteURL feed.subscriptionID = nil // MARK: TODO What should this be? } else { subscriptionsToAdd.insert(subscription) @@ -206,8 +207,8 @@ private extension FeedWranglerAccountDelegate { } subscriptionsToAdd.forEach { subscription in - let feedId = String(subscription.feed_id) - let feed = account.createFeed(with: subscription.title, url: subscription.feed_url, feedID: feedId, homePageURL: subscription.site_url) + let feedId = String(subscription.feedID) + let feed = account.createFeed(with: subscription.title, url: subscription.feedURL, feedID: feedId, homePageURL: subscription.siteURL) feed.subscriptionID = nil account.addFeed(feed) } diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerSubscription.swift b/Frameworks/Account/FeedWrangler/FeedWranglerSubscription.swift index 3a69961dd..821dd41ad 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerSubscription.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerSubscription.swift @@ -10,9 +10,17 @@ import RSCore import RSParser struct FeedWranglerSubscription: Hashable, Codable { + let title: String - let feed_id: Int - let feed_url: String - let site_url: String? + let feedID: Int + let feedURL: String + let siteURL: String? + + enum CodingKeys: String, CodingKey { + case title = "title" + case feedID = "feed_id" + case feedURL = "feed_url" + case siteURL = "site_url" + } } From f010f2693d8a078685287b282f015f9cabd43df6 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 16 Oct 2019 09:24:55 -0400 Subject: [PATCH 06/28] disable Feed Wrangler account creation --- Mac/Preferences/Accounts/AccountsAddViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mac/Preferences/Accounts/AccountsAddViewController.swift b/Mac/Preferences/Accounts/AccountsAddViewController.swift index dababea22..39abf3f76 100644 --- a/Mac/Preferences/Accounts/AccountsAddViewController.swift +++ b/Mac/Preferences/Accounts/AccountsAddViewController.swift @@ -15,7 +15,7 @@ class AccountsAddViewController: NSViewController { private var accountsAddWindowController: NSWindowController? - private let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly, .feedWrangler, .freshRSS] + private let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly, .freshRSS] init() { super.init(nibName: "AccountsAdd", bundle: nil) From d1b4c20494893c669a108267bd2bcd0771b3268a Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 16 Oct 2019 11:12:55 -0400 Subject: [PATCH 07/28] append query items, don't overwrite everything --- Frameworks/Account/Credentials/URLRequest+RSWeb.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift index 90d215a72..d056a8666 100755 --- a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift +++ b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift @@ -40,9 +40,9 @@ public extension URLRequest { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return } - components.queryItems = [ - URLQueryItem(name: "access_token", value: credentials.secret), - ] + var queryItems = components.queryItems ?? [] + queryItems.append(URLQueryItem(name: "access_token", value: credentials.secret)) + components.queryItems = queryItems self.url = components.url case .readerBasic: setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") From 36861f2eb36904413092be4da8461fe3a384b98e Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 16 Oct 2019 11:32:22 -0400 Subject: [PATCH 08/28] allow renaming of feeds --- .../FeedWrangler/FeedWranglerAPICaller.swift | 27 +++++++++++++++++++ .../FeedWranglerAccountDelegate.swift | 24 ++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift index 1aeabe525..577a8a4c0 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift @@ -71,4 +71,31 @@ final class FeedWranglerAPICaller: NSObject { } } + func renameSubscription(feedID: String, newName: String, completion: @escaping (Result) -> Void) { + guard var components = URLComponents(url: FeedWranglerConfig.clientURL.appendingPathComponent("subscriptions/rename_feed"), resolvingAgainstBaseURL: false) else { + completion(.failure(TransportError.noURL)) + return + } + components.queryItems = [ + URLQueryItem(name: "feed_id", value: feedID), + URLQueryItem(name: "feed_name", value: newName), + ] + + guard let url = components.url else { + completion(.failure(TransportError.noURL)) + return + } + + let request = URLRequest(url: url, credentials: credentials) + + transport.send(request: request, resultType: FeedWranglerSubscriptionsRequest.self) { result in + switch result { + case .success: + completion(.success(())) + + case .failure(let error): + completion(.failure(error)) + } + } + } } diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index 1f5a056da..0ded03802 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -141,7 +141,28 @@ final class FeedWranglerAccountDelegate: AccountDelegate { } func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result) -> Void) { - fatalError() + refreshProgress.addToNumberOfTasksAndRemaining(2) + + self.refreshCredentials(for: account) { + self.refreshProgress.completeTask() + self.caller.renameSubscription(feedID: feed.feedID, newName: name) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + DispatchQueue.main.async { + feed.editedName = name + completion(.success(())) + } + + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + } } func addFeed(for account: Account, with: Feed, to container: Container, completion: @escaping (Result) -> Void) { @@ -199,6 +220,7 @@ private extension FeedWranglerAccountDelegate { if let feed = account.existingFeed(withFeedID: subscriptionId) { feed.name = subscription.title + feed.editedName = nil feed.homePageURL = subscription.siteURL feed.subscriptionID = nil // MARK: TODO What should this be? } else { From 81bffda093f88b5fa629cc9d8c2560a16139c59f Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 16 Oct 2019 11:43:49 -0400 Subject: [PATCH 09/28] allow removal of feeds --- .../Account/Account.xcodeproj/project.pbxproj | 4 +++ .../FeedWrangler/FeedWranglerAPICaller.swift | 27 +++++++++++++++++++ .../FeedWranglerAccountDelegate.swift | 24 ++++++++++++++++- .../FeedWranglerGenericResult.swift | 16 +++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 Frameworks/Account/FeedWrangler/FeedWranglerGenericResult.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index e0c3cc872..cb9b17717 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 3BF610C923571CD4000EF978 /* FeedWranglerAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF610C623571CD4000EF978 /* FeedWranglerAccountDelegate.swift */; }; 3BF6112423572A62000EF978 /* FeedWranglerSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF6112323572A62000EF978 /* FeedWranglerSubscription.swift */; }; 3BF6112623572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF6112523572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift */; }; + 3BF6119023577173000EF978 /* FeedWranglerGenericResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF6118F23577173000EF978 /* FeedWranglerGenericResult.swift */; }; 5107A099227DE42E00C7C3C5 /* AccountCredentialsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */; }; 5107A09B227DE49500C7C3C5 /* TestAccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */; }; 5107A09D227DE77700C7C3C5 /* TestTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09C227DE77700C7C3C5 /* TestTransport.swift */; }; @@ -195,6 +196,7 @@ 3BF610C623571CD4000EF978 /* FeedWranglerAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAccountDelegate.swift; sourceTree = ""; }; 3BF6112323572A62000EF978 /* FeedWranglerSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscription.swift; sourceTree = ""; }; 3BF6112523572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscriptionsRequest.swift; sourceTree = ""; }; + 3BF6118F23577173000EF978 /* FeedWranglerGenericResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerGenericResult.swift; sourceTree = ""; }; 5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCredentialsTest.swift; sourceTree = ""; }; 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAccountManager.swift; sourceTree = ""; }; 5107A09C227DE77700C7C3C5 /* TestTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTransport.swift; sourceTree = ""; }; @@ -355,6 +357,7 @@ 3BF610C623571CD4000EF978 /* FeedWranglerAccountDelegate.swift */, 3BF6112323572A62000EF978 /* FeedWranglerSubscription.swift */, 3BF6112523572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift */, + 3BF6118F23577173000EF978 /* FeedWranglerGenericResult.swift */, ); path = FeedWrangler; sourceTree = ""; @@ -917,6 +920,7 @@ 5165D72922835F7A00D9D53D /* FeedSpecifier.swift in Sources */, 9E1D154D233370D800F4944C /* FeedlySyncStrategy.swift in Sources */, 844B297D2106C7EC004020B3 /* Feed.swift in Sources */, + 3BF6119023577173000EF978 /* FeedWranglerGenericResult.swift in Sources */, 9E1D15572334355900F4944C /* FeedlyRequestStreamsOperation.swift in Sources */, 9E1D15512334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift in Sources */, 9E1773D7234575AB0056A5A8 /* FeedlyTag.swift in Sources */, diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift index 577a8a4c0..61464ea8e 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift @@ -98,4 +98,31 @@ final class FeedWranglerAPICaller: NSObject { } } } + + func removeSubscription(feedID: String, completion: @escaping (Result) -> Void) { + guard var components = URLComponents(url: FeedWranglerConfig.clientURL.appendingPathComponent("subscriptions/remove_feed"), resolvingAgainstBaseURL: false) else { + completion(.failure(TransportError.noURL)) + return + } + components.queryItems = [ + URLQueryItem(name: "feed_id", value: feedID) + ] + + guard let url = components.url else { + completion(.failure(TransportError.noURL)) + return + } + + let request = URLRequest(url: url, credentials: credentials) + + transport.send(request: request, resultType: FeedWranglerGenericResult.self) { result in + switch result { + case .success: + completion(.success(())) + + case .failure(let error): + completion(.failure(error)) + } + } + } } diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index 0ded03802..10da65746 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -170,7 +170,29 @@ final class FeedWranglerAccountDelegate: AccountDelegate { } func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result) -> Void) { - fatalError() + refreshProgress.addToNumberOfTasksAndRemaining(2) + + self.refreshCredentials(for: account) { + self.refreshProgress.completeTask() + self.caller.removeSubscription(feedID: feed.feedID) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + DispatchQueue.main.async { + account.clearFeedMetadata(feed) + account.removeFeed(feed) + completion(.success(())) + } + + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + } } func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result) -> Void) { diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerGenericResult.swift b/Frameworks/Account/FeedWrangler/FeedWranglerGenericResult.swift new file mode 100644 index 000000000..817fe9c8b --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerGenericResult.swift @@ -0,0 +1,16 @@ +// +// FeedWranglerGenericResult.swift +// Account +// +// Created by Jonathan Bennett on 2019-10-16. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct FeedWranglerGenericResult: Hashable, Codable { + + let error: String? + let result: String + +} From c7d0d2314645368ea8857e2bcfec7571db1ba7ff Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 16 Oct 2019 15:06:01 -0400 Subject: [PATCH 10/28] add page size configuration option --- Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift b/Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift index ba301f08e..16205707f 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerConfig.swift @@ -9,6 +9,7 @@ import Foundation enum FeedWranglerConfig { + static let pageSize = 100 static let clientKey = "{FEEDWRANGLERKEY}" // Add FEED_WRANGLER_KEY = XYZ to SharedXcodeSettings/DeveloperSettings.xcconfig static let clientPath = "https://feedwrangler.net/api/v2/" static let clientURL = { From 09faf1a0c29cc1e56d59838771b3428f3624de6b Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Thu, 17 Oct 2019 01:05:18 -0400 Subject: [PATCH 11/28] start supporting article sync --- .../Account/Account.xcodeproj/project.pbxproj | 8 + .../FeedWrangler/FeedWranglerAPICaller.swift | 158 ++++++++++++++++++ .../FeedWranglerAccountDelegate.swift | 148 +++++++++++++++- .../FeedWrangler/FeedWranglerFeedItem.swift | 62 +++++++ .../FeedWranglerFeedItemsRequest.swift | 25 +++ 5 files changed, 394 insertions(+), 7 deletions(-) create mode 100644 Frameworks/Account/FeedWrangler/FeedWranglerFeedItem.swift create mode 100644 Frameworks/Account/FeedWrangler/FeedWranglerFeedItemsRequest.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index cb9b17717..53d524c24 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -13,6 +13,8 @@ 3BF6112423572A62000EF978 /* FeedWranglerSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF6112323572A62000EF978 /* FeedWranglerSubscription.swift */; }; 3BF6112623572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF6112523572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift */; }; 3BF6119023577173000EF978 /* FeedWranglerGenericResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF6118F23577173000EF978 /* FeedWranglerGenericResult.swift */; }; + 3BF611922357877E000EF978 /* FeedWranglerFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF611912357877E000EF978 /* FeedWranglerFeedItem.swift */; }; + 3BF6119423578F55000EF978 /* FeedWranglerFeedItemsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF6119323578F55000EF978 /* FeedWranglerFeedItemsRequest.swift */; }; 5107A099227DE42E00C7C3C5 /* AccountCredentialsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */; }; 5107A09B227DE49500C7C3C5 /* TestAccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */; }; 5107A09D227DE77700C7C3C5 /* TestTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09C227DE77700C7C3C5 /* TestTransport.swift */; }; @@ -197,6 +199,8 @@ 3BF6112323572A62000EF978 /* FeedWranglerSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscription.swift; sourceTree = ""; }; 3BF6112523572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscriptionsRequest.swift; sourceTree = ""; }; 3BF6118F23577173000EF978 /* FeedWranglerGenericResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerGenericResult.swift; sourceTree = ""; }; + 3BF611912357877E000EF978 /* FeedWranglerFeedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItem.swift; sourceTree = ""; }; + 3BF6119323578F55000EF978 /* FeedWranglerFeedItemsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItemsRequest.swift; sourceTree = ""; }; 5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCredentialsTest.swift; sourceTree = ""; }; 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAccountManager.swift; sourceTree = ""; }; 5107A09C227DE77700C7C3C5 /* TestTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTransport.swift; sourceTree = ""; }; @@ -358,6 +362,8 @@ 3BF6112323572A62000EF978 /* FeedWranglerSubscription.swift */, 3BF6112523572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift */, 3BF6118F23577173000EF978 /* FeedWranglerGenericResult.swift */, + 3BF611912357877E000EF978 /* FeedWranglerFeedItem.swift */, + 3BF6119323578F55000EF978 /* FeedWranglerFeedItemsRequest.swift */, ); path = FeedWrangler; sourceTree = ""; @@ -939,6 +945,7 @@ 9E1D154F233371DD00F4944C /* FeedlyGetCollectionsOperation.swift in Sources */, 9EAEC626233318400085D7C9 /* FeedlyStream.swift in Sources */, 9EAEC60C2332FE830085D7C9 /* FeedlyCollection.swift in Sources */, + 3BF6119423578F55000EF978 /* FeedWranglerFeedItemsRequest.swift in Sources */, 51E3EB41229AF61B00645299 /* AccountError.swift in Sources */, 9E1D155D233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.swift in Sources */, 51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */, @@ -964,6 +971,7 @@ 3BF6112623572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift in Sources */, 841974011F6DD1EC006346C4 /* Folder.swift in Sources */, 510BD111232C3801002692E4 /* AccountMetadataFile.swift in Sources */, + 3BF611922357877E000EF978 /* FeedWranglerFeedItem.swift in Sources */, 846E774F1F6EF9C000A165E2 /* LocalAccountDelegate.swift in Sources */, 515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */, 844B297F210CE37E004020B3 /* UnreadCountProvider.swift in Sources */, diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift index 61464ea8e..bbb30fc6c 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift @@ -9,6 +9,7 @@ import Foundation import Foundation +import SyncDatabase import RSWeb final class FeedWranglerAPICaller: NSObject { @@ -125,4 +126,161 @@ final class FeedWranglerAPICaller: NSObject { } } } + + // MARK: FeedItems + func retrieveFeedItems(page: Int = 0, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { + guard var components = URLComponents(url: FeedWranglerConfig.clientURL.appendingPathComponent("feed_items/list"), resolvingAgainstBaseURL: false) else { + completion(.failure(TransportError.noURL)) + return + } + // todo: handle initial sync better + components.queryItems = [ + URLQueryItem(name: "read", value: "false"), + URLQueryItem(name: "offset", value: String(page * FeedWranglerConfig.pageSize)), +// URLQueryItem(name: "created_since", value: feedID), +// URLQueryItem(name: "updated_since", value: feedID), + ] + + guard let url = components.url else { + completion(.failure(TransportError.noURL)) + return + } + + let request = URLRequest(url: url, credentials: credentials) + + transport.send(request: request, resultType: FeedWranglerFeedItemsRequest.self) { result in + switch result { + case .success(let (_, results)): + completion(.success(results?.feedItems ?? [])) + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func retrieveUnreadFeedItems(page: Int = 0, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { + guard var components = URLComponents(url: FeedWranglerConfig.clientURL.appendingPathComponent("feed_items/list"), resolvingAgainstBaseURL: false) else { + completion(.failure(TransportError.noURL)) + return + } + components.queryItems = [ + URLQueryItem(name: "read", value: "false"), + URLQueryItem(name: "offset", value: String(page * FeedWranglerConfig.pageSize)), +// URLQueryItem(name: "created_since", value: feedID), +// URLQueryItem(name: "updated_since", value: feedID), + ] + + guard let url = components.url else { + completion(.failure(TransportError.noURL)) + return + } + + let request = URLRequest(url: url, credentials: credentials) + + transport.send(request: request, resultType: FeedWranglerFeedItemsRequest.self) { result in + switch result { + case .success(let (_, results)): + completion(.success(results?.feedItems ?? [])) + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func retrieveAllUnreadFeedItems(foundItems: [FeedWranglerFeedItem] = [], page: Int = 0, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { + retrieveUnreadFeedItems(page: page) { result in + switch result { + case .success(let newItems): + if newItems.count > 0 { + self.retrieveAllUnreadFeedItems(foundItems: foundItems + newItems, page: (page + 1), completion: completion) + } else { + completion(.success(foundItems + newItems)) + } + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func retrieveStarredFeedItems(page: Int = 0, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { + guard var components = URLComponents(url: FeedWranglerConfig.clientURL.appendingPathComponent("feed_items/list"), resolvingAgainstBaseURL: false) else { + completion(.failure(TransportError.noURL)) + return + } + components.queryItems = [ + URLQueryItem(name: "starred", value: "true"), + URLQueryItem(name: "offset", value: String(page * FeedWranglerConfig.pageSize)), + // URLQueryItem(name: "created_since", value: feedID), + // URLQueryItem(name: "updated_since", value: feedID), + ] + + guard let url = components.url else { + completion(.failure(TransportError.noURL)) + return + } + + let request = URLRequest(url: url, credentials: credentials) + + transport.send(request: request, resultType: FeedWranglerFeedItemsRequest.self) { result in + switch result { + case .success(let (_, results)): + completion(.success(results?.feedItems ?? [])) + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func retrieveAllStarredFeedItems(foundItems: [FeedWranglerFeedItem] = [], page: Int = 0, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { + retrieveStarredFeedItems(page: page) { result in + switch result { + case .success(let newItems): + if newItems.count > 0 { + self.retrieveAllStarredFeedItems(foundItems: foundItems + newItems, page: (page + 1), completion: completion) + } else { + completion(.success(foundItems + newItems)) + } + + case .failure(let error): + completion(.failure(error)) + } + } + } + + func updateArticleStatus(_ articleID: String, _ statuses: [SyncStatus], completion: @escaping () -> Void) { + guard var components = URLComponents(url: FeedWranglerConfig.clientURL.appendingPathComponent("feed_items/update"), resolvingAgainstBaseURL: false) else { + completion() + return + } + var queryItems = statuses.compactMap { status -> URLQueryItem? in + switch status.key { + case .read: + return URLQueryItem(name: "read", value: status.flag.description) + + case .starred: + return URLQueryItem(name: "starred", value: status.flag.description) + + case .userDeleted: + return nil + } + } + queryItems.append(URLQueryItem(name: "feed_item_id", value: articleID)) + components.queryItems = (components.queryItems ?? []) + queryItems + + guard let url = components.url else { + completion() + return + } + + let request = URLRequest(url: url, credentials: credentials) + + transport.send(request: request, resultType: FeedWranglerGenericResult.self) { result in + completion() + } + } + } diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index 10da65746..fc0ca55f0 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -53,7 +53,7 @@ final class FeedWranglerAccountDelegate: AccountDelegate { caller = FeedWranglerAPICaller(transport: session) } - database = SyncDatabase(databaseFilePath: dataFolder.appending("/Sync.sqlite3")) + database = SyncDatabase(databaseFilePath: dataFolder.appending("/DB.sqlite3")) } func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { @@ -100,9 +100,25 @@ final class FeedWranglerAccountDelegate: AccountDelegate { } } - func refreshArticles(for account: Account, completion: @escaping (() -> Void)) { - os_log(.debug, log: log, "Refreshing articles...") - completion() + func refreshArticles(for account: Account, page: Int = 0, completion: @escaping (() -> Void)) { + os_log(.debug, log: log, "Refreshing articles, page: %d...", page) + + caller.retrieveFeedItems(page: page) { result in + switch result { + case .success(let items): + self.syncFeedItems(account, items) { + if items.count == 0 { + completion() + } else { + self.refreshArticles(for: account, page: (page + 1), completion: completion) + } + } + + case .failure: + // TODO Handle error + completion() + } + } } func refreshMissingArticles(for account: Account, completion: @escaping (() -> Void)) { @@ -112,12 +128,60 @@ final class FeedWranglerAccountDelegate: AccountDelegate { func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) { os_log(.debug, log: log, "Sending article status...") - completion() + + let syncStatuses = database.selectForProcessing() + let articleStatuses = Dictionary(grouping: syncStatuses, by: { $0.articleID }) + let group = DispatchGroup() + + articleStatuses.forEach { articleID, statuses in + group.enter() + caller.updateArticleStatus(articleID, statuses) { + 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 status...") - completion() + let group = DispatchGroup() + + group.enter() + caller.retrieveAllUnreadFeedItems { result in + switch result { + case .success(let items): + self.syncArticleReadState(account, items) + group.leave() + + case .failure(let error): + os_log(.info, log: self.log, "Retrieving unread entries failed: %@.", error.localizedDescription) + group.leave() + } + } + + // starred + group.enter() + caller.retrieveAllStarredFeedItems { result in + switch result { + case .success(let items): + self.syncArticleStarredState(account, items) + 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) { @@ -208,7 +272,14 @@ final class FeedWranglerAccountDelegate: AccountDelegate { } func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { - fatalError() + let syncStatuses = articles.map { SyncStatus(articleID: $0.articleID, key: statusKey, flag: flag)} + database.insertStatuses(syncStatuses) + + if database.selectPendingCount() > 0 { + sendArticleStatus(for: account) {} // do it in the background + } + + return account.update(articles, statusKey: statusKey, flag: flag) } func accountDidInitialize(_ account: Account) { @@ -257,4 +328,67 @@ private extension FeedWranglerAccountDelegate { account.addFeed(feed) } } + + func syncFeedItems(_ account: Account, _ feedItems: [FeedWranglerFeedItem], completion: @escaping (() -> Void)) { + let parsedItems = feedItems.map { (item: FeedWranglerFeedItem) -> ParsedItem in + let itemID = String(item.feedItemID) + // let authors = ... + let parsedItem = ParsedItem(syncServiceID: itemID, uniqueID: itemID, feedURL: String(item.feedID), url: nil, externalURL: item.url, title: item.title, contentHTML: item.body, contentText: nil, summary: nil, imageURL: nil, bannerImageURL: nil, datePublished: item.publishedDate, dateModified: item.updatedDate, authors: nil, tags: nil, attachments: nil) + + return parsedItem + } + + let feedIDsAndItems = Dictionary(grouping: parsedItems, by: { $0.feedURL }).mapValues { Set($0) } + account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true, completion: completion) + } + + func syncArticleReadState(_ account: Account, _ unreadFeedItems: [FeedWranglerFeedItem]) { + let unreadServerItemIDs = Set(unreadFeedItems.map { String($0.feedItemID) }) + let unreadLocalItemIDs = account.fetchUnreadArticleIDs() + + // unread if unread on server + let unreadDiffItemIDs = unreadServerItemIDs.subtracting(unreadLocalItemIDs) + let unreadFoundArticles = account.fetchArticles(.articleIDs(unreadDiffItemIDs)) + account.update(unreadFoundArticles, statusKey: .read, flag: false) + + let unreadFoundItemIDs = Set(unreadFoundArticles.map { $0.articleID }) + let missingArticleIDs = unreadDiffItemIDs.subtracting(unreadFoundItemIDs) + account.ensureStatuses(missingArticleIDs, true, .read, false) + + let readItemIDs = unreadLocalItemIDs.subtracting(unreadServerItemIDs) + let readArtices = account.fetchArticles(.articleIDs(readItemIDs)) + account.update(readArtices, statusKey: .read, flag: true) + + let foundReadArticleIDs = Set(readArtices.map { $0.articleID }) + let readMissingIDs = readItemIDs.subtracting(foundReadArticleIDs) + account.ensureStatuses(readMissingIDs, true, .read, true) + } + + func syncArticleStarredState(_ account: Account, _ unreadFeedItems: [FeedWranglerFeedItem]) { + let unreadServerItemIDs = Set(unreadFeedItems.map { String($0.feedItemID) }) + let unreadLocalItemIDs = account.fetchUnreadArticleIDs() + + // starred if start on server + let unreadDiffItemIDs = unreadServerItemIDs.subtracting(unreadLocalItemIDs) + let unreadFoundArticles = account.fetchArticles(.articleIDs(unreadDiffItemIDs)) + account.update(unreadFoundArticles, statusKey: .starred, flag: true) + + let unreadFoundItemIDs = Set(unreadFoundArticles.map { $0.articleID }) + let missingArticleIDs = unreadDiffItemIDs.subtracting(unreadFoundItemIDs) + account.ensureStatuses(missingArticleIDs, true, .starred, true) + + let readItemIDs = unreadLocalItemIDs.subtracting(unreadServerItemIDs) + let readArtices = account.fetchArticles(.articleIDs(readItemIDs)) + account.update(readArtices, statusKey: .starred, flag: false) + + let foundReadArticleIDs = Set(readArtices.map { $0.articleID }) + let readMissingIDs = readItemIDs.subtracting(foundReadArticleIDs) + account.ensureStatuses(readMissingIDs, true, .starred, false) + } + + func syncArticleState(_ account: Account, key: ArticleStatus.Key, flag: Bool, serverFeedItems: [FeedWranglerFeedItem]) { + let serverFeedItemIDs = serverFeedItems.map { String($0.feedID) } + + // todo generalize this logic + } } diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerFeedItem.swift b/Frameworks/Account/FeedWrangler/FeedWranglerFeedItem.swift new file mode 100644 index 000000000..28389b292 --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerFeedItem.swift @@ -0,0 +1,62 @@ +// +// FeedWranglerFeedItem.swift +// Account +// +// Created by Jonathan Bennett on 2019-10-16.4// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct FeedWranglerFeedItem: Hashable, Codable { + + let feedItemID: Int + let publishedAt: Int + let createdAt: Int + let versionKey: Int + let updatedAt: Int + let url: String + let title: String + let starred: Bool + let read: Bool + let readLater: Bool + let body: String + let author: String? + let feedID: Int + let feedName: String + + var publishedDate: Date { + get { + Date(timeIntervalSince1970: Double(publishedAt)) + } + } + + var createdDate: Date { + get { + Date(timeIntervalSince1970: Double(createdAt)) + } + } + + var updatedDate: Date { + get { + Date(timeIntervalSince1970: Double(updatedAt)) + } + } + + enum CodingKeys: String, CodingKey { + case feedItemID = "feed_item_id" + case publishedAt = "published_at" + case createdAt = "created_at" + case versionKey = "version_key" + case updatedAt = "updated_at" + case url = "url" + case title = "title" + case starred = "starred" + case read = "read" + case readLater = "read_later" + case body = "body" + case author = "author" + case feedID = "feed_id" + case feedName = "feed_name" + } + +} diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerFeedItemsRequest.swift b/Frameworks/Account/FeedWrangler/FeedWranglerFeedItemsRequest.swift new file mode 100644 index 000000000..426aae6d3 --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerFeedItemsRequest.swift @@ -0,0 +1,25 @@ +// +// FeedWranglerFeedItemsRequest.swift +// Account +// +// Created by Jonathan Bennett on 2019-10-16. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct FeedWranglerFeedItemsRequest: Hashable, Codable { + + let count: Int + let feedItems: [FeedWranglerFeedItem] + let error: String? + let result: String + + enum CodingKeys: String, CodingKey { + case count = "count" + case feedItems = "feed_items" + case error = "error" + case result = "result" + } + +} From 4dea5e2cbbd4101cb9237d16c4afb4a9ecf841e9 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Thu, 17 Oct 2019 13:59:43 -0400 Subject: [PATCH 12/28] use URLQueryItem helper --- .../Credentials/URLRequest+RSWeb.swift | 17 +-- .../FeedWrangler/FeedWranglerAPICaller.swift | 126 ++++++++---------- .../Account/ReaderAPI/ReaderAPICaller.swift | 19 ++- 3 files changed, 67 insertions(+), 95 deletions(-) diff --git a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift index d056a8666..1edd0ac8e 100755 --- a/Frameworks/Account/Credentials/URLRequest+RSWeb.swift +++ b/Frameworks/Account/Credentials/URLRequest+RSWeb.swift @@ -26,24 +26,13 @@ public extension URLRequest { let auth = "Basic \(base64 ?? "")" setValue(auth, forHTTPHeaderField: HTTPRequestHeader.authorization) case .feedWranglerBasic: - - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return - } - components.queryItems = [ + self.url = url.appendingQueryItems([ URLQueryItem(name: "email", value: credentials.username), URLQueryItem(name: "password", value: credentials.secret), URLQueryItem(name: "client_key", value: FeedWranglerConfig.clientKey) - ] - self.url = components.url + ]) case .feedWranglerToken: - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return - } - var queryItems = components.queryItems ?? [] - queryItems.append(URLQueryItem(name: "access_token", value: credentials.secret)) - components.queryItems = queryItems - self.url = components.url + self.url = url.appendingQueryItem(URLQueryItem(name: "access_token", value: credentials.secret)) case .readerBasic: setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") httpMethod = "POST" diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift index bbb30fc6c..a53f0ce91 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift @@ -73,21 +73,20 @@ final class FeedWranglerAPICaller: NSObject { } func renameSubscription(feedID: String, newName: String, completion: @escaping (Result) -> Void) { - guard var components = URLComponents(url: FeedWranglerConfig.clientURL.appendingPathComponent("subscriptions/rename_feed"), resolvingAgainstBaseURL: false) else { - completion(.failure(TransportError.noURL)) - return - } - components.queryItems = [ - URLQueryItem(name: "feed_id", value: feedID), - URLQueryItem(name: "feed_name", value: newName), - ] - guard let url = components.url else { + let url = FeedWranglerConfig.clientURL + .appendingPathComponent("subscriptions/rename_feed") + .appendingQueryItems([ + URLQueryItem(name: "feed_id", value: feedID), + URLQueryItem(name: "feed_name", value: newName), + ]) + + guard let callURL = url else { completion(.failure(TransportError.noURL)) return } - let request = URLRequest(url: url, credentials: credentials) + let request = URLRequest(url: callURL, credentials: credentials) transport.send(request: request, resultType: FeedWranglerSubscriptionsRequest.self) { result in switch result { @@ -101,20 +100,17 @@ final class FeedWranglerAPICaller: NSObject { } func removeSubscription(feedID: String, completion: @escaping (Result) -> Void) { - guard var components = URLComponents(url: FeedWranglerConfig.clientURL.appendingPathComponent("subscriptions/remove_feed"), resolvingAgainstBaseURL: false) else { - completion(.failure(TransportError.noURL)) - return - } - components.queryItems = [ - URLQueryItem(name: "feed_id", value: feedID) - ] - guard let url = components.url else { + let url = FeedWranglerConfig.clientURL + .appendingPathComponent("subscriptions/remove_feed") + .appendingQueryItem(URLQueryItem(name: "feed_id", value: feedID)) + + guard let callURL = url else { completion(.failure(TransportError.noURL)) return } - let request = URLRequest(url: url, credentials: credentials) + let request = URLRequest(url: callURL, credentials: credentials) transport.send(request: request, resultType: FeedWranglerGenericResult.self) { result in switch result { @@ -129,24 +125,21 @@ final class FeedWranglerAPICaller: NSObject { // MARK: FeedItems func retrieveFeedItems(page: Int = 0, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { - guard var components = URLComponents(url: FeedWranglerConfig.clientURL.appendingPathComponent("feed_items/list"), resolvingAgainstBaseURL: false) else { - completion(.failure(TransportError.noURL)) - return - } + // todo: handle initial sync better - components.queryItems = [ - URLQueryItem(name: "read", value: "false"), - URLQueryItem(name: "offset", value: String(page * FeedWranglerConfig.pageSize)), -// URLQueryItem(name: "created_since", value: feedID), -// URLQueryItem(name: "updated_since", value: feedID), - ] + let url = FeedWranglerConfig.clientURL + .appendingPathComponent("feed_items/list") + .appendingQueryItems([ + URLQueryItem(name: "read", value: "false"), + URLQueryItem(name: "offset", value: String(page * FeedWranglerConfig.pageSize)), + ]) - guard let url = components.url else { + guard let callURL = url else { completion(.failure(TransportError.noURL)) return } - let request = URLRequest(url: url, credentials: credentials) + let request = URLRequest(url: callURL, credentials: credentials) transport.send(request: request, resultType: FeedWranglerFeedItemsRequest.self) { result in switch result { @@ -160,23 +153,20 @@ final class FeedWranglerAPICaller: NSObject { } func retrieveUnreadFeedItems(page: Int = 0, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { - guard var components = URLComponents(url: FeedWranglerConfig.clientURL.appendingPathComponent("feed_items/list"), resolvingAgainstBaseURL: false) else { - completion(.failure(TransportError.noURL)) - return - } - components.queryItems = [ - URLQueryItem(name: "read", value: "false"), - URLQueryItem(name: "offset", value: String(page * FeedWranglerConfig.pageSize)), -// URLQueryItem(name: "created_since", value: feedID), -// URLQueryItem(name: "updated_since", value: feedID), - ] + + let url = FeedWranglerConfig.clientURL + .appendingPathComponent("feed_items/list") + .appendingQueryItems([ + URLQueryItem(name: "read", value: "false"), + URLQueryItem(name: "offset", value: String(page * FeedWranglerConfig.pageSize)), + ]) - guard let url = components.url else { + guard let callURL = url else { completion(.failure(TransportError.noURL)) return } - let request = URLRequest(url: url, credentials: credentials) + let request = URLRequest(url: callURL, credentials: credentials) transport.send(request: request, resultType: FeedWranglerFeedItemsRequest.self) { result in switch result { @@ -206,34 +196,31 @@ final class FeedWranglerAPICaller: NSObject { } func retrieveStarredFeedItems(page: Int = 0, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { - guard var components = URLComponents(url: FeedWranglerConfig.clientURL.appendingPathComponent("feed_items/list"), resolvingAgainstBaseURL: false) else { - completion(.failure(TransportError.noURL)) - return - } - components.queryItems = [ + + let url = FeedWranglerConfig.clientURL + .appendingPathComponent("feed_items/list") + .appendingQueryItems([ URLQueryItem(name: "starred", value: "true"), URLQueryItem(name: "offset", value: String(page * FeedWranglerConfig.pageSize)), - // URLQueryItem(name: "created_since", value: feedID), - // URLQueryItem(name: "updated_since", value: feedID), - ] + ]) - guard let url = components.url else { - completion(.failure(TransportError.noURL)) - return - } + guard let callURL = url else { + completion(.failure(TransportError.noURL)) + return + } - let request = URLRequest(url: url, credentials: credentials) + let request = URLRequest(url: callURL, credentials: credentials) - transport.send(request: request, resultType: FeedWranglerFeedItemsRequest.self) { result in - switch result { - case .success(let (_, results)): - completion(.success(results?.feedItems ?? [])) + transport.send(request: request, resultType: FeedWranglerFeedItemsRequest.self) { result in + switch result { + case .success(let (_, results)): + completion(.success(results?.feedItems ?? [])) - case .failure(let error): - completion(.failure(error)) - } + case .failure(let error): + completion(.failure(error)) } } + } func retrieveAllStarredFeedItems(foundItems: [FeedWranglerFeedItem] = [], page: Int = 0, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { retrieveStarredFeedItems(page: page) { result in @@ -252,10 +239,7 @@ final class FeedWranglerAPICaller: NSObject { } func updateArticleStatus(_ articleID: String, _ statuses: [SyncStatus], completion: @escaping () -> Void) { - guard var components = URLComponents(url: FeedWranglerConfig.clientURL.appendingPathComponent("feed_items/update"), resolvingAgainstBaseURL: false) else { - completion() - return - } + var queryItems = statuses.compactMap { status -> URLQueryItem? in switch status.key { case .read: @@ -269,14 +253,16 @@ final class FeedWranglerAPICaller: NSObject { } } queryItems.append(URLQueryItem(name: "feed_item_id", value: articleID)) - components.queryItems = (components.queryItems ?? []) + queryItems + let url = FeedWranglerConfig.clientURL + .appendingPathComponent("feed_items/update") + .appendingQueryItems(queryItems) - guard let url = components.url else { + guard let callURL = url else { completion() return } - let request = URLRequest(url: url, credentials: credentials) + let request = URLRequest(url: callURL, credentials: credentials) transport.send(request: request, resultType: FeedWranglerGenericResult.self) { result in completion() diff --git a/Frameworks/Account/ReaderAPI/ReaderAPICaller.swift b/Frameworks/Account/ReaderAPI/ReaderAPICaller.swift index ba1234a81..3fddb2bd8 100644 --- a/Frameworks/Account/ReaderAPI/ReaderAPICaller.swift +++ b/Frameworks/Account/ReaderAPI/ReaderAPICaller.swift @@ -875,18 +875,15 @@ final class ReaderAPICaller: NSObject { return } - guard var components = URLComponents(url: baseURL.appendingPathComponent(ReaderAPIEndpoints.itemIds.rawValue), resolvingAgainstBaseURL: false) else { - completion(.failure(TransportError.noURL)) - return - } + let url = baseURL + .appendingPathComponent(ReaderAPIEndpoints.itemIds.rawValue) + .appendingQueryItems([ + URLQueryItem(name: "s", value: "user/-/state/com.google/starred"), + URLQueryItem(name: "n", value: "10000"), + URLQueryItem(name: "output", value: "json") + ]) - 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 { + guard let callURL = url else { completion(.failure(TransportError.noURL)) return } From 48e47ec40ba79547cda8962af6912bcc723f52d7 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Thu, 24 Oct 2019 11:48:12 -0400 Subject: [PATCH 13/28] Allow adding of feeds --- .../FeedWrangler/FeedWranglerAPICaller.swift | 30 +++++++++++++++++++ .../FeedWranglerAccountDelegate.swift | 21 ++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift index a53f0ce91..bafab6ec3 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift @@ -12,6 +12,10 @@ import Foundation import SyncDatabase import RSWeb +enum FeedWranglerError : Error { + case general(message: String) +} + final class FeedWranglerAPICaller: NSObject { private var transport: Transport! @@ -72,6 +76,32 @@ final class FeedWranglerAPICaller: NSObject { } } + func addSubscription(url: String, completion: @escaping (Result) -> Void) { + guard let callURL = FeedWranglerConfig + .clientURL + .appendingPathComponent("subscriptions/add_feed") + .appendingQueryItem(URLQueryItem(name: "feed_url", value: url)) else { + completion(.failure(TransportError.noURL)) + return + } + + let request = URLRequest(url: callURL, credentials: credentials) + + transport.send(request: request, resultType: FeedWranglerGenericResult.self) { result in + switch result { + case .success(let (_, results)): + if let error = results?.error { + completion(.failure(FeedWranglerError.general(message: error))) + } else { + completion(.success(())) + } + + case .failure(let error): + completion(.failure(error)) + } + } + } + func renameSubscription(feedID: String, newName: String, completion: @escaping (Result) -> Void) { let url = FeedWranglerConfig.clientURL diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index fc0ca55f0..a15c92186 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -201,7 +201,26 @@ final class FeedWranglerAccountDelegate: AccountDelegate { } func createFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { - fatalError() + refreshProgress.addToNumberOfTasksAndRemaining(3) + + self.refreshCredentials(for: account) { + self.refreshProgress.completeTask() + self.caller.addSubscription(url: url) { result in + self.refreshProgress.completeTask() + self.caller.addSubscription(url: url) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + let feed = account.createFeed(with: name, url: url, feedID: url, homePageURL: url) + completion(.success(feed)) + + case .failure(let error): + completion(.failure(error)) + } + } + } + } } func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result) -> Void) { From 739256ffa4921bd68cddf3f9982d9bbc5abccc4f Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Thu, 24 Oct 2019 12:19:51 -0400 Subject: [PATCH 14/28] correct submodule --- submodules/RSWeb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/RSWeb b/submodules/RSWeb index d140c97a1..1ea5f5ccf 160000 --- a/submodules/RSWeb +++ b/submodules/RSWeb @@ -1 +1 @@ -Subproject commit d140c97a13799a38fe62c09d1719c3963c3df3ce +Subproject commit 1ea5f5ccfc3646ffdf2891abbc5ea63e3d449def From f4bee1d0b96e2656eaee543d13a6853579f386e5 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 20 Nov 2019 06:13:30 -0500 Subject: [PATCH 15/28] retrieve missing articles --- .../FeedWrangler/FeedWranglerAPICaller.swift | 26 +++++++++++++++++ .../FeedWranglerAccountDelegate.swift | 28 ++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift index bafab6ec3..ac9bcffd5 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift @@ -154,6 +154,32 @@ final class FeedWranglerAPICaller: NSObject { } // MARK: FeedItems + func retrieveEntries(articleIDs: [String], completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { + let IDs = articleIDs.joined(separator: ",") + let url = FeedWranglerConfig.clientURL + .appendingPathComponent("feed_items/get") + .appendingQueryItem(URLQueryItem(name: "feed_item_ids", value: IDs)) + print("\(url!)") + + guard let callURL = url else { + completion(.failure(TransportError.noURL)) + return + } + + let request = URLRequest(url: callURL, credentials: credentials) + + transport.send(request: request, resultType: FeedWranglerFeedItemsRequest.self) { result in + switch result { + case .success(let (_, results)): + completion(.success(results?.feedItems ?? [])) + + case .failure(let error): + completion(.failure(error)) + } + } + + } + func retrieveFeedItems(page: Int = 0, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { // todo: handle initial sync better diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index a15c92186..9fedb8a5b 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -123,7 +123,33 @@ final class FeedWranglerAccountDelegate: AccountDelegate { func refreshMissingArticles(for account: Account, completion: @escaping (() -> Void)) { os_log(.debug, log: log, "Refreshing missing articles...") - completion() + let group = DispatchGroup() + + let fetchedArticleIDs = account.fetchArticleIDsForStatusesWithoutArticles() + let articleIDs = Array(fetchedArticleIDs) + let chunkedArticleIDs = articleIDs.chunked(into: 100) + + for chunk in chunkedArticleIDs { + group.enter() + self.caller.retrieveEntries(articleIDs: chunk) { result in + switch result { + case .success(let entries): + self.syncFeedItems(account, 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 sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) { From e4cce9f7f227e4a6e5284a4e01971770769d1b14 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 20 Nov 2019 10:21:24 -0500 Subject: [PATCH 16/28] setup credentials update button for mac --- Mac/Preferences/Accounts/AccountsDetailViewController.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Mac/Preferences/Accounts/AccountsDetailViewController.swift b/Mac/Preferences/Accounts/AccountsDetailViewController.swift index 902293e39..2c3499591 100644 --- a/Mac/Preferences/Accounts/AccountsDetailViewController.swift +++ b/Mac/Preferences/Accounts/AccountsDetailViewController.swift @@ -69,6 +69,11 @@ final class AccountsDetailViewController: NSViewController, NSTextFieldDelegate case .feedly: assertionFailure("Implement feedly logout window controller") break + case .feedWrangler: + let accountsFeedWranglerWindowController = AccountsFeedWranglerWindowController() + accountsFeedWranglerWindowController.account = account + accountsFeedWranglerWindowController.runSheetOnWindow(self.view.window!) + accountsWindowController = accountsFeedWranglerWindowController default: break } From e867991ec87c072824b809982b3d570f8f3fe959 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 20 Nov 2019 11:47:28 -0500 Subject: [PATCH 17/28] use transport.send(request, resultType, completion) --- .../Account/Account.xcodeproj/project.pbxproj | 4 +++ .../FeedWrangler/FeedWranglerAPICaller.swift | 25 +++++-------------- .../FeedWranglerAuthorizationResult.swift | 23 +++++++++++++++++ 3 files changed, 33 insertions(+), 19 deletions(-) create mode 100644 Frameworks/Account/FeedWrangler/FeedWranglerAuthorizationResult.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 67dd8f4f4..1ffc4b449 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 3B826D6923859D9D00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D6823859D9D00FC1ADB /* FeedWranglerAuthorizationResult.swift */; }; 3BF610C723571CD4000EF978 /* FeedWranglerAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF610C423571CD4000EF978 /* FeedWranglerAPICaller.swift */; }; 3BF610C823571CD4000EF978 /* FeedWranglerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF610C523571CD4000EF978 /* FeedWranglerConfig.swift */; }; 3BF610C923571CD4000EF978 /* FeedWranglerAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF610C623571CD4000EF978 /* FeedWranglerAccountDelegate.swift */; }; @@ -178,6 +179,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 3B826D6823859D9D00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = ""; }; 3BF610C423571CD4000EF978 /* FeedWranglerAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAPICaller.swift; sourceTree = ""; }; 3BF610C523571CD4000EF978 /* FeedWranglerConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerConfig.swift; sourceTree = ""; }; 3BF610C623571CD4000EF978 /* FeedWranglerAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAccountDelegate.swift; sourceTree = ""; }; @@ -349,6 +351,7 @@ 3BF6118F23577173000EF978 /* FeedWranglerGenericResult.swift */, 3BF611912357877E000EF978 /* FeedWranglerFeedItem.swift */, 3BF6119323578F55000EF978 /* FeedWranglerFeedItemsRequest.swift */, + 3B826D6823859D9D00FC1ADB /* FeedWranglerAuthorizationResult.swift */, ); path = FeedWrangler; sourceTree = ""; @@ -948,6 +951,7 @@ 9E1D155B2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift in Sources */, 552032FE229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift in Sources */, 3BF610C723571CD4000EF978 /* FeedWranglerAPICaller.swift in Sources */, + 3B826D6923859D9D00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */, 5170743C232AEDB500A461A3 /* OPMLFile.swift in Sources */, 51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */, 9E1773D923458D590056A5A8 /* FeedlyResourceId.swift in Sources */, diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift index ac9bcffd5..e6fc1d934 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift @@ -33,31 +33,18 @@ final class FeedWranglerAPICaller: NSObject { let request = URLRequest(url: callURL, credentials: credentials) let username = self.credentials?.username ?? "" - transport.send(request: request) { result in + transport.send(request: request, resultType: FeedWranglerAuthorizationResult.self) { result in switch result { - case .success(let (_, data)): - guard let data = data else { + case .success(let (_, results)): + if let accessToken = results?.accessToken { + let authCredentials = Credentials(type: .feedWranglerToken, username: username, secret: accessToken) + completion(.success(authCredentials)) + } else { completion(.success(nil)) - return - } - - do { - if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String:Any] { - if let accessToken = json["access_token"] as? String { - let authCredentials = Credentials(type: .feedWranglerToken, username: username, secret: accessToken) - completion(.success(authCredentials)) - return - } - } - - completion(.success(nil)) - } catch let error { - completion(.failure(error)) } case .failure(let error): completion(.failure(error)) } - } } diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAuthorizationResult.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAuthorizationResult.swift new file mode 100644 index 000000000..5055b4a0b --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAuthorizationResult.swift @@ -0,0 +1,23 @@ +// +// FeedWranglerAuthorizationResult.swift +// Account +// +// Created by Jonathan Bennett on 2019-11-20. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct FeedWranglerAuthorizationResult: Hashable, Codable { + + let accessToken: String? + let error: String? + let result: String + + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case error = "error" + case result = "result" + } +} From ac33bf982e212e8e60099f5510c5857ba2488b3f Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 20 Nov 2019 11:53:39 -0500 Subject: [PATCH 18/28] cleanup code most network calls follow the same structure --- .../FeedWrangler/FeedWranglerAPICaller.swift | 106 +++++------------- 1 file changed, 27 insertions(+), 79 deletions(-) diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift index e6fc1d934..8dae24210 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift @@ -29,11 +29,10 @@ final class FeedWranglerAPICaller: NSObject { } func validateCredentials(completion: @escaping (Result) -> Void) { - let callURL = FeedWranglerConfig.clientURL.appendingPathComponent("users/authorize") - let request = URLRequest(url: callURL, credentials: credentials) + let url = FeedWranglerConfig.clientURL.appendingPathComponent("users/authorize") let username = self.credentials?.username ?? "" - - transport.send(request: request, resultType: FeedWranglerAuthorizationResult.self) { result in + + standardSend(url: url, resultType: FeedWranglerAuthorizationResult.self) { result in switch result { case .success(let (_, results)): if let accessToken = results?.accessToken { @@ -50,9 +49,8 @@ final class FeedWranglerAPICaller: NSObject { func retrieveSubscriptions(completion: @escaping (Result<[FeedWranglerSubscription], Error>) -> Void) { let url = FeedWranglerConfig.clientURL.appendingPathComponent("subscriptions/list") - let request = URLRequest(url: url, credentials: credentials) - transport.send(request: request, resultType: FeedWranglerSubscriptionsRequest.self) { result in + standardSend(url: url, resultType: FeedWranglerSubscriptionsRequest.self) { result in switch result { case .success(let (_, results)): completion(.success(results?.feeds ?? [])) @@ -64,17 +62,12 @@ final class FeedWranglerAPICaller: NSObject { } func addSubscription(url: String, completion: @escaping (Result) -> Void) { - guard let callURL = FeedWranglerConfig + let url = FeedWranglerConfig .clientURL .appendingPathComponent("subscriptions/add_feed") - .appendingQueryItem(URLQueryItem(name: "feed_url", value: url)) else { - completion(.failure(TransportError.noURL)) - return - } + .appendingQueryItem(URLQueryItem(name: "feed_url", value: url)) - let request = URLRequest(url: callURL, credentials: credentials) - - transport.send(request: request, resultType: FeedWranglerGenericResult.self) { result in + standardSend(url: url, resultType: FeedWranglerGenericResult.self) { result in switch result { case .success(let (_, results)): if let error = results?.error { @@ -82,7 +75,7 @@ final class FeedWranglerAPICaller: NSObject { } else { completion(.success(())) } - + case .failure(let error): completion(.failure(error)) } @@ -90,7 +83,6 @@ final class FeedWranglerAPICaller: NSObject { } func renameSubscription(feedID: String, newName: String, completion: @escaping (Result) -> Void) { - let url = FeedWranglerConfig.clientURL .appendingPathComponent("subscriptions/rename_feed") .appendingQueryItems([ @@ -98,14 +90,7 @@ final class FeedWranglerAPICaller: NSObject { URLQueryItem(name: "feed_name", value: newName), ]) - guard let callURL = url else { - completion(.failure(TransportError.noURL)) - return - } - - let request = URLRequest(url: callURL, credentials: credentials) - - transport.send(request: request, resultType: FeedWranglerSubscriptionsRequest.self) { result in + standardSend(url: url, resultType: FeedWranglerSubscriptionsRequest.self) { result in switch result { case .success: completion(.success(())) @@ -117,19 +102,11 @@ final class FeedWranglerAPICaller: NSObject { } func removeSubscription(feedID: String, completion: @escaping (Result) -> Void) { - let url = FeedWranglerConfig.clientURL .appendingPathComponent("subscriptions/remove_feed") .appendingQueryItem(URLQueryItem(name: "feed_id", value: feedID)) - guard let callURL = url else { - completion(.failure(TransportError.noURL)) - return - } - - let request = URLRequest(url: callURL, credentials: credentials) - - transport.send(request: request, resultType: FeedWranglerGenericResult.self) { result in + standardSend(url: url, resultType: FeedWranglerGenericResult.self) { result in switch result { case .success: completion(.success(())) @@ -146,16 +123,8 @@ final class FeedWranglerAPICaller: NSObject { let url = FeedWranglerConfig.clientURL .appendingPathComponent("feed_items/get") .appendingQueryItem(URLQueryItem(name: "feed_item_ids", value: IDs)) - print("\(url!)") - guard let callURL = url else { - completion(.failure(TransportError.noURL)) - return - } - - let request = URLRequest(url: callURL, credentials: credentials) - - transport.send(request: request, resultType: FeedWranglerFeedItemsRequest.self) { result in + standardSend(url: url, resultType: FeedWranglerFeedItemsRequest.self) { result in switch result { case .success(let (_, results)): completion(.success(results?.feedItems ?? [])) @@ -168,7 +137,6 @@ final class FeedWranglerAPICaller: NSObject { } func retrieveFeedItems(page: Int = 0, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { - // todo: handle initial sync better let url = FeedWranglerConfig.clientURL .appendingPathComponent("feed_items/list") @@ -177,14 +145,7 @@ final class FeedWranglerAPICaller: NSObject { URLQueryItem(name: "offset", value: String(page * FeedWranglerConfig.pageSize)), ]) - guard let callURL = url else { - completion(.failure(TransportError.noURL)) - return - } - - let request = URLRequest(url: callURL, credentials: credentials) - - transport.send(request: request, resultType: FeedWranglerFeedItemsRequest.self) { result in + standardSend(url: url, resultType: FeedWranglerFeedItemsRequest.self) { result in switch result { case .success(let (_, results)): completion(.success(results?.feedItems ?? [])) @@ -196,22 +157,14 @@ final class FeedWranglerAPICaller: NSObject { } func retrieveUnreadFeedItems(page: Int = 0, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { - let url = FeedWranglerConfig.clientURL .appendingPathComponent("feed_items/list") .appendingQueryItems([ URLQueryItem(name: "read", value: "false"), URLQueryItem(name: "offset", value: String(page * FeedWranglerConfig.pageSize)), ]) - - guard let callURL = url else { - completion(.failure(TransportError.noURL)) - return - } - - let request = URLRequest(url: callURL, credentials: credentials) - - transport.send(request: request, resultType: FeedWranglerFeedItemsRequest.self) { result in + + standardSend(url: url, resultType: FeedWranglerFeedItemsRequest.self) { result in switch result { case .success(let (_, results)): completion(.success(results?.feedItems ?? [])) @@ -239,22 +192,14 @@ final class FeedWranglerAPICaller: NSObject { } func retrieveStarredFeedItems(page: Int = 0, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { - let url = FeedWranglerConfig.clientURL .appendingPathComponent("feed_items/list") .appendingQueryItems([ URLQueryItem(name: "starred", value: "true"), URLQueryItem(name: "offset", value: String(page * FeedWranglerConfig.pageSize)), ]) - - guard let callURL = url else { - completion(.failure(TransportError.noURL)) - return - } - - let request = URLRequest(url: callURL, credentials: credentials) - - transport.send(request: request, resultType: FeedWranglerFeedItemsRequest.self) { result in + + standardSend(url: url, resultType: FeedWranglerFeedItemsRequest.self) { result in switch result { case .success(let (_, results)): completion(.success(results?.feedItems ?? [])) @@ -300,16 +245,19 @@ final class FeedWranglerAPICaller: NSObject { .appendingPathComponent("feed_items/update") .appendingQueryItems(queryItems) - guard let callURL = url else { - completion() - return - } - - let request = URLRequest(url: callURL, credentials: credentials) - - transport.send(request: request, resultType: FeedWranglerGenericResult.self) { result in + standardSend(url: url, resultType: FeedWranglerGenericResult.self) { result in completion() } } + + private func standardSend(url: URL?, resultType: R.Type, completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void) { + guard let callURL = url else { + completion(.failure(TransportError.noURL)) + return + } + let request = URLRequest(url: callURL, credentials: credentials) + + transport.send(request: request, resultType: resultType, completion: completion) + } } From 82d76316949545e70eac27bdb27ac598cd0c38cc Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 20 Nov 2019 12:27:21 -0500 Subject: [PATCH 19/28] treack refresh progress better --- .../Account/FeedWrangler/FeedWranglerAccountDelegate.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index 9fedb8a5b..feb3517f4 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -60,12 +60,17 @@ final class FeedWranglerAccountDelegate: AccountDelegate { refreshProgress.addToNumberOfTasksAndRemaining(6) self.refreshCredentials(for: account) { + self.refreshProgress.completeTask() self.refreshSubscriptions(for: account) { _ in + self.refreshProgress.completeTask() self.sendArticleStatus(for: account) { + self.refreshProgress.completeTask() self.refreshArticleStatus(for: account) { + self.refreshProgress.completeTask() self.refreshArticles(for: account) { + self.refreshProgress.completeTask() self.refreshMissingArticles(for: account) { - self.refreshProgress.clear() + self.refreshProgress.completeTask() DispatchQueue.main.async { completion(.success(())) } From 29a06082886a4439e9936748c9a9f1512feed4c1 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 20 Nov 2019 14:51:07 -0500 Subject: [PATCH 20/28] pbxproj merge fixes --- .../Account/Account.xcodeproj/project.pbxproj | 97 ++++++++----------- NetNewsWire.xcodeproj/project.pbxproj | 25 +++-- 2 files changed, 54 insertions(+), 68 deletions(-) diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 8d8b146a4..389f756a5 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -7,15 +7,15 @@ objects = { /* Begin PBXBuildFile section */ - 3B826D6923859D9D00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D6823859D9D00FC1ADB /* FeedWranglerAuthorizationResult.swift */; }; - 3BF610C723571CD4000EF978 /* FeedWranglerAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF610C423571CD4000EF978 /* FeedWranglerAPICaller.swift */; }; - 3BF610C823571CD4000EF978 /* FeedWranglerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF610C523571CD4000EF978 /* FeedWranglerConfig.swift */; }; - 3BF610C923571CD4000EF978 /* FeedWranglerAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF610C623571CD4000EF978 /* FeedWranglerAccountDelegate.swift */; }; - 3BF6112423572A62000EF978 /* FeedWranglerSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF6112323572A62000EF978 /* FeedWranglerSubscription.swift */; }; - 3BF6112623572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF6112523572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift */; }; - 3BF6119023577173000EF978 /* FeedWranglerGenericResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF6118F23577173000EF978 /* FeedWranglerGenericResult.swift */; }; - 3BF611922357877E000EF978 /* FeedWranglerFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF611912357877E000EF978 /* FeedWranglerFeedItem.swift */; }; - 3BF6119423578F55000EF978 /* FeedWranglerFeedItemsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF6119323578F55000EF978 /* FeedWranglerFeedItemsRequest.swift */; }; + 3B826DA72385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */; }; + 3B826DA82385C81C00FC1ADB /* FeedWranglerFeedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */; }; + 3B826DA92385C81C00FC1ADB /* FeedWranglerAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA02385C81C00FC1ADB /* FeedWranglerAPICaller.swift */; }; + 3B826DAA2385C81C00FC1ADB /* FeedWranglerSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA12385C81C00FC1ADB /* FeedWranglerSubscription.swift */; }; + 3B826DAB2385C81C00FC1ADB /* FeedWranglerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA22385C81C00FC1ADB /* FeedWranglerConfig.swift */; }; + 3B826DAC2385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA32385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift */; }; + 3B826DAD2385C81C00FC1ADB /* FeedWranglerFeedItemsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA42385C81C00FC1ADB /* FeedWranglerFeedItemsRequest.swift */; }; + 3B826DAE2385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA52385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift */; }; + 3B826DAF2385C81C00FC1ADB /* FeedWranglerGenericResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA62385C81C00FC1ADB /* FeedWranglerGenericResult.swift */; }; 5107A099227DE42E00C7C3C5 /* AccountCredentialsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */; }; 5107A09B227DE49500C7C3C5 /* TestAccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */; }; 5107A09D227DE77700C7C3C5 /* TestTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09C227DE77700C7C3C5 /* TestTransport.swift */; }; @@ -217,15 +217,15 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 3B826D6823859D9D00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = ""; }; - 3BF610C423571CD4000EF978 /* FeedWranglerAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAPICaller.swift; sourceTree = ""; }; - 3BF610C523571CD4000EF978 /* FeedWranglerConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerConfig.swift; sourceTree = ""; }; - 3BF610C623571CD4000EF978 /* FeedWranglerAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAccountDelegate.swift; sourceTree = ""; }; - 3BF6112323572A62000EF978 /* FeedWranglerSubscription.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscription.swift; sourceTree = ""; }; - 3BF6112523572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscriptionsRequest.swift; sourceTree = ""; }; - 3BF6118F23577173000EF978 /* FeedWranglerGenericResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerGenericResult.swift; sourceTree = ""; }; - 3BF611912357877E000EF978 /* FeedWranglerFeedItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItem.swift; sourceTree = ""; }; - 3BF6119323578F55000EF978 /* FeedWranglerFeedItemsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItemsRequest.swift; sourceTree = ""; }; + 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAuthorizationResult.swift; sourceTree = ""; }; + 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItem.swift; sourceTree = ""; }; + 3B826DA02385C81C00FC1ADB /* FeedWranglerAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAPICaller.swift; sourceTree = ""; }; + 3B826DA12385C81C00FC1ADB /* FeedWranglerSubscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscription.swift; sourceTree = ""; }; + 3B826DA22385C81C00FC1ADB /* FeedWranglerConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerConfig.swift; sourceTree = ""; }; + 3B826DA32385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerAccountDelegate.swift; sourceTree = ""; }; + 3B826DA42385C81C00FC1ADB /* FeedWranglerFeedItemsRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItemsRequest.swift; sourceTree = ""; }; + 3B826DA52385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscriptionsRequest.swift; sourceTree = ""; }; + 3B826DA62385C81C00FC1ADB /* FeedWranglerGenericResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerGenericResult.swift; sourceTree = ""; }; 5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCredentialsTest.swift; sourceTree = ""; }; 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAccountManager.swift; sourceTree = ""; }; 5107A09C227DE77700C7C3C5 /* TestTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTransport.swift; sourceTree = ""; }; @@ -416,18 +416,18 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 3BF610C323571CD4000EF978 /* FeedWrangler */ = { + 3B826D9D2385C81C00FC1ADB /* FeedWrangler */ = { isa = PBXGroup; children = ( - 3BF610C423571CD4000EF978 /* FeedWranglerAPICaller.swift */, - 3BF610C523571CD4000EF978 /* FeedWranglerConfig.swift */, - 3BF610C623571CD4000EF978 /* FeedWranglerAccountDelegate.swift */, - 3BF6112323572A62000EF978 /* FeedWranglerSubscription.swift */, - 3BF6112523572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift */, - 3BF6118F23577173000EF978 /* FeedWranglerGenericResult.swift */, - 3BF611912357877E000EF978 /* FeedWranglerFeedItem.swift */, - 3BF6119323578F55000EF978 /* FeedWranglerFeedItemsRequest.swift */, - 3B826D6823859D9D00FC1ADB /* FeedWranglerAuthorizationResult.swift */, + 3B826D9E2385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift */, + 3B826D9F2385C81C00FC1ADB /* FeedWranglerFeedItem.swift */, + 3B826DA02385C81C00FC1ADB /* FeedWranglerAPICaller.swift */, + 3B826DA12385C81C00FC1ADB /* FeedWranglerSubscription.swift */, + 3B826DA22385C81C00FC1ADB /* FeedWranglerConfig.swift */, + 3B826DA32385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift */, + 3B826DA42385C81C00FC1ADB /* FeedWranglerFeedItemsRequest.swift */, + 3B826DA52385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift */, + 3B826DA62385C81C00FC1ADB /* FeedWranglerGenericResult.swift */, ); path = FeedWrangler; sourceTree = ""; @@ -582,8 +582,8 @@ 515E4EB12324FF7D0057B0E7 /* Credentials */, 8419742B1F6DDE84006346C4 /* LocalAccount */, 84245C7D1FDDD2580074AFBB /* Feedbin */, + 3B826D9D2385C81C00FC1ADB /* FeedWrangler */, 552032EA229D5D5A009559E0 /* ReaderAPI */, - 3BF610C323571CD4000EF978 /* FeedWrangler */, 9EA31339231E368100268BA0 /* Feedly */, 848935031F62484F00CEBD24 /* AccountTests */, 848934F71F62484F00CEBD24 /* Products */, @@ -766,15 +766,11 @@ isa = PBXNativeTarget; buildConfigurationList = 8489350A1F62485000CEBD24 /* Build configuration list for PBXNativeTarget "Account" */; buildPhases = ( -<<<<<<< HEAD - 3BF610B123571C31000EF978 /* Run Script: Update FeedWranglerConfig.swift */, - 848934F11F62484F00CEBD24 /* Sources */, - 3BF610B223571C32000EF978 /* Run Script: Reset FeedWranglerConfig.swift */, -======= 9E964EBB2375512300A7AF2E /* Run Script: Update OAuthAuthorizationClient+Feedly.swift */, + 3B826DCF2385CE1B00FC1ADB /* Run Script: Update FeedWranglerConfig.swift */, 848934F11F62484F00CEBD24 /* Sources */, 9E964EBC2375517100A7AF2E /* Run Script: Reset OAuthAuthorizationClient+Feedly.swift */, ->>>>>>> master + 3B826DD02385CE9500FC1ADB /* Run Script: Reset FeedWranglerConfig.swift */, 848934F21F62484F00CEBD24 /* Frameworks */, 848934F31F62484F00CEBD24 /* Headers */, 848934F41F62484F00CEBD24 /* Resources */, @@ -929,7 +925,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 3BF610B123571C31000EF978 /* Run Script: Update FeedWranglerConfig.swift */ = { + 3B826DCF2385CE1B00FC1ADB /* Run Script: Update FeedWranglerConfig.swift */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -947,7 +943,7 @@ shellPath = /bin/sh; shellScript = "FAILED=false\n\nif [ -z \"${FEED_WRANGLER_KEY}\" ]; then\nFAILED=true\nfi\n\nif [ \"$FAILED\" = true ]; then\necho \"Missing Feed Wrangler Key. FeedWranglerConfig.swift not changed.\"\nexit 0\nfi\n\nsed -i .tmp \"s|{FEEDWRANGLERKEY}|${FEED_WRANGLER_KEY}|g; s|{FEEDWRANGLERKEY}|${FEED_WRANGLER_KEY}|g\" \"${SRCROOT}/FeedWrangler/FeedWranglerConfig.swift\"\n\nrm -f \"${SRCROOT}/FeedWrangler/FeedWranglerConfig.swift.tmp\"\n\necho \"All Feed Wrangler env values found!\"\n\n"; }; - 3BF610B223571C32000EF978 /* Run Script: Reset FeedWranglerConfig.swift */ = { + 3B826DD02385CE9500FC1ADB /* Run Script: Reset FeedWranglerConfig.swift */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -1038,10 +1034,10 @@ 8469F81C1F6DD15E0084783E /* Account.swift in Sources */, 9EAEC60E2332FEC20085D7C9 /* FeedlyFeed.swift in Sources */, 5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */, + 3B826DAF2385C81C00FC1ADB /* FeedWranglerGenericResult.swift in Sources */, 9ECC9A85234DC16E009B5144 /* FeedlyAccountDelegateError.swift in Sources */, 9EA3133B231E368100268BA0 /* FeedlyAccountDelegate.swift in Sources */, 51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */, - 3BF6112423572A62000EF978 /* FeedWranglerSubscription.swift in Sources */, 846E77451F6EF9B900A165E2 /* Container.swift in Sources */, 9E1D15532334304B00F4944C /* FeedlyGetStreamContentsOperation.swift in Sources */, 9E12B0202334696A00ADE5A0 /* FeedlyCreateFeedsForCollectionFoldersOperation.swift in Sources */, @@ -1049,12 +1045,7 @@ 9EAEC62A23331EE70085D7C9 /* FeedlyOrigin.swift in Sources */, 511B9804237CD4270028BCAA /* FeedIdentifier.swift in Sources */, 84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */, -<<<<<<< HEAD - 9E713653233AD63E00765C84 /* FeedlyRefreshStreamEntriesStatusOperation.swift in Sources */, - 3BF610C923571CD4000EF978 /* FeedWranglerAccountDelegate.swift in Sources */, -======= 9E713653233AD63E00765C84 /* FeedlySetUnreadArticlesOperation.swift in Sources */, ->>>>>>> master 841974251F6DDCE4006346C4 /* AccountDelegate.swift in Sources */, 510BD113232C3E9D002692E4 /* WebFeedMetadataFile.swift in Sources */, 5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */, @@ -1065,22 +1056,19 @@ 51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */, 9EEAE06E235D002D00E3FEE4 /* FeedlyGetCollectionsService.swift in Sources */, 5165D72922835F7A00D9D53D /* FeedSpecifier.swift in Sources */, -<<<<<<< HEAD - 9E1D154D233370D800F4944C /* FeedlySyncStrategy.swift in Sources */, - 844B297D2106C7EC004020B3 /* Feed.swift in Sources */, - 3BF6119023577173000EF978 /* FeedWranglerGenericResult.swift in Sources */, -======= 9E85C8ED2367020700D0F1F7 /* FeedlyGetEntriesService.swift in Sources */, 9E84DC492359A73600D6E809 /* FeedlyCheckpointOperation.swift in Sources */, 9E85C8EB236700E600D0F1F7 /* FeedlyGetEntriesOperation.swift in Sources */, 9E1D154D233370D800F4944C /* FeedlySyncAllOperation.swift in Sources */, 844B297D2106C7EC004020B3 /* WebFeed.swift in Sources */, + 3B826DA72385C81C00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */, 9E964EBA23754B4000A7AF2E /* OAuthAccountAuthorizationOperation.swift in Sources */, ->>>>>>> master 9E1D15572334355900F4944C /* FeedlyRequestStreamsOperation.swift in Sources */, 9E1D15512334282100F4944C /* FeedlyMirrorCollectionsAsFoldersOperation.swift in Sources */, 9E1773D7234575AB0056A5A8 /* FeedlyTag.swift in Sources */, + 3B826DAB2385C81C00FC1ADB /* FeedWranglerConfig.swift in Sources */, 515E4EB62324FF8C0057B0E7 /* URLRequest+RSWeb.swift in Sources */, + 3B826DA82385C81C00FC1ADB /* FeedWranglerFeedItem.swift in Sources */, 9E672396236F7E68000BE141 /* OAuthAcessTokenRefreshing.swift in Sources */, 9E7299D723505E9600DAEFB7 /* FeedlyAddFeedOperation.swift in Sources */, 9EEAE075235D01C400E3FEE4 /* FeedlyMarkArticlesService.swift in Sources */, @@ -1099,8 +1087,8 @@ 55203300229D5D5A009559E0 /* ReaderAPICaller.swift in Sources */, 9E1D154F233371DD00F4944C /* FeedlyGetCollectionsOperation.swift in Sources */, 9EAEC626233318400085D7C9 /* FeedlyStream.swift in Sources */, + 3B826DA92385C81C00FC1ADB /* FeedWranglerAPICaller.swift in Sources */, 9EAEC60C2332FE830085D7C9 /* FeedlyCollection.swift in Sources */, - 3BF6119423578F55000EF978 /* FeedWranglerFeedItemsRequest.swift in Sources */, 51E3EB41229AF61B00645299 /* AccountError.swift in Sources */, 9E1D155D233447F000F4944C /* FeedlyUpdateAccountFeedsWithItemsOperation.swift in Sources */, 51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */, @@ -1110,8 +1098,6 @@ 51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */, 9E1D155B2334423300F4944C /* FeedlyOrganiseParsedItemsByFeedOperation.swift in Sources */, 552032FE229D5D5A009559E0 /* ReaderAPIAccountDelegate.swift in Sources */, - 3BF610C723571CD4000EF978 /* FeedWranglerAPICaller.swift in Sources */, - 3B826D6923859D9D00FC1ADB /* FeedWranglerAuthorizationResult.swift in Sources */, 5170743C232AEDB500A461A3 /* OPMLFile.swift in Sources */, 51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */, 9E1773D923458D590056A5A8 /* FeedlyResourceId.swift in Sources */, @@ -1120,25 +1106,26 @@ 9EC688EA232B973C00A8D0A2 /* FeedlyAPICaller.swift in Sources */, 9E1773D32345700F0056A5A8 /* FeedlyLink.swift in Sources */, 9EAEC62823331C350085D7C9 /* FeedlyCategory.swift in Sources */, + 3B826DAE2385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift in Sources */, 9E964EB823754AC400A7AF2E /* OAuthAuthorizationClient+Feedly.swift in Sources */, 9EF1B10923590E93000A486A /* FeedlyStreamIds.swift in Sources */, 84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */, 84CAD7161FDF2E22000F0755 /* FeedbinEntry.swift in Sources */, 5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */, - 3BF6112623572E43000EF978 /* FeedWranglerSubscriptionsRequest.swift in Sources */, 841974011F6DD1EC006346C4 /* Folder.swift in Sources */, 510BD111232C3801002692E4 /* AccountMetadataFile.swift in Sources */, - 3BF611922357877E000EF978 /* FeedWranglerFeedItem.swift in Sources */, + 3B826DAD2385C81C00FC1ADB /* FeedWranglerFeedItemsRequest.swift in Sources */, 846E774F1F6EF9C000A165E2 /* LocalAccountDelegate.swift in Sources */, 515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */, 844B297F210CE37E004020B3 /* UnreadCountProvider.swift in Sources */, 9E1773D5234570E30056A5A8 /* FeedlyEntryParser.swift in Sources */, 9E1D1555233431A600F4944C /* FeedlyOperation.swift in Sources */, - 3BF610C823571CD4000EF978 /* FeedWranglerConfig.swift in Sources */, 9E1AF38B2353D41A008BD1D5 /* FeedlySetStarredArticlesOperation.swift in Sources */, 84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */, 9EF1B10723590D61000A486A /* FeedlyGetStreamIdsOperation.swift in Sources */, 84245C851FDDD8CB0074AFBB /* FeedbinSubscription.swift in Sources */, + 3B826DAA2385C81C00FC1ADB /* FeedWranglerSubscription.swift in Sources */, + 3B826DAC2385C81C00FC1ADB /* FeedWranglerAccountDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index b3da1fc64..fdb4470f4 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -7,8 +7,10 @@ objects = { /* Begin PBXBuildFile section */ - 3B29BC69233EA81F002A346D /* AccountsFeedWranglerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B29BC5F233EA81F002A346D /* AccountsFeedWranglerWindowController.swift */; }; - 3B29BC6A233EA81F002A346D /* AccountsFeedWrangler.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3B29BC68233EA81F002A346D /* AccountsFeedWrangler.xib */; }; + 3B826DCB2385C84800FC1ADB /* AccountsFeedWrangler.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3B826DB02385C84800FC1ADB /* AccountsFeedWrangler.xib */; }; + 3B826DCC2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DCA2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift */; }; + 3B826DCD2385C89600FC1ADB /* AccountsFeedWrangler.xib in Resources */ = {isa = PBXBuildFile; fileRef = 3B826DB02385C84800FC1ADB /* AccountsFeedWrangler.xib */; }; + 3B826DCE2385C89600FC1ADB /* AccountsFeedWranglerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DCA2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift */; }; 49F40DF82335B71000552BF4 /* newsfoot.js in Resources */ = {isa = PBXBuildFile; fileRef = 49F40DEF2335B71000552BF4 /* newsfoot.js */; }; 49F40DF92335B71000552BF4 /* newsfoot.js in Resources */ = {isa = PBXBuildFile; fileRef = 49F40DEF2335B71000552BF4 /* newsfoot.js */; }; 5108F6B62375E612001ABC45 /* CacheCleaner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5108F6B52375E612001ABC45 /* CacheCleaner.swift */; }; @@ -1215,8 +1217,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 3B29BC5F233EA81F002A346D /* AccountsFeedWranglerWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountsFeedWranglerWindowController.swift; sourceTree = ""; }; - 3B29BC68233EA81F002A346D /* AccountsFeedWrangler.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = AccountsFeedWrangler.xib; sourceTree = ""; }; + 3B826DB02385C84800FC1ADB /* AccountsFeedWrangler.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = AccountsFeedWrangler.xib; sourceTree = ""; }; + 3B826DCA2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountsFeedWranglerWindowController.swift; sourceTree = ""; }; 49F40DEF2335B71000552BF4 /* newsfoot.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = newsfoot.js; sourceTree = ""; }; 5108F6B52375E612001ABC45 /* CacheCleaner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheCleaner.swift; sourceTree = ""; }; 5108F6D12375EED2001ABC45 /* TimelineCustomizerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineCustomizerViewController.swift; sourceTree = ""; }; @@ -2520,13 +2522,8 @@ 5144EA2E2279FAB600D19003 /* AccountsDetailViewController.swift */, 5144EA50227B8E4500D19003 /* AccountsFeedbin.xib */, 5144EA4F227B8E4500D19003 /* AccountsFeedbinWindowController.swift */, -<<<<<<< HEAD - 9EA33BB82318F8C10097B644 /* AccountsFeedlyWeb.xib */, - 9EA33BB72318F8C10097B644 /* AccountsFeedlyWebWindowController.swift */, - 3B29BC68233EA81F002A346D /* AccountsFeedWrangler.xib */, - 3B29BC5F233EA81F002A346D /* AccountsFeedWranglerWindowController.swift */, -======= ->>>>>>> master + 3B826DB02385C84800FC1ADB /* AccountsFeedWrangler.xib */, + 3B826DCA2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift */, 55E15BC1229D65A900D6602A /* AccountsReaderAPI.xib */, 55E15BCA229D65A900D6602A /* AccountsReaderAPIWindowController.swift */, 5144EA352279FC3D00D19003 /* AccountsAddLocal.xib */, @@ -3398,6 +3395,7 @@ 65ED4066235DEF6C0081F399 /* TimelineTableView.xib in Resources */, 65ED4067235DEF6C0081F399 /* page.html in Resources */, 65ED4068235DEF6C0081F399 /* MainWindow.storyboard in Resources */, + 3B826DCD2385C89600FC1ADB /* AccountsFeedWrangler.xib in Resources */, 65ED4069235DEF6C0081F399 /* AccountsReaderAPI.xib in Resources */, 65ED406A235DEF6C0081F399 /* newsfoot.js in Resources */, 65ED406B235DEF6C0081F399 /* CrashReporterWindow.xib in Resources */, @@ -3483,13 +3481,13 @@ 8405DDA222168920008CE1BF /* TimelineTableView.xib in Resources */, B528F81E23333C7E00E735DD /* page.html in Resources */, 8483630E2262A3FE00DA1D35 /* MainWindow.storyboard in Resources */, + 3B826DCB2385C84800FC1ADB /* AccountsFeedWrangler.xib in Resources */, 55E15BCB229D65A900D6602A /* AccountsReaderAPI.xib in Resources */, 49F40DF82335B71000552BF4 /* newsfoot.js in Resources */, 84BAE64921CEDAF20046DB56 /* CrashReporterWindow.xib in Resources */, 84C9FC8E22629E8F00D921D6 /* Credits.rtf in Resources */, 84BBB12D20142A4700F054F5 /* Inspector.storyboard in Resources */, 848363022262A3BD00DA1D35 /* AddFeedSheet.xib in Resources */, - 3B29BC6A233EA81F002A346D /* AccountsFeedWrangler.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -3835,6 +3833,7 @@ 65ED3FFF235DEF6C0081F399 /* SidebarOutlineDataSource.swift in Sources */, 65ED4000235DEF6C0081F399 /* SidebarCellAppearance.swift in Sources */, 65ED4001235DEF6C0081F399 /* StarredFeedDelegate.swift in Sources */, + 3B826DCE2385C89600FC1ADB /* AccountsFeedWranglerWindowController.swift in Sources */, 65ED4002235DEF6C0081F399 /* FaviconDownloader.swift in Sources */, 65ED4003235DEF6C0081F399 /* AdvancedPreferencesViewController.swift in Sources */, 65ED4004235DEF6C0081F399 /* SharingServicePickerDelegate.swift in Sources */, @@ -4055,7 +4054,6 @@ 847CD6CA232F4CBF00FAC46D /* IconView.swift in Sources */, 51FA73AA2332C2FD0090D516 /* ArticleExtractorConfig.swift in Sources */, 84BBB12E20142A4700F054F5 /* InspectorWindowController.swift in Sources */, - 3B29BC69233EA81F002A346D /* AccountsFeedWranglerWindowController.swift in Sources */, 51EF0F7A22771B890050506E /* ColorHash.swift in Sources */, 84E46C7D1F75EF7B005ECFB3 /* AppDefaults.swift in Sources */, D5907D972004B7EB005947E5 /* Account+Scriptability.swift in Sources */, @@ -4156,6 +4154,7 @@ 84CAFCAF22BC8C35007694F0 /* FetchRequestOperation.swift in Sources */, 8426119E1FCB6ED40086A189 /* HTMLMetadataDownloader.swift in Sources */, 849A976E1ED9EBC8007D329B /* TimelineViewController.swift in Sources */, + 3B826DCC2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift in Sources */, 5154368B229404D1005E1CDF /* FaviconGenerator.swift in Sources */, 5183CCE6226F4E110010922C /* RefreshInterval.swift in Sources */, 849A97771ED9EC04007D329B /* TimelineCellData.swift in Sources */, From 8aa62bc190f9aa5a17d391588f9c272bb6659c68 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 20 Nov 2019 15:59:10 -0500 Subject: [PATCH 21/28] fix submodules --- submodules/RSCore | 2 +- submodules/RSDatabase | 2 +- submodules/RSParser | 2 +- submodules/RSTree | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/submodules/RSCore b/submodules/RSCore index d70c24feb..ba7bbb2ce 160000 --- a/submodules/RSCore +++ b/submodules/RSCore @@ -1 +1 @@ -Subproject commit d70c24feb4bdad8fdbe7e0beb6edd12d3383c7dc +Subproject commit ba7bbb2ce10ee04a730c0a1e425a1b2e9d338520 diff --git a/submodules/RSDatabase b/submodules/RSDatabase index 9bb7e3745..d64e2e220 160000 --- a/submodules/RSDatabase +++ b/submodules/RSDatabase @@ -1 +1 @@ -Subproject commit 9bb7e374518ad481c6da14b656d6676c02b33548 +Subproject commit d64e2e220690fc63db7670d7efd060b4c129f045 diff --git a/submodules/RSParser b/submodules/RSParser index d3fe846f8..3b5c88aa1 160000 --- a/submodules/RSParser +++ b/submodules/RSParser @@ -1 +1 @@ -Subproject commit d3fe846f8969f8914d233242b711f4314ec1cb17 +Subproject commit 3b5c88aa1c3d50e2a7500f2a260f5ffce81c71d2 diff --git a/submodules/RSTree b/submodules/RSTree index 7581b16d4..3dc1c288b 160000 --- a/submodules/RSTree +++ b/submodules/RSTree @@ -1 +1 @@ -Subproject commit 7581b16d4e8f22fad183d34338d44af85d37b30d +Subproject commit 3dc1c288bb4e15fedf17fa8fc43c1d5cec36af5e From 36c2aabe384425d71cdcc86ee1552360f4f0990c Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 20 Nov 2019 16:02:25 -0500 Subject: [PATCH 22/28] rename Feed to WebFeed --- .../FeedWranglerAccountDelegate.swift | 56 +++++++++++-------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index feb3517f4..4677e8485 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -56,6 +56,10 @@ final class FeedWranglerAccountDelegate: AccountDelegate { database = SyncDatabase(databaseFilePath: dataFolder.appending("/DB.sqlite3")) } + func accountWillBeDeleted(_ account: Account) { + fatalError() + } + func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { refreshProgress.addToNumberOfTasksAndRemaining(6) @@ -63,9 +67,9 @@ final class FeedWranglerAccountDelegate: AccountDelegate { self.refreshProgress.completeTask() self.refreshSubscriptions(for: account) { _ in self.refreshProgress.completeTask() - self.sendArticleStatus(for: account) { + self.sendArticleStatus(for: account) { _ in self.refreshProgress.completeTask() - self.refreshArticleStatus(for: account) { + self.refreshArticleStatus(for: account) { _ in self.refreshProgress.completeTask() self.refreshArticles(for: account) { self.refreshProgress.completeTask() @@ -82,6 +86,10 @@ final class FeedWranglerAccountDelegate: AccountDelegate { } } + func cancelAll(for account: Account) { + fatalError() + } + func refreshCredentials(for account: Account, completion: @escaping (() -> Void)) { os_log(.debug, log: log, "Refreshing credentials...") // MARK: TODO @@ -157,7 +165,7 @@ final class FeedWranglerAccountDelegate: AccountDelegate { } } - func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) { + func sendArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { os_log(.debug, log: log, "Sending article status...") let syncStatuses = database.selectForProcessing() @@ -174,11 +182,11 @@ final class FeedWranglerAccountDelegate: AccountDelegate { group.notify(queue: DispatchQueue.main) { os_log(.debug, log: self.log, "Done sending article statuses.") - completion() + completion(.success(())) } } - func refreshArticleStatus(for account: Account, completion: @escaping (() -> Void)) { + func refreshArticleStatus(for account: Account, completion: @escaping ((Result) -> Void)) { os_log(.debug, log: log, "Refreshing article status...") let group = DispatchGroup() @@ -211,7 +219,7 @@ final class FeedWranglerAccountDelegate: AccountDelegate { group.notify(queue: DispatchQueue.main) { os_log(.debug, log: self.log, "Done refreshing article statuses.") - completion() + completion(.success(())) } } @@ -231,7 +239,7 @@ final class FeedWranglerAccountDelegate: AccountDelegate { fatalError() } - func createFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { + func createWebFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { refreshProgress.addToNumberOfTasksAndRemaining(3) self.refreshCredentials(for: account) { @@ -243,7 +251,7 @@ final class FeedWranglerAccountDelegate: AccountDelegate { switch result { case .success: - let feed = account.createFeed(with: name, url: url, feedID: url, homePageURL: url) + let feed = account.createWebFeed(with: name, url: url, webFeedID: url, homePageURL: url) completion(.success(feed)) case .failure(let error): @@ -254,12 +262,12 @@ final class FeedWranglerAccountDelegate: AccountDelegate { } } - func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result) -> Void) { + func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result) -> Void) { refreshProgress.addToNumberOfTasksAndRemaining(2) self.refreshCredentials(for: account) { self.refreshProgress.completeTask() - self.caller.renameSubscription(feedID: feed.feedID, newName: name) { result in + self.caller.renameSubscription(feedID: feed.webFeedID, newName: name) { result in self.refreshProgress.completeTask() switch result { @@ -279,23 +287,23 @@ final class FeedWranglerAccountDelegate: AccountDelegate { } } - func addFeed(for account: Account, with: Feed, to container: Container, completion: @escaping (Result) -> Void) { + func addWebFeed(for account: Account, with: WebFeed, to container: Container, completion: @escaping (Result) -> Void) { fatalError() } - func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result) -> Void) { + func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result) -> Void) { refreshProgress.addToNumberOfTasksAndRemaining(2) self.refreshCredentials(for: account) { self.refreshProgress.completeTask() - self.caller.removeSubscription(feedID: feed.feedID) { result in + self.caller.removeSubscription(feedID: feed.webFeedID) { result in self.refreshProgress.completeTask() switch result { case .success: DispatchQueue.main.async { - account.clearFeedMetadata(feed) - account.removeFeed(feed) + account.clearWebFeedMetadata(feed) + account.removeWebFeed(feed) completion(.success(())) } @@ -309,11 +317,11 @@ final class FeedWranglerAccountDelegate: AccountDelegate { } } - func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result) -> Void) { + func moveWebFeed(for account: Account, with feed: WebFeed, from: Container, to: Container, completion: @escaping (Result) -> Void) { fatalError() } - func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result) -> Void) { + func restoreWebFeed(for account: Account, feed: WebFeed, container: Container, completion: @escaping (Result) -> Void) { fatalError() } @@ -326,7 +334,9 @@ final class FeedWranglerAccountDelegate: AccountDelegate { database.insertStatuses(syncStatuses) if database.selectPendingCount() > 0 { - sendArticleStatus(for: account) {} // do it in the background + sendArticleStatus(for: account) { _ in + // do it in the background + } } return account.update(articles, statusKey: statusKey, flag: flag) @@ -354,14 +364,14 @@ private extension FeedWranglerAccountDelegate { assert(Thread.isMainThread) let feedIds = subscriptions.map { String($0.feedID) } - let feedsToRemove = account.topLevelFeeds.filter { !feedIds.contains($0.feedID) } + let feedsToRemove = account.topLevelWebFeeds.filter { !feedIds.contains($0.webFeedID) } account.removeFeeds(feedsToRemove) var subscriptionsToAdd = Set() subscriptions.forEach { subscription in let subscriptionId = String(subscription.feedID) - if let feed = account.existingFeed(withFeedID: subscriptionId) { + if let feed = account.existingWebFeed(withWebFeedID: subscriptionId) { feed.name = subscription.title feed.editedName = nil feed.homePageURL = subscription.siteURL @@ -373,9 +383,9 @@ private extension FeedWranglerAccountDelegate { subscriptionsToAdd.forEach { subscription in let feedId = String(subscription.feedID) - let feed = account.createFeed(with: subscription.title, url: subscription.feedURL, feedID: feedId, homePageURL: subscription.siteURL) + let feed = account.createWebFeed(with: subscription.title, url: subscription.feedURL, webFeedID: feedId, homePageURL: subscription.siteURL) feed.subscriptionID = nil - account.addFeed(feed) + account.addWebFeed(feed) } } @@ -389,7 +399,7 @@ private extension FeedWranglerAccountDelegate { } let feedIDsAndItems = Dictionary(grouping: parsedItems, by: { $0.feedURL }).mapValues { Set($0) } - account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true, completion: completion) + account.update(webFeedIDsAndItems: feedIDsAndItems, defaultRead: true, completion: completion) } func syncArticleReadState(_ account: Account, _ unreadFeedItems: [FeedWranglerFeedItem]) { From 93595ab7451f6eafd28d6c7cf32173ea21288e7c Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 20 Nov 2019 16:32:50 -0500 Subject: [PATCH 23/28] bubble transport errors up --- .../FeedWranglerAccountDelegate.swift | 66 ++++++++++++++----- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index 4677e8485..1656fe3ba 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -65,22 +65,57 @@ final class FeedWranglerAccountDelegate: AccountDelegate { self.refreshCredentials(for: account) { self.refreshProgress.completeTask() - self.refreshSubscriptions(for: account) { _ in + self.refreshSubscriptions(for: account) { result in self.refreshProgress.completeTask() - self.sendArticleStatus(for: account) { _ in - self.refreshProgress.completeTask() - self.refreshArticleStatus(for: account) { _ in + + switch result { + case .success: + self.sendArticleStatus(for: account) { result in self.refreshProgress.completeTask() - self.refreshArticles(for: account) { - self.refreshProgress.completeTask() - self.refreshMissingArticles(for: account) { + + switch result { + case .success: + self.refreshArticleStatus(for: account) { result in self.refreshProgress.completeTask() - DispatchQueue.main.async { - completion(.success(())) + + switch result { + case .success: + self.refreshArticles(for: account) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + self.refreshMissingArticles(for: account) { result in + self.refreshProgress.completeTask() + + switch result { + case .success: + DispatchQueue.main.async { + completion(.success(())) + } + + case .failure(let error): + completion(.failure(error)) + } + } + + case .failure(let error): + completion(.failure(error)) + } + } + + case .failure(let error): + completion(.failure(error)) } } + + case .failure(let error): + completion(.failure(error)) } } + + case .failure(let error): + completion(.failure(error)) } } } @@ -113,7 +148,7 @@ final class FeedWranglerAccountDelegate: AccountDelegate { } } - func refreshArticles(for account: Account, page: Int = 0, completion: @escaping (() -> Void)) { + func refreshArticles(for account: Account, page: Int = 0, completion: @escaping ((Result) -> Void)) { os_log(.debug, log: log, "Refreshing articles, page: %d...", page) caller.retrieveFeedItems(page: page) { result in @@ -121,20 +156,19 @@ final class FeedWranglerAccountDelegate: AccountDelegate { case .success(let items): self.syncFeedItems(account, items) { if items.count == 0 { - completion() + completion(.success(())) } else { self.refreshArticles(for: account, page: (page + 1), completion: completion) } } - case .failure: - // TODO Handle error - completion() + case .failure(let error): + completion(.failure(error)) } } } - func refreshMissingArticles(for account: Account, completion: @escaping (() -> Void)) { + func refreshMissingArticles(for account: Account, completion: @escaping ((Result)-> Void)) { os_log(.debug, log: log, "Refreshing missing articles...") let group = DispatchGroup() @@ -161,7 +195,7 @@ final class FeedWranglerAccountDelegate: AccountDelegate { group.notify(queue: DispatchQueue.main) { self.refreshProgress.completeTask() os_log(.debug, log: self.log, "Done refreshing missing articles.") - completion() + completion(.success(())) } } From 85d54c17cc08764b777abc9ff6052cb80792c6b9 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 20 Nov 2019 16:33:56 -0500 Subject: [PATCH 24/28] allow cancelling of network requests --- Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift | 4 ++++ .../Account/FeedWrangler/FeedWranglerAccountDelegate.swift | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift index 8dae24210..139d51ca5 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift @@ -28,6 +28,10 @@ final class FeedWranglerAPICaller: NSObject { self.transport = transport } + func cancelAll() { + transport.cancelAll() + } + func validateCredentials(completion: @escaping (Result) -> Void) { let url = FeedWranglerConfig.clientURL.appendingPathComponent("users/authorize") let username = self.credentials?.username ?? "" diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index 1656fe3ba..02e59b02c 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -57,7 +57,7 @@ final class FeedWranglerAccountDelegate: AccountDelegate { } func accountWillBeDeleted(_ account: Account) { - fatalError() + // noop } func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { @@ -122,7 +122,7 @@ final class FeedWranglerAccountDelegate: AccountDelegate { } func cancelAll(for account: Account) { - fatalError() + caller.cancelAll() } func refreshCredentials(for account: Account, completion: @escaping (() -> Void)) { From d3c168a12e286f1b92339de815693899ad057c02 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Wed, 20 Nov 2019 17:26:27 -0500 Subject: [PATCH 25/28] use add_feed_and_wait endpoint The add_feed endpoint does not return feed info. The _and_wait endpoint can be slower (up to 10 seconds) but will make sure we gett the right URL if available. --- .../Account/Account.xcodeproj/project.pbxproj | 4 ++++ .../FeedWrangler/FeedWranglerAPICaller.swift | 24 ++++++++++++------- .../FeedWranglerAccountDelegate.swift | 4 ++-- .../FeedWranglerSubscriptionResult.swift | 18 ++++++++++++++ 4 files changed, 40 insertions(+), 10 deletions(-) create mode 100644 Frameworks/Account/FeedWrangler/FeedWranglerSubscriptionResult.swift diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 389f756a5..63585f829 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 3B826DAD2385C81C00FC1ADB /* FeedWranglerFeedItemsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA42385C81C00FC1ADB /* FeedWranglerFeedItemsRequest.swift */; }; 3B826DAE2385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA52385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift */; }; 3B826DAF2385C81C00FC1ADB /* FeedWranglerGenericResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B826DA62385C81C00FC1ADB /* FeedWranglerGenericResult.swift */; }; + 3BC23AB92385ECB100371CBA /* FeedWranglerSubscriptionResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC23AB82385ECB100371CBA /* FeedWranglerSubscriptionResult.swift */; }; 5107A099227DE42E00C7C3C5 /* AccountCredentialsTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */; }; 5107A09B227DE49500C7C3C5 /* TestAccountManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */; }; 5107A09D227DE77700C7C3C5 /* TestTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5107A09C227DE77700C7C3C5 /* TestTransport.swift */; }; @@ -226,6 +227,7 @@ 3B826DA42385C81C00FC1ADB /* FeedWranglerFeedItemsRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerFeedItemsRequest.swift; sourceTree = ""; }; 3B826DA52385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscriptionsRequest.swift; sourceTree = ""; }; 3B826DA62385C81C00FC1ADB /* FeedWranglerGenericResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FeedWranglerGenericResult.swift; sourceTree = ""; }; + 3BC23AB82385ECB100371CBA /* FeedWranglerSubscriptionResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedWranglerSubscriptionResult.swift; sourceTree = ""; }; 5107A098227DE42E00C7C3C5 /* AccountCredentialsTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCredentialsTest.swift; sourceTree = ""; }; 5107A09A227DE49500C7C3C5 /* TestAccountManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAccountManager.swift; sourceTree = ""; }; 5107A09C227DE77700C7C3C5 /* TestTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTransport.swift; sourceTree = ""; }; @@ -428,6 +430,7 @@ 3B826DA42385C81C00FC1ADB /* FeedWranglerFeedItemsRequest.swift */, 3B826DA52385C81C00FC1ADB /* FeedWranglerSubscriptionsRequest.swift */, 3B826DA62385C81C00FC1ADB /* FeedWranglerGenericResult.swift */, + 3BC23AB82385ECB100371CBA /* FeedWranglerSubscriptionResult.swift */, ); path = FeedWrangler; sourceTree = ""; @@ -1079,6 +1082,7 @@ 9EAEC624233315F60085D7C9 /* FeedlyEntry.swift in Sources */, 9EEAE073235D01AE00E3FEE4 /* FeedlyGetStreamIdsService.swift in Sources */, 9EEEF7212355277F009E9D80 /* FeedlySyncStarredArticlesOperation.swift in Sources */, + 3BC23AB92385ECB100371CBA /* FeedWranglerSubscriptionResult.swift in Sources */, 5144EA49227B497600D19003 /* FeedbinAPICaller.swift in Sources */, 84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */, 9E510D6E234F16A8002E6F1A /* FeedlyAddFeedRequest.swift in Sources */, diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift index 139d51ca5..20f01e51b 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift @@ -65,20 +65,28 @@ final class FeedWranglerAPICaller: NSObject { } } - func addSubscription(url: String, completion: @escaping (Result) -> Void) { + func addSubscription(url: String, completion: @escaping (Result) -> Void) { let url = FeedWranglerConfig .clientURL - .appendingPathComponent("subscriptions/add_feed") - .appendingQueryItem(URLQueryItem(name: "feed_url", value: url)) - - standardSend(url: url, resultType: FeedWranglerGenericResult.self) { result in + .appendingPathComponent("subscriptions/add_feed_and_wait") + .appendingQueryItems([ + URLQueryItem(name: "feed_url", value: url), + URLQueryItem(name: "choose_first", value: "true") + ]) + + standardSend(url: url, resultType: FeedWranglerSubscriptionResult.self) { result in switch result { case .success(let (_, results)): - if let error = results?.error { - completion(.failure(FeedWranglerError.general(message: error))) + if let results = results { + if let error = results.error { + completion(.failure(FeedWranglerError.general(message: error))) + } else { + completion(.success(results.feed)) + } } else { - completion(.success(())) + completion(.failure(FeedWranglerError.general(message: "No feed found"))) } + case .failure(let error): completion(.failure(error)) diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index 02e59b02c..78a66fb9b 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -284,8 +284,8 @@ final class FeedWranglerAccountDelegate: AccountDelegate { self.refreshProgress.completeTask() switch result { - case .success: - let feed = account.createWebFeed(with: name, url: url, webFeedID: url, homePageURL: url) + case .success(let subscription): + let feed = account.createWebFeed(with: subscription.title, url: subscription.feedURL, webFeedID: String(subscription.feedID), homePageURL: subscription.siteURL) completion(.success(feed)) case .failure(let error): diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerSubscriptionResult.swift b/Frameworks/Account/FeedWrangler/FeedWranglerSubscriptionResult.swift new file mode 100644 index 000000000..2bd21bbee --- /dev/null +++ b/Frameworks/Account/FeedWrangler/FeedWranglerSubscriptionResult.swift @@ -0,0 +1,18 @@ +// +// FeedWranglerSubscriptionResult.swift +// Account +// +// Created by Jonathan Bennett on 2019-11-20. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct FeedWranglerSubscriptionResult: Hashable, Codable { + + let feed: FeedWranglerSubscription + let error: String? + let result: String + +} + From b3c053964c69b300d9238b4667a91f0a9f3c575b Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Thu, 21 Nov 2019 01:09:09 -0500 Subject: [PATCH 26/28] handle new feeds better this is in prep for the initial acount/feeds changes --- .../FeedWrangler/FeedWranglerAPICaller.swift | 15 ++-- .../FeedWranglerAccountDelegate.swift | 71 ++++++++++++++++--- 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift index 20f01e51b..d2feee7b5 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift @@ -148,15 +148,16 @@ final class FeedWranglerAPICaller: NSObject { } - func retrieveFeedItems(page: Int = 0, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { - // todo: handle initial sync better + func retrieveFeedItems(page: Int = 0, feed: WebFeed? = nil, completion: @escaping (Result<[FeedWranglerFeedItem], Error>) -> Void) { + let queryItems = [ + URLQueryItem(name: "read", value: "false"), + URLQueryItem(name: "offset", value: String(page * FeedWranglerConfig.pageSize)), + feed.map { URLQueryItem(name: "feed_id", value: $0.webFeedID) } + ].compactMap { $0 } let url = FeedWranglerConfig.clientURL .appendingPathComponent("feed_items/list") - .appendingQueryItems([ - URLQueryItem(name: "read", value: "false"), - URLQueryItem(name: "offset", value: String(page * FeedWranglerConfig.pageSize)), - ]) - + .appendingQueryItems(queryItems) + standardSend(url: url, resultType: FeedWranglerFeedItemsRequest.self) { result in switch result { case .success(let (_, results)): diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index 78a66fb9b..dd3f69175 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -274,21 +274,19 @@ final class FeedWranglerAccountDelegate: AccountDelegate { } func createWebFeed(for account: Account, url: String, name: String?, container: Container, completion: @escaping (Result) -> Void) { - refreshProgress.addToNumberOfTasksAndRemaining(3) + refreshProgress.addToNumberOfTasksAndRemaining(2) self.refreshCredentials(for: account) { self.refreshProgress.completeTask() self.caller.addSubscription(url: url) { result in self.refreshProgress.completeTask() - self.caller.addSubscription(url: url) { result in - self.refreshProgress.completeTask() - switch result { - case .success(let subscription): - let feed = account.createWebFeed(with: subscription.title, url: subscription.feedURL, webFeedID: String(subscription.feedID), homePageURL: subscription.siteURL) - completion(.success(feed)) - - case .failure(let error): + switch result { + case .success(let subscription): + self.addFeedWranglerSubscription(account: account, subscription: subscription, name: name, container: container, completion: completion) + + case .failure(let error): + DispatchQueue.main.async { completion(.failure(error)) } } @@ -296,6 +294,53 @@ final class FeedWranglerAccountDelegate: AccountDelegate { } } + private func addFeedWranglerSubscription(account: Account, subscription sub: FeedWranglerSubscription, name: String?, container: Container, completion: @escaping (Result) -> Void) { + DispatchQueue.main.async { + let feed = account.createWebFeed(with: sub.title, url: sub.feedURL, webFeedID: String(sub.feedID), homePageURL: sub.siteURL) + + account.addWebFeed(feed, to: container) { result in + switch result { + case .success: + if let name = name { + account.renameWebFeed(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)) + } + } + } + } + + private func initialFeedDownload(account: Account, feed: WebFeed, completion: @escaping (Result) -> Void) { + + self.caller.retrieveFeedItems(page: 0, feed: feed) { results in + switch results { + case .success(let entries): + self.syncFeedItems(account, entries) { + DispatchQueue.main.async { + completion(.success(feed)) + } + } + + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + } + func renameWebFeed(for account: Account, with feed: WebFeed, to name: String, completion: @escaping (Result) -> Void) { refreshProgress.addToNumberOfTasksAndRemaining(2) @@ -321,8 +366,12 @@ final class FeedWranglerAccountDelegate: AccountDelegate { } } - func addWebFeed(for account: Account, with: WebFeed, to container: Container, completion: @escaping (Result) -> Void) { - fatalError() + func addWebFeed(for account: Account, with feed: WebFeed, to container: Container, completion: @escaping (Result) -> Void) { + // just add to account, folders are not supported + DispatchQueue.main.async { + account.addFeedIfNotInAnyFolder(feed) + completion(.success(())) + } } func removeWebFeed(for account: Account, with feed: WebFeed, from container: Container, completion: @escaping (Result) -> Void) { From 0f105c8421106aeeeda7cdca89396eea46afbf78 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Thu, 21 Nov 2019 01:17:34 -0500 Subject: [PATCH 27/28] logout when deleting account --- .../FeedWrangler/FeedWranglerAPICaller.swift | 15 +++++++++++++++ .../FeedWranglerAccountDelegate.swift | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift index d2feee7b5..65c2e41cb 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAPICaller.swift @@ -32,6 +32,21 @@ final class FeedWranglerAPICaller: NSObject { transport.cancelAll() } + func logout(completion: @escaping (Result) -> Void) { + let url = FeedWranglerConfig.clientURL.appendingPathComponent("users/logout") + let request = URLRequest(url: url, credentials: credentials) + + transport.send(request: request) { result in + switch result { + case .success: + completion(.success(())) + + case .failure(let error): + completion(.failure(error)) + } + } + } + func validateCredentials(completion: @escaping (Result) -> Void) { let url = FeedWranglerConfig.clientURL.appendingPathComponent("users/authorize") let username = self.credentials?.username ?? "" diff --git a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift index dd3f69175..f1b4352f4 100644 --- a/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift +++ b/Frameworks/Account/FeedWrangler/FeedWranglerAccountDelegate.swift @@ -57,7 +57,7 @@ final class FeedWranglerAccountDelegate: AccountDelegate { } func accountWillBeDeleted(_ account: Account) { - // noop + caller.logout() { _ in } } func refreshAll(for account: Account, completion: @escaping (Result) -> Void) { From 68ff7378e74948e485c7a1d5dcf1fd170b19963b Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Thu, 21 Nov 2019 11:28:08 -0600 Subject: [PATCH 28/28] Added FeedWrangler image assets --- Mac/AppAssets.swift | 2 ++ .../Accounts/AccountsAddViewController.swift | 2 +- .../AccountsPreferencesViewController.swift | 13 +------------ .../accountFeedWrangler.imageset/Contents.json | 2 +- .../accountFreshRSS.pdf | Bin 4206 -> 0 bytes .../outline-512.png | Bin 0 -> 49563 bytes 6 files changed, 5 insertions(+), 14 deletions(-) delete mode 100644 Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/accountFreshRSS.pdf create mode 100644 Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/outline-512.png diff --git a/Mac/AppAssets.swift b/Mac/AppAssets.swift index e6af844ff..4c01f583b 100644 --- a/Mac/AppAssets.swift +++ b/Mac/AppAssets.swift @@ -129,6 +129,8 @@ struct AppAssets { return AppAssets.accountFeedbin case .feedly: return AppAssets.accountFeedly + case .feedWrangler: + return AppAssets.accountFeedWrangler case .freshRSS: return AppAssets.accountFreshRSS default: diff --git a/Mac/Preferences/Accounts/AccountsAddViewController.swift b/Mac/Preferences/Accounts/AccountsAddViewController.swift index ed451924e..bfd0050b8 100644 --- a/Mac/Preferences/Accounts/AccountsAddViewController.swift +++ b/Mac/Preferences/Accounts/AccountsAddViewController.swift @@ -15,7 +15,7 @@ class AccountsAddViewController: NSViewController { private var accountsAddWindowController: NSWindowController? - private let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly, .freshRSS] + private let addableAccountTypes: [AccountType] = [.onMyMac, .feedbin, .feedly, .feedWrangler, .freshRSS] init() { super.init(nibName: "AccountsAdd", bundle: nil) diff --git a/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift b/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift index 5c5022610..2c1212a98 100644 --- a/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift +++ b/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift @@ -103,18 +103,7 @@ extension AccountsPreferencesViewController: NSTableViewDelegate { if let cell = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "Cell"), owner: nil) as? NSTableCellView { let account = sortedAccounts[row] cell.textField?.stringValue = account.nameForDisplay - switch account.type { - case .onMyMac: - cell.imageView?.image = AppAssets.accountLocal - case .feedbin: - cell.imageView?.image = AppAssets.accountFeedbin - case .freshRSS: - cell.imageView?.image = AppAssets.accountFreshRSS - case .feedly: - cell.imageView?.image = AppAssets.accountFeedly - default: - break - } + cell.imageView?.image = account.smallIcon?.image return cell } return nil diff --git a/Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/Contents.json b/Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/Contents.json index b2c62a136..0721d5d1f 100644 --- a/Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/Contents.json +++ b/Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/Contents.json @@ -2,7 +2,7 @@ "images" : [ { "idiom" : "universal", - "filename" : "accountFreshRSS.pdf" + "filename" : "outline-512.png" } ], "info" : { diff --git a/Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/accountFreshRSS.pdf b/Mac/Resources/Assets.xcassets/accountFeedWrangler.imageset/accountFreshRSS.pdf deleted file mode 100644 index 1ff98e115ed9096d447af7849c78bbd5906f8b00..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4206 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 YTb<&+Df~VTqec+s2M*E-ja@GOALHb7=Kufz literal 0 HcmV?d00001