Fix lint issues.

This commit is contained in:
Brent Simmons 2025-01-22 22:00:38 -08:00
parent 27500633ab
commit 6fc9e5c25e
47 changed files with 631 additions and 636 deletions

View File

@ -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, _)):

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ protocol FeedlyResourceProviding {
}
extension FeedlyFeedResourceId: FeedlyResourceProviding {
var resource: FeedlyResourceId {
return self
}

View File

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

View File

@ -11,30 +11,30 @@ import Foundation
struct FeedlyEntry: Decodable {
/// the unique, immutable ID for this particular article.
let id: String
/// the articles 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 authors 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 feeds 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]?

View File

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

View File

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

View File

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

View File

@ -9,11 +9,11 @@
import Foundation
struct FeedlyFeedsSearchResponse: Decodable {
struct Feed: Decodable {
let title: String
let feedId: String
}
let results: [Feed]
}

View File

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

View File

@ -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])

View File

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

View File

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

View File

@ -11,7 +11,7 @@ import Foundation
struct FeedlyStreamIds: Decodable {
let continuation: String?
let ids: [String]
var isStreamEnd: Bool {
return continuation == nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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. Theres 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)

View File

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

View File

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

View File

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

View File

@ -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):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,5 +9,5 @@
import Foundation
protocol FeedlyGetCollectionsService: AnyObject {
func getCollections(completion: @escaping (Result<[FeedlyCollection], Error>) -> ())
func getCollections(completion: @escaping (Result<[FeedlyCollection], Error>) -> Void)
}

View File

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

View File

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

View File

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

View File

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