From 84dbdf25e223f15ec4e777c83264af30d2543d6f Mon Sep 17 00:00:00 2001 From: Jeremy Beker Date: Tue, 28 May 2019 13:08:15 -0400 Subject: [PATCH] Google Reader API Account Provider and initial integration * Creation of account classes (based on FeedBin) * Integration on Mac side into account dialog * Initial authentication call works and extracts auth token, but no where to put it right now. --- Frameworks/Account/Account.swift | 9 + .../Account/Account.xcodeproj/project.pbxproj | 52 + .../GoogleReaderCompatibleAPICaller.swift | 615 +++++++++ ...oogleReaderCompatibleAccountDelegate.swift | 1199 +++++++++++++++++ .../GoogleReaderCompatibleDate.swift | 21 + .../GoogleReaderCompatibleEntry.swift | 67 + .../GoogleReaderCompatibleIcon.swift | 21 + .../GoogleReaderCompatibleImportResult.swift | 21 + .../GoogleReaderCompatibleStarredEntry.swift | 19 + .../GoogleReaderCompatibleSubscription.swift | 55 + .../GoogleReaderCompatibleTag.swift | 43 + .../GoogleReaderCompatibleTagging.swift | 35 + .../GoogleReaderCompatibleUnreadEntry.swift | 19 + .../Accounts/AccountsAddViewController.swift | 9 +- .../AccountsGoogleReaderCompatible.xib | 210 +++ ...ogleReaderCompatibleWindowController.swift | 125 ++ .../AccountsPreferencesViewController.swift | 2 + Mac/Scriptability/Account+Scriptability.swift | 2 + NetNewsWire.xcodeproj/project.pbxproj | 20 +- 19 files changed, 2537 insertions(+), 7 deletions(-) create mode 100644 Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleAPICaller.swift create mode 100644 Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleAccountDelegate.swift create mode 100644 Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleDate.swift create mode 100644 Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleEntry.swift create mode 100644 Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleIcon.swift create mode 100644 Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleImportResult.swift create mode 100644 Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleStarredEntry.swift create mode 100644 Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleSubscription.swift create mode 100644 Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleTag.swift create mode 100644 Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleTagging.swift create mode 100644 Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleUnreadEntry.swift create mode 100644 Mac/Preferences/Accounts/AccountsGoogleReaderCompatible.xib create mode 100644 Mac/Preferences/Accounts/AccountsGoogleReaderCompatibleWindowController.swift diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 4bb5df495..6c436895c 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -33,6 +33,7 @@ public enum AccountType: Int { case feedbin = 17 case feedWrangler = 18 case newsBlur = 19 + case googleReaderCompatible = 20 // TODO: more } @@ -196,6 +197,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, self.delegate = LocalAccountDelegate() case .feedbin: self.delegate = FeedbinAccountDelegate(dataFolder: dataFolder, transport: transport) + case .googleReaderCompatible: + self.delegate = GoogleReaderCompatibleAccountDelegate(dataFolder: dataFolder, transport: transport) default: fatalError("Only Local and Feedbin accounts are supported") } @@ -223,6 +226,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, defaultName = "FeedWrangler" case .newsBlur: defaultName = "NewsBlur" + case .googleReaderCompatible: + defaultName = "Google Reader Compatible" } NotificationCenter.default.addObserver(self, selector: #selector(downloadProgressDidChange(_:)), name: .DownloadProgressDidChange, object: nil) @@ -254,6 +259,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, switch credentials { case .basic(let username, _): self.username = username + case .googleLogin(let username, _, _, _): + self.username = username } try CredentialsManager.storeCredentials(credentials, server: server) @@ -283,6 +290,8 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, LocalAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion) case .feedbin: FeedbinAccountDelegate.validateCredentials(transport: transport, credentials: credentials, completion: completion) + case .googleReaderCompatible: + GoogleReaderCompatibleAccountDelegate.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 4e8d20f58..a9b840800 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -35,6 +35,17 @@ 51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E490352288C37100C791F0 /* FeedbinDate.swift */; }; 51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */; }; 51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */; }; + 552032F6229D5D5A009559E0 /* GoogleReaderCompatibleDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032EB229D5D5A009559E0 /* GoogleReaderCompatibleDate.swift */; }; + 552032F7229D5D5A009559E0 /* GoogleReaderCompatibleIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032EC229D5D5A009559E0 /* GoogleReaderCompatibleIcon.swift */; }; + 552032F8229D5D5A009559E0 /* GoogleReaderCompatibleEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032ED229D5D5A009559E0 /* GoogleReaderCompatibleEntry.swift */; }; + 552032F9229D5D5A009559E0 /* GoogleReaderCompatibleSubscription.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032EE229D5D5A009559E0 /* GoogleReaderCompatibleSubscription.swift */; }; + 552032FA229D5D5A009559E0 /* GoogleReaderCompatibleStarredEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032EF229D5D5A009559E0 /* GoogleReaderCompatibleStarredEntry.swift */; }; + 552032FB229D5D5A009559E0 /* GoogleReaderCompatibleTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F0229D5D5A009559E0 /* GoogleReaderCompatibleTag.swift */; }; + 552032FC229D5D5A009559E0 /* GoogleReaderCompatibleUnreadEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F1229D5D5A009559E0 /* GoogleReaderCompatibleUnreadEntry.swift */; }; + 552032FD229D5D5A009559E0 /* GoogleReaderCompatibleTagging.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F2229D5D5A009559E0 /* GoogleReaderCompatibleTagging.swift */; }; + 552032FE229D5D5A009559E0 /* GoogleReaderCompatibleAccountDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F3229D5D5A009559E0 /* GoogleReaderCompatibleAccountDelegate.swift */; }; + 552032FF229D5D5A009559E0 /* GoogleReaderCompatibleImportResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F4229D5D5A009559E0 /* GoogleReaderCompatibleImportResult.swift */; }; + 55203300229D5D5A009559E0 /* GoogleReaderCompatibleAPICaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552032F5229D5D5A009559E0 /* GoogleReaderCompatibleAPICaller.swift */; }; 841973FE1F6DD1BC006346C4 /* RSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841973EF1F6DD19E006346C4 /* RSCore.framework */; }; 841973FF1F6DD1C5006346C4 /* RSParser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 841973FA1F6DD1AC006346C4 /* RSParser.framework */; }; 841974011F6DD1EC006346C4 /* Folder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841974001F6DD1EC006346C4 /* Folder.swift */; }; @@ -136,6 +147,17 @@ 51E490352288C37100C791F0 /* FeedbinDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinDate.swift; sourceTree = ""; }; 51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinUnreadEntry.swift; sourceTree = ""; }; 51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinStarredEntry.swift; sourceTree = ""; }; + 552032EB229D5D5A009559E0 /* GoogleReaderCompatibleDate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleDate.swift; sourceTree = ""; }; + 552032EC229D5D5A009559E0 /* GoogleReaderCompatibleIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleIcon.swift; sourceTree = ""; }; + 552032ED229D5D5A009559E0 /* GoogleReaderCompatibleEntry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleEntry.swift; sourceTree = ""; }; + 552032EE229D5D5A009559E0 /* GoogleReaderCompatibleSubscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleSubscription.swift; sourceTree = ""; }; + 552032EF229D5D5A009559E0 /* GoogleReaderCompatibleStarredEntry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleStarredEntry.swift; sourceTree = ""; }; + 552032F0229D5D5A009559E0 /* GoogleReaderCompatibleTag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleTag.swift; sourceTree = ""; }; + 552032F1229D5D5A009559E0 /* GoogleReaderCompatibleUnreadEntry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleUnreadEntry.swift; sourceTree = ""; }; + 552032F2229D5D5A009559E0 /* GoogleReaderCompatibleTagging.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleTagging.swift; sourceTree = ""; }; + 552032F3229D5D5A009559E0 /* GoogleReaderCompatibleAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleAccountDelegate.swift; sourceTree = ""; }; + 552032F4229D5D5A009559E0 /* GoogleReaderCompatibleImportResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleImportResult.swift; sourceTree = ""; }; + 552032F5229D5D5A009559E0 /* GoogleReaderCompatibleAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleAPICaller.swift; sourceTree = ""; }; 841973E81F6DD19E006346C4 /* RSCore.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSCore.xcodeproj; path = ../RSCore/RSCore.xcodeproj; sourceTree = ""; }; 841973F41F6DD1AC006346C4 /* RSParser.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSParser.xcodeproj; path = ../RSParser/RSParser.xcodeproj; sourceTree = ""; }; 841974001F6DD1EC006346C4 /* Folder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Folder.swift; sourceTree = ""; }; @@ -222,6 +244,24 @@ path = JSON; sourceTree = ""; }; + 552032EA229D5D5A009559E0 /* GoogleReaderCompatible */ = { + isa = PBXGroup; + children = ( + 552032EB229D5D5A009559E0 /* GoogleReaderCompatibleDate.swift */, + 552032EC229D5D5A009559E0 /* GoogleReaderCompatibleIcon.swift */, + 552032ED229D5D5A009559E0 /* GoogleReaderCompatibleEntry.swift */, + 552032EE229D5D5A009559E0 /* GoogleReaderCompatibleSubscription.swift */, + 552032EF229D5D5A009559E0 /* GoogleReaderCompatibleStarredEntry.swift */, + 552032F0229D5D5A009559E0 /* GoogleReaderCompatibleTag.swift */, + 552032F1229D5D5A009559E0 /* GoogleReaderCompatibleUnreadEntry.swift */, + 552032F2229D5D5A009559E0 /* GoogleReaderCompatibleTagging.swift */, + 552032F3229D5D5A009559E0 /* GoogleReaderCompatibleAccountDelegate.swift */, + 552032F4229D5D5A009559E0 /* GoogleReaderCompatibleImportResult.swift */, + 552032F5229D5D5A009559E0 /* GoogleReaderCompatibleAPICaller.swift */, + ); + path = GoogleReaderCompatible; + sourceTree = ""; + }; 841973E91F6DD19E006346C4 /* Products */ = { isa = PBXGroup; children = ( @@ -302,6 +342,7 @@ 5165D71F22835E9800D9D53D /* FeedFinder */, 8419742B1F6DDE84006346C4 /* LocalAccount */, 84245C7D1FDDD2580074AFBB /* Feedbin */, + 552032EA229D5D5A009559E0 /* GoogleReaderCompatible */, 848935031F62484F00CEBD24 /* AccountTests */, 848934F71F62484F00CEBD24 /* Products */, 8469F80F1F6DC3C10084783E /* Frameworks */, @@ -515,14 +556,17 @@ buildActionMask = 2147483647; files = ( 84C8B3F41F89DE430053CCA6 /* DataExtensions.swift in Sources */, + 552032F9229D5D5A009559E0 /* GoogleReaderCompatibleSubscription.swift in Sources */, 84C3654A1F899F3B001EC85C /* CombinedRefreshProgress.swift in Sources */, 8469F81C1F6DD15E0084783E /* Account.swift in Sources */, 5144EA4E227B829A00D19003 /* FeedbinAccountDelegate.swift in Sources */, 51E5959B228C781500FCC42B /* FeedbinStarredEntry.swift in Sources */, 846E77451F6EF9B900A165E2 /* Container.swift in Sources */, + 552032FD229D5D5A009559E0 /* GoogleReaderCompatibleTagging.swift in Sources */, 84F73CF1202788D90000BCEF /* ArticleFetcher.swift in Sources */, 841974251F6DDCE4006346C4 /* AccountDelegate.swift in Sources */, 5165D73122837F3400D9D53D /* InitialFeedDownloader.swift in Sources */, + 552032FA229D5D5A009559E0 /* GoogleReaderCompatibleStarredEntry.swift in Sources */, 846E77541F6F00E300A165E2 /* AccountManager.swift in Sources */, 51E490362288C37100C791F0 /* FeedbinDate.swift in Sources */, 5165D72922835F7A00D9D53D /* FeedSpecifier.swift in Sources */, @@ -532,11 +576,19 @@ 5144EA49227B497600D19003 /* FeedbinAPICaller.swift in Sources */, 84B99C9F1FAE8D3200ECDEDB /* ContainerPath.swift in Sources */, 5133231122810EB200C30F19 /* FeedbinIcon.swift in Sources */, + 552032F6229D5D5A009559E0 /* GoogleReaderCompatibleDate.swift in Sources */, 846E77501F6EF9C400A165E2 /* LocalAccountRefresher.swift in Sources */, + 55203300229D5D5A009559E0 /* GoogleReaderCompatibleAPICaller.swift in Sources */, 51E3EB41229AF61B00645299 /* AccountError.swift in Sources */, 51E59599228C77BC00FCC42B /* FeedbinUnreadEntry.swift in Sources */, + 552032F8229D5D5A009559E0 /* GoogleReaderCompatibleEntry.swift in Sources */, + 552032FB229D5D5A009559E0 /* GoogleReaderCompatibleTag.swift in Sources */, 5165D72822835F7800D9D53D /* FeedFinder.swift in Sources */, + 552032F7229D5D5A009559E0 /* GoogleReaderCompatibleIcon.swift in Sources */, + 552032FF229D5D5A009559E0 /* GoogleReaderCompatibleImportResult.swift in Sources */, 51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */, + 552032FE229D5D5A009559E0 /* GoogleReaderCompatibleAccountDelegate.swift in Sources */, + 552032FC229D5D5A009559E0 /* GoogleReaderCompatibleUnreadEntry.swift in Sources */, 84D09623217418DC00D77525 /* FeedbinTagging.swift in Sources */, 84CAD7161FDF2E22000F0755 /* FeedbinEntry.swift in Sources */, 5165D72A22835F7D00D9D53D /* HTMLFeedFinder.swift in Sources */, diff --git a/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleAPICaller.swift b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleAPICaller.swift new file mode 100644 index 000000000..002da3d61 --- /dev/null +++ b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleAPICaller.swift @@ -0,0 +1,615 @@ +// +// GoogleReaderCompatibleAPICaller.swift +// Account +// +// Created by Maurice Parker on 5/2/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +// GoogleReaderCompatible currently has a maximum of 250 requests per second. If you begin to receive +// HTTP Response Codes of 403, you have exceeded this limit. Wait 5 minutes and your +// IP address will become unblocked and you can use the service again. + +import Foundation +import RSWeb + +enum CreateGoogleReaderSubscriptionResult { + case created(GoogleReaderCompatibleSubscription) + case multipleChoice([GoogleReaderCompatibleSubscriptionChoice]) + case alreadySubscribed + case notFound +} + +final class GoogleReaderCompatibleAPICaller: NSObject { + + struct ConditionalGetKeys { + static let subscriptions = "subscriptions" + static let tags = "tags" + static let taggings = "taggings" + static let icons = "icons" + static let unreadEntries = "unreadEntries" + static let starredEntries = "starredEntries" + } + + private let GoogleReaderCompatibleBaseURL = URL(string: "https://api.GoogleReaderCompatible.com/v2/")! + private var transport: Transport! + + var credentials: Credentials? + var apiAuthToken: String? + weak var accountMetadata: AccountMetadata? + + init(transport: Transport) { + super.init() + self.transport = transport + } + + func validateCredentials(completion: @escaping (Result) -> Void) { + guard let credentials = credentials else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + guard case .googleLogin(let username, let password, let apiUrl, _) = credentials else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + guard var loginURL = URLComponents(url: apiUrl.appendingPathComponent("/accounts/ClientLogin"), resolvingAgainstBaseURL: false) else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + loginURL.queryItems = [ + URLQueryItem(name: "Email", value: username), + URLQueryItem(name: "Passwd", value: password) + ] + + guard let callURL = loginURL.url else { + completion(.failure(CredentialsError.incompleteCredentials)) + return + } + + let request = URLRequest(url: callURL, credentials: credentials) + + transport.send(request: request) { result in + switch result { + case .success(let (_, data)): + guard let resultData = data else { + completion(.failure(TransportError.noData)) + break + } + + // Convert the return data to UTF8 and then parse out the Auth token + guard let rawData = String(data: resultData, encoding: .utf8) else { + completion(.failure(TransportError.noData)) + break + } + + var authData: [String: String] = [:] + rawData.split(separator: "\n").forEach({ (line: Substring) in + let items = line.split(separator: "=").map{String($0)} + authData[items[0]] = items[1] + }) + + guard let authString = authData["Auth"] else { + completion(.failure(CredentialsError.incompleteCredentials)) + break + } + + // Save Auth Token for later use + self.apiAuthToken = authString + + completion(.success(true)) + case .failure(let error): + switch error { + case TransportError.httpError(let status): + if status == 401 { + completion(.success(false)) + } else { + completion(.failure(error)) + } + default: + completion(.failure(error)) + } + } + } + + } + + func importOPML(opmlData: Data, completion: @escaping (Result) -> Void) { + + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("imports.json") + var request = URLRequest(url: callURL, credentials: credentials) + request.addValue("text/xml; charset=utf-8", forHTTPHeaderField: HTTPRequestHeader.contentType) + + transport.send(request: request, method: HTTPMethod.post, payload: opmlData) { result in + + switch result { + case .success(let (_, data)): + + guard let resultData = data else { + completion(.failure(TransportError.noData)) + break + } + + do { + let result = try JSONDecoder().decode(GoogleReaderCompatibleImportResult.self, from: resultData) + completion(.success(result)) + } catch { + completion(.failure(error)) + } + + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + func retrieveOPMLImportResult(importID: Int, completion: @escaping (Result) -> Void) { + + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("imports/\(importID).json") + let request = URLRequest(url: callURL, credentials: credentials) + + transport.send(request: request, resultType: GoogleReaderCompatibleImportResult.self) { result in + + switch result { + case .success(let (_, importResult)): + completion(.success(importResult)) + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + func retrieveTags(completion: @escaping (Result<[GoogleReaderCompatibleTag]?, Error>) -> Void) { + + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("tags.json") + let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.tags] + let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet) + + transport.send(request: request, resultType: [GoogleReaderCompatibleTag].self) { result in + + switch result { + case .success(let (response, tags)): + self.storeConditionalGet(key: ConditionalGetKeys.tags, headers: response.allHeaderFields) + completion(.success(tags)) + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + func renameTag(oldName: String, newName: String, completion: @escaping (Result) -> Void) { + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("tags.json") + let request = URLRequest(url: callURL, credentials: credentials) + let payload = GoogleReaderCompatibleRenameTag(oldName: oldName, newName: newName) + transport.send(request: request, method: HTTPMethod.post, payload: payload, completion: completion) + } + + func deleteTag(name: String, completion: @escaping (Result<[GoogleReaderCompatibleTagging]?, Error>) -> Void) { + + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("tags.json") + let request = URLRequest(url: callURL, credentials: credentials) + let payload = GoogleReaderCompatibleDeleteTag(name: name) + + transport.send(request: request, method: HTTPMethod.delete, payload: payload, resultType: [GoogleReaderCompatibleTagging].self) { result in + + switch result { + case .success(let (_, taggings)): + completion(.success(taggings)) + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + func retrieveSubscriptions(completion: @escaping (Result<[GoogleReaderCompatibleSubscription]?, Error>) -> Void) { + + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("subscriptions.json") + let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.subscriptions] + let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet) + + transport.send(request: request, resultType: [GoogleReaderCompatibleSubscription].self) { result in + + switch result { + case .success(let (response, subscriptions)): + self.storeConditionalGet(key: ConditionalGetKeys.subscriptions, headers: response.allHeaderFields) + completion(.success(subscriptions)) + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + func createSubscription(url: String, completion: @escaping (Result) -> Void) { + + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("subscriptions.json") + var request = URLRequest(url: callURL, credentials: credentials) + request.addValue("application/json; charset=utf-8", forHTTPHeaderField: HTTPRequestHeader.contentType) + + let payload: Data + do { + payload = try JSONEncoder().encode(GoogleReaderCompatibleCreateSubscription(feedURL: url)) + } catch { + completion(.failure(error)) + return + } + + transport.send(request: request, method: HTTPMethod.post, payload: payload) { result in + + switch result { + case .success(let (response, data)): + + switch response.forcedStatusCode { + case 201: + guard let subData = data else { + completion(.failure(TransportError.noData)) + break + } + do { + let subscription = try JSONDecoder().decode(GoogleReaderCompatibleSubscription.self, from: subData) + completion(.success(.created(subscription))) + } catch { + completion(.failure(error)) + } + case 300: + guard let subData = data else { + completion(.failure(TransportError.noData)) + break + } + do { + let subscriptions = try JSONDecoder().decode([GoogleReaderCompatibleSubscriptionChoice].self, from: subData) + completion(.success(.multipleChoice(subscriptions))) + } catch { + completion(.failure(error)) + } + case 302: + completion(.success(.alreadySubscribed)) + default: + completion(.failure(TransportError.httpError(status: response.forcedStatusCode))) + } + + case .failure(let error): + + switch error { + case TransportError.httpError(let status): + switch status { + case 401: + // I don't know why we get 401's here. This looks like a GoogleReaderCompatible bug, but it only happens + // when you are already subscribed to the feed. + completion(.success(.alreadySubscribed)) + case 404: + completion(.success(.notFound)) + default: + completion(.failure(error)) + } + default: + completion(.failure(error)) + } + + } + + } + + } + + func renameSubscription(subscriptionID: String, newName: String, completion: @escaping (Result) -> Void) { + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("subscriptions/\(subscriptionID)/update.json") + let request = URLRequest(url: callURL, credentials: credentials) + let payload = GoogleReaderCompatibleUpdateSubscription(title: newName) + transport.send(request: request, method: HTTPMethod.post, payload: payload, completion: completion) + } + + func deleteSubscription(subscriptionID: String, completion: @escaping (Result) -> Void) { + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("subscriptions/\(subscriptionID).json") + let request = URLRequest(url: callURL, credentials: credentials) + transport.send(request: request, method: HTTPMethod.delete, completion: completion) + } + + func retrieveTaggings(completion: @escaping (Result<[GoogleReaderCompatibleTagging]?, Error>) -> Void) { + + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("taggings.json") + let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.taggings] + let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet) + + transport.send(request: request, resultType: [GoogleReaderCompatibleTagging].self) { result in + + switch result { + case .success(let (response, taggings)): + self.storeConditionalGet(key: ConditionalGetKeys.taggings, headers: response.allHeaderFields) + completion(.success(taggings)) + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + func createTagging(feedID: Int, name: String, completion: @escaping (Result) -> Void) { + + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("taggings.json") + var request = URLRequest(url: callURL, credentials: credentials) + request.addValue("application/json; charset=utf-8", forHTTPHeaderField: HTTPRequestHeader.contentType) + + let payload: Data + do { + payload = try JSONEncoder().encode(GoogleReaderCompatibleCreateTagging(feedID: feedID, name: name)) + } catch { + completion(.failure(error)) + return + } + + transport.send(request: request, method: HTTPMethod.post, payload:payload) { result in + + switch result { + case .success(let (response, _)): + if let taggingLocation = response.valueForHTTPHeaderField(HTTPResponseHeader.location), + let lowerBound = taggingLocation.range(of: "v2/taggings/")?.upperBound, + let upperBound = taggingLocation.range(of: ".json")?.lowerBound, + let taggingID = Int(taggingLocation[lowerBound..) -> Void) { + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("taggings/\(taggingID).json") + var request = URLRequest(url: callURL, credentials: credentials) + request.addValue("application/json; charset=utf-8", forHTTPHeaderField: HTTPRequestHeader.contentType) + transport.send(request: request, method: HTTPMethod.delete, completion: completion) + } + + func retrieveIcons(completion: @escaping (Result<[GoogleReaderCompatibleIcon]?, Error>) -> Void) { + + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("icons.json") + let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.icons] + let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet) + + transport.send(request: request, resultType: [GoogleReaderCompatibleIcon].self) { result in + + switch result { + case .success(let (response, icons)): + self.storeConditionalGet(key: ConditionalGetKeys.icons, headers: response.allHeaderFields) + completion(.success(icons)) + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + func retrieveEntries(articleIDs: [String], completion: @escaping (Result<([GoogleReaderCompatibleEntry]?), Error>) -> Void) { + + guard !articleIDs.isEmpty else { + completion(.success(([GoogleReaderCompatibleEntry]()))) + return + } + + let concatIDs = articleIDs.reduce("") { param, articleID in return param + ",\(articleID)" } + let paramIDs = String(concatIDs.dropFirst()) + + var callComponents = URLComponents(url: GoogleReaderCompatibleBaseURL.appendingPathComponent("entries.json"), resolvingAgainstBaseURL: false)! + callComponents.queryItems = [URLQueryItem(name: "ids", value: paramIDs), URLQueryItem(name: "mode", value: "extended")] + let request = URLRequest(url: callComponents.url!, credentials: credentials) + + transport.send(request: request, resultType: [GoogleReaderCompatibleEntry].self) { result in + + switch result { + case .success(let (_, entries)): + completion(.success((entries))) + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + func retrieveEntries(feedID: String, completion: @escaping (Result<([GoogleReaderCompatibleEntry]?, String?), Error>) -> Void) { + + let since = Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date() + let sinceString = GoogleReaderCompatibleDate.formatter.string(from: since) + + var callComponents = URLComponents(url: GoogleReaderCompatibleBaseURL.appendingPathComponent("feeds/\(feedID)/entries.json"), resolvingAgainstBaseURL: false)! + callComponents.queryItems = [URLQueryItem(name: "since", value: sinceString), URLQueryItem(name: "per_page", value: "100"), URLQueryItem(name: "mode", value: "extended")] + let request = URLRequest(url: callComponents.url!, credentials: credentials) + + transport.send(request: request, resultType: [GoogleReaderCompatibleEntry].self) { result in + + switch result { + case .success(let (response, entries)): + + let pagingInfo = HTTPLinkPagingInfo(urlResponse: response) + completion(.success((entries, pagingInfo.nextPage))) + + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + func retrieveEntries(completion: @escaping (Result<([GoogleReaderCompatibleEntry]?, String?, Int?), Error>) -> Void) { + + let since: Date = { + if let lastArticleFetch = accountMetadata?.lastArticleFetch { + return lastArticleFetch + } else { + return Calendar.current.date(byAdding: .month, value: -3, to: Date()) ?? Date() + } + }() + + let sinceString = GoogleReaderCompatibleDate.formatter.string(from: since) + var callComponents = URLComponents(url: GoogleReaderCompatibleBaseURL.appendingPathComponent("entries.json"), resolvingAgainstBaseURL: false)! + callComponents.queryItems = [URLQueryItem(name: "since", value: sinceString), URLQueryItem(name: "per_page", value: "100"), URLQueryItem(name: "mode", value: "extended")] + let request = URLRequest(url: callComponents.url!, credentials: credentials) + + transport.send(request: request, resultType: [GoogleReaderCompatibleEntry].self) { result in + + switch result { + case .success(let (response, entries)): + + let dateInfo = HTTPDateInfo(urlResponse: response) + self.accountMetadata?.lastArticleFetch = dateInfo?.date + + let pagingInfo = HTTPLinkPagingInfo(urlResponse: response) + let lastPageNumber = self.extractPageNumber(link: pagingInfo.lastPage) + completion(.success((entries, pagingInfo.nextPage, lastPageNumber))) + + case .failure(let error): + self.accountMetadata?.lastArticleFetch = nil + completion(.failure(error)) + } + + } + + } + + func retrieveEntries(page: String, completion: @escaping (Result<([GoogleReaderCompatibleEntry]?, String?), Error>) -> Void) { + + guard let url = URL(string: page), var callComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + completion(.success((nil, nil))) + return + } + + callComponents.queryItems?.append(URLQueryItem(name: "mode", value: "extended")) + let request = URLRequest(url: callComponents.url!, credentials: credentials) + + transport.send(request: request, resultType: [GoogleReaderCompatibleEntry].self) { result in + + switch result { + case .success(let (response, entries)): + + let pagingInfo = HTTPLinkPagingInfo(urlResponse: response) + completion(.success((entries, pagingInfo.nextPage))) + + case .failure(let error): + self.accountMetadata?.lastArticleFetch = nil + completion(.failure(error)) + } + + } + + } + + func retrieveUnreadEntries(completion: @escaping (Result<[Int]?, Error>) -> Void) { + + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("unread_entries.json") + let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.unreadEntries] + let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet) + + transport.send(request: request, resultType: [Int].self) { result in + + switch result { + case .success(let (response, unreadEntries)): + self.storeConditionalGet(key: ConditionalGetKeys.unreadEntries, headers: response.allHeaderFields) + completion(.success(unreadEntries)) + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + func createUnreadEntries(entries: [Int], completion: @escaping (Result) -> Void) { + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("unread_entries.json") + let request = URLRequest(url: callURL, credentials: credentials) + let payload = GoogleReaderCompatibleUnreadEntry(unreadEntries: entries) + transport.send(request: request, method: HTTPMethod.post, payload: payload, completion: completion) + } + + func deleteUnreadEntries(entries: [Int], completion: @escaping (Result) -> Void) { + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("unread_entries.json") + let request = URLRequest(url: callURL, credentials: credentials) + let payload = GoogleReaderCompatibleUnreadEntry(unreadEntries: entries) + transport.send(request: request, method: HTTPMethod.delete, payload: payload, completion: completion) + } + + func retrieveStarredEntries(completion: @escaping (Result<[Int]?, Error>) -> Void) { + + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("starred_entries.json") + let conditionalGet = accountMetadata?.conditionalGetInfo[ConditionalGetKeys.starredEntries] + let request = URLRequest(url: callURL, credentials: credentials, conditionalGet: conditionalGet) + + transport.send(request: request, resultType: [Int].self) { result in + + switch result { + case .success(let (response, starredEntries)): + self.storeConditionalGet(key: ConditionalGetKeys.starredEntries, headers: response.allHeaderFields) + completion(.success(starredEntries)) + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + func createStarredEntries(entries: [Int], completion: @escaping (Result) -> Void) { + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("starred_entries.json") + let request = URLRequest(url: callURL, credentials: credentials) + let payload = GoogleReaderCompatibleStarredEntry(starredEntries: entries) + transport.send(request: request, method: HTTPMethod.post, payload: payload, completion: completion) + } + + func deleteStarredEntries(entries: [Int], completion: @escaping (Result) -> Void) { + let callURL = GoogleReaderCompatibleBaseURL.appendingPathComponent("starred_entries.json") + let request = URLRequest(url: callURL, credentials: credentials) + let payload = GoogleReaderCompatibleStarredEntry(starredEntries: entries) + transport.send(request: request, method: HTTPMethod.delete, payload: payload, completion: completion) + } + +} + +// MARK: Private + +extension GoogleReaderCompatibleAPICaller { + + func storeConditionalGet(key: String, headers: [AnyHashable : Any]) { + if var conditionalGet = accountMetadata?.conditionalGetInfo { + conditionalGet[key] = HTTPConditionalGetInfo(headers: headers) + accountMetadata?.conditionalGetInfo = conditionalGet + } + } + + func extractPageNumber(link: String?) -> Int? { + + guard let link = link else { + return nil + } + + if let lowerBound = link.range(of: "page=")?.upperBound { + if let upperBound = link.range(of: "&")?.lowerBound { + return Int(link[lowerBound..")?.lowerBound { + return Int(link[lowerBound..) -> Void) { + + refreshProgress.addToNumberOfTasksAndRemaining(6) + + refreshAccount(account) { result in + switch result { + case .success(): + + self.refreshArticles(account) { + self.refreshArticleStatus(for: account) { + self.refreshMissingArticles(account) { + self.refreshProgress.clear() + DispatchQueue.main.async { + completion(.success(())) + } + } + } + } + + case .failure(let error): + DispatchQueue.main.async { + self.refreshProgress.clear() + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + + } + + } + + func sendArticleStatus(for account: Account, completion: @escaping (() -> Void)) { + + os_log(.debug, log: log, "Sending article statuses...") + + let syncStatuses = database.selectForProcessing() + let createUnreadStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.read && $0.flag == false } + let deleteUnreadStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.read && $0.flag == true } + let createStarredStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.starred && $0.flag == true } + let deleteStarredStatuses = syncStatuses.filter { $0.key == ArticleStatus.Key.starred && $0.flag == false } + + let group = DispatchGroup() + + group.enter() + sendArticleStatuses(createUnreadStatuses, apiCall: caller.createUnreadEntries) { + group.leave() + } + + group.enter() + sendArticleStatuses(deleteUnreadStatuses, apiCall: caller.deleteUnreadEntries) { + group.leave() + } + + group.enter() + sendArticleStatuses(createStarredStatuses, apiCall: caller.createStarredEntries) { + group.leave() + } + + group.enter() + sendArticleStatuses(deleteStarredStatuses, apiCall: caller.deleteStarredEntries) { + group.leave() + } + + group.notify(queue: DispatchQueue.main) { + os_log(.debug, log: self.log, "Done sending article statuses.") + completion() + } + + } + + func refreshArticleStatus(for account: Account, completion: @escaping (() -> Void)) { + + os_log(.debug, log: log, "Refreshing article statuses...") + + let group = DispatchGroup() + + group.enter() + caller.retrieveUnreadEntries() { result in + switch result { + case .success(let articleIDs): + self.syncArticleReadState(account: account, articleIDs: articleIDs) + group.leave() + case .failure(let error): + os_log(.info, log: self.log, "Retrieving unread entries failed: %@.", error.localizedDescription) + group.leave() + } + + } + + group.enter() + caller.retrieveStarredEntries() { result in + switch result { + case .success(let articleIDs): + self.syncArticleStarredState(account: account, articleIDs: articleIDs) + group.leave() + case .failure(let error): + os_log(.info, log: self.log, "Retrieving starred entries failed: %@.", error.localizedDescription) + group.leave() + } + + } + + group.notify(queue: DispatchQueue.main) { + os_log(.debug, log: self.log, "Done refreshing article statuses.") + completion() + } + + } + + func importOPML(for account:Account, opmlFile: URL, completion: @escaping (Result) -> Void) { + + var fileData: Data? + + do { + fileData = try Data(contentsOf: opmlFile) + } catch { + completion(.failure(error)) + return + } + + guard let opmlData = fileData else { + completion(.success(())) + return + } + + os_log(.debug, log: log, "Begin importing OPML...") + opmlImportInProgress = true + + caller.importOPML(opmlData: opmlData) { result in + switch result { + case .success(let importResult): + if importResult.complete { + os_log(.debug, log: self.log, "Import OPML done.") + self.opmlImportInProgress = false + DispatchQueue.main.async { + completion(.success(())) + } + } else { + self.checkImportResult(opmlImportResultID: importResult.importResultID, completion: completion) + } + case .failure(let error): + os_log(.debug, log: self.log, "Import OPML failed.") + self.opmlImportInProgress = false + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + + } + + func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result) -> Void) { + + caller.renameTag(oldName: folder.name ?? "", newName: name) { result in + switch result { + case .success: + DispatchQueue.main.async { + folder.name = name + completion(.success(())) + } + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + + } + + func deleteFolder(for account: Account, with folder: Folder, completion: @escaping (Result) -> Void) { + + // GoogleReaderCompatible uses tags and if at least one feed isn't tagged, then the folder doesn't exist on their system + guard folder.hasAtLeastOneFeed() else { + account.deleteFolder(folder) + return + } + + // After we successfully delete at GoogleReaderCompatible, we add all the feeds to the account to save them. We then + // delete the folder. We then sync the taggings we received on the delete to remove any feeds from + // the account that might be in another folder. + caller.deleteTag(name: folder.name ?? "") { result in + switch result { + case .success(let taggings): + DispatchQueue.main.sync { + BatchUpdate.shared.perform { + for feed in folder.topLevelFeeds { + account.addFeed(feed) + self.clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") + } + account.deleteFolder(folder) + } + completion(.success(())) + } + self.syncTaggings(account, taggings) + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + + } + + func createFeed(for account: Account, url: String, completion: @escaping (Result) -> Void) { + + caller.createSubscription(url: url) { result in + switch result { + case .success(let subResult): + switch subResult { + case .created(let subscription): + self.createFeed(account: account, subscription: subscription, completion: completion) + case .multipleChoice(let choices): + self.decideBestFeedChoice(account: account, url: url, choices: choices, completion: completion) + case .alreadySubscribed: + DispatchQueue.main.async { + completion(.failure(AccountError.createErrorAlreadySubscribed)) + } + case .notFound: + DispatchQueue.main.async { + completion(.failure(AccountError.createErrorNotFound)) + } + } + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + + } + + } + + func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result) -> Void) { + + // This error should never happen + guard let subscriptionID = feed.subscriptionID else { + completion(.failure(GoogleReaderCompatibleAccountDelegateError.invalidParameter)) + return + } + + caller.renameSubscription(subscriptionID: subscriptionID, newName: name) { result in + switch result { + case .success: + DispatchQueue.main.async { + feed.editedName = name + completion(.success(())) + } + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + + } + + func deleteFeed(for account: Account, with feed: Feed, completion: @escaping (Result) -> Void) { + + // This error should never happen + guard let subscriptionID = feed.subscriptionID else { + completion(.failure(GoogleReaderCompatibleAccountDelegateError.invalidParameter)) + return + } + + caller.deleteSubscription(subscriptionID: subscriptionID) { result in + switch result { + case .success: + DispatchQueue.main.async { + account.removeFeed(feed) + if let folders = account.folders { + for folder in folders { + folder.removeFeed(feed) + } + } + completion(.success(())) + } + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + + } + + func addFeed(for account: Account, to container: Container, with feed: Feed, completion: @escaping (Result) -> Void) { + + if let folder = container as? Folder, let feedID = Int(feed.feedID) { + caller.createTagging(feedID: feedID, name: folder.name ?? "") { result in + switch result { + case .success(let taggingID): + DispatchQueue.main.async { + self.saveFolderRelationship(for: feed, withFolderName: folder.name ?? "", id: String(taggingID)) + folder.addFeed(feed) + completion(.success(())) + } + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + } else { + if let account = container as? Account { + account.addFeed(feed) + } + DispatchQueue.main.async { + completion(.success(())) + } + } + + } + + func removeFeed(for account: Account, from container: Container, with feed: Feed, completion: @escaping (Result) -> Void) { + + if let folder = container as? Folder, let feedTaggingID = feed.folderRelationship?[folder.name ?? ""] { + caller.deleteTagging(taggingID: feedTaggingID) { result in + switch result { + case .success: + DispatchQueue.main.async { + folder.removeFeed(feed) + completion(.success(())) + } + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + } else { + if let account = container as? Account { + account.removeFeed(feed) + } + completion(.success(())) + } + + } + + func restoreFeed(for account: Account, feed: Feed, folder: Folder?, completion: @escaping (Result) -> Void) { + + let editedName = feed.editedName + + createFeed(for: account, url: feed.url) { result in + switch result { + case .success(let feed): + self.processRestoredFeed(for: account, feed: feed, editedName: editedName, folder: folder, completion: completion) + case .failure(let error): + DispatchQueue.main.async { + let wrappedError = AccountError.wrappedError(error: error, account: account) + completion(.failure(wrappedError)) + } + } + } + + } + + func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result) -> Void) { + + account.addFolder(folder) + let group = DispatchGroup() + + for feed in folder.topLevelFeeds { + + group.enter() + addFeed(for: account, to: folder, with: feed) { result in + if account.topLevelFeeds.contains(feed) { + account.removeFeed(feed) + } + group.leave() + } + + } + + group.notify(queue: DispatchQueue.main) { + completion(.success(())) + } + + } + + func markArticles(for account: Account, articles: Set
, statusKey: ArticleStatus.Key, flag: Bool) -> Set
? { + + let syncStatuses = articles.map { article in + return SyncStatus(articleID: article.articleID, key: statusKey, flag: flag) + } + database.insertStatuses(syncStatuses) + + if database.selectPendingCount() > 100 { + sendArticleStatus(for: account) {} + } + + return account.update(articles, statusKey: statusKey, flag: flag) + + } + + func accountDidInitialize(_ account: Account) { + credentials = try? account.retrieveBasicCredentials() + accountMetadata = account.metadata + } + + static func validateCredentials(transport: Transport, credentials: Credentials, completion: @escaping (Result) -> Void) { + + let caller = GoogleReaderCompatibleAPICaller(transport: transport) + caller.credentials = credentials + caller.validateCredentials() { result in + DispatchQueue.main.async { + completion(result) + } + } + + } + +} + +// MARK: Private + +private extension GoogleReaderCompatibleAccountDelegate { + + func refreshAccount(_ account: Account, completion: @escaping (Result) -> Void) { + + caller.retrieveTags { result in + switch result { + case .success(let tags): + BatchUpdate.shared.perform { + self.syncFolders(account, tags) + } + self.refreshProgress.completeTask() + self.refreshFeeds(account, completion: completion) + case .failure(let error): + completion(.failure(error)) + } + } + + } + + func checkImportResult(opmlImportResultID: Int, completion: @escaping (Result) -> Void) { + + DispatchQueue.main.async { + + Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { timer in + + os_log(.debug, log: self.log, "Checking status of OPML import...") + + self.caller.retrieveOPMLImportResult(importID: opmlImportResultID) { result in + switch result { + case .success(let importResult): + if let result = importResult, result.complete { + os_log(.debug, log: self.log, "Checking status of OPML import successfully completed.") + timer.invalidate() + self.opmlImportInProgress = false + DispatchQueue.main.async { + completion(.success(())) + } + } + case .failure(let error): + os_log(.debug, log: self.log, "Import OPML check failed.") + timer.invalidate() + self.opmlImportInProgress = false + DispatchQueue.main.async { + completion(.failure(error)) + } + } + } + + } + + } + + } + + func syncFolders(_ account: Account, _ tags: [GoogleReaderCompatibleTag]?) { + + guard let tags = tags else { return } + + os_log(.debug, log: log, "Syncing folders with %ld tags.", tags.count) + + let tagNames = tags.map { $0.name } + + // Delete any folders not at GoogleReaderCompatible + if let folders = account.folders { + folders.forEach { folder in + if !tagNames.contains(folder.name ?? "") { + DispatchQueue.main.sync { + for feed in folder.topLevelFeeds { + account.addFeed(feed) + clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") + } + account.deleteFolder(folder) + } + } + } + } + + let folderNames: [String] = { + if let folders = account.folders { + return folders.map { $0.name ?? "" } + } else { + return [String]() + } + }() + + // Make any folders GoogleReaderCompatible has, but we don't + tagNames.forEach { tagName in + if !folderNames.contains(tagName) { + DispatchQueue.main.sync { + _ = account.ensureFolder(with: tagName) + } + } + } + + } + + func refreshFeeds(_ account: Account, completion: @escaping (Result) -> Void) { + + caller.retrieveSubscriptions { result in + switch result { + case .success(let subscriptions): + + self.refreshProgress.completeTask() + self.caller.retrieveTaggings { result in + switch result { + case .success(let taggings): + + self.refreshProgress.completeTask() + self.caller.retrieveIcons { result in + switch result { + case .success(let icons): + + BatchUpdate.shared.perform { + self.syncFeeds(account, subscriptions) + self.syncTaggings(account, taggings) + self.syncFavicons(account, icons) + } + + self.refreshProgress.completeTask() + completion(.success(())) + + case .failure(let error): + completion(.failure(error)) + } + + } + + case .failure(let error): + completion(.failure(error)) + } + + } + + case .failure(let error): + completion(.failure(error)) + } + + } + + } + + func syncFeeds(_ account: Account, _ subscriptions: [GoogleReaderCompatibleSubscription]?) { + + guard let subscriptions = subscriptions else { return } + + os_log(.debug, log: log, "Syncing feeds with %ld subscriptions.", subscriptions.count) + + let subFeedIds = subscriptions.map { String($0.feedID) } + + // Remove any feeds that are no longer in the subscriptions + if let folders = account.folders { + for folder in folders { + for feed in folder.topLevelFeeds { + if !subFeedIds.contains(feed.feedID) { + DispatchQueue.main.sync { + folder.removeFeed(feed) + } + } + } + } + } + + for feed in account.topLevelFeeds { + if !subFeedIds.contains(feed.feedID) { + DispatchQueue.main.sync { + account.removeFeed(feed) + } + } + } + + // Add any feeds we don't have and update any we do + subscriptions.forEach { subscription in + + let subFeedId = String(subscription.feedID) + + DispatchQueue.main.sync { + if let feed = account.idToFeedDictionary[subFeedId] { + feed.name = subscription.name + feed.homePageURL = subscription.homePageURL + } else { + let feed = account.createFeed(with: subscription.name, url: subscription.url, feedID: subFeedId, homePageURL: subscription.homePageURL) + feed.subscriptionID = String(subscription.subscriptionID) + account.addFeed(feed) + } + } + + } + + } + + func syncTaggings(_ account: Account, _ taggings: [GoogleReaderCompatibleTagging]?) { + + guard let taggings = taggings else { return } + + os_log(.debug, log: log, "Syncing taggings with %ld taggings.", taggings.count) + + // Set up some structures to make syncing easier + let folderDict: [String: Folder] = { + if let folders = account.folders { + return Dictionary(uniqueKeysWithValues: folders.map { ($0.name ?? "", $0) } ) + } else { + return [String: Folder]() + } + }() + + let taggingsDict = taggings.reduce([String: [GoogleReaderCompatibleTagging]]()) { (dict, tagging) in + var taggedFeeds = dict + if var taggedFeed = taggedFeeds[tagging.name] { + taggedFeed.append(tagging) + taggedFeeds[tagging.name] = taggedFeed + } else { + taggedFeeds[tagging.name] = [tagging] + } + return taggedFeeds + } + + // Sync the folders + for (folderName, groupedTaggings) in taggingsDict { + + guard let folder = folderDict[folderName] else { return } + + let taggingFeedIDs = groupedTaggings.map { String($0.feedID) } + + // Move any feeds not in the folder to the account + for feed in folder.topLevelFeeds { + if !taggingFeedIDs.contains(feed.feedID) { + DispatchQueue.main.sync { + folder.removeFeed(feed) + clearFolderRelationship(for: feed, withFolderName: folder.name ?? "") + account.addFeed(feed) + } + } + } + + // Add any feeds not in the folder + let folderFeedIds = folder.topLevelFeeds.map { $0.feedID } + + for tagging in groupedTaggings { + let taggingFeedID = String(tagging.feedID) + if !folderFeedIds.contains(taggingFeedID) { + guard let feed = account.idToFeedDictionary[taggingFeedID] else { + continue + } + DispatchQueue.main.sync { + saveFolderRelationship(for: feed, withFolderName: folderName, id: String(tagging.taggingID)) + folder.addFeed(feed) + } + } + } + + } + + let taggedFeedIDs = Set(taggings.map { String($0.feedID) }) + + // Remove all feeds from the account container that have a tag + DispatchQueue.main.sync { + for feed in account.topLevelFeeds { + if taggedFeedIDs.contains(feed.feedID) { + account.removeFeed(feed) + } + } + } + + } + + func syncFavicons(_ account: Account, _ icons: [GoogleReaderCompatibleIcon]?) { + + guard let icons = icons else { return } + + os_log(.debug, log: log, "Syncing favicons with %ld icons.", icons.count) + + let iconDict = Dictionary(uniqueKeysWithValues: icons.map { ($0.host, $0.url) } ) + + for feed in account.flattenedFeeds() { + for (key, value) in iconDict { + if feed.homePageURL?.contains(key) ?? false { + DispatchQueue.main.sync { + feed.faviconURL = value + } + break + } + } + } + + } + + + func sendArticleStatuses(_ statuses: [SyncStatus], + apiCall: ([Int], @escaping (Result) -> Void) -> Void, + completion: @escaping (() -> Void)) { + + guard !statuses.isEmpty else { + completion() + return + } + + let group = DispatchGroup() + + let articleIDs = statuses.compactMap { Int($0.articleID) } + let articleIDGroups = articleIDs.chunked(into: 1000) + for articleIDGroup in articleIDGroups { + + group.enter() + apiCall(articleIDGroup) { result in + switch result { + case .success: + self.database.deleteSelectedForProcessing(articleIDGroup.map { String($0) } ) + group.leave() + case .failure(let error): + os_log(.error, log: self.log, "Article status sync call failed: %@.", error.localizedDescription) + self.database.resetSelectedForProcessing(articleIDGroup.map { String($0) } ) + group.leave() + } + } + + } + + group.notify(queue: DispatchQueue.main) { + completion() + } + + } + + func processRestoredFeed(for account: Account, feed: Feed, editedName: String?, folder: Folder?, completion: @escaping (Result) -> Void) { + + if let folder = folder { + + addFeed(for: account, to: folder, with: feed) { result in + + switch result { + case .success: + + if editedName != nil { + DispatchQueue.main.async { + account.removeFeed(feed) + folder.addFeed(feed) + } + self.processRestoredFeedName(for: account, feed: feed, editedName: editedName!, completion: completion) + } else { + DispatchQueue.main.async { + account.removeFeed(feed) + folder.addFeed(feed) + completion(.success(())) + } + } + + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + + } + + } else { + + DispatchQueue.main.async { + account.addFeed(feed) + } + + if editedName != nil { + processRestoredFeedName(for: account, feed: feed, editedName: editedName!, completion: completion) + } else { + DispatchQueue.main.async { + completion(.success(())) + } + } + + } + + } + + func processRestoredFeedName(for account: Account, feed: Feed, editedName: String, completion: @escaping (Result) -> Void) { + + renameFeed(for: account, with: feed, to: editedName) { result in + switch result { + case .success: + DispatchQueue.main.async { + feed.editedName = editedName + completion(.success(())) + } + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(error)) + } + } + + } + + } + + func clearFolderRelationship(for feed: Feed, withFolderName folderName: String) { + if var folderRelationship = feed.folderRelationship { + folderRelationship[folderName] = nil + feed.folderRelationship = folderRelationship + } + } + + func saveFolderRelationship(for feed: Feed, withFolderName folderName: String, id: String) { + if var folderRelationship = feed.folderRelationship { + folderRelationship[folderName] = id + feed.folderRelationship = folderRelationship + } else { + feed.folderRelationship = [folderName: id] + } + } + + func decideBestFeedChoice(account: Account, url: String, choices: [GoogleReaderCompatibleSubscriptionChoice], completion: @escaping (Result) -> Void) { + + let feedSpecifiers: [FeedSpecifier] = choices.map { choice in + let source = url == choice.url ? FeedSpecifier.Source.UserEntered : FeedSpecifier.Source.HTMLLink + let specifier = FeedSpecifier(title: choice.name, urlString: choice.url, source: source) + return specifier + } + + if let bestSpecifier = FeedSpecifier.bestFeed(in: Set(feedSpecifiers)) { + if let bestSubscription = choices.filter({ bestSpecifier.urlString == $0.url }).first { + createFeed(for: account, url: bestSubscription.url, completion: completion) + } else { + DispatchQueue.main.async { + completion(.failure(GoogleReaderCompatibleAccountDelegateError.invalidParameter)) + } + } + } else { + DispatchQueue.main.async { + completion(.failure(GoogleReaderCompatibleAccountDelegateError.invalidParameter)) + } + } + + } + + func createFeed( account: Account, subscription sub: GoogleReaderCompatibleSubscription, completion: @escaping (Result) -> Void) { + + DispatchQueue.main.async { + + let feed = account.createFeed(with: sub.name, url: sub.url, feedID: String(sub.feedID), homePageURL: sub.homePageURL) + feed.subscriptionID = String(sub.subscriptionID) + + // Download the initial articles + self.caller.retrieveEntries(feedID: feed.feedID) { result in + + switch result { + case .success(let (entries, page)): + + self.processEntries(account: account, entries: entries) { + self.refreshArticles(account, page: page) { + self.refreshArticleStatus(for: account) { + self.refreshMissingArticles(account) { + DispatchQueue.main.async { + completion(.success(feed)) + } + } + } + } + } + + case .failure(let error): + os_log(.error, log: self.log, "Initial articles download failed: %@.", error.localizedDescription) + DispatchQueue.main.async { + completion(.success(feed)) + } + } + + } + + } + + } + + func refreshArticles(_ account: Account, completion: @escaping (() -> Void)) { + + os_log(.debug, log: log, "Refreshing articles...") + + caller.retrieveEntries() { result in + + switch result { + case .success(let (entries, page, lastPageNumber)): + + if let last = lastPageNumber { + self.refreshProgress.addToNumberOfTasksAndRemaining(last - 1) + } + + self.processEntries(account: account, entries: entries) { + + self.refreshProgress.completeTask() + self.refreshArticles(account, page: page) { + os_log(.debug, log: self.log, "Done refreshing articles.") + completion() + } + + } + + case .failure(let error): + os_log(.error, log: self.log, "Refresh articles failed: %@.", error.localizedDescription) + completion() + } + + } + + } + + func refreshMissingArticles(_ account: Account, completion: @escaping (() -> Void)) { + + os_log(.debug, log: log, "Refreshing missing articles...") + let articleIDs = Array(account.fetchArticleIDsForStatusesWithoutArticles()) + + let group = DispatchGroup() + + let chunkedArticleIDs = articleIDs.chunked(into: 100) + + for chunk in chunkedArticleIDs { + + group.enter() + caller.retrieveEntries(articleIDs: chunk) { result in + + switch result { + case .success(let entries): + + self.processEntries(account: account, entries: entries) { + group.leave() + } + + case .failure(let error): + os_log(.error, log: self.log, "Refresh missing articles failed: %@.", error.localizedDescription) + group.leave() + } + + } + + } + + group.notify(queue: DispatchQueue.main) { + self.refreshProgress.completeTask() + os_log(.debug, log: self.log, "Done refreshing missing articles.") + completion() + } + + } + + func refreshArticles(_ account: Account, page: String?, completion: @escaping (() -> Void)) { + + guard let page = page else { + completion() + return + } + + caller.retrieveEntries(page: page) { result in + + switch result { + case .success(let (entries, nextPage)): + + self.processEntries(account: account, entries: entries) { + self.refreshProgress.completeTask() + self.refreshArticles(account, page: nextPage, completion: completion) + } + + case .failure(let error): + os_log(.error, log: self.log, "Refresh articles for additional pages failed: %@.", error.localizedDescription) + completion() + } + + } + + } + + func processEntries(account: Account, entries: [GoogleReaderCompatibleEntry]?, completion: @escaping (() -> Void)) { + + let parsedItems = mapEntriesToParsedItems(entries: entries) + let parsedMap = Dictionary(grouping: parsedItems, by: { item in item.feedURL } ) + + let group = DispatchGroup() + + for (feedID, mapItems) in parsedMap { + + group.enter() + + if let feed = account.idToFeedDictionary[feedID] { + DispatchQueue.main.async { + account.update(feed, parsedItems: Set(mapItems), defaultRead: true) { + group.leave() + } + } + } else { + group.leave() + } + + } + + group.notify(queue: DispatchQueue.main) { + completion() + } + + } + + func mapEntriesToParsedItems(entries: [GoogleReaderCompatibleEntry]?) -> Set { + + guard let entries = entries else { + return Set() + } + + let parsedItems: [ParsedItem] = entries.map { entry in + let authors = Set([ParsedAuthor(name: entry.authorName, url: entry.jsonFeed?.jsonFeedAuthor?.url, avatarURL: entry.jsonFeed?.jsonFeedAuthor?.avatarURL, emailAddress: nil)]) + return ParsedItem(syncServiceID: String(entry.articleID), uniqueID: String(entry.articleID), feedURL: String(entry.feedID), url: nil, externalURL: entry.url, title: entry.title, contentHTML: entry.contentHTML, contentText: nil, summary: entry.summary, imageURL: nil, bannerImageURL: nil, datePublished: entry.parseDatePublished(), dateModified: nil, authors: authors, tags: nil, attachments: nil) + } + + return Set(parsedItems) + + } + + func syncArticleReadState(account: Account, articleIDs: [Int]?) { + + guard let articleIDs = articleIDs else { + return + } + + let GoogleReaderCompatibleUnreadArticleIDs = Set(articleIDs.map { String($0) } ) + let currentUnreadArticleIDs = account.fetchUnreadArticleIDs() + + // Mark articles as unread + let deltaUnreadArticleIDs = GoogleReaderCompatibleUnreadArticleIDs.subtracting(currentUnreadArticleIDs) + let markUnreadArticles = account.fetchArticles(forArticleIDs: deltaUnreadArticleIDs) + DispatchQueue.main.async { + _ = account.update(markUnreadArticles, statusKey: .read, flag: false) + } + + // Save any unread statuses for articles we haven't yet received + let markUnreadArticleIDs = Set(markUnreadArticles.map { $0.articleID }) + let missingUnreadArticleIDs = deltaUnreadArticleIDs.subtracting(markUnreadArticleIDs) + if !missingUnreadArticleIDs.isEmpty { + DispatchQueue.main.async { + account.ensureStatuses(missingUnreadArticleIDs, .read, false) + } + } + + // Mark articles as read + let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(GoogleReaderCompatibleUnreadArticleIDs) + let markReadArticles = account.fetchArticles(forArticleIDs: deltaReadArticleIDs) + DispatchQueue.main.async { + _ = account.update(markReadArticles, statusKey: .read, flag: true) + } + + // Save any read statuses for articles we haven't yet received + let markReadArticleIDs = Set(markReadArticles.map { $0.articleID }) + let missingReadArticleIDs = deltaReadArticleIDs.subtracting(markReadArticleIDs) + if !missingReadArticleIDs.isEmpty { + DispatchQueue.main.async { + account.ensureStatuses(missingReadArticleIDs, .read, true) + } + } + + } + + func syncArticleStarredState(account: Account, articleIDs: [Int]?) { + + guard let articleIDs = articleIDs else { + return + } + + let GoogleReaderCompatibleStarredArticleIDs = Set(articleIDs.map { String($0) } ) + let currentStarredArticleIDs = account.fetchStarredArticleIDs() + + // Mark articles as starred + let deltaStarredArticleIDs = GoogleReaderCompatibleStarredArticleIDs.subtracting(currentStarredArticleIDs) + let markStarredArticles = account.fetchArticles(forArticleIDs: deltaStarredArticleIDs) + DispatchQueue.main.async { + _ = account.update(markStarredArticles, statusKey: .starred, flag: true) + } + + // Save any starred statuses for articles we haven't yet received + let markStarredArticleIDs = Set(markStarredArticles.map { $0.articleID }) + let missingStarredArticleIDs = deltaStarredArticleIDs.subtracting(markStarredArticleIDs) + if !missingStarredArticleIDs.isEmpty { + DispatchQueue.main.async { + account.ensureStatuses(missingStarredArticleIDs, .starred, true) + } + } + + // Mark articles as unstarred + let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(GoogleReaderCompatibleStarredArticleIDs) + let markUnstarredArticles = account.fetchArticles(forArticleIDs: deltaUnstarredArticleIDs) + DispatchQueue.main.async { + _ = account.update(markUnstarredArticles, statusKey: .starred, flag: false) + } + + // Save any unstarred statuses for articles we haven't yet received + let markUnstarredArticleIDs = Set(markUnstarredArticles.map { $0.articleID }) + let missingUnstarredArticleIDs = deltaUnstarredArticleIDs.subtracting(markUnstarredArticleIDs) + if !missingUnstarredArticleIDs.isEmpty { + DispatchQueue.main.async { + account.ensureStatuses(missingUnstarredArticleIDs, .starred, false) + } + } + + } + +} diff --git a/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleDate.swift b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleDate.swift new file mode 100644 index 000000000..c292eab35 --- /dev/null +++ b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleDate.swift @@ -0,0 +1,21 @@ +// +// GoogleReaderCompatibleDate.swift +// Account +// +// Created by Maurice Parker on 5/12/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct GoogleReaderCompatibleDate { + + public static var formatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'" + formatter.locale = Locale(identifier: "en_US") + formatter.timeZone = TimeZone(abbreviation: "GMT") + return formatter + }() + +} diff --git a/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleEntry.swift b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleEntry.swift new file mode 100644 index 000000000..24fabd9dc --- /dev/null +++ b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleEntry.swift @@ -0,0 +1,67 @@ +// +// GoogleReaderCompatibleArticle.swift +// Account +// +// Created by Brent Simmons on 12/11/17. +// Copyright © 2017 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSParser +import RSCore + +struct GoogleReaderCompatibleEntry: Codable { + + let articleID: Int + let feedID: Int + let title: String? + let url: String? + let authorName: String? + let contentHTML: String? + let summary: String? + let datePublished: String? + let dateArrived: String? + let jsonFeed: GoogleReaderCompatibleEntryJSONFeed? + + enum CodingKeys: String, CodingKey { + case articleID = "id" + case feedID = "feed_id" + case title = "title" + case url = "url" + case authorName = "author" + case contentHTML = "content" + case summary = "summary" + case datePublished = "published" + case dateArrived = "created_at" + case jsonFeed = "json_feed" + } + + // GoogleReaderCompatible dates can't be decoded by the JSONDecoding 8601 decoding strategy. GoogleReaderCompatible + // requires a very specific date formatter to work and even then it fails occasionally. + // Rather than loose all the entries we only lose the one date by decoding as a string + // and letting the one date fail when parsed. + func parseDatePublished() -> Date? { + if datePublished != nil { + return GoogleReaderCompatibleDate.formatter.date(from: datePublished!) + } else { + return nil + } + } + +} + +struct GoogleReaderCompatibleEntryJSONFeed: Codable { + let jsonFeedAuthor: GoogleReaderCompatibleEntryJSONFeedAuthor? + enum CodingKeys: String, CodingKey { + case jsonFeedAuthor = "author" + } +} + +struct GoogleReaderCompatibleEntryJSONFeedAuthor: Codable { + let url: String? + let avatarURL: String? + enum CodingKeys: String, CodingKey { + case url = "url" + case avatarURL = "avatar" + } +} diff --git a/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleIcon.swift b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleIcon.swift new file mode 100644 index 000000000..69b2fc5d7 --- /dev/null +++ b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleIcon.swift @@ -0,0 +1,21 @@ +// +// GoogleReaderCompatibleIcon.swift +// Account +// +// Created by Maurice Parker on 5/6/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct GoogleReaderCompatibleIcon: Codable { + + let host: String + let url: String + + enum CodingKeys: String, CodingKey { + case host + case url + } + +} diff --git a/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleImportResult.swift b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleImportResult.swift new file mode 100644 index 000000000..fbc6a45e9 --- /dev/null +++ b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleImportResult.swift @@ -0,0 +1,21 @@ +// +// GoogleReaderCompatibleImportResult.swift +// Account +// +// Created by Maurice Parker on 5/17/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct GoogleReaderCompatibleImportResult: Codable { + + let importResultID: Int + let complete: Bool + + enum CodingKeys: String, CodingKey { + case importResultID = "id" + case complete + } + +} diff --git a/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleStarredEntry.swift b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleStarredEntry.swift new file mode 100644 index 000000000..8036f7b63 --- /dev/null +++ b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleStarredEntry.swift @@ -0,0 +1,19 @@ +// +// GoogleReaderCompatibleStarredEntry.swift +// Account +// +// Created by Maurice Parker on 5/15/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct GoogleReaderCompatibleStarredEntry: Codable { + + let starredEntries: [Int] + + enum CodingKeys: String, CodingKey { + case starredEntries = "starred_entries" + } + +} diff --git a/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleSubscription.swift b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleSubscription.swift new file mode 100644 index 000000000..6850133fd --- /dev/null +++ b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleSubscription.swift @@ -0,0 +1,55 @@ +// +// GoogleReaderCompatibleFeed.swift +// Account +// +// Created by Brent Simmons on 12/10/17. +// Copyright © 2017 Ranchero Software, LLC. All rights reserved. +// + +import Foundation +import RSCore +import RSParser + +struct GoogleReaderCompatibleSubscription: Codable { + + let subscriptionID: Int + let feedID: Int + let name: String? + let url: String + let homePageURL: String? + + enum CodingKeys: String, CodingKey { + case subscriptionID = "id" + case feedID = "feed_id" + case name = "title" + case url = "feed_url" + case homePageURL = "site_url" + } + +} + +struct GoogleReaderCompatibleCreateSubscription: Codable { + let feedURL: String + enum CodingKeys: String, CodingKey { + case feedURL = "feed_url" + } +} + +struct GoogleReaderCompatibleUpdateSubscription: Codable { + let title: String + enum CodingKeys: String, CodingKey { + case title + } +} + +struct GoogleReaderCompatibleSubscriptionChoice: Codable { + + let name: String? + let url: String + + enum CodingKeys: String, CodingKey { + case name = "title" + case url = "feed_url" + } + +} diff --git a/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleTag.swift b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleTag.swift new file mode 100644 index 000000000..3daf9f0eb --- /dev/null +++ b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleTag.swift @@ -0,0 +1,43 @@ +// +// GoogleReaderCompatibleTag.swift +// Account +// +// Created by Maurice Parker on 5/5/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct GoogleReaderCompatibleTag: Codable { + + let tagID: Int + let name: String + + enum CodingKeys: String, CodingKey { + case tagID = "id" + case name = "name" + } + +} + +struct GoogleReaderCompatibleRenameTag: Codable { + + let oldName: String + let newName: String + + enum CodingKeys: String, CodingKey { + case oldName = "old_name" + case newName = "new_name" + } + +} + +struct GoogleReaderCompatibleDeleteTag: Codable { + + let name: String + + enum CodingKeys: String, CodingKey { + case name + } + +} diff --git a/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleTagging.swift b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleTagging.swift new file mode 100644 index 000000000..b3a0f57aa --- /dev/null +++ b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleTagging.swift @@ -0,0 +1,35 @@ +// +// GoogleReaderCompatibleTagging.swift +// Account +// +// Created by Brent Simmons on 10/14/18. +// Copyright © 2018 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct GoogleReaderCompatibleTagging: Codable { + + let taggingID: Int + let feedID: Int + let name: String + + enum CodingKeys: String, CodingKey { + case taggingID = "id" + case feedID = "feed_id" + case name = "name" + } + +} + +struct GoogleReaderCompatibleCreateTagging: Codable { + + let feedID: Int + let name: String + + enum CodingKeys: String, CodingKey { + case feedID = "feed_id" + case name = "name" + } + +} diff --git a/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleUnreadEntry.swift b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleUnreadEntry.swift new file mode 100644 index 000000000..a5ce66ae0 --- /dev/null +++ b/Frameworks/Account/GoogleReaderCompatible/GoogleReaderCompatibleUnreadEntry.swift @@ -0,0 +1,19 @@ +// +// GoogleReaderCompatibleUnreadEntry.swift +// Account +// +// Created by Maurice Parker on 5/15/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +struct GoogleReaderCompatibleUnreadEntry: Codable { + + let unreadEntries: [Int] + + enum CodingKeys: String, CodingKey { + case unreadEntries = "unread_entries" + } + +} diff --git a/Mac/Preferences/Accounts/AccountsAddViewController.swift b/Mac/Preferences/Accounts/AccountsAddViewController.swift index 6c3a4158f..cf091fff1 100644 --- a/Mac/Preferences/Accounts/AccountsAddViewController.swift +++ b/Mac/Preferences/Accounts/AccountsAddViewController.swift @@ -39,7 +39,7 @@ class AccountsAddViewController: NSViewController { extension AccountsAddViewController: NSTableViewDataSource { func numberOfRows(in tableView: NSTableView) -> Int { - return 2 + return 3 } func tableView(_ tableView: NSTableView, objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any? { @@ -63,6 +63,9 @@ extension AccountsAddViewController: NSTableViewDelegate { case 1: cell.accountNameLabel?.stringValue = NSLocalizedString("Feedbin", comment: "Feedbin") cell.accountImageView?.image = AppAssets.accountFeedbin + case 2: + cell.accountNameLabel?.stringValue = NSLocalizedString("Google Reader API", comment: "Google Reader API") + cell.accountImageView?.image = AppAssets.accountLocal default: break } @@ -87,6 +90,10 @@ extension AccountsAddViewController: NSTableViewDelegate { let accountsFeedbinWindowController = AccountsFeedbinWindowController() accountsFeedbinWindowController.runSheetOnWindow(self.view.window!) accountsAddWindowController = accountsFeedbinWindowController + case 2: + let accountsGoogleReaderCompatibleWindowController = AccountsGoogleReaderCompatibleWindowController() + accountsGoogleReaderCompatibleWindowController.runSheetOnWindow(self.view.window!) + accountsAddWindowController = accountsGoogleReaderCompatibleWindowController default: break } diff --git a/Mac/Preferences/Accounts/AccountsGoogleReaderCompatible.xib b/Mac/Preferences/Accounts/AccountsGoogleReaderCompatible.xib new file mode 100644 index 000000000..e734fab2c --- /dev/null +++ b/Mac/Preferences/Accounts/AccountsGoogleReaderCompatible.xib @@ -0,0 +1,210 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NSAllRomanInputSourcesLocaleIdentifier + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Mac/Preferences/Accounts/AccountsGoogleReaderCompatibleWindowController.swift b/Mac/Preferences/Accounts/AccountsGoogleReaderCompatibleWindowController.swift new file mode 100644 index 000000000..a8471eba7 --- /dev/null +++ b/Mac/Preferences/Accounts/AccountsGoogleReaderCompatibleWindowController.swift @@ -0,0 +1,125 @@ +// +// AccountsAddFeedbinWindowController.swift +// NetNewsWire +// +// Created by Maurice Parker on 5/2/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import AppKit +import Account +import RSWeb + +class AccountsGoogleReaderCompatibleWindowController: NSWindowController { + + @IBOutlet weak var progressIndicator: NSProgressIndicator! + @IBOutlet weak var usernameTextField: NSTextField! + @IBOutlet weak var apiURLTextField: NSTextField! + @IBOutlet weak var passwordTextField: NSSecureTextField! + @IBOutlet weak var errorMessageLabel: NSTextField! + @IBOutlet weak var actionButton: NSButton! + + var account: Account? + + private weak var hostWindow: NSWindow? + + convenience init() { + self.init(windowNibName: NSNib.Name("AccountsGoogleReaderCompatible")) + } + + override func windowDidLoad() { + if let account = account, let credentials = try? account.retrieveBasicCredentials() { + if case .basic(let username, let password) = credentials { + usernameTextField.stringValue = username + passwordTextField.stringValue = password + } + actionButton.title = NSLocalizedString("Update", comment: "Update") + } else { + actionButton.title = NSLocalizedString("Create", comment: "Create") + } + } + + // MARK: API + + func runSheetOnWindow(_ hostWindow: NSWindow, completionHandler handler: ((NSApplication.ModalResponse) -> Void)? = nil) { + self.hostWindow = hostWindow + hostWindow.beginSheet(window!, completionHandler: handler) + } + + // MARK: Actions + + @IBAction func cancel(_ sender: Any) { + hostWindow!.endSheet(window!, returnCode: NSApplication.ModalResponse.cancel) + } + + @IBAction func action(_ sender: Any) { + + self.errorMessageLabel.stringValue = "" + + guard !usernameTextField.stringValue.isEmpty && !passwordTextField.stringValue.isEmpty && !apiURLTextField.stringValue.isEmpty else { + self.errorMessageLabel.stringValue = NSLocalizedString("Username, password & API URL are required.", comment: "Credentials Error") + return + } + + actionButton.isEnabled = false + progressIndicator.isHidden = false + progressIndicator.startAnimation(self) + + guard let apiURL = URL(string: apiURLTextField.stringValue) else { + self.errorMessageLabel.stringValue = NSLocalizedString("Invalie API URL.", comment: "Credentials Error") + return + } + + let credentials = Credentials.googleLogin(username: usernameTextField.stringValue, password: passwordTextField.stringValue, apiUrl: apiURL, apiKey: nil) + Account.validateCredentials(type: .googleReaderCompatible, 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 authenticated): + + if authenticated { + + var newAccount = false + if self.account == nil { + self.account = AccountManager.shared.createAccount(type: .googleReaderCompatible) + newAccount = true + } + + do { + try self.account?.removeBasicCredentials() + try self.account?.storeCredentials(credentials) + if newAccount { + self.account?.refreshAll() { result in + switch result { + case .success: + break + case .failure(let error): + NSApplication.shared.presentError(error) + } + } + } + self.hostWindow?.endSheet(self.window!, returnCode: NSApplication.ModalResponse.OK) + } catch { + self.errorMessageLabel.stringValue = NSLocalizedString("Keychain error while storing credentials.", comment: "Credentials Error") + } + + } else { + self.errorMessageLabel.stringValue = NSLocalizedString("Invalid email/password combination.", comment: "Credentials Error") + } + + case .failure: + + self.errorMessageLabel.stringValue = NSLocalizedString("Network error. Try again later.", comment: "Credentials Error") + + } + + } + + } + +} diff --git a/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift b/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift index 4de8f17af..9edd0539e 100644 --- a/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift +++ b/Mac/Preferences/Accounts/AccountsPreferencesViewController.swift @@ -107,6 +107,8 @@ extension AccountsPreferencesViewController: NSTableViewDelegate { cell.imageView?.image = AppAssets.accountLocal case .feedbin: cell.imageView?.image = NSImage(named: "accountFeedbin") + case .googleReaderCompatible: + cell.imageView?.image = AppAssets.accountLocal default: break } diff --git a/Mac/Scriptability/Account+Scriptability.swift b/Mac/Scriptability/Account+Scriptability.swift index 7c9e9c73c..726d03dc5 100644 --- a/Mac/Scriptability/Account+Scriptability.swift +++ b/Mac/Scriptability/Account+Scriptability.swift @@ -138,6 +138,8 @@ class ScriptableAccount: NSObject, UniqueIdScriptingObject, ScriptingObjectConta osType = "FWrg" case .newsBlur: osType = "NBlr" + case .googleReaderCompatible: + osType = "Grdr" } return osType.fourCharCode() } diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index bb3af139d..907a1d012 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -146,6 +146,8 @@ 51F85BF92274AA7B00C787DC /* UIBarButtonItem-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F85BF82274AA7B00C787DC /* UIBarButtonItem-Extensions.swift */; }; 51F85BFB2275D85000C787DC /* Array-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F85BFA2275D85000C787DC /* Array-Extensions.swift */; }; 51F85BFD2275DCA800C787DC /* SingleLineUILabelSizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F85BFC2275DCA800C787DC /* SingleLineUILabelSizer.swift */; }; + 55E15BCB229D65A900D6602A /* AccountsGoogleReaderCompatible.xib in Resources */ = {isa = PBXBuildFile; fileRef = 55E15BC1229D65A900D6602A /* AccountsGoogleReaderCompatible.xib */; }; + 55E15BCC229D65A900D6602A /* AccountsGoogleReaderCompatibleWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55E15BCA229D65A900D6602A /* AccountsGoogleReaderCompatibleWindowController.swift */; }; 6581C73820CED60100F4AD34 /* SafariExtensionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6581C73720CED60100F4AD34 /* SafariExtensionHandler.swift */; }; 6581C73A20CED60100F4AD34 /* SafariExtensionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6581C73920CED60100F4AD34 /* SafariExtensionViewController.swift */; }; 6581C73D20CED60100F4AD34 /* SafariExtensionViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 6581C73B20CED60100F4AD34 /* SafariExtensionViewController.xib */; }; @@ -739,6 +741,8 @@ 51F85BF82274AA7B00C787DC /* UIBarButtonItem-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBarButtonItem-Extensions.swift"; sourceTree = ""; }; 51F85BFA2275D85000C787DC /* Array-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array-Extensions.swift"; sourceTree = ""; }; 51F85BFC2275DCA800C787DC /* SingleLineUILabelSizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingleLineUILabelSizer.swift; sourceTree = ""; }; + 55E15BC1229D65A900D6602A /* AccountsGoogleReaderCompatible.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = AccountsGoogleReaderCompatible.xib; sourceTree = ""; }; + 55E15BCA229D65A900D6602A /* AccountsGoogleReaderCompatibleWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountsGoogleReaderCompatibleWindowController.swift; sourceTree = ""; }; 6581C73320CED60000F4AD34 /* Subscribe to Feed.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Subscribe to Feed.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 6581C73420CED60100F4AD34 /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = System/Library/Frameworks/Cocoa.framework; sourceTree = SDKROOT; }; 6581C73720CED60100F4AD34 /* SafariExtensionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariExtensionHandler.swift; sourceTree = ""; }; @@ -1624,6 +1628,8 @@ 84C9FC6F22629E1200D921D6 /* Accounts */ = { isa = PBXGroup; children = ( + 55E15BC1229D65A900D6602A /* AccountsGoogleReaderCompatible.xib */, + 55E15BCA229D65A900D6602A /* AccountsGoogleReaderCompatibleWindowController.swift */, 84C9FC7022629E1200D921D6 /* AccountsTableViewBackgroundView.swift */, 84C9FC7122629E1200D921D6 /* AccountsControlsBackgroundView.swift */, 84C9FC7222629E1200D921D6 /* AccountsPreferencesViewController.swift */, @@ -1942,12 +1948,12 @@ ORGANIZATIONNAME = "Ranchero Software"; TargetAttributes = { 6581C73220CED60000F4AD34 = { - DevelopmentTeam = SHJK2V3AJG; - ProvisioningStyle = Manual; + DevelopmentTeam = 96VR936H35; + ProvisioningStyle = Automatic; }; 840D617B2029031C009BC708 = { CreatedOnToolsVersion = 9.3; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = 96VR936H35; ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.BackgroundModes = { @@ -1963,8 +1969,8 @@ }; 849C645F1ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = SHJK2V3AJG; - ProvisioningStyle = Manual; + DevelopmentTeam = 96VR936H35; + ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.HardenedRuntime = { enabled = 1; @@ -1973,7 +1979,7 @@ }; 849C64701ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = SHJK2V3AJG; + DevelopmentTeam = 96VR936H35; ProvisioningStyle = Automatic; TestTargetID = 849C645F1ED37A5D003D8FC0; }; @@ -2249,6 +2255,7 @@ 5144EA52227B8E4500D19003 /* AccountsFeedbin.xib in Resources */, 8405DDA222168920008CE1BF /* TimelineTableView.xib in Resources */, 8483630E2262A3FE00DA1D35 /* MainWindow.storyboard in Resources */, + 55E15BCB229D65A900D6602A /* AccountsGoogleReaderCompatible.xib in Resources */, 84BAE64921CEDAF20046DB56 /* CrashReporterWindow.xib in Resources */, 84C9FC8E22629E8F00D921D6 /* Credits.rtf in Resources */, 84BBB12D20142A4700F054F5 /* Inspector.storyboard in Resources */, @@ -2467,6 +2474,7 @@ 8477ACBE22238E9500DF7F37 /* SearchFeedDelegate.swift in Sources */, 51E3EB33229AB02C00645299 /* ErrorHandler.swift in Sources */, 8472058120142E8900AD578B /* FeedInspectorViewController.swift in Sources */, + 55E15BCC229D65A900D6602A /* AccountsGoogleReaderCompatibleWindowController.swift in Sources */, 5144EA382279FC6200D19003 /* AccountsAddLocalWindowController.swift in Sources */, 84AD1EAA2031617300BC20B7 /* FolderPasteboardWriter.swift in Sources */, 5144EA51227B8E4500D19003 /* AccountsFeedbinWindowController.swift in Sources */,