Delete Feedly operations.

This commit is contained in:
Brent Simmons 2024-04-29 22:02:38 -07:00
parent e58072e281
commit 8c8cfa6377
25 changed files with 38 additions and 2161 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 dont do inheritance but in this case
/// its 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()
}
}

View File

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

View File

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

View File

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