Convert NewsBlur to async await.

This commit is contained in:
Brent Simmons 2024-05-13 21:59:42 -07:00
parent be4564716f
commit d58821a7ad
4 changed files with 334 additions and 849 deletions

View File

@ -19,29 +19,22 @@ import CommonErrors
extension NewsBlurAccountDelegate {
func refreshFeeds(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
os_log(.debug, log: log, "Refreshing feeds...")
func refreshFeeds(for account: Account) async throws {
caller.retrieveFeeds { result in
os_log(.debug, log: log, "Refreshing feeds…")
MainActor.assumeIsolated {
switch result {
case .success((let feeds, let folders)):
BatchUpdate.shared.perform {
self.syncFolders(account, folders)
self.syncFeeds(account, feeds)
self.syncFeedFolderRelationship(account, folders)
}
completion(.success(()))
case .failure(let error):
completion(.failure(error))
}
}
let (feeds, folders) = try await caller.retrieveFeeds()
BatchUpdate.shared.perform {
self.syncFolders(account, folders)
self.syncFeeds(account, feeds)
self.syncFeedFolderRelationship(account, folders)
}
}
func syncFolders(_ account: Account, _ folders: [NewsBlurFolder]?) {
guard let folders = folders else { return }
guard let folders else { return }
assert(Thread.isMainThread)
os_log(.debug, log: log, "Syncing folders with %ld folders.", folders.count)
@ -79,7 +72,7 @@ extension NewsBlurAccountDelegate {
}
func syncFeeds(_ account: Account, _ feeds: [NewsBlurFeed]?) {
guard let feeds = feeds else { return }
guard let feeds else { return }
assert(Thread.isMainThread)
os_log(.debug, log: log, "Syncing feeds with %ld feeds.", feeds.count)
@ -130,7 +123,8 @@ extension NewsBlurAccountDelegate {
}
func syncFeedFolderRelationship(_ account: Account, _ folders: [NewsBlurFolder]?) {
guard let folders = folders else { return }
guard let folders else { return }
assert(Thread.isMainThread)
os_log(.debug, log: log, "Syncing folders with %ld folders.", folders.count)
@ -231,44 +225,23 @@ extension NewsBlurAccountDelegate {
return d
}
func refreshUnreadStories(for account: Account, hashes: [NewsBlurStoryHash]?, updateFetchDate: Date?, completion: @escaping (Result<Void, Error>) -> Void) {
guard let hashes = hashes, !hashes.isEmpty else {
func refreshUnreadStories(for account: Account, hashes: [NewsBlurStoryHash]?, updateFetchDate: Date?) async throws {
guard let hashes, !hashes.isEmpty else {
if let lastArticleFetch = updateFetchDate {
self.accountMetadata?.lastArticleFetchStartTime = lastArticleFetch
self.accountMetadata?.lastArticleFetchEndTime = Date()
}
completion(.success(()))
return
}
let numberOfStories = min(hashes.count, 100) // api limit
let hashesToFetch = Array(hashes[..<numberOfStories])
caller.retrieveStories(hashes: hashesToFetch) { result in
switch result {
case .success((let stories, let date)):
self.processStories(account: account, stories: stories) { result in
self.refreshProgress.completeTask()
if case .failure(let error) = result {
completion(.failure(error))
return
}
self.refreshUnreadStories(for: account, hashes: Array(hashes[numberOfStories...]), updateFetchDate: date) { result in
os_log(.debug, log: self.log, "Done refreshing stories.")
switch result {
case .success:
completion(.success(()))
case .failure(let error):
completion(.failure(error))
}
}
}
case .failure(let error):
completion(.failure(error))
}
}
let (stories, date) = try await caller.retrieveStories(hashes: hashesToFetch)
try await processStories(account: account, stories: stories)
try await refreshUnreadStories(for: account, hashes: Array(hashes[numberOfStories...]), updateFetchDate: date)
os_log(.debug, log: self.log, "Done refreshing stories.")
}
func mapStoriesToParsedItems(stories: [NewsBlurStory]?) -> Set<ParsedItem> {
@ -282,251 +255,170 @@ extension NewsBlurAccountDelegate {
return Set(parsedItems)
}
func sendStoryStatuses(_ statuses: [SyncStatus],
throttle: Bool,
apiCall: ([String], @escaping (Result<Void, Error>) -> Void) -> Void,
completion: @escaping (Result<Void, Error>) -> Void) {
func sendStoryStatuses(_ statuses: Set<SyncStatus>, throttle: Bool, apiCall: (Set<String>) async throws -> Void) async throws {
guard !statuses.isEmpty else {
completion(.success(()))
return
}
let group = DispatchGroup()
var errorOccurred = false
let storyHashes = statuses.compactMap { $0.articleID }
let storyHashGroups = storyHashes.chunked(into: throttle ? 1 : 5) // api limit
for storyHashGroup in storyHashGroups {
group.enter()
apiCall(storyHashGroup) { result in
switch result {
case .success:
Task {
try? await self.database.deleteSelectedForProcessing(storyHashGroup.map { String($0) } )
group.leave()
}
case .failure(let error):
errorOccurred = true
os_log(.error, log: self.log, "Story status sync call failed: %@.", error.localizedDescription)
Task {
try? await self.database.resetSelectedForProcessing(storyHashGroup.map { String($0) } )
group.leave()
}
}
do {
try await apiCall(Set(storyHashGroup))
} catch {
errorOccurred = true
os_log(.error, log: self.log, "Story status sync call failed: %@.", error.localizedDescription)
try? await syncDatabase.resetSelectedForProcessing(storyHashGroup.map { String($0) } )
}
}
group.notify(queue: DispatchQueue.main) {
if errorOccurred {
completion(.failure(NewsBlurError.unknown))
} else {
completion(.success(()))
}
if errorOccurred {
throw NewsBlurError.unknown
}
}
func syncStoryReadState(account: Account, hashes: [NewsBlurStoryHash]?, completion: @escaping (() -> Void)) {
func syncStoryReadState(account: Account, hashes: Set<NewsBlurStoryHash>?) async {
guard let hashes else {
completion()
return
}
Task { @MainActor in
do {
let pendingArticleIDs = (try await self.database.selectPendingReadStatusArticleIDs()) ?? Set<String>()
do {
let pendingArticleIDs = (try await syncDatabase.selectPendingReadStatusArticleIDs()) ?? Set<String>()
let newsBlurUnreadStoryHashes = Set(hashes.map { $0.hash } )
let updatableNewsBlurUnreadStoryHashes = newsBlurUnreadStoryHashes.subtracting(pendingArticleIDs)
let newsBlurUnreadStoryHashes = Set(hashes.map { $0.hash } )
let updatableNewsBlurUnreadStoryHashes = newsBlurUnreadStoryHashes.subtracting(pendingArticleIDs)
guard let currentUnreadArticleIDs = try await account.fetchUnreadArticleIDs() else {
completion()
return
}
// Mark articles as unread
let deltaUnreadArticleIDs = updatableNewsBlurUnreadStoryHashes.subtracting(currentUnreadArticleIDs)
try? await account.markAsUnread(deltaUnreadArticleIDs)
// Mark articles as read
let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(updatableNewsBlurUnreadStoryHashes)
try? await account.markAsRead(deltaReadArticleIDs)
completion()
} catch {
os_log(.error, log: self.log, "Sync Story Read Status failed: %@.", error.localizedDescription)
guard let currentUnreadArticleIDs = try await account.fetchUnreadArticleIDs() else {
return
}
// Mark articles as unread
let deltaUnreadArticleIDs = updatableNewsBlurUnreadStoryHashes.subtracting(currentUnreadArticleIDs)
try? await account.markAsUnread(deltaUnreadArticleIDs)
// Mark articles as read
let deltaReadArticleIDs = currentUnreadArticleIDs.subtracting(updatableNewsBlurUnreadStoryHashes)
try? await account.markAsRead(deltaReadArticleIDs)
} catch {
os_log(.error, log: self.log, "Sync Story Read Status failed: %@.", error.localizedDescription)
}
}
func syncStoryStarredState(account: Account, hashes: [NewsBlurStoryHash]?, completion: @escaping (() -> Void)) {
guard let hashes = hashes else {
completion()
func syncStoryStarredState(account: Account, hashes: Set<NewsBlurStoryHash>?) async {
guard let hashes else {
return
}
Task { @MainActor in
do {
let pendingArticleIDs = (try await syncDatabase.selectPendingStarredStatusArticleIDs()) ?? Set<String>()
do {
let pendingArticleIDs = (try await self.database.selectPendingStarredStatusArticleIDs()) ?? Set<String>()
let newsBlurStarredStoryHashes = Set(hashes.map { $0.hash } )
let updatableNewsBlurUnreadStoryHashes = newsBlurStarredStoryHashes.subtracting(pendingArticleIDs)
let newsBlurStarredStoryHashes = Set(hashes.map { $0.hash } )
let updatableNewsBlurUnreadStoryHashes = newsBlurStarredStoryHashes.subtracting(pendingArticleIDs)
guard let currentStarredArticleIDs = try await account.fetchStarredArticleIDs() else {
completion()
return
}
// Mark articles as starred
let deltaStarredArticleIDs = updatableNewsBlurUnreadStoryHashes.subtracting(currentStarredArticleIDs)
try? await account.markAsStarred(deltaStarredArticleIDs)
// Mark articles as unstarred
let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(updatableNewsBlurUnreadStoryHashes)
try? await account.markAsUnstarred(deltaUnstarredArticleIDs)
completion()
} catch {
os_log(.error, log: self.log, "Sync Story Starred Status failed: %@.", error.localizedDescription)
guard let currentStarredArticleIDs = try await account.fetchStarredArticleIDs() else {
return
}
// Mark articles as starred
let deltaStarredArticleIDs = updatableNewsBlurUnreadStoryHashes.subtracting(currentStarredArticleIDs)
try? await account.markAsStarred(deltaStarredArticleIDs)
// Mark articles as unstarred
let deltaUnstarredArticleIDs = currentStarredArticleIDs.subtracting(updatableNewsBlurUnreadStoryHashes)
try? await account.markAsUnstarred(deltaUnstarredArticleIDs)
} catch {
os_log(.error, log: self.log, "Sync Story Starred Status failed: %@.", error.localizedDescription)
}
}
func createFeed(account: Account, feed: NewsBlurFeed?, name: String?, container: Container, completion: @escaping (Result<Feed, Error>) -> Void) {
guard let feed = feed else {
completion(.failure(NewsBlurError.invalidParameter))
func createFeed(account: Account, newsBlurFeed: NewsBlurFeed, name: String?, container: Container) async throws -> Feed {
let feed = account.createFeed(with: newsBlurFeed.name, url: newsBlurFeed.feedURL, feedID: String(newsBlurFeed.feedID), homePageURL: newsBlurFeed.homePageURL)
feed.externalID = String(newsBlurFeed.feedID)
feed.faviconURL = newsBlurFeed.faviconURL
try await account.addFeed(feed, to: container)
if let name {
try await renameFeed(for: account, with: feed, to: name)
}
try await initialFeedDownload(account: account, feed: feed)
return feed
}
func downloadFeed(account: Account, feed: Feed, page: Int) async throws {
refreshProgress.addTask()
defer {
refreshProgress.completeTask()
}
let (stories, _) = try await caller.retrieveStories(feedID: feed.feedID, page: page)
refreshProgress.completeTask()
guard let stories, stories.count > 0 else {
return
}
Task { @MainActor in
let feed = account.createFeed(with: feed.name, url: feed.feedURL, feedID: String(feed.feedID), homePageURL: feed.homePageURL)
feed.externalID = String(feed.feedID)
feed.faviconURL = feed.faviconURL
let since: Date? = Calendar.current.date(byAdding: .month, value: -3, to: Date())
do {
try await account.addFeed(feed, to: container)
if let name {
try await self.renameFeed(for: account, with: feed, to: name)
}
self.initialFeedDownload(account: account, feed: feed, completion: completion)
} catch {
completion(.failure(error))
}
let hasStories = try await processStories(account: account, stories: stories, since: since)
if hasStories {
try await downloadFeed(account: account, feed: feed, page: page + 1)
}
}
func downloadFeed(account: Account, feed: Feed, page: Int, completion: @escaping (Result<Void, Error>) -> Void) {
refreshProgress.addToNumberOfTasksAndRemaining(1)
func initialFeedDownload(account: Account, feed: Feed) async throws {
caller.retrieveStories(feedID: feed.feedID, page: page) { result in
switch result {
case .success((let stories, _)):
// No more stories
guard let stories = stories, stories.count > 0 else {
self.refreshProgress.completeTask()
completion(.success(()))
return
}
let since: Date? = Calendar.current.date(byAdding: .month, value: -3, to: Date())
self.processStories(account: account, stories: stories, since: since) { result in
self.refreshProgress.completeTask()
if case .failure(let error) = result {
completion(.failure(error))
return
}
// No more recent stories
if case .success(let hasStories) = result, !hasStories {
completion(.success(()))
return
}
self.downloadFeed(account: account, feed: feed, page: page + 1, completion: completion)
}
case .failure(let error):
completion(.failure(error))
}
refreshProgress.addTask()
defer {
refreshProgress.completeTask()
}
}
func initialFeedDownload(account: Account, feed: Feed, completion: @escaping (Result<Feed, Error>) -> Void) {
refreshProgress.addToNumberOfTasksAndRemaining(1)
// Download the initial articles
downloadFeed(account: account, feed: feed, page: 1) { result in
Task { @MainActor in
do {
try await self.refreshArticleStatus(for: account)
self.refreshMissingStories(for: account) { result in
switch result {
case .success:
self.refreshProgress.completeTask()
DispatchQueue.main.async {
completion(.success(feed))
}
case .failure(let error):
completion(.failure(error))
}
}
} catch {
completion(.failure(error))
}
}
}
try await downloadFeed(account: account, feed: feed, page: 1)
try await refreshArticleStatus(for: account)
try await refreshMissingStories(for: account)
}
func deleteFeed(for account: Account, with feed: Feed, from container: Container?, completion: @escaping (Result<Void, Error>) -> Void) {
func deleteFeed(for account: Account, with feed: Feed, from container: Container?) async throws {
// This error should never happen
guard let feedID = feed.externalID else {
completion(.failure(NewsBlurError.invalidParameter))
return
throw NewsBlurError.invalidParameter
}
refreshProgress.addToNumberOfTasksAndRemaining(1)
refreshProgress.addTask()
defer {
refreshProgress.completeTask()
}
let folderName = (container as? Folder)?.name
caller.deleteFeed(feedID: feedID, folder: folderName) { result in
self.refreshProgress.completeTask()
switch result {
case .success:
DispatchQueue.main.async {
let feedID = feed.feedID
do {
try await caller.deleteFeed(feedID: feedID, folder: folderName)
if folderName == nil {
account.removeFeed(feed)
}
if folderName == nil {
account.removeFeed(feed)
}
if let folders = account.folders {
for folder in folders where folderName != nil && folder.name == folderName {
folder.removeFeed(feed)
}
}
if account.existingFeed(withFeedID: feedID) != nil {
account.clearFeedMetadata(feed)
}
completion(.success(()))
}
case .failure(let error):
DispatchQueue.main.async {
let wrappedError = AccountError.wrappedError(error: error, account: account)
completion(.failure(wrappedError))
if let folders = account.folders {
for folder in folders where folderName != nil && folder.name == folderName {
folder.removeFeed(feed)
}
}
if account.existingFeed(withFeedID: feed.feedID) != nil {
account.clearFeedMetadata(feed)
}
} catch {
throw AccountError.wrappedError(error: error, account: account)
}
}
}

View File

@ -33,7 +33,7 @@ final class NewsBlurAccountDelegate: AccountDelegate {
let caller: NewsBlurAPICaller
let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "NewsBlur")
let database: SyncDatabase
let syncDatabase: SyncDatabase
init(dataFolder: String, transport: Transport?) {
if let transport = transport {
@ -53,320 +53,164 @@ final class NewsBlurAccountDelegate: AccountDelegate {
caller = NewsBlurAPICaller(transport: session)
}
database = SyncDatabase(databasePath: dataFolder.appending("/DB.sqlite3"))
syncDatabase = SyncDatabase(databasePath: dataFolder.appending("/DB.sqlite3"))
}
func receiveRemoteNotification(for account: Account, userInfo: [AnyHashable : Any]) async {
}
func refreshAll(for account: Account) async throws {
refreshProgress.addToNumberOfTasksAndRemaining(4)
try await withCheckedThrowingContinuation { continuation in
try await refreshFeeds(for: account)
refreshProgress.completeTask()
self.refreshAll(for: account) { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
try await sendArticleStatus(for: account)
refreshProgress.completeTask()
private func refreshAll(for account: Account, completion: @escaping (Result<Void, Error>) -> ()) {
self.refreshProgress.addToNumberOfTasksAndRemaining(4)
try await refreshArticleStatus(for: account)
refreshProgress.completeTask()
refreshFeeds(for: account) { result in
self.refreshProgress.completeTask()
switch result {
case .success:
self.sendArticleStatus(for: account) { result in
self.refreshProgress.completeTask()
switch result {
case .success:
self.refreshArticleStatus(for: account) { result in
self.refreshProgress.completeTask()
switch result {
case .success:
self.refreshMissingStories(for: account) { result in
self.refreshProgress.completeTask()
switch result {
case .success:
DispatchQueue.main.async {
completion(.success(()))
}
case .failure(let error):
DispatchQueue.main.async {
self.refreshProgress.clear()
let wrappedError = AccountError.wrappedError(error: error, account: account)
completion(.failure(wrappedError))
}
}
}
case .failure(let error):
completion(.failure(error))
}
}
case .failure(let error):
completion(.failure(error))
}
}
case .failure(let error):
completion(.failure(error))
}
}
try await refreshMissingStories(for: account)
refreshProgress.completeTask()
}
func syncArticleStatus(for account: Account) async throws {
try await withCheckedThrowingContinuation { continuation in
sendArticleStatus(for: account) { result in
switch result {
case .success:
self.refreshArticleStatus(for: account) { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
try await sendArticleStatus(for: account)
try await refreshArticleStatus(for: account)
}
public func sendArticleStatus(for account: Account) async throws {
try await withCheckedThrowingContinuation { continuation in
os_log(.debug, log: log, "Sending story statuses…")
self.sendArticleStatus(for: account) { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
let syncStatuses = (try await self.syncDatabase.selectForProcessing()) ?? Set<SyncStatus>()
let createUnreadStatuses = syncStatuses.filter {
$0.key == SyncStatus.Key.read && $0.flag == false
}
let deleteUnreadStatuses = syncStatuses.filter {
$0.key == SyncStatus.Key.read && $0.flag == true
}
let createStarredStatuses = syncStatuses.filter {
$0.key == SyncStatus.Key.starred && $0.flag == true
}
let deleteStarredStatuses = syncStatuses.filter {
$0.key == SyncStatus.Key.starred && $0.flag == false
}
}
private func sendArticleStatus(for account: Account, completion: @escaping (Result<Void, Error>) -> ()) {
os_log(.debug, log: log, "Sending story statuses...")
Task { @MainActor in
var errorOccurred = false
do {
let syncStatuses = (try await self.database.selectForProcessing()) ?? Set<SyncStatus>()
do {
try await sendStoryStatuses(createUnreadStatuses, throttle: true, apiCall: caller.markAsUnread)
} catch {
errorOccurred = true
}
@MainActor func processStatuses(_ syncStatuses: [SyncStatus]) {
let createUnreadStatuses = syncStatuses.filter {
$0.key == SyncStatus.Key.read && $0.flag == false
}
let deleteUnreadStatuses = syncStatuses.filter {
$0.key == SyncStatus.Key.read && $0.flag == true
}
let createStarredStatuses = syncStatuses.filter {
$0.key == SyncStatus.Key.starred && $0.flag == true
}
let deleteStarredStatuses = syncStatuses.filter {
$0.key == SyncStatus.Key.starred && $0.flag == false
}
do {
try await sendStoryStatuses(deleteUnreadStatuses, throttle: false, apiCall: caller.markAsRead)
} catch {
errorOccurred = true
}
let group = DispatchGroup()
var errorOccurred = false
do {
try await sendStoryStatuses(createStarredStatuses, throttle: true, apiCall: caller.star)
} catch {
errorOccurred = true
}
group.enter()
self.sendStoryStatuses(createUnreadStatuses, throttle: true, apiCall: self.caller.markAsUnread) { result in
group.leave()
if case .failure = result {
errorOccurred = true
}
}
do {
try await sendStoryStatuses(deleteStarredStatuses, throttle: true, apiCall: caller.unstar)
} catch {
errorOccurred = true
}
group.enter()
self.sendStoryStatuses(deleteUnreadStatuses, throttle: false, apiCall: self.caller.markAsRead) { result in
group.leave()
if case .failure = result {
errorOccurred = true
}
}
group.enter()
self.sendStoryStatuses(createStarredStatuses, throttle: true, apiCall: self.caller.star) { result in
group.leave()
if case .failure = result {
errorOccurred = true
}
}
group.enter()
self.sendStoryStatuses(deleteStarredStatuses, throttle: true, apiCall: self.caller.unstar) { result in
group.leave()
if case .failure = result {
errorOccurred = true
}
}
group.notify(queue: DispatchQueue.main) {
os_log(.debug, log: self.log, "Done sending article statuses.")
if errorOccurred {
completion(.failure(NewsBlurError.unknown))
} else {
completion(.success(()))
}
}
}
processStatuses(Array(syncStatuses))
} catch {
completion(.failure(error))
}
os_log(.debug, log: self.log, "Done sending article statuses.")
if errorOccurred {
throw NewsBlurError.unknown
}
}
func refreshArticleStatus(for account: Account) async throws {
try await withCheckedThrowingContinuation { continuation in
self.refreshArticleStatus(for: account) { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
os_log(.debug, log: log, "Refreshing story statuses…")
private func refreshArticleStatus(for account: Account, completion: @escaping (Result<Void, Error>) -> ()) {
os_log(.debug, log: log, "Refreshing story statuses...")
let group = DispatchGroup()
var errorOccurred = false
group.enter()
caller.retrieveUnreadStoryHashes { result in
switch result {
case .success(let storyHashes):
self.syncStoryReadState(account: account, hashes: storyHashes) {
group.leave()
}
case .failure(let error):
errorOccurred = true
os_log(.info, log: self.log, "Retrieving unread stories failed: %@.", error.localizedDescription)
group.leave()
}
do {
let storyHashes = try await caller.retrieveUnreadStoryHashes()
await syncStoryReadState(account: account, hashes: storyHashes)
} catch {
errorOccurred = true
os_log(.info, log: self.log, "Retrieving unread stories failed: %@.", error.localizedDescription)
}
group.enter()
caller.retrieveStarredStoryHashes { result in
switch result {
case .success(let storyHashes):
self.syncStoryStarredState(account: account, hashes: storyHashes) {
group.leave()
}
case .failure(let error):
errorOccurred = true
os_log(.info, log: self.log, "Retrieving starred stories failed: %@.", error.localizedDescription)
group.leave()
}
do {
let storyHashes = try await caller.retrieveStarredStoryHashes()
await syncStoryStarredState(account: account, hashes: storyHashes)
} catch {
errorOccurred = true
os_log(.info, log: self.log, "Retrieving starred stories failed: %@.", error.localizedDescription)
}
group.notify(queue: DispatchQueue.main) {
os_log(.debug, log: self.log, "Done refreshing article statuses.")
if errorOccurred {
completion(.failure(NewsBlurError.unknown))
} else {
completion(.success(()))
}
os_log(.debug, log: self.log, "Done refreshing article statuses.")
if errorOccurred {
throw NewsBlurError.unknown
}
}
func refreshStories(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
os_log(.debug, log: log, "Refreshing stories...")
os_log(.debug, log: log, "Refreshing unread stories...")
func refreshStories(for account: Account) async throws {
caller.retrieveUnreadStoryHashes { result in
switch result {
case .success(let storyHashes):
os_log(.debug, log: log, "Refreshing stories…")
os_log(.debug, log: log, "Refreshing unread stories…")
if let count = storyHashes?.count, count > 0 {
self.refreshProgress.addToNumberOfTasksAndRemaining((count - 1) / 100 + 1)
}
self.refreshUnreadStories(for: account, hashes: storyHashes, updateFetchDate: nil, completion: completion)
case .failure(let error):
completion(.failure(error))
}
let storyHashes = try await caller.retrieveUnreadStoryHashes()
if let count = storyHashes?.count, count > 0 {
refreshProgress.addToNumberOfTasksAndRemaining((count - 1) / 100 + 1)
}
let storyHashesArray: [NewsBlurStoryHash] = {
if let storyHashes {
return Array(storyHashes)
}
return [NewsBlurStoryHash]()
}()
try await refreshUnreadStories(for: account, hashes: storyHashesArray, updateFetchDate: nil)
}
func refreshMissingStories(for account: Account, completion: @escaping (Result<Void, Error>) -> Void) {
os_log(.debug, log: log, "Refreshing missing stories...")
func refreshMissingStories(for account: Account) async throws {
Task { @MainActor in
os_log(.debug, log: log, "Refreshing missing stories…")
let fetchedArticleIDs = try await account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate() ?? Set<String>()
var errorOccurred = false
let storyHashes = Array(fetchedArticleIDs).map {
NewsBlurStoryHash(hash: $0, timestamp: Date())
}
let chunkedStoryHashes = storyHashes.chunked(into: 100)
for chunk in chunkedStoryHashes {
do {
let fetchedArticleIDs = try await account.fetchArticleIDsForStatusesWithoutArticlesNewerThanCutoffDate() ?? Set<String>()
let group = DispatchGroup()
var errorOccurred = false
let storyHashes = Array(fetchedArticleIDs).map {
NewsBlurStoryHash(hash: $0, timestamp: Date())
}
let chunkedStoryHashes = storyHashes.chunked(into: 100)
for chunk in chunkedStoryHashes {
group.enter()
self.caller.retrieveStories(hashes: chunk) { result in
switch result {
case .success((let stories, _)):
self.processStories(account: account, stories: stories) { result in
group.leave()
if case .failure = result {
errorOccurred = true
}
}
case .failure(let error):
errorOccurred = true
os_log(.error, log: self.log, "Refresh missing stories failed: %@.", error.localizedDescription)
group.leave()
}
}
}
group.notify(queue: DispatchQueue.main) {
self.refreshProgress.completeTask()
os_log(.debug, log: self.log, "Done refreshing missing stories.")
if errorOccurred {
completion(.failure(NewsBlurError.unknown))
} else {
completion(.success(()))
}
}
let (stories, _) = try await caller.retrieveStories(hashes: chunk)
try await processStories(account: account, stories: stories)
} catch {
self.refreshProgress.completeTask()
completion(.failure(error))
errorOccurred = true
os_log(.error, log: self.log, "Refresh missing stories failed: %@.", error.localizedDescription)
}
}
os_log(.debug, log: self.log, "Done refreshing missing stories.")
if errorOccurred {
throw NewsBlurError.unknown
}
}
func processStories(account: Account, stories: [NewsBlurStory]?, since: Date? = nil, completion: @escaping (Result<Bool, DatabaseError>) -> Void) {
@discardableResult
func processStories(account: Account, stories: [NewsBlurStory]?, since: Date? = nil) async throws -> Bool {
let parsedItems = mapStoriesToParsedItems(stories: stories).filter {
guard let datePublished = $0.datePublished, let since = since else {
@ -379,14 +223,8 @@ final class NewsBlurAccountDelegate: AccountDelegate {
Set($0)
}
Task { @MainActor in
do {
try await account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true)
completion(.success(!feedIDsAndItems.isEmpty))
} catch {
completion(.failure(.suspended))
}
}
try await account.update(feedIDsAndItems: feedIDsAndItems, defaultRead: true)
return !feedIDsAndItems.isEmpty
}
func importOPML(for account: Account, opmlFile: URL) async throws {
@ -394,97 +232,44 @@ final class NewsBlurAccountDelegate: AccountDelegate {
func createFolder(for account: Account, name: String) async throws -> Folder {
try await withCheckedThrowingContinuation { continuation in
refreshProgress.addTask()
self.createFolder(for: account, name: name) { result in
switch result {
case .success(let folder):
continuation.resume(returning: folder)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
try await caller.addFolder(named: name)
refreshProgress.completeTask()
private func createFolder(for account: Account, name: String, completion: @escaping (Result<Folder, Error>) -> ()) {
self.refreshProgress.addToNumberOfTasksAndRemaining(1)
caller.addFolder(named: name) { result in
self.refreshProgress.completeTask()
switch result {
case .success():
if let folder = account.ensureFolder(with: name) {
completion(.success(folder))
} else {
completion(.failure(NewsBlurError.invalidParameter))
}
case .failure(let error):
completion(.failure(error))
}
if let folder = account.ensureFolder(with: name) {
return folder
} else {
throw NewsBlurError.invalidParameter
}
}
func renameFolder(for account: Account, with folder: Folder, to name: String) async throws {
try await withCheckedThrowingContinuation { continuation in
self.renameFolder(for: account, with: folder, to: name) { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
private func renameFolder(for account: Account, with folder: Folder, to name: String, completion: @escaping (Result<Void, Error>) -> ()) {
guard let folderToRename = folder.name else {
completion(.failure(NewsBlurError.invalidParameter))
return
throw NewsBlurError.invalidParameter
}
refreshProgress.addToNumberOfTasksAndRemaining(1)
refreshProgress.addTask()
defer {
refreshProgress.completeTask()
}
let nameBefore = folder.name
caller.renameFolder(with: folderToRename, to: name) { result in
self.refreshProgress.completeTask()
switch result {
case .success:
completion(.success(()))
case .failure(let error):
folder.name = nameBefore
completion(.failure(error))
}
do {
try await caller.renameFolder(with: folderToRename, to: name)
folder.name = name
} catch {
folder.name = nameBefore
throw error
}
folder.name = name
}
func removeFolder(for account: Account, with folder: Folder) async throws {
try await withCheckedThrowingContinuation { continuation in
self.removeFolder(for: account, with: folder) { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
private func removeFolder(for account: Account, with folder: Folder, completion: @escaping (Result<Void, Error>) -> ()) {
guard let folderToRemove = folder.name else {
completion(.failure(NewsBlurError.invalidParameter))
return
throw NewsBlurError.invalidParameter
}
var feedIDs: [String] = []
@ -496,246 +281,105 @@ final class NewsBlurAccountDelegate: AccountDelegate {
}
}
refreshProgress.addToNumberOfTasksAndRemaining(1)
caller.removeFolder(named: folderToRemove, feedIDs: feedIDs) { result in
self.refreshProgress.completeTask()
switch result {
case .success:
account.removeFolder(folder: folder)
completion(.success(()))
case .failure(let error):
completion(.failure(error))
}
refreshProgress.addTask()
defer {
refreshProgress.completeTask()
}
try await caller.removeFolder(named: folderToRemove, feedIDs: feedIDs)
account.removeFolder(folder: folder)
}
@discardableResult
func createFeed(for account: Account, url: String, name: String?, container: Container, validateFeed: Bool) async throws -> Feed {
try await withCheckedThrowingContinuation { continuation in
self.createFeed(for: account, url: url, name: name, container: container, validateFeed: validateFeed) { result in
switch result {
case .success(let feed):
continuation.resume(returning: feed)
case .failure(let error):
continuation.resume(throwing: error)
}
}
refreshProgress.addTask()
defer {
refreshProgress.completeTask()
}
}
private func createFeed(for account: Account, url: String, name: String?, container: Container, validateFeed: Bool, completion: @escaping (Result<Feed, Error>) -> ()) {
refreshProgress.addToNumberOfTasksAndRemaining(1)
let folderName = (container as? Folder)?.name
caller.addURL(url, folder: folderName) { result in
self.refreshProgress.completeTask()
switch result {
case .success(let feed):
self.createFeed(account: account, feed: feed, name: name, container: container, completion: completion)
case .failure(let error):
DispatchQueue.main.async {
let wrappedError = AccountError.wrappedError(error: error, account: account)
completion(.failure(wrappedError))
}
do {
guard let newsBlurFeed = try await caller.addURL(url, folder: folderName) else {
throw NewsBlurError.unknown
}
let feed = try await createFeed(account: account, newsBlurFeed: newsBlurFeed, name: name, container: container)
return feed
} catch {
throw AccountError.wrappedError(error: error, account: account)
}
}
func renameFeed(for account: Account, with feed: Feed, to name: String) async throws {
try await withCheckedThrowingContinuation { continuation in
self.renameFeed(for: account, with: feed, to: name) { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
private func renameFeed(for account: Account, with feed: Feed, to name: String, completion: @escaping (Result<Void, Error>) -> ()) {
guard let feedID = feed.externalID else {
completion(.failure(NewsBlurError.invalidParameter))
return
throw NewsBlurError.invalidParameter
}
refreshProgress.addToNumberOfTasksAndRemaining(1)
refreshProgress.addTask()
defer {
refreshProgress.completeTask()
}
caller.renameFeed(feedID: feedID, newName: name) { result in
self.refreshProgress.completeTask()
switch result {
case .success:
DispatchQueue.main.async {
feed.editedName = name
completion(.success(()))
}
case .failure(let error):
DispatchQueue.main.async {
let wrappedError = AccountError.wrappedError(error: error, account: account)
completion(.failure(wrappedError))
}
}
do {
try await caller.renameFeed(feedID: feedID, newName: name)
feed.editedName = name
} catch {
throw AccountError.wrappedError(error: error, account: account)
}
}
func addFeed(for account: Account, with feed: Feed, to container: any Container) async throws {
try await withCheckedThrowingContinuation { continuation in
self.addFeed(for: account, with: feed, to: container) { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
private func addFeed(for account: Account, with feed: Feed, to container: Container, completion: @escaping (Result<Void, Error>) -> ()) {
guard let folder = container as? Folder else {
DispatchQueue.main.async {
if let account = container as? Account {
account.addFeed(feed)
}
completion(.success(()))
}
if let account = container as? Account {
account.addFeed(feed)
return
}
guard let folder = container as? Folder else {
return
}
let folderName = folder.name ?? ""
saveFolderRelationship(for: feed, withFolderName: folderName, id: folderName)
folder.addFeed(feed)
completion(.success(()))
}
func removeFeed(for account: Account, with feed: Feed, from container: any Container) async throws {
try await withCheckedThrowingContinuation { continuation in
self.removeFeed(for: account, with: feed, from: container) { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
private func removeFeed(for account: Account, with feed: Feed, from container: Container, completion: @escaping (Result<Void, Error>) -> ()) {
deleteFeed(for: account, with: feed, from: container, completion: completion)
try await deleteFeed(for: account, with: feed, from: container)
}
func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container) async throws {
try await withCheckedThrowingContinuation { continuation in
self.moveFeed(for: account, with: feed, from: from, to: to) { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
private func moveFeed(for account: Account, with feed: Feed, from: Container, to: Container, completion: @escaping (Result<Void, Error>) -> ()) {
guard let feedID = feed.externalID else {
completion(.failure(NewsBlurError.invalidParameter))
return
throw NewsBlurError.invalidParameter
}
refreshProgress.addToNumberOfTasksAndRemaining(1)
caller.moveFeed(
feedID: feedID,
from: (from as? Folder)?.name,
to: (to as? Folder)?.name
) { result in
self.refreshProgress.completeTask()
switch result {
case .success:
from.removeFeed(feed)
to.addFeed(feed)
completion(.success(()))
case .failure(let error):
completion(.failure(error))
}
refreshProgress.addTask()
defer {
refreshProgress.completeTask()
}
try await caller.moveFeed( feedID: feedID, from: (from as? Folder)?.name, to: (to as? Folder)?.name)
from.removeFeed(feed)
to.addFeed(feed)
}
func restoreFeed(for account: Account, feed: Feed, container: any Container) async throws {
try await withCheckedThrowingContinuation { continuation in
self.restoreFeed(for: account, feed: feed, container: container) { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
private func restoreFeed(for account: Account, feed: Feed, container: Container, completion: @escaping (Result<Void, Error>) -> ()) {
if let existingFeed = account.existingFeed(withURL: feed.url) {
Task { @MainActor in
do {
try await account.addFeed(existingFeed, to: container)
completion(.success(()))
} catch {
completion(.failure(error))
}
}
return try await account.addFeed(existingFeed, to: container)
} else {
createFeed(for: account, url: feed.url, name: feed.editedName, container: container, validateFeed: true) { result in
switch result {
case .success:
completion(.success(()))
case .failure(let error):
completion(.failure(error))
}
}
try await createFeed(for: account, url: feed.url, name: feed.editedName, container: container, validateFeed: true)
}
}
func restoreFolder(for account: Account, folder: Folder) async throws {
try await withCheckedThrowingContinuation { continuation in
self.restoreFolder(for: account, folder: folder) { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
private func restoreFolder(for account: Account, folder: Folder, completion: @escaping (Result<Void, Error>) -> ()) {
guard let folderName = folder.name else {
completion(.failure(NewsBlurError.invalidParameter))
return
throw NewsBlurError.invalidParameter
}
var feedsToRestore: [Feed] = []
@ -744,71 +388,33 @@ final class NewsBlurAccountDelegate: AccountDelegate {
folder.topLevelFeeds.remove(feed)
}
let group = DispatchGroup()
group.enter()
createFolder(for: account, name: folderName) { result in
group.leave()
switch result {
case .success(let folder):
for feed in feedsToRestore {
group.enter()
self.restoreFeed(for: account, feed: feed, container: folder) { result in
group.leave()
switch result {
case .success:
break
case .failure(let error):
os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription)
}
}
do {
let folder = try await createFolder(for: account, name: folderName)
for feed in feedsToRestore {
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
}
case .failure(let error):
os_log(.error, log: self.log, "Restore folder feed error: %@.", error.localizedDescription)
}
}
group.notify(queue: DispatchQueue.main) {
completion(.success(()))
} catch {
os_log(.error, log: self.log, "Restore folder error: %@.", error.localizedDescription)
throw error
}
}
func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool) async throws {
try await withCheckedThrowingContinuation { continuation in
self.markArticles(for: account, articles: articles, statusKey: statusKey, flag: flag) { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
let articles = try await account.update(articles: articles, statusKey: statusKey, flag: flag)
let syncStatuses = articles.map { article in
return SyncStatus(articleID: article.articleID, key: SyncStatus.Key(statusKey), flag: flag)
}
}
try? await syncDatabase.insertStatuses(syncStatuses)
private func markArticles(for account: Account, articles: Set<Article>, statusKey: ArticleStatus.Key, flag: Bool, completion: @escaping (Result<Void, Error>) -> Void) {
Task { @MainActor in
do {
let articles = try await account.update(articles: articles, statusKey: statusKey, flag: flag)
let syncStatuses = articles.map { article in
return SyncStatus(articleID: article.articleID, key: SyncStatus.Key(statusKey), flag: flag)
}
try? await self.database.insertStatuses(syncStatuses)
if let count = try? await self.database.selectPendingCount(), count > 100 {
self.sendArticleStatus(for: account) { _ in }
}
completion(.success(()))
} catch {
completion(.failure(error))
}
if let count = try? await syncDatabase.selectPendingCount(), count > 100 {
try await sendArticleStatus(for: account)
}
}
@ -817,32 +423,16 @@ final class NewsBlurAccountDelegate: AccountDelegate {
}
func accountWillBeDeleted(_ account: Account) {
caller.logout() { _ in }
Task { @MainActor in
try await caller.logout()
}
}
static func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL?, secretsProvider: SecretsProvider) async throws -> Credentials? {
try await withCheckedThrowingContinuation { continuation in
self.validateCredentials(transport: transport, credentials: credentials, endpoint: endpoint, secretsProvider: secretsProvider) { result in
switch result {
case .success(let credentials):
continuation.resume(returning: credentials)
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
private class func validateCredentials(transport: Transport, credentials: Credentials, endpoint: URL? = nil, secretsProvider: SecretsProvider, completion: @escaping (Result<Credentials?, Error>) -> ()) {
let caller = NewsBlurAPICaller(transport: transport)
caller.credentials = credentials
caller.validateCredentials() { result in
DispatchQueue.main.async {
completion(result)
}
}
return try await caller.validateCredentials()
}
// MARK: Suspend and Resume (for iOS)
@ -856,7 +446,7 @@ final class NewsBlurAccountDelegate: AccountDelegate {
func suspendDatabase() {
Task {
await database.suspend()
await syncDatabase.suspend()
}
}
@ -865,7 +455,7 @@ final class NewsBlurAccountDelegate: AccountDelegate {
Task {
caller.resume()
await database.resume()
await syncDatabase.resume()
}
}
}

View File

@ -10,7 +10,7 @@ import Foundation
public struct NewsBlurStoryStatusChange: Sendable {
public let hashes: [String]
public let hashes: Set<String>
}
extension NewsBlurStoryStatusChange: NewsBlurDataConvertible {

View File

@ -78,7 +78,7 @@ import Secrets
return (payload?.feeds, payload?.folders)
}
func retrieveStoryHashes(endpoint: String) async throws -> [NewsBlurStoryHash]? {
func retrieveStoryHashes(endpoint: String) async throws -> Set<NewsBlurStoryHash>? {
let url: URL! = baseURL
.appendingPathComponent(endpoint)
@ -88,16 +88,19 @@ import Secrets
let (_, payload) = try await requestData(callURL: url, resultType: NewsBlurStoryHashesResponse.self, dateDecoding: .secondsSince1970)
let hashes = payload?.unread ?? payload?.starred
return hashes
if let hashes = payload?.unread ?? payload?.starred {
return Set(hashes)
} else {
return nil
}
}
public func retrieveUnreadStoryHashes() async throws -> [NewsBlurStoryHash]? {
public func retrieveUnreadStoryHashes() async throws -> Set<NewsBlurStoryHash>? {
return try await retrieveStoryHashes(endpoint: "reader/unread_story_hashes")
}
public func retrieveStarredStoryHashes() async throws -> [NewsBlurStoryHash]? {
public func retrieveStarredStoryHashes() async throws -> Set<NewsBlurStoryHash>? {
return try await retrieveStoryHashes(endpoint: "reader/starred_story_hashes")
}
@ -131,22 +134,22 @@ import Secrets
return (payload?.stories, HTTPDateInfo(urlResponse: response)?.date)
}
public func markAsUnread(hashes: [String]) async throws {
public func markAsUnread(hashes: Set<String>) async throws {
try await sendUpdates(endpoint: "reader/mark_story_hash_as_unread", payload: NewsBlurStoryStatusChange(hashes: hashes))
}
public func markAsRead(hashes: [String]) async throws {
public func markAsRead(hashes: Set<String>) async throws {
try await sendUpdates(endpoint: "reader/mark_story_hashes_as_read", payload: NewsBlurStoryStatusChange(hashes: hashes))
}
public func star(hashes: [String]) async throws {
public func star(hashes: Set<String>) async throws {
try await sendUpdates(endpoint: "reader/mark_story_hash_as_starred", payload: NewsBlurStoryStatusChange(hashes: hashes))
}
public func unstar(hashes: [String]) async throws {
public func unstar(hashes: Set<String>) async throws {
try await sendUpdates(endpoint: "reader/mark_story_hash_as_unstarred", payload: NewsBlurStoryStatusChange(hashes: hashes))
}