NetNewsWire/Account/Sources/Account/Feedly/FeedlyAccountDelegate.swift

991 lines
30 KiB
Swift

//
// FeedlyAccountDelegate.swift
// Account
//
// Created by Kiel Gillard on 3/9/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Articles
import Parser
import Web
import SyncDatabase
import os.log
import Secrets
import Core
import CommonErrors
import Feedly
final class FeedlyAccountDelegate: AccountDelegate {
/// Feedly has a sandbox API and a production API.
/// This property is referred to when clients need to know which environment it should be pointing to.
/// The value of this proptery must match any `OAuthAuthorizationClient` used.
/// Currently this is always returning the cloud API, but we are leaving it stubbed out for now.
static var environment: FeedlyAPICaller.API {
return .cloud
}
// TODO: Kiel, if you decide not to support OPML import you will have to disallow it in the behaviors
// See https://developer.feedly.com/v3/opml/
var behaviors: AccountBehaviors = [.disallowFeedInRootFolder, .disallowMarkAsUnreadAfterPeriod(31)]
let isOPMLImportSupported = false
var isOPMLImportInProgress = false
var server: String? {
return caller.server
}
var credentials: Credentials? {
didSet {
#if DEBUG
// https://developer.feedly.com/v3/developer/
if let devToken = ProcessInfo.processInfo.environment["FEEDLY_DEV_ACCESS_TOKEN"], !devToken.isEmpty {
caller.credentials = Credentials(type: .oauthAccessToken, username: "Developer", secret: devToken)
return
}
#endif
caller.credentials = credentials
}
}
let oauthAuthorizationClient: OAuthAuthorizationClient
var accountMetadata: AccountMetadata?
var refreshProgress = DownloadProgress(numberOfTasks: 0)
private var userID: String? {
credentials?.username
}
/// Set on `accountDidInitialize` for the purposes of refreshing OAuth tokens when they expire.
/// See the implementation for `FeedlyAPICallerDelegate`.
private weak var account: Account?
private var refreshing = false
internal let caller: FeedlyAPICaller
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Feedly")
private let syncDatabase: SyncDatabase
private weak var currentSyncAllOperation: MainThreadOperation?
private let operationQueue = MainThreadOperationQueue()
init(dataFolder: String, transport: Transport?, api: FeedlyAPICaller.API, secretsProvider: SecretsProvider) {
// Many operations have their own operation queues, such as the sync all operation.
// Making this a serial queue at this higher level of abstraction means we can ensure,
// for example, a `FeedlyRefreshAccessTokenOperation` occurs before a `FeedlySyncAllOperation`,
// improving our ability to debug, reason about and predict the behaviour of the code.
if let transport = transport {
self.caller = FeedlyAPICaller(transport: transport, api: api, secretsProvider: secretsProvider)
} else {
let sessionConfiguration = URLSessionConfiguration.default
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
sessionConfiguration.timeoutIntervalForRequest = 60.0
sessionConfiguration.httpShouldSetCookies = false
sessionConfiguration.httpCookieAcceptPolicy = .never
sessionConfiguration.httpMaximumConnectionsPerHost = 1
sessionConfiguration.httpCookieStorage = nil
sessionConfiguration.urlCache = nil
sessionConfiguration.httpAdditionalHeaders = UserAgent.headers
let session = URLSession(configuration: sessionConfiguration)
self.caller = FeedlyAPICaller(transport: session, api: api, secretsProvider: secretsProvider)
}
let databasePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3")
self.syncDatabase = SyncDatabase(databasePath: databasePath)
self.oauthAuthorizationClient = api.oauthAuthorizationClient(secretsProvider: secretsProvider)
self.caller.delegate = self
}
// MARK: Account API
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any]) async {
}
func refreshAll(for account: Account) async throws {
if refreshing {
os_log(.debug, log: log, "Ignoring refreshAll: Feedly sync already in progress.")
return
}
// TODO: update/clear refreshProgress
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()
// Get all the Collections the user has.
let collections = try await fetchCollections()
// Ensure a folder exists for each Collection, removing Folders without a corresponding Collection.
guard let feedsAndFolders = mirrorCollectionsAsFolders(collections: collections) else {
return
}
// Ensure feeds are created and grouped by their folders.
createFeedsForCollectionFolders(feedsAndFolders: feedsAndFolders)
try await fetchAndProcessAllArticleIDs()
// Get each page of unread article ids in the global.all stream for the last 31 days (Feedly API default).
try await fetchAndProcessUnreadArticleIDs()
// Get each page of the article ids which have been updated since the last successful fetch start date.
let updatedArticleIDs = try await fetchUpdatedArticleIDs()
// Get each page of the article ids for starred articles.
try await fetchAndProcessStarredArticleIDs()
let missingArticleIDs = try await account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate()
try await downloadArticles(missingArticleIDs: missingArticleIDs, updatedArticleIDs: updatedArticleIDs)
}
func syncArticleStatus(for account: Account) async throws {
try await sendArticleStatus(for: account)
try await refreshArticleStatus(for: account)
}
public func sendArticleStatus(for account: Account) async throws {
try await sendArticleStatuses()
}
/// Attempts to ensure local articles have the same status as they do remotely.
/// So if the user is using another client roughly simultaneously with this app,
/// this app does its part to ensure the articles have a consistent status between both.
///
/// - Parameter account: The account whose articles have a remote status.
func refreshArticleStatus(for account: Account) async throws {
try await fetchAndProcessUnreadArticleIDs()
try await fetchAndProcessStarredArticleIDs()
}
func importOPML(for account: Account, opmlFile: URL) async throws {
let data = try Data(contentsOf: opmlFile)
os_log(.debug, log: log, "Begin importing OPML…")
isOPMLImportInProgress = true
refreshProgress.addTask()
defer {
isOPMLImportInProgress = false
refreshProgress.completeTask()
}
do {
try await caller.importOPML(data)
os_log(.debug, log: self.log, "Import OPML done.")
} catch {
os_log(.debug, log: self.log, "Import OPML failed.")
let wrappedError = AccountError.wrappedError(error: error, account: account)
throw wrappedError
}
}
func createFolder(for account: Account, name: String) async throws -> Folder {
refreshProgress.addTask()
defer {
refreshProgress.completeTask()
}
let collection = try await caller.createCollection(named: name)
if let folder = account.ensureFolder(with: collection.label) {
folder.externalID = collection.id
return folder
} else {
// Is the name empty? Or one of the global resource names?
throw FeedlyAccountDelegateError.unableToAddFolder(name)
}
}
func renameFolder(for account: Account, with folder: Folder, to name: String) async throws {
guard let id = folder.externalID else {
throw FeedlyAccountDelegateError.unableToRenameFolder(folder.nameForDisplay, name)
}
let nameBefore = folder.name
do {
let collection = try await caller.renameCollection(with: id, to: name)
folder.name = collection.label
} catch {
folder.name = nameBefore
throw error
}
}
func removeFolder(for account: Account, with folder: Folder) async throws {
guard let id = folder.externalID else {
throw FeedlyAccountDelegateError.unableToRemoveFolder(folder.nameForDisplay)
}
refreshProgress.addTask()
defer { refreshProgress.completeTask() }
try await caller.deleteCollection(with: id)
account.removeFolder(folder: folder)
}
@discardableResult
func createFeed(for account: Account, url: String, name: String?, container: Container, validateFeed: Bool) async throws -> Feed {
// TODO: make this work
throw FeedlyAccountDelegateError.notLoggedIn
}
func renameFeed(for account: Account, with feed: Feed, to name: String) async throws {
let folderCollectionIDs = account.folders?.filter { $0.has(feed) }.compactMap { $0.externalID }
guard let collectionIDs = folderCollectionIDs, let collectionID = collectionIDs.first else {
throw FeedlyAccountDelegateError.unableToRenameFeed(feed.nameForDisplay, name)
}
let feedID = FeedlyFeedResourceID(id: feed.feedID)
let editedNameBefore = feed.editedName
// Optimistically set the name
feed.editedName = name
do {
// Adding an existing feed updates it.
// Updating feed name in one folder/collection updates it for all folders/collections.
try await caller.addFeed(with: feedID, title: name, toCollectionWith: collectionID)
} catch {
feed.editedName = editedNameBefore
}
}
func addFeed(for account: Account, with feed: Feed, to container: any Container) async throws {
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 {
guard let folder = container as? Folder, let collectionID = folder.externalID else {
throw FeedlyAccountDelegateError.unableToRemoveFeed(feed.nameForDisplay)
}
try await caller.removeFeed(feed.feedID, fromCollectionWith: collectionID)
folder.removeFeed(feed)
}
func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container) async throws {
guard let sourceFolder = from as? Folder, let destinationFolder = to as? Folder else {
throw FeedlyAccountDelegateError.addFeedChooseFolder
}
// Optimistically move the feed, undoing as appropriate to the failure
sourceFolder.removeFeed(feed)
destinationFolder.addFeed(feed)
do {
try await addFeed(for: account, with: feed, to: destinationFolder)
} catch {
destinationFolder.removeFeed(feed)
throw FeedlyAccountDelegateError.unableToMoveFeedBetweenFolders(feed.nameForDisplay, sourceFolder.nameForDisplay, destinationFolder.nameForDisplay)
}
// Now that we have added the feed, remove it from the source folder
do {
try await removeFeed(for: account, with: feed, from: sourceFolder)
} catch {
sourceFolder.addFeed(feed)
throw FeedlyAccountDelegateError.unableToMoveFeedBetweenFolders(feed.nameForDisplay, sourceFolder.nameForDisplay, destinationFolder.nameForDisplay)
}
}
func restoreFeed(for account: Account, feed: Feed, container: any Container) async throws {
if let existingFeed = account.existingFeed(withURL: feed.url) {
try await account.addFeed(existingFeed, to: container)
} else {
try await createFeed(for: account, url: feed.url, name: feed.editedName, container: container, validateFeed: true)
}
}
func restoreFolder(for account: Account, folder: Folder) async throws {
for feed in folder.topLevelFeeds {
folder.topLevelFeeds.remove(feed)
do {
try await restoreFeed(for: account, feed: feed, container: folder)
} catch {
os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription)
}
}
account.addFolder(folder)
}
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) async throws {
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 syncDatabase.insertStatuses(syncStatuses)
if let count = try? await syncDatabase.selectPendingCount(), count > 100 {
try? await sendArticleStatus(for: account)
}
}
func accountDidInitialize(_ account: Account) {
self.account = account
credentials = try? account.retrieveCredentials(type: .oauthAccessToken)
}
func accountWillBeDeleted(_ account: Account) {
Task {
try? await logout(account: account)
}
}
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, secretsProvider: SecretsProvider) async throws -> Credentials? {
assertionFailure("An `account` instance should refresh the access token first instead.")
return credentials
}
func fetchUpdatedArticleIDs() async throws -> Set<String>? {
guard let userID = credentials?.username else {
return nil
}
guard let date = accountMetadata?.lastArticleFetchStartTime else {
return nil // Everything is new; nothing is updated.
}
var articleIDs = Set<String>()
let resource = FeedlyCategoryResourceID.Global.all(for: userID)
func fetchStreamIDs(_ continuation: String?) async throws {
let streamIDs = try await caller.getStreamIDs(for: resource, continuation: continuation, newerThan: date, unreadOnly: nil)
articleIDs.formUnion(streamIDs.ids)
guard let continuation = streamIDs.continuation else {
os_log(.debug, log: log, "%{public}i articles updated since last successful sync start date.", articleIDs.count)
return
}
try await fetchStreamIDs(continuation)
}
return articleIDs
}
func updateAccountFeeds(parsedItems: Set<ParsedItem>) async throws {
let feedIDsAndItems = parsedItemsKeyedByFeedURL(parsedItems)
try await updateAccountFeedsWithItems(feedIDsAndItems: feedIDsAndItems)
}
func updateAccountFeedsWithItems(feedIDsAndItems: [String: Set<ParsedItem>]) async throws {
guard let account else { return }
try await account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true)
os_log(.debug, log: self.log, "Updated %i feeds", feedIDsAndItems.count)
}
func logout(account: Account) async throws {
do {
os_log("Requesting logout of Feedly account.")
try await caller.logout()
os_log("Logged out of Feedly account.")
try account.removeCredentials(type: .oauthAccessToken)
try account.removeCredentials(type: .oauthRefreshToken)
} catch {
os_log("Logout failed because %{public}@.", error as NSError)
throw error
}
}
@discardableResult
func addFeedToCollection(feedResource: FeedlyFeedResourceID, feedName: String? = nil, collectionID: String, folder: Folder) async throws -> [([FeedlyFeed], Folder)] {
let feedlyFeeds = try await caller.addFeed(with: feedResource, title: feedName, toCollectionWith: collectionID)
let feedsWithCreatedFeedID = feedlyFeeds.filter { $0.id == feedResource.id }
if feedsWithCreatedFeedID.isEmpty {
throw AccountError.createErrorNotFound
}
let feedsAndFolders = [(feedlyFeeds, folder)]
return feedsAndFolders
}
func parsedItemsKeyedByFeedURL(_ parsedItems: Set<ParsedItem>) -> [String: Set<ParsedItem>] {
var d = [String: Set<ParsedItem>]()
for parsedItem in parsedItems {
let key = parsedItem.feedURL
let value: Set<ParsedItem> = {
if var items = d[key] {
items.insert(parsedItem)
return items
} else {
return [parsedItem]
}
}()
d[key] = value
}
return d
}
func downloadArticles(missingArticleIDs: Set<String>?, updatedArticleIDs: Set<String>?) async throws {
let allArticleIDs: Set<String> = {
var articleIDs = Set<String>()
if let missingArticleIDs {
articleIDs.formUnion(missingArticleIDs)
}
if let updatedArticleIDs {
articleIDs.formUnion(updatedArticleIDs)
}
return articleIDs
}()
if allArticleIDs.isEmpty {
return
}
os_log(.debug, log: log, "Requesting %{public}i articles.", allArticleIDs.count)
let feedlyAPILimitBatchSize = 1000
for articleIDs in Array(allArticleIDs).chunked(into: feedlyAPILimitBatchSize) {
let parsedItems = try await fetchParsedItems(articleIDs: Set(articleIDs))
try await updateAccountFeeds(parsedItems: parsedItems)
}
}
func fetchParsedItems(articleIDs: Set<String>) async throws -> Set<ParsedItem> {
do {
let entries = try await caller.getEntries(for: articleIDs)
return parsedItems(with: Set(entries))
} catch {
os_log(.debug, log: self.log, "Unable to get entries: %{public}@.", error as NSError)
throw error
}
}
func parsedItems(with entries: Set<FeedlyEntry>) -> Set<ParsedItem> {
// TODO: convert directly from FeedlyEntry to ParsedItem without having to use FeedlyEntryParser.
let parsedItems = Set(entries.compactMap {
FeedlyEntryParser(entry: $0).parsedItemRepresentation
})
return parsedItems
}
func fetchCollections() async throws -> Set<FeedlyCollection> {
os_log(.debug, log: log, "Requesting collections.")
do {
let collections = try await caller.getCollections()
os_log(.debug, log: self.log, "Received collections: %{public}@", collections.map { $0.id })
return collections
} catch {
os_log(.debug, log: self.log, "Unable to request collections: %{public}@.", error as NSError)
throw error
}
}
func sendArticleStatuses() async throws {
guard let syncStatuses = try await syncDatabase.selectForProcessing() else {
return
}
let statusActionMap: [(status: SyncStatus.Key, flag: Bool, action: FeedlyMarkAction)] = [
(.read, false, .unread),
(.read, true, .read),
(.starred, true, .saved),
(.starred, false, .unsaved)
]
for statusAction in statusActionMap {
let statuses = syncStatuses.filter {
$0.key == statusAction.status && $0.flag == statusAction.flag
}
guard !statuses.isEmpty else {
continue
}
let articleIDs = Set(statuses.map { $0.articleID })
do {
try await caller.mark(articleIDs, as: statusAction.action)
try? await syncDatabase.deleteSelectedForProcessing(Array(articleIDs))
} catch {
try? await syncDatabase.resetSelectedForProcessing(Array(articleIDs))
throw error
}
}
os_log(.debug, log: self.log, "Done sending article statuses.")
}
func searchForFeed(url: String) async throws -> FeedlyFeedsSearchResponse {
try await caller.getFeeds(for: url, count: 1, localeIdentifier: Locale.current.identifier)
}
func fetchStreamContents(resourceID: FeedlyResourceID, continuation: String? = nil, newerThan: Date?, unreadOnly: Bool? = nil) async throws -> Set<ParsedItem> {
do {
let stream = try await caller.getStreamContents(for: resourceID, continuation: continuation, newerThan: newerThan, unreadOnly: unreadOnly)
return parsedItems(with: Set(stream.items))
} catch {
os_log(.debug, log: self.log, "Unable to get stream contents: %{public}@.", error as NSError)
throw error
}
}
func fetchRemoteArticleIDs(resource: FeedlyResourceID, unreadOnly: Bool? = nil) async throws -> Set<String> {
var remoteArticleIDs = Set<String>()
func fetchIDs(_ continuation: String? = nil) async throws {
let streamIDs = try await caller.getStreamIDs(for: resource, continuation: continuation, newerThan: nil, unreadOnly: unreadOnly)
remoteArticleIDs.formUnion(streamIDs.ids)
guard let continuation = streamIDs.continuation else { // finished fetching article IDs?
return
}
try await fetchIDs(continuation)
}
try await fetchIDs()
return remoteArticleIDs
}
func fetchRemoteUnreadArticleIDs() async throws -> Set<String> {
guard let userID else { return Set<String>() }
let resource = FeedlyCategoryResourceID.Global.all(for: userID)
return try await fetchRemoteArticleIDs(resource: resource, unreadOnly: true)
}
func fetchRemoteStarredArticleIDs() async throws -> Set<String> {
guard let userID else { return Set<String>() }
let resource = FeedlyTagResourceID.Global.saved(for: userID)
return try await fetchRemoteArticleIDs(resource: resource)
}
func processStarredArticleIDs(remoteArticleIDs: Set<String>) async throws {
guard let account else { return }
var remoteArticleIDs = remoteArticleIDs
func removeEntryIDsWithPendingStatus() async throws {
if let pendingArticleIDs = try await syncDatabase.selectPendingStarredStatusArticleIDs() {
remoteArticleIDs.subtract(pendingArticleIDs)
}
}
func process() async throws {
let localStarredArticleIDs = (try await account.fetchStarredArticleIDs()) ?? Set<String>()
var markAsStarredError: Error?
var markAsUnstarredError: Error?
let remoteStarredArticleIDs = remoteArticleIDs
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
}
}
try await removeEntryIDsWithPendingStatus()
try await process()
}
func fetchAndProcessStarredArticleIDs() async throws {
let remoteArticleIDs = try await fetchRemoteStarredArticleIDs()
try await processStarredArticleIDs(remoteArticleIDs: remoteArticleIDs)
}
func processUnreadArticleIDs(remoteArticleIDs: Set<String>) async throws {
guard let account else { return }
var remoteArticleIDs = remoteArticleIDs
func removeEntryIDsWithPendingStatus() async throws {
if let pendingArticleIDs = try await syncDatabase.selectPendingReadStatusArticleIDs() {
remoteArticleIDs.subtract(pendingArticleIDs)
}
}
func process() async throws {
let localUnreadArticleIDs = try await account.fetchUnreadArticleIDs() ?? Set<String>()
var markAsUnreadError: Error?
var markAsReadError: Error?
let remoteUnreadArticleIDs = remoteArticleIDs
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
}
}
try await removeEntryIDsWithPendingStatus()
try await process()
}
func fetchAndProcessUnreadArticleIDs() async throws {
let remoteArticleIDs = try await fetchRemoteUnreadArticleIDs()
try await processUnreadArticleIDs(remoteArticleIDs: remoteArticleIDs)
}
func fetchAllArticleIDs() async throws -> Set<String> {
guard let userID else { return Set<String>() }
var allArticleIDs = Set<String>()
let resource = FeedlyCategoryResourceID.Global.all(for: userID)
func fetchStreamIDs(_ continuation: String?) async throws {
let streamIDs = try await caller.getStreamIDs(for: resource, continuation: continuation, newerThan: nil, unreadOnly: nil)
allArticleIDs.formUnion(streamIDs.ids)
guard let continuation = streamIDs.continuation else {
os_log(.debug, log: self.log, "Reached end of stream for %@", resource.id)
return
}
try await fetchStreamIDs(continuation)
}
return allArticleIDs
}
func fetchAndProcessAllArticleIDs() async throws {
guard let account else { return }
let allArticleIDs = try await fetchAllArticleIDs()
try await account.createStatusesIfNeeded(articleIDs: allArticleIDs)
}
func syncStreamContents(feedResourceID: FeedlyFeedResourceID) async throws {
let parsedItems = try await fetchStreamContents(resourceID: feedResourceID, newerThan: nil)
try await updateAccountFeeds(parsedItems: parsedItems)
}
@MainActor struct FeedlyFeedContainerValidator {
var container: Container
func getValidContainer() throws -> (Folder, String) {
guard let folder = container as? Folder else {
throw FeedlyAccountDelegateError.addFeedChooseFolder
}
guard let collectionID = folder.externalID else {
throw FeedlyAccountDelegateError.addFeedInvalidFolder(folder.nameForDisplay)
}
return (folder, collectionID)
}
}
func addNewFeed(url: String, feedName: String?, container: Container) async throws {
let validator = FeedlyFeedContainerValidator(container: container)
let (folder, collectionID) = try validator.getValidContainer()
let searchResponse = try await searchForFeed(url: url)
guard let firstFeed = searchResponse.results.first else {
throw AccountError.createErrorNotFound
}
let feedResourceID = FeedlyFeedResourceID(id: firstFeed.feedID)
try await addFeedToCollection(feedResource: feedResourceID, feedName: feedName, collectionID: collectionID, folder: folder)
// TODO: FeedlyCreateFeedsForCollectionFoldersOperation replacement
// let createFeeds = TODO
//try await fetchAndProcessUnreadArticleIDs() // TODO
try await syncStreamContents(feedResourceID: feedResourceID)
}
func addExistingFeed(resourceID: FeedlyFeedResourceID, container: Container, customFeedName: String? = nil) async throws {
let validator = FeedlyFeedContainerValidator(container: container)
let (folder, collectionID) = try validator.getValidContainer()
try await addFeedToCollection(feedResource: resourceID, feedName: customFeedName, collectionID: collectionID, folder: folder)
}
func mirrorCollectionsAsFolders(collections: Set<FeedlyCollection>) -> [([FeedlyFeed], Folder)]? {
guard let account else { return nil }
let localFolders = account.folders ?? Set()
let feedsAndFolders: [([FeedlyFeed], Folder)] = 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 })
}
return feedsAndFolders
}
func createFeedsForCollectionFolders(feedsAndFolders: [([FeedlyFeed], Folder)]) {
guard let account else { return }
let pairs = 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)
}
}
// MARK: Suspend and Resume (for iOS)
/// Suspend all network activity
func suspendNetwork() {
MainActor.assumeIsolated {
caller.suspend()
operationQueue.cancelAllOperations()
}
}
/// Suspend the SQLite databases
func suspendDatabase() {
Task {
await syncDatabase.suspend()
}
}
/// Make sure no SQLite databases are open and we are ready to issue network requests.
func resume() {
Task {
await syncDatabase.resume()
caller.resume()
}
}
}
extension FeedlyAccountDelegate: FeedlyAPICallerDelegate {
@MainActor func reauthorizeFeedlyAPICaller(_ caller: FeedlyAPICaller) async -> Bool {
guard let account else {
return false
}
do {
try await refreshAccessToken(account: account)
return true
} catch {
return false
}
}
private func refreshAccessToken(account: Account) async throws {
guard let credentials = try account.retrieveCredentials(type: .oauthRefreshToken) else {
os_log(.debug, log: log, "Could not find a refresh token in the keychain. Check the refresh token is added to the Keychain, remove the account and add it again.")
throw TransportError.httpError(status: 403)
}
os_log(.debug, log: log, "Refreshing access token.")
let grant = try await caller.refreshAccessToken(with: credentials.secret, client: oauthAuthorizationClient)
os_log(.debug, log: log, "Storing refresh token.")
if let refreshToken = grant.refreshToken {
try account.storeCredentials(refreshToken)
}
os_log(.debug, log: log, "Storing access token.")
try account.storeCredentials(grant.accessToken)
}
}