991 lines
30 KiB
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)
|
|
}
|
|
}
|