NetNewsWire/Account/Sources/Account/Feedbin/FeedbinAccountDelegate.swift

1020 lines
31 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// FeedbinAccountDelegate.swift
// Account
//
// Created by Maurice Parker on 5/2/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import Articles
import Database
import Parser
import Web
import SyncDatabase
import os.log
import Secrets
import Core
import Feedbin
import CommonErrors
import FeedFinder
public enum FeedbinAccountDelegateError: String, Error {
case invalidParameter = "There was an invalid parameter passed."
case unknown = "An unknown error occurred."
}
@MainActor final class FeedbinAccountDelegate: AccountDelegate {
private let database: SyncDatabase
private let caller: FeedbinAPICaller
private var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Feedbin")
let behaviors: AccountBehaviors = [.disallowFeedCopyInRootFolder]
let server: String? = "api.feedbin.com"
var isOPMLImportInProgress = false
var credentials: Credentials? {
didSet {
caller.credentials = credentials
}
}
weak var accountMetadata: AccountMetadata?
var refreshProgress = DownloadProgress(numberOfTasks: 0)
init(dataFolder: String, transport: Transport?) {
let databasePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3")
database = SyncDatabase(databasePath: databasePath)
if transport != nil {
caller = FeedbinAPICaller(transport: transport!)
} 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
caller = FeedbinAPICaller(transport: URLSession(configuration: sessionConfiguration))
}
caller.delegate = self
}
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any]) async {
}
func refreshAll(for account: Account) async throws {
refreshProgress.addToNumberOfTasksAndRemaining(5)
do {
try await refreshAccount(account)
} catch {
refreshProgress.clear()
let wrappedError = AccountError.wrappedError(error: error, account: account)
throw wrappedError
}
}
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 {
os_log(.debug, log: log, "Sending article statuses...")
let syncStatuses = (try await self.database.selectForProcessing()) ?? Set<SyncStatus>()
let createUnreadStatuses = Array(syncStatuses.filter { $0.key == SyncStatus.Key.read && $0.flag == false })
let deleteUnreadStatuses = Array(syncStatuses.filter { $0.key == SyncStatus.Key.read && $0.flag == true })
let createStarredStatuses = Array(syncStatuses.filter { $0.key == SyncStatus.Key.starred && $0.flag == true })
let deleteStarredStatuses = Array(syncStatuses.filter { $0.key == SyncStatus.Key.starred && $0.flag == false })
try await sendArticleStatuses(createUnreadStatuses, apiCall: caller.createUnreadEntries)
try await sendArticleStatuses(deleteUnreadStatuses, apiCall: caller.deleteUnreadEntries)
try await sendArticleStatuses(createStarredStatuses, apiCall: caller.createStarredEntries)
try await sendArticleStatuses(deleteStarredStatuses, apiCall: caller.deleteStarredEntries)
os_log(.debug, log: self.log, "Done sending article statuses.")
}
func refreshArticleStatus(for account: Account) async throws {
os_log(.debug, log: log, "Refreshing article statuses...")
var readStateSyncError: Error? = nil
var starredStateSyncError: Error? = nil
do {
let articleIDs = try await caller.retrieveUnreadEntries()
await syncArticleReadState(account: account, articleIDs: articleIDs)
} catch {
readStateSyncError = error
os_log(.info, log: self.log, "Retrieving unread entries failed: %@.", error.localizedDescription)
}
do {
let articleIDs = try await caller.retrieveStarredEntries()
await syncArticleStarredState(account: account, articleIDs: articleIDs)
} catch {
starredStateSyncError = error
os_log(.info, log: self.log, "Retrieving starred entries failed: %@.", error.localizedDescription)
}
os_log(.debug, log: self.log, "Done refreshing article statuses.")
if let error = readStateSyncError ?? starredStateSyncError {
throw error
}
}
func importOPML(for account: Account, opmlFile: URL) async throws {
let opmlData = try Data(contentsOf: opmlFile)
if opmlData.isEmpty {
return
}
os_log(.debug, log: log, "Begin importing OPML...")
isOPMLImportInProgress = true
refreshProgress.addToNumberOfTasksAndRemaining(1)
do {
let importResult = try await caller.importOPML(opmlData: opmlData)
if importResult.complete {
os_log(.debug, log: self.log, "Import OPML done.")
refreshProgress.completeTask()
isOPMLImportInProgress = false
} else {
try await checkImportResult(opmlImportResultID: importResult.importResultID)
refreshProgress.completeTask()
isOPMLImportInProgress = false
}
} catch {
os_log(.debug, log: self.log, "Import OPML failed.")
refreshProgress.completeTask()
isOPMLImportInProgress = false
let wrappedError = AccountError.wrappedError(error: error, account: account)
throw wrappedError
}
}
func createFolder(for account: Account, name: String) async throws -> Folder {
guard let folder = account.ensureFolder(with: name) else {
throw FeedbinAccountDelegateError.invalidParameter
}
return folder
}
func renameFolder(for account: Account, with folder: Folder, to name: String) async throws {
guard folder.hasAtLeastOneFeed() else {
folder.name = name
return
}
refreshProgress.addToNumberOfTasksAndRemaining(1)
defer {
self.refreshProgress.completeTask()
}
do {
try await caller.renameTag(oldName: folder.name ?? "", newName: name)
renameFolderRelationship(for: account, fromName: folder.name ?? "", toName: name)
folder.name = name
} catch {
let wrappedError = AccountError.wrappedError(error: error, account: account)
throw wrappedError
}
}
func removeFolder(for account: Account, with folder: Folder) async throws {
// Feedbin uses tags and if at least one feed isn't tagged, then the folder doesn't exist on their system
guard folder.hasAtLeastOneFeed() else {
account.removeFolder(folder: folder)
return
}
let feeds = folder.topLevelFeeds
let numberOfFeeds = feeds.count
refreshProgress.addToNumberOfTasksAndRemaining(numberOfFeeds)
for feed in feeds {
if feed.folderRelationship?.count ?? 0 > 1 {
if let feedTaggingID = feed.folderRelationship?[folder.name ?? ""] {
do {
try await caller.deleteTagging(taggingID: feedTaggingID)
clearFolderRelationship(for: feed, withFolderName: folder.name ?? "")
} catch {
os_log(.error, log: self.log, "Remove feed error: %@.", error.localizedDescription)
throw error
}
}
} else {
if let subscriptionID = feed.externalID {
do {
try await caller.deleteSubscription(subscriptionID: subscriptionID)
account.clearFeedMetadata(feed)
} catch {
os_log(.error, log: self.log, "Remove feed error: %@.", error.localizedDescription)
throw error
}
}
}
refreshProgress.completeTask()
}
account.removeFolder(folder: folder)
}
@discardableResult
func createFeed(for account: Account, url: String, name: String?, container: Container, validateFeed: Bool) async throws -> Feed {
refreshProgress.addToNumberOfTasksAndRemaining(1)
defer { refreshProgress.completeTask() }
do {
let subResult = try await caller.createSubscription(url: url)
switch subResult {
case .created(let subscription):
return try await createFeed(account: account, subscription: subscription, name: name, container: container)
case .multipleChoice(let choices):
return try await decideBestFeedChoice(account: account, url: url, name: name, container: container, choices: choices)
case .alreadySubscribed:
throw AccountError.createErrorAlreadySubscribed
case .notFound:
throw AccountError.createErrorNotFound
}
} catch {
let wrappedError = AccountError.wrappedError(error: error, account: account)
throw wrappedError
}
}
func renameFeed(for account: Account, with feed: Feed, to name: String) async throws {
// This error should never happen
guard let subscriptionID = feed.externalID else {
throw FeedbinAccountDelegateError.invalidParameter
}
refreshProgress.addToNumberOfTasksAndRemaining(1)
defer { refreshProgress.completeTask() }
do {
try await caller.renameSubscription(subscriptionID: subscriptionID, newName: name)
feed.editedName = name
} catch {
let wrappedError = AccountError.wrappedError(error: error, account: account)
throw wrappedError
}
}
func removeFeed(for account: Account, with feed: Feed, from container: any Container) async throws {
if feed.folderRelationship?.count ?? 0 > 1 {
try await deleteTagging(for: account, with: feed, from: container)
} else {
try await deleteSubscription(for: account, with: feed, from: container)
}
}
func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container) async throws {
if from is Account {
try await addFeed(for: account, with: feed, to: to)
} else {
try await deleteTagging(for: account, with: feed, from: from)
try await addFeed(for: account, with: feed, to: to)
}
}
func addFeed(for account: Account, with feed: Feed, to container: any Container) async throws {
if let folder = container as? Folder, let feedID = Int(feed.feedID) {
refreshProgress.addToNumberOfTasksAndRemaining(1)
defer { refreshProgress.completeTask() }
do {
let taggingID = try await caller.createTagging(feedID: feedID, name: folder.name ?? "")
saveFolderRelationship(for: feed, withFolderName: folder.name ?? "", id: String(taggingID))
account.removeFeed(feed)
folder.addFeed(feed)
} catch {
let wrappedError = AccountError.wrappedError(error: error, account: account)
throw wrappedError
}
} else if let account = container as? Account {
account.addFeedIfNotInAnyFolder(feed)
}
}
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)
throw error
}
}
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 database.insertStatuses(syncStatuses)
if let count = try? await database.selectPendingCount(), count > 100 {
try? await sendArticleStatus(for: account)
}
}
func accountDidInitialize(_ account: Account) {
credentials = try? account.retrieveCredentials(type: .basic)
}
func accountWillBeDeleted(_ account: Account) {
}
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, secretsProvider: SecretsProvider) async throws -> Credentials? {
let caller = FeedbinAPICaller(transport: transport)
caller.credentials = credentials
return try await caller.validateCredentials()
}
// MARK: Suspend and Resume (for iOS)
/// Suspend all network activity
func suspendNetwork() {
caller.suspend()
}
/// Suspend the SQLLite databases
func suspendDatabase() {
Task {
await database.suspend()
}
}
/// Make sure no SQLite databases are open and we are ready to issue network requests.
func resume() {
caller.resume()
Task {
await database.resume()
}
}
}
// MARK: Private
private extension FeedbinAccountDelegate {
func checkImportResult(opmlImportResultID: Int) async throws {
try await withCheckedThrowingContinuation { continuation in
self.checkImportResult(opmlImportResultID: opmlImportResultID) { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
private func delay(seconds: Double) async {
await withCheckedContinuation { continuation in
self.performBlockAfter(seconds: seconds) {
continuation.resume()
}
}
}
nonisolated private func performBlockAfter(seconds: Double, block: @escaping @Sendable @MainActor () -> ()) {
let delayTime = DispatchTime.now() + seconds
DispatchQueue.main.asyncAfter(deadline: delayTime) {
Task { @MainActor in
block()
}
}
}
func checkImportResult(opmlImportResultID: Int, completion: @escaping @Sendable (Result<Void, Error>) -> Void) {
Task { @MainActor in
var retry = 0
let maxRetries = 6 // a guess at a good number
@MainActor func checkResult() async {
if retry >= maxRetries {
return
}
retry = retry + 1
await delay(seconds: 15)
os_log(.debug, log: self.log, "Checking status of OPML import...")
do {
let importResult = try await self.caller.retrieveOPMLImportResult(importID: opmlImportResultID)
if let importResult, importResult.complete {
os_log(.debug, log: self.log, "Checking status of OPML import successfully completed.")
completion(.success(()))
} else {
await checkResult()
}
} catch {
os_log(.debug, log: self.log, "Import OPML check failed.")
completion(.failure(error))
}
}
await checkResult()
}
}
func refreshAccount(_ account: Account) async throws {
let tags = try await caller.retrieveTags()
refreshProgress.completeTask()
let subscriptions = try await caller.retrieveSubscriptions()
refreshProgress.completeTask()
forceExpireFolderFeedRelationship(account, tags)
let taggings = try await caller.retrieveTaggings()
BatchUpdate.shared.perform {
self.syncFolders(account, tags)
self.syncFeeds(account, subscriptions)
self.syncFeedFolderRelationship(account, taggings)
}
refreshProgress.completeTask()
}
func refreshArticlesAndStatuses(_ account: Account) async throws {
try await sendArticleStatus(for: account)
try await refreshArticleStatus(for: account)
try await refreshArticles(account)
try await refreshMissingArticles(account)
}
// This function can be deleted if Feedbin updates their taggings.json service to
// show a change when a tag is renamed.
func forceExpireFolderFeedRelationship(_ account: Account, _ tags: [FeedbinTag]?) {
guard let tags = tags else { return }
let folderNames: [String] = {
if let folders = account.folders {
return folders.map { $0.name ?? "" }
} else {
return [String]()
}
}()
// Feedbin has a tag that we don't have a folder for. We might not get a new
// taggings response for it if it is a folder rename. Force expire the tagging
// so that we will for sure get the new tagging information.
for tag in tags {
if !folderNames.contains(tag.name) {
accountMetadata?.conditionalGetInfo[FeedbinAPICaller.ConditionalGetKeys.taggings] = nil
}
}
}
func syncFolders(_ account: Account, _ tags: [FeedbinTag]?) {
guard let tags = tags else { return }
assert(Thread.isMainThread)
os_log(.debug, log: log, "Syncing folders with %ld tags.", tags.count)
let tagNames = tags.map { $0.name }
// Delete any folders not at Feedbin
if let folders = account.folders {
for folder in folders {
if !tagNames.contains(folder.name ?? "") {
for feed in folder.topLevelFeeds {
account.addFeed(feed)
clearFolderRelationship(for: feed, withFolderName: folder.name ?? "")
}
account.removeFolder(folder: folder)
}
}
}
let folderNames: [String] = {
if let folders = account.folders {
return folders.map { $0.name ?? "" }
} else {
return [String]()
}
}()
// Make any folders Feedbin has, but we don't
for tagName in tagNames {
if !folderNames.contains(tagName) {
_ = account.ensureFolder(with: tagName)
}
}
}
func syncFeeds(_ account: Account, _ subscriptions: [FeedbinSubscription]?) {
guard let subscriptions = subscriptions else { return }
assert(Thread.isMainThread)
os_log(.debug, log: log, "Syncing feeds with %ld subscriptions.", subscriptions.count)
let subFeedIDs = subscriptions.map { String($0.feedID) }
// Remove any feeds that are no longer in the subscriptions
if let folders = account.folders {
for folder in folders {
for feed in folder.topLevelFeeds {
if !subFeedIDs.contains(feed.feedID) {
folder.removeFeed(feed)
}
}
}
}
for feed in account.topLevelFeeds {
if !subFeedIDs.contains(feed.feedID) {
account.removeFeed(feed)
}
}
// Add any feeds we don't have and update any we do
var subscriptionsToAdd = Set<FeedbinSubscription>()
for subscription in subscriptions {
let subFeedID = String(subscription.feedID)
if let feed = account.existingFeed(withFeedID: subFeedID) {
feed.name = subscription.name
// If the name has been changed on the server remove the locally edited name
feed.editedName = nil
feed.homePageURL = subscription.homePageURL
feed.externalID = String(subscription.subscriptionID)
feed.faviconURL = subscription.jsonFeed?.favicon
feed.iconURL = subscription.jsonFeed?.icon
}
else {
subscriptionsToAdd.insert(subscription)
}
}
// Actually add subscriptions all in one go, so we dont trigger various rebuilding things that Account does.
for subscription in subscriptionsToAdd {
let feed = account.createFeed(with: subscription.name, url: subscription.url, feedID: String(subscription.feedID), homePageURL: subscription.homePageURL)
feed.externalID = String(subscription.subscriptionID)
account.addFeed(feed)
}
}
func syncFeedFolderRelationship(_ account: Account, _ taggings: [FeedbinTagging]?) {
guard let taggings = taggings else { return }
assert(Thread.isMainThread)
os_log(.debug, log: log, "Syncing taggings with %ld taggings.", taggings.count)
// Set up some structures to make syncing easier
let folderDict = nameToFolderDictionary(with: account.folders)
let taggingsDict = taggings.reduce([String: [FeedbinTagging]]()) { (dict, tagging) in
var taggedFeeds = dict
if var taggedFeed = taggedFeeds[tagging.name] {
taggedFeed.append(tagging)
taggedFeeds[tagging.name] = taggedFeed
} else {
taggedFeeds[tagging.name] = [tagging]
}
return taggedFeeds
}
// Sync the folders
for (folderName, groupedTaggings) in taggingsDict {
guard let folder = folderDict[folderName] else { return }
let taggingFeedIDs = groupedTaggings.map { String($0.feedID) }
// Move any feeds not in the folder to the account
for feed in folder.topLevelFeeds {
if !taggingFeedIDs.contains(feed.feedID) {
folder.removeFeed(feed)
clearFolderRelationship(for: feed, withFolderName: folder.name ?? "")
account.addFeed(feed)
}
}
// Add any feeds not in the folder
let folderFeedIDs = folder.topLevelFeeds.map { $0.feedID }
for tagging in groupedTaggings {
let taggingFeedID = String(tagging.feedID)
if !folderFeedIDs.contains(taggingFeedID) {
guard let feed = account.existingFeed(withFeedID: taggingFeedID) else {
continue
}
saveFolderRelationship(for: feed, withFolderName: folderName, id: String(tagging.taggingID))
folder.addFeed(feed)
}
}
}
let taggedFeedIDs = Set(taggings.map { String($0.feedID) })
// Remove all feeds from the account container that have a tag
for feed in account.topLevelFeeds {
if taggedFeedIDs.contains(feed.feedID) {
account.removeFeed(feed)
}
}
}
func nameToFolderDictionary(with folders: Set<Folder>?) -> [String: Folder] {
guard let folders = folders else {
return [String: Folder]()
}
var d = [String: Folder]()
for folder in folders {
let name = folder.name ?? ""
if d[name] == nil {
d[name] = folder
}
}
return d
}
func sendArticleStatuses(_ statuses: [SyncStatus], apiCall: ([Int]) async throws -> Void) async throws {
guard !statuses.isEmpty else {
return
}
var localError: Error?
let articleIDs = statuses.compactMap { Int($0.articleID) }
let articleIDGroups = articleIDs.chunked(into: 1000)
for articleIDGroup in articleIDGroups {
do {
try await apiCall(articleIDGroup)
try? await database.deleteSelectedForProcessing(articleIDGroup.map { String($0) } )
} catch {
try? await database.resetSelectedForProcessing(articleIDGroup.map { String($0) } )
localError = error
os_log(.error, log: self.log, "Article status sync call failed: %@.", error.localizedDescription)
}
}
if let localError {
throw localError
}
}
func renameFolderRelationship(for account: Account, fromName: String, toName: String) {
for feed in account.flattenedFeeds() {
if var folderRelationship = feed.folderRelationship {
let relationship = folderRelationship[fromName]
folderRelationship[fromName] = nil
folderRelationship[toName] = relationship
feed.folderRelationship = folderRelationship
}
}
}
func clearFolderRelationship(for feed: Feed, withFolderName folderName: String) {
if var folderRelationship = feed.folderRelationship {
folderRelationship[folderName] = nil
feed.folderRelationship = folderRelationship
}
}
func saveFolderRelationship(for feed: Feed, withFolderName folderName: String, id: String) {
if var folderRelationship = feed.folderRelationship {
folderRelationship[folderName] = id
feed.folderRelationship = folderRelationship
} else {
feed.folderRelationship = [folderName: id]
}
}
func decideBestFeedChoice(account: Account, url: String, name: String?, container: Container, choices: [FeedbinSubscriptionChoice]) async throws -> Feed {
var orderFound = 0
let feedSpecifiers: [FeedSpecifier] = choices.map { choice in
let source = url == choice.url ? FeedSpecifier.Source.UserEntered : FeedSpecifier.Source.HTMLLink
orderFound = orderFound + 1
let specifier = FeedSpecifier(title: choice.name, urlString: choice.url, source: source, orderFound: orderFound)
return specifier
}
if let bestSpecifier = FeedSpecifier.bestFeed(in: Set(feedSpecifiers)) {
return try await createFeed(for: account, url: bestSpecifier.urlString, name: name, container: container, validateFeed: true)
} else {
throw FeedbinAccountDelegateError.invalidParameter
}
}
func createFeed( account: Account, subscription sub: FeedbinSubscription, name: String?, container: Container) async throws -> Feed {
let feed = account.createFeed(with: sub.name, url: sub.url, feedID: String(sub.feedID), homePageURL: sub.homePageURL)
feed.externalID = String(sub.subscriptionID)
feed.iconURL = sub.jsonFeed?.icon
feed.faviconURL = sub.jsonFeed?.favicon
try await account.addFeed(feed, to: container)
if let name {
try await self.renameFeed(for: account, with: feed, to: name)
}
return try await initialFeedDownload(account: account, feed: feed)
}
func initialFeedDownload( account: Account, feed: Feed) async throws -> Feed {
refreshProgress.addToNumberOfTasksAndRemaining(1)
defer { refreshProgress.completeTask() }
let (entries, page) = try await caller.retrieveEntries(feedID: feed.feedID)
try await processEntries(account: account, entries: entries)
try await refreshArticleStatus(for: account)
try await refreshArticles(account, page: page, updateFetchDate: nil)
try await refreshMissingArticles(account)
return feed
}
func refreshArticles(_ account: Account) async throws {
os_log(.debug, log: log, "Refreshing articles...")
let (entries, page, updateFetchDate, _) = try await caller.retrieveEntries()
try await self.processEntries(account: account, entries: entries)
try await refreshArticles(account, page: page, updateFetchDate: updateFetchDate)
os_log(.debug, log: self.log, "Done refreshing articles.")
}
func refreshMissingArticles(_ account: Account) async throws {
os_log(.debug, log: log, "Refreshing missing articles...")
defer {
refreshProgress.completeTask()
}
let fetchedArticleIDs = try await account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate() ?? Set<String>()
let articleIDs = Array(fetchedArticleIDs)
let chunkedArticleIDs = articleIDs.chunked(into: 100)
for chunk in chunkedArticleIDs {
do {
let entries = try await self.caller.retrieveEntries(articleIDs: chunk)
try await self.processEntries(account: account, entries: entries)
} catch {
os_log(.error, log: self.log, "Refresh missing articles failed: %@.", error.localizedDescription)
}
}
os_log(.debug, log: self.log, "Done refreshing missing articles.")
}
func refreshArticles(_ account: Account, page: String?, updateFetchDate: Date?) async throws {
guard let page else {
if let lastArticleFetch = updateFetchDate {
accountMetadata?.lastArticleFetchStartTime = lastArticleFetch
accountMetadata?.lastArticleFetchEndTime = Date()
}
return
}
let (entries, nextPage) = try await caller.retrieveEntries(page: page)
try await processEntries(account: account, entries: entries)
try await refreshArticles(account, page: nextPage, updateFetchDate: updateFetchDate)
}
func processEntries(account: Account, entries: [FeedbinEntry]?) async throws {
let parsedItems = mapEntriesToParsedItems(entries: entries)
let feedIDsAndItems = Dictionary(grouping: parsedItems, by: { item in item.feedURL } ).mapValues { Set($0) }
try await account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true)
}
func mapEntriesToParsedItems(entries: [FeedbinEntry]?) -> Set<ParsedItem> {
guard let entries = entries else {
return Set<ParsedItem>()
}
let parsedItems: [ParsedItem] = entries.map { entry in
let authors = Set([ParsedAuthor(name: entry.authorName, url: entry.jsonFeed?.jsonFeedAuthor?.url, avatarURL: entry.jsonFeed?.jsonFeedAuthor?.avatarURL, emailAddress: nil)])
return ParsedItem(syncServiceID: String(entry.articleID), uniqueID: String(entry.articleID), feedURL: String(entry.feedID), url: entry.url, externalURL: entry.jsonFeed?.jsonFeedExternalURL, title: entry.title, language: nil, contentHTML: entry.contentHTML, contentText: nil, summary: entry.summary, imageURL: nil, bannerImageURL: nil, datePublished: entry.parsedDatePublished, dateModified: nil, authors: authors, tags: nil, attachments: nil)
}
return Set(parsedItems)
}
func syncArticleReadState(account: Account, articleIDs: [Int]?) async {
guard let articleIDs, !articleIDs.isEmpty else {
return
}
do {
let pendingArticleIDs = (try await self.database.selectPendingReadStatusArticleIDs()) ?? Set<String>()
let feedbinUnreadArticleIDs = Set(articleIDs.map { String($0) } )
let updatableFeedbinUnreadArticleIDs = feedbinUnreadArticleIDs.subtracting(pendingArticleIDs)
guard let currentUnreadArticleIDs = try await account.fetchUnreadArticleIDs() else {
return
}
// Mark articles as unread
let deltaUnreadArticleIDs = updatableFeedbinUnreadArticleIDs.subtracting(currentUnreadArticleIDs)
try? await account.markAsUnread(deltaUnreadArticleIDs)
// Mark articles as read
let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(updatableFeedbinUnreadArticleIDs)
try? await account.markAsRead(deltaReadArticleIDs)
} catch {
os_log(.error, log: self.log, "Sync Article Read Status failed: %@.", error.localizedDescription)
}
}
func syncArticleStarredState(account: Account, articleIDs: [Int]?) async {
guard let articleIDs, !articleIDs.isEmpty else {
return
}
do {
let pendingArticleIDs = (try await self.database.selectPendingStarredStatusArticleIDs()) ?? Set<String>()
let feedbinStarredArticleIDs = Set(articleIDs.map { String($0) } )
let updatableFeedbinStarredArticleIDs = feedbinStarredArticleIDs.subtracting(pendingArticleIDs)
guard let currentStarredArticleIDs = try await account.fetchStarredArticleIDs() else {
return
}
// Mark articles as starred
let deltaStarredArticleIDs = updatableFeedbinStarredArticleIDs.subtracting(currentStarredArticleIDs)
try? await account.markAsStarred(deltaStarredArticleIDs)
// Mark articles as unstarred
let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(updatableFeedbinStarredArticleIDs)
try? await account.markAsUnstarred(deltaUnstarredArticleIDs)
} catch {
os_log(.error, log: self.log, "Sync Article Starred Status failed: %@.", error.localizedDescription)
}
}
func deleteTagging(for account: Account, with feed: Feed, from container: Container?) async throws {
if let folder = container as? Folder, let feedTaggingID = feed.folderRelationship?[folder.name ?? ""] {
refreshProgress.addToNumberOfTasksAndRemaining(1)
defer { refreshProgress.completeTask() }
do {
try await caller.deleteTagging(taggingID: feedTaggingID)
clearFolderRelationship(for: feed, withFolderName: folder.name ?? "")
folder.removeFeed(feed)
account.addFeedIfNotInAnyFolder(feed)
} catch {
let wrappedError = AccountError.wrappedError(error: error, account: account)
throw wrappedError
}
} else {
if let account = container as? Account {
account.removeFeed(feed)
}
}
}
func deleteSubscription(for account: Account, with feed: Feed, from container: Container?) async throws {
// This error should never happen
guard let subscriptionID = feed.externalID else {
throw FeedbinAccountDelegateError.invalidParameter
}
refreshProgress.addToNumberOfTasksAndRemaining(1)
defer { refreshProgress.completeTask() }
do {
try await caller.deleteSubscription(subscriptionID: subscriptionID)
account.clearFeedMetadata(feed)
account.removeFeed(feed)
if let folders = account.folders {
for folder in folders {
folder.removeFeed(feed)
}
}
} catch {
let wrappedError = AccountError.wrappedError(error: error, account: account)
throw wrappedError
}
}
}
extension FeedbinAccountDelegate: FeedbinAPICallerDelegate {
@MainActor var conditionalGetInfo: [String: HTTPConditionalGetInfo] {
get {
accountMetadata?.conditionalGetInfo ?? [String: HTTPConditionalGetInfo]()
}
set {
accountMetadata?.conditionalGetInfo = newValue
}
}
@MainActor var lastArticleFetchStartTime: Date? {
accountMetadata?.lastArticleFetchStartTime
}
}