Delete Feedly operations.
This commit is contained in:
parent
e58072e281
commit
8c8cfa6377
|
@ -129,6 +129,11 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
|||
refreshing = true
|
||||
defer { refreshing = false }
|
||||
|
||||
let date = Date()
|
||||
defer {
|
||||
os_log(.debug, log: log, "Sync took %{public}.3f seconds", -date.timeIntervalSinceNow)
|
||||
}
|
||||
|
||||
// Send any read/unread/starred article statuses to Feedly before anything else.
|
||||
try await sendArticleStatuses()
|
||||
|
||||
|
@ -159,103 +164,15 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
|||
try await downloadArticles(missingArticleIDs: missingArticleIDs, updatedArticleIDs: updatedArticleIDs)
|
||||
}
|
||||
|
||||
private func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
assert(Thread.isMainThread)
|
||||
func syncArticleStatus(for account: Account) async throws {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
let log = self.log
|
||||
|
||||
let syncAllOperation = FeedlySyncAllOperation(account: account, feedlyUserID: credentials.username, caller: caller, database: syncDatabase, 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)
|
||||
}
|
||||
|
||||
currentSyncAllOperation = syncAllOperation
|
||||
|
||||
operationQueue.add(syncAllOperation)
|
||||
}
|
||||
|
||||
@MainActor func syncArticleStatus(for account: Account) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
sendArticleStatus(for: account) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
self.refreshArticleStatus(for: account) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
try await sendArticleStatus(for: account)
|
||||
try await refreshArticleStatus(for: account)
|
||||
}
|
||||
|
||||
public func sendArticleStatus(for account: Account) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
|
||||
self.sendArticleStatus(for: account) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private 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: syncDatabase, service: caller, log: log)
|
||||
send.completionBlock = { operation in
|
||||
// TODO: not call with success if operation was canceled? Not sure.
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
operationQueue.add(send)
|
||||
}
|
||||
|
||||
func refreshArticleStatus(for account: Account) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.refreshArticleStatus(for: account) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
try await sendArticleStatuses()
|
||||
}
|
||||
|
||||
/// Attempts to ensure local articles have the same status as they do remotely.
|
||||
|
@ -263,34 +180,10 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
|||
/// this app does its part to ensure the articles have a consistent status between both.
|
||||
///
|
||||
/// - Parameter account: The account whose articles have a remote status.
|
||||
/// - Parameter completion: Call on the main queue.
|
||||
private func refreshArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void)) {
|
||||
guard let credentials = credentials else {
|
||||
return completion(.success(()))
|
||||
}
|
||||
func refreshArticleStatus(for account: Account) async throws {
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
let ingestUnread = FeedlyIngestUnreadArticleIDsOperation(account: account, userID: credentials.username, service: caller, database: syncDatabase, newerThan: nil, log: log)
|
||||
|
||||
group.enter()
|
||||
ingestUnread.completionBlock = { _ in
|
||||
group.leave()
|
||||
|
||||
}
|
||||
|
||||
let ingestStarred = FeedlyIngestStarredArticleIDsOperation(account: account, userID: credentials.username, service: caller, database: syncDatabase, newerThan: nil, log: log)
|
||||
|
||||
group.enter()
|
||||
ingestStarred.completionBlock = { _ in
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
completion(.success(()))
|
||||
}
|
||||
|
||||
operationQueue.addOperations([ingestUnread, ingestStarred])
|
||||
try await fetchAndProcessUnreadArticleIDs()
|
||||
try await fetchAndProcessStarredArticleIDs()
|
||||
}
|
||||
|
||||
func importOPML(for account: Account, opmlFile: URL) async throws {
|
||||
|
@ -366,49 +259,9 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
|||
|
||||
func createFeed(for account: Account, url: String, name: String?, container: Container, validateFeed: Bool) async throws -> Feed {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.createFeed(for: account, url: url, name: name, container: container, validateFeed: validateFeed) { result in
|
||||
switch result {
|
||||
case .success(let feed):
|
||||
continuation.resume(returning: feed)
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: make this work
|
||||
|
||||
private 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,
|
||||
feedName: name,
|
||||
searchService: caller,
|
||||
addToCollectionService: caller,
|
||||
syncUnreadIDsService: caller,
|
||||
getStreamContentsService: caller,
|
||||
database: syncDatabase,
|
||||
container: container,
|
||||
progress: refreshProgress,
|
||||
log: log)
|
||||
|
||||
addNewFeed.addCompletionHandler = { result in
|
||||
completion(result)
|
||||
}
|
||||
|
||||
operationQueue.add(addNewFeed)
|
||||
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
throw FeedlyAccountDelegateError.notLoggedIn
|
||||
}
|
||||
|
||||
func renameFeed(for account: Account, with feed: Feed, to name: String) async throws {
|
||||
|
@ -435,48 +288,8 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
|||
|
||||
func addFeed(for account: Account, with feed: Feed, to container: any Container) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
|
||||
self.addFeed(for: account, with: feed, to: container) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
resource: resource,
|
||||
service: caller,
|
||||
container: container,
|
||||
progress: refreshProgress,
|
||||
log: log,
|
||||
customFeedName: feed.editedName)
|
||||
|
||||
|
||||
addExistingFeed.addCompletionHandler = { result in
|
||||
completion(result)
|
||||
}
|
||||
|
||||
operationQueue.add(addExistingFeed)
|
||||
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
let resourceID = FeedlyFeedResourceID(id: feed.feedID)
|
||||
try await addExistingFeed(resourceID: resourceID, container: container)
|
||||
}
|
||||
|
||||
func removeFeed(for account: Account, with feed: Feed, from container: any Container) async throws {
|
||||
|
@ -517,119 +330,42 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
|||
|
||||
func restoreFeed(for account: Account, feed: Feed, container: any Container) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
|
||||
self.restoreFeed(for: account, feed: feed, container: container) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
|
||||
if let existingFeed = account.existingFeed(withURL: feed.url) {
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
try await account.addFeed(existingFeed, to: container)
|
||||
completion(.success(()))
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
try await account.addFeed(existingFeed, to: container)
|
||||
} else {
|
||||
createFeed(for: account, url: feed.url, name: feed.editedName, container: container, validateFeed: true) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
completion(.success(()))
|
||||
case .failure(let error):
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
try await createFeed(for: account, url: feed.url, name: feed.editedName, container: container, validateFeed: true)
|
||||
}
|
||||
}
|
||||
|
||||
func restoreFolder(for account: Account, folder: Folder) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.restoreFolder(for: account, folder: folder) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private 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()
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure(let error):
|
||||
os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription)
|
||||
}
|
||||
do {
|
||||
try await restoreFeed(for: account, feed: feed, container: folder)
|
||||
|
||||
} catch {
|
||||
os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
account.addFolder(folder)
|
||||
completion(.success(()))
|
||||
}
|
||||
account.addFolder(folder)
|
||||
}
|
||||
|
||||
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) async throws {
|
||||
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.markArticles(for: account, articles: articles, statusKey: statusKey, flag: flag) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
continuation.resume()
|
||||
case .failure(let error):
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
let articles = try await account.update(articles: articles, statusKey: statusKey, flag: flag)
|
||||
|
||||
let syncStatuses = articles.map { article in
|
||||
return SyncStatus(articleID: article.articleID, key: SyncStatus.Key(statusKey), flag: flag)
|
||||
}
|
||||
}
|
||||
|
||||
private func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
try? await syncDatabase.insertStatuses(syncStatuses)
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
do {
|
||||
|
||||
let articles = try await account.update(articles: articles, statusKey: statusKey, flag: flag)
|
||||
|
||||
let syncStatuses = articles.map { article in
|
||||
return SyncStatus(articleID: article.articleID, key: SyncStatus.Key(statusKey), flag: flag)
|
||||
}
|
||||
|
||||
try? await self.syncDatabase.insertStatuses(syncStatuses)
|
||||
|
||||
if let count = try? await self.syncDatabase.selectPendingCount(), count > 100 {
|
||||
self.sendArticleStatus(for: account) { _ in }
|
||||
}
|
||||
completion(.success(()))
|
||||
|
||||
} catch {
|
||||
completion(.failure(error))
|
||||
}
|
||||
if let count = try? await syncDatabase.selectPendingCount(), count > 100 {
|
||||
try? await sendArticleStatus(for: account)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -638,10 +374,10 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
|||
credentials = try? account.retrieveCredentials(type: .oauthAccessToken)
|
||||
}
|
||||
|
||||
@MainActor func accountWillBeDeleted(_ account: Account) {
|
||||
let logout = FeedlyLogoutOperation(service: caller, log: log)
|
||||
// Dispatch on the shared queue because the lifetime of the account delegate is uncertain.
|
||||
MainThreadOperationQueue.shared.add(logout)
|
||||
func accountWillBeDeleted(_ account: Account) {
|
||||
Task {
|
||||
try? await logout(account: account)
|
||||
}
|
||||
}
|
||||
|
||||
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, secretsProvider: SecretsProvider) async throws -> Credentials? {
|
||||
|
|
|
@ -259,7 +259,7 @@ protocol FeedlyAPICallerDelegate: AnyObject {
|
|||
}
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlyAddFeedToCollectionService {
|
||||
extension FeedlyAPICaller {
|
||||
|
||||
@MainActor func addFeed(with feedID: FeedlyFeedResourceID, title: String? = nil, toCollectionWith collectionID: String) async throws -> [FeedlyFeed] {
|
||||
|
||||
|
@ -533,7 +533,7 @@ extension FeedlyAPICaller: FeedlyMarkArticlesService {
|
|||
}
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlySearchService {
|
||||
extension FeedlyAPICaller {
|
||||
|
||||
func getFeeds(for query: String, count: Int, localeIdentifier: String) async throws -> FeedlyFeedsSearchResponse {
|
||||
|
||||
|
@ -563,7 +563,7 @@ extension FeedlyAPICaller: FeedlySearchService {
|
|||
}
|
||||
}
|
||||
|
||||
extension FeedlyAPICaller: FeedlyLogoutService {
|
||||
extension FeedlyAPICaller {
|
||||
|
||||
func logout() async throws {
|
||||
|
||||
|
|
|
@ -1,76 +0,0 @@
|
|||
//
|
||||
// FeedlyAddExistingFeedOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 27/11/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import Web
|
||||
import Secrets
|
||||
import Core
|
||||
import Feedly
|
||||
|
||||
@MainActor final class FeedlyAddExistingFeedOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyCheckpointOperationDelegate {
|
||||
|
||||
private let operationQueue = MainThreadOperationQueue()
|
||||
var addCompletionHandler: ((Result<Void, Error>) -> ())?
|
||||
|
||||
@MainActor 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(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()
|
||||
}
|
||||
|
||||
override func didCancel() {
|
||||
operationQueue.cancelAllOperations()
|
||||
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()
|
||||
}
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
//
|
||||
// FeedlyAddFeedToCollectionOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 11/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CommonErrors
|
||||
import Feedly
|
||||
|
||||
protocol FeedlyAddFeedToCollectionService {
|
||||
func addFeed(with feedID: FeedlyFeedResourceID, title: String?, toCollectionWith collectionID: String) async throws -> [FeedlyFeed]
|
||||
}
|
||||
|
||||
final class FeedlyAddFeedToCollectionOperation: FeedlyOperation, FeedlyFeedsAndFoldersProviding, FeedlyResourceProviding {
|
||||
|
||||
let feedName: String?
|
||||
let collectionID: String
|
||||
let service: FeedlyAddFeedToCollectionService
|
||||
let folder: Folder
|
||||
let feedResource: FeedlyFeedResourceID
|
||||
|
||||
init(folder: Folder, feedResource: FeedlyFeedResourceID, feedName: String? = nil, collectionID: String, service: FeedlyAddFeedToCollectionService) {
|
||||
self.folder = folder
|
||||
self.feedResource = feedResource
|
||||
self.feedName = feedName
|
||||
self.collectionID = collectionID
|
||||
self.service = service
|
||||
}
|
||||
|
||||
private(set) var feedsAndFolders = [([FeedlyFeed], Folder)]()
|
||||
|
||||
var resource: FeedlyResourceID {
|
||||
return feedResource
|
||||
}
|
||||
|
||||
override func run() {
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
do {
|
||||
let feedlyFeeds = try await service.addFeed(with: feedResource, title: feedName, toCollectionWith: collectionID)
|
||||
|
||||
feedsAndFolders = [(feedlyFeeds, folder)]
|
||||
|
||||
let feedsWithCreatedFeedID = feedlyFeeds.filter { $0.id == resource.id }
|
||||
if feedsWithCreatedFeedID.isEmpty {
|
||||
didFinish(with: AccountError.createErrorNotFound)
|
||||
} else {
|
||||
didFinish()
|
||||
}
|
||||
|
||||
} catch {
|
||||
didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,150 +0,0 @@
|
|||
//
|
||||
// FeedlyAddNewFeedOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 27/11/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import SyncDatabase
|
||||
import Web
|
||||
import Secrets
|
||||
import Core
|
||||
import CommonErrors
|
||||
import Feedly
|
||||
|
||||
final class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlySearchOperationDelegate, FeedlyCheckpointOperationDelegate {
|
||||
|
||||
private let operationQueue = MainThreadOperationQueue()
|
||||
private let folder: Folder
|
||||
private let collectionID: String
|
||||
private let url: String
|
||||
private let account: Account
|
||||
private let credentials: Credentials
|
||||
private let database: SyncDatabase
|
||||
private let feedName: String?
|
||||
private let addToCollectionService: FeedlyAddFeedToCollectionService
|
||||
private let syncUnreadIDsService: FeedlyGetStreamIDsService
|
||||
private let getStreamContentsService: FeedlyGetStreamContentsService
|
||||
private let log: OSLog
|
||||
private var feedResourceID: FeedlyFeedResourceID?
|
||||
var addCompletionHandler: ((Result<Feed, Error>) -> ())?
|
||||
|
||||
@MainActor 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
|
||||
self.credentials = credentials
|
||||
self.database = database
|
||||
self.feedName = feedName
|
||||
self.addToCollectionService = addToCollectionService
|
||||
self.syncUnreadIDsService = syncUnreadIDsService
|
||||
self.getStreamContentsService = getStreamContentsService
|
||||
self.log = log
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
override func didCancel() {
|
||||
operationQueue.cancelAllOperations()
|
||||
addCompletionHandler = nil
|
||||
super.didCancel()
|
||||
}
|
||||
|
||||
override func didFinish(with error: Error) {
|
||||
assert(Thread.isMainThread)
|
||||
addCompletionHandler?(.failure(error))
|
||||
addCompletionHandler = nil
|
||||
super.didFinish(with: error)
|
||||
}
|
||||
|
||||
@MainActor func feedlySearchOperation(_ operation: FeedlySearchOperation, didGet response: FeedlyFeedsSearchResponse) {
|
||||
guard !isCanceled else {
|
||||
return
|
||||
}
|
||||
guard let first = response.results.first else {
|
||||
return didFinish(with: AccountError.createErrorNotFound)
|
||||
}
|
||||
|
||||
let feedResourceID = FeedlyFeedResourceID(id: first.feedID)
|
||||
self.feedResourceID = feedResourceID
|
||||
|
||||
let addRequest = FeedlyAddFeedToCollectionOperation(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
|
||||
finishOperation.addDependency(syncFeed)
|
||||
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
|
||||
}
|
||||
defer {
|
||||
didFinish()
|
||||
}
|
||||
|
||||
guard let handler = addCompletionHandler else {
|
||||
return
|
||||
}
|
||||
if let feedResource = feedResourceID, let feed = folder.existingFeed(withFeedID: feedResource.id) {
|
||||
handler(.success(feed))
|
||||
}
|
||||
else {
|
||||
handler(.failure(AccountError.createErrorNotFound))
|
||||
}
|
||||
addCompletionHandler = nil
|
||||
}
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
//
|
||||
// FeedlyCheckpointOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 18/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Feedly
|
||||
|
||||
protocol FeedlyCheckpointOperationDelegate: AnyObject {
|
||||
@MainActor func feedlyCheckpointOperationDidReachCheckpoint(_ operation: FeedlyCheckpointOperation)
|
||||
}
|
||||
|
||||
/// Let the delegate know an instance is executing. The semantics are up to the delegate.
|
||||
final class FeedlyCheckpointOperation: FeedlyOperation {
|
||||
|
||||
weak var checkpointDelegate: FeedlyCheckpointOperationDelegate?
|
||||
|
||||
override func run() {
|
||||
defer {
|
||||
didFinish()
|
||||
}
|
||||
checkpointDelegate?.feedlyCheckpointOperationDidReachCheckpoint(self)
|
||||
}
|
||||
}
|
|
@ -1,115 +0,0 @@
|
|||
//
|
||||
// FeedlyCreateFeedsForCollectionFoldersOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 20/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import Core
|
||||
import Feedly
|
||||
|
||||
/// 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
|
||||
|
||||
init(account: Account, feedsAndFoldersProvider: FeedlyFeedsAndFoldersProviding, log: OSLog) {
|
||||
self.feedsAndFoldersProvider = feedsAndFoldersProvider
|
||||
self.account = account
|
||||
self.log = log
|
||||
}
|
||||
|
||||
public 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
|
||||
let feedsInCollection = Set(collectionFeeds.map { $0.id })
|
||||
let feedsToRemove = feedsInFolder.filter { !feedsInCollection.contains($0.feedID) }
|
||||
if !feedsToRemove.isEmpty {
|
||||
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
|
||||
return (feed, folder) // pairs a folder for every feed in parallel
|
||||
}
|
||||
})
|
||||
.flatMap { $0 }
|
||||
.compactMap { (collectionFeed, folder) -> (Feed, Folder) in
|
||||
|
||||
// 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.
|
||||
if feed.editedName != nil {
|
||||
feed.editedName = nil
|
||||
} else {
|
||||
feed.postDisplayNameDidChangeNotification()
|
||||
}
|
||||
}
|
||||
return (feed, folder)
|
||||
} else {
|
||||
// find an existing feed we created below in an earlier value
|
||||
for feed in feedsAdded where feed.feedID == collectionFeed.id {
|
||||
return (feed, folder)
|
||||
}
|
||||
}
|
||||
|
||||
// no existing feed, create a new one
|
||||
let parser = FeedlyFeedParser(feed: collectionFeed)
|
||||
let feed = account.createFeed(with: parser.title,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,99 +0,0 @@
|
|||
//
|
||||
// FeedlyDownloadArticlesOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 9/1/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import Web
|
||||
import Core
|
||||
import Feedly
|
||||
|
||||
class FeedlyDownloadArticlesOperation: FeedlyOperation {
|
||||
|
||||
private let account: Account
|
||||
private let log: OSLog
|
||||
private let missingArticleEntryIDProvider: FeedlyEntryIdentifierProviding
|
||||
private let updatedArticleEntryIDProvider: FeedlyEntryIdentifierProviding
|
||||
private let getEntriesService: FeedlyGetEntriesService
|
||||
private let operationQueue = MainThreadOperationQueue()
|
||||
private let finishOperation: FeedlyCheckpointOperation
|
||||
|
||||
@MainActor init(account: Account, missingArticleEntryIDProvider: FeedlyEntryIdentifierProviding, updatedArticleEntryIDProvider: FeedlyEntryIdentifierProviding, getEntriesService: FeedlyGetEntriesService, log: OSLog) {
|
||||
self.account = account
|
||||
self.operationQueue.suspend()
|
||||
self.missingArticleEntryIDProvider = missingArticleEntryIDProvider
|
||||
self.updatedArticleEntryIDProvider = updatedArticleEntryIDProvider
|
||||
self.getEntriesService = getEntriesService
|
||||
self.finishOperation = FeedlyCheckpointOperation()
|
||||
self.log = log
|
||||
super.init()
|
||||
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) {
|
||||
|
||||
Task { @MainActor in
|
||||
let provider = FeedlyEntryIdentifierProvider(entryIDs: Set(articleIDs))
|
||||
let getEntries = FeedlyGetEntriesOperation(service: self.getEntriesService, provider: provider, log: self.log)
|
||||
getEntries.delegate = self
|
||||
self.operationQueue.add(getEntries)
|
||||
|
||||
let organiseByFeed = FeedlyOrganiseParsedItemsByFeedOperation(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)
|
||||
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()
|
||||
}
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
//
|
||||
// FeedlyFetchIDsForMissingArticlesOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 7/1/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import Feedly
|
||||
|
||||
final class FeedlyFetchIDsForMissingArticlesOperation: FeedlyOperation, FeedlyEntryIdentifierProviding {
|
||||
|
||||
private let account: Account
|
||||
|
||||
private(set) var entryIDs = Set<String>()
|
||||
|
||||
init(account: Account) {
|
||||
self.account = account
|
||||
}
|
||||
|
||||
override func run() {
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
do {
|
||||
if let articleIDs = try await account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate() {
|
||||
self.entryIDs.formUnion(articleIDs)
|
||||
}
|
||||
|
||||
self.didFinish()
|
||||
|
||||
} catch {
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,108 +0,0 @@
|
|||
//
|
||||
// FeedlyIngestStarredArticleIDsOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 15/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import SyncDatabase
|
||||
import Secrets
|
||||
import Feedly
|
||||
|
||||
/// Clone locally the remote starred article state.
|
||||
///
|
||||
/// Typically, it pages through the article ids of the global.saved stream.
|
||||
/// When all the article ids are collected, a status is created for each.
|
||||
/// The article ids previously marked as starred but not collected become unstarred.
|
||||
/// So this operation has side effects *for the entire account* it operates on.
|
||||
final class FeedlyIngestStarredArticleIDsOperation: FeedlyOperation {
|
||||
|
||||
private let account: Account
|
||||
private let resource: FeedlyResourceID
|
||||
private let service: FeedlyGetStreamIDsService
|
||||
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 = FeedlyTagResourceID.Global.saved(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
|
||||
self.service = service
|
||||
self.database = database
|
||||
self.log = log
|
||||
}
|
||||
|
||||
override func run() {
|
||||
getStreamIDs(nil)
|
||||
}
|
||||
|
||||
private func getStreamIDs(_ continuation: String?) {
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
do {
|
||||
let streamIDs = try await service.getStreamIDs(for: resource, continuation: continuation, newerThan: nil, unreadOnly: nil)
|
||||
remoteEntryIDs.formUnion(streamIDs.ids)
|
||||
|
||||
guard let continuation = streamIDs.continuation else {
|
||||
try await removeEntryIDsWithPendingStatus()
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
getStreamIDs(continuation)
|
||||
|
||||
} catch {
|
||||
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() async throws {
|
||||
|
||||
if let pendingArticleIDs = try await database.selectPendingStarredStatusArticleIDs() {
|
||||
remoteEntryIDs.subtract(pendingArticleIDs)
|
||||
}
|
||||
try await updateStarredStatuses()
|
||||
}
|
||||
|
||||
private func updateStarredStatuses() async throws {
|
||||
|
||||
if let localStarredArticleIDs = try await account.fetchStarredArticleIDs() {
|
||||
try await processStarredArticleIDs(localStarredArticleIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func processStarredArticleIDs(_ localStarredArticleIDs: Set<String>) async throws {
|
||||
|
||||
var markAsStarredError: Error?
|
||||
var markAsUnstarredError: Error?
|
||||
|
||||
let remoteStarredArticleIDs = remoteEntryIDs
|
||||
do {
|
||||
try await account.markAsStarred(remoteStarredArticleIDs)
|
||||
} catch {
|
||||
markAsStarredError = error
|
||||
}
|
||||
|
||||
let deltaUnstarredArticleIDs = localStarredArticleIDs.subtracting(remoteStarredArticleIDs)
|
||||
do {
|
||||
try await account.markAsUnstarred(deltaUnstarredArticleIDs)
|
||||
} catch {
|
||||
markAsUnstarredError = error
|
||||
}
|
||||
|
||||
if let markingError = markAsStarredError ?? markAsUnstarredError {
|
||||
throw markingError
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,64 +0,0 @@
|
|||
//
|
||||
// FeedlyIngestStreamArticleIDsOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 9/1/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import Secrets
|
||||
import Database
|
||||
import Feedly
|
||||
|
||||
/// Ensure a status exists for every article id the user might be interested in.
|
||||
///
|
||||
/// Typically, it pages through the article ids of the global.all stream.
|
||||
/// As the article ids are collected, a default read status is created for each.
|
||||
/// So this operation has side effects *for the entire account* it operates on.
|
||||
class FeedlyIngestStreamArticleIDsOperation: FeedlyOperation {
|
||||
|
||||
private let account: Account
|
||||
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?) {
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
do {
|
||||
let streamIDs = try await service.getStreamIDs(for: resource, continuation: continuation, newerThan: nil, unreadOnly: nil)
|
||||
|
||||
try await account.createStatusesIfNeeded(articleIDs: Set(streamIDs.ids))
|
||||
|
||||
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)
|
||||
|
||||
} catch {
|
||||
didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,110 +0,0 @@
|
|||
//
|
||||
// FeedlyIngestUnreadArticleIDsOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 18/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import Parser
|
||||
import SyncDatabase
|
||||
import Secrets
|
||||
import Feedly
|
||||
|
||||
/// Clone locally the remote unread article state.
|
||||
///
|
||||
/// Typically, it pages through the unread article ids of the global.all stream.
|
||||
/// When all the unread article ids are collected, a status is created for each.
|
||||
/// The article ids previously marked as unread but not collected become read.
|
||||
/// So this operation has side effects *for the entire account* it operates on.
|
||||
final class FeedlyIngestUnreadArticleIDsOperation: FeedlyOperation {
|
||||
|
||||
private let account: Account
|
||||
private let resource: FeedlyResourceID
|
||||
private let service: FeedlyGetStreamIDsService
|
||||
private let database: SyncDatabase
|
||||
private var remoteEntryIDs = Set<String>()
|
||||
private let log: OSLog
|
||||
|
||||
public 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)
|
||||
}
|
||||
|
||||
public init(account: Account, resource: FeedlyResourceID, service: FeedlyGetStreamIDsService, database: SyncDatabase, newerThan: Date?, log: OSLog) {
|
||||
self.account = account
|
||||
self.resource = resource
|
||||
self.service = service
|
||||
self.database = database
|
||||
self.log = log
|
||||
}
|
||||
|
||||
override func run() {
|
||||
getStreamIDs(nil)
|
||||
}
|
||||
|
||||
private func getStreamIDs(_ continuation: String?) {
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
do {
|
||||
let streamIDs = try await service.getStreamIDs(for: resource, continuation: continuation, newerThan: nil, unreadOnly: true)
|
||||
remoteEntryIDs.formUnion(streamIDs.ids)
|
||||
|
||||
guard let continuation = streamIDs.continuation else {
|
||||
try await removeEntryIDsWithPendingStatus()
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
getStreamIDs(continuation)
|
||||
|
||||
} catch {
|
||||
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() async throws {
|
||||
|
||||
if let pendingArticleIDs = try await database.selectPendingReadStatusArticleIDs() {
|
||||
remoteEntryIDs.subtract(pendingArticleIDs)
|
||||
}
|
||||
try await updateUnreadStatuses()
|
||||
}
|
||||
|
||||
private func updateUnreadStatuses() async throws {
|
||||
|
||||
if let localUnreadArticleIDs = try await account.fetchUnreadArticleIDs() {
|
||||
try await processUnreadArticleIDs(localUnreadArticleIDs)
|
||||
}
|
||||
}
|
||||
|
||||
private func processUnreadArticleIDs(_ localUnreadArticleIDs: Set<String>) async throws {
|
||||
|
||||
let remoteUnreadArticleIDs = remoteEntryIDs
|
||||
|
||||
var markAsUnreadError: Error?
|
||||
var markAsReadError: Error?
|
||||
|
||||
do {
|
||||
try await account.markAsUnread(remoteUnreadArticleIDs)
|
||||
} catch {
|
||||
markAsUnreadError = error
|
||||
}
|
||||
|
||||
let articleIDsToMarkRead = localUnreadArticleIDs.subtracting(remoteUnreadArticleIDs)
|
||||
do {
|
||||
try await account.markAsRead(articleIDsToMarkRead)
|
||||
} catch {
|
||||
markAsReadError = error
|
||||
}
|
||||
|
||||
if let markingError = markAsReadError ?? markAsUnreadError {
|
||||
throw markingError
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,64 +0,0 @@
|
|||
//
|
||||
// FeedlyMirrorCollectionsAsFoldersOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 20/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import Feedly
|
||||
|
||||
protocol FeedlyFeedsAndFoldersProviding {
|
||||
@MainActor var feedsAndFolders: [([FeedlyFeed], Folder)] { get }
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
self.collectionsProvider = collectionsProvider
|
||||
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 {
|
||||
assertionFailure("Why wasn't a folder created?")
|
||||
return nil
|
||||
}
|
||||
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(folder: unmatched)
|
||||
}
|
||||
|
||||
os_log(.debug, log: log, "Removed %i folders: %@", foldersWithoutCollections.count, foldersWithoutCollections.map { $0.externalID ?? $0.nameForDisplay })
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,173 +0,0 @@
|
|||
//
|
||||
// FeedlySyncAllOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 19/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import SyncDatabase
|
||||
import Web
|
||||
import Secrets
|
||||
import Core
|
||||
import Feedly
|
||||
|
||||
/// Compose the operations necessary to get the entire set of articles, feeds and folders with the statuses the user expects between now and a certain date in the past.
|
||||
final class FeedlySyncAllOperation: FeedlyOperation {
|
||||
|
||||
private let operationQueue = MainThreadOperationQueue()
|
||||
private let log: OSLog
|
||||
let syncUUID: UUID
|
||||
|
||||
var syncCompletionHandler: ((Result<Void, Error>) -> ())?
|
||||
|
||||
/// 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).
|
||||
/// 3. The set of all article ids changed since the last sync (a subset of 1).
|
||||
/// 4. The set of all starred article ids.
|
||||
///
|
||||
/// On the response for 1, create statuses for each article id.
|
||||
/// On the response for 2, create unread statuses for each article id and mark as read those no longer in the response.
|
||||
/// On the response for 4, create starred statuses for each article id and mark as unstarred those no longer in the response.
|
||||
///
|
||||
/// Download articles for statuses at the union of those statuses without its corresponding article and those included in 3 (changed since last successful sync).
|
||||
///
|
||||
@MainActor init(account: Account, feedlyUserID: String, lastSuccessfulFetchStartDate: Date?, markArticlesService: FeedlyMarkArticlesService, getUnreadService: FeedlyGetStreamIDsService, getCollectionsService: FeedlyGetCollectionsService, getStreamContentsService: FeedlyGetStreamContentsService, getStarredService: FeedlyGetStreamIDsService, getStreamIDsService: FeedlyGetStreamIDsService, getEntriesService: FeedlyGetEntriesService, database: SyncDatabase, downloadProgress: DownloadProgress, log: OSLog) {
|
||||
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(userID: feedlyUserID, service: getStreamIDsService, newerThan: lastSuccessfulFetchStartDate, log: log)
|
||||
getUpdated.delegate = self
|
||||
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)
|
||||
getMissingIDs.delegate = self
|
||||
getMissingIDs.downloadProgress = downloadProgress
|
||||
getMissingIDs.addDependency(getAllArticleIDs)
|
||||
getMissingIDs.addDependency(getUnread)
|
||||
getMissingIDs.addDependency(getStarred)
|
||||
getMissingIDs.addDependency(getUpdated)
|
||||
self.operationQueue.add(getMissingIDs)
|
||||
|
||||
// Download all the missing and updated articles
|
||||
let downloadMissingArticles = FeedlyDownloadArticlesOperation(account: account,
|
||||
missingArticleEntryIDProvider: getMissingIDs,
|
||||
updatedArticleEntryIDProvider: getUpdated,
|
||||
getEntriesService: getEntriesService,
|
||||
log: log)
|
||||
downloadMissingArticles.delegate = self
|
||||
downloadMissingArticles.downloadProgress = downloadProgress
|
||||
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
|
||||
finishOperation.downloadProgress = downloadProgress
|
||||
finishOperation.addDependency(downloadMissingArticles)
|
||||
self.operationQueue.add(finishOperation)
|
||||
}
|
||||
|
||||
@MainActor 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()
|
||||
}
|
||||
|
||||
override func didCancel() {
|
||||
os_log(.debug, log: log, "Cancelling sync %{public}@", syncUUID.uuidString)
|
||||
self.operationQueue.cancelAllOperations()
|
||||
syncCompletionHandler = nil
|
||||
super.didCancel()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
|
@ -1,117 +0,0 @@
|
|||
//
|
||||
// FeedlySyncStreamContentsOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 17/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import Parser
|
||||
import Web
|
||||
import Secrets
|
||||
import Core
|
||||
import Feedly
|
||||
|
||||
final class FeedlySyncStreamContentsOperation: FeedlyOperation, FeedlyOperationDelegate, FeedlyGetStreamContentsOperationDelegate, FeedlyCheckpointOperationDelegate {
|
||||
|
||||
private let account: Account
|
||||
private let resource: FeedlyResourceID
|
||||
private let operationQueue = MainThreadOperationQueue()
|
||||
private let service: FeedlyGetStreamContentsService
|
||||
private let newerThan: Date?
|
||||
private let isPagingEnabled: Bool
|
||||
private let log: OSLog
|
||||
private let finishOperation: FeedlyCheckpointOperation
|
||||
|
||||
@MainActor init(account: Account, resource: FeedlyResourceID, service: FeedlyGetStreamContentsService, isPagingEnabled: Bool, newerThan: Date?, log: OSLog) {
|
||||
self.account = account
|
||||
self.resource = resource
|
||||
self.service = service
|
||||
self.isPagingEnabled = isPagingEnabled
|
||||
self.operationQueue.suspend()
|
||||
self.newerThan = newerThan
|
||||
self.log = log
|
||||
self.finishOperation = FeedlyCheckpointOperation()
|
||||
|
||||
super.init()
|
||||
|
||||
self.operationQueue.add(self.finishOperation)
|
||||
self.finishOperation.checkpointDelegate = self
|
||||
enqueueOperations(for: nil)
|
||||
}
|
||||
|
||||
@MainActor 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()
|
||||
}
|
||||
|
||||
override func didCancel() {
|
||||
os_log(.debug, log: log, "Canceling sync stream contents for %{public}@", resource.id)
|
||||
operationQueue.cancelAllOperations()
|
||||
super.didCancel()
|
||||
}
|
||||
|
||||
@MainActor func enqueueOperations(for continuation: String?) {
|
||||
os_log(.debug, log: log, "Requesting page for %{public}@", resource.id)
|
||||
let operations = pageOperations(for: continuation)
|
||||
operationQueue.addOperations(operations)
|
||||
}
|
||||
|
||||
func pageOperations(for continuation: String?) -> [MainThreadOperation] {
|
||||
let getPage = FeedlyGetStreamContentsOperation(resource: resource,
|
||||
service: service,
|
||||
continuation: continuation,
|
||||
newerThan: newerThan,
|
||||
log: log)
|
||||
|
||||
|
||||
let organiseByFeed = FeedlyOrganiseParsedItemsByFeedOperation(parsedItemProvider: getPage, log: log)
|
||||
|
||||
let updateAccount = FeedlyUpdateAccountFeedsWithItemsOperation(account: account, organisedItemsProvider: organiseByFeed, log: log)
|
||||
|
||||
getPage.delegate = self
|
||||
getPage.streamDelegate = self
|
||||
|
||||
organiseByFeed.addDependency(getPage)
|
||||
organiseByFeed.delegate = self
|
||||
|
||||
updateAccount.addDependency(organiseByFeed)
|
||||
updateAccount.delegate = self
|
||||
|
||||
finishOperation.addDependency(updateAccount)
|
||||
|
||||
return [getPage, organiseByFeed, updateAccount]
|
||||
}
|
||||
|
||||
@MainActor 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)
|
||||
}
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
//
|
||||
// FeedlyUpdateAccountFeedsWithItemsOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 20/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Parser
|
||||
import os.log
|
||||
import Database
|
||||
import Feedly
|
||||
|
||||
/// Combine the articles with their feeds for a specific account.
|
||||
final class FeedlyUpdateAccountFeedsWithItemsOperation: FeedlyOperation {
|
||||
|
||||
private let account: Account
|
||||
private let organisedItemsProvider: FeedlyParsedItemsByFeedProviding
|
||||
private let log: OSLog
|
||||
|
||||
init(account: Account, organisedItemsProvider: FeedlyParsedItemsByFeedProviding, log: OSLog) {
|
||||
|
||||
self.account = account
|
||||
self.organisedItemsProvider = organisedItemsProvider
|
||||
self.log = log
|
||||
}
|
||||
|
||||
override func run() {
|
||||
|
||||
let feedIDsAndItems = organisedItemsProvider.parsedItemsKeyedByFeedID
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
|
||||
try await account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true)
|
||||
os_log(.debug, log: self.log, "Updated %i feeds for \"%@\"", feedIDsAndItems.count, self.organisedItemsProvider.parsedItemsByFeedProviderName)
|
||||
self.didFinish()
|
||||
|
||||
} catch {
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
//
|
||||
// FeedlyGetCollectionsOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 19/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
public protocol FeedlyCollectionProviding: AnyObject {
|
||||
|
||||
@MainActor var collections: [FeedlyCollection] { get }
|
||||
}
|
||||
|
||||
/// Get Collections from Feedly.
|
||||
public final class FeedlyGetCollectionsOperation: FeedlyOperation, FeedlyCollectionProviding {
|
||||
|
||||
let service: FeedlyGetCollectionsService
|
||||
let log: OSLog
|
||||
|
||||
private(set) public var collections = [FeedlyCollection]()
|
||||
|
||||
public init(service: FeedlyGetCollectionsService, log: OSLog) {
|
||||
self.service = service
|
||||
self.log = log
|
||||
}
|
||||
|
||||
public override func run() {
|
||||
|
||||
Task { @MainActor in
|
||||
os_log(.debug, log: log, "Requesting collections.")
|
||||
|
||||
do {
|
||||
let collections = try await service.getCollections()
|
||||
os_log(.debug, log: self.log, "Received collections: %{public}@", collections.map { $0.id })
|
||||
self.collections = Array(collections)
|
||||
self.didFinish()
|
||||
|
||||
} catch {
|
||||
os_log(.debug, log: self.log, "Unable to request collections: %{public}@.", error as NSError)
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,70 +0,0 @@
|
|||
//
|
||||
// FeedlyGetEntriesOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 28/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import Parser
|
||||
|
||||
/// Get full entries for the entry identifiers.
|
||||
public final class FeedlyGetEntriesOperation: FeedlyOperation, FeedlyEntryProviding, FeedlyParsedItemProviding {
|
||||
|
||||
let service: FeedlyGetEntriesService
|
||||
let provider: FeedlyEntryIdentifierProviding
|
||||
let log: OSLog
|
||||
|
||||
public init(service: FeedlyGetEntriesService, provider: FeedlyEntryIdentifierProviding, log: OSLog) {
|
||||
self.service = service
|
||||
self.provider = provider
|
||||
self.log = log
|
||||
}
|
||||
|
||||
private (set) public var entries = [FeedlyEntry]()
|
||||
|
||||
private var storedParsedEntries: Set<ParsedItem>?
|
||||
|
||||
public var parsedEntries: Set<ParsedItem> {
|
||||
if let entries = storedParsedEntries {
|
||||
return entries
|
||||
}
|
||||
|
||||
let parsed = Set(entries.compactMap {
|
||||
FeedlyEntryParser(entry: $0).parsedItemRepresentation
|
||||
})
|
||||
|
||||
// TODO: Fix the below. There’s an error on the os.log line: "Expression type '()' is ambiguous without more context"
|
||||
// if parsed.count != entries.count {
|
||||
// let entryIDs = Set(entries.map { $0.id })
|
||||
// let parsedIDs = Set(parsed.map { $0.uniqueID })
|
||||
// let difference = entryIDs.subtracting(parsedIDs)
|
||||
// os_log(.debug, log: log, "%{public}@ dropping articles with ids: %{public}@.", self, difference)
|
||||
// }
|
||||
|
||||
storedParsedEntries = parsed
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
public var parsedItemProviderName: String {
|
||||
return name ?? String(describing: Self.self)
|
||||
}
|
||||
|
||||
public override func run() {
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
do {
|
||||
let entries = try await service.getEntries(for: provider.entryIDs)
|
||||
self.entries = entries
|
||||
self.didFinish()
|
||||
} catch {
|
||||
os_log(.debug, log: self.log, "Unable to get entries: %{public}@.", error as NSError)
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,117 +0,0 @@
|
|||
//
|
||||
// FeedlyGetStreamOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 20/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Parser
|
||||
import os.log
|
||||
|
||||
public protocol FeedlyEntryProviding {
|
||||
@MainActor var entries: [FeedlyEntry] { get }
|
||||
}
|
||||
|
||||
public protocol FeedlyParsedItemProviding {
|
||||
@MainActor var parsedItemProviderName: String { get }
|
||||
@MainActor var parsedEntries: Set<ParsedItem> { get }
|
||||
}
|
||||
|
||||
public protocol FeedlyGetStreamContentsOperationDelegate: AnyObject {
|
||||
func feedlyGetStreamContentsOperation(_ operation: FeedlyGetStreamContentsOperation, didGetContentsOf stream: FeedlyStream)
|
||||
}
|
||||
|
||||
/// Get the stream content of a Collection from Feedly.
|
||||
public final class FeedlyGetStreamContentsOperation: FeedlyOperation, FeedlyEntryProviding, FeedlyParsedItemProviding {
|
||||
|
||||
@MainActor struct ResourceProvider: FeedlyResourceProviding {
|
||||
var resource: FeedlyResourceID
|
||||
}
|
||||
|
||||
let resourceProvider: FeedlyResourceProviding
|
||||
|
||||
public var parsedItemProviderName: String {
|
||||
return resourceProvider.resource.id
|
||||
}
|
||||
|
||||
public var entries: [FeedlyEntry] {
|
||||
guard let entries = stream?.items else {
|
||||
// assert(isFinished, "This should only be called when the operation finishes without error.")
|
||||
assertionFailure("Has this operation been addeded as a dependency on the caller?")
|
||||
return []
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
public 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 service: FeedlyGetStreamContentsService
|
||||
let unreadOnly: Bool?
|
||||
let newerThan: Date?
|
||||
let continuation: String?
|
||||
let log: OSLog
|
||||
|
||||
public weak var streamDelegate: FeedlyGetStreamContentsOperationDelegate?
|
||||
|
||||
public init(resource: FeedlyResourceID, service: FeedlyGetStreamContentsService, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool? = nil, log: OSLog) {
|
||||
|
||||
self.resourceProvider = ResourceProvider(resource: resource)
|
||||
self.service = service
|
||||
self.continuation = continuation
|
||||
self.unreadOnly = unreadOnly
|
||||
self.newerThan = newerThan
|
||||
self.log = log
|
||||
}
|
||||
|
||||
convenience init(resourceProvider: FeedlyResourceProviding, service: FeedlyGetStreamContentsService, newerThan: Date?, unreadOnly: Bool? = nil, log: OSLog) {
|
||||
|
||||
self.init(resource: resourceProvider.resource, service: service, newerThan: newerThan, unreadOnly: unreadOnly, log: log)
|
||||
}
|
||||
|
||||
public override func run() {
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
do {
|
||||
let stream = try await service.getStreamContents(for: resourceProvider.resource, continuation: continuation, newerThan: newerThan, unreadOnly: unreadOnly)
|
||||
|
||||
self.stream = stream
|
||||
self.streamDelegate?.feedlyGetStreamContentsOperation(self, didGetContentsOf: stream)
|
||||
self.didFinish()
|
||||
|
||||
} catch {
|
||||
os_log(.debug, log: self.log, "Unable to get stream contents: %{public}@.", error as NSError)
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,73 +0,0 @@
|
|||
//
|
||||
// FeedlyGetUpdatedArticleIDsOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 11/1/20.
|
||||
// Copyright © 2020 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
import Secrets
|
||||
|
||||
/// Single responsibility is to identify articles that have changed since a particular date.
|
||||
///
|
||||
/// Typically, it pages through the article ids of the global.all stream.
|
||||
/// When all the article ids are collected, it is the responsibility of another operation to download them when appropriate.
|
||||
public final class FeedlyGetUpdatedArticleIDsOperation: FeedlyOperation, FeedlyEntryIdentifierProviding {
|
||||
|
||||
private let resource: FeedlyResourceID
|
||||
private let service: FeedlyGetStreamIDsService
|
||||
private let newerThan: Date?
|
||||
private let log: OSLog
|
||||
|
||||
public init(resource: FeedlyResourceID, service: FeedlyGetStreamIDsService, newerThan: Date?, log: OSLog) {
|
||||
|
||||
self.resource = resource
|
||||
self.service = service
|
||||
self.newerThan = newerThan
|
||||
self.log = log
|
||||
}
|
||||
|
||||
public convenience init(userID: String, service: FeedlyGetStreamIDsService, newerThan: Date?, log: OSLog) {
|
||||
let all = FeedlyCategoryResourceID.Global.all(for: userID)
|
||||
self.init(resource: all, service: service, newerThan: newerThan, log: log)
|
||||
}
|
||||
|
||||
public var entryIDs: Set<String> {
|
||||
return storedUpdatedArticleIDs
|
||||
}
|
||||
|
||||
private var storedUpdatedArticleIDs = Set<String>()
|
||||
|
||||
public override func run() {
|
||||
getStreamIDs(nil)
|
||||
}
|
||||
|
||||
private func getStreamIDs(_ continuation: String?) {
|
||||
|
||||
Task { @MainActor in
|
||||
guard let date = newerThan else {
|
||||
os_log(.debug, log: log, "No date provided so everything must be new (nothing is updated).")
|
||||
didFinish()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let streamIDs = try await service.getStreamIDs(for: resource, continuation: continuation, newerThan: date, unreadOnly: nil)
|
||||
|
||||
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)
|
||||
|
||||
} catch {
|
||||
didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
//
|
||||
// FeedlyLogoutOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 15/11/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
public protocol FeedlyLogoutService {
|
||||
|
||||
@MainActor func logout() async throws
|
||||
}
|
||||
|
||||
public final class FeedlyLogoutOperation: FeedlyOperation {
|
||||
|
||||
let service: FeedlyLogoutService
|
||||
let log: OSLog
|
||||
|
||||
public init(service: FeedlyLogoutService, log: OSLog) {
|
||||
self.service = service
|
||||
self.log = log
|
||||
}
|
||||
|
||||
public override func run() {
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
do {
|
||||
os_log("Requesting logout of Feedly account.")
|
||||
try await service.logout()
|
||||
os_log("Logged out of Feedly account.")
|
||||
|
||||
// TODO: fix removing credentials
|
||||
// try account.removeCredentials(type: .oauthAccessToken)
|
||||
// try account.removeCredentials(type: .oauthRefreshToken)
|
||||
|
||||
didFinish()
|
||||
|
||||
} catch {
|
||||
os_log("Logout failed because %{public}@.", error as NSError)
|
||||
didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,64 +0,0 @@
|
|||
//
|
||||
// FeedlyOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 20/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Web
|
||||
import Core
|
||||
|
||||
public protocol FeedlyOperationDelegate: AnyObject {
|
||||
@MainActor func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error)
|
||||
}
|
||||
|
||||
/// Abstract base class for Feedly sync operations.
|
||||
///
|
||||
/// Normally we don’t do inheritance — but in this case
|
||||
/// it’s the best option.
|
||||
@MainActor open class FeedlyOperation: MainThreadOperation {
|
||||
|
||||
public weak var delegate: FeedlyOperationDelegate?
|
||||
public var downloadProgress: DownloadProgress? {
|
||||
didSet {
|
||||
oldValue?.completeTask()
|
||||
downloadProgress?.addToNumberOfTasksAndRemaining(1)
|
||||
}
|
||||
}
|
||||
|
||||
// MainThreadOperation
|
||||
public var isCanceled = false {
|
||||
didSet {
|
||||
if isCanceled {
|
||||
didCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
public var id: Int?
|
||||
public weak var operationDelegate: MainThreadOperationDelegate?
|
||||
public var name: String?
|
||||
public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
|
||||
|
||||
public init() {}
|
||||
|
||||
open func run() {
|
||||
}
|
||||
|
||||
open func didFinish() {
|
||||
if !isCanceled {
|
||||
operationDelegate?.operationDidComplete(self)
|
||||
}
|
||||
downloadProgress?.completeTask()
|
||||
}
|
||||
|
||||
open func didFinish(with error: Error) {
|
||||
delegate?.feedlyOperation(self, didFailWith: error)
|
||||
didFinish()
|
||||
}
|
||||
|
||||
open func didCancel() {
|
||||
didFinish()
|
||||
}
|
||||
}
|
|
@ -1,67 +0,0 @@
|
|||
//
|
||||
// FeedlyOrganiseParsedItemsByFeedOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 20/9/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Parser
|
||||
import os.log
|
||||
|
||||
public protocol FeedlyParsedItemsByFeedProviding {
|
||||
|
||||
@MainActor var parsedItemsByFeedProviderName: String { get }
|
||||
@MainActor var parsedItemsKeyedByFeedID: [String: Set<ParsedItem>] { get }
|
||||
}
|
||||
|
||||
/// Group articles by their feeds.
|
||||
public final class FeedlyOrganiseParsedItemsByFeedOperation: FeedlyOperation, FeedlyParsedItemsByFeedProviding {
|
||||
|
||||
private let parsedItemProvider: FeedlyParsedItemProviding
|
||||
private let log: OSLog
|
||||
|
||||
public var parsedItemsByFeedProviderName: String {
|
||||
return name ?? String(describing: Self.self)
|
||||
}
|
||||
|
||||
public 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>]()
|
||||
|
||||
public init(parsedItemProvider: FeedlyParsedItemProviding, log: OSLog) {
|
||||
|
||||
self.parsedItemProvider = parsedItemProvider
|
||||
self.log = log
|
||||
}
|
||||
|
||||
public override func run() {
|
||||
defer {
|
||||
didFinish()
|
||||
}
|
||||
|
||||
let items = parsedItemProvider.parsedEntries
|
||||
var dict = [String: Set<ParsedItem>](minimumCapacity: items.count)
|
||||
|
||||
for item in items {
|
||||
let key = item.feedURL
|
||||
let value: Set<ParsedItem> = {
|
||||
if var items = dict[key] {
|
||||
items.insert(item)
|
||||
return items
|
||||
} else {
|
||||
return [item]
|
||||
}
|
||||
}()
|
||||
dict[key] = value
|
||||
}
|
||||
|
||||
os_log(.debug, log: log, "Grouped %i items by %i feeds for %@", items.count, dict.count, parsedItemProvider.parsedItemProviderName)
|
||||
|
||||
itemsKeyedByFeedID = dict
|
||||
}
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
//
|
||||
// FeedlySearchOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 1/12/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol FeedlySearchService: AnyObject {
|
||||
|
||||
@MainActor func getFeeds(for query: String, count: Int, localeIdentifier: String) async throws -> FeedlyFeedsSearchResponse
|
||||
}
|
||||
|
||||
public protocol FeedlySearchOperationDelegate: AnyObject {
|
||||
|
||||
@MainActor func feedlySearchOperation(_ operation: FeedlySearchOperation, didGet response: FeedlyFeedsSearchResponse)
|
||||
}
|
||||
|
||||
/// Find one and only one feed for a given query (usually, a URL).
|
||||
/// What happens when a feed is found for the URL is delegated to the `searchDelegate`.
|
||||
public final class FeedlySearchOperation: FeedlyOperation {
|
||||
|
||||
let query: String
|
||||
let locale: Locale
|
||||
let searchService: FeedlySearchService
|
||||
public weak var searchDelegate: FeedlySearchOperationDelegate?
|
||||
|
||||
public init(query: String, locale: Locale = .current, service: FeedlySearchService) {
|
||||
self.query = query
|
||||
self.locale = locale
|
||||
self.searchService = service
|
||||
}
|
||||
|
||||
public override func run() {
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
do {
|
||||
let searchResponse = try await searchService.getFeeds(for: query, count: 1, localeIdentifier: locale.identifier)
|
||||
self.searchDelegate?.feedlySearchOperation(self, didGet: searchResponse)
|
||||
self.didFinish()
|
||||
|
||||
} catch {
|
||||
self.didFinish(with: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,76 +0,0 @@
|
|||
//
|
||||
// FeedlySendArticleStatusesOperation.swift
|
||||
// Account
|
||||
//
|
||||
// Created by Kiel Gillard on 14/10/19.
|
||||
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Articles
|
||||
import SyncDatabase
|
||||
import os.log
|
||||
|
||||
/// Take changes to statuses of articles locally and apply them to the corresponding the articles remotely.
|
||||
public final class FeedlySendArticleStatusesOperation: FeedlyOperation {
|
||||
|
||||
private let database: SyncDatabase
|
||||
private let log: OSLog
|
||||
private let service: FeedlyMarkArticlesService
|
||||
|
||||
public init(database: SyncDatabase, service: FeedlyMarkArticlesService, log: OSLog) {
|
||||
self.database = database
|
||||
self.service = service
|
||||
self.log = log
|
||||
}
|
||||
|
||||
public override func run() {
|
||||
os_log(.debug, log: log, "Sending article statuses...")
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
do {
|
||||
let syncStatuses = (try await self.database.selectForProcessing()) ?? Set<SyncStatus>()
|
||||
self.processStatuses(Array(syncStatuses))
|
||||
} catch {
|
||||
self.didFinish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension FeedlySendArticleStatusesOperation {
|
||||
|
||||
func processStatuses(_ pending: [SyncStatus]) {
|
||||
|
||||
let statuses: [(status: SyncStatus.Key, flag: Bool, action: FeedlyMarkAction)] = [
|
||||
(.read, false, .unread),
|
||||
(.read, true, .read),
|
||||
(.starred, true, .saved),
|
||||
(.starred, false, .unsaved),
|
||||
]
|
||||
|
||||
Task { @MainActor in
|
||||
|
||||
for pairing in statuses {
|
||||
|
||||
let articleIDs = pending.filter { $0.key == pairing.status && $0.flag == pairing.flag }
|
||||
guard !articleIDs.isEmpty else {
|
||||
continue
|
||||
}
|
||||
|
||||
let ids = Set(articleIDs.map { $0.articleID })
|
||||
|
||||
do {
|
||||
try await service.mark(ids, as: pairing.action)
|
||||
try? await database.deleteSelectedForProcessing(Array(ids))
|
||||
} catch {
|
||||
try? await database.resetSelectedForProcessing(Array(ids))
|
||||
}
|
||||
}
|
||||
|
||||
os_log(.debug, log: self.log, "Done sending article statuses.")
|
||||
self.didFinish()
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue