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.
This commit is contained in:
Jeremy Beker 2019-05-28 13:08:15 -04:00
parent 6b93576f37
commit 84dbdf25e2
No known key found for this signature in database
GPG Key ID: CD5EE767A4A34FD0
19 changed files with 2537 additions and 7 deletions

View File

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

View File

@ -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 = "<group>"; };
51E59598228C77BC00FCC42B /* FeedbinUnreadEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinUnreadEntry.swift; sourceTree = "<group>"; };
51E5959A228C781500FCC42B /* FeedbinStarredEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinStarredEntry.swift; sourceTree = "<group>"; };
552032EB229D5D5A009559E0 /* GoogleReaderCompatibleDate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleDate.swift; sourceTree = "<group>"; };
552032EC229D5D5A009559E0 /* GoogleReaderCompatibleIcon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleIcon.swift; sourceTree = "<group>"; };
552032ED229D5D5A009559E0 /* GoogleReaderCompatibleEntry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleEntry.swift; sourceTree = "<group>"; };
552032EE229D5D5A009559E0 /* GoogleReaderCompatibleSubscription.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleSubscription.swift; sourceTree = "<group>"; };
552032EF229D5D5A009559E0 /* GoogleReaderCompatibleStarredEntry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleStarredEntry.swift; sourceTree = "<group>"; };
552032F0229D5D5A009559E0 /* GoogleReaderCompatibleTag.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleTag.swift; sourceTree = "<group>"; };
552032F1229D5D5A009559E0 /* GoogleReaderCompatibleUnreadEntry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleUnreadEntry.swift; sourceTree = "<group>"; };
552032F2229D5D5A009559E0 /* GoogleReaderCompatibleTagging.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleTagging.swift; sourceTree = "<group>"; };
552032F3229D5D5A009559E0 /* GoogleReaderCompatibleAccountDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleAccountDelegate.swift; sourceTree = "<group>"; };
552032F4229D5D5A009559E0 /* GoogleReaderCompatibleImportResult.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleImportResult.swift; sourceTree = "<group>"; };
552032F5229D5D5A009559E0 /* GoogleReaderCompatibleAPICaller.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleReaderCompatibleAPICaller.swift; sourceTree = "<group>"; };
841973E81F6DD19E006346C4 /* RSCore.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSCore.xcodeproj; path = ../RSCore/RSCore.xcodeproj; sourceTree = "<group>"; };
841973F41F6DD1AC006346C4 /* RSParser.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RSParser.xcodeproj; path = ../RSParser/RSParser.xcodeproj; sourceTree = "<group>"; };
841974001F6DD1EC006346C4 /* Folder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Folder.swift; sourceTree = "<group>"; };
@ -222,6 +244,24 @@
path = JSON;
sourceTree = "<group>";
};
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 = "<group>";
};
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 */,

View File

@ -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<Bool, Error>) -> 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<GoogleReaderCompatibleImportResult, Error>) -> 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<GoogleReaderCompatibleImportResult?, Error>) -> 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, Error>) -> 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<CreateGoogleReaderSubscriptionResult, Error>) -> 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, Error>) -> 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, Error>) -> 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<Int, Error>) -> 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..<upperBound]) {
completion(.success(taggingID))
} else {
completion(.failure(TransportError.noData))
}
case .failure(let error):
completion(.failure(error))
}
}
}
func deleteTagging(taggingID: String, completion: @escaping (Result<Void, Error>) -> 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, Error>) -> 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, Error>) -> 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, Error>) -> 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, Error>) -> 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..<upperBound])
}
if let upperBound = link.range(of: ">")?.lowerBound {
return Int(link[lowerBound..<upperBound])
}
}
return nil
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,210 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="14490.70" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14490.70"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="AccountsGoogleReaderCompatibleWindowController" customModule="NetNewsWire" customModuleProvider="target">
<connections>
<outlet property="actionButton" destination="9mz-D9-krh" id="ozu-6Q-9Lb"/>
<outlet property="apiURLTextField" destination="d7d-ZV-CcZ" id="Af4-uM-Dgd"/>
<outlet property="errorMessageLabel" destination="byK-Sd-r7F" id="8zt-9d-dWQ"/>
<outlet property="passwordTextField" destination="JSa-LY-zNQ" id="E9W-0F-69m"/>
<outlet property="progressIndicator" destination="B0W-bh-Evv" id="Tiq-gx-s3F"/>
<outlet property="usernameTextField" destination="78p-Cf-f55" id="RWd-0q-oAL"/>
<outlet property="window" destination="F0z-JX-Cv5" id="gIp-Ho-8D9"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<window title="Window" allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" releasedWhenClosed="NO" visibleAtLaunch="NO" animationBehavior="default" id="F0z-JX-Cv5">
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="196" y="240" width="433" height="249"/>
<rect key="screenRect" x="0.0" y="0.0" width="2560" height="1417"/>
<view key="contentView" id="se5-gp-TjO">
<rect key="frame" x="0.0" y="0.0" width="433" height="283"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<stackView distribution="fill" orientation="horizontal" alignment="bottom" spacing="19" horizontalStackHuggingPriority="249.99998474121094" verticalStackHuggingPriority="249.99998474121094" detachesHiddenViews="YES" translatesAutoresizingMaskIntoConstraints="NO" id="7Ht-Fn-0Ya">
<rect key="frame" x="60" y="224" width="314" height="39"/>
<subviews>
<imageView horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="Ssh-Dh-xbg">
<rect key="frame" x="0.0" y="0.0" width="36" height="36"/>
<constraints>
<constraint firstAttribute="height" constant="36" id="Ern-Kk-8LX"/>
<constraint firstAttribute="width" constant="36" id="PLS-68-NMc"/>
</constraints>
<imageCell key="cell" refusesFirstResponder="YES" alignment="left" imageScaling="proportionallyDown" image="accountLocal" id="y38-YL-woC"/>
</imageView>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="lti-yM-8LV">
<rect key="frame" x="53" y="0.0" width="263" height="39"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="Google Reader API" id="ras-dj-nP8">
<font key="font" metaFont="system" size="32"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</subviews>
<visibilityPriorities>
<integer value="1000"/>
<integer value="1000"/>
</visibilityPriorities>
<customSpacing>
<real value="3.4028234663852886e+38"/>
<real value="3.4028234663852886e+38"/>
</customSpacing>
</stackView>
<gridView xPlacement="trailing" yPlacement="center" rowAlignment="none" rowSpacing="12" columnSpacing="14" translatesAutoresizingMaskIntoConstraints="NO" id="zBB-JH-huI">
<rect key="frame" x="79" y="61" width="276" height="131"/>
<rows>
<gridRow id="DRl-lC-vUc"/>
<gridRow id="eW8-uH-txq"/>
<gridRow id="ebD-On-mOK"/>
<gridRow id="DbI-7g-Xme"/>
</rows>
<columns>
<gridColumn id="fCQ-jY-Mts"/>
<gridColumn xPlacement="leading" id="7CY-bX-6x4"/>
</columns>
<gridCells>
<gridCell row="DRl-lC-vUc" column="fCQ-jY-Mts" id="4DI-01-jGD">
<textField key="contentView" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="Zy6-9c-8TI">
<rect key="frame" x="23" y="112" width="41" height="17"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="Email:" id="DqN-SV-v35">
<font key="font" usesAppearanceFont="YES"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</gridCell>
<gridCell row="DRl-lC-vUc" column="7CY-bX-6x4" id="Z0b-qS-MUJ">
<textField key="contentView" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="78p-Cf-f55">
<rect key="frame" x="76" y="109" width="200" height="22"/>
<constraints>
<constraint firstAttribute="width" constant="200" id="Qin-jm-4zt"/>
</constraints>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" drawsBackground="YES" id="fCk-Tf-q01">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</gridCell>
<gridCell row="eW8-uH-txq" column="fCQ-jY-Mts" id="Hqa-3w-cQv">
<textField key="contentView" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="wEx-TM-rPM">
<rect key="frame" x="-2" y="78" width="66" height="17"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="Password:" id="7g8-Kk-ISg">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</gridCell>
<gridCell row="eW8-uH-txq" column="7CY-bX-6x4" id="m16-3v-9pf">
<secureTextField key="contentView" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="JSa-LY-zNQ">
<rect key="frame" x="76" y="75" width="200" height="22"/>
<constraints>
<constraint firstAttribute="width" constant="200" id="eal-wa-1nU"/>
</constraints>
<secureTextFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" drawsBackground="YES" usesSingleLineMode="YES" id="trK-OG-tBe">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
<allowedInputSourceLocales>
<string>NSAllRomanInputSourcesLocaleIdentifier</string>
</allowedInputSourceLocales>
</secureTextFieldCell>
</secureTextField>
</gridCell>
<gridCell row="ebD-On-mOK" column="fCQ-jY-Mts" id="xwR-xz-N6h">
<textField key="contentView" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="H6f-t4-SMg">
<rect key="frame" x="7" y="44" width="57" height="17"/>
<textFieldCell key="cell" lineBreakMode="clipping" title="API URL:" id="zBm-dZ-EF1">
<font key="font" usesAppearanceFont="YES"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</gridCell>
<gridCell row="ebD-On-mOK" column="7CY-bX-6x4" id="Wd5-Zp-t61">
<textField key="contentView" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="d7d-ZV-CcZ" userLabel="API URL Text Field">
<rect key="frame" x="76" y="41" width="200" height="22"/>
<constraints>
<constraint firstAttribute="width" constant="200" id="Mki-bb-tDu"/>
</constraints>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" selectable="YES" editable="YES" sendsActionOnEndEditing="YES" borderStyle="bezel" drawsBackground="YES" id="0OO-BG-GXI">
<font key="font" metaFont="system"/>
<color key="textColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</gridCell>
<gridCell row="DbI-7g-Xme" column="fCQ-jY-Mts" headOfMergedCell="xX0-vn-AId" xPlacement="leading" id="xX0-vn-AId">
<textField key="contentView" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="byK-Sd-r7F">
<rect key="frame" x="-2" y="6" width="104" height="17"/>
<textFieldCell key="cell" lineBreakMode="clipping" id="0yh-Ab-UTX">
<font key="font" usesAppearanceFont="YES"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</gridCell>
<gridCell row="DbI-7g-Xme" column="7CY-bX-6x4" headOfMergedCell="xX0-vn-AId" id="hk5-St-E4y"/>
</gridCells>
</gridView>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="9mz-D9-krh">
<rect key="frame" x="340" y="13" width="79" height="32"/>
<buttonCell key="cell" type="push" title="Action" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="IMO-YT-k9Z">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
<string key="keyEquivalent" base64-UTF8="YES">
DQ
</string>
</buttonCell>
<connections>
<action selector="action:" target="-2" id="Kix-5a-5Og"/>
</connections>
</button>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="XAM-Hb-0Hw">
<rect key="frame" x="258" y="13" width="82" height="32"/>
<buttonCell key="cell" type="push" title="Cancel" bezelStyle="rounded" alignment="center" borderStyle="border" imageScaling="proportionallyDown" inset="2" id="ufs-ar-BAY">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
<string key="keyEquivalent" base64-UTF8="YES">
Gw
</string>
</buttonCell>
<connections>
<action selector="cancel:" target="-2" id="WAD-ES-hpq"/>
</connections>
</button>
<progressIndicator hidden="YES" wantsLayer="YES" horizontalHuggingPriority="750" verticalHuggingPriority="750" maxValue="100" displayedWhenStopped="NO" bezeled="NO" indeterminate="YES" controlSize="small" style="spinning" translatesAutoresizingMaskIntoConstraints="NO" id="B0W-bh-Evv">
<rect key="frame" x="209" y="200" width="16" height="16"/>
</progressIndicator>
</subviews>
<constraints>
<constraint firstItem="9mz-D9-krh" firstAttribute="leading" secondItem="XAM-Hb-0Hw" secondAttribute="trailing" constant="12" symbolic="YES" id="CC8-HR-FDy"/>
<constraint firstItem="XAM-Hb-0Hw" firstAttribute="centerY" secondItem="9mz-D9-krh" secondAttribute="centerY" id="M2M-fb-kfR"/>
<constraint firstAttribute="bottom" secondItem="9mz-D9-krh" secondAttribute="bottom" constant="20" id="PK2-Ye-400"/>
<constraint firstItem="zBB-JH-huI" firstAttribute="top" secondItem="B0W-bh-Evv" secondAttribute="bottom" constant="8" id="V7z-a7-OOG"/>
<constraint firstItem="9mz-D9-krh" firstAttribute="top" secondItem="zBB-JH-huI" secondAttribute="bottom" constant="20" symbolic="YES" id="Wu3-hp-Vzh"/>
<constraint firstItem="zBB-JH-huI" firstAttribute="centerX" secondItem="se5-gp-TjO" secondAttribute="centerX" id="aFI-4s-mMv"/>
<constraint firstAttribute="trailing" secondItem="9mz-D9-krh" secondAttribute="trailing" constant="20" id="fVQ-zN-rKd"/>
<constraint firstItem="B0W-bh-Evv" firstAttribute="top" secondItem="lti-yM-8LV" secondAttribute="bottom" constant="8" id="gq2-tB-pXH"/>
<constraint firstItem="7Ht-Fn-0Ya" firstAttribute="top" secondItem="se5-gp-TjO" secondAttribute="top" constant="20" id="jlY-Jg-KJR"/>
<constraint firstItem="B0W-bh-Evv" firstAttribute="centerX" secondItem="se5-gp-TjO" secondAttribute="centerX" id="lrN-Gd-iXd"/>
<constraint firstItem="7Ht-Fn-0Ya" firstAttribute="centerX" secondItem="se5-gp-TjO" secondAttribute="centerX" id="tAZ-Te-w3H"/>
</constraints>
</view>
<connections>
<outlet property="delegate" destination="-2" id="0bl-1N-AYu"/>
</connections>
<point key="canvasLocation" x="116.5" y="136.5"/>
</window>
</objects>
<resources>
<image name="accountLocal" width="78" height="98"/>
</resources>
</document>

View File

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

View File

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

View File

@ -138,6 +138,8 @@ class ScriptableAccount: NSObject, UniqueIdScriptingObject, ScriptingObjectConta
osType = "FWrg"
case .newsBlur:
osType = "NBlr"
case .googleReaderCompatible:
osType = "Grdr"
}
return osType.fourCharCode()
}

View File

@ -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 = "<group>"; };
51F85BFA2275D85000C787DC /* Array-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array-Extensions.swift"; sourceTree = "<group>"; };
51F85BFC2275DCA800C787DC /* SingleLineUILabelSizer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SingleLineUILabelSizer.swift; sourceTree = "<group>"; };
55E15BC1229D65A900D6602A /* AccountsGoogleReaderCompatible.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = AccountsGoogleReaderCompatible.xib; sourceTree = "<group>"; };
55E15BCA229D65A900D6602A /* AccountsGoogleReaderCompatibleWindowController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AccountsGoogleReaderCompatibleWindowController.swift; sourceTree = "<group>"; };
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 = "<group>"; };
@ -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 */,