1020 lines
31 KiB
Swift
1020 lines
31 KiB
Swift
//
|
||
// 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 don’t 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
|
||
}
|
||
}
|