Fix lint issues.
This commit is contained in:
parent
27500633ab
commit
6fc9e5c25e
@ -14,19 +14,19 @@ import Secrets
|
||||
protocol FeedlyAPICallerDelegate: AnyObject {
|
||||
/// Implemented by the `FeedlyAccountDelegate` reauthorize the client with a fresh OAuth token so the client can retry the unauthorized request.
|
||||
/// Pass `true` to the completion handler if the failing request should be retried with a fresh token or `false` if the unauthorized request should complete with the original failure error.
|
||||
func reauthorizeFeedlyAPICaller(_ caller: FeedlyAPICaller, completionHandler: @escaping (Bool) -> ())
|
||||
func reauthorizeFeedlyAPICaller(_ caller: FeedlyAPICaller, completionHandler: @escaping (Bool) -> Void)
|
||||
}
|
||||
|
||||
final class FeedlyAPICaller {
|
||||
|
||||
|
||||
enum API {
|
||||
case sandbox
|
||||
case cloud
|
||||
|
||||
|
||||
var baseUrlComponents: URLComponents {
|
||||
var components = URLComponents()
|
||||
components.scheme = "https"
|
||||
switch self{
|
||||
switch self {
|
||||
case .sandbox:
|
||||
// https://groups.google.com/forum/#!topic/feedly-cloud/WwQWMgDmOuw
|
||||
components.host = "sandbox7.feedly.com"
|
||||
@ -36,7 +36,7 @@ final class FeedlyAPICaller {
|
||||
}
|
||||
return components
|
||||
}
|
||||
|
||||
|
||||
var oauthAuthorizationClient: OAuthAuthorizationClient {
|
||||
switch self {
|
||||
case .sandbox:
|
||||
@ -46,83 +46,83 @@ final class FeedlyAPICaller {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private let transport: Transport
|
||||
private let baseUrlComponents: URLComponents
|
||||
private let uriComponentAllowed: CharacterSet
|
||||
|
||||
|
||||
init(transport: Transport, api: API) {
|
||||
self.transport = transport
|
||||
self.baseUrlComponents = api.baseUrlComponents
|
||||
|
||||
|
||||
var urlHostAllowed = CharacterSet.urlHostAllowed
|
||||
urlHostAllowed.remove("+")
|
||||
uriComponentAllowed = urlHostAllowed
|
||||
}
|
||||
|
||||
|
||||
weak var delegate: FeedlyAPICallerDelegate?
|
||||
|
||||
|
||||
var credentials: Credentials?
|
||||
|
||||
|
||||
var server: String? {
|
||||
return baseUrlComponents.host
|
||||
}
|
||||
|
||||
|
||||
func cancelAll() {
|
||||
transport.cancelAll()
|
||||
}
|
||||
|
||||
|
||||
private var isSuspended = false
|
||||
|
||||
|
||||
/// Cancels all pending requests rejects any that come in later
|
||||
func suspend() {
|
||||
transport.cancelAll()
|
||||
isSuspended = true
|
||||
}
|
||||
|
||||
|
||||
func resume() {
|
||||
isSuspended = false
|
||||
}
|
||||
|
||||
|
||||
func send<R: Decodable>(request: URLRequest, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void) {
|
||||
transport.send(request: request, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding) { [weak self] result in
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
completion(result)
|
||||
case .failure(let error):
|
||||
switch error {
|
||||
case TransportError.httpError(let statusCode) where statusCode == 401:
|
||||
|
||||
|
||||
assert(self == nil ? true : self?.delegate != nil, "Check the delegate is set to \(FeedlyAccountDelegate.self).")
|
||||
|
||||
|
||||
guard let self = self, let delegate = self.delegate else {
|
||||
completion(result)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
/// Capture the credentials before the reauthorization to check for a change.
|
||||
let credentialsBefore = self.credentials
|
||||
|
||||
|
||||
delegate.reauthorizeFeedlyAPICaller(self) { [weak self] isReauthorizedAndShouldRetry in
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
|
||||
guard isReauthorizedAndShouldRetry, let self = self else {
|
||||
completion(result)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Check for a change. Not only would it help debugging, but it'll also catch an infinitely recursive attempt to refresh.
|
||||
guard let accessToken = self.credentials?.secret, accessToken != credentialsBefore?.secret else {
|
||||
assertionFailure("Could not update the request with a new OAuth token. Did \(String(describing: self.delegate)) set them on \(self)?")
|
||||
completion(result)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
var reauthorizedRequest = request
|
||||
reauthorizedRequest.setValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
|
||||
self.send(request: reauthorizedRequest, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding, completion: completion)
|
||||
}
|
||||
default:
|
||||
@ -131,14 +131,14 @@ final class FeedlyAPICaller {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func importOpml(_ opmlData: Data, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
|
||||
func importOpml(_ opmlData: Data, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
@ -146,18 +146,18 @@ final class FeedlyAPICaller {
|
||||
}
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/opml"
|
||||
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("text/xml", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
request.httpBody = opmlData
|
||||
|
||||
|
||||
send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (httpResponse, _)):
|
||||
@ -171,14 +171,14 @@ final class FeedlyAPICaller {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createCollection(named label: String, completion: @escaping (Result<FeedlyCollection, Error>) -> ()) {
|
||||
|
||||
func createCollection(named label: String, completion: @escaping (Result<FeedlyCollection, Error>) -> Void) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
@ -186,17 +186,17 @@ final class FeedlyAPICaller {
|
||||
}
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/collections"
|
||||
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
|
||||
do {
|
||||
struct CreateCollectionBody: Encodable {
|
||||
var label: String
|
||||
@ -209,7 +209,7 @@ final class FeedlyAPICaller {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (httpResponse, collections)):
|
||||
@ -223,14 +223,14 @@ final class FeedlyAPICaller {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func renameCollection(with id: String, to name: String, completion: @escaping (Result<FeedlyCollection, Error>) -> ()) {
|
||||
|
||||
func renameCollection(with id: String, to name: String, completion: @escaping (Result<FeedlyCollection, Error>) -> Void) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
@ -238,17 +238,17 @@ final class FeedlyAPICaller {
|
||||
}
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/collections"
|
||||
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
|
||||
do {
|
||||
struct RenameCollectionBody: Encodable {
|
||||
var id: String
|
||||
@ -262,7 +262,7 @@ final class FeedlyAPICaller {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (httpResponse, collections)):
|
||||
@ -276,18 +276,18 @@ final class FeedlyAPICaller {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func encodeForURLPath(_ pathComponent: String) -> String? {
|
||||
return pathComponent.addingPercentEncoding(withAllowedCharacters: uriComponentAllowed)
|
||||
}
|
||||
|
||||
func deleteCollection(with id: String, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
|
||||
func deleteCollection(with id: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
@ -300,17 +300,17 @@ final class FeedlyAPICaller {
|
||||
}
|
||||
var components = baseUrlComponents
|
||||
components.percentEncodedPath = "/v3/collections/\(encodedId)"
|
||||
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "DELETE"
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
|
||||
send(request: request, resultType: Optional<FeedlyCollection>.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (httpResponse, _)):
|
||||
@ -324,14 +324,14 @@ final class FeedlyAPICaller {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeFeed(_ feedId: String, fromCollectionWith collectionId: String, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
|
||||
func removeFeed(_ feedId: String, fromCollectionWith collectionId: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
@ -343,20 +343,20 @@ final class FeedlyAPICaller {
|
||||
completion(.failure(FeedlyAccountDelegateError.unexpectedResourceId(collectionId)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var components = baseUrlComponents
|
||||
components.percentEncodedPath = "/v3/collections/\(encodedCollectionId)/feeds/.mdelete"
|
||||
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "DELETE"
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
|
||||
do {
|
||||
struct RemovableFeed: Encodable {
|
||||
let id: String
|
||||
@ -369,7 +369,7 @@ final class FeedlyAPICaller {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// `resultType` is optional because the Feedly API has gone from returning an array of removed feeds to returning `null`.
|
||||
// https://developer.feedly.com/v3/collections/#remove-multiple-feeds-from-a-personal-collection
|
||||
send(request: request, resultType: Optional<[FeedlyFeed]>.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
@ -388,14 +388,14 @@ final class FeedlyAPICaller {
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlyAddFeedToCollectionService {
|
||||
|
||||
func addFeed(with feedId: FeedlyFeedResourceId, title: String? = nil, toCollectionWith collectionId: String, completion: @escaping (Result<[FeedlyFeed], Error>) -> ()) {
|
||||
|
||||
func addFeed(with feedId: FeedlyFeedResourceId, title: String? = nil, toCollectionWith collectionId: String, completion: @escaping (Result<[FeedlyFeed], Error>) -> Void) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
@ -409,17 +409,17 @@ extension FeedlyAPICaller: FeedlyAddFeedToCollectionService {
|
||||
}
|
||||
var components = baseUrlComponents
|
||||
components.percentEncodedPath = "/v3/collections/\(encodedId)/feeds"
|
||||
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "PUT"
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
|
||||
do {
|
||||
struct AddFeedBody: Encodable {
|
||||
var id: String
|
||||
@ -433,7 +433,7 @@ extension FeedlyAPICaller: FeedlyAddFeedToCollectionService {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
send(request: request, resultType: [FeedlyFeed].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success((_, let collectionFeeds)):
|
||||
@ -450,45 +450,45 @@ extension FeedlyAPICaller: FeedlyAddFeedToCollectionService {
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: OAuthAuthorizationCodeGrantRequesting {
|
||||
|
||||
|
||||
static func authorizationCodeUrlRequest(for request: OAuthAuthorizationRequest, baseUrlComponents: URLComponents) -> URLRequest {
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/auth/auth"
|
||||
components.queryItems = request.queryItems
|
||||
|
||||
|
||||
guard let url = components.url else {
|
||||
assert(components.scheme != nil)
|
||||
assert(components.host != nil)
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
|
||||
typealias AccessTokenResponse = FeedlyOAuthAccessTokenResponse
|
||||
|
||||
func requestAccessToken(_ authorizationRequest: OAuthAccessTokenRequest, completion: @escaping (Result<FeedlyOAuthAccessTokenResponse, Error>) -> ()) {
|
||||
|
||||
func requestAccessToken(_ authorizationRequest: OAuthAccessTokenRequest, completion: @escaping (Result<FeedlyOAuthAccessTokenResponse, Error>) -> Void) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/auth/token"
|
||||
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
|
||||
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
@ -499,7 +499,7 @@ extension FeedlyAPICaller: OAuthAuthorizationCodeGrantRequesting {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
send(request: request, resultType: AccessTokenResponse.self, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (_, tokenResponse)):
|
||||
@ -516,26 +516,26 @@ extension FeedlyAPICaller: OAuthAuthorizationCodeGrantRequesting {
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: OAuthAcessTokenRefreshRequesting {
|
||||
|
||||
func refreshAccessToken(_ refreshRequest: OAuthRefreshAccessTokenRequest, completion: @escaping (Result<FeedlyOAuthAccessTokenResponse, Error>) -> ()) {
|
||||
|
||||
func refreshAccessToken(_ refreshRequest: OAuthRefreshAccessTokenRequest, completion: @escaping (Result<FeedlyOAuthAccessTokenResponse, Error>) -> Void) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/auth/token"
|
||||
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
|
||||
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
@ -546,7 +546,7 @@ extension FeedlyAPICaller: OAuthAcessTokenRefreshRequesting {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
send(request: request, resultType: AccessTokenResponse.self, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (_, tokenResponse)):
|
||||
@ -563,14 +563,14 @@ extension FeedlyAPICaller: OAuthAcessTokenRefreshRequesting {
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlyGetCollectionsService {
|
||||
|
||||
func getCollections(completion: @escaping (Result<[FeedlyCollection], Error>) -> ()) {
|
||||
|
||||
func getCollections(completion: @escaping (Result<[FeedlyCollection], Error>) -> Void) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
@ -578,16 +578,16 @@ extension FeedlyAPICaller: FeedlyGetCollectionsService {
|
||||
}
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/collections"
|
||||
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
|
||||
send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (_, collections)):
|
||||
@ -604,58 +604,58 @@ extension FeedlyAPICaller: FeedlyGetCollectionsService {
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlyGetStreamContentsService {
|
||||
|
||||
func getStreamContents(for resource: FeedlyResourceId, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStream, Error>) -> ()) {
|
||||
|
||||
func getStreamContents(for resource: FeedlyResourceId, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStream, Error>) -> Void) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/streams/contents"
|
||||
|
||||
|
||||
var queryItems = [URLQueryItem]()
|
||||
|
||||
|
||||
if let date = newerThan {
|
||||
let value = String(Int(date.timeIntervalSince1970 * 1000))
|
||||
let queryItem = URLQueryItem(name: "newerThan", value: value)
|
||||
queryItems.append(queryItem)
|
||||
}
|
||||
|
||||
|
||||
if let flag = unreadOnly {
|
||||
let value = flag ? "true" : "false"
|
||||
let queryItem = URLQueryItem(name: "unreadOnly", value: value)
|
||||
queryItems.append(queryItem)
|
||||
}
|
||||
|
||||
|
||||
if let value = continuation, !value.isEmpty {
|
||||
let queryItem = URLQueryItem(name: "continuation", value: value)
|
||||
queryItems.append(queryItem)
|
||||
}
|
||||
|
||||
|
||||
queryItems.append(contentsOf: [
|
||||
URLQueryItem(name: "count", value: "1000"),
|
||||
URLQueryItem(name: "streamId", value: resource.id),
|
||||
URLQueryItem(name: "streamId", value: resource.id)
|
||||
])
|
||||
|
||||
|
||||
components.queryItems = queryItems
|
||||
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
|
||||
send(request: request, resultType: FeedlyStream.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (_, collections)):
|
||||
@ -672,58 +672,58 @@ extension FeedlyAPICaller: FeedlyGetStreamContentsService {
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlyGetStreamIdsService {
|
||||
|
||||
func getStreamIds(for resource: FeedlyResourceId, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStreamIds, Error>) -> ()) {
|
||||
|
||||
func getStreamIds(for resource: FeedlyResourceId, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStreamIds, Error>) -> Void) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/streams/ids"
|
||||
|
||||
var queryItems = [URLQueryItem]()
|
||||
|
||||
|
||||
if let date = newerThan {
|
||||
let value = String(Int(date.timeIntervalSince1970 * 1000))
|
||||
let queryItem = URLQueryItem(name: "newerThan", value: value)
|
||||
queryItems.append(queryItem)
|
||||
}
|
||||
|
||||
|
||||
if let flag = unreadOnly {
|
||||
let value = flag ? "true" : "false"
|
||||
let queryItem = URLQueryItem(name: "unreadOnly", value: value)
|
||||
queryItems.append(queryItem)
|
||||
}
|
||||
|
||||
|
||||
if let value = continuation, !value.isEmpty {
|
||||
let queryItem = URLQueryItem(name: "continuation", value: value)
|
||||
queryItems.append(queryItem)
|
||||
}
|
||||
|
||||
|
||||
queryItems.append(contentsOf: [
|
||||
URLQueryItem(name: "count", value: "10000"),
|
||||
URLQueryItem(name: "streamId", value: resource.id),
|
||||
URLQueryItem(name: "streamId", value: resource.id)
|
||||
])
|
||||
|
||||
|
||||
components.queryItems = queryItems
|
||||
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
|
||||
send(request: request, resultType: FeedlyStreamIds.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (_, collections)):
|
||||
@ -740,29 +740,29 @@ extension FeedlyAPICaller: FeedlyGetStreamIdsService {
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlyGetEntriesService {
|
||||
|
||||
func getEntries(for ids: Set<String>, completion: @escaping (Result<[FeedlyEntry], Error>) -> ()) {
|
||||
|
||||
func getEntries(for ids: Set<String>, completion: @escaping (Result<[FeedlyEntry], Error>) -> Void) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/entries/.mget"
|
||||
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
|
||||
|
||||
do {
|
||||
let body = Array(ids)
|
||||
let encoder = JSONEncoder()
|
||||
@ -773,12 +773,12 @@ extension FeedlyAPICaller: FeedlyGetEntriesService {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
|
||||
send(request: request, resultType: [FeedlyEntry].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (_, entries)):
|
||||
@ -795,20 +795,20 @@ extension FeedlyAPICaller: FeedlyGetEntriesService {
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlyMarkArticlesService {
|
||||
|
||||
|
||||
private struct MarkerEntriesBody: Encodable {
|
||||
let type = "entries"
|
||||
var action: String
|
||||
var entryIds: [String]
|
||||
}
|
||||
|
||||
func mark(_ articleIds: Set<String>, as action: FeedlyMarkAction, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
|
||||
func mark(_ articleIds: Set<String>, as action: FeedlyMarkAction, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
@ -816,23 +816,23 @@ extension FeedlyAPICaller: FeedlyMarkArticlesService {
|
||||
}
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/markers"
|
||||
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
|
||||
let articleIdChunks = Array(articleIds).chunked(into: 300)
|
||||
let dispatchGroup = DispatchGroup()
|
||||
var groupError: Error? = nil
|
||||
var groupError: Error?
|
||||
|
||||
for articleIdChunk in articleIdChunks {
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
|
||||
do {
|
||||
let body = MarkerEntriesBody(action: action.actionValue, entryIds: Array(articleIdChunk))
|
||||
let encoder = JSONEncoder()
|
||||
@ -843,7 +843,7 @@ extension FeedlyAPICaller: FeedlyMarkArticlesService {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
dispatchGroup.enter()
|
||||
send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
@ -857,7 +857,7 @@ extension FeedlyAPICaller: FeedlyMarkArticlesService {
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
dispatchGroup.notify(queue: .main) {
|
||||
if let groupError = groupError {
|
||||
completion(.failure(groupError))
|
||||
@ -869,34 +869,33 @@ extension FeedlyAPICaller: FeedlyMarkArticlesService {
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlySearchService {
|
||||
|
||||
func getFeeds(for query: String, count: Int, locale: String, completion: @escaping (Result<FeedlyFeedsSearchResponse, Error>) -> ()) {
|
||||
|
||||
|
||||
func getFeeds(for query: String, count: Int, locale: String, completion: @escaping (Result<FeedlyFeedsSearchResponse, Error>) -> Void) {
|
||||
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/search/feeds"
|
||||
|
||||
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "query", value: query),
|
||||
URLQueryItem(name: "count", value: String(count)),
|
||||
URLQueryItem(name: "locale", value: locale)
|
||||
]
|
||||
|
||||
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
|
||||
|
||||
send(request: request, resultType: FeedlyFeedsSearchResponse.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (_, searchResponse)):
|
||||
@ -913,14 +912,14 @@ extension FeedlyAPICaller: FeedlySearchService {
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlyLogoutService {
|
||||
|
||||
func logout(completion: @escaping (Result<Void, Error>) -> ()) {
|
||||
|
||||
func logout(completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard !isSuspended else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(TransportError.suspended))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let accessToken = credentials?.secret else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(CredentialsError.incompleteCredentials))
|
||||
@ -928,17 +927,17 @@ extension FeedlyAPICaller: FeedlyLogoutService {
|
||||
}
|
||||
var components = baseUrlComponents
|
||||
components.path = "/v3/auth/logout"
|
||||
|
||||
|
||||
guard let url = components.url else {
|
||||
fatalError("\(components) does not produce a valid URL.")
|
||||
}
|
||||
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||
|
||||
|
||||
send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||
switch result {
|
||||
case .success(let (httpResponse, _)):
|
||||
|
@ -25,9 +25,9 @@ public struct FeedlyOAuthAccessTokenResponse: Decodable, OAuthAccessTokenRespons
|
||||
}
|
||||
|
||||
extension FeedlyAccountDelegate: OAuthAuthorizationGranting {
|
||||
|
||||
|
||||
private static let oauthAuthorizationGrantScope = "https://cloud.feedly.com/subscriptions"
|
||||
|
||||
|
||||
static func oauthAuthorizationCodeGrantRequest() -> URLRequest {
|
||||
let client = environment.oauthAuthorizationClient
|
||||
let authorizationRequest = OAuthAuthorizationRequest(clientId: client.id,
|
||||
@ -37,8 +37,8 @@ extension FeedlyAccountDelegate: OAuthAuthorizationGranting {
|
||||
let baseURLComponents = environment.baseUrlComponents
|
||||
return FeedlyAPICaller.authorizationCodeUrlRequest(for: authorizationRequest, baseUrlComponents: baseURLComponents)
|
||||
}
|
||||
|
||||
static func requestOAuthAccessToken(with response: OAuthAuthorizationResponse, transport: Transport, completion: @escaping (Result<OAuthAuthorizationGrant, Error>) -> ()) {
|
||||
|
||||
static func requestOAuthAccessToken(with response: OAuthAuthorizationResponse, transport: Transport, completion: @escaping (Result<OAuthAuthorizationGrant, Error>) -> Void) {
|
||||
let client = environment.oauthAuthorizationClient
|
||||
let request = OAuthAccessTokenRequest(authorizationResponse: response,
|
||||
scope: oauthAuthorizationGrantScope,
|
||||
@ -48,18 +48,18 @@ extension FeedlyAccountDelegate: OAuthAuthorizationGranting {
|
||||
switch result {
|
||||
case .success(let response):
|
||||
let accessToken = Credentials(type: .oauthAccessToken, username: response.id, secret: response.accessToken)
|
||||
|
||||
|
||||
let refreshToken: Credentials? = {
|
||||
guard let token = response.refreshToken else {
|
||||
return nil
|
||||
}
|
||||
return Credentials(type: .oauthRefreshToken, username: response.id, secret: token)
|
||||
}()
|
||||
|
||||
|
||||
let grant = OAuthAuthorizationGrant(accessToken: accessToken, refreshToken: refreshToken)
|
||||
|
||||
|
||||
completion(.success(grant))
|
||||
|
||||
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
@ -68,25 +68,25 @@ extension FeedlyAccountDelegate: OAuthAuthorizationGranting {
|
||||
}
|
||||
|
||||
extension FeedlyAccountDelegate: OAuthAccessTokenRefreshing {
|
||||
func refreshAccessToken(with refreshToken: String, client: OAuthAuthorizationClient, completion: @escaping (Result<OAuthAuthorizationGrant, Error>) -> ()) {
|
||||
func refreshAccessToken(with refreshToken: String, client: OAuthAuthorizationClient, completion: @escaping (Result<OAuthAuthorizationGrant, Error>) -> Void) {
|
||||
let request = OAuthRefreshAccessTokenRequest(refreshToken: refreshToken, scope: nil, client: client)
|
||||
|
||||
|
||||
caller.refreshAccessToken(request) { result in
|
||||
switch result {
|
||||
case .success(let response):
|
||||
let accessToken = Credentials(type: .oauthAccessToken, username: response.id, secret: response.accessToken)
|
||||
|
||||
|
||||
let refreshToken: Credentials? = {
|
||||
guard let token = response.refreshToken else {
|
||||
return nil
|
||||
}
|
||||
return Credentials(type: .oauthRefreshToken, username: response.id, secret: token)
|
||||
}()
|
||||
|
||||
|
||||
let grant = OAuthAuthorizationGrant(accessToken: accessToken, refreshToken: refreshToken)
|
||||
|
||||
|
||||
completion(.success(grant))
|
||||
|
||||
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
@ -28,15 +28,15 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
// TODO: Kiel, if you decide not to support OPML import you will have to disallow it in the behaviors
|
||||
// See https://developer.feedly.com/v3/opml/
|
||||
var behaviors: AccountBehaviors = [.disallowFeedInRootFolder, .disallowMarkAsUnreadAfterPeriod(31)]
|
||||
|
||||
|
||||
let isOPMLImportSupported = false
|
||||
|
||||
|
||||
var isOPMLImportInProgress = false
|
||||
|
||||
|
||||
var server: String? {
|
||||
return caller.server
|
||||
}
|
||||
|
||||
|
||||
var credentials: Credentials? {
|
||||
didSet {
|
||||
#if DEBUG
|
||||
@ -49,36 +49,36 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
caller.credentials = credentials
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let oauthAuthorizationClient: OAuthAuthorizationClient
|
||||
|
||||
|
||||
var accountMetadata: AccountMetadata?
|
||||
|
||||
|
||||
var refreshProgress = DownloadProgress(numberOfTasks: 0)
|
||||
|
||||
|
||||
/// Set on `accountDidInitialize` for the purposes of refreshing OAuth tokens when they expire.
|
||||
/// See the implementation for `FeedlyAPICallerDelegate`.
|
||||
private weak var initializedAccount: Account?
|
||||
|
||||
|
||||
internal let caller: FeedlyAPICaller
|
||||
|
||||
|
||||
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Feedly")
|
||||
private let database: SyncDatabase
|
||||
|
||||
|
||||
private weak var currentSyncAllOperation: MainThreadOperation?
|
||||
private let operationQueue = MainThreadOperationQueue()
|
||||
|
||||
|
||||
init(dataFolder: String, transport: Transport?, api: FeedlyAPICaller.API) {
|
||||
// Many operations have their own operation queues, such as the sync all operation.
|
||||
// Making this a serial queue at this higher level of abstraction means we can ensure,
|
||||
// for example, a `FeedlyRefreshAccessTokenOperation` occurs before a `FeedlySyncAllOperation`,
|
||||
// improving our ability to debug, reason about and predict the behaviour of the code.
|
||||
|
||||
|
||||
if let transport = transport {
|
||||
self.caller = FeedlyAPICaller(transport: transport, api: api)
|
||||
|
||||
|
||||
} else {
|
||||
|
||||
|
||||
let sessionConfiguration = URLSessionConfiguration.default
|
||||
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
sessionConfiguration.timeoutIntervalForRequest = 60.0
|
||||
@ -87,43 +87,43 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
sessionConfiguration.httpMaximumConnectionsPerHost = 1
|
||||
sessionConfiguration.httpCookieStorage = nil
|
||||
sessionConfiguration.urlCache = nil
|
||||
|
||||
|
||||
if let userAgentHeaders = UserAgent.headers() {
|
||||
sessionConfiguration.httpAdditionalHeaders = userAgentHeaders
|
||||
}
|
||||
|
||||
|
||||
let session = URLSession(configuration: sessionConfiguration)
|
||||
self.caller = FeedlyAPICaller(transport: session, api: api)
|
||||
}
|
||||
|
||||
|
||||
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3")
|
||||
self.database = SyncDatabase(databaseFilePath: databaseFilePath)
|
||||
self.oauthAuthorizationClient = api.oauthAuthorizationClient
|
||||
|
||||
|
||||
self.caller.delegate = self
|
||||
}
|
||||
|
||||
|
||||
// MARK: Account API
|
||||
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any], completion: @escaping () -> Void) {
|
||||
|
||||
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable: Any], completion: @escaping () -> Void) {
|
||||
completion()
|
||||
}
|
||||
|
||||
func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
|
||||
guard currentSyncAllOperation == nil else {
|
||||
os_log(.debug, log: log, "Ignoring refreshAll: Feedly sync already in progress.")
|
||||
completion(.success(()))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
guard let credentials = credentials else {
|
||||
os_log(.debug, log: log, "Ignoring refreshAll: Feedly account has no credentials.")
|
||||
completion(.failure(FeedlyAccountDelegateError.notLoggedIn))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
refreshProgress.reset()
|
||||
|
||||
let log = self.log
|
||||
@ -131,24 +131,24 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
let syncAllOperation = FeedlySyncAllOperation(account: account, feedlyUserId: credentials.username, caller: caller, database: database, lastSuccessfulFetchStartDate: accountMetadata?.lastArticleFetchStartTime, downloadProgress: refreshProgress, log: log)
|
||||
|
||||
syncAllOperation.downloadProgress = refreshProgress
|
||||
|
||||
|
||||
let date = Date()
|
||||
syncAllOperation.syncCompletionHandler = { [weak self] result in
|
||||
if case .success = result {
|
||||
self?.accountMetadata?.lastArticleFetchStartTime = date
|
||||
self?.accountMetadata?.lastArticleFetchEndTime = Date()
|
||||
}
|
||||
|
||||
|
||||
os_log(.debug, log: log, "Sync took %{public}.3f seconds", -date.timeIntervalSinceNow)
|
||||
completion(result)
|
||||
self?.refreshProgress.reset()
|
||||
}
|
||||
|
||||
|
||||
currentSyncAllOperation = syncAllOperation
|
||||
|
||||
|
||||
operationQueue.add(syncAllOperation)
|
||||
}
|
||||
|
||||
|
||||
func syncArticleStatus(for account: Account, completion: ((Result<Void, Error>) -> Void)? = nil) {
|
||||
sendArticleStatus(for: account) { result in
|
||||
switch result {
|
||||
@ -166,11 +166,11 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func sendArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
// Ensure remote articles have the same status as they do locally.
|
||||
let send = FeedlySendArticleStatusesOperation(database: database, service: caller, log: log)
|
||||
send.completionBlock = { operation in
|
||||
send.completionBlock = { _ in
|
||||
// TODO: not call with success if operation was canceled? Not sure.
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
@ -178,7 +178,7 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
}
|
||||
operationQueue.add(send)
|
||||
}
|
||||
|
||||
|
||||
/// Attempts to ensure local articles have the same status as they do remotely.
|
||||
/// So if the user is using another client roughly simultaneously with this app,
|
||||
/// this app does its part to ensure the articles have a consistent status between both.
|
||||
@ -189,45 +189,45 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
guard let credentials = credentials else {
|
||||
return completion(.success(()))
|
||||
}
|
||||
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
|
||||
let ingestUnread = FeedlyIngestUnreadArticleIdsOperation(account: account, userId: credentials.username, service: caller, database: database, newerThan: nil, log: log)
|
||||
|
||||
|
||||
group.enter()
|
||||
ingestUnread.completionBlock = { _ in
|
||||
group.leave()
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
let ingestStarred = FeedlyIngestStarredArticleIdsOperation(account: account, userId: credentials.username, service: caller, database: database, newerThan: nil, log: log)
|
||||
|
||||
|
||||
group.enter()
|
||||
ingestStarred.completionBlock = { _ in
|
||||
group.leave()
|
||||
}
|
||||
|
||||
|
||||
group.notify(queue: .main) {
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
|
||||
operationQueue.addOperations([ingestUnread, ingestStarred])
|
||||
}
|
||||
|
||||
|
||||
func importOPML(for account: Account, opmlFile: URL, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
let data: Data
|
||||
|
||||
|
||||
do {
|
||||
data = try Data(contentsOf: opmlFile)
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
os_log(.debug, log: log, "Begin importing OPML...")
|
||||
isOPMLImportInProgress = true
|
||||
refreshProgress.addToNumberOfTasksAndRemaining(1)
|
||||
|
||||
|
||||
caller.importOpml(data) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
@ -248,15 +248,15 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func createFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> Void) {
|
||||
|
||||
|
||||
let progress = refreshProgress
|
||||
progress.addToNumberOfTasksAndRemaining(1)
|
||||
|
||||
|
||||
caller.createCollection(named: name) { result in
|
||||
progress.completeTask()
|
||||
|
||||
|
||||
switch result {
|
||||
case .success(let collection):
|
||||
if let folder = account.ensureFolder(with: collection.label) {
|
||||
@ -271,16 +271,16 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let id = folder.externalID else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(FeedlyAccountDelegateError.unableToRenameFolder(folder.nameForDisplay, name)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let nameBefore = folder.name
|
||||
|
||||
|
||||
caller.renameCollection(with: id, to: name) { result in
|
||||
switch result {
|
||||
case .success(let collection):
|
||||
@ -291,23 +291,23 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
folder.name = name
|
||||
}
|
||||
|
||||
|
||||
func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let id = folder.externalID else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(FeedlyAccountDelegateError.unableToRemoveFolder(folder.nameForDisplay)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let progress = refreshProgress
|
||||
progress.addToNumberOfTasksAndRemaining(1)
|
||||
|
||||
|
||||
caller.deleteCollection(with: id) { result in
|
||||
progress.completeTask()
|
||||
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
account.removeFolder(folder)
|
||||
@ -317,14 +317,14 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func createFeed(for account: Account, url: String, name: String?, container: Container, validateFeed: Bool, completion: @escaping (Result<Feed, Error>) -> Void) {
|
||||
|
||||
|
||||
do {
|
||||
guard let credentials = credentials else {
|
||||
throw FeedlyAccountDelegateError.notLoggedIn
|
||||
}
|
||||
|
||||
|
||||
let addNewFeed = try FeedlyAddNewFeedOperation(account: account,
|
||||
credentials: credentials,
|
||||
url: url,
|
||||
@ -337,54 +337,54 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
container: container,
|
||||
progress: refreshProgress,
|
||||
log: log)
|
||||
|
||||
|
||||
addNewFeed.addCompletionHandler = { result in
|
||||
completion(result)
|
||||
}
|
||||
|
||||
|
||||
operationQueue.add(addNewFeed)
|
||||
|
||||
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
let folderCollectionIds = account.folders?.filter { $0.has(feed) }.compactMap { $0.externalID }
|
||||
guard let collectionIds = folderCollectionIds, let collectionId = collectionIds.first else {
|
||||
completion(.failure(FeedlyAccountDelegateError.unableToRenameFeed(feed.nameForDisplay, name)))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
let feedId = FeedlyFeedResourceId(id: feed.feedID)
|
||||
let editedNameBefore = feed.editedName
|
||||
|
||||
|
||||
// Adding an existing feed updates it.
|
||||
// Updating feed name in one folder/collection updates it for all folders/collections.
|
||||
caller.addFeed(with: feedId, title: name, toCollectionWith: collectionId) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
|
||||
|
||||
case .failure(let error):
|
||||
feed.editedName = editedNameBefore
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// optimistically set the name
|
||||
feed.editedName = name
|
||||
}
|
||||
|
||||
|
||||
func addFeed(for account: Account, with feed: Feed, to container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
|
||||
do {
|
||||
guard let credentials = credentials else {
|
||||
throw FeedlyAccountDelegateError.notLoggedIn
|
||||
}
|
||||
|
||||
|
||||
let resource = FeedlyFeedResourceId(id: feed.feedID)
|
||||
let addExistingFeed = try FeedlyAddExistingFeedOperation(account: account,
|
||||
credentials: credentials,
|
||||
@ -394,28 +394,27 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
progress: refreshProgress,
|
||||
log: log,
|
||||
customFeedName: feed.editedName)
|
||||
|
||||
|
||||
|
||||
addExistingFeed.addCompletionHandler = { result in
|
||||
completion(result)
|
||||
}
|
||||
|
||||
|
||||
operationQueue.add(addExistingFeed)
|
||||
|
||||
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let folder = container as? Folder, let collectionId = folder.externalID else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(FeedlyAccountDelegateError.unableToRemoveFeed(feed)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
caller.removeFeed(feed.feedID, fromCollectionWith: collectionId) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
@ -425,17 +424,17 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
folder.removeFeed(feed)
|
||||
}
|
||||
|
||||
|
||||
func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
guard let from = from as? Folder, let to = to as? Folder else {
|
||||
return DispatchQueue.main.async {
|
||||
completion(.failure(FeedlyAccountDelegateError.addFeedChooseFolder))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
addFeed(for: account, with: feed, to: to) { [weak self] addResult in
|
||||
switch addResult {
|
||||
// now that we have added the feed, remove it from the other collection
|
||||
@ -454,14 +453,14 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
to.removeFeed(feed)
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
// optimistically move the feed, undoing as appropriate to the failure
|
||||
from.removeFeed(feed)
|
||||
to.addFeed(feed)
|
||||
}
|
||||
|
||||
|
||||
func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
if let existingFeed = account.existingFeed(withURL: feed.url) {
|
||||
account.addFeed(existingFeed, to: container) { result in
|
||||
@ -483,14 +482,14 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
let group = DispatchGroup()
|
||||
|
||||
|
||||
for feed in folder.topLevelFeeds {
|
||||
|
||||
|
||||
folder.topLevelFeeds.remove(feed)
|
||||
|
||||
|
||||
group.enter()
|
||||
restoreFeed(for: account, feed: feed, container: folder) { result in
|
||||
group.leave()
|
||||
@ -501,15 +500,15 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
group.notify(queue: .main) {
|
||||
account.addFolder(folder)
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
account.update(articles, statusKey: statusKey, flag: flag) { result in
|
||||
switch result {
|
||||
@ -536,13 +535,13 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
initializedAccount = account
|
||||
credentials = try? account.retrieveCredentials(type: .oauthAccessToken)
|
||||
}
|
||||
|
||||
|
||||
func accountWillBeDeleted(_ account: Account) {
|
||||
let logout = FeedlyLogoutOperation(account: account, service: caller, log: log)
|
||||
// Dispatch on the shared queue because the lifetime of the account delegate is uncertain.
|
||||
MainThreadOperationQueue.shared.add(logout)
|
||||
}
|
||||
|
||||
|
||||
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, completion: @escaping (Result<Credentials?, Error>) -> Void) {
|
||||
assertionFailure("An `account` instance should enqueue an \(FeedlyRefreshAccessTokenOperation.self) instead.")
|
||||
completion(.success(credentials))
|
||||
@ -555,12 +554,12 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
caller.suspend()
|
||||
operationQueue.cancelAllOperations()
|
||||
}
|
||||
|
||||
|
||||
/// Suspend the SQLLite databases
|
||||
func suspendDatabase() {
|
||||
database.suspend()
|
||||
}
|
||||
|
||||
|
||||
/// Make sure no SQLite databases are open and we are ready to issue network requests.
|
||||
func resume() {
|
||||
database.resume()
|
||||
@ -569,35 +568,35 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
||||
}
|
||||
|
||||
extension FeedlyAccountDelegate: FeedlyAPICallerDelegate {
|
||||
|
||||
func reauthorizeFeedlyAPICaller(_ caller: FeedlyAPICaller, completionHandler: @escaping (Bool) -> ()) {
|
||||
|
||||
func reauthorizeFeedlyAPICaller(_ caller: FeedlyAPICaller, completionHandler: @escaping (Bool) -> Void) {
|
||||
guard let account = initializedAccount else {
|
||||
completionHandler(false)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
/// Captures a failure to refresh a token, assuming that it was refreshed unless told otherwise.
|
||||
final class RefreshAccessTokenOperationDelegate: FeedlyOperationDelegate {
|
||||
|
||||
|
||||
private(set) var didReauthorize = true
|
||||
|
||||
|
||||
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
|
||||
didReauthorize = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let refreshAccessToken = FeedlyRefreshAccessTokenOperation(account: account, service: self, oauthClient: oauthAuthorizationClient, log: log)
|
||||
refreshAccessToken.downloadProgress = refreshProgress
|
||||
|
||||
|
||||
/// This must be strongly referenced by the completionBlock of the `FeedlyRefreshAccessTokenOperation`.
|
||||
let refreshAccessTokenDelegate = RefreshAccessTokenOperationDelegate()
|
||||
refreshAccessToken.delegate = refreshAccessTokenDelegate
|
||||
|
||||
|
||||
refreshAccessToken.completionBlock = { operation in
|
||||
assert(Thread.isMainThread)
|
||||
completionHandler(refreshAccessTokenDelegate.didReauthorize && !operation.isCanceled)
|
||||
}
|
||||
|
||||
|
||||
MainThreadOperationQueue.shared.add(refreshAccessToken)
|
||||
}
|
||||
}
|
||||
|
@ -19,80 +19,80 @@ enum FeedlyAccountDelegateError: LocalizedError {
|
||||
case addFeedInvalidFolder(Folder)
|
||||
case unableToRenameFeed(String, String)
|
||||
case unableToRemoveFeed(Feed)
|
||||
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notLoggedIn:
|
||||
return NSLocalizedString("Please add the Feedly account again. If this problem persists, open Keychain Access and delete all feedly.com entries, then try again.", comment: "Feedly – Credentials not found.")
|
||||
|
||||
|
||||
case .unexpectedResourceId(let resourceId):
|
||||
let template = NSLocalizedString("Could not encode the identifier “%@”.", comment: "Feedly – Could not encode resource id to send to Feedly.")
|
||||
return String(format: template, resourceId)
|
||||
|
||||
|
||||
case .unableToAddFolder(let name):
|
||||
let template = NSLocalizedString("Could not create a folder named “%@”.", comment: "Feedly – Could not create a folder/collection.")
|
||||
return String(format: template, name)
|
||||
|
||||
|
||||
case .unableToRenameFolder(let from, let to):
|
||||
let template = NSLocalizedString("Could not rename “%@” to “%@”.", comment: "Feedly – Could not rename a folder/collection.")
|
||||
return String(format: template, from, to)
|
||||
|
||||
|
||||
case .unableToRemoveFolder(let name):
|
||||
let template = NSLocalizedString("Could not remove the folder named “%@”.", comment: "Feedly – Could not remove a folder/collection.")
|
||||
return String(format: template, name)
|
||||
|
||||
|
||||
case .unableToMoveFeedBetweenFolders(let feed, _, let to):
|
||||
let template = NSLocalizedString("Could not move “%@” to “%@”.", comment: "Feedly – Could not move a feed between folders/collections.")
|
||||
return String(format: template, feed.nameForDisplay, to.nameForDisplay)
|
||||
|
||||
|
||||
case .addFeedChooseFolder:
|
||||
return NSLocalizedString("Please choose a folder to contain the feed.", comment: "Feedly – Feed can only be added to folders.")
|
||||
|
||||
|
||||
case .addFeedInvalidFolder(let invalidFolder):
|
||||
let template = NSLocalizedString("Feeds cannot be added to the “%@” folder.", comment: "Feedly – Feed can only be added to folders.")
|
||||
return String(format: template, invalidFolder.nameForDisplay)
|
||||
|
||||
|
||||
case .unableToRenameFeed(let from, let to):
|
||||
let template = NSLocalizedString("Could not rename “%@” to “%@”.", comment: "Feedly – Could not rename a feed.")
|
||||
return String(format: template, from, to)
|
||||
|
||||
|
||||
case .unableToRemoveFeed(let feed):
|
||||
let template = NSLocalizedString("Could not remove “%@”.", comment: "Feedly – Could not remove a feed.")
|
||||
return String(format: template, feed.nameForDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var recoverySuggestion: String? {
|
||||
switch self {
|
||||
case .notLoggedIn:
|
||||
return nil
|
||||
|
||||
|
||||
case .unexpectedResourceId:
|
||||
let template = NSLocalizedString("Please contact NetNewsWire support.", comment: "Feedly – Recovery suggestion for not being able to encode a resource id to send to Feedly..")
|
||||
return String(format: template)
|
||||
|
||||
|
||||
case .unableToAddFolder:
|
||||
return nil
|
||||
|
||||
|
||||
case .unableToRenameFolder:
|
||||
return nil
|
||||
|
||||
|
||||
case .unableToRemoveFolder:
|
||||
return nil
|
||||
|
||||
|
||||
case .unableToMoveFeedBetweenFolders(let feed, let from, let to):
|
||||
let template = NSLocalizedString("“%@” may be in both “%@” and “%@”.", comment: "Feedly – Could not move a feed between folders/collections.")
|
||||
return String(format: template, feed.nameForDisplay, from.nameForDisplay, to.nameForDisplay)
|
||||
|
||||
|
||||
case .addFeedChooseFolder:
|
||||
return nil
|
||||
|
||||
|
||||
case .addFeedInvalidFolder:
|
||||
return NSLocalizedString("Please choose a different folder to contain the feed.", comment: "Feedly – Feed can only be added to folders recovery suggestion.")
|
||||
|
||||
|
||||
case .unableToRemoveFeed:
|
||||
return nil
|
||||
|
||||
|
||||
case .unableToRenameFeed:
|
||||
return nil
|
||||
}
|
||||
|
@ -10,16 +10,16 @@ import Foundation
|
||||
|
||||
struct FeedlyFeedContainerValidator {
|
||||
var container: Container
|
||||
|
||||
|
||||
func getValidContainer() throws -> (Folder, String) {
|
||||
guard let folder = container as? Folder else {
|
||||
throw FeedlyAccountDelegateError.addFeedChooseFolder
|
||||
}
|
||||
|
||||
|
||||
guard let collectionId = folder.externalID else {
|
||||
throw FeedlyAccountDelegateError.addFeedInvalidFolder(folder)
|
||||
}
|
||||
|
||||
|
||||
return (folder, collectionId)
|
||||
}
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ protocol FeedlyResourceProviding {
|
||||
}
|
||||
|
||||
extension FeedlyFeedResourceId: FeedlyResourceProviding {
|
||||
|
||||
|
||||
var resource: FeedlyResourceId {
|
||||
return self
|
||||
}
|
||||
|
@ -12,11 +12,11 @@ struct FeedlyCollectionParser {
|
||||
let collection: FeedlyCollection
|
||||
|
||||
private let rightToLeftTextSantizer = FeedlyRTLTextSanitizer()
|
||||
|
||||
|
||||
var folderName: String {
|
||||
return rightToLeftTextSantizer.sanitize(collection.label) ?? ""
|
||||
}
|
||||
|
||||
|
||||
var externalID: String {
|
||||
return collection.id
|
||||
}
|
||||
|
@ -11,30 +11,30 @@ import Foundation
|
||||
struct FeedlyEntry: Decodable {
|
||||
/// the unique, immutable ID for this particular article.
|
||||
let id: String
|
||||
|
||||
|
||||
/// the article’s title. This string does not contain any HTML markup.
|
||||
let title: String?
|
||||
|
||||
|
||||
struct Content: Decodable {
|
||||
|
||||
|
||||
enum Direction: String, Decodable {
|
||||
case leftToRight = "ltr"
|
||||
case rightToLeft = "rtl"
|
||||
}
|
||||
|
||||
|
||||
let content: String?
|
||||
let direction: Direction?
|
||||
}
|
||||
|
||||
|
||||
/// This object typically has two values: “content” for the content itself, and “direction” (“ltr” for left-to-right, “rtl” for right-to-left). The content itself contains sanitized HTML markup.
|
||||
let content: Content?
|
||||
|
||||
|
||||
/// content object the article summary. See the content object above.
|
||||
let summary: Content?
|
||||
|
||||
|
||||
/// the author’s name
|
||||
let author: String?
|
||||
|
||||
|
||||
/// the immutable timestamp, in ms, when this article was processed by the feedly Cloud servers.
|
||||
let crawled: Date
|
||||
|
||||
@ -43,7 +43,7 @@ struct FeedlyEntry: Decodable {
|
||||
|
||||
/// the feed from which this article was crawled. If present, “streamId” will contain the feed id, “title” will contain the feed title, and “htmlUrl” will contain the feed’s website.
|
||||
let origin: FeedlyOrigin?
|
||||
|
||||
|
||||
/// Used to help find the URL to visit an article on a web site.
|
||||
/// See https://groups.google.com/forum/#!searchin/feedly-cloud/feed$20url%7Csort:date/feedly-cloud/Rx3dVd4aTFQ/Hf1ZfLJoCQAJ
|
||||
let canonical: [FeedlyLink]?
|
||||
|
@ -14,15 +14,15 @@ protocol FeedlyEntryIdentifierProviding: AnyObject {
|
||||
|
||||
final class FeedlyEntryIdentifierProvider: FeedlyEntryIdentifierProviding {
|
||||
private(set) var entryIds: Set<String>
|
||||
|
||||
|
||||
init(entryIds: Set<String> = Set()) {
|
||||
self.entryIds = entryIds
|
||||
}
|
||||
|
||||
|
||||
func addEntryIds(from provider: FeedlyEntryIdentifierProviding) {
|
||||
entryIds.formUnion(provider.entryIds)
|
||||
}
|
||||
|
||||
|
||||
func addEntryIds(in articleIds: [String]) {
|
||||
entryIds.formUnion(articleIds)
|
||||
}
|
||||
|
@ -12,13 +12,13 @@ import Parser
|
||||
|
||||
struct FeedlyEntryParser {
|
||||
let entry: FeedlyEntry
|
||||
|
||||
|
||||
private let rightToLeftTextSantizer = FeedlyRTLTextSanitizer()
|
||||
|
||||
|
||||
var id: String {
|
||||
return entry.id
|
||||
}
|
||||
|
||||
|
||||
/// When ingesting articles, the feedURL must match a feed's `feedID` for the article to be reachable between it and its matching feed. It reminds me of a foreign key.
|
||||
var feedUrl: String? {
|
||||
guard let id = entry.origin?.streamId else {
|
||||
@ -28,7 +28,7 @@ struct FeedlyEntryParser {
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
|
||||
/// Convoluted external URL logic "documented" here:
|
||||
/// https://groups.google.com/forum/#!searchin/feedly-cloud/feed$20url%7Csort:date/feedly-cloud/Rx3dVd4aTFQ/Hf1ZfLJoCQAJ
|
||||
var externalUrl: String? {
|
||||
@ -38,39 +38,39 @@ struct FeedlyEntryParser {
|
||||
let webPageLinks = flattened.filter { $0.type == nil || $0.type == "text/html" }
|
||||
return webPageLinks.first?.href
|
||||
}
|
||||
|
||||
|
||||
var title: String? {
|
||||
return rightToLeftTextSantizer.sanitize(entry.title)
|
||||
}
|
||||
|
||||
|
||||
var contentHMTL: String? {
|
||||
return entry.content?.content ?? entry.summary?.content
|
||||
}
|
||||
|
||||
|
||||
var contentText: String? {
|
||||
// We could strip HTML from contentHTML?
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
var summary: String? {
|
||||
return rightToLeftTextSantizer.sanitize(entry.summary?.content)
|
||||
}
|
||||
|
||||
|
||||
var datePublished: Date {
|
||||
return entry.crawled
|
||||
}
|
||||
|
||||
|
||||
var dateModified: Date? {
|
||||
return entry.recrawled
|
||||
}
|
||||
|
||||
|
||||
var authors: Set<ParsedAuthor>? {
|
||||
guard let name = entry.author else {
|
||||
return nil
|
||||
}
|
||||
return Set([ParsedAuthor(name: name, url: nil, avatarURL: nil, emailAddress: nil)])
|
||||
}
|
||||
|
||||
|
||||
/// While there is not yet a tagging interface, articles can still be searched for by tags.
|
||||
var tags: Set<String>? {
|
||||
guard let labels = entry.tags?.compactMap({ $0.label }), !labels.isEmpty else {
|
||||
@ -78,7 +78,7 @@ struct FeedlyEntryParser {
|
||||
}
|
||||
return Set(labels)
|
||||
}
|
||||
|
||||
|
||||
var attachments: Set<ParsedAttachment>? {
|
||||
guard let enclosure = entry.enclosure, !enclosure.isEmpty else {
|
||||
return nil
|
||||
@ -86,12 +86,12 @@ struct FeedlyEntryParser {
|
||||
let attachments = enclosure.compactMap { ParsedAttachment(url: $0.href, mimeType: $0.type, title: nil, sizeInBytes: nil, durationInSeconds: nil) }
|
||||
return attachments.isEmpty ? nil : Set(attachments)
|
||||
}
|
||||
|
||||
|
||||
var parsedItemRepresentation: ParsedItem? {
|
||||
guard let feedUrl = feedUrl else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
return ParsedItem(syncServiceID: id,
|
||||
uniqueID: id, // This value seems to get ignored or replaced.
|
||||
feedURL: feedUrl,
|
||||
|
@ -12,20 +12,20 @@ struct FeedlyFeedParser {
|
||||
let feed: FeedlyFeed
|
||||
|
||||
private let rightToLeftTextSantizer = FeedlyRTLTextSanitizer()
|
||||
|
||||
|
||||
var title: String? {
|
||||
return rightToLeftTextSantizer.sanitize(feed.title) ?? ""
|
||||
}
|
||||
|
||||
|
||||
var feedID: String {
|
||||
return feed.id
|
||||
}
|
||||
|
||||
|
||||
var url: String {
|
||||
let resource = FeedlyFeedResourceId(id: feed.id)
|
||||
return resource.url
|
||||
}
|
||||
|
||||
|
||||
var homePageURL: String? {
|
||||
return feed.website
|
||||
}
|
||||
|
@ -9,11 +9,11 @@
|
||||
import Foundation
|
||||
|
||||
struct FeedlyFeedsSearchResponse: Decodable {
|
||||
|
||||
|
||||
struct Feed: Decodable {
|
||||
let title: String
|
||||
let feedId: String
|
||||
}
|
||||
|
||||
|
||||
let results: [Feed]
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ import Foundation
|
||||
|
||||
struct FeedlyLink: Decodable {
|
||||
let href: String
|
||||
|
||||
|
||||
/// The mime type of the resource located by `href`.
|
||||
/// When `nil`, it's probably a web page?
|
||||
/// https://groups.google.com/forum/#!searchin/feedly-cloud/feed$20url%7Csort:date/feedly-cloud/Rx3dVd4aTFQ/Hf1ZfLJoCQAJ
|
||||
|
@ -11,16 +11,16 @@ import Foundation
|
||||
struct FeedlyRTLTextSanitizer {
|
||||
private let rightToLeftPrefix = "<div style=\"direction:rtl;text-align:right\">"
|
||||
private let rightToLeftSuffix = "</div>"
|
||||
|
||||
|
||||
func sanitize(_ sourceText: String?) -> String? {
|
||||
guard let source = sourceText, !source.isEmpty else {
|
||||
return sourceText
|
||||
}
|
||||
|
||||
|
||||
guard source.hasPrefix(rightToLeftPrefix) && source.hasSuffix(rightToLeftSuffix) else {
|
||||
return source
|
||||
}
|
||||
|
||||
|
||||
let start = source.index(source.startIndex, offsetBy: rightToLeftPrefix.indices.count)
|
||||
let end = source.index(source.endIndex, offsetBy: -rightToLeftSuffix.indices.count)
|
||||
return String(source[start..<end])
|
||||
|
@ -10,7 +10,7 @@ import Foundation
|
||||
|
||||
/// The kinds of Resource Ids is documented here: https://developer.feedly.com/cloud/
|
||||
protocol FeedlyResourceId {
|
||||
|
||||
|
||||
/// The resource Id from Feedly.
|
||||
var id: String { get }
|
||||
}
|
||||
@ -18,7 +18,7 @@ protocol FeedlyResourceId {
|
||||
/// The Feed Resource is documented here: https://developer.feedly.com/cloud/
|
||||
struct FeedlyFeedResourceId: FeedlyResourceId {
|
||||
let id: String
|
||||
|
||||
|
||||
/// The location of the kind of resource a concrete type represents.
|
||||
/// If the concrete type cannot strip the resource type from the Id, it should just return the Id
|
||||
/// since the Id is a legitimate URL.
|
||||
@ -32,7 +32,7 @@ struct FeedlyFeedResourceId: FeedlyResourceId {
|
||||
mutant.removeSubrange(range)
|
||||
return mutant
|
||||
}
|
||||
|
||||
|
||||
// It seems values like "something/https://my.blog/posts.xml" is a legit URL.
|
||||
return id
|
||||
}
|
||||
@ -46,22 +46,22 @@ extension FeedlyFeedResourceId {
|
||||
|
||||
struct FeedlyCategoryResourceId: FeedlyResourceId {
|
||||
let id: String
|
||||
|
||||
|
||||
enum Global {
|
||||
|
||||
|
||||
static func uncategorized(for userId: String) -> FeedlyCategoryResourceId {
|
||||
// https://developer.feedly.com/cloud/#global-resource-ids
|
||||
let id = "user/\(userId)/category/global.uncategorized"
|
||||
return FeedlyCategoryResourceId(id: id)
|
||||
}
|
||||
|
||||
|
||||
/// All articles from all the feeds the user subscribes to.
|
||||
static func all(for userId: String) -> FeedlyCategoryResourceId {
|
||||
// https://developer.feedly.com/cloud/#global-resource-ids
|
||||
let id = "user/\(userId)/category/global.all"
|
||||
return FeedlyCategoryResourceId(id: id)
|
||||
}
|
||||
|
||||
|
||||
/// All articles from all the feeds the user loves most.
|
||||
static func mustRead(for userId: String) -> FeedlyCategoryResourceId {
|
||||
// https://developer.feedly.com/cloud/#global-resource-ids
|
||||
@ -73,9 +73,9 @@ struct FeedlyCategoryResourceId: FeedlyResourceId {
|
||||
|
||||
struct FeedlyTagResourceId: FeedlyResourceId {
|
||||
let id: String
|
||||
|
||||
|
||||
enum Global {
|
||||
|
||||
|
||||
static func saved(for userId: String) -> FeedlyTagResourceId {
|
||||
// https://developer.feedly.com/cloud/#global-resource-ids
|
||||
let id = "user/\(userId)/tag/global.saved"
|
||||
|
@ -10,16 +10,16 @@ import Foundation
|
||||
|
||||
struct FeedlyStream: Decodable {
|
||||
let id: String
|
||||
|
||||
|
||||
/// Of the most recent entry for this stream (regardless of continuation, newerThan, etc).
|
||||
let updated: Date?
|
||||
|
||||
|
||||
/// the continuation id to pass to the next stream call, for pagination.
|
||||
/// This id guarantees that no entry will be duplicated in a stream (meaning, there is no need to de-duplicate entries returned by this call).
|
||||
/// If this value is not returned, it means the end of the stream has been reached.
|
||||
let continuation: String?
|
||||
let items: [FeedlyEntry]
|
||||
|
||||
|
||||
var isStreamEnd: Bool {
|
||||
return continuation == nil
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ import Foundation
|
||||
struct FeedlyStreamIds: Decodable {
|
||||
let continuation: String?
|
||||
let ids: [String]
|
||||
|
||||
|
||||
var isStreamEnd: Bool {
|
||||
return continuation == nil
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ public protocol OAuthAccountAuthorizationOperationDelegate: AnyObject {
|
||||
|
||||
public enum OAuthAccountAuthorizationOperationError: LocalizedError {
|
||||
case duplicateAccount
|
||||
|
||||
|
||||
public var errorDescription: String? {
|
||||
return NSLocalizedString("There is already a Feedly account with that username created.", comment: "Duplicate Error")
|
||||
}
|
||||
@ -38,44 +38,44 @@ public enum OAuthAccountAuthorizationOperationError: LocalizedError {
|
||||
|
||||
public weak var presentationAnchor: ASPresentationAnchor?
|
||||
public weak var delegate: OAuthAccountAuthorizationOperationDelegate?
|
||||
|
||||
|
||||
private let accountType: AccountType
|
||||
private let oauthClient: OAuthAuthorizationClient
|
||||
private var session: ASWebAuthenticationSession?
|
||||
|
||||
|
||||
public init(accountType: AccountType) {
|
||||
self.accountType = accountType
|
||||
self.oauthClient = Account.oauthAuthorizationClient(for: accountType)
|
||||
}
|
||||
|
||||
|
||||
public func run() {
|
||||
assert(presentationAnchor != nil, "\(self) outlived presentation anchor.")
|
||||
|
||||
|
||||
let request = Account.oauthAuthorizationCodeGrantRequest(for: accountType)
|
||||
|
||||
|
||||
guard let url = request.url else {
|
||||
return DispatchQueue.main.async {
|
||||
self.didEndAuthentication(url: nil, error: URLError(.badURL))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
guard let redirectUri = URL(string: oauthClient.redirectUri), let scheme = redirectUri.scheme else {
|
||||
assertionFailure("Could not get callback URL scheme from \(oauthClient.redirectUri)")
|
||||
return DispatchQueue.main.async {
|
||||
self.didEndAuthentication(url: nil, error: URLError(.badURL))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { url, error in
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.didEndAuthentication(url: url, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
session.presentationContextProvider = self
|
||||
|
||||
|
||||
guard session.start() else {
|
||||
|
||||
|
||||
/// Documentation does not say on why `ASWebAuthenticationSession.start` or `canStart` might return false.
|
||||
/// Perhaps it has something to do with an inter-process communication failure? No browsers installed? No browsers that support web authentication?
|
||||
struct UnableToStartASWebAuthenticationSessionError: LocalizedError {
|
||||
@ -84,25 +84,25 @@ public enum OAuthAccountAuthorizationOperationError: LocalizedError {
|
||||
let recoverySuggestion: String? = NSLocalizedString("Check your default web browser in System Preferences or change it to Safari and try again.",
|
||||
comment: "OAuth - recovery suggestion - ensure browser selected supports web authentication.")
|
||||
}
|
||||
|
||||
|
||||
didFinish(UnableToStartASWebAuthenticationSessionError())
|
||||
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
self.session = session
|
||||
}
|
||||
|
||||
|
||||
public func cancel() {
|
||||
session?.cancel()
|
||||
}
|
||||
|
||||
|
||||
private func didEndAuthentication(url: URL?, error: Error?) {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
do {
|
||||
guard let url = url else {
|
||||
if let error = error {
|
||||
@ -110,32 +110,32 @@ public enum OAuthAccountAuthorizationOperationError: LocalizedError {
|
||||
}
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
|
||||
let response = try OAuthAuthorizationResponse(url: url, client: oauthClient)
|
||||
|
||||
|
||||
Account.requestOAuthAccessToken(with: response, client: oauthClient, accountType: accountType, completion: didEndRequestingAccessToken(_:))
|
||||
|
||||
|
||||
} catch is ASWebAuthenticationSessionError {
|
||||
didFinish() // Primarily, cancellation.
|
||||
|
||||
|
||||
} catch {
|
||||
didFinish(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
guard let anchor = presentationAnchor else {
|
||||
fatalError("\(self) has outlived presentation anchor.")
|
||||
}
|
||||
return anchor
|
||||
}
|
||||
|
||||
|
||||
private func didEndRequestingAccessToken(_ result: Result<OAuthAuthorizationGrant, Error>) {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
switch result {
|
||||
case .success(let tokenResponse):
|
||||
saveAccount(for: tokenResponse)
|
||||
@ -143,39 +143,39 @@ public enum OAuthAccountAuthorizationOperationError: LocalizedError {
|
||||
didFinish(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func saveAccount(for grant: OAuthAuthorizationGrant) {
|
||||
guard !AccountManager.shared.duplicateServiceAccount(type: .feedly, username: grant.accessToken.username) else {
|
||||
didFinish(OAuthAccountAuthorizationOperationError.duplicateAccount)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
let account = AccountManager.shared.createAccount(type: .feedly)
|
||||
do {
|
||||
|
||||
|
||||
// Store the refresh token first because it sends this token to the account delegate.
|
||||
if let token = grant.refreshToken {
|
||||
try account.storeCredentials(token)
|
||||
}
|
||||
|
||||
|
||||
// Now store the access token because we want the account delegate to use it.
|
||||
try account.storeCredentials(grant.accessToken)
|
||||
|
||||
|
||||
delegate?.oauthAccountAuthorizationOperation(self, didCreate: account)
|
||||
|
||||
|
||||
didFinish()
|
||||
} catch {
|
||||
didFinish(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: Managing Operation State
|
||||
|
||||
|
||||
private func didFinish() {
|
||||
assert(Thread.isMainThread)
|
||||
operationDelegate?.operationDidComplete(self)
|
||||
}
|
||||
|
||||
|
||||
private func didFinish(_ error: Error) {
|
||||
assert(Thread.isMainThread)
|
||||
delegate?.oauthAccountAuthorizationOperation(self, didFailWith: error)
|
||||
|
@ -15,11 +15,11 @@ public struct OAuthRefreshAccessTokenRequest: Encodable {
|
||||
public let grantType = "refresh_token"
|
||||
public var refreshToken: String
|
||||
public var scope: String?
|
||||
|
||||
|
||||
// Possibly not part of the standard but specific to certain implementations (e.g.: Feedly).
|
||||
public var clientId: String
|
||||
public var clientSecret: String
|
||||
|
||||
|
||||
public init(refreshToken: String, scope: String?, client: OAuthAuthorizationClient) {
|
||||
self.refreshToken = refreshToken
|
||||
self.scope = scope
|
||||
@ -32,15 +32,15 @@ public struct OAuthRefreshAccessTokenRequest: Encodable {
|
||||
/// https://tools.ietf.org/html/rfc6749#section-6
|
||||
public protocol OAuthAcessTokenRefreshRequesting {
|
||||
associatedtype AccessTokenResponse: OAuthAccessTokenResponse
|
||||
|
||||
|
||||
/// Access tokens expire. Perform a request for a fresh access token given the long life refresh token received when authorization was granted.
|
||||
/// - Parameter refreshRequest: The refresh token and other information the authorization server requires to grant the client fresh access tokens on the user's behalf.
|
||||
/// - Parameter completion: On success, the access token response appropriate for concrete type's service. Both the access and refresh token should be stored, preferably on the Keychain. On failure, possibly a `URLError` or `OAuthAuthorizationErrorResponse` value.
|
||||
func refreshAccessToken(_ refreshRequest: OAuthRefreshAccessTokenRequest, completion: @escaping (Result<AccessTokenResponse, Error>) -> ())
|
||||
func refreshAccessToken(_ refreshRequest: OAuthRefreshAccessTokenRequest, completion: @escaping (Result<AccessTokenResponse, Error>) -> Void)
|
||||
}
|
||||
|
||||
/// Implemented by concrete types to perform the actual request.
|
||||
protocol OAuthAccessTokenRefreshing: AnyObject {
|
||||
|
||||
func refreshAccessToken(with refreshToken: String, client: OAuthAuthorizationClient, completion: @escaping (Result<OAuthAuthorizationGrant, Error>) -> ())
|
||||
|
||||
func refreshAccessToken(with refreshToken: String, client: OAuthAuthorizationClient, completion: @escaping (Result<OAuthAuthorizationGrant, Error>) -> Void)
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ import Foundation
|
||||
import Secrets
|
||||
|
||||
extension OAuthAuthorizationClient {
|
||||
|
||||
|
||||
static var feedlyCloudClient: OAuthAuthorizationClient {
|
||||
/// Models private NetNewsWire client secrets.
|
||||
/// These placeholders are substituted at build time using a Run Script phase with build settings.
|
||||
@ -20,7 +20,7 @@ extension OAuthAuthorizationClient {
|
||||
state: nil,
|
||||
secret: SecretKey.feedlyClientSecret)
|
||||
}
|
||||
|
||||
|
||||
static var feedlySandboxClient: OAuthAuthorizationClient {
|
||||
/// We use this funky redirect URI because ASWebAuthenticationSession will try to load http://localhost URLs.
|
||||
/// See https://developer.feedly.com/v3/sandbox/ for more information.
|
||||
|
@ -15,36 +15,36 @@ import Secrets
|
||||
class FeedlyAddExistingFeedOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyCheckpointOperationDelegate {
|
||||
|
||||
private let operationQueue = MainThreadOperationQueue()
|
||||
var addCompletionHandler: ((Result<Void, Error>) -> ())?
|
||||
var addCompletionHandler: ((Result<Void, Error>) -> Void)?
|
||||
|
||||
init(account: Account, credentials: Credentials, resource: FeedlyFeedResourceId, service: FeedlyAddFeedToCollectionService, container: Container, progress: DownloadProgress, log: OSLog, customFeedName: String? = nil) throws {
|
||||
|
||||
|
||||
let validator = FeedlyFeedContainerValidator(container: container)
|
||||
let (folder, collectionId) = try validator.getValidContainer()
|
||||
|
||||
|
||||
self.operationQueue.suspend()
|
||||
|
||||
super.init()
|
||||
|
||||
|
||||
self.downloadProgress = progress
|
||||
|
||||
|
||||
let addRequest = FeedlyAddFeedToCollectionOperation(account: account, folder: folder, feedResource: resource, feedName: customFeedName, collectionId: collectionId, service: service)
|
||||
addRequest.delegate = self
|
||||
addRequest.downloadProgress = progress
|
||||
self.operationQueue.add(addRequest)
|
||||
|
||||
|
||||
let createFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: addRequest, log: log)
|
||||
createFeeds.downloadProgress = progress
|
||||
createFeeds.addDependency(addRequest)
|
||||
self.operationQueue.add(createFeeds)
|
||||
|
||||
|
||||
let finishOperation = FeedlyCheckpointOperation()
|
||||
finishOperation.checkpointDelegate = self
|
||||
finishOperation.downloadProgress = progress
|
||||
finishOperation.addDependency(createFeeds)
|
||||
self.operationQueue.add(finishOperation)
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
operationQueue.resume()
|
||||
}
|
||||
@ -54,22 +54,22 @@ class FeedlyAddExistingFeedOperation: FeedlyOperation, FeedlyOperationDelegate,
|
||||
addCompletionHandler = nil
|
||||
super.didCancel()
|
||||
}
|
||||
|
||||
|
||||
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
|
||||
addCompletionHandler?(.failure(error))
|
||||
addCompletionHandler = nil
|
||||
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
|
||||
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) {
|
||||
guard !isCanceled else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
addCompletionHandler?(.success(()))
|
||||
addCompletionHandler = nil
|
||||
|
||||
|
||||
didFinish()
|
||||
}
|
||||
}
|
||||
|
@ -9,7 +9,7 @@
|
||||
import Foundation
|
||||
|
||||
protocol FeedlyAddFeedToCollectionService {
|
||||
func addFeed(with feedId: FeedlyFeedResourceId, title: String?, toCollectionWith collectionId: String, completion: @escaping (Result<[FeedlyFeed], Error>) -> ())
|
||||
func addFeed(with feedId: FeedlyFeedResourceId, title: String?, toCollectionWith collectionId: String, completion: @escaping (Result<[FeedlyFeed], Error>) -> Void)
|
||||
}
|
||||
|
||||
final class FeedlyAddFeedToCollectionOperation: FeedlyOperation, FeedlyFeedsAndFoldersProviding, FeedlyResourceProviding {
|
||||
@ -29,13 +29,13 @@ final class FeedlyAddFeedToCollectionOperation: FeedlyOperation, FeedlyFeedsAndF
|
||||
self.collectionId = collectionId
|
||||
self.service = service
|
||||
}
|
||||
|
||||
|
||||
private(set) var feedsAndFolders = [([FeedlyFeed], Folder)]()
|
||||
|
||||
|
||||
var resource: FeedlyResourceId {
|
||||
return feedResource
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
service.addFeed(with: feedResource, title: feedName, toCollectionWith: collectionId) { [weak self] result in
|
||||
guard let self = self else {
|
||||
@ -56,15 +56,15 @@ private extension FeedlyAddFeedToCollectionOperation {
|
||||
switch result {
|
||||
case .success(let feedlyFeeds):
|
||||
feedsAndFolders = [(feedlyFeeds, folder)]
|
||||
|
||||
|
||||
let feedsWithCreatedFeedId = feedlyFeeds.filter { $0.id == resource.id }
|
||||
|
||||
|
||||
if feedsWithCreatedFeedId.isEmpty {
|
||||
didFinish(with: AccountError.createErrorNotFound)
|
||||
} else {
|
||||
didFinish()
|
||||
}
|
||||
|
||||
|
||||
case .failure(let error):
|
||||
didFinish(with: error)
|
||||
}
|
||||
|
@ -28,14 +28,13 @@ class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, Feedl
|
||||
private let getStreamContentsService: FeedlyGetStreamContentsService
|
||||
private let log: OSLog
|
||||
private var feedResourceId: FeedlyFeedResourceId?
|
||||
var addCompletionHandler: ((Result<Feed, Error>) -> ())?
|
||||
var addCompletionHandler: ((Result<Feed, Error>) -> Void)?
|
||||
|
||||
init(account: Account, credentials: Credentials, url: String, feedName: String?, searchService: FeedlySearchService, addToCollectionService: FeedlyAddFeedToCollectionService, syncUnreadIdsService: FeedlyGetStreamIdsService, getStreamContentsService: FeedlyGetStreamContentsService, database: SyncDatabase, container: Container, progress: DownloadProgress, log: OSLog) throws {
|
||||
|
||||
|
||||
let validator = FeedlyFeedContainerValidator(container: container)
|
||||
(self.folder, self.collectionId) = try validator.getValidContainer()
|
||||
|
||||
|
||||
self.url = url
|
||||
self.operationQueue.suspend()
|
||||
self.account = account
|
||||
@ -50,14 +49,14 @@ class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, Feedl
|
||||
super.init()
|
||||
|
||||
self.downloadProgress = progress
|
||||
|
||||
|
||||
let search = FeedlySearchOperation(query: url, locale: .current, service: searchService)
|
||||
search.delegate = self
|
||||
search.searchDelegate = self
|
||||
search.downloadProgress = progress
|
||||
self.operationQueue.add(search)
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
operationQueue.resume()
|
||||
}
|
||||
@ -67,7 +66,7 @@ class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, Feedl
|
||||
addCompletionHandler = nil
|
||||
super.didCancel()
|
||||
}
|
||||
|
||||
|
||||
override func didFinish(with error: Error) {
|
||||
assert(Thread.isMainThread)
|
||||
addCompletionHandler?(.failure(error))
|
||||
@ -82,33 +81,33 @@ class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, Feedl
|
||||
guard let first = response.results.first else {
|
||||
return didFinish(with: AccountError.createErrorNotFound)
|
||||
}
|
||||
|
||||
|
||||
let feedResourceId = FeedlyFeedResourceId(id: first.feedId)
|
||||
self.feedResourceId = feedResourceId
|
||||
|
||||
|
||||
let addRequest = FeedlyAddFeedToCollectionOperation(account: account, folder: folder, feedResource: feedResourceId, feedName: feedName, collectionId: collectionId, service: addToCollectionService)
|
||||
addRequest.delegate = self
|
||||
addRequest.downloadProgress = downloadProgress
|
||||
operationQueue.add(addRequest)
|
||||
|
||||
|
||||
let createFeeds = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: addRequest, log: log)
|
||||
createFeeds.delegate = self
|
||||
createFeeds.addDependency(addRequest)
|
||||
createFeeds.downloadProgress = downloadProgress
|
||||
operationQueue.add(createFeeds)
|
||||
|
||||
|
||||
let syncUnread = FeedlyIngestUnreadArticleIdsOperation(account: account, userId: credentials.username, service: syncUnreadIdsService, database: database, newerThan: nil, log: log)
|
||||
syncUnread.addDependency(createFeeds)
|
||||
syncUnread.downloadProgress = downloadProgress
|
||||
syncUnread.delegate = self
|
||||
operationQueue.add(syncUnread)
|
||||
|
||||
|
||||
let syncFeed = FeedlySyncStreamContentsOperation(account: account, resource: feedResourceId, service: getStreamContentsService, isPagingEnabled: false, newerThan: nil, log: log)
|
||||
syncFeed.addDependency(syncUnread)
|
||||
syncFeed.downloadProgress = downloadProgress
|
||||
syncFeed.delegate = self
|
||||
operationQueue.add(syncFeed)
|
||||
|
||||
|
||||
let finishOperation = FeedlyCheckpointOperation()
|
||||
finishOperation.checkpointDelegate = self
|
||||
finishOperation.downloadProgress = downloadProgress
|
||||
@ -116,16 +115,16 @@ class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, Feedl
|
||||
finishOperation.delegate = self
|
||||
operationQueue.add(finishOperation)
|
||||
}
|
||||
|
||||
|
||||
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
|
||||
addCompletionHandler?(.failure(error))
|
||||
addCompletionHandler = nil
|
||||
|
||||
|
||||
os_log(.debug, log: log, "Unable to add new feed: %{public}@.", error as NSError)
|
||||
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
|
||||
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) {
|
||||
guard !isCanceled else {
|
||||
return
|
||||
@ -133,14 +132,13 @@ class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, Feedl
|
||||
defer {
|
||||
didFinish()
|
||||
}
|
||||
|
||||
|
||||
guard let handler = addCompletionHandler else {
|
||||
return
|
||||
}
|
||||
if let feedResource = feedResourceId, let feed = folder.existingFeed(withFeedID: feedResource.id) {
|
||||
handler(.success(feed))
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
handler(.failure(AccountError.createErrorNotFound))
|
||||
}
|
||||
addCompletionHandler = nil
|
||||
|
@ -11,7 +11,7 @@ import os.log
|
||||
|
||||
/// Single responsibility is to accurately reflect Collections and their Feeds as Folders and their Feeds.
|
||||
final class FeedlyCreateFeedsForCollectionFoldersOperation: FeedlyOperation {
|
||||
|
||||
|
||||
let account: Account
|
||||
let feedsAndFoldersProvider: FeedlyFeedsAndFoldersProviding
|
||||
let log: OSLog
|
||||
@ -21,18 +21,18 @@ final class FeedlyCreateFeedsForCollectionFoldersOperation: FeedlyOperation {
|
||||
self.account = account
|
||||
self.log = log
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
defer {
|
||||
didFinish()
|
||||
}
|
||||
|
||||
let pairs = feedsAndFoldersProvider.feedsAndFolders
|
||||
|
||||
|
||||
let feedsBefore = Set(pairs
|
||||
.map { $0.1 }
|
||||
.flatMap { $0.topLevelFeeds })
|
||||
|
||||
|
||||
// Remove feeds in a folder which are not in the corresponding collection.
|
||||
for (collectionFeeds, folder) in pairs {
|
||||
let feedsInFolder = folder.topLevelFeeds
|
||||
@ -42,12 +42,12 @@ final class FeedlyCreateFeedsForCollectionFoldersOperation: FeedlyOperation {
|
||||
folder.removeFeeds(feedsToRemove)
|
||||
// os_log(.debug, log: log, "\"%@\" - removed: %@", collection.label, feedsToRemove.map { $0.feedID }, feedsInCollection)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Pair each Feed with its Folder.
|
||||
var feedsAdded = Set<Feed>()
|
||||
|
||||
|
||||
let feedsAndFolders = pairs
|
||||
.map({ (collectionFeeds, folder) -> [(FeedlyFeed, Folder)] in
|
||||
return collectionFeeds.map { feed -> (FeedlyFeed, Folder) in
|
||||
@ -59,11 +59,11 @@ final class FeedlyCreateFeedsForCollectionFoldersOperation: FeedlyOperation {
|
||||
|
||||
// find an existing feed previously added to the account
|
||||
if let feed = account.existingFeed(withFeedID: collectionFeed.id) {
|
||||
|
||||
|
||||
// If the feed was renamed on Feedly, ensure we ingest the new name.
|
||||
if feed.nameForDisplay != collectionFeed.title {
|
||||
feed.name = collectionFeed.title
|
||||
|
||||
|
||||
// Let the rest of the app (e.g.: the sidebar) know the feed name changed
|
||||
// `editedName` would post this if its value is changing.
|
||||
// Setting the `name` property has no side effects like this.
|
||||
@ -87,25 +87,25 @@ final class FeedlyCreateFeedsForCollectionFoldersOperation: FeedlyOperation {
|
||||
url: parser.url,
|
||||
feedID: parser.feedID,
|
||||
homePageURL: parser.homePageURL)
|
||||
|
||||
|
||||
// So the same feed isn't created more than once.
|
||||
feedsAdded.insert(feed)
|
||||
|
||||
|
||||
return (feed, folder)
|
||||
}
|
||||
|
||||
|
||||
os_log(.debug, log: log, "Processing %i feeds.", feedsAndFolders.count)
|
||||
for (feed, folder) in feedsAndFolders {
|
||||
if !folder.has(feed) {
|
||||
folder.addFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Remove feeds without folders/collections.
|
||||
let feedsAfter = Set(feedsAndFolders.map { $0.0 })
|
||||
let feedsWithoutCollections = feedsBefore.subtracting(feedsAfter)
|
||||
account.removeFeeds(feedsWithoutCollections)
|
||||
|
||||
|
||||
if !feedsWithoutCollections.isEmpty {
|
||||
os_log(.debug, log: log, "Removed %i feeds", feedsWithoutCollections.count)
|
||||
}
|
||||
|
@ -20,7 +20,7 @@ class FeedlyDownloadArticlesOperation: FeedlyOperation {
|
||||
private let getEntriesService: FeedlyGetEntriesService
|
||||
private let operationQueue = MainThreadOperationQueue()
|
||||
private let finishOperation: FeedlyCheckpointOperation
|
||||
|
||||
|
||||
init(account: Account, missingArticleEntryIdProvider: FeedlyEntryIdentifierProviding, updatedArticleEntryIdProvider: FeedlyEntryIdentifierProviding, getEntriesService: FeedlyGetEntriesService, log: OSLog) {
|
||||
self.account = account
|
||||
self.operationQueue.suspend()
|
||||
@ -33,65 +33,65 @@ class FeedlyDownloadArticlesOperation: FeedlyOperation {
|
||||
self.finishOperation.checkpointDelegate = self
|
||||
self.operationQueue.add(self.finishOperation)
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
var articleIds = missingArticleEntryIdProvider.entryIds
|
||||
articleIds.formUnion(updatedArticleEntryIdProvider.entryIds)
|
||||
|
||||
|
||||
os_log(.debug, log: log, "Requesting %{public}i articles.", articleIds.count)
|
||||
|
||||
|
||||
let feedlyAPILimitBatchSize = 1000
|
||||
for articleIds in Array(articleIds).chunked(into: feedlyAPILimitBatchSize) {
|
||||
|
||||
|
||||
let provider = FeedlyEntryIdentifierProvider(entryIds: Set(articleIds))
|
||||
let getEntries = FeedlyGetEntriesOperation(account: account, service: getEntriesService, provider: provider, log: log)
|
||||
getEntries.delegate = self
|
||||
self.operationQueue.add(getEntries)
|
||||
|
||||
|
||||
let organiseByFeed = FeedlyOrganiseParsedItemsByFeedOperation(account: account,
|
||||
parsedItemProvider: getEntries,
|
||||
log: log)
|
||||
organiseByFeed.delegate = self
|
||||
organiseByFeed.addDependency(getEntries)
|
||||
self.operationQueue.add(organiseByFeed)
|
||||
|
||||
|
||||
let updateAccount = FeedlyUpdateAccountFeedsWithItemsOperation(account: account,
|
||||
organisedItemsProvider: organiseByFeed,
|
||||
log: log)
|
||||
|
||||
|
||||
updateAccount.delegate = self
|
||||
updateAccount.addDependency(organiseByFeed)
|
||||
self.operationQueue.add(updateAccount)
|
||||
|
||||
finishOperation.addDependency(updateAccount)
|
||||
}
|
||||
|
||||
|
||||
operationQueue.resume()
|
||||
}
|
||||
|
||||
override func didCancel() {
|
||||
// TODO: fix error on below line: "Expression type '()' is ambiguous without more context"
|
||||
//os_log(.debug, log: log, "Cancelling %{public}@.", self)
|
||||
// os_log(.debug, log: log, "Cancelling %{public}@.", self)
|
||||
operationQueue.cancelAllOperations()
|
||||
super.didCancel()
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlyDownloadArticlesOperation: FeedlyCheckpointOperationDelegate {
|
||||
|
||||
|
||||
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) {
|
||||
didFinish()
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlyDownloadArticlesOperation: FeedlyOperationDelegate {
|
||||
|
||||
|
||||
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
|
||||
// Having this log is useful for debugging missing required JSON keys in the response from Feedly, for example.
|
||||
os_log(.debug, log: log, "%{public}@ failed with error: %{public}@.", String(describing: operation), error as NSError)
|
||||
|
||||
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
@ -13,21 +13,21 @@ final class FeedlyFetchIdsForMissingArticlesOperation: FeedlyOperation, FeedlyEn
|
||||
|
||||
private let account: Account
|
||||
private let log: OSLog
|
||||
|
||||
|
||||
private(set) var entryIds = Set<String>()
|
||||
|
||||
|
||||
init(account: Account, log: OSLog) {
|
||||
self.account = account
|
||||
self.log = log
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate { result in
|
||||
switch result {
|
||||
case .success(let articleIds):
|
||||
self.entryIds.formUnion(articleIds)
|
||||
self.didFinish()
|
||||
|
||||
|
||||
case .failure(let error):
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
|
@ -15,27 +15,27 @@ protocol FeedlyCollectionProviding: AnyObject {
|
||||
|
||||
/// Get Collections from Feedly.
|
||||
final class FeedlyGetCollectionsOperation: FeedlyOperation, FeedlyCollectionProviding {
|
||||
|
||||
|
||||
let service: FeedlyGetCollectionsService
|
||||
let log: OSLog
|
||||
|
||||
|
||||
private(set) var collections = [FeedlyCollection]()
|
||||
|
||||
init(service: FeedlyGetCollectionsService, log: OSLog) {
|
||||
self.service = service
|
||||
self.log = log
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
os_log(.debug, log: log, "Requesting collections.")
|
||||
|
||||
|
||||
service.getCollections { result in
|
||||
switch result {
|
||||
case .success(let collections):
|
||||
os_log(.debug, log: self.log, "Received collections: %{public}@", collections.map { $0.id })
|
||||
self.collections = collections
|
||||
self.didFinish()
|
||||
|
||||
|
||||
case .failure(let error):
|
||||
os_log(.debug, log: self.log, "Unable to request collections: %{public}@.", error as NSError)
|
||||
self.didFinish(with: error)
|
||||
|
@ -24,20 +24,20 @@ final class FeedlyGetEntriesOperation: FeedlyOperation, FeedlyEntryProviding, Fe
|
||||
self.provider = provider
|
||||
self.log = log
|
||||
}
|
||||
|
||||
|
||||
private(set) var entries = [FeedlyEntry]()
|
||||
|
||||
|
||||
private var storedParsedEntries: Set<ParsedItem>?
|
||||
|
||||
|
||||
var parsedEntries: Set<ParsedItem> {
|
||||
if let entries = storedParsedEntries {
|
||||
return entries
|
||||
}
|
||||
|
||||
|
||||
let parsed = Set(entries.compactMap {
|
||||
FeedlyEntryParser(entry: $0).parsedItemRepresentation
|
||||
})
|
||||
|
||||
|
||||
// TODO: Fix the below. There’s an error on the os.log line: "Expression type '()' is ambiguous without more context"
|
||||
// if parsed.count != entries.count {
|
||||
// let entryIds = Set(entries.map { $0.id })
|
||||
@ -45,23 +45,23 @@ final class FeedlyGetEntriesOperation: FeedlyOperation, FeedlyEntryProviding, Fe
|
||||
// let difference = entryIds.subtracting(parsedIds)
|
||||
// os_log(.debug, log: log, "%{public}@ dropping articles with ids: %{public}@.", self, difference)
|
||||
// }
|
||||
|
||||
|
||||
storedParsedEntries = parsed
|
||||
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
|
||||
var parsedItemProviderName: String {
|
||||
return name ?? String(describing: Self.self)
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
service.getEntries(for: provider.entryIds) { result in
|
||||
switch result {
|
||||
case .success(let entries):
|
||||
self.entries = entries
|
||||
self.didFinish()
|
||||
|
||||
|
||||
case .failure(let error):
|
||||
os_log(.debug, log: self.log, "Unable to get entries: %{public}@.", error as NSError)
|
||||
self.didFinish(with: error)
|
||||
|
@ -25,17 +25,17 @@ protocol FeedlyGetStreamContentsOperationDelegate: AnyObject {
|
||||
|
||||
/// Get the stream content of a Collection from Feedly.
|
||||
final class FeedlyGetStreamContentsOperation: FeedlyOperation, FeedlyEntryProviding, FeedlyParsedItemProviding {
|
||||
|
||||
|
||||
struct ResourceProvider: FeedlyResourceProviding {
|
||||
var resource: FeedlyResourceId
|
||||
}
|
||||
|
||||
|
||||
let resourceProvider: FeedlyResourceProviding
|
||||
|
||||
|
||||
var parsedItemProviderName: String {
|
||||
return resourceProvider.resource.id
|
||||
}
|
||||
|
||||
|
||||
var entries: [FeedlyEntry] {
|
||||
guard let entries = stream?.items else {
|
||||
// assert(isFinished, "This should only be called when the operation finishes without error.")
|
||||
@ -44,43 +44,43 @@ final class FeedlyGetStreamContentsOperation: FeedlyOperation, FeedlyEntryProvid
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
|
||||
var parsedEntries: Set<ParsedItem> {
|
||||
if let entries = storedParsedEntries {
|
||||
return entries
|
||||
}
|
||||
|
||||
|
||||
let parsed = Set(entries.compactMap {
|
||||
FeedlyEntryParser(entry: $0).parsedItemRepresentation
|
||||
})
|
||||
|
||||
|
||||
if parsed.count != entries.count {
|
||||
let entryIds = Set(entries.map { $0.id })
|
||||
let parsedIds = Set(parsed.map { $0.uniqueID })
|
||||
let difference = entryIds.subtracting(parsedIds)
|
||||
os_log(.debug, log: log, "Dropping articles with ids: %{public}@.", difference)
|
||||
}
|
||||
|
||||
|
||||
storedParsedEntries = parsed
|
||||
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
|
||||
private(set) var stream: FeedlyStream? {
|
||||
didSet {
|
||||
storedParsedEntries = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var storedParsedEntries: Set<ParsedItem>?
|
||||
|
||||
|
||||
let account: Account
|
||||
let service: FeedlyGetStreamContentsService
|
||||
let unreadOnly: Bool?
|
||||
let newerThan: Date?
|
||||
let continuation: String?
|
||||
let log: OSLog
|
||||
|
||||
|
||||
weak var streamDelegate: FeedlyGetStreamContentsOperationDelegate?
|
||||
|
||||
init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamContentsService, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool? = nil, log: OSLog) {
|
||||
@ -92,21 +92,21 @@ final class FeedlyGetStreamContentsOperation: FeedlyOperation, FeedlyEntryProvid
|
||||
self.newerThan = newerThan
|
||||
self.log = log
|
||||
}
|
||||
|
||||
|
||||
convenience init(account: Account, resourceProvider: FeedlyResourceProviding, service: FeedlyGetStreamContentsService, newerThan: Date?, unreadOnly: Bool? = nil, log: OSLog) {
|
||||
self.init(account: account, resource: resourceProvider.resource, service: service, newerThan: newerThan, unreadOnly: unreadOnly, log: log)
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
service.getStreamContents(for: resourceProvider.resource, continuation: continuation, newerThan: newerThan, unreadOnly: unreadOnly) { result in
|
||||
switch result {
|
||||
case .success(let stream):
|
||||
self.stream = stream
|
||||
|
||||
|
||||
self.streamDelegate?.feedlyGetStreamContentsOperation(self, didGetContentsOf: stream)
|
||||
|
||||
|
||||
self.didFinish()
|
||||
|
||||
|
||||
case .failure(let error):
|
||||
os_log(.debug, log: self.log, "Unable to get stream contents: %{public}@.", error as NSError)
|
||||
self.didFinish(with: error)
|
||||
|
@ -15,7 +15,7 @@ protocol FeedlyGetStreamIdsOperationDelegate: AnyObject {
|
||||
|
||||
/// Single responsibility is to get the stream ids from Feedly.
|
||||
final class FeedlyGetStreamIdsOperation: FeedlyOperation, FeedlyEntryIdentifierProviding {
|
||||
|
||||
|
||||
var entryIds: Set<String> {
|
||||
guard let ids = streamIds?.ids else {
|
||||
assertionFailure("Has this operation been addeded as a dependency on the caller?")
|
||||
@ -23,9 +23,9 @@ final class FeedlyGetStreamIdsOperation: FeedlyOperation, FeedlyEntryIdentifierP
|
||||
}
|
||||
return Set(ids)
|
||||
}
|
||||
|
||||
|
||||
private(set) var streamIds: FeedlyStreamIds?
|
||||
|
||||
|
||||
let account: Account
|
||||
let service: FeedlyGetStreamIdsService
|
||||
let continuation: String?
|
||||
@ -43,19 +43,19 @@ final class FeedlyGetStreamIdsOperation: FeedlyOperation, FeedlyEntryIdentifierP
|
||||
self.unreadOnly = unreadOnly
|
||||
self.log = log
|
||||
}
|
||||
|
||||
|
||||
weak var streamIdsDelegate: FeedlyGetStreamIdsOperationDelegate?
|
||||
|
||||
|
||||
override func run() {
|
||||
service.getStreamIds(for: resource, continuation: continuation, newerThan: newerThan, unreadOnly: unreadOnly) { result in
|
||||
switch result {
|
||||
case .success(let stream):
|
||||
self.streamIds = stream
|
||||
|
||||
|
||||
self.streamIdsDelegate?.feedlyGetStreamIdsOperation(self, didGet: stream)
|
||||
|
||||
|
||||
self.didFinish()
|
||||
|
||||
|
||||
case .failure(let error):
|
||||
os_log(.debug, log: self.log, "Unable to get stream ids: %{public}@.", error as NSError)
|
||||
self.didFinish(with: error)
|
||||
|
@ -21,7 +21,7 @@ class FeedlyGetUpdatedArticleIdsOperation: FeedlyOperation, FeedlyEntryIdentifie
|
||||
private let service: FeedlyGetStreamIdsService
|
||||
private let newerThan: Date?
|
||||
private let log: OSLog
|
||||
|
||||
|
||||
init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamIdsService, newerThan: Date?, log: OSLog) {
|
||||
self.account = account
|
||||
self.resource = resource
|
||||
@ -29,50 +29,50 @@ class FeedlyGetUpdatedArticleIdsOperation: FeedlyOperation, FeedlyEntryIdentifie
|
||||
self.newerThan = newerThan
|
||||
self.log = log
|
||||
}
|
||||
|
||||
|
||||
convenience init(account: Account, userId: String, service: FeedlyGetStreamIdsService, newerThan: Date?, log: OSLog) {
|
||||
let all = FeedlyCategoryResourceId.Global.all(for: userId)
|
||||
self.init(account: account, resource: all, service: service, newerThan: newerThan, log: log)
|
||||
}
|
||||
|
||||
|
||||
var entryIds: Set<String> {
|
||||
return storedUpdatedArticleIds
|
||||
}
|
||||
|
||||
|
||||
private var storedUpdatedArticleIds = Set<String>()
|
||||
|
||||
|
||||
override func run() {
|
||||
getStreamIds(nil)
|
||||
}
|
||||
|
||||
|
||||
private func getStreamIds(_ continuation: String?) {
|
||||
guard let date = newerThan else {
|
||||
os_log(.debug, log: log, "No date provided so everything must be new (nothing is updated).")
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
service.getStreamIds(for: resource, continuation: continuation, newerThan: date, unreadOnly: nil, completion: didGetStreamIds(_:))
|
||||
}
|
||||
|
||||
|
||||
private func didGetStreamIds(_ result: Result<FeedlyStreamIds, Error>) {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
switch result {
|
||||
case .success(let streamIds):
|
||||
storedUpdatedArticleIds.formUnion(streamIds.ids)
|
||||
|
||||
|
||||
guard let continuation = streamIds.continuation else {
|
||||
os_log(.debug, log: log, "%{public}i articles updated since last successful sync start date.", storedUpdatedArticleIds.count)
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
getStreamIds(continuation)
|
||||
|
||||
|
||||
case .failure(let error):
|
||||
didFinish(with: error)
|
||||
}
|
||||
|
@ -21,48 +21,48 @@ class FeedlyIngestStreamArticleIdsOperation: FeedlyOperation {
|
||||
private let resource: FeedlyResourceId
|
||||
private let service: FeedlyGetStreamIdsService
|
||||
private let log: OSLog
|
||||
|
||||
|
||||
init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamIdsService, log: OSLog) {
|
||||
self.account = account
|
||||
self.resource = resource
|
||||
self.service = service
|
||||
self.log = log
|
||||
}
|
||||
|
||||
|
||||
convenience init(account: Account, userId: String, service: FeedlyGetStreamIdsService, log: OSLog) {
|
||||
let all = FeedlyCategoryResourceId.Global.all(for: userId)
|
||||
self.init(account: account, resource: all, service: service, log: log)
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
getStreamIds(nil)
|
||||
}
|
||||
|
||||
|
||||
private func getStreamIds(_ continuation: String?) {
|
||||
service.getStreamIds(for: resource, continuation: continuation, newerThan: nil, unreadOnly: nil, completion: didGetStreamIds(_:))
|
||||
}
|
||||
|
||||
|
||||
private func didGetStreamIds(_ result: Result<FeedlyStreamIds, Error>) {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
switch result {
|
||||
case .success(let streamIds):
|
||||
account.createStatusesIfNeeded(articleIDs: Set(streamIds.ids)) { databaseError in
|
||||
|
||||
|
||||
if let error = databaseError {
|
||||
self.didFinish(with: error)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
guard let continuation = streamIds.continuation else {
|
||||
os_log(.debug, log: self.log, "Reached end of stream for %@", self.resource.id)
|
||||
self.didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
self.getStreamIds(continuation)
|
||||
}
|
||||
case .failure(let error):
|
||||
|
@ -26,12 +26,12 @@ final class FeedlyIngestUnreadArticleIdsOperation: FeedlyOperation {
|
||||
private let database: SyncDatabase
|
||||
private var remoteEntryIds = Set<String>()
|
||||
private let log: OSLog
|
||||
|
||||
|
||||
convenience init(account: Account, userId: String, service: FeedlyGetStreamIdsService, database: SyncDatabase, newerThan: Date?, log: OSLog) {
|
||||
let resource = FeedlyCategoryResourceId.Global.all(for: userId)
|
||||
self.init(account: account, resource: resource, service: service, database: database, newerThan: newerThan, log: log)
|
||||
}
|
||||
|
||||
|
||||
init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamIdsService, database: SyncDatabase, newerThan: Date?, log: OSLog) {
|
||||
self.account = account
|
||||
self.resource = resource
|
||||
@ -39,75 +39,75 @@ final class FeedlyIngestUnreadArticleIdsOperation: FeedlyOperation {
|
||||
self.database = database
|
||||
self.log = log
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
getStreamIds(nil)
|
||||
}
|
||||
|
||||
|
||||
private func getStreamIds(_ continuation: String?) {
|
||||
service.getStreamIds(for: resource, continuation: continuation, newerThan: nil, unreadOnly: true, completion: didGetStreamIds(_:))
|
||||
}
|
||||
|
||||
|
||||
private func didGetStreamIds(_ result: Result<FeedlyStreamIds, Error>) {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
switch result {
|
||||
case .success(let streamIds):
|
||||
|
||||
|
||||
remoteEntryIds.formUnion(streamIds.ids)
|
||||
|
||||
|
||||
guard let continuation = streamIds.continuation else {
|
||||
removeEntryIdsWithPendingStatus()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
getStreamIds(continuation)
|
||||
|
||||
|
||||
case .failure(let error):
|
||||
didFinish(with: error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Do not override pending statuses with the remote statuses of the same articles, otherwise an article will temporarily re-acquire the remote status before the pending status is pushed and subseqently pulled.
|
||||
private func removeEntryIdsWithPendingStatus() {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
database.selectPendingReadStatusArticleIDs { result in
|
||||
switch result {
|
||||
case .success(let pendingArticleIds):
|
||||
self.remoteEntryIds.subtract(pendingArticleIds)
|
||||
|
||||
|
||||
self.updateUnreadStatuses()
|
||||
|
||||
|
||||
case .failure(let error):
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func updateUnreadStatuses() {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
account.fetchUnreadArticleIDs { result in
|
||||
switch result {
|
||||
case .success(let localUnreadArticleIDs):
|
||||
self.processUnreadArticleIDs(localUnreadArticleIDs)
|
||||
|
||||
|
||||
case .failure(let error):
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func processUnreadArticleIDs(_ localUnreadArticleIDs: Set<String>) {
|
||||
guard !isCanceled else {
|
||||
didFinish()
|
||||
@ -116,14 +116,14 @@ final class FeedlyIngestUnreadArticleIdsOperation: FeedlyOperation {
|
||||
|
||||
let remoteUnreadArticleIDs = remoteEntryIds
|
||||
let group = DispatchGroup()
|
||||
|
||||
|
||||
final class ReadStatusResults {
|
||||
var markAsUnreadError: Error?
|
||||
var markAsReadError: Error?
|
||||
}
|
||||
|
||||
|
||||
let results = ReadStatusResults()
|
||||
|
||||
|
||||
group.enter()
|
||||
account.markAsUnread(remoteUnreadArticleIDs) { result in
|
||||
if case .failure(let error) = result {
|
||||
|
@ -10,7 +10,7 @@ import Foundation
|
||||
import os.log
|
||||
|
||||
protocol FeedlyLogoutService {
|
||||
func logout(completion: @escaping (Result<Void, Error>) -> ())
|
||||
func logout(completion: @escaping (Result<Void, Error>) -> Void)
|
||||
}
|
||||
|
||||
final class FeedlyLogoutOperation: FeedlyOperation {
|
||||
@ -18,18 +18,18 @@ final class FeedlyLogoutOperation: FeedlyOperation {
|
||||
let service: FeedlyLogoutService
|
||||
let account: Account
|
||||
let log: OSLog
|
||||
|
||||
|
||||
init(account: Account, service: FeedlyLogoutService, log: OSLog) {
|
||||
self.service = service
|
||||
self.account = account
|
||||
self.log = log
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
os_log("Requesting logout of %{public}@ account.", "\(account.type)")
|
||||
service.logout(completion: didCompleteLogout(_:))
|
||||
}
|
||||
|
||||
|
||||
func didCompleteLogout(_ result: Result<Void, Error>) {
|
||||
assert(Thread.isMainThread)
|
||||
switch result {
|
||||
@ -42,7 +42,7 @@ final class FeedlyLogoutOperation: FeedlyOperation {
|
||||
// oh well, we tried our best.
|
||||
}
|
||||
didFinish()
|
||||
|
||||
|
||||
case .failure(let error):
|
||||
os_log("Logout failed because %{public}@.", error as NSError)
|
||||
didFinish(with: error)
|
||||
|
@ -15,11 +15,11 @@ protocol FeedlyFeedsAndFoldersProviding {
|
||||
|
||||
/// Reflect Collections from Feedly as Folders.
|
||||
final class FeedlyMirrorCollectionsAsFoldersOperation: FeedlyOperation, FeedlyFeedsAndFoldersProviding {
|
||||
|
||||
|
||||
let account: Account
|
||||
let collectionsProvider: FeedlyCollectionProviding
|
||||
let log: OSLog
|
||||
|
||||
|
||||
private(set) var feedsAndFolders = [([FeedlyFeed], Folder)]()
|
||||
|
||||
init(account: Account, collectionsProvider: FeedlyCollectionProviding, log: OSLog) {
|
||||
@ -27,15 +27,15 @@ final class FeedlyMirrorCollectionsAsFoldersOperation: FeedlyOperation, FeedlyFe
|
||||
self.account = account
|
||||
self.log = log
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
defer {
|
||||
didFinish()
|
||||
}
|
||||
|
||||
|
||||
let localFolders = account.folders ?? Set()
|
||||
let collections = collectionsProvider.collections
|
||||
|
||||
|
||||
feedsAndFolders = collections.compactMap { collection -> ([FeedlyFeed], Folder)? in
|
||||
let parser = FeedlyCollectionParser(collection: collection)
|
||||
guard let folder = account.ensureFolder(with: parser.folderName) else {
|
||||
@ -45,18 +45,18 @@ final class FeedlyMirrorCollectionsAsFoldersOperation: FeedlyOperation, FeedlyFe
|
||||
folder.externalID = parser.externalID
|
||||
return (collection.feeds, folder)
|
||||
}
|
||||
|
||||
|
||||
os_log(.debug, log: log, "Ensured %i folders for %i collections.", feedsAndFolders.count, collections.count)
|
||||
|
||||
|
||||
// Remove folders without a corresponding collection
|
||||
let collectionFolders = Set(feedsAndFolders.map { $0.1 })
|
||||
let foldersWithoutCollections = localFolders.subtracting(collectionFolders)
|
||||
|
||||
|
||||
if !foldersWithoutCollections.isEmpty {
|
||||
for unmatched in foldersWithoutCollections {
|
||||
account.removeFolder(unmatched)
|
||||
}
|
||||
|
||||
|
||||
os_log(.debug, log: log, "Removed %i folders: %@", foldersWithoutCollections.count, foldersWithoutCollections.map { $0.externalID ?? $0.nameForDisplay })
|
||||
}
|
||||
}
|
||||
|
@ -21,24 +21,24 @@ final class FeedlyOrganiseParsedItemsByFeedOperation: FeedlyOperation, FeedlyPar
|
||||
private let account: Account
|
||||
private let parsedItemProvider: FeedlyParsedItemProviding
|
||||
private let log: OSLog
|
||||
|
||||
|
||||
var parsedItemsByFeedProviderName: String {
|
||||
return name ?? String(describing: Self.self)
|
||||
}
|
||||
|
||||
var parsedItemsKeyedByFeedId: [String : Set<ParsedItem>] {
|
||||
|
||||
var parsedItemsKeyedByFeedId: [String: Set<ParsedItem>] {
|
||||
precondition(Thread.isMainThread) // Needs to be on main thread because Feed is a main-thread-only model type.
|
||||
return itemsKeyedByFeedId
|
||||
}
|
||||
|
||||
|
||||
private var itemsKeyedByFeedId = [String: Set<ParsedItem>]()
|
||||
|
||||
|
||||
init(account: Account, parsedItemProvider: FeedlyParsedItemProviding, log: OSLog) {
|
||||
self.account = account
|
||||
self.parsedItemProvider = parsedItemProvider
|
||||
self.log = log
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
defer {
|
||||
didFinish()
|
||||
@ -46,7 +46,7 @@ final class FeedlyOrganiseParsedItemsByFeedOperation: FeedlyOperation, FeedlyPar
|
||||
|
||||
let items = parsedItemProvider.parsedEntries
|
||||
var dict = [String: Set<ParsedItem>](minimumCapacity: items.count)
|
||||
|
||||
|
||||
for item in items {
|
||||
let key = item.feedURL
|
||||
let value: Set<ParsedItem> = {
|
||||
@ -59,9 +59,9 @@ final class FeedlyOrganiseParsedItemsByFeedOperation: FeedlyOperation, FeedlyPar
|
||||
}()
|
||||
dict[key] = value
|
||||
}
|
||||
|
||||
|
||||
os_log(.debug, log: log, "Grouped %i items by %i feeds for %@", items.count, dict.count, parsedItemProvider.parsedItemProviderName)
|
||||
|
||||
|
||||
itemsKeyedByFeedId = dict
|
||||
}
|
||||
}
|
||||
|
@ -17,41 +17,41 @@ final class FeedlyRefreshAccessTokenOperation: FeedlyOperation {
|
||||
let oauthClient: OAuthAuthorizationClient
|
||||
let account: Account
|
||||
let log: OSLog
|
||||
|
||||
|
||||
init(account: Account, service: OAuthAccessTokenRefreshing, oauthClient: OAuthAuthorizationClient, log: OSLog) {
|
||||
self.oauthClient = oauthClient
|
||||
self.service = service
|
||||
self.account = account
|
||||
self.log = log
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
let refreshToken: Credentials
|
||||
|
||||
|
||||
do {
|
||||
guard let credentials = try account.retrieveCredentials(type: .oauthRefreshToken) else {
|
||||
os_log(.debug, log: log, "Could not find a refresh token in the keychain. Check the refresh token is added to the Keychain, remove the account and add it again.")
|
||||
throw TransportError.httpError(status: 403)
|
||||
}
|
||||
|
||||
|
||||
refreshToken = credentials
|
||||
|
||||
|
||||
} catch {
|
||||
didFinish(with: error)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
os_log(.debug, log: log, "Refreshing access token.")
|
||||
|
||||
|
||||
// Ignore cancellation after the request is resumed otherwise we may continue storing a potentially invalid token!
|
||||
service.refreshAccessToken(with: refreshToken.secret, client: oauthClient) { result in
|
||||
self.didRefreshAccessToken(result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func didRefreshAccessToken(_ result: Result<OAuthAuthorizationGrant, Error>) {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
|
||||
switch result {
|
||||
case .success(let grant):
|
||||
do {
|
||||
@ -60,16 +60,16 @@ final class FeedlyRefreshAccessTokenOperation: FeedlyOperation {
|
||||
if let token = grant.refreshToken {
|
||||
try account.storeCredentials(token)
|
||||
}
|
||||
|
||||
|
||||
os_log(.debug, log: log, "Storing access token.")
|
||||
// Now store the access token because we want the account delegate to use it.
|
||||
try account.storeCredentials(grant.accessToken)
|
||||
|
||||
|
||||
didFinish()
|
||||
} catch {
|
||||
didFinish(with: error)
|
||||
}
|
||||
|
||||
|
||||
case .failure(let error):
|
||||
didFinish(with: error)
|
||||
}
|
||||
|
@ -16,9 +16,9 @@ protocol FeedlyRequestStreamsOperationDelegate: AnyObject {
|
||||
/// Create one stream request operation for one Feedly collection.
|
||||
/// This is the start of the process of refreshing the entire contents of a Folder.
|
||||
final class FeedlyRequestStreamsOperation: FeedlyOperation {
|
||||
|
||||
|
||||
weak var queueDelegate: FeedlyRequestStreamsOperationDelegate?
|
||||
|
||||
|
||||
let collectionsProvider: FeedlyCollectionProviding
|
||||
let service: FeedlyGetStreamContentsService
|
||||
let account: Account
|
||||
@ -34,16 +34,16 @@ final class FeedlyRequestStreamsOperation: FeedlyOperation {
|
||||
self.unreadOnly = unreadOnly
|
||||
self.log = log
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
defer {
|
||||
didFinish()
|
||||
}
|
||||
|
||||
|
||||
assert(queueDelegate != nil, "This is not particularly useful unless the `queueDelegate` is non-nil.")
|
||||
|
||||
|
||||
// TODO: Prioritise the must read collection/category before others so the most important content for the user loads first.
|
||||
|
||||
|
||||
for collection in collectionsProvider.collections {
|
||||
let resource = FeedlyCategoryResourceId(id: collection.id)
|
||||
let operation = FeedlyGetStreamContentsOperation(account: account,
|
||||
@ -54,7 +54,7 @@ final class FeedlyRequestStreamsOperation: FeedlyOperation {
|
||||
log: log)
|
||||
queueDelegate?.feedlyRequestStreamsOperation(self, enqueue: operation)
|
||||
}
|
||||
|
||||
|
||||
os_log(.debug, log: log, "Requested %i collection streams", collectionsProvider.collections.count)
|
||||
}
|
||||
}
|
||||
|
@ -9,7 +9,7 @@
|
||||
import Foundation
|
||||
|
||||
protocol FeedlySearchService: AnyObject {
|
||||
func getFeeds(for query: String, count: Int, locale: String, completion: @escaping (Result<FeedlyFeedsSearchResponse, Error>) -> ())
|
||||
func getFeeds(for query: String, count: Int, locale: String, completion: @escaping (Result<FeedlyFeedsSearchResponse, Error>) -> Void)
|
||||
}
|
||||
|
||||
protocol FeedlySearchOperationDelegate: AnyObject {
|
||||
@ -30,7 +30,7 @@ class FeedlySearchOperation: FeedlyOperation {
|
||||
self.locale = locale
|
||||
self.searchService = service
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
searchService.getFeeds(for: query, count: 1, locale: locale.identifier) { result in
|
||||
switch result {
|
||||
@ -38,7 +38,7 @@ class FeedlySearchOperation: FeedlyOperation {
|
||||
assert(Thread.isMainThread)
|
||||
self.searchDelegate?.feedlySearchOperation(self, didGet: response)
|
||||
self.didFinish()
|
||||
|
||||
|
||||
case .failure(let error):
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
|
@ -19,9 +19,9 @@ final class FeedlySyncAllOperation: FeedlyOperation {
|
||||
private let operationQueue = MainThreadOperationQueue()
|
||||
private let log: OSLog
|
||||
let syncUUID: UUID
|
||||
|
||||
var syncCompletionHandler: ((Result<Void, Error>) -> ())?
|
||||
|
||||
|
||||
var syncCompletionHandler: ((Result<Void, Error>) -> Void)?
|
||||
|
||||
/// These requests to Feedly determine which articles to download:
|
||||
/// 1. The set of all article ids we might need or show.
|
||||
/// 2. The set of all unread article ids we might need or show (a subset of 1).
|
||||
@ -38,49 +38,49 @@ final class FeedlySyncAllOperation: FeedlyOperation {
|
||||
self.syncUUID = UUID()
|
||||
self.log = log
|
||||
self.operationQueue.suspend()
|
||||
|
||||
|
||||
super.init()
|
||||
|
||||
|
||||
self.downloadProgress = downloadProgress
|
||||
|
||||
|
||||
// Send any read/unread/starred article statuses to Feedly before anything else.
|
||||
let sendArticleStatuses = FeedlySendArticleStatusesOperation(database: database, service: markArticlesService, log: log)
|
||||
sendArticleStatuses.delegate = self
|
||||
sendArticleStatuses.downloadProgress = downloadProgress
|
||||
self.operationQueue.add(sendArticleStatuses)
|
||||
|
||||
|
||||
// Get all the Collections the user has.
|
||||
let getCollections = FeedlyGetCollectionsOperation(service: getCollectionsService, log: log)
|
||||
getCollections.delegate = self
|
||||
getCollections.downloadProgress = downloadProgress
|
||||
getCollections.addDependency(sendArticleStatuses)
|
||||
self.operationQueue.add(getCollections)
|
||||
|
||||
|
||||
// Ensure a folder exists for each Collection, removing Folders without a corresponding Collection.
|
||||
let mirrorCollectionsAsFolders = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: getCollections, log: log)
|
||||
mirrorCollectionsAsFolders.delegate = self
|
||||
mirrorCollectionsAsFolders.addDependency(getCollections)
|
||||
self.operationQueue.add(mirrorCollectionsAsFolders)
|
||||
|
||||
|
||||
// Ensure feeds are created and grouped by their folders.
|
||||
let createFeedsOperation = FeedlyCreateFeedsForCollectionFoldersOperation(account: account, feedsAndFoldersProvider: mirrorCollectionsAsFolders, log: log)
|
||||
createFeedsOperation.delegate = self
|
||||
createFeedsOperation.addDependency(mirrorCollectionsAsFolders)
|
||||
self.operationQueue.add(createFeedsOperation)
|
||||
|
||||
|
||||
let getAllArticleIds = FeedlyIngestStreamArticleIdsOperation(account: account, userId: feedlyUserId, service: getStreamIdsService, log: log)
|
||||
getAllArticleIds.delegate = self
|
||||
getAllArticleIds.downloadProgress = downloadProgress
|
||||
getAllArticleIds.addDependency(createFeedsOperation)
|
||||
self.operationQueue.add(getAllArticleIds)
|
||||
|
||||
|
||||
// Get each page of unread article ids in the global.all stream for the last 31 days (nil = Feedly API default).
|
||||
let getUnread = FeedlyIngestUnreadArticleIdsOperation(account: account, userId: feedlyUserId, service: getUnreadService, database: database, newerThan: nil, log: log)
|
||||
getUnread.delegate = self
|
||||
getUnread.addDependency(getAllArticleIds)
|
||||
getUnread.downloadProgress = downloadProgress
|
||||
self.operationQueue.add(getUnread)
|
||||
|
||||
|
||||
// Get each page of the article ids which have been update since the last successful fetch start date.
|
||||
// If the date is nil, this operation provides an empty set (everything is new, nothing is updated).
|
||||
let getUpdated = FeedlyGetUpdatedArticleIdsOperation(account: account, userId: feedlyUserId, service: getStreamIdsService, newerThan: lastSuccessfulFetchStartDate, log: log)
|
||||
@ -88,14 +88,14 @@ final class FeedlySyncAllOperation: FeedlyOperation {
|
||||
getUpdated.downloadProgress = downloadProgress
|
||||
getUpdated.addDependency(createFeedsOperation)
|
||||
self.operationQueue.add(getUpdated)
|
||||
|
||||
|
||||
// Get each page of the article ids for starred articles.
|
||||
let getStarred = FeedlyIngestStarredArticleIdsOperation(account: account, userId: feedlyUserId, service: getStarredService, database: database, newerThan: nil, log: log)
|
||||
getStarred.delegate = self
|
||||
getStarred.downloadProgress = downloadProgress
|
||||
getStarred.addDependency(createFeedsOperation)
|
||||
self.operationQueue.add(getStarred)
|
||||
|
||||
|
||||
// Now all the possible article ids we need have a status, fetch the article ids for missing articles.
|
||||
let getMissingIds = FeedlyFetchIdsForMissingArticlesOperation(account: account, log: log)
|
||||
getMissingIds.delegate = self
|
||||
@ -105,7 +105,7 @@ final class FeedlySyncAllOperation: FeedlyOperation {
|
||||
getMissingIds.addDependency(getStarred)
|
||||
getMissingIds.addDependency(getUpdated)
|
||||
self.operationQueue.add(getMissingIds)
|
||||
|
||||
|
||||
// Download all the missing and updated articles
|
||||
let downloadMissingArticles = FeedlyDownloadArticlesOperation(account: account,
|
||||
missingArticleEntryIdProvider: getMissingIds,
|
||||
@ -117,7 +117,7 @@ final class FeedlySyncAllOperation: FeedlyOperation {
|
||||
downloadMissingArticles.addDependency(getMissingIds)
|
||||
downloadMissingArticles.addDependency(getUpdated)
|
||||
self.operationQueue.add(downloadMissingArticles)
|
||||
|
||||
|
||||
// Once this operation's dependencies, their dependencies etc finish, we can finish.
|
||||
let finishOperation = FeedlyCheckpointOperation()
|
||||
finishOperation.checkpointDelegate = self
|
||||
@ -125,11 +125,11 @@ final class FeedlySyncAllOperation: FeedlyOperation {
|
||||
finishOperation.addDependency(downloadMissingArticles)
|
||||
self.operationQueue.add(finishOperation)
|
||||
}
|
||||
|
||||
|
||||
convenience init(account: Account, feedlyUserId: String, caller: FeedlyAPICaller, database: SyncDatabase, lastSuccessfulFetchStartDate: Date?, downloadProgress: DownloadProgress, log: OSLog) {
|
||||
self.init(account: account, feedlyUserId: feedlyUserId, lastSuccessfulFetchStartDate: lastSuccessfulFetchStartDate, markArticlesService: caller, getUnreadService: caller, getCollectionsService: caller, getStreamContentsService: caller, getStarredService: caller, getStreamIdsService: caller, getEntriesService: caller, database: database, downloadProgress: downloadProgress, log: log)
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
os_log(.debug, log: log, "Starting sync %{public}@", syncUUID.uuidString)
|
||||
operationQueue.resume()
|
||||
@ -144,29 +144,29 @@ final class FeedlySyncAllOperation: FeedlyOperation {
|
||||
}
|
||||
|
||||
extension FeedlySyncAllOperation: FeedlyCheckpointOperationDelegate {
|
||||
|
||||
|
||||
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) {
|
||||
assert(Thread.isMainThread)
|
||||
os_log(.debug, log: self.log, "Sync completed: %{public}@", syncUUID.uuidString)
|
||||
|
||||
|
||||
syncCompletionHandler?(.success(()))
|
||||
syncCompletionHandler = nil
|
||||
|
||||
|
||||
didFinish()
|
||||
}
|
||||
}
|
||||
|
||||
extension FeedlySyncAllOperation: FeedlyOperationDelegate {
|
||||
|
||||
|
||||
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
|
||||
// Having this log is useful for debugging missing required JSON keys in the response from Feedly, for example.
|
||||
os_log(.debug, log: log, "%{public}@ failed with error: %{public}@.", String(describing: operation), error as NSError)
|
||||
|
||||
|
||||
syncCompletionHandler?(.failure(error))
|
||||
syncCompletionHandler = nil
|
||||
|
||||
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ final class FeedlySyncStreamContentsOperation: FeedlyOperation, FeedlyOperationD
|
||||
private let isPagingEnabled: Bool
|
||||
private let log: OSLog
|
||||
private let finishOperation: FeedlyCheckpointOperation
|
||||
|
||||
|
||||
init(account: Account, resource: FeedlyResourceId, service: FeedlyGetStreamContentsService, isPagingEnabled: Bool, newerThan: Date?, log: OSLog) {
|
||||
self.account = account
|
||||
self.resource = resource
|
||||
@ -33,19 +33,19 @@ final class FeedlySyncStreamContentsOperation: FeedlyOperation, FeedlyOperationD
|
||||
self.newerThan = newerThan
|
||||
self.log = log
|
||||
self.finishOperation = FeedlyCheckpointOperation()
|
||||
|
||||
|
||||
super.init()
|
||||
|
||||
|
||||
self.operationQueue.add(self.finishOperation)
|
||||
self.finishOperation.checkpointDelegate = self
|
||||
enqueueOperations(for: nil)
|
||||
}
|
||||
|
||||
|
||||
convenience init(account: Account, credentials: Credentials, service: FeedlyGetStreamContentsService, newerThan: Date?, log: OSLog) {
|
||||
let all = FeedlyCategoryResourceId.Global.all(for: credentials.username)
|
||||
self.init(account: account, resource: all, service: service, isPagingEnabled: true, newerThan: newerThan, log: log)
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
operationQueue.resume()
|
||||
}
|
||||
@ -61,7 +61,7 @@ final class FeedlySyncStreamContentsOperation: FeedlyOperation, FeedlyOperationD
|
||||
let operations = pageOperations(for: continuation)
|
||||
operationQueue.addOperations(operations)
|
||||
}
|
||||
|
||||
|
||||
func pageOperations(for continuation: String?) -> [MainThreadOperation] {
|
||||
let getPage = FeedlyGetStreamContentsOperation(account: account,
|
||||
resource: resource,
|
||||
@ -70,11 +70,10 @@ final class FeedlySyncStreamContentsOperation: FeedlyOperation, FeedlyOperationD
|
||||
newerThan: newerThan,
|
||||
log: log)
|
||||
|
||||
|
||||
let organiseByFeed = FeedlyOrganiseParsedItemsByFeedOperation(account: account, parsedItemProvider: getPage, log: log)
|
||||
|
||||
|
||||
let updateAccount = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: organiseByFeed, log: log)
|
||||
|
||||
|
||||
getPage.delegate = self
|
||||
getPage.streamDelegate = self
|
||||
|
||||
@ -88,28 +87,28 @@ final class FeedlySyncStreamContentsOperation: FeedlyOperation, FeedlyOperationD
|
||||
|
||||
return [getPage, organiseByFeed, updateAccount]
|
||||
}
|
||||
|
||||
|
||||
func feedlyGetStreamContentsOperation(_ operation: FeedlyGetStreamContentsOperation, didGetContentsOf stream: FeedlyStream) {
|
||||
guard !isCanceled else {
|
||||
os_log(.debug, log: log, "Cancelled requesting page for %{public}@", resource.id)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
os_log(.debug, log: log, "Ingesting %i items from %{public}@", stream.items.count, stream.id)
|
||||
|
||||
|
||||
guard isPagingEnabled, let continuation = stream.continuation else {
|
||||
os_log(.debug, log: log, "Reached end of stream for %{public}@", stream.id)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
enqueueOperations(for: continuation)
|
||||
}
|
||||
|
||||
|
||||
func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation) {
|
||||
os_log(.debug, log: log, "Completed ingesting items from %{public}@", resource.id)
|
||||
didFinish()
|
||||
}
|
||||
|
||||
|
||||
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
|
||||
operationQueue.cancelAllOperations()
|
||||
didFinish(with: error)
|
||||
|
@ -22,16 +22,16 @@ final class FeedlyUpdateAccountFeedsWithItemsOperation: FeedlyOperation {
|
||||
self.organisedItemsProvider = organisedItemsProvider
|
||||
self.log = log
|
||||
}
|
||||
|
||||
|
||||
override func run() {
|
||||
let feedIDsAndItems = organisedItemsProvider.parsedItemsKeyedByFeedId
|
||||
|
||||
|
||||
account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true) { databaseError in
|
||||
if let error = databaseError {
|
||||
self.didFinish(with: error)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
os_log(.debug, log: self.log, "Updated %i feeds for \"%@\"", feedIDsAndItems.count, self.organisedItemsProvider.parsedItemsByFeedProviderName)
|
||||
self.didFinish()
|
||||
}
|
||||
|
@ -9,5 +9,5 @@
|
||||
import Foundation
|
||||
|
||||
protocol FeedlyGetCollectionsService: AnyObject {
|
||||
func getCollections(completion: @escaping (Result<[FeedlyCollection], Error>) -> ())
|
||||
func getCollections(completion: @escaping (Result<[FeedlyCollection], Error>) -> Void)
|
||||
}
|
||||
|
@ -9,5 +9,5 @@
|
||||
import Foundation
|
||||
|
||||
protocol FeedlyGetEntriesService: AnyObject {
|
||||
func getEntries(for ids: Set<String>, completion: @escaping (Result<[FeedlyEntry], Error>) -> ())
|
||||
func getEntries(for ids: Set<String>, completion: @escaping (Result<[FeedlyEntry], Error>) -> Void)
|
||||
}
|
||||
|
@ -9,5 +9,5 @@
|
||||
import Foundation
|
||||
|
||||
protocol FeedlyGetStreamContentsService: AnyObject {
|
||||
func getStreamContents(for resource: FeedlyResourceId, continuation: String?, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStream, Error>) -> ())
|
||||
func getStreamContents(for resource: FeedlyResourceId, continuation: String?, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStream, Error>) -> Void)
|
||||
}
|
||||
|
@ -9,5 +9,5 @@
|
||||
import Foundation
|
||||
|
||||
protocol FeedlyGetStreamIdsService: AnyObject {
|
||||
func getStreamIds(for resource: FeedlyResourceId, continuation: String?, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStreamIds, Error>) -> ())
|
||||
func getStreamIds(for resource: FeedlyResourceId, continuation: String?, newerThan: Date?, unreadOnly: Bool?, completion: @escaping (Result<FeedlyStreamIds, Error>) -> Void)
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ enum FeedlyMarkAction: String {
|
||||
case unread
|
||||
case saved
|
||||
case unsaved
|
||||
|
||||
|
||||
/// These values are paired with the "action" key in POST requests to the markers API.
|
||||
/// See for example: https://developer.feedly.com/v3/markers/#mark-one-or-multiple-articles-as-read
|
||||
var actionValue: String {
|
||||
@ -31,5 +31,5 @@ enum FeedlyMarkAction: String {
|
||||
}
|
||||
|
||||
protocol FeedlyMarkArticlesService: AnyObject {
|
||||
func mark(_ articleIds: Set<String>, as action: FeedlyMarkAction, completion: @escaping (Result<Void, Error>) -> ())
|
||||
func mark(_ articleIds: Set<String>, as action: FeedlyMarkAction, completion: @escaping (Result<Void, Error>) -> Void)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user